@modesto_ma/dsh-channels 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -55
- package/docs/access-control-design.md +5 -5
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +26 -20
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +1715 -882
- package/lib/client.js.map +1 -1
- package/lib/index.js +1556 -680
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +106 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +44 -12
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +32 -14
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +259 -65
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +223 -47
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +171 -25
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
- package/src/client/panel.tsx +0 -1475
package/src/cards/streaming.ts
CHANGED
|
@@ -1,16 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Streaming reply card: reasoning panel,
|
|
3
|
-
*
|
|
2
|
+
* Streaming reply card: the reasoning fold panel, the transient activity
|
|
3
|
+
* lines, the reply body and the settled footer — plus the byte/table budgets
|
|
4
|
+
* that keep one card inside Feishu's limits.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* panel
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Composition, main card:
|
|
7
|
+
*
|
|
8
|
+
* [fold panel] [activity status] [activity tool] [body]
|
|
9
|
+
* [hr] [model · session · reasoning time]
|
|
10
|
+
* [context share · input · output · cache hit]
|
|
11
|
+
*
|
|
12
|
+
* - The panel shows the reasoning behind its own window. While the turn is
|
|
13
|
+
* still thinking it doubles as the phase indicator; once the turn settles
|
|
14
|
+
* without any reasoning content (non-reasoning models) it is omitted
|
|
15
|
+
* entirely instead of leaving an empty bordered box.
|
|
16
|
+
* - The two activity lines are transient: the streamer only passes them for
|
|
17
|
+
* the card that is currently streaming, so frozen segment cards never repeat
|
|
18
|
+
* a stale "正在调用工具" line.
|
|
19
|
+
* - The settled footer is a divider plus two notation lines and renders
|
|
20
|
+
* exactly once: on the main card, only after the turn settles. It carries
|
|
21
|
+
* the model/effort, session id and the turn's accumulated reasoning time
|
|
22
|
+
* (line 1), then the context pressure with its window share plus the
|
|
23
|
+
* input/output token counts and cache hit ratio (line 2). First frames
|
|
24
|
+
* therefore never show a row of zeroed counters.
|
|
25
|
+
*
|
|
26
|
+
* Continuation cards (`options.segment`) carry a segment header and the body;
|
|
27
|
+
* they have no panel and no footer, so a long answer is not repeated chrome.
|
|
28
|
+
*
|
|
29
|
+
* Budgets, two independent ones:
|
|
30
|
+
* - Each card is measured as a whole: segmentation targets `CARD_TARGET_BYTES`
|
|
31
|
+
* (main cards probe the panel at its maximum window, so reasoning that keeps
|
|
32
|
+
* growing after the split cannot push the card over), while `CARD_MAX_BYTES`
|
|
33
|
+
* is the platform hard cap used only by `buildCard` as an absolute guard.
|
|
34
|
+
* - The reasoning panel is a fixed `REASONING_MAX_BYTES` window, clipped from
|
|
35
|
+
* the tail with a truncation marker. It neither competes with nor is squeezed
|
|
36
|
+
* by the reply body: the visible reasoning window is identical whether the
|
|
37
|
+
* answer is one line or a full segment.
|
|
10
38
|
*
|
|
11
39
|
* @module dsh-channels/cards/streaming
|
|
12
40
|
*/
|
|
13
41
|
|
|
42
|
+
import { card, divider, foldPanel, markdown, note } from './primitives.ts'
|
|
43
|
+
|
|
14
44
|
export type PanelPhase = 'thinking' | 'replying' | 'done' | 'error'
|
|
15
45
|
|
|
16
46
|
export const PANEL_TITLES: Record<PanelPhase, string> = {
|
|
@@ -25,27 +55,17 @@ export const PANEL_CHEVRON_TOKEN = 'down-small-ccm_outlined'
|
|
|
25
55
|
|
|
26
56
|
/** Feishu hard cap for one card message body. */
|
|
27
57
|
export const CARD_MAX_BYTES = 30_000
|
|
28
|
-
/** Target budget per card — 2 KB below the hard cap as headroom
|
|
58
|
+
/** Target budget per card — 2 KB below the hard cap as headroom for the
|
|
59
|
+
* figures that keep growing between a segment split and the final patch. */
|
|
29
60
|
export const CARD_TARGET_BYTES = 28_000
|
|
30
61
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* ASCII chars, ≈1.65 K CJK chars, ≈2.5 K emoji. The panel element's own
|
|
37
|
-
* wrapper (border, header, chevron) is charged separately — see
|
|
38
|
-
* `CARD_PANEL_RESERVE_BYTES`.
|
|
62
|
+
* Cap for the reasoning fold panel's markdown (5 KB, JSON-escaped UTF-8,
|
|
63
|
+
* including the truncation marker). The panel is clipped against this alone —
|
|
64
|
+
* never against the card — so the visible reasoning window is the same whether
|
|
65
|
+
* the reply is one line or a full segment. Visible window at the cap: ≈4.96 K
|
|
66
|
+
* ASCII chars, ≈1.65 K CJK chars, ≈1.24 K emoji.
|
|
39
67
|
*/
|
|
40
68
|
export const REASONING_MAX_BYTES = 5_000
|
|
41
|
-
/**
|
|
42
|
-
* Bytes held back from the card target for the fold panel as a whole, so a
|
|
43
|
-
* segmented card's body plus its panel stay inside `CARD_TARGET_BYTES`.
|
|
44
|
-
* `REASONING_MAX_BYTES` caps the panel's markdown; the element wrapper around
|
|
45
|
-
* it (border, header, chevron icon, `elements` array) measures a further 315 B
|
|
46
|
-
* at the worst phase, and this reserve covers that with headroom.
|
|
47
|
-
*/
|
|
48
|
-
export const CARD_PANEL_RESERVE_BYTES = 5_500
|
|
49
69
|
/**
|
|
50
70
|
* Feishu hard-limits the number of markdown tables a single card may contain
|
|
51
71
|
* (API error 11310 "card table number over limit"; verified on device: the
|
|
@@ -56,7 +76,7 @@ export const MAX_TABLES_PER_CARD = 5
|
|
|
56
76
|
/** Appended to clipped reasoning (and, as an absolute last resort, clipped body). */
|
|
57
77
|
export const TRUNCATION_SUFFIX = '\n\n…(内容过长,已截断)'
|
|
58
78
|
|
|
59
|
-
/** Session-scoped figures rendered on the
|
|
79
|
+
/** Session-scoped figures rendered on the settled card's footer. */
|
|
60
80
|
export interface CardStats {
|
|
61
81
|
/** DSH session id. */
|
|
62
82
|
sessionId: string
|
|
@@ -74,6 +94,13 @@ export interface CardStats {
|
|
|
74
94
|
model?: string
|
|
75
95
|
/** Reasoning effort (current turn's latest request header). */
|
|
76
96
|
effort?: string
|
|
97
|
+
/**
|
|
98
|
+
* Accumulated wall-clock time the turn spent in reasoning phases, in ms.
|
|
99
|
+
* Each step's phase opens at its first reasoning delta and closes when that
|
|
100
|
+
* step's output ends (first answer text or step end), so tool execution time
|
|
101
|
+
* is never counted. Undefined when the turn produced no reasoning → `—`.
|
|
102
|
+
*/
|
|
103
|
+
reasoningMs?: number
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
export interface CardState {
|
|
@@ -85,25 +112,27 @@ export interface CardState {
|
|
|
85
112
|
/**
|
|
86
113
|
* Transient delegation status (subagent/workflow progress, settled workflow
|
|
87
114
|
* outcome, heartbeat elapsed) rendered as a notation line between the panel
|
|
88
|
-
* and the body
|
|
89
|
-
* omitted. Callers cap its length so it stays
|
|
115
|
+
* and the body. The streamer only passes it for the card that is currently
|
|
116
|
+
* streaming. Empty/undefined → omitted. Callers cap its length so it stays
|
|
117
|
+
* inside the budget headroom.
|
|
90
118
|
*/
|
|
91
119
|
status?: string
|
|
92
120
|
/**
|
|
93
121
|
* The parent agent's in-flight tool call, rendered as a notation line below
|
|
94
|
-
* the status line
|
|
95
|
-
*
|
|
122
|
+
* the status line. Independent of `status`: neither ever supersedes the
|
|
123
|
+
* other. Same rule as `status` — only the actively streaming card renders it.
|
|
124
|
+
* Empty/undefined → omitted.
|
|
96
125
|
*/
|
|
97
126
|
tool?: string
|
|
98
127
|
stats: CardStats
|
|
99
128
|
}
|
|
100
129
|
|
|
101
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Rendering variant. Omitted → the main card (reasoning panel + settled
|
|
132
|
+
* footer); a 1-based `segment` → a continuation card (segment header only).
|
|
133
|
+
*/
|
|
102
134
|
export interface CardOptions {
|
|
103
|
-
/**
|
|
104
|
-
* cards pass `false` and get a segment header instead. */
|
|
105
|
-
panel?: boolean
|
|
106
|
-
/** 1-based segment number shown in the continuation header. */
|
|
135
|
+
/** 1-based continuation segment number. */
|
|
107
136
|
segment?: number
|
|
108
137
|
}
|
|
109
138
|
|
|
@@ -116,11 +145,20 @@ export interface BodySplit {
|
|
|
116
145
|
tail: string
|
|
117
146
|
}
|
|
118
147
|
|
|
148
|
+
const encoder = new TextEncoder()
|
|
149
|
+
|
|
119
150
|
/** UTF-8 byte size of a card's JSON — the metric behind Feishu's 30 KB cap. */
|
|
120
|
-
export function cardJsonBytes(
|
|
121
|
-
return
|
|
151
|
+
export function cardJsonBytes(value: object): number {
|
|
152
|
+
return encoder.encode(JSON.stringify(value)).length
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** JSON-escaped UTF-8 size of a string, as embedded in a card's JSON. */
|
|
156
|
+
function jsonBytes(text: string): number {
|
|
157
|
+
return encoder.encode(JSON.stringify(text)).length
|
|
122
158
|
}
|
|
123
159
|
|
|
160
|
+
// ---- body primitives ------------------------------------------------------
|
|
161
|
+
|
|
124
162
|
/** A contiguous run of markdown table lines; `end` is exclusive. */
|
|
125
163
|
export interface TableBlock {
|
|
126
164
|
start: number
|
|
@@ -159,6 +197,41 @@ export function tableBlocks(body: string): TableBlock[] {
|
|
|
159
197
|
return blocks
|
|
160
198
|
}
|
|
161
199
|
|
|
200
|
+
/** Fenced code block ranges (opening fence line through the closing line). An
|
|
201
|
+
* unclosed fence extends to the end of the body. */
|
|
202
|
+
function codeFenceBlocks(body: string): TableBlock[] {
|
|
203
|
+
const blocks: TableBlock[] = []
|
|
204
|
+
let start = -1
|
|
205
|
+
let offset = 0
|
|
206
|
+
for (const line of body.split('\n')) {
|
|
207
|
+
if (/^[ \t]*```/.test(line)) {
|
|
208
|
+
if (start < 0) {
|
|
209
|
+
start = offset
|
|
210
|
+
} else {
|
|
211
|
+
blocks.push({ start, end: offset + line.length })
|
|
212
|
+
start = -1
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
offset += line.length + 1
|
|
216
|
+
}
|
|
217
|
+
if (start >= 0) blocks.push({ start, end: body.length })
|
|
218
|
+
return blocks
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Move a cut position out of any protected block (code fence or table) it
|
|
223
|
+
* lands inside: the block is kept whole on the following card. A block that
|
|
224
|
+
* starts at position 0 cannot be moved back (that would produce an empty
|
|
225
|
+
* head), so that degenerate case is left to the hard-cut escape hatch.
|
|
226
|
+
*/
|
|
227
|
+
function avoidProtected(blocks: TableBlock[], at: number): number {
|
|
228
|
+
for (const block of blocks) {
|
|
229
|
+
if (block.start >= at) break
|
|
230
|
+
if (at < block.end) return block.start > 0 ? block.start : at
|
|
231
|
+
}
|
|
232
|
+
return at
|
|
233
|
+
}
|
|
234
|
+
|
|
162
235
|
/**
|
|
163
236
|
* Replace markdown table blocks with a placeholder. Used for the reasoning
|
|
164
237
|
* panel, whose content is auxiliary (truncation has no impact) and is never
|
|
@@ -179,30 +252,58 @@ export function stripTables(text: string): string {
|
|
|
179
252
|
return out
|
|
180
253
|
}
|
|
181
254
|
|
|
255
|
+
// ---- card content ---------------------------------------------------------
|
|
256
|
+
|
|
182
257
|
function fmt(n: number): string {
|
|
183
258
|
return n.toLocaleString('en-US')
|
|
184
259
|
}
|
|
185
260
|
|
|
261
|
+
/** Context-window share: one decimal below 10%, a whole number from 10% up. */
|
|
262
|
+
function fmtContextPct(tokens: number, window: number): string {
|
|
263
|
+
const pct = (tokens / window) * 100
|
|
264
|
+
// Round first, then decide the precision, so 9.96% renders as `10%` (not
|
|
265
|
+
// `10.0%`) and agrees with the integer branch.
|
|
266
|
+
return `${Number(pct.toFixed(1)) >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** `上下文 3,618/1,000,000 (0.4%)`; without a usable window the share is omitted. */
|
|
186
270
|
function fmtContext(tokens?: number, window?: number): string {
|
|
187
271
|
if (tokens === undefined) return '—'
|
|
188
|
-
if (window === undefined) return `${fmt(tokens)} tokens`
|
|
189
|
-
return `${fmt(tokens)}
|
|
272
|
+
if (window === undefined || window <= 0) return `${fmt(tokens)} tokens`
|
|
273
|
+
return `${fmt(tokens)}/${fmt(window)} (${fmtContextPct(tokens, window)})`
|
|
190
274
|
}
|
|
191
275
|
|
|
192
276
|
function fmtCacheHit(pct?: number): string {
|
|
193
277
|
return pct === undefined ? '—' : `${(pct * 100).toFixed(1)}%`
|
|
194
278
|
}
|
|
195
279
|
|
|
196
|
-
/**
|
|
280
|
+
/** `推理 3.2s` / `推理 1分5s`; `—` when the turn had no reasoning phase. */
|
|
281
|
+
function fmtReasoningTime(ms?: number): string {
|
|
282
|
+
if (ms === undefined) return '—'
|
|
283
|
+
const safe = Math.max(0, ms)
|
|
284
|
+
if (safe < 60_000) return `${(safe / 1000).toFixed(1)}s`
|
|
285
|
+
const total = Math.round(safe / 1000)
|
|
286
|
+
return `${Math.floor(total / 60)}分${total % 60}s`
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Settled footer, line 1: 🧠 model/effort · 🔑 session id · ⏱ reasoning time.
|
|
291
|
+
* Rendered once, after the turn settles, so live cards never flash a row of
|
|
292
|
+
* zeroed counters.
|
|
293
|
+
*/
|
|
197
294
|
function buildModelLine(stats: CardStats): string {
|
|
198
295
|
const model = stats.model ?? '—'
|
|
199
296
|
const effort = stats.effort !== undefined && stats.effort !== '' ? `(${stats.effort})` : ''
|
|
200
|
-
return `🧠 模型 ${model}${effort} ·
|
|
297
|
+
return `🧠 模型 ${model}${effort} · 🔑 会话 ${stats.sessionId} · ⏱ 推理 ${fmtReasoningTime(stats.reasoningMs)}`
|
|
201
298
|
}
|
|
202
299
|
|
|
203
|
-
/**
|
|
204
|
-
|
|
205
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Settled footer, line 2: 📊 context pressure (counts + share) · 📩 input
|
|
302
|
+
* tokens · 📨 output tokens · ⚡ cache hit ratio.
|
|
303
|
+
*/
|
|
304
|
+
function buildUsageLine(stats: CardStats): string {
|
|
305
|
+
return `📊 上下文 ${fmtContext(stats.contextTokens, stats.contextWindow)}`
|
|
306
|
+
+ ` · 📩 输入 ${fmt(stats.inputTokens)} · 📨 输出 ${fmt(stats.outputTokens)} · ⚡ 缓存命中 ${fmtCacheHit(stats.cacheHitPct)}`
|
|
206
307
|
}
|
|
207
308
|
|
|
208
309
|
function reasoningPlaceholder(phase: PanelPhase): string {
|
|
@@ -213,142 +314,187 @@ function bodyPlaceholder(phase: PanelPhase): string {
|
|
|
213
314
|
return phase === 'done' || phase === 'error' ? '*(本次无正文输出)*' : '*正在生成…*'
|
|
214
315
|
}
|
|
215
316
|
|
|
216
|
-
/**
|
|
217
|
-
function
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
return `📄 ${title} · ${PANEL_TITLES[phase]}`
|
|
317
|
+
/** The panel's markdown: stripped of tables, with a placeholder when empty. */
|
|
318
|
+
function panelContent(phase: PanelPhase, reasoning: string): string {
|
|
319
|
+
const stripped = stripTables(reasoning)
|
|
320
|
+
return stripped.trim() === '' ? reasoningPlaceholder(phase) : stripped
|
|
221
321
|
}
|
|
222
322
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
|
|
323
|
+
/**
|
|
324
|
+
* Whether the fold panel renders. `thinking` always does (it carries the
|
|
325
|
+
* phase indicator while the card is otherwise empty); every later phase only
|
|
326
|
+
* when there is actual reasoning to fold away — a settled non-reasoning turn
|
|
327
|
+
* gets no empty bordered box.
|
|
328
|
+
*/
|
|
329
|
+
function panelShown(phase: PanelPhase, reasoning: string): boolean {
|
|
330
|
+
return phase === 'thinking' || stripTables(reasoning).trim() !== ''
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Continuation header: `📄 回复分段 {n}`. */
|
|
334
|
+
function segmentHeader(segment: number): string {
|
|
335
|
+
return `📄 回复分段 ${segment}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Interruption notice header for a repaired continuation card. */
|
|
339
|
+
function interruptionHeader(segment: number): string {
|
|
340
|
+
return `📄 回复分段 ${segment} · 回复中断`
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isSettled(phase: PanelPhase): boolean {
|
|
344
|
+
return phase === 'done' || phase === 'error'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Defensive cap on caller-supplied activity lines. The streamer already clips
|
|
349
|
+
* them to 64 chars; this bound keeps `buildCard`'s "always ≤ CARD_MAX_BYTES"
|
|
350
|
+
* guarantee even for a direct caller that passes an unbounded line.
|
|
351
|
+
*/
|
|
352
|
+
const ACTIVITY_LINE_MAX_CHARS = 512
|
|
353
|
+
|
|
354
|
+
/** One caller-supplied notation line, defensively capped; undefined when empty. */
|
|
355
|
+
function activityNote(text: string | undefined): object | undefined {
|
|
356
|
+
if (text === undefined || text === '') return undefined
|
|
357
|
+
const clipped = text.length <= ACTIVITY_LINE_MAX_CHARS
|
|
358
|
+
? text
|
|
359
|
+
: `${text.slice(0, ACTIVITY_LINE_MAX_CHARS - 1)}…`
|
|
360
|
+
return note(clipped)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---- rendering ------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/** Render one card. `reasoning`/`body` are the already-budgeted texts. */
|
|
366
|
+
function renderCard(
|
|
367
|
+
state: CardState,
|
|
368
|
+
options: CardOptions,
|
|
226
369
|
reasoning: string,
|
|
227
370
|
body: string,
|
|
228
|
-
stats: CardStats,
|
|
229
|
-
options: CardOptions,
|
|
230
|
-
status?: string,
|
|
231
|
-
tool?: string,
|
|
232
371
|
): object {
|
|
372
|
+
const segment = options.segment
|
|
373
|
+
const main = segment === undefined
|
|
233
374
|
const elements: object[] = []
|
|
234
375
|
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
icon: { tag: 'standard_icon', token: PANEL_CHEVRON_TOKEN },
|
|
244
|
-
icon_position: 'right',
|
|
245
|
-
icon_expanded_angle: 180,
|
|
246
|
-
},
|
|
247
|
-
elements: [
|
|
248
|
-
{ tag: 'markdown', content: panelReasoning.trim() === '' ? reasoningPlaceholder(phase) : panelReasoning },
|
|
249
|
-
],
|
|
250
|
-
})
|
|
376
|
+
if (main) {
|
|
377
|
+
if (panelShown(state.phase, reasoning)) {
|
|
378
|
+
elements.push(foldPanel(
|
|
379
|
+
PANEL_TITLES[state.phase],
|
|
380
|
+
panelContent(state.phase, reasoning),
|
|
381
|
+
PANEL_CHEVRON_TOKEN,
|
|
382
|
+
))
|
|
383
|
+
}
|
|
251
384
|
} else {
|
|
252
|
-
elements.push(
|
|
385
|
+
elements.push(note(segmentHeader(segment)))
|
|
253
386
|
}
|
|
254
387
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (
|
|
259
|
-
elements.push({ tag: 'markdown', content: tool, text_size: 'notation' })
|
|
260
|
-
}
|
|
388
|
+
const statusNote = activityNote(state.status)
|
|
389
|
+
if (statusNote !== undefined) elements.push(statusNote)
|
|
390
|
+
const toolNote = activityNote(state.tool)
|
|
391
|
+
if (toolNote !== undefined) elements.push(toolNote)
|
|
261
392
|
|
|
262
|
-
elements.push(
|
|
263
|
-
elements.push({ tag: 'markdown', content: body.trim() === '' ? bodyPlaceholder(phase) : body })
|
|
264
|
-
elements.push({ tag: 'hr' })
|
|
265
|
-
elements.push({ tag: 'markdown', content: buildSessionLine(stats), text_size: 'notation' })
|
|
266
|
-
elements.push({ tag: 'markdown', content: buildModelLine(stats), text_size: 'notation' })
|
|
393
|
+
elements.push(markdown(body.trim() === '' ? bodyPlaceholder(state.phase) : body))
|
|
267
394
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
395
|
+
if (main && isSettled(state.phase)) {
|
|
396
|
+
// The divider separates the answer from the turn's metadata; the two
|
|
397
|
+
// notation lines below it are the only place the settled figures appear.
|
|
398
|
+
elements.push(divider())
|
|
399
|
+
elements.push(note(buildModelLine(state.stats)))
|
|
400
|
+
elements.push(note(buildUsageLine(state.stats)))
|
|
272
401
|
}
|
|
402
|
+
|
|
403
|
+
return card(elements)
|
|
273
404
|
}
|
|
274
405
|
|
|
406
|
+
// ---- budgeting ------------------------------------------------------------
|
|
407
|
+
|
|
275
408
|
/**
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
409
|
+
* Longest prefix of `text` that fits `budget` under `measure`.
|
|
410
|
+
*
|
|
411
|
+
* Returns `text` unchanged when it already fits; otherwise the longest prefix
|
|
412
|
+
* with `suffix` appended still fits, so a clipped result is guaranteed to stay
|
|
413
|
+
* within the same budget.
|
|
280
414
|
*/
|
|
281
|
-
function
|
|
282
|
-
|
|
415
|
+
function fitPrefix(
|
|
416
|
+
text: string,
|
|
417
|
+
budget: number,
|
|
418
|
+
measure: (candidate: string) => number,
|
|
419
|
+
suffix = '',
|
|
420
|
+
): string {
|
|
421
|
+
if (measure(text) <= budget) return text
|
|
283
422
|
let lo = 0
|
|
284
423
|
let hi = text.length
|
|
285
424
|
while (lo < hi) {
|
|
286
425
|
const mid = Math.ceil((lo + hi) / 2)
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
} else {
|
|
290
|
-
hi = mid - 1
|
|
291
|
-
}
|
|
426
|
+
if (measure(text.slice(0, mid) + suffix) <= budget) lo = mid
|
|
427
|
+
else hi = mid - 1
|
|
292
428
|
}
|
|
293
429
|
return text.slice(0, lo) + suffix
|
|
294
430
|
}
|
|
295
431
|
|
|
296
|
-
/**
|
|
297
|
-
function
|
|
298
|
-
return
|
|
432
|
+
/** Reasoning as rendered: clipped against the panel's own fixed window. */
|
|
433
|
+
function budgetReasoning(state: CardState): string {
|
|
434
|
+
return fitPrefix(
|
|
435
|
+
state.reasoning,
|
|
436
|
+
REASONING_MAX_BYTES,
|
|
437
|
+
content => jsonBytes(panelContent(state.phase, content)),
|
|
438
|
+
TRUNCATION_SUFFIX,
|
|
439
|
+
)
|
|
299
440
|
}
|
|
300
441
|
|
|
301
442
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
443
|
+
* Build one card.
|
|
444
|
+
*
|
|
445
|
+
* The reasoning window is clipped against `REASONING_MAX_BYTES` alone, and the
|
|
446
|
+
* body is then clipped against `CARD_MAX_BYTES` measured on the card that is
|
|
447
|
+
* actually rendered — the guard exists only for direct callers and for the
|
|
448
|
+
* turn-failure notice appended to a settled body; the streamer pre-splits with
|
|
449
|
+
* `segmentCardContent`.
|
|
450
|
+
*
|
|
451
|
+
* Guarantee: the returned card is ≤ `CARD_MAX_BYTES`, and its panel markdown
|
|
452
|
+
* is ≤ `REASONING_MAX_BYTES`.
|
|
307
453
|
*/
|
|
308
|
-
function
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
454
|
+
export function buildCard(state: CardState, options: CardOptions = {}): object {
|
|
455
|
+
const reasoning = options.segment === undefined ? budgetReasoning(state) : state.reasoning
|
|
456
|
+
const body = fitPrefix(
|
|
457
|
+
state.body,
|
|
458
|
+
CARD_MAX_BYTES,
|
|
459
|
+
content => cardJsonBytes(renderCard(state, options, reasoning, content)),
|
|
460
|
+
TRUNCATION_SUFFIX,
|
|
461
|
+
)
|
|
462
|
+
return renderCard(state, options, reasoning, body)
|
|
314
463
|
}
|
|
315
464
|
|
|
316
|
-
/**
|
|
317
|
-
*
|
|
318
|
-
|
|
465
|
+
/**
|
|
466
|
+
* Worst-case panel markdown: exactly `REASONING_MAX_BYTES` JSON bytes of
|
|
467
|
+
* ASCII text (the surrounding quotes cost 2 B).
|
|
468
|
+
*/
|
|
469
|
+
const PANEL_PROBE_MARKDOWN = 'r'.repeat(REASONING_MAX_BYTES - 2)
|
|
319
470
|
|
|
320
471
|
/**
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* - Reasoning is clipped so its markdown stays within `REASONING_MAX_BYTES`
|
|
324
|
-
* (including the truncation marker): a fixed, self-contained budget that
|
|
325
|
-
* neither grants space to nor borrows space from the body, so the visible
|
|
326
|
-
* reasoning window is the same whether the reply is one line or a 28 KB
|
|
327
|
-
* segment.
|
|
328
|
-
* - The body gets whatever is left of `CARD_MAX_BYTES` once the clipped
|
|
329
|
-
* reasoning is accounted for, which keeps the total under Feishu's hard cap.
|
|
330
|
-
* The streamer pre-splits with `segmentCardContent` (probed at the
|
|
331
|
-
* worst-case phase), so a live stream never reaches this clip: it is a pure
|
|
332
|
-
* guard for direct callers.
|
|
472
|
+
* Split a body so one card fits: `head` re-renders within `CARD_TARGET_BYTES`
|
|
473
|
+
* and `tail` flows into the next segment card ('' when everything fits).
|
|
333
474
|
*
|
|
334
|
-
*
|
|
335
|
-
* always
|
|
475
|
+
* The probe renders the worst card this segment can ever become: the main card
|
|
476
|
+
* always budgets the panel's full window (reasoning may keep growing after the
|
|
477
|
+
* split) and its settled footer, while continuation cards have neither. The
|
|
478
|
+
* `error` phase is probed because it has the longest panel title.
|
|
336
479
|
*/
|
|
337
|
-
export function
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
480
|
+
export function segmentCardContent(state: CardState, options: CardOptions = {}): BodySplit {
|
|
481
|
+
const main = options.segment === undefined
|
|
482
|
+
const probe: CardState = {
|
|
483
|
+
...state,
|
|
484
|
+
phase: 'error',
|
|
485
|
+
reasoning: main ? PANEL_PROBE_MARKDOWN : '',
|
|
486
|
+
}
|
|
487
|
+
const limit = fitPrefix(
|
|
488
|
+
state.body,
|
|
489
|
+
CARD_TARGET_BYTES,
|
|
490
|
+
content => cardJsonBytes(renderCard(probe, options, probe.reasoning, content)),
|
|
491
|
+
).length
|
|
492
|
+
return splitBodyForCard(state.body, limit)
|
|
493
|
+
}
|
|
346
494
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
CARD_MAX_BYTES - panelBytes(reasoning) - SUFFIX_JSON_BYTES)
|
|
351
|
-
return render(reasoning, body)
|
|
495
|
+
/** Card-variant options for one segment (`0` is the main card). */
|
|
496
|
+
export function segmentCardOptions(index: number): CardOptions {
|
|
497
|
+
return index === 0 ? {} : { segment: index + 1 }
|
|
352
498
|
}
|
|
353
499
|
|
|
354
500
|
/** Interruption notice body for cards repaired after a restart mid-turn. */
|
|
@@ -365,60 +511,19 @@ export const INTERRUPTION_NOTICE = '⚠️ 服务已重启,本次回复中断
|
|
|
365
511
|
*/
|
|
366
512
|
export function buildInterruptionCard(options: CardOptions = {}): object {
|
|
367
513
|
const elements: object[] = []
|
|
368
|
-
if (options.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
content: `📄 ${options.segment === undefined ? '续上一条卡片' : `回复分段 ${options.segment}`} · 回复中断`,
|
|
372
|
-
text_size: 'notation',
|
|
373
|
-
})
|
|
374
|
-
}
|
|
375
|
-
elements.push({ tag: 'markdown', content: INTERRUPTION_NOTICE })
|
|
376
|
-
return {
|
|
377
|
-
schema: '2.0',
|
|
378
|
-
config: { width_mode: 'fill' },
|
|
379
|
-
body: { elements },
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Largest body prefix (char count) whose card renders within `CARD_TARGET_BYTES`
|
|
385
|
-
* minus `CARD_PANEL_RESERVE_BYTES`, reserving the panel's whole footprint for
|
|
386
|
-
* the reasoning that travels with the segment. `segmentCardContent` only ever
|
|
387
|
-
* segments bodies, so reasoning is never a segment budget input by construction
|
|
388
|
-
* — but the panel still occupies the card, so its cap is held back. The probe renders
|
|
389
|
-
* at phase `error`, the worst case (its segment header title is the longest),
|
|
390
|
-
* so a frozen segment can never outgrow the budget merely by the phase flipping
|
|
391
|
-
* 回复中 → 回复失败; stats growth between the split and the final patch consumes
|
|
392
|
-
* the 2 KB redundancy (`CARD_MAX_BYTES` − `CARD_TARGET_BYTES`).
|
|
393
|
-
*/
|
|
394
|
-
function largestBodyPrefix(state: CardState, options: CardOptions): number {
|
|
395
|
-
const text = state.body
|
|
396
|
-
const budget = CARD_TARGET_BYTES - CARD_PANEL_RESERVE_BYTES
|
|
397
|
-
// `panel: false` keeps the reserved panel cap out of the measurement itself.
|
|
398
|
-
const render = (content: string) =>
|
|
399
|
-
assemble('error', '', content, state.stats, { ...options, panel: false }, state.status, state.tool)
|
|
400
|
-
if (cardJsonBytes(render(text)) <= budget) return text.length
|
|
401
|
-
let lo = 0
|
|
402
|
-
let hi = text.length
|
|
403
|
-
while (lo < hi) {
|
|
404
|
-
const mid = Math.ceil((lo + hi) / 2)
|
|
405
|
-
if (cardJsonBytes(render(text.slice(0, mid))) <= budget) {
|
|
406
|
-
lo = mid
|
|
407
|
-
} else {
|
|
408
|
-
hi = mid - 1
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
return lo
|
|
514
|
+
if (options.segment !== undefined) elements.push(note(interruptionHeader(options.segment)))
|
|
515
|
+
elements.push(markdown(INTERRUPTION_NOTICE))
|
|
516
|
+
return card(elements)
|
|
412
517
|
}
|
|
413
518
|
|
|
414
519
|
/**
|
|
415
520
|
* Split a body at a clean breakpoint so `head` fits one card:
|
|
416
521
|
* 1. the head holds at most `maxTables` whole markdown tables — the boundary
|
|
417
522
|
* is capped at the first excess table (Feishu rejects cards with more);
|
|
418
|
-
* 2. never inside a fenced code block
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
523
|
+
* 2. never inside a fenced code block or a table block — the boundary is
|
|
524
|
+
* pulled back to the block's start (a block beginning the body is left to
|
|
525
|
+
* the hard-cut escape hatch);
|
|
526
|
+
* 3. then the clean-breakpoint tiers: paragraph blank line → `#` heading line
|
|
422
527
|
* → line break → hard cut as the escape hatch.
|
|
423
528
|
*/
|
|
424
529
|
export function splitBodyForCard(body: string, maxChars: number, maxTables: number = MAX_TABLES_PER_CARD): BodySplit {
|
|
@@ -427,89 +532,38 @@ export function splitBodyForCard(body: string, maxChars: number, maxTables: numb
|
|
|
427
532
|
return { head: body, tail: '' }
|
|
428
533
|
}
|
|
429
534
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
// 1. Table-count ceiling: the head may hold at most `maxTables` tables, so
|
|
433
|
-
// never extend past the boundary right before the first excess table.
|
|
434
|
-
const firstExcess = tables[maxTables]
|
|
435
|
-
if (firstExcess !== undefined) {
|
|
436
|
-
limit = Math.min(limit, firstExcess.start)
|
|
437
|
-
}
|
|
535
|
+
// Every range a cut may never land inside: fenced code blocks and tables.
|
|
536
|
+
const blocks = [...codeFenceBlocks(body), ...tables].sort((a, b) => a.start - b.start)
|
|
438
537
|
|
|
439
|
-
// 2.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
let inside = false
|
|
443
|
-
for (const match of body.matchAll(fencePattern)) {
|
|
444
|
-
const index = match.index ?? 0
|
|
445
|
-
if (!inside) {
|
|
446
|
-
if (index >= limit) break
|
|
447
|
-
inside = true
|
|
448
|
-
fenceStart = index
|
|
449
|
-
} else if (index >= limit) {
|
|
450
|
-
// The block opened before the ceiling and closes after it: move the
|
|
451
|
-
// boundary to just before the opening fence (unless it starts at 0).
|
|
452
|
-
if (fenceStart > 0) limit = fenceStart
|
|
453
|
-
break
|
|
454
|
-
} else {
|
|
455
|
-
inside = false
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
if (inside && fenceStart > 0 && fenceStart < limit) limit = fenceStart
|
|
459
|
-
|
|
460
|
-
// 3. Never cut inside a table block straddling the ceiling (pull the
|
|
461
|
-
// boundary back to its start; a table beginning the body is left to the
|
|
462
|
-
// hard-cut escape hatch).
|
|
463
|
-
for (const block of tables) {
|
|
464
|
-
if (block.start >= limit) break
|
|
465
|
-
if (block.end > limit) {
|
|
466
|
-
if (block.start > 0) limit = block.start
|
|
467
|
-
break
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/** Move a cut position out of any table block (to just before it). */
|
|
472
|
-
const avoidTables = (at: number): number => {
|
|
473
|
-
for (const block of tables) {
|
|
474
|
-
if (block.start >= at) break
|
|
475
|
-
if (at <= block.end && block.start > 0) return block.start
|
|
476
|
-
}
|
|
477
|
-
return at
|
|
478
|
-
}
|
|
538
|
+
// 1. Table-count ceiling, then 2. pull the ceiling out of any block that
|
|
539
|
+
// straddles it.
|
|
540
|
+
const limit = avoidProtected(blocks, Math.min(maxChars, tables[maxTables]?.start ?? body.length))
|
|
479
541
|
|
|
480
542
|
const window = body.slice(0, limit)
|
|
543
|
+
// Preferred breakpoints scanning back from the ceiling; only accept one that
|
|
544
|
+
// keeps the card reasonably full, else fall through to the next tier.
|
|
545
|
+
const minFill = Math.floor(limit * 0.7)
|
|
546
|
+
|
|
481
547
|
const cut = (at: number): BodySplit => ({
|
|
482
548
|
head: body.slice(0, at).replace(/\s+$/u, ''),
|
|
483
549
|
tail: body.slice(at).replace(/^\n+/u, ''),
|
|
484
550
|
})
|
|
485
551
|
|
|
486
|
-
//
|
|
487
|
-
// keeps the card reasonably full, else fall through to the next tier.
|
|
488
|
-
const minFill = Math.floor(limit * 0.7)
|
|
489
|
-
|
|
552
|
+
// 3. Clean-breakpoint tiers, each moved out of a protected block.
|
|
490
553
|
const paragraph = window.lastIndexOf('\n\n')
|
|
491
|
-
if (paragraph >= minFill) return cut(
|
|
554
|
+
if (paragraph >= minFill) return cut(avoidProtected(blocks, paragraph + 2))
|
|
492
555
|
|
|
493
556
|
let heading = -1
|
|
494
557
|
for (const match of window.matchAll(/^#{1,6} .*$/gm)) {
|
|
495
558
|
const start = match.index ?? 0
|
|
496
559
|
heading = start === 0 ? 0 : start - 1
|
|
497
560
|
}
|
|
498
|
-
if (heading >= minFill) return cut(
|
|
561
|
+
if (heading >= minFill) return cut(avoidProtected(blocks, heading))
|
|
499
562
|
|
|
500
563
|
const line = window.lastIndexOf('\n')
|
|
501
|
-
if (line >= minFill) return cut(
|
|
564
|
+
if (line >= minFill) return cut(avoidProtected(blocks, line + 1))
|
|
502
565
|
|
|
503
|
-
return
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* How the accumulated body should be laid out for one card: everything that
|
|
508
|
-
* fits (clean breakpoint) plus the remainder for the next segment card.
|
|
509
|
-
* `head` re-renders within `CARD_TARGET_BYTES`; the body is never dropped.
|
|
510
|
-
*/
|
|
511
|
-
export function segmentCardContent(state: CardState, options: CardOptions = {}): BodySplit {
|
|
512
|
-
return splitBodyForCard(state.body, largestBodyPrefix(state, options))
|
|
566
|
+
return cut(limit)
|
|
513
567
|
}
|
|
514
568
|
|
|
515
569
|
/**
|
|
@@ -526,38 +580,3 @@ export function initialStreamingCard(sessionId: string): object {
|
|
|
526
580
|
}
|
|
527
581
|
return buildCard(state)
|
|
528
582
|
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* One-time onboarding card: what the bot does, how workspaces are scoped, and
|
|
532
|
-
* the command cheat sheet. Sent on the first private message of a chat.
|
|
533
|
-
*/
|
|
534
|
-
export function buildOnboardingCard(): object {
|
|
535
|
-
return {
|
|
536
|
-
schema: '2.0',
|
|
537
|
-
config: { width_mode: 'fill' },
|
|
538
|
-
body: {
|
|
539
|
-
elements: [
|
|
540
|
-
{ tag: 'markdown', content: '**👋 欢迎使用飞书 DSH**' },
|
|
541
|
-
{
|
|
542
|
-
tag: 'markdown',
|
|
543
|
-
content: '直接发消息即可开始一个会话,我会在话题里流式回复;话题内继续发消息无需再 @ 我。',
|
|
544
|
-
},
|
|
545
|
-
{
|
|
546
|
-
tag: 'markdown',
|
|
547
|
-
content: '**工作区**:私聊一人一个;群里每位成员各自一个,互不影响。',
|
|
548
|
-
},
|
|
549
|
-
{
|
|
550
|
-
tag: 'markdown',
|
|
551
|
-
content: '**常用命令**:`/help` 命令列表 · `/status` 会话状态 · `/workspace` 工作目录\n'
|
|
552
|
-
+ '`/effort` 推理强度 · `/model` 模型 · `/permissions` 权限预设\n'
|
|
553
|
-
+ '`/new` 新建会话 · `/stop` 中断当前回合 · `/history` 历史 · `/export` 导出日志',
|
|
554
|
-
},
|
|
555
|
-
{
|
|
556
|
-
tag: 'markdown',
|
|
557
|
-
content: '需要授权或补充信息时,我会发一张卡片,点按钮或直接回复即可。',
|
|
558
|
-
text_size: 'notation',
|
|
559
|
-
},
|
|
560
|
-
],
|
|
561
|
-
},
|
|
562
|
-
}
|
|
563
|
-
}
|