dsh-lark-agent 0.2.0 → 0.3.1

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.
@@ -1,10 +1,10 @@
1
1
  import { cardMarkdown, markdownBlocks, plainLabel } from './replies.js';
2
2
  // CardKit rejects cards over 30 KB, measured on the card JSON.
3
3
  const CARD_BYTES = 28_000;
4
- const LIVE_STEP_BYTES = 6_000;
5
4
  const LIVE_ANSWER_BYTES = 20_000;
6
- const FINAL_STEP_BYTES = 8_000;
7
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
8
  // One card entity accepts at most 10 operations per second.
9
9
  const STREAM_INTERVAL_MS = 500;
10
10
  const FULL_INTERVAL_MS = 2_000;
@@ -14,6 +14,41 @@ const TRUNCATED = '回复超过了飞书卡片的长度上限,后面的内容
14
14
  const grey = (markdown) => `<font color='grey'>${markdown}</font>`;
15
15
  const bytes = (value) => Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
16
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
+ }
17
52
  /** The longest prefix of text within maxBytes, never splitting a code point. */
18
53
  function head(text, maxBytes) {
19
54
  let size = 0, end = 0;
@@ -51,10 +86,12 @@ function tail(text, maxBytes) {
51
86
  return text.slice(Math.min(cut, text.length));
52
87
  }
53
88
  /**
54
- * One streaming reply card: the answer, with reasoning and tool steps in a
55
- * panel that stays folded unless the reader opens it. Only the initial reply
56
- * is a send; every later operation updates the same card entity and is safe
57
- * to supersede or repeat. A reply never continues in another message.
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.
58
95
  */
59
96
  export class LiveReply {
60
97
  transport;
@@ -62,15 +99,18 @@ export class LiveReply {
62
99
  options;
63
100
  phase = 'thinking';
64
101
  steps = [];
102
+ nextStep = 0;
65
103
  trimmed = false;
66
104
  answer = '';
67
105
  separator = false;
68
106
  mark = { steps: 0, tail: -1, answer: 0 };
69
107
  tools = 0;
70
108
  note;
109
+ footer;
71
110
  cardId;
72
111
  sequence = 0;
73
112
  sent = new Map();
113
+ shown = [];
74
114
  dirty = false;
75
115
  layoutDirty = false;
76
116
  degraded = false;
@@ -90,15 +130,17 @@ export class LiveReply {
90
130
  }
91
131
  /** Send the card. False means nothing was sent, so the caller keeps its ordinary reply. */
92
132
  async open() {
133
+ let card;
93
134
  try {
94
- this.cardId = await this.transport.createCardEntity(this.card());
135
+ card = this.card();
136
+ this.cardId = await this.transport.createCardEntity(card);
95
137
  }
96
138
  catch {
97
139
  this.audit('live-card-unavailable');
98
140
  return false;
99
141
  }
100
142
  this.streamingSince = this.now();
101
- this.markSent();
143
+ this.markSent(card);
102
144
  try {
103
145
  await this.transport.replyCardEntity(this.target.messageId, this.cardId, { idempotencyKey: this.target.idempotencyKey, inThread: this.target.inThread });
104
146
  }
@@ -111,6 +153,14 @@ export class LiveReply {
111
153
  this.schedule();
112
154
  return true;
113
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
+ }
114
164
  push(event) {
115
165
  if (this.closed)
116
166
  return;
@@ -122,20 +172,30 @@ export class LiveReply {
122
172
  break;
123
173
  }
124
174
  case 'discard': {
175
+ if (this.steps.length > this.mark.steps)
176
+ this.layoutDirty = true;
125
177
  this.steps.length = Math.min(this.steps.length, this.mark.steps);
126
178
  const last = this.steps.at(-1);
127
179
  if (this.mark.tail >= 0 && last?.kind === 'reasoning')
128
180
  last.text = last.text.slice(0, this.mark.tail);
129
181
  this.answer = this.answer.slice(0, this.mark.answer);
130
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
+ }
131
189
  break;
132
190
  }
133
191
  case 'reasoning': {
134
192
  const last = this.steps.at(-1);
135
193
  if (last?.kind === 'reasoning')
136
194
  last.text += event.text;
137
- else
138
- this.steps.push({ kind: 'reasoning', text: event.text });
195
+ else {
196
+ this.steps.push({ id: this.nextStep++, kind: 'reasoning', text: event.text });
197
+ this.layoutDirty = true;
198
+ }
139
199
  this.trim();
140
200
  break;
141
201
  }
@@ -147,7 +207,7 @@ export class LiveReply {
147
207
  this.separator = false;
148
208
  }
149
209
  this.answer += event.text;
150
- // The folded panel's title switches from "thinking" to the time it took.
210
+ // The folded panel's title switches from the current step to the time it took.
151
211
  if (this.phase === 'thinking') {
152
212
  this.phase = 'answering';
153
213
  this.answeredAt = this.now();
@@ -157,7 +217,8 @@ export class LiveReply {
157
217
  case 'tool':
158
218
  if (this.steps.some(step => step.kind === 'tool' && step.callId === event.callId))
159
219
  return;
160
- this.steps.push({ kind: 'tool', callId: event.callId, name: event.name, ...(event.detail ? { detail: event.detail } : {}), state: 'running' });
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 } : {}) });
161
222
  this.tools++;
162
223
  this.layoutDirty = true;
163
224
  this.trim();
@@ -167,6 +228,10 @@ export class LiveReply {
167
228
  if (step?.kind !== 'tool')
168
229
  return;
169
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;
170
235
  break;
171
236
  }
172
237
  }
@@ -184,7 +249,7 @@ export class LiveReply {
184
249
  if (final.answer !== undefined)
185
250
  this.answer = final.answer;
186
251
  if (final.reasoning && !this.steps.some(step => step.kind === 'reasoning'))
187
- this.steps.unshift({ kind: 'reasoning', text: final.reasoning });
252
+ this.steps.unshift({ id: this.nextStep++, kind: 'reasoning', text: final.reasoning });
188
253
  for (const step of this.steps)
189
254
  if (step.kind === 'tool' && step.state === 'running')
190
255
  step.state = final.phase === 'done' ? 'done' : 'failed';
@@ -214,11 +279,12 @@ export class LiveReply {
214
279
  /** Keep only what the card can still show; the turn result stays authoritative. */
215
280
  trim() {
216
281
  let total = this.steps.reduce((sum, step) => sum + (step.kind === 'reasoning' ? step.text.length : 200), 0);
217
- while (total > MAX_KEPT_CHARS && this.steps.length > 1) {
282
+ while ((total > MAX_KEPT_CHARS || this.steps.length > MAX_ROWS * 2) && this.steps.length > 1) {
218
283
  const removed = this.steps.shift();
219
284
  total -= removed.kind === 'reasoning' ? removed.text.length : 200;
220
285
  this.mark.steps = Math.max(0, this.mark.steps - 1);
221
286
  this.trimmed = true;
287
+ this.layoutDirty = true;
222
288
  }
223
289
  const only = this.steps[0];
224
290
  if (this.steps.length === 1 && only?.kind === 'reasoning' && only.text.length > MAX_KEPT_CHARS) {
@@ -255,13 +321,17 @@ export class LiveReply {
255
321
  }
256
322
  async flush() {
257
323
  const cardId = this.cardId;
258
- if (this.degraded || this.layoutDirty || this.now() - this.streamingSince > REOPEN_AFTER_MS) {
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) {
259
329
  this.layoutDirty = false;
260
330
  try {
261
331
  // A complete card also turns streaming mode back on.
262
- await this.transport.updateCardEntity(cardId, this.card(), ++this.sequence);
332
+ await this.transport.updateCardEntity(cardId, card, ++this.sequence);
263
333
  this.streamingSince = this.now();
264
- this.markSent();
334
+ this.markSent(card);
265
335
  this.degraded = this.streamFailures >= 3;
266
336
  }
267
337
  catch {
@@ -269,7 +339,7 @@ export class LiveReply {
269
339
  }
270
340
  return;
271
341
  }
272
- for (const [id, content] of [['think', this.thinkContent()], ['answer', this.answerContent()]]) {
342
+ for (const [id, content] of elements) {
273
343
  if (this.closed)
274
344
  return;
275
345
  if (this.sent.get(id) === content)
@@ -286,20 +356,45 @@ export class LiveReply {
286
356
  }
287
357
  }
288
358
  }
289
- markSent() {
290
- this.sent.set('think', this.thinkContent());
291
- this.sent.set('answer', this.answerContent());
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;
292
373
  }
293
- stepsMarkdown(maxBytes) {
294
- const parts = this.steps.map(step => step.kind === 'reasoning' ? cardMarkdown(step.text.trim())
295
- : `${step.state === 'running' ? '⏳' : step.state === 'done' ? '✅' : '⚠️'} ${code(step.name)}${step.detail ? ' ' + code(step.detail) : ''}`)
296
- .filter(Boolean);
297
- const text = parts.join('\n\n');
298
- const shown = tail(text, maxBytes);
299
- return (this.trimmed || shown.length < text.length) && shown ? `${grey('…')}\n${shown}` : shown;
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);
300
380
  }
301
- thinkContent() {
302
- return this.stepsMarkdown(LIVE_STEP_BYTES) || grey('正在思考…');
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}` };
303
398
  }
304
399
  answerContent() {
305
400
  if (!this.answer)
@@ -308,34 +403,68 @@ export class LiveReply {
308
403
  return cardMarkdown(shown) + (shown.length < this.answer.length ? `\n\n${grey('……')}` : '');
309
404
  }
310
405
  panelTitle() {
311
- const tools = this.tools ? ` · 使用工具 ${this.tools} 次` : '';
312
- if (this.phase === 'thinking')
313
- return `💭 思考中…${tools}`;
314
- const seconds = Math.max(1, Math.round(((this.answeredAt ?? this.finishedAt ?? this.now()) - this.startedAt) / 1000));
315
- return `💭 思考了 ${seconds} 秒${tools}`;
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
+ }
316
421
  }
317
- /** The folded process panel. Readers open it when they want the reasoning and tool steps. */
318
- panel(content) {
422
+ /** The folded process panel. Readers open it when they want the thinking and tool steps. */
423
+ panel(rows) {
319
424
  return {
320
425
  tag: 'collapsible_panel', element_id: 'think_panel', expanded: false,
321
- header: { title: { tag: 'markdown', content: this.panelTitle() }, vertical_align: 'center',
322
- icon: { tag: 'standard_icon', token: 'down-small-ccm_outlined', size: '16px 16px' }, icon_position: 'follow_text', icon_expanded_angle: -180 },
323
- border: { color: 'grey', corner_radius: '5px' }, vertical_spacing: '8px', padding: '8px 8px 8px 8px',
324
- elements: [{ tag: 'markdown', element_id: 'think', content, text_size: 'notation' }],
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' }],
325
430
  };
326
431
  }
327
- /** Live layout when steps is undefined; a final layout otherwise ('' leaves out the panel). */
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). */
328
454
  card(final) {
329
455
  const elements = [];
330
456
  if (!final)
331
- elements.push(this.panel(this.thinkContent()));
332
- else if (final.steps)
333
- elements.push(this.panel(final.steps));
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));
334
460
  elements.push({ tag: 'markdown', element_id: 'answer',
335
461
  content: final ? (final.answer.trim() ? cardMarkdown(final.answer) : grey('(没有文字回复)')) : this.answerContent() });
336
462
  const note = [this.note, final?.truncated ? TRUNCATED : undefined].filter(Boolean).join(' ');
337
463
  if (final && note)
338
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)) });
339
468
  const summary = final ? plainLabel(final.answer || this.note || '回复', 60) || '回复' : this.answer ? '正在回复…' : '正在思考…';
340
469
  return {
341
470
  schema: '2.0',
@@ -344,12 +473,12 @@ export class LiveReply {
344
473
  body: { direction: 'vertical', vertical_spacing: '8px', padding: '12px 16px 12px 16px', elements },
345
474
  };
346
475
  }
347
- /** The answer comes first. The folded process only gets the space that is left. */
476
+ /** The answer comes first. The folded process gets all the space that is left. */
348
477
  finalCard() {
349
478
  // Reserve room for the folded title, so its time and tool count stay visible even beside a long answer.
350
- const minimal = this.steps.length ? grey('思考过程较长,卡片里没有显示。') : '';
479
+ const minimal = this.steps.length ? [{ tag: 'markdown', element_id: 'omitted', text_size: 'notation', content: grey('过程较长,卡片里没有显示;完整过程在电脑上的这段 Harness 会话里。') }] : null;
351
480
  let answer = this.answer, truncated = false;
352
- const fits = (candidate, steps) => bytes(this.card({ answer: candidate, steps, truncated })) <= CARD_BYTES;
481
+ const fits = (candidate, rows) => bytes(this.card({ answer: candidate, rows, truncated })) <= CARD_BYTES;
353
482
  if (!fits(answer, minimal)) {
354
483
  truncated = true;
355
484
  // Cut between top-level blocks so what is shown stays valid Markdown.
@@ -375,15 +504,15 @@ export class LiveReply {
375
504
  }
376
505
  }
377
506
  if (minimal) {
378
- let budget = Math.min(FINAL_STEP_BYTES, CARD_BYTES - bytes(this.card({ answer, steps: '', truncated })) - 800);
507
+ let budget = CARD_BYTES - bytes(this.card({ answer, rows: [], truncated })) - 200;
379
508
  while (budget > 300) {
380
- const steps = this.stepsMarkdown(budget);
381
- if (steps && fits(answer, steps))
382
- return this.card({ answer, steps, truncated });
383
- budget = Math.floor(budget * 0.7);
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);
384
513
  }
385
514
  }
386
- return this.card({ answer, steps: minimal, truncated });
515
+ return this.card({ answer, rows: minimal, truncated });
387
516
  }
388
517
  }
389
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
@@ -27,9 +27,13 @@ export declare function lockedConnectionControl(): ConnectionControl;
27
27
  export declare function registerConnectionRpc(rpc: RpcHost, controller: ConnectionControl): () => Promise<void>;
28
28
  export declare function registerConnectionRpc(rpc: RpcHost, controller: ConnectionControl, ownerRoutes?: OwnerFetchRoutes): (() => Promise<void>) | Promise<() => Promise<void>>;
29
29
  export declare function findLarkExecutable(explicit?: string): Promise<string>;
30
- /** Attach the control plane without requiring any user-written team configuration. */
30
+ /**
31
+ * Attach the control plane without requiring any user-written team configuration.
32
+ * `latestVersion` replaces the npm lookup behind the upgrade notice (tests use it to stay offline).
33
+ */
31
34
  export declare function applyOnboarding(ctx: Context, options?: {
32
35
  dataDir?: string;
33
36
  executable?: string;
37
+ latestVersion?: (signal: AbortSignal) => Promise<string | undefined>;
34
38
  }): Promise<void>;
35
39
  export {};
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { access } from 'node:fs/promises';
3
- import { constants } from 'node:fs';
3
+ import { constants, readFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { delimiter, join, resolve } from 'node:path';
6
6
  import { createBotService, BotServiceStartError } from '../bot-service.js';
@@ -9,6 +9,7 @@ import { acquireLock, LockHeldError } from '../lock.js';
9
9
  import { ConnectionController, SetupError } from './controller.js';
10
10
  import { ConnectionStore } from './store.js';
11
11
  import { LarkPlatform } from './lark-platform.js';
12
+ import { npmLatest, UpdateCheck } from './updates.js';
12
13
  const endpoints = new Set(['status', 'profiles', 'create', 'use-profile', 'recheck', 'cancel',
13
14
  'renew-pairing', 'confirm-owner', 'pause', 'resume', 'disconnect', 'enable-documents', 'cancel-documents']);
14
15
  const failure = (message) => ({ ok: false, error: { code: 'internal', message, details: {} } });
@@ -151,6 +152,21 @@ export async function findLarkExecutable(explicit) {
151
152
  }
152
153
  return 'lark-cli';
153
154
  }
155
+ /** This plugin's own package name and version, from the package it was installed from. */
156
+ function installedPackage() {
157
+ try {
158
+ const value = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
159
+ return typeof value.name === 'string' && typeof value.version === 'string' ? { name: value.name, version: value.version } : undefined;
160
+ }
161
+ catch {
162
+ return;
163
+ }
164
+ }
165
+ /** Every snapshot the page receives carries the upgrade notice once a newer release is known. */
166
+ function withUpdate(snapshot, updates) {
167
+ const update = updates.status();
168
+ return update ? { ...snapshot, update } : snapshot;
169
+ }
154
170
  function requireRuntime(ctx) {
155
171
  if (!checkRuntimeCompatibility(ctx).supported)
156
172
  throw new SetupError('当前 dsh 缺少所需的会话能力,请检查插件和持久化服务。', false);
@@ -185,7 +201,10 @@ async function waitReady(service, signal) {
185
201
  abort();
186
202
  });
187
203
  }
188
- /** Attach the control plane without requiring any user-written team configuration. */
204
+ /**
205
+ * Attach the control plane without requiring any user-written team configuration.
206
+ * `latestVersion` replaces the npm lookup behind the upgrade notice (tests use it to stay offline).
207
+ */
189
208
  export async function applyOnboarding(ctx, options = {}) {
190
209
  const connection = ctx.get('connection');
191
210
  if (!connection?.rpc)
@@ -240,9 +259,17 @@ export async function applyOnboarding(ctx, options = {}) {
240
259
  throw error;
241
260
  }
242
261
  }, });
262
+ const installed = installedPackage();
263
+ const updates = new UpdateCheck(installed?.version, options.latestVersion
264
+ ?? (signal => installed ? npmLatest(installed.name, signal) : Promise.resolve(undefined)));
265
+ const control = {
266
+ snapshot: () => withUpdate(controller.snapshot(), updates),
267
+ profiles: signal => controller.profiles(signal),
268
+ command: async (endpoint, payload) => withUpdate(await controller.command(endpoint, payload), updates),
269
+ };
243
270
  let remove;
244
271
  try {
245
- remove = await registerConnectionRpc(connection.rpc, controller, connection.fetch);
272
+ remove = await registerConnectionRpc(connection.rpc, control, connection.fetch);
246
273
  ctx.effect(() => {
247
274
  return async () => {
248
275
  await remove();
@@ -40,6 +40,11 @@ export interface ConnectionSnapshot {
40
40
  expiresAt: number;
41
41
  };
42
42
  };
43
+ /** A newer published release of this plugin, with the installed version. */
44
+ update?: {
45
+ current: string;
46
+ latest: string;
47
+ };
43
48
  }
44
49
  export interface ConnectionProfile {
45
50
  name: string;