dsh-lark-agent 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -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/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/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
|
|
@@ -5,9 +5,16 @@ export interface PermissionView {
|
|
|
5
5
|
options: PermissionOption[];
|
|
6
6
|
current?: string;
|
|
7
7
|
}
|
|
8
|
+
/** Where the card is: idle, switching to an option, switched, refused with a reason, or refreshed with a notice. */
|
|
9
|
+
export interface PanelState {
|
|
10
|
+
changed?: boolean;
|
|
11
|
+
pending?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
notice?: string;
|
|
14
|
+
}
|
|
8
15
|
/** Presets that let Harness act without asking get a second confirmation. */
|
|
9
16
|
export declare const unattended: (id: string) => boolean;
|
|
10
17
|
export declare function permissionStateText(view: PermissionView): string;
|
|
11
18
|
type Issue = (label: string, command: string, primary: boolean, confirm?: ReplyAction['confirm']) => ReplyAction | undefined;
|
|
12
|
-
export declare function permissionPanel(view: PermissionView, issue: Issue,
|
|
19
|
+
export declare function permissionPanel(view: PermissionView, issue: Issue, state?: PanelState): ReplyPanel;
|
|
13
20
|
export {};
|
|
@@ -3,24 +3,35 @@ export const unattended = (id) => /full|danger|auto/.test(id);
|
|
|
3
3
|
function currentName(view) {
|
|
4
4
|
if (!view.current)
|
|
5
5
|
return '跟随电脑上的默认设置';
|
|
6
|
-
return view
|
|
6
|
+
return optionName(view, view.current) ?? '自定义(在电脑上调整过)';
|
|
7
7
|
}
|
|
8
|
+
const optionName = (view, id) => view.options.find(option => option.id === id)?.name;
|
|
8
9
|
export function permissionStateText(view) {
|
|
9
10
|
return `当前权限模式:${currentName(view)}。\n可选:${view.options.map(option => option.name).join('、')}。只影响这个私聊里的对话。`;
|
|
10
11
|
}
|
|
11
|
-
export function permissionPanel(view, issue,
|
|
12
|
+
export function permissionPanel(view, issue, state = {}) {
|
|
12
13
|
const actions = [];
|
|
13
14
|
for (const option of view.options) {
|
|
14
15
|
const current = option.id === view.current;
|
|
15
|
-
|
|
16
|
+
// While a switch runs nothing is clickable; afterwards the current option is shown, not offered.
|
|
17
|
+
if (state.pending || current) {
|
|
18
|
+
actions.push({ label: `${option.id === state.pending ? '⏳ ' : current ? '✓ ' : ''}${option.name}`, value: {}, disabled: true });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const action = issue(option.name, `/permission use ${option.id}`, false, unattended(option.id) ? { title: `切换到「${option.name}」?`,
|
|
16
22
|
text: '之后 Harness 执行命令、修改文件都不再询问,在飞书发一句话就能直接操作这台电脑。随时可以切回。' } : undefined);
|
|
17
23
|
if (action)
|
|
18
24
|
actions.push(action);
|
|
19
25
|
}
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const pendingName = state.pending ? optionName(view, state.pending) ?? state.pending : undefined;
|
|
27
|
+
const status = state.error ? { text: state.error, tone: 'error' }
|
|
28
|
+
: pendingName ? { text: `正在切换到「${pendingName}」…`, tone: 'pending' }
|
|
29
|
+
: state.changed ? { text: `已切换到「${currentName(view)}」`, tone: 'success' }
|
|
30
|
+
: { text: state.notice ? `${state.notice}\n当前:${currentName(view)}` : `当前:${currentName(view)}` };
|
|
31
|
+
return { title: state.changed && !state.error ? '权限模式已更新' : '权限模式', subtitle: '只影响这个私聊里的对话',
|
|
32
|
+
tone: status.tone ?? 'normal', status, summary: status.text,
|
|
33
|
+
sections: view.options.filter(option => option.description).map(option => ({ title: option.name, text: option.description })),
|
|
23
34
|
actions, actionColumns: 1,
|
|
24
|
-
note: '需要确认的操作由这里的模式决定;电脑上其他会话和默认设置不受影响。' };
|
|
35
|
+
note: state.pending ? '完成后会在这张卡片上更新,无需重复点击。' : '需要确认的操作由这里的模式决定;电脑上其他会话和默认设置不受影响。' };
|
|
25
36
|
}
|
|
26
37
|
//# sourceMappingURL=permission-controls.js.map
|
package/dist/replies.d.ts
CHANGED
|
@@ -17,11 +17,21 @@ export interface ReplyAction {
|
|
|
17
17
|
title: string;
|
|
18
18
|
text: string;
|
|
19
19
|
};
|
|
20
|
+
/** Shown but not clickable, such as the current choice or every option while a switch runs. */
|
|
21
|
+
disabled?: boolean;
|
|
20
22
|
}
|
|
23
|
+
/** normal: blue; pending: a switch is running; success: green; error: red; muted: closed or expired. */
|
|
24
|
+
export type PanelTone = 'normal' | 'pending' | 'success' | 'error' | 'muted';
|
|
21
25
|
export interface ReplyPanel {
|
|
22
26
|
title: string;
|
|
23
27
|
summary: string;
|
|
24
28
|
subtitle?: string;
|
|
29
|
+
tone?: PanelTone;
|
|
30
|
+
/** One highlighted line under the header: the current choice, a running switch, or its result. */
|
|
31
|
+
status?: {
|
|
32
|
+
text: string;
|
|
33
|
+
tone?: PanelTone;
|
|
34
|
+
};
|
|
25
35
|
metrics?: {
|
|
26
36
|
label: string;
|
|
27
37
|
value: string;
|