@sidleo3/dsh-chat-feishu 0.0.4

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.
@@ -0,0 +1,778 @@
1
+ /**
2
+ * 把一轮任务的执行过程渲染到飞书。
3
+ *
4
+ * 三态(每个会话类型独立配置):
5
+ * - `off`:只回最终答案;
6
+ * - `post`:每一步单独回一条消息(工具调用、注入上下文等);
7
+ * - `streaming_card`:全程一张交互卡片,过程与最终答案都在这张卡里原地刷新。
8
+ *
9
+ * 呈现口径对齐 **DSH Web 会话**(真机反馈:"每一项工具跟思考要跟 dsh web 的会话一样,
10
+ * 显示为 web 会话未展开的样子"):
11
+ * - 一行一项,形如 `工具调用 · wiki_get · 永辉/组织架构/品类架构`、`思考 · …`、`提问 · …`;
12
+ * 项目分类与摘要口径直接照搬 Web 的工具行模型(见下方 `TOOL_VARIANTS` / `SUMMARY_KEYS`,
13
+ * 来源:DSH 安装目录内 `@deepseek-ai/dsh-client-ui-tool` 的 `toolRowModel`)。
14
+ * - 工具、思考、**已答的提问**全部收进**同一个**折叠面板:默认收起、展开看全部;
15
+ * 已答提问在面板里再嵌一层 `❓ N/M 已回答` 折叠面板(真机要求:提问也要能自己收起/展开;
16
+ * Card 2.0 的容器最多嵌套 5 层),**位置就是它本来出现的顺序**(不能被推到面板底部);
17
+ * - 面板标题:本轮没结束时显示**最新的一项**(一眼看到在干什么),本轮结束后显示
18
+ * `工具与思考(N)`;
19
+ * - **任务清单**(`todo_write`)单独一个面板放在工具面板**下面**:本轮没结束时默认展开
20
+ * (看得到完成进度),结束后收起;
21
+ * - 还没回答的提问控件放在面板**外面**——Card 2.0 的折叠面板里不能放 form/输入框。
22
+ *
23
+ * @module dsh-chat-feishu/turn-presenter
24
+ */
25
+
26
+ /** 折叠面板最多保留的行数(超出丢弃最旧的)。 */
27
+ const MAX_ROWS = 24;
28
+
29
+ /** 卡片正文长度上限,避免超出飞书卡片限制。 */
30
+ const MAX_CARD_CONTENT = 12_000;
31
+
32
+ /** 未能收起的行(面板标题)长度上限——标题是一行,太长会被挤掉。 */
33
+ const MAX_PANEL_TITLE = 46;
34
+
35
+ /** 思考行的长度上限。 */
36
+ const MAX_THINK_CHARS = 120;
37
+
38
+ /** 工具行摘要的长度上限。 */
39
+ const MAX_TOOL_SUMMARY = 60;
40
+
41
+ /**
42
+ * 工具名 → 行的"种类"(决定用什么标题与摘要取哪些参数)。
43
+ *
44
+ * 照搬 Web:`dsh-client-ui-tool` 的 `TOOL_VARIANTS`。没列出的工具归 `others`。
45
+ */
46
+ const TOOL_VARIANTS = Object.freeze({
47
+ bash: 'bash',
48
+ pwsh: 'bash',
49
+ read: 'read',
50
+ read_image: 'read',
51
+ web_fetch: 'read',
52
+ web_search: 'search',
53
+ grep: 'search',
54
+ glob: 'search',
55
+ write: 'write',
56
+ edit: 'edit',
57
+ run_code: 'code',
58
+ cordis_package_inspect: 'read',
59
+ cordis_runtime_inspect: 'read',
60
+ cordis_run: 'others',
61
+ cordis_stop: 'others',
62
+ cordis_undefine: 'others',
63
+ });
64
+
65
+ /** 种类 → 行标题(对齐 Web 的 `tool.title.*` 中文文案)。 */
66
+ const VARIANT_TITLES = Object.freeze({
67
+ search: '搜索',
68
+ read: '读取',
69
+ bash: 'Bash',
70
+ write: '写入',
71
+ edit: '编辑',
72
+ code: '代码',
73
+ others: '工具调用',
74
+ });
75
+
76
+ /**
77
+ * 有专属卡片的工具:Web 里由插件注册了专门的卡片,标题不是"工具调用"。
78
+ * 这里只补真正会出现在会话里、且 Web 显示为专属标题的那几个。
79
+ */
80
+ const TOOL_TITLES = Object.freeze({
81
+ skill: 'Skill',
82
+ todo_write: '更新任务清单',
83
+ ask_user_question: '提问',
84
+ present: '交付文件',
85
+ chat_send: '发送消息',
86
+ chat_send_file: '发送文件',
87
+ chat_targets: '查看投递目标',
88
+ chat_save_target: '保存投递目标',
89
+ });
90
+
91
+ /** 摘要优先取哪个参数(对齐 Web 的 `SUMMARY_KEYS`)。 */
92
+ const SUMMARY_KEYS = Object.freeze({
93
+ bash: ['description', 'command'],
94
+ read: ['path', 'file_path', 'url'],
95
+ search: ['query', 'pattern', 'url'],
96
+ write: ['path', 'file_path'],
97
+ edit: ['path', 'file_path'],
98
+ code: ['description'],
99
+ others: [],
100
+ });
101
+
102
+ function firstLine(text) {
103
+ const value = typeof text === 'string' ? text : '';
104
+ const newline = value.indexOf('\n');
105
+ return (newline === -1 ? value : value.slice(0, newline)).replace(/\s+/g, ' ').trim();
106
+ }
107
+
108
+ function parseArgs(args) {
109
+ if (args === null || args === undefined) return null;
110
+ if (typeof args === 'object') return args;
111
+ if (typeof args !== 'string') return null;
112
+ try {
113
+ const parsed = JSON.parse(args);
114
+ return typeof parsed === 'object' && parsed !== null ? parsed : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function pickString(args, keys) {
121
+ for (const key of keys) {
122
+ const value = args[key];
123
+ if (typeof value === 'string' && value !== '') return value;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /** 按 Web 的口径从参数里挑一句摘要;挑不到就退化到第一个非空字符串参数。 */
129
+ function deriveSummary(variant, args, argsRaw) {
130
+ const parsed = args ?? parseArgs(argsRaw);
131
+ if (parsed === null) return firstLine(typeof argsRaw === 'string' ? argsRaw : '');
132
+ if (variant === 'search' && Array.isArray(parsed.queries)) {
133
+ const queries = parsed.queries.filter((query) => typeof query === 'string' && query !== '');
134
+ if (queries.length > 0) return queries.map(firstLine).join(', ');
135
+ }
136
+ const picked = pickString(parsed, SUMMARY_KEYS[variant] ?? []);
137
+ if (picked !== undefined) return firstLine(picked);
138
+ for (const value of Object.values(parsed)) {
139
+ if (typeof value === 'string' && value !== '') return firstLine(value);
140
+ }
141
+ // 和 Web 一样退化到原始参数(`wiki_list` 传 `{}` 时就显示 `{}`,一眼看出没带参数)。
142
+ return firstLine(typeof argsRaw === 'string' ? argsRaw : '');
143
+ }
144
+
145
+ function clamp(text, max) {
146
+ const value = typeof text === 'string' ? text : '';
147
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
148
+ }
149
+
150
+ /**
151
+ * 把一次工具调用渲染成 Web 那样的一行。
152
+ *
153
+ * 未知工具跟 Web 一样保留工具名:`工具调用 · wiki_get · 永辉/组织架构/品类架构`,
154
+ * 已知工具用自己的标题:`Bash · Show current date and time`、`Skill · yh-bigdata`。
155
+ *
156
+ * @param options - { name, arguments }(`arguments` 可以是对象或原始 JSON 串)。
157
+ * @returns 一行文本。
158
+ */
159
+ export function toolRow({ name, arguments: argsRaw } = {}) {
160
+ const toolName = typeof name === 'string' && name ? name : '工具';
161
+ const variant = TOOL_VARIANTS[toolName] ?? 'others';
162
+ const summary = clamp(deriveSummary(variant, parseArgs(argsRaw), argsRaw), MAX_TOOL_SUMMARY);
163
+ const own = TOOL_TITLES[toolName];
164
+ if (own) {
165
+ // 有专属标题的工具(Skill / 更新任务清单…):Web 只用它的标题 + 关键参数。
166
+ return summary ? `${own} · ${summary}` : own;
167
+ }
168
+ if (variant === 'others') {
169
+ return summary ? `工具调用 · ${toolName} · ${summary}` : `工具调用 · ${toolName}`;
170
+ }
171
+ const title = VARIANT_TITLES[variant];
172
+ return summary ? `${title} · ${summary}` : title;
173
+ }
174
+
175
+ /**
176
+ * 把一段思考渲染成一行。
177
+ *
178
+ * @param text - 推理文本。
179
+ * @returns 一行文本。
180
+ */
181
+ export function thinkRow(text) {
182
+ const line = clamp(firstLine(text), MAX_THINK_CHARS);
183
+ return line ? `思考 · ${line}` : '';
184
+ }
185
+
186
+ /**
187
+ * 把 `todo_write` 的清单渲染成几行。
188
+ *
189
+ * 模型每次调用都带**全量**清单,因此外层只保留最后一次的结果(覆盖即可,不必累积)。
190
+ *
191
+ * @param args - `todo_write` 的原始参数。
192
+ * @returns { rows, done, total }:`⬜/🔄/✅ 内容` 行与完成计数(没有清单时为 null)。
193
+ */
194
+ export function todoRows(args) {
195
+ const parsed = parseArgs(args);
196
+ const todos = Array.isArray(parsed?.todos) ? parsed.todos : null;
197
+ if (!todos || todos.length === 0) return null;
198
+ const rows = [];
199
+ for (const todo of todos.slice(0, 50)) {
200
+ const content = firstLine(todo?.content);
201
+ if (!content) continue;
202
+ const status = todo?.status;
203
+ const mark = status === 'completed' ? '✅' : status === 'in_progress' ? '🔄' : '⬜';
204
+ rows.push(`${mark} ${clamp(content, 60)}`);
205
+ }
206
+ if (rows.length === 0) return null;
207
+ const done = todos.filter((todo) => todo?.status === 'completed').length;
208
+ return { rows, done, total: todos.length };
209
+ }
210
+
211
+ /**
212
+ * 把一条已回答的提问渲染成一行。
213
+ *
214
+ * @param options - { header, question, answer }。
215
+ * @returns 一行文本。
216
+ */
217
+ export function askRow({ header, question, answer } = {}) {
218
+ const title = firstLine(header || question || '提问');
219
+ const value = firstLine(answer) || '(空)';
220
+ return `提问 · ${clamp(title, 40)} → ${clamp(value, 60)}`;
221
+ }
222
+
223
+ /**
224
+ * 渲染一张过程卡。
225
+ *
226
+ * 正文按"每条元素各自截断 + 总量预算"控制,绝不做字符串级截断——
227
+ * 那会产出非法 JSON 让卡片整条发不出去。
228
+ *
229
+ * @param options - { title, rows, questionRows, answer, panelTitle, currentQuestion, template }。
230
+ * `rows` 是工具/思考行,`questionRows` 是已答提问行,两者同处一个折叠面板;
231
+ * `currentQuestion` 是**还没回答**的提问元素(控件必须留在面板外)。
232
+ * @returns 飞书交互卡片对象。
233
+ */
234
+ export function renderStepCard({
235
+ title,
236
+ panelItems = [],
237
+ answer = '',
238
+ panelTitle = '',
239
+ currentQuestion = [],
240
+ todos = null,
241
+ template = 'blue',
242
+ }) {
243
+ const budget = { left: MAX_CARD_CONTENT };
244
+ const clampBudget = (text) => {
245
+ const value = typeof text === 'string' ? text : '';
246
+ if (budget.left <= 0) return '';
247
+ const allowed = Math.min(value.length, budget.left);
248
+ budget.left -= allowed;
249
+ return allowed < value.length ? `${value.slice(0, allowed)}…` : value;
250
+ };
251
+
252
+ const elements = [];
253
+ // 面板里的内容**按发生顺序**排:工具/思考若干行 → 该批提问的内层折叠控件 → 后面的行…
254
+ // (真机反馈:提问必须留在它本来出现的位置,不能被推到面板底部)。
255
+ if (panelItems.length > 0) {
256
+ const inner = [];
257
+ for (const item of panelItems) {
258
+ if (item?.kind === 'ask') {
259
+ const asked = clampBudget(item.rows.map((row) => `· ${row.text}`).join('\n'));
260
+ if (!asked) continue;
261
+ inner.push({
262
+ tag: 'collapsible_panel',
263
+ expanded: item.expanded === true,
264
+ border: { color: 'grey', corner_radius: '4px' },
265
+ header: {
266
+ title: {
267
+ tag: 'plain_text',
268
+ content: clampBudget(item.title || `❓ ${item.rows.length} 已回答`),
269
+ },
270
+ width: 'fill',
271
+ icon_position: 'right',
272
+ icon_expanded_angle: -180,
273
+ },
274
+ elements: [{ tag: 'markdown', content: asked }],
275
+ });
276
+ continue;
277
+ }
278
+ const body = clampBudget((item?.rows ?? []).map((row) => `· ${row}`).join('\n'));
279
+ if (body) inner.push({ tag: 'markdown', content: body });
280
+ }
281
+ if (inner.length > 0) {
282
+ elements.push({
283
+ tag: 'collapsible_panel',
284
+ expanded: false,
285
+ border: { color: 'grey', corner_radius: '4px' },
286
+ header: {
287
+ title: { tag: 'plain_text', content: clampBudget(panelTitle) },
288
+ width: 'fill',
289
+ icon_position: 'right',
290
+ icon_expanded_angle: -180,
291
+ },
292
+ elements: inner,
293
+ });
294
+ }
295
+ }
296
+ // 任务清单单独一个面板(在工具面板下面):没结束时展开看进度,结束后收起。
297
+ if (todos && Array.isArray(todos.rows) && todos.rows.length > 0) {
298
+ const body = clampBudget(todos.rows.join('\n'));
299
+ if (body) {
300
+ elements.push({
301
+ tag: 'collapsible_panel',
302
+ expanded: todos.expanded === true,
303
+ border: { color: 'grey', corner_radius: '4px' },
304
+ header: {
305
+ title: {
306
+ tag: 'plain_text',
307
+ content: `任务清单 · ${todos.done}/${todos.total} 已完成`,
308
+ },
309
+ width: 'fill',
310
+ icon_position: 'right',
311
+ icon_expanded_angle: -180,
312
+ },
313
+ elements: [{ tag: 'markdown', content: body }],
314
+ });
315
+ }
316
+ }
317
+ // 还没回答的提问:控件留在面板外面(Card 2.0 的面板里放不了 form/输入框)。
318
+ if (Array.isArray(currentQuestion) && currentQuestion.length > 0) {
319
+ elements.push(...currentQuestion);
320
+ }
321
+ if (answer && budget.left > 0) {
322
+ elements.push({ tag: 'hr' });
323
+ elements.push({ tag: 'markdown', content: clampBudget(answer) });
324
+ }
325
+ if (elements.length === 0) {
326
+ elements.push({ tag: 'markdown', content: '正在处理…' });
327
+ }
328
+ return {
329
+ schema: '2.0',
330
+ config: { update_multi: true, width_mode: 'default' },
331
+ header: {
332
+ template,
333
+ title: { tag: 'plain_text', content: String(title).slice(0, 100) },
334
+ },
335
+ body: { direction: 'vertical', elements },
336
+ };
337
+ }
338
+
339
+ /**
340
+ * 只装最终答案的卡片(「不显示过程」那条路用)。
341
+ *
342
+ * 为什么不用过程卡:过程卡的头是「工具与思考(N)」、正文按 `· ` 逐行排过程——
343
+ * 关掉过程时它是空的,只剩答案,用户看到的会是一张"什么都没有"的卡。
344
+ * 这里给一张干净的卡:同一套 header 文案(✅ 已完成 / ⚠️ 未正常完成)与配色,
345
+ * 正文只有答案的 markdown——**格式(表格、代码块、链接)因此得以保留**,这正是要卡片的原因。
346
+ *
347
+ * @param options - { title, answer, template }。
348
+ * @returns 飞书交互卡片对象。
349
+ */
350
+ export function renderAnswerCard({ title, answer, template = 'green' } = {}) {
351
+ return {
352
+ schema: '2.0',
353
+ config: { update_multi: true, width_mode: 'default' },
354
+ header: {
355
+ template,
356
+ title: { tag: 'plain_text', content: String(title ?? '').slice(0, 100) },
357
+ },
358
+ body: {
359
+ direction: 'vertical',
360
+ elements: [{ tag: 'markdown', content: String(answer ?? '') }],
361
+ },
362
+ };
363
+ }
364
+
365
+ /**
366
+ * 创建一轮任务的展示器。
367
+ *
368
+ * @param options - {
369
+ * mode, gateway, message, chatType, bot, logger,
370
+ * }。
371
+ * @returns { tool, think, setQuestion, finish }。
372
+ */
373
+ export function createTurnPresenter({
374
+ mode,
375
+ gateway,
376
+ message,
377
+ chatType,
378
+ bot,
379
+ logger = console,
380
+ }) {
381
+ const messageId = message?.message_id;
382
+ const chatId = message?.chat_id;
383
+ // 群聊开启"话题回复"时,所有回复落在同一话题里。
384
+ const replyInThread = chatType === 'group' && bot?.groupTopicReply === true;
385
+ // 标题不带机器人名前缀(真机反馈:卡片本身就在这个机器人的会话里,重复没意义)。
386
+ const title = '正在处理';
387
+
388
+ /**
389
+ * 面板里的行,按发生顺序:`{ key, text }`。
390
+ * key 用来原地更新(同一条提问被回答多次时不能重复占行)。
391
+ */
392
+ let entries = [];
393
+ /** 还没回答的提问元素(面板外)。 */
394
+ let currentQuestion = [];
395
+ /** 提问进度:用于标题里的"第 N/M 题"。 */
396
+ let questionProgress = null;
397
+ /**
398
+ * 每批提问的状态:batchKey → { total, expanded }。
399
+ * batchKey 由题目 id 拼成,因此同一批问题被反复渲染(每答一题刷一次)只会有一份记录。
400
+ */
401
+ const askBatches = new Map();
402
+ /** 最新的任务清单(`todo_write` 每次都是全量,覆盖即可)。 */
403
+ let todos = null;
404
+ /** 已产出的最终答案:提问区刷新时要把答案一起画回去,不能抹掉。 */
405
+ let lastAnswer = '';
406
+ /** 呈现状态:running(默认)/ done / failed。 */
407
+ let state = 'running';
408
+ let cardId = null;
409
+ let cardBroken = false;
410
+ /** 本轮的最后一个呈现失败:调用方(桥)要把它变成可见的状态,不能只留在日志里。 */
411
+ let lastFailure = null;
412
+ /** 过程刷新的最小间隔:一次 patch 是整卡重写,工具多时不能每个事件都刷。 */
413
+ const PATCH_MIN_INTERVAL_MS = 1_200;
414
+ let lastPatchAt = 0;
415
+ let patchTimer = null;
416
+ /** 最终答案实际走了哪条路(card/text/failed),供桥记录"用户到底收到没有"。 */
417
+ let lastDelivery = null;
418
+ // 所有呈现动作串行执行:过程事件是"发出去就不等"的,若不排队,
419
+ // 收尾的最终答案可能先于某一步骤落到卡片/聊天里(顺序错乱)。
420
+ let chain = Promise.resolve();
421
+ function enqueue(task) {
422
+ chain = chain.then(task, task);
423
+ return chain;
424
+ }
425
+
426
+ function noteFailure(what, error) {
427
+ lastFailure = `${what}:${error?.message ?? error}`;
428
+ logger.warn?.(`[dsh-chat-feishu] ${lastFailure}`);
429
+ }
430
+
431
+ /**
432
+ * 当前卡片的标题:随状态变化。
433
+ * 真机反馈:一轮处理完了标题还写着"正在处理",看不出结束没结束。
434
+ */
435
+ function currentTitle() {
436
+ if (currentQuestion.length > 0 && questionProgress) {
437
+ return `❓ 等你确认(第 ${questionProgress.index}/${questionProgress.total} 题)`;
438
+ }
439
+ if (state === 'done') return '✅ 已完成';
440
+ if (state === 'failed') return '⚠️ 未正常完成';
441
+ return title;
442
+ }
443
+
444
+ /**
445
+ * 折叠面板的标题。
446
+ *
447
+ * 真机反馈两条,一起满足:
448
+ * - 本轮**没结束**时显示最新的一项(一眼看到此刻在干什么),不写前缀;
449
+ * - 本轮**结束后**才显示 `工具与思考(N)`。
450
+ */
451
+ function panelTitle() {
452
+ const count = entries.length;
453
+ if (count === 0) return '';
454
+ if (state !== 'running') return `工具与思考(${count})`;
455
+ return clamp(entries[count - 1].text, MAX_PANEL_TITLE);
456
+ }
457
+
458
+ /**
459
+ * 把有序的 entries 折成面板内容:连续的工具/思考行合成一个 markdown 块,
460
+ * 每批提问在**它第一次出现的位置**放一个内层折叠面板。
461
+ *
462
+ * @returns `[{kind:'rows',rows} | {kind:'ask',title,rows,expanded}]`。
463
+ */
464
+ function panelItems() {
465
+ const items = [];
466
+ let buffer = [];
467
+ let batch = null;
468
+ const flush = () => {
469
+ if (buffer.length > 0) items.push({ kind: 'rows', rows: buffer });
470
+ buffer = [];
471
+ };
472
+ for (const entry of entries) {
473
+ if (entry.kind !== 'ask') {
474
+ // 一批提问结束(后面又出现了工具/思考),再来的提问算新的一批。
475
+ batch = null;
476
+ buffer.push(entry.text);
477
+ continue;
478
+ }
479
+ if (entry.batch !== batch) {
480
+ flush();
481
+ batch = entry.batch;
482
+ items.push({ kind: 'ask', batch, rows: [], title: '', expanded: false });
483
+ }
484
+ items[items.length - 1].rows.push({ id: entry.key, text: entry.text });
485
+ }
486
+ flush();
487
+ for (const item of items) {
488
+ if (item.kind !== 'ask') continue;
489
+ const info = askBatches.get(item.batch);
490
+ const total = info?.total ?? item.rows.length;
491
+ item.title = `❓ ${item.rows.length}/${total} 已回答`;
492
+ item.expanded = info?.expanded === true;
493
+ }
494
+ return items;
495
+ }
496
+
497
+ function cardPayload(answer) {
498
+ return renderStepCard({
499
+ title: currentTitle(),
500
+ panelItems: panelItems(),
501
+ answer,
502
+ panelTitle: panelTitle(),
503
+ currentQuestion,
504
+ todos: todos ? { ...todos, expanded: state === 'running' } : null,
505
+ template: state === 'done' ? 'green' : state === 'failed' ? 'orange' : 'blue',
506
+ });
507
+ }
508
+
509
+ async function ensureCard() {
510
+ if (cardId || cardBroken) return cardId;
511
+ try {
512
+ const created = await gateway.replyCard({
513
+ messageId,
514
+ card: cardPayload(''),
515
+ replyInThread,
516
+ });
517
+ cardId = created?.messageId ?? null;
518
+ if (!cardId) noteFailure('创建过程卡失败', new Error('飞书没有返回卡片消息 id'));
519
+ } catch (error) {
520
+ cardBroken = true;
521
+ noteFailure('创建过程卡失败', error);
522
+ }
523
+ return cardId;
524
+ }
525
+
526
+ /**
527
+ * 发一条文本:优先回复原消息(保留上下文),失败再退到"发到这个会话"。
528
+ * 两条都失败才算真失败——那也必须留下可查的原因。
529
+ */
530
+ async function sendText(body) {
531
+ try {
532
+ await gateway.replyText({ messageId, text: body, replyInThread });
533
+ return true;
534
+ } catch (error) {
535
+ noteFailure('回复失败', error);
536
+ }
537
+ if (!chatId) return false;
538
+ try {
539
+ await gateway.sendText({ chatId, text: body });
540
+ return true;
541
+ } catch (error) {
542
+ noteFailure('回退发送失败', error);
543
+ return false;
544
+ }
545
+ }
546
+
547
+ /** 追加/原地更新一行(超出上限丢最旧的)。 */
548
+ function putEntry({ key, kind, text }) {
549
+ if (!text) return;
550
+ const index = key ? entries.findIndex((entry) => entry.key === key) : -1;
551
+ if (index >= 0) {
552
+ entries = entries.map((entry, at) => (at === index ? { ...entry, text } : entry));
553
+ return;
554
+ }
555
+ entries = [...entries, { key, kind, text }].slice(-MAX_ROWS);
556
+ }
557
+
558
+ /** 立刻刷新一次卡片(记下时间用于节流)。 */
559
+ async function patchNow(answer = lastAnswer) {
560
+ lastPatchAt = Date.now();
561
+ return patch(answer);
562
+ }
563
+
564
+ /**
565
+ * 过程事件到达时按最小间隔合并刷新:一次 patch 是**整卡重写**,
566
+ * 一轮几十上百个工具调用如果每个都刷,既慢又浪费;收尾时一定会再刷一次。
567
+ */
568
+ function schedulePatch() {
569
+ if (mode !== 'streaming_card' || cardBroken) return;
570
+ // 还没建卡时立刻建,别让用户等
571
+ if (!cardId) {
572
+ void enqueue(() => patchNow());
573
+ return;
574
+ }
575
+ const wait = PATCH_MIN_INTERVAL_MS - (Date.now() - lastPatchAt);
576
+ if (wait <= 0) {
577
+ void enqueue(() => patchNow());
578
+ return;
579
+ }
580
+ if (patchTimer) return;
581
+ patchTimer = setTimeout(() => {
582
+ patchTimer = null;
583
+ void enqueue(() => patchNow());
584
+ }, wait);
585
+ }
586
+
587
+ /** @returns 卡片是否可用(更新成功才算)。 */
588
+ async function patch(answer) {
589
+ const id = await ensureCard();
590
+ if (!id) return false;
591
+ try {
592
+ await gateway.patchCard({ messageId: id, card: cardPayload(answer) });
593
+ return true;
594
+ } catch (error) {
595
+ cardBroken = true;
596
+ noteFailure('更新过程卡失败', error);
597
+ return false;
598
+ }
599
+ }
600
+
601
+ /** 推一行:卡片模式进面板,`post` 模式单独回一条消息。 */
602
+ function push(text) {
603
+ if (mode === 'off' || !text) return Promise.resolve();
604
+ if (mode === 'post') {
605
+ return enqueue(async () => {
606
+ try {
607
+ await gateway.replyText({ messageId, text, replyInThread });
608
+ } catch (error) {
609
+ noteFailure('发送过程消息失败', error);
610
+ }
611
+ });
612
+ }
613
+ schedulePatch();
614
+ return Promise.resolve();
615
+ }
616
+
617
+ /**
618
+ * 把最终答案作为**一张卡片**发出(`off` 模式:不显示过程,但答案仍走卡片)。
619
+ *
620
+ * 两条硬约束:
621
+ * - 答案超过单卡内容预算时**不截断**,退回文本发送(截断答案比丢格式更糟),并留日志;
622
+ * - 卡片发不出去也退回文本——答案一定到得了,失败仍记在 `lastError` 里。
623
+ *
624
+ * @param body - 最终答案文本。
625
+ * @returns 是否作为卡片发出。
626
+ */
627
+ async function sendAnswerCard(body) {
628
+ if (typeof body !== 'string' || !body.trim()) return false;
629
+ if (body.length > MAX_CARD_CONTENT) {
630
+ logger.info?.(`[dsh-chat-feishu] 答案 ${body.length} 字超过单卡预算`
631
+ + `(${MAX_CARD_CONTENT}),改用文本发送(不截断)。`);
632
+ return false;
633
+ }
634
+ try {
635
+ await gateway.replyCard({
636
+ messageId,
637
+ card: renderAnswerCard({
638
+ title: currentTitle(),
639
+ answer: body,
640
+ template: state === 'failed' ? 'orange' : 'green',
641
+ }),
642
+ replyInThread,
643
+ });
644
+ return true;
645
+ } catch (error) {
646
+ noteFailure('发送答案卡片失败', error);
647
+ return false;
648
+ }
649
+ }
650
+
651
+ return {
652
+ /**
653
+ * 记录一次工具调用,渲染成 Web 那样的一行。
654
+ *
655
+ * @param call - { name, arguments }。
656
+ */
657
+ tool(call) {
658
+ const row = toolRow(call);
659
+ putEntry({ kind: 'tool', text: row });
660
+ // 任务清单每次都带全量,直接覆盖;渲染在工具面板下面的独立面板里。
661
+ if (call?.name === 'todo_write') {
662
+ const parsed = todoRows(call.arguments);
663
+ if (parsed) todos = parsed;
664
+ }
665
+ return push(row);
666
+ },
667
+
668
+ /**
669
+ * 记录一段思考(模型的推理),与工具调用同处一个折叠面板。
670
+ *
671
+ * @param text - 推理文本。
672
+ */
673
+ think(text) {
674
+ const row = thinkRow(text);
675
+ putEntry({ kind: 'think', text: row });
676
+ return push(row);
677
+ },
678
+
679
+ /**
680
+ * 同步一批提问:已答的变成面板里的一行,没答的元素留在面板外做交互。
681
+ *
682
+ * @param payload - { questions, answered, final }。
683
+ * @returns 是否成功内嵌(false 表示这张卡放不了提问,调用方应改用独立卡片)。
684
+ */
685
+ setQuestion(payload) {
686
+ if (mode !== 'streaming_card' || typeof gateway.renderQuestionElements !== 'function') {
687
+ return Promise.resolve(false);
688
+ }
689
+ if (patchTimer) {
690
+ clearTimeout(patchTimer);
691
+ patchTimer = null;
692
+ }
693
+ return enqueue(async () => {
694
+ const questions = payload?.questions ?? [];
695
+ const answered = payload?.answered ?? {};
696
+ const batchKey = questions.map((question) => String(question?.id ?? '')).join('|');
697
+ const rendered = gateway.renderQuestionElements({
698
+ questions,
699
+ answered,
700
+ final: payload?.final === true,
701
+ });
702
+ // 已答的提问:按题号原地更新,位置就是它第一次出现的位置。
703
+ for (const row of rendered.rows ?? []) {
704
+ putEntry({ key: `ask:${row.id}`, kind: 'ask', batch: batchKey, text: row.text });
705
+ }
706
+ currentQuestion = Array.isArray(rendered.elements) ? rendered.elements : [];
707
+ const current = rendered.current;
708
+ questionProgress = current && questions.length > 0
709
+ ? { index: questions.indexOf(current) + 1, total: questions.length }
710
+ : null;
711
+ if (batchKey) {
712
+ askBatches.set(batchKey, {
713
+ total: questions.length,
714
+ // 还有题要答时展开方便对照;这一批答完就收起。
715
+ expanded: Boolean(current),
716
+ });
717
+ }
718
+ return patchNow();
719
+ });
720
+ },
721
+
722
+ /** @returns 本轮最后一次呈现失败(无失败则为 null)。 */
723
+ lastError: () => lastFailure,
724
+ /** @returns 最终答案的投递方式:card / text / failed / null(还没收尾)。 */
725
+ delivery: () => lastDelivery,
726
+
727
+ /**
728
+ * 收尾:把最终答案交给用户(排在所有已排队的步骤之后)。
729
+ *
730
+ * 这里有两条硬约束:
731
+ * 1. **绝不能静默**——用户等了一轮却什么都没收到,是最难排查的故障形态;
732
+ * 2. **回退要真做**——卡片建不出来/刷不动时必须改用普通消息,而不是只打一行日志。
733
+ *
734
+ * @param answer - 最终文本。
735
+ * @param reason - 回合结束原因(DSH 的 `turn/end` 数据)。
736
+ */
737
+ finish(answer, reason) {
738
+ return enqueue(async () => {
739
+ const text = typeof answer === 'string' ? answer.trim() : '';
740
+ const failed = reason?.kind && reason.kind !== 'completed';
741
+ const body = text || (failed
742
+ ? `任务未正常完成(${reason.kind})。`
743
+ : '(本轮没有文本输出)');
744
+
745
+ lastAnswer = body;
746
+ state = failed ? 'failed' : 'done';
747
+ // 收尾时提问控件一律收起来(面板里那一行还在,可展开回看)。
748
+ currentQuestion = [];
749
+ questionProgress = null;
750
+ for (const [key, info] of askBatches) askBatches.set(key, { ...info, expanded: false });
751
+ // 收尾一定刷新(把之前节流掉的过程一次性画上,并让标题变成 工具与思考(N))
752
+ if (patchTimer) {
753
+ clearTimeout(patchTimer);
754
+ patchTimer = null;
755
+ }
756
+ if (mode === 'streaming_card') {
757
+ // 卡片能刷就刷;刷不动(含建卡失败)就退化成普通消息,保证答案一定到得了。
758
+ if (!cardBroken && await patch(body)) {
759
+ lastDelivery = 'card';
760
+ return lastDelivery;
761
+ }
762
+ lastDelivery = await sendText(body) ? 'text' : 'failed';
763
+ return lastDelivery;
764
+ }
765
+ /**
766
+ * 「不显示过程」也走卡片:正文里的表格/代码块/链接要保留格式,
767
+ * 纯文本发出去这些全没了。发不出去(或答案太长)自动退回文本。
768
+ */
769
+ if (mode === 'off' && await sendAnswerCard(body)) {
770
+ lastDelivery = 'card';
771
+ return lastDelivery;
772
+ }
773
+ lastDelivery = await sendText(body) ? 'text' : 'failed';
774
+ return lastDelivery;
775
+ });
776
+ },
777
+ };
778
+ }