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.
- package/README.md +8 -6
- package/dist/bot-menu.d.ts +2 -1
- package/dist/bot-menu.js +3 -2
- package/dist/bridge.d.ts +30 -1
- package/dist/bridge.js +269 -49
- package/dist/client/ConnectionCard.d.ts +6 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client.cjs +77 -7
- package/dist/dsh-runtime.d.ts +20 -2
- package/dist/dsh-runtime.js +113 -11
- package/dist/interaction-cards.d.ts +8 -3
- package/dist/interaction-cards.js +4 -4
- package/dist/live-reply.d.ts +23 -9
- package/dist/live-reply.js +183 -54
- package/dist/model-controls.d.ts +9 -1
- package/dist/model-controls.js +45 -20
- package/dist/onboarding/host.d.ts +5 -1
- package/dist/onboarding/host.js +30 -3
- package/dist/onboarding/protocol.d.ts +5 -0
- package/dist/onboarding/updates.d.ts +23 -0
- package/dist/onboarding/updates.js +67 -0
- package/dist/permission-controls.d.ts +8 -1
- package/dist/permission-controls.js +18 -7
- package/dist/replies.d.ts +10 -0
- package/dist/replies.js +18 -5
- package/package.json +7 -7
package/dist/live-reply.js
CHANGED
|
@@ -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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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 ? ` ·
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
318
|
-
panel(
|
|
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: '
|
|
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: '
|
|
324
|
-
elements: [{ tag: 'markdown', element_id: '
|
|
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
|
-
/**
|
|
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.
|
|
332
|
-
else if (final.
|
|
333
|
-
elements.push(this.panel(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));
|
|
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
|
|
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,
|
|
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 =
|
|
507
|
+
let budget = CARD_BYTES - bytes(this.card({ answer, rows: [], truncated })) - 200;
|
|
379
508
|
while (budget > 300) {
|
|
380
|
-
const
|
|
381
|
-
if (
|
|
382
|
-
return this.card({ answer,
|
|
383
|
-
budget = Math.floor(budget * 0.
|
|
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,
|
|
515
|
+
return this.card({ answer, rows: minimal, truncated });
|
|
387
516
|
}
|
|
388
517
|
}
|
|
389
518
|
//# sourceMappingURL=live-reply.js.map
|
package/dist/model-controls.d.ts
CHANGED
|
@@ -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,
|
|
19
|
+
export declare function modelControlPanel(kind: 'model' | 'thinking', catalog: ModelCatalog, selection: ModelSelection, issue: Issue, state?: ModelPanelState): ReplyPanel;
|
|
12
20
|
export {};
|
package/dist/model-controls.js
CHANGED
|
@@ -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,
|
|
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
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
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 = `${
|
|
39
|
-
if (!add(text, `/model use ${
|
|
40
|
-
sections.push({ title: label(item.name || item.model), text: `/model use ${
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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) *
|
|
83
|
+
if ((currentPage + 1) * size < efforts.length)
|
|
65
84
|
add('下一页', `/thinking page ${currentPage + 1}`, false);
|
|
66
85
|
add('选择模型', '/model', false);
|
|
67
86
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
/**
|
|
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 {};
|
package/dist/onboarding/host.js
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
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;
|