dsh-lark-agent 0.1.2 → 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.
@@ -0,0 +1,518 @@
1
+ import { cardMarkdown, markdownBlocks, plainLabel } from './replies.js';
2
+ // CardKit rejects cards over 30 KB, measured on the card JSON.
3
+ const CARD_BYTES = 28_000;
4
+ const LIVE_ANSWER_BYTES = 20_000;
5
+ const MAX_KEPT_CHARS = 40_000;
6
+ // Cards have a component limit; older steps fold into one note beyond this.
7
+ const MAX_ROWS = 60;
8
+ // One card entity accepts at most 10 operations per second.
9
+ const STREAM_INTERVAL_MS = 500;
10
+ const FULL_INTERVAL_MS = 2_000;
11
+ // Feishu turns streaming mode off 10 minutes after it was last turned on.
12
+ const REOPEN_AFTER_MS = 9 * 60_000;
13
+ const TRUNCATED = '回复超过了飞书卡片的长度上限,后面的内容没有显示。完整回复在电脑上的这段 Harness 会话里,也可以让我整理成文档。';
14
+ const grey = (markdown) => `<font color='grey'>${markdown}</font>`;
15
+ const bytes = (value) => Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
16
+ const code = (value) => '`' + value.replace(/`/g, 'ˋ') + '`';
17
+ /** The App's step labels and a matching Feishu icon, by tool name. */
18
+ function activity(name) {
19
+ const is = (...names) => names.includes(name);
20
+ if (is('read'))
21
+ return { label: '读取', doing: '正在读取文件', icon: 'file-link-text_outlined' };
22
+ if (is('read_image'))
23
+ return { label: '读取图片', doing: '正在读取图片', icon: 'image_outlined' };
24
+ if (is('grep'))
25
+ return { label: '搜索文件内容', doing: '正在搜索代码', icon: 'doc-search_outlined' };
26
+ if (is('glob') || name.endsWith('_inspect'))
27
+ return { label: '查找文件', doing: '正在搜索代码', icon: 'search_outlined' };
28
+ if (is('write'))
29
+ return { label: '写入', doing: '正在写入文件', icon: 'add-doc_outlined' };
30
+ if (is('edit', 'apply_patch'))
31
+ return { label: '编辑', doing: '正在编辑文件', icon: 'edit_outlined' };
32
+ if (is('bash', 'pwsh', 'exec_command', 'write_stdin') || name.startsWith('terminal_'))
33
+ return { label: '运行命令', doing: '正在运行命令', icon: 'code_outlined' };
34
+ if (is('run_code'))
35
+ return { label: '运行代码', doing: '正在运行代码', icon: 'codeblock_outlined' };
36
+ if (is('web_search'))
37
+ return { label: '网页搜索', doing: '正在搜索网页', icon: 'internet_outlined' };
38
+ if (is('web_fetch'))
39
+ return { label: '网页获取', doing: '正在访问网页', icon: 'web-card_outlined' };
40
+ if (is('subagent') || name.startsWith('subagent_'))
41
+ return { label: '子智能体', doing: '正在协调子智能体', icon: 'robot_outlined' };
42
+ if (is('todo_write', 'create_goal', 'update_goal', 'get_goal'))
43
+ return { label: '更新计划', doing: '正在更新计划', icon: 'list-check_outlined' };
44
+ if (is('ask_user_question', 'request_user_input'))
45
+ return { label: '提问', doing: '等待你的操作', icon: 'chat_outlined' };
46
+ if (name.startsWith('lark_memory_'))
47
+ return { label: '记忆', doing: '正在查看记忆', icon: 'mindnote_outlined' };
48
+ if (is('lark_present'))
49
+ return { label: '整理作品', doing: '正在整理作品', icon: 'web-card_outlined' };
50
+ return { label: plainLabel(name, 40) || '工具调用', doing: '正在调用工具', icon: 'setting_outlined' };
51
+ }
52
+ /** The longest prefix of text within maxBytes, never splitting a code point. */
53
+ function head(text, maxBytes) {
54
+ let size = 0, end = 0;
55
+ for (const point of text) {
56
+ size += Buffer.byteLength(point, 'utf8');
57
+ if (size > maxBytes)
58
+ break;
59
+ end += point.length;
60
+ }
61
+ return text.slice(0, end);
62
+ }
63
+ /**
64
+ * A suffix of text within maxBytes. The cut moves in coarse steps so that
65
+ * successive updates mostly share a prefix, which keeps Feishu's typewriter
66
+ * effect instead of redrawing the whole element.
67
+ */
68
+ function tail(text, maxBytes) {
69
+ if (bytes(text) <= maxBytes)
70
+ return text;
71
+ let size = 0, start = text.length;
72
+ while (start > 0) {
73
+ const pair = start >= 2 && (text.charCodeAt(start - 1) & 0xfc00) === 0xdc00 && (text.charCodeAt(start - 2) & 0xfc00) === 0xd800;
74
+ const width = Buffer.byteLength(text.slice(start - (pair ? 2 : 1), start), 'utf8');
75
+ if (size + width > maxBytes)
76
+ break;
77
+ size += width;
78
+ start -= pair ? 2 : 1;
79
+ }
80
+ let cut = Math.ceil(start / 1_000) * 1_000;
81
+ const newline = text.indexOf('\n', cut);
82
+ if (newline >= 0 && newline - cut < 200)
83
+ cut = newline + 1;
84
+ if (cut < text.length && (text.charCodeAt(cut) & 0xfc00) === 0xdc00)
85
+ cut++;
86
+ return text.slice(Math.min(cut, text.length));
87
+ }
88
+ /**
89
+ * One streaming reply card: the answer, with the process in a panel that
90
+ * stays folded unless the reader opens it. Like the App, the process is a
91
+ * list of steps, thinking and tool calls, each on its own row. Only the
92
+ * initial reply is a send; every later operation updates the same card
93
+ * entity and is safe to supersede or repeat. A reply never continues in
94
+ * another message.
95
+ */
96
+ export class LiveReply {
97
+ transport;
98
+ target;
99
+ options;
100
+ phase = 'thinking';
101
+ steps = [];
102
+ nextStep = 0;
103
+ trimmed = false;
104
+ answer = '';
105
+ separator = false;
106
+ mark = { steps: 0, tail: -1, answer: 0 };
107
+ tools = 0;
108
+ note;
109
+ footer;
110
+ cardId;
111
+ sequence = 0;
112
+ sent = new Map();
113
+ shown = [];
114
+ dirty = false;
115
+ layoutDirty = false;
116
+ degraded = false;
117
+ streamFailures = 0;
118
+ closed = false;
119
+ loop;
120
+ wake;
121
+ streamingSince = 0;
122
+ startedAt;
123
+ answeredAt;
124
+ finishedAt;
125
+ constructor(transport, target, options = {}) {
126
+ this.transport = transport;
127
+ this.target = target;
128
+ this.options = options;
129
+ this.startedAt = this.now();
130
+ }
131
+ /** Send the card. False means nothing was sent, so the caller keeps its ordinary reply. */
132
+ async open() {
133
+ let card;
134
+ try {
135
+ card = this.card();
136
+ this.cardId = await this.transport.createCardEntity(card);
137
+ }
138
+ catch {
139
+ this.audit('live-card-unavailable');
140
+ return false;
141
+ }
142
+ this.streamingSince = this.now();
143
+ this.markSent(card);
144
+ try {
145
+ await this.transport.replyCardEntity(this.target.messageId, this.cardId, { idempotencyKey: this.target.idempotencyKey, inThread: this.target.inThread });
146
+ }
147
+ // The reply may still have been delivered. Never send another message for this
148
+ // turn; updating a card nobody received is harmless.
149
+ catch {
150
+ this.audit('live-card-delivery-unknown');
151
+ }
152
+ if (this.dirty)
153
+ this.schedule();
154
+ return true;
155
+ }
156
+ /** Trusted settings line under the answer, such as the model and thinking depth in use. */
157
+ setFooter(text) {
158
+ if (this.closed || !text.trim() || text === this.footer)
159
+ return;
160
+ this.footer = text;
161
+ this.layoutDirty = true;
162
+ this.schedule();
163
+ }
164
+ push(event) {
165
+ if (this.closed)
166
+ return;
167
+ switch (event.type) {
168
+ case 'attempt': {
169
+ const last = this.steps.at(-1);
170
+ this.mark = { steps: this.steps.length, tail: last?.kind === 'reasoning' ? last.text.length : -1, answer: this.answer.length };
171
+ this.separator = this.answer.length > 0;
172
+ break;
173
+ }
174
+ case 'discard': {
175
+ if (this.steps.length > this.mark.steps)
176
+ this.layoutDirty = true;
177
+ this.steps.length = Math.min(this.steps.length, this.mark.steps);
178
+ const last = this.steps.at(-1);
179
+ if (this.mark.tail >= 0 && last?.kind === 'reasoning')
180
+ last.text = last.text.slice(0, this.mark.tail);
181
+ this.answer = this.answer.slice(0, this.mark.answer);
182
+ this.separator = this.answer.length > 0;
183
+ // A discarded draft was the only answer so far: the reply is still at work.
184
+ if (!this.answer && this.phase === 'answering') {
185
+ this.phase = 'thinking';
186
+ this.answeredAt = undefined;
187
+ this.layoutDirty = true;
188
+ }
189
+ break;
190
+ }
191
+ case 'reasoning': {
192
+ const last = this.steps.at(-1);
193
+ if (last?.kind === 'reasoning')
194
+ last.text += event.text;
195
+ else {
196
+ this.steps.push({ id: this.nextStep++, kind: 'reasoning', text: event.text });
197
+ this.layoutDirty = true;
198
+ }
199
+ this.trim();
200
+ break;
201
+ }
202
+ case 'text':
203
+ if (this.answer.length > 70_000)
204
+ return;
205
+ if (this.separator) {
206
+ this.answer += '\n\n';
207
+ this.separator = false;
208
+ }
209
+ this.answer += event.text;
210
+ // The folded panel's title switches from the current step to the time it took.
211
+ if (this.phase === 'thinking') {
212
+ this.phase = 'answering';
213
+ this.answeredAt = this.now();
214
+ this.layoutDirty = true;
215
+ }
216
+ break;
217
+ case 'tool':
218
+ if (this.steps.some(step => step.kind === 'tool' && step.callId === event.callId))
219
+ return;
220
+ this.steps.push({ id: this.nextStep++, kind: 'tool', callId: event.callId, name: event.name, state: 'running',
221
+ ...(event.detail ? { detail: event.detail } : {}), ...(event.code ? { code: true } : {}) });
222
+ this.tools++;
223
+ this.layoutDirty = true;
224
+ this.trim();
225
+ break;
226
+ case 'tool-done': {
227
+ const step = this.steps.find(item => item.kind === 'tool' && item.callId === event.callId);
228
+ if (step?.kind !== 'tool')
229
+ return;
230
+ step.state = event.ok ? 'done' : 'failed';
231
+ if (event.exitCode !== undefined)
232
+ step.exitCode = event.exitCode;
233
+ // The title names the running step, so it changes too.
234
+ this.layoutDirty = true;
235
+ break;
236
+ }
237
+ }
238
+ this.schedule();
239
+ }
240
+ /** Stop streaming and write the final card. True only when Feishu confirmed it. */
241
+ async finish(final) {
242
+ if (this.closed)
243
+ return false;
244
+ this.closed = true;
245
+ this.wake?.();
246
+ await this.loop;
247
+ this.finishedAt = this.now();
248
+ this.phase = final.phase;
249
+ if (final.answer !== undefined)
250
+ this.answer = final.answer;
251
+ if (final.reasoning && !this.steps.some(step => step.kind === 'reasoning'))
252
+ this.steps.unshift({ id: this.nextStep++, kind: 'reasoning', text: final.reasoning });
253
+ for (const step of this.steps)
254
+ if (step.kind === 'tool' && step.state === 'running')
255
+ step.state = final.phase === 'done' ? 'done' : 'failed';
256
+ this.note = final.note;
257
+ const card = this.finalCard();
258
+ if (!this.cardId)
259
+ return false;
260
+ for (let attempt = 0; attempt < 3; attempt++) {
261
+ try {
262
+ await this.transport.updateCardEntity(this.cardId, card, ++this.sequence);
263
+ return true;
264
+ }
265
+ catch {
266
+ if (attempt < 2)
267
+ await this.sleep(500 * 3 ** attempt);
268
+ }
269
+ }
270
+ this.audit('live-card-final-failed');
271
+ return false;
272
+ }
273
+ now() { return (this.options.now ?? Date.now)(); }
274
+ sleep(ms) { return (this.options.sleep ?? (wait => new Promise(done => setTimeout(done, wait))))(ms); }
275
+ audit(stage) { try {
276
+ this.options.audit?.(stage);
277
+ }
278
+ catch { /* Diagnostics only. */ } }
279
+ /** Keep only what the card can still show; the turn result stays authoritative. */
280
+ trim() {
281
+ let total = this.steps.reduce((sum, step) => sum + (step.kind === 'reasoning' ? step.text.length : 200), 0);
282
+ while ((total > MAX_KEPT_CHARS || this.steps.length > MAX_ROWS * 2) && this.steps.length > 1) {
283
+ const removed = this.steps.shift();
284
+ total -= removed.kind === 'reasoning' ? removed.text.length : 200;
285
+ this.mark.steps = Math.max(0, this.mark.steps - 1);
286
+ this.trimmed = true;
287
+ this.layoutDirty = true;
288
+ }
289
+ const only = this.steps[0];
290
+ if (this.steps.length === 1 && only?.kind === 'reasoning' && only.text.length > MAX_KEPT_CHARS) {
291
+ const cut = only.text.length - MAX_KEPT_CHARS / 2;
292
+ only.text = only.text.slice(cut);
293
+ this.mark.tail = Math.max(-1, this.mark.tail - cut);
294
+ this.trimmed = true;
295
+ }
296
+ }
297
+ schedule() {
298
+ this.dirty = true;
299
+ if (!this.cardId || this.closed || this.loop)
300
+ return;
301
+ this.loop = this.run().finally(() => {
302
+ this.loop = undefined;
303
+ if (this.dirty && !this.closed)
304
+ this.schedule();
305
+ });
306
+ }
307
+ async run() {
308
+ while (this.dirty && !this.closed) {
309
+ this.dirty = false;
310
+ const started = this.now();
311
+ await this.flush();
312
+ const wait = (this.degraded ? FULL_INTERVAL_MS : STREAM_INTERVAL_MS) - (this.now() - started);
313
+ if (wait > 0 && !this.closed)
314
+ await this.pause(wait);
315
+ }
316
+ }
317
+ /** Pacing between updates; finishing the reply ends it early. */
318
+ async pause(ms) {
319
+ await Promise.race([this.sleep(ms), new Promise(done => { this.wake = done; })]);
320
+ this.wake = undefined;
321
+ }
322
+ async flush() {
323
+ const cardId = this.cardId;
324
+ const card = this.card();
325
+ const elements = this.textElements(card);
326
+ // Rows that appeared, left or moved need the whole card, which also sets the title.
327
+ const layout = elements.filter(([id]) => id !== 'answer').map(([id]) => id).join() !== this.shown.join();
328
+ if (this.degraded || this.layoutDirty || layout || this.now() - this.streamingSince > REOPEN_AFTER_MS) {
329
+ this.layoutDirty = false;
330
+ try {
331
+ // A complete card also turns streaming mode back on.
332
+ await this.transport.updateCardEntity(cardId, card, ++this.sequence);
333
+ this.streamingSince = this.now();
334
+ this.markSent(card);
335
+ this.degraded = this.streamFailures >= 3;
336
+ }
337
+ catch {
338
+ this.degraded = true;
339
+ }
340
+ return;
341
+ }
342
+ for (const [id, content] of elements) {
343
+ if (this.closed)
344
+ return;
345
+ if (this.sent.get(id) === content)
346
+ continue;
347
+ try {
348
+ await this.transport.streamCardText(cardId, id, content, ++this.sequence);
349
+ this.sent.set(id, content);
350
+ }
351
+ catch {
352
+ this.streamFailures++;
353
+ this.degraded = true;
354
+ this.audit('live-card-degraded');
355
+ return;
356
+ }
357
+ }
358
+ }
359
+ /** Streamable text elements of a card: its process rows and the answer. */
360
+ textElements(card) {
361
+ const result = [];
362
+ const visit = (items) => {
363
+ for (const item of items) {
364
+ if (Array.isArray(item.elements))
365
+ visit(item.elements);
366
+ if (item.tag === 'markdown' && typeof item.element_id === 'string' && /^(?:s[0-9]+|answer)$/.test(item.element_id)) {
367
+ result.push([item.element_id, item.content]);
368
+ }
369
+ }
370
+ };
371
+ visit(card.body.elements);
372
+ return result;
373
+ }
374
+ markSent(card) {
375
+ const elements = this.textElements(card);
376
+ this.sent.clear();
377
+ for (const [id, content] of elements)
378
+ this.sent.set(id, content);
379
+ this.shown = elements.filter(([id]) => id !== 'answer').map(([id]) => id);
380
+ }
381
+ /** One step as a row, in the App's words: what was done and on what. */
382
+ row(step, maxBytes = Infinity) {
383
+ if (step.kind === 'reasoning') {
384
+ const text = step.text.trim();
385
+ const shown = bytes(text) > maxBytes ? tail(text, Math.max(200, maxBytes)) : text;
386
+ return { tag: 'markdown', element_id: `s${step.id}`, text_size: 'notation',
387
+ icon: { tag: 'standard_icon', token: 'mindnote_outlined', color: 'grey' },
388
+ content: (shown.length < text.length ? `${grey('…')}\n` : '') + (cardMarkdown(shown) || grey('正在思考…')) };
389
+ }
390
+ const { label, icon } = activity(step.name);
391
+ const detail = step.detail ? ' · ' + (step.code ? code(step.detail) : grey(cardMarkdown(step.detail))) : '';
392
+ const status = step.state === 'running' ? ` · ${grey('进行中…')}`
393
+ : step.state === 'failed' ? ` · <font color='red'>失败</font>`
394
+ : step.exitCode !== undefined ? ` · <font color='red'>退出码 ${step.exitCode}</font>` : '';
395
+ return { tag: 'markdown', element_id: `s${step.id}`, text_size: 'notation',
396
+ icon: { tag: 'standard_icon', token: icon, color: step.state === 'failed' || step.exitCode !== undefined ? 'red' : 'grey' },
397
+ content: `${label}${detail}${status}` };
398
+ }
399
+ answerContent() {
400
+ if (!this.answer)
401
+ return grey('…');
402
+ const shown = head(this.answer, LIVE_ANSWER_BYTES);
403
+ return cardMarkdown(shown) + (shown.length < this.answer.length ? `\n\n${grey('……')}` : '');
404
+ }
405
+ panelTitle() {
406
+ const tools = this.tools ? ` · ${this.tools} 次工具调用` : '';
407
+ const seconds = (end) => Math.max(1, Math.round(((end ?? this.now()) - this.startedAt) / 1000));
408
+ switch (this.phase) {
409
+ case 'thinking': {
410
+ const running = [...this.steps].reverse().find(step => step.kind === 'tool' && step.state === 'running');
411
+ if (running?.kind !== 'tool')
412
+ return `正在思考…${tools}`;
413
+ const detail = running.detail ? plainLabel(running.detail, 40) : '';
414
+ return `${activity(running.name).doing}${detail ? ` · ${detail}` : '…'}`;
415
+ }
416
+ case 'answering': return `已思考 ${seconds(this.answeredAt)} 秒${tools}`;
417
+ case 'done': return `已完成,用时 ${seconds(this.finishedAt)} 秒${tools}`;
418
+ case 'stopped': return `已停止,用时 ${seconds(this.finishedAt)} 秒${tools}`;
419
+ case 'failed': return `处理失败,用时 ${seconds(this.finishedAt)} 秒${tools}`;
420
+ }
421
+ }
422
+ /** The folded process panel. Readers open it when they want the thinking and tool steps. */
423
+ panel(rows) {
424
+ return {
425
+ tag: 'collapsible_panel', element_id: 'think_panel', expanded: false,
426
+ header: { title: { tag: 'plain_text', content: this.panelTitle() }, vertical_align: 'center',
427
+ icon: { tag: 'standard_icon', token: 'down-small-ccm_outlined', color: 'grey', size: '16px 16px' }, icon_position: 'follow_text', icon_expanded_angle: -180 },
428
+ border: { color: 'grey', corner_radius: '5px' }, vertical_spacing: '6px', padding: '8px 8px 8px 8px',
429
+ elements: rows.length ? rows : [{ tag: 'markdown', element_id: 's_wait', content: grey('正在思考…'), text_size: 'notation' }],
430
+ };
431
+ }
432
+ /**
433
+ * The rows that fit in maxBytes, newest last. Older steps give way first and
434
+ * leave a note; a single oversized thought keeps its most recent part.
435
+ */
436
+ rows(maxBytes) {
437
+ const rows = this.steps.map(step => this.row(step));
438
+ const sizes = rows.map(row => bytes(row) + 1);
439
+ let total = sizes.reduce((sum, size) => sum + size, 0);
440
+ let start = Math.max(0, rows.length - MAX_ROWS);
441
+ for (let index = 0; index < start; index++)
442
+ total -= sizes[index];
443
+ while (start < rows.length - 1 && total > maxBytes)
444
+ total -= sizes[start++];
445
+ const shown = rows.slice(start);
446
+ if (shown.length === 1 && total > maxBytes)
447
+ shown[0] = this.row(this.steps[start], maxBytes - 400);
448
+ if (start > 0 || this.trimmed)
449
+ shown.unshift({ tag: 'markdown', element_id: 'omitted', text_size: 'notation',
450
+ content: grey('前面的步骤较多,这里只显示最近的部分;完整过程在电脑上的这段 Harness 会话里。') });
451
+ return shown;
452
+ }
453
+ /** Live layout when final is undefined; a final layout otherwise (null leaves out the panel's rows). */
454
+ card(final) {
455
+ const elements = [];
456
+ if (!final)
457
+ elements.push(this.panel(this.rows(Math.max(1_000, CARD_BYTES - 3_000 - bytes(this.answerContent())))));
458
+ else if (final.rows)
459
+ elements.push(this.panel(final.rows));
460
+ elements.push({ tag: 'markdown', element_id: 'answer',
461
+ content: final ? (final.answer.trim() ? cardMarkdown(final.answer) : grey('(没有文字回复)')) : this.answerContent() });
462
+ const note = [this.note, final?.truncated ? TRUNCATED : undefined].filter(Boolean).join(' ');
463
+ if (final && note)
464
+ elements.push({ tag: 'markdown', element_id: 'note', content: grey(cardMarkdown(note)), text_size: 'notation' });
465
+ if (this.footer)
466
+ elements.push({ tag: 'markdown', element_id: 'footer', text_size: 'notation',
467
+ icon: { tag: 'standard_icon', token: 'robot_outlined', color: 'grey' }, content: grey(cardMarkdown(this.footer)) });
468
+ const summary = final ? plainLabel(final.answer || this.note || '回复', 60) || '回复' : this.answer ? '正在回复…' : '正在思考…';
469
+ return {
470
+ schema: '2.0',
471
+ config: { update_multi: true, enable_forward: false, width_mode: 'default', summary: { content: summary }, streaming_mode: !final,
472
+ ...(final ? {} : { streaming_config: { print_frequency_ms: { default: 30 }, print_step: { default: 2 }, print_strategy: 'fast' } }) },
473
+ body: { direction: 'vertical', vertical_spacing: '8px', padding: '12px 16px 12px 16px', elements },
474
+ };
475
+ }
476
+ /** The answer comes first. The folded process gets all the space that is left. */
477
+ finalCard() {
478
+ // Reserve room for the folded title, so its time and tool count stay visible even beside a long answer.
479
+ const minimal = this.steps.length ? [{ tag: 'markdown', element_id: 'omitted', text_size: 'notation', content: grey('过程较长,卡片里没有显示;完整过程在电脑上的这段 Harness 会话里。') }] : null;
480
+ let answer = this.answer, truncated = false;
481
+ const fits = (candidate, rows) => bytes(this.card({ answer: candidate, rows, truncated })) <= CARD_BYTES;
482
+ if (!fits(answer, minimal)) {
483
+ truncated = true;
484
+ // Cut between top-level blocks so what is shown stays valid Markdown.
485
+ const blocks = markdownBlocks(this.answer);
486
+ let low = 1, high = blocks.length - 1, best = 0;
487
+ while (low <= high) {
488
+ const middle = (low + high) >> 1;
489
+ if (fits(blocks.slice(0, middle).join(''), minimal)) {
490
+ best = middle;
491
+ low = middle + 1;
492
+ }
493
+ else
494
+ high = middle - 1;
495
+ }
496
+ if (best > 0)
497
+ answer = blocks.slice(0, best).join('');
498
+ else {
499
+ // One oversized block, such as a long code listing: cut it by size.
500
+ let budget = 20_000;
501
+ answer = head(this.answer, budget);
502
+ while (answer && !fits(answer, minimal))
503
+ answer = head(this.answer, budget = Math.floor(budget * 0.7));
504
+ }
505
+ }
506
+ if (minimal) {
507
+ let budget = CARD_BYTES - bytes(this.card({ answer, rows: [], truncated })) - 200;
508
+ while (budget > 300) {
509
+ const rows = this.rows(budget);
510
+ if (fits(answer, rows))
511
+ return this.card({ answer, rows, truncated });
512
+ budget = Math.floor(budget * 0.85);
513
+ }
514
+ }
515
+ return this.card({ answer, rows: minimal, truncated });
516
+ }
517
+ }
518
+ //# sourceMappingURL=live-reply.js.map
@@ -7,6 +7,14 @@ export declare function modelStateText(selection: ModelSelection, catalog: Model
7
7
  export declare function isModelQuestion(text: string): boolean;
8
8
  export declare function modelChoice(catalog: ModelCatalog, key: string): ModelSelection | undefined;
9
9
  export declare function thinkingChoice(catalog: ModelCatalog, selection: ModelSelection, key: string): ModelSelection | undefined;
10
+ /** The card's state: a page, a switch in flight (its choice key), a confirmed switch, a refusal, or a refresh notice. */
11
+ export interface ModelPanelState {
12
+ page?: number;
13
+ changed?: boolean;
14
+ pending?: string;
15
+ error?: string;
16
+ notice?: string;
17
+ }
10
18
  type Issue = (label: string, command: string, mutation: boolean, primary?: boolean) => ReplyAction | undefined;
11
- export declare function modelControlPanel(kind: 'model' | 'thinking', catalog: ModelCatalog, selection: ModelSelection, issue: Issue, page?: number, changed?: boolean): ReplyPanel;
19
+ export declare function modelControlPanel(kind: 'model' | 'thinking', catalog: ModelCatalog, selection: ModelSelection, issue: Issue, state?: ModelPanelState): ReplyPanel;
12
20
  export {};
@@ -21,23 +21,39 @@ export function thinkingChoice(catalog, selection, key) {
21
21
  const found = model?.reasoning?.efforts.filter(item => thinkingChoiceKey(item.id) === key);
22
22
  return found?.length === 1 ? { ...selection, reasoningEffort: found[0].id } : undefined;
23
23
  }
24
- export function modelControlPanel(kind, catalog, selection, issue, page = 0, changed = false) {
24
+ export function modelControlPanel(kind, catalog, selection, issue, state = {}) {
25
25
  const model = catalog.models.find(item => item.provider === selection.provider && item.model === selection.model);
26
26
  const actions = [];
27
- const add = (...args) => { const value = issue(...args); if (value)
28
- actions.push(value); return Boolean(value); };
29
- const summary = modelStateText(selection, catalog);
30
- const sections = [{ text: summary }];
27
+ const sections = [];
28
+ // While a switch runs nothing is clickable; otherwise the current choice is shown, not offered.
29
+ const add = (text, command, mutation, current = false, key) => {
30
+ if (state.pending || current) {
31
+ actions.push({ label: `${key !== undefined && key === state.pending ? '⏳ ' : current ? '✓ ' : ''}${text}`, value: {}, disabled: true });
32
+ return true;
33
+ }
34
+ const value = issue(text, command, mutation);
35
+ if (value)
36
+ actions.push(value);
37
+ return Boolean(value);
38
+ };
39
+ let pendingName;
40
+ const size = kind === 'model' ? 5 : 6;
31
41
  if (kind === 'model') {
32
- const pages = Math.max(1, Math.ceil(catalog.models.length / 5));
33
- const currentPage = Math.min(Math.max(0, page), pages - 1);
34
- for (const item of catalog.models.slice(currentPage * 5, currentPage * 5 + 5)) {
42
+ // Open on the page that holds the current (or pending) model unless a page was asked for.
43
+ const index = catalog.models.findIndex(item => state.pending ? modelChoiceKey(item) === state.pending
44
+ : item.provider === selection.provider && item.model === selection.model);
45
+ const pages = Math.max(1, Math.ceil(catalog.models.length / size));
46
+ const currentPage = Math.min(Math.max(0, state.page ?? Math.max(0, Math.floor(index / size))), pages - 1);
47
+ for (const item of catalog.models.slice(currentPage * size, currentPage * size + size)) {
35
48
  const current = item.provider === selection.provider && item.model === selection.model;
49
+ const key = modelChoiceKey(item);
50
+ if (key === state.pending)
51
+ pendingName = label(item.name || item.model);
36
52
  // Feishu keeps button text on one line on mobile. Put the provider first
37
53
  // so the distinguishing route remains visible when a long name truncates.
38
- const text = `${current ? '✓ ' : ''}${label(item.providerName || item.provider, 20)} · ${label(item.name || item.model, 32)}`;
39
- if (!add(text, `/model use ${modelChoiceKey(item)}`, true, current)) {
40
- sections.push({ title: label(item.name || item.model), text: `/model use ${modelChoiceKey(item)}` });
54
+ const text = `${label(item.providerName || item.provider, 20)} · ${label(item.name || item.model, 32)}`;
55
+ if (!add(text, `/model use ${key}`, true, current, key)) {
56
+ sections.push({ title: label(item.name || item.model), text: `/model use ${key}` });
41
57
  }
42
58
  }
43
59
  if (!catalog.models.length)
@@ -50,23 +66,32 @@ export function modelControlPanel(kind, catalog, selection, issue, page = 0, cha
50
66
  }
51
67
  else {
52
68
  const efforts = model?.reasoning?.efforts ?? [];
53
- const currentPage = Math.min(Math.max(0, page), Math.max(0, Math.ceil(efforts.length / 6) - 1));
54
- for (const effort of efforts.slice(currentPage * 6, currentPage * 6 + 6)) {
55
- const current = effort.id === selection.reasoningEffort;
56
- if (!add(`${current ? '✓ ' : ''}${label(effort.name || effort.id, 32)}`, `/thinking use ${thinkingChoiceKey(effort.id)}`, true, current)) {
57
- sections.push({ title: label(effort.name || effort.id), text: `/thinking use ${thinkingChoiceKey(effort.id)}` });
69
+ const index = efforts.findIndex(effort => state.pending ? thinkingChoiceKey(effort.id) === state.pending : effort.id === selection.reasoningEffort);
70
+ const currentPage = Math.min(Math.max(0, state.page ?? Math.max(0, Math.floor(index / size))), Math.max(0, Math.ceil(efforts.length / size) - 1));
71
+ for (const effort of efforts.slice(currentPage * size, currentPage * size + size)) {
72
+ const key = thinkingChoiceKey(effort.id);
73
+ if (key === state.pending)
74
+ pendingName = label(effort.name || effort.id);
75
+ if (!add(label(effort.name || effort.id, 32), `/thinking use ${key}`, true, effort.id === selection.reasoningEffort, key)) {
76
+ sections.push({ title: label(effort.name || effort.id), text: `/thinking use ${key}` });
58
77
  }
59
78
  }
60
79
  if (!model?.reasoning?.efforts.length)
61
80
  sections.push({ text: '这个模型没有提供可切换的思考档位,可以保留当前配置或选择其他模型。' });
62
81
  if (currentPage > 0)
63
82
  add('上一页', `/thinking page ${currentPage - 1}`, false);
64
- if ((currentPage + 1) * 6 < efforts.length)
83
+ if ((currentPage + 1) * size < efforts.length)
65
84
  add('下一页', `/thinking page ${currentPage + 1}`, false);
66
85
  add('选择模型', '/model', false);
67
86
  }
68
- return { title: changed ? '设置已更新' : kind === 'model' ? '选择模型' : '思考深度', summary,
69
- subtitle: '仅用于你在当前聊天中的对话', sections, actions, actionColumns: kind === 'model' ? 1 : 3,
70
- note: '下一条回复使用所选配置,已有对话保留。更换模型会使用新模型自身的默认思考档位。' };
87
+ const current = modelStateText(selection, catalog);
88
+ const status = state.error ? { text: state.error, tone: 'error' }
89
+ : state.pending ? { text: `正在切换到「${pendingName ?? '所选配置'}」…`, tone: 'pending' }
90
+ : state.changed ? { text: `已切换。${current}`, tone: 'success' }
91
+ : { text: state.notice ? `${state.notice}\n${current}` : current };
92
+ return { title: state.changed && !state.error ? '设置已更新' : kind === 'model' ? '选择模型' : '思考深度',
93
+ subtitle: '仅用于你在当前聊天中的对话', tone: status.tone ?? 'normal', status, summary: status.text,
94
+ sections, actions, actionColumns: kind === 'model' ? 1 : 3,
95
+ note: state.pending ? '完成后会在这张卡片上更新,无需重复点击。' : '下一条回复使用所选配置,已有对话保留。更换模型会使用新模型自身的默认思考档位。' };
71
96
  }
72
97
  //# sourceMappingURL=model-controls.js.map