dsh-lark-agent 0.1.2 → 0.2.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 -2
- package/dist/bot-menu.d.ts +1 -0
- package/dist/bot-menu.js +2 -0
- package/dist/bot-service.js +4 -2
- package/dist/bridge.d.ts +42 -1
- package/dist/bridge.js +340 -18
- package/dist/card-actions.js +1 -1
- package/dist/client.cjs +3 -3
- package/dist/config.d.ts +8 -0
- package/dist/config.js +8 -2
- package/dist/dsh-runtime.d.ts +75 -2
- package/dist/dsh-runtime.js +346 -23
- package/dist/interaction-cards.d.ts +23 -0
- package/dist/interaction-cards.js +119 -0
- package/dist/lark-cli.d.ts +24 -0
- package/dist/lark-cli.js +92 -4
- package/dist/live-reply.d.ts +87 -0
- package/dist/live-reply.js +389 -0
- package/dist/onboarding/controller.js +9 -7
- package/dist/onboarding/lark-platform.js +5 -5
- package/dist/permission-controls.d.ts +13 -0
- package/dist/permission-controls.js +26 -0
- package/dist/permission-preferences.d.ts +26 -0
- package/dist/permission-preferences.js +169 -0
- package/dist/presentation.d.ts +7 -0
- package/dist/replies.d.ts +5 -0
- package/dist/replies.js +9 -1
- package/dist/rich-scopes.d.ts +4 -0
- package/dist/rich-scopes.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { cardMarkdown, markdownBlocks, plainLabel } from './replies.js';
|
|
2
|
+
// CardKit rejects cards over 30 KB, measured on the card JSON.
|
|
3
|
+
const CARD_BYTES = 28_000;
|
|
4
|
+
const LIVE_STEP_BYTES = 6_000;
|
|
5
|
+
const LIVE_ANSWER_BYTES = 20_000;
|
|
6
|
+
const FINAL_STEP_BYTES = 8_000;
|
|
7
|
+
const MAX_KEPT_CHARS = 40_000;
|
|
8
|
+
// One card entity accepts at most 10 operations per second.
|
|
9
|
+
const STREAM_INTERVAL_MS = 500;
|
|
10
|
+
const FULL_INTERVAL_MS = 2_000;
|
|
11
|
+
// Feishu turns streaming mode off 10 minutes after it was last turned on.
|
|
12
|
+
const REOPEN_AFTER_MS = 9 * 60_000;
|
|
13
|
+
const TRUNCATED = '回复超过了飞书卡片的长度上限,后面的内容没有显示。完整回复在电脑上的这段 Harness 会话里,也可以让我整理成文档。';
|
|
14
|
+
const grey = (markdown) => `<font color='grey'>${markdown}</font>`;
|
|
15
|
+
const bytes = (value) => Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
|
|
16
|
+
const code = (value) => '`' + value.replace(/`/g, 'ˋ') + '`';
|
|
17
|
+
/** The longest prefix of text within maxBytes, never splitting a code point. */
|
|
18
|
+
function head(text, maxBytes) {
|
|
19
|
+
let size = 0, end = 0;
|
|
20
|
+
for (const point of text) {
|
|
21
|
+
size += Buffer.byteLength(point, 'utf8');
|
|
22
|
+
if (size > maxBytes)
|
|
23
|
+
break;
|
|
24
|
+
end += point.length;
|
|
25
|
+
}
|
|
26
|
+
return text.slice(0, end);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A suffix of text within maxBytes. The cut moves in coarse steps so that
|
|
30
|
+
* successive updates mostly share a prefix, which keeps Feishu's typewriter
|
|
31
|
+
* effect instead of redrawing the whole element.
|
|
32
|
+
*/
|
|
33
|
+
function tail(text, maxBytes) {
|
|
34
|
+
if (bytes(text) <= maxBytes)
|
|
35
|
+
return text;
|
|
36
|
+
let size = 0, start = text.length;
|
|
37
|
+
while (start > 0) {
|
|
38
|
+
const pair = start >= 2 && (text.charCodeAt(start - 1) & 0xfc00) === 0xdc00 && (text.charCodeAt(start - 2) & 0xfc00) === 0xd800;
|
|
39
|
+
const width = Buffer.byteLength(text.slice(start - (pair ? 2 : 1), start), 'utf8');
|
|
40
|
+
if (size + width > maxBytes)
|
|
41
|
+
break;
|
|
42
|
+
size += width;
|
|
43
|
+
start -= pair ? 2 : 1;
|
|
44
|
+
}
|
|
45
|
+
let cut = Math.ceil(start / 1_000) * 1_000;
|
|
46
|
+
const newline = text.indexOf('\n', cut);
|
|
47
|
+
if (newline >= 0 && newline - cut < 200)
|
|
48
|
+
cut = newline + 1;
|
|
49
|
+
if (cut < text.length && (text.charCodeAt(cut) & 0xfc00) === 0xdc00)
|
|
50
|
+
cut++;
|
|
51
|
+
return text.slice(Math.min(cut, text.length));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
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.
|
|
58
|
+
*/
|
|
59
|
+
export class LiveReply {
|
|
60
|
+
transport;
|
|
61
|
+
target;
|
|
62
|
+
options;
|
|
63
|
+
phase = 'thinking';
|
|
64
|
+
steps = [];
|
|
65
|
+
trimmed = false;
|
|
66
|
+
answer = '';
|
|
67
|
+
separator = false;
|
|
68
|
+
mark = { steps: 0, tail: -1, answer: 0 };
|
|
69
|
+
tools = 0;
|
|
70
|
+
note;
|
|
71
|
+
cardId;
|
|
72
|
+
sequence = 0;
|
|
73
|
+
sent = new Map();
|
|
74
|
+
dirty = false;
|
|
75
|
+
layoutDirty = false;
|
|
76
|
+
degraded = false;
|
|
77
|
+
streamFailures = 0;
|
|
78
|
+
closed = false;
|
|
79
|
+
loop;
|
|
80
|
+
wake;
|
|
81
|
+
streamingSince = 0;
|
|
82
|
+
startedAt;
|
|
83
|
+
answeredAt;
|
|
84
|
+
finishedAt;
|
|
85
|
+
constructor(transport, target, options = {}) {
|
|
86
|
+
this.transport = transport;
|
|
87
|
+
this.target = target;
|
|
88
|
+
this.options = options;
|
|
89
|
+
this.startedAt = this.now();
|
|
90
|
+
}
|
|
91
|
+
/** Send the card. False means nothing was sent, so the caller keeps its ordinary reply. */
|
|
92
|
+
async open() {
|
|
93
|
+
try {
|
|
94
|
+
this.cardId = await this.transport.createCardEntity(this.card());
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
this.audit('live-card-unavailable');
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
this.streamingSince = this.now();
|
|
101
|
+
this.markSent();
|
|
102
|
+
try {
|
|
103
|
+
await this.transport.replyCardEntity(this.target.messageId, this.cardId, { idempotencyKey: this.target.idempotencyKey, inThread: this.target.inThread });
|
|
104
|
+
}
|
|
105
|
+
// The reply may still have been delivered. Never send another message for this
|
|
106
|
+
// turn; updating a card nobody received is harmless.
|
|
107
|
+
catch {
|
|
108
|
+
this.audit('live-card-delivery-unknown');
|
|
109
|
+
}
|
|
110
|
+
if (this.dirty)
|
|
111
|
+
this.schedule();
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
push(event) {
|
|
115
|
+
if (this.closed)
|
|
116
|
+
return;
|
|
117
|
+
switch (event.type) {
|
|
118
|
+
case 'attempt': {
|
|
119
|
+
const last = this.steps.at(-1);
|
|
120
|
+
this.mark = { steps: this.steps.length, tail: last?.kind === 'reasoning' ? last.text.length : -1, answer: this.answer.length };
|
|
121
|
+
this.separator = this.answer.length > 0;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'discard': {
|
|
125
|
+
this.steps.length = Math.min(this.steps.length, this.mark.steps);
|
|
126
|
+
const last = this.steps.at(-1);
|
|
127
|
+
if (this.mark.tail >= 0 && last?.kind === 'reasoning')
|
|
128
|
+
last.text = last.text.slice(0, this.mark.tail);
|
|
129
|
+
this.answer = this.answer.slice(0, this.mark.answer);
|
|
130
|
+
this.separator = this.answer.length > 0;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'reasoning': {
|
|
134
|
+
const last = this.steps.at(-1);
|
|
135
|
+
if (last?.kind === 'reasoning')
|
|
136
|
+
last.text += event.text;
|
|
137
|
+
else
|
|
138
|
+
this.steps.push({ kind: 'reasoning', text: event.text });
|
|
139
|
+
this.trim();
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case 'text':
|
|
143
|
+
if (this.answer.length > 70_000)
|
|
144
|
+
return;
|
|
145
|
+
if (this.separator) {
|
|
146
|
+
this.answer += '\n\n';
|
|
147
|
+
this.separator = false;
|
|
148
|
+
}
|
|
149
|
+
this.answer += event.text;
|
|
150
|
+
// The folded panel's title switches from "thinking" to the time it took.
|
|
151
|
+
if (this.phase === 'thinking') {
|
|
152
|
+
this.phase = 'answering';
|
|
153
|
+
this.answeredAt = this.now();
|
|
154
|
+
this.layoutDirty = true;
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
case 'tool':
|
|
158
|
+
if (this.steps.some(step => step.kind === 'tool' && step.callId === event.callId))
|
|
159
|
+
return;
|
|
160
|
+
this.steps.push({ kind: 'tool', callId: event.callId, name: event.name, ...(event.detail ? { detail: event.detail } : {}), state: 'running' });
|
|
161
|
+
this.tools++;
|
|
162
|
+
this.layoutDirty = true;
|
|
163
|
+
this.trim();
|
|
164
|
+
break;
|
|
165
|
+
case 'tool-done': {
|
|
166
|
+
const step = this.steps.find(item => item.kind === 'tool' && item.callId === event.callId);
|
|
167
|
+
if (step?.kind !== 'tool')
|
|
168
|
+
return;
|
|
169
|
+
step.state = event.ok ? 'done' : 'failed';
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this.schedule();
|
|
174
|
+
}
|
|
175
|
+
/** Stop streaming and write the final card. True only when Feishu confirmed it. */
|
|
176
|
+
async finish(final) {
|
|
177
|
+
if (this.closed)
|
|
178
|
+
return false;
|
|
179
|
+
this.closed = true;
|
|
180
|
+
this.wake?.();
|
|
181
|
+
await this.loop;
|
|
182
|
+
this.finishedAt = this.now();
|
|
183
|
+
this.phase = final.phase;
|
|
184
|
+
if (final.answer !== undefined)
|
|
185
|
+
this.answer = final.answer;
|
|
186
|
+
if (final.reasoning && !this.steps.some(step => step.kind === 'reasoning'))
|
|
187
|
+
this.steps.unshift({ kind: 'reasoning', text: final.reasoning });
|
|
188
|
+
for (const step of this.steps)
|
|
189
|
+
if (step.kind === 'tool' && step.state === 'running')
|
|
190
|
+
step.state = final.phase === 'done' ? 'done' : 'failed';
|
|
191
|
+
this.note = final.note;
|
|
192
|
+
const card = this.finalCard();
|
|
193
|
+
if (!this.cardId)
|
|
194
|
+
return false;
|
|
195
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
196
|
+
try {
|
|
197
|
+
await this.transport.updateCardEntity(this.cardId, card, ++this.sequence);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
if (attempt < 2)
|
|
202
|
+
await this.sleep(500 * 3 ** attempt);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
this.audit('live-card-final-failed');
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
now() { return (this.options.now ?? Date.now)(); }
|
|
209
|
+
sleep(ms) { return (this.options.sleep ?? (wait => new Promise(done => setTimeout(done, wait))))(ms); }
|
|
210
|
+
audit(stage) { try {
|
|
211
|
+
this.options.audit?.(stage);
|
|
212
|
+
}
|
|
213
|
+
catch { /* Diagnostics only. */ } }
|
|
214
|
+
/** Keep only what the card can still show; the turn result stays authoritative. */
|
|
215
|
+
trim() {
|
|
216
|
+
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) {
|
|
218
|
+
const removed = this.steps.shift();
|
|
219
|
+
total -= removed.kind === 'reasoning' ? removed.text.length : 200;
|
|
220
|
+
this.mark.steps = Math.max(0, this.mark.steps - 1);
|
|
221
|
+
this.trimmed = true;
|
|
222
|
+
}
|
|
223
|
+
const only = this.steps[0];
|
|
224
|
+
if (this.steps.length === 1 && only?.kind === 'reasoning' && only.text.length > MAX_KEPT_CHARS) {
|
|
225
|
+
const cut = only.text.length - MAX_KEPT_CHARS / 2;
|
|
226
|
+
only.text = only.text.slice(cut);
|
|
227
|
+
this.mark.tail = Math.max(-1, this.mark.tail - cut);
|
|
228
|
+
this.trimmed = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
schedule() {
|
|
232
|
+
this.dirty = true;
|
|
233
|
+
if (!this.cardId || this.closed || this.loop)
|
|
234
|
+
return;
|
|
235
|
+
this.loop = this.run().finally(() => {
|
|
236
|
+
this.loop = undefined;
|
|
237
|
+
if (this.dirty && !this.closed)
|
|
238
|
+
this.schedule();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async run() {
|
|
242
|
+
while (this.dirty && !this.closed) {
|
|
243
|
+
this.dirty = false;
|
|
244
|
+
const started = this.now();
|
|
245
|
+
await this.flush();
|
|
246
|
+
const wait = (this.degraded ? FULL_INTERVAL_MS : STREAM_INTERVAL_MS) - (this.now() - started);
|
|
247
|
+
if (wait > 0 && !this.closed)
|
|
248
|
+
await this.pause(wait);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Pacing between updates; finishing the reply ends it early. */
|
|
252
|
+
async pause(ms) {
|
|
253
|
+
await Promise.race([this.sleep(ms), new Promise(done => { this.wake = done; })]);
|
|
254
|
+
this.wake = undefined;
|
|
255
|
+
}
|
|
256
|
+
async flush() {
|
|
257
|
+
const cardId = this.cardId;
|
|
258
|
+
if (this.degraded || this.layoutDirty || this.now() - this.streamingSince > REOPEN_AFTER_MS) {
|
|
259
|
+
this.layoutDirty = false;
|
|
260
|
+
try {
|
|
261
|
+
// A complete card also turns streaming mode back on.
|
|
262
|
+
await this.transport.updateCardEntity(cardId, this.card(), ++this.sequence);
|
|
263
|
+
this.streamingSince = this.now();
|
|
264
|
+
this.markSent();
|
|
265
|
+
this.degraded = this.streamFailures >= 3;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
this.degraded = true;
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
for (const [id, content] of [['think', this.thinkContent()], ['answer', this.answerContent()]]) {
|
|
273
|
+
if (this.closed)
|
|
274
|
+
return;
|
|
275
|
+
if (this.sent.get(id) === content)
|
|
276
|
+
continue;
|
|
277
|
+
try {
|
|
278
|
+
await this.transport.streamCardText(cardId, id, content, ++this.sequence);
|
|
279
|
+
this.sent.set(id, content);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
this.streamFailures++;
|
|
283
|
+
this.degraded = true;
|
|
284
|
+
this.audit('live-card-degraded');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
markSent() {
|
|
290
|
+
this.sent.set('think', this.thinkContent());
|
|
291
|
+
this.sent.set('answer', this.answerContent());
|
|
292
|
+
}
|
|
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;
|
|
300
|
+
}
|
|
301
|
+
thinkContent() {
|
|
302
|
+
return this.stepsMarkdown(LIVE_STEP_BYTES) || grey('正在思考…');
|
|
303
|
+
}
|
|
304
|
+
answerContent() {
|
|
305
|
+
if (!this.answer)
|
|
306
|
+
return grey('…');
|
|
307
|
+
const shown = head(this.answer, LIVE_ANSWER_BYTES);
|
|
308
|
+
return cardMarkdown(shown) + (shown.length < this.answer.length ? `\n\n${grey('……')}` : '');
|
|
309
|
+
}
|
|
310
|
+
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}`;
|
|
316
|
+
}
|
|
317
|
+
/** The folded process panel. Readers open it when they want the reasoning and tool steps. */
|
|
318
|
+
panel(content) {
|
|
319
|
+
return {
|
|
320
|
+
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' }],
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** Live layout when steps is undefined; a final layout otherwise ('' leaves out the panel). */
|
|
328
|
+
card(final) {
|
|
329
|
+
const elements = [];
|
|
330
|
+
if (!final)
|
|
331
|
+
elements.push(this.panel(this.thinkContent()));
|
|
332
|
+
else if (final.steps)
|
|
333
|
+
elements.push(this.panel(final.steps));
|
|
334
|
+
elements.push({ tag: 'markdown', element_id: 'answer',
|
|
335
|
+
content: final ? (final.answer.trim() ? cardMarkdown(final.answer) : grey('(没有文字回复)')) : this.answerContent() });
|
|
336
|
+
const note = [this.note, final?.truncated ? TRUNCATED : undefined].filter(Boolean).join(' ');
|
|
337
|
+
if (final && note)
|
|
338
|
+
elements.push({ tag: 'markdown', element_id: 'note', content: grey(cardMarkdown(note)), text_size: 'notation' });
|
|
339
|
+
const summary = final ? plainLabel(final.answer || this.note || '回复', 60) || '回复' : this.answer ? '正在回复…' : '正在思考…';
|
|
340
|
+
return {
|
|
341
|
+
schema: '2.0',
|
|
342
|
+
config: { update_multi: true, enable_forward: false, width_mode: 'default', summary: { content: summary }, streaming_mode: !final,
|
|
343
|
+
...(final ? {} : { streaming_config: { print_frequency_ms: { default: 30 }, print_step: { default: 2 }, print_strategy: 'fast' } }) },
|
|
344
|
+
body: { direction: 'vertical', vertical_spacing: '8px', padding: '12px 16px 12px 16px', elements },
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** The answer comes first. The folded process only gets the space that is left. */
|
|
348
|
+
finalCard() {
|
|
349
|
+
// 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('思考过程较长,卡片里没有显示。') : '';
|
|
351
|
+
let answer = this.answer, truncated = false;
|
|
352
|
+
const fits = (candidate, steps) => bytes(this.card({ answer: candidate, steps, truncated })) <= CARD_BYTES;
|
|
353
|
+
if (!fits(answer, minimal)) {
|
|
354
|
+
truncated = true;
|
|
355
|
+
// Cut between top-level blocks so what is shown stays valid Markdown.
|
|
356
|
+
const blocks = markdownBlocks(this.answer);
|
|
357
|
+
let low = 1, high = blocks.length - 1, best = 0;
|
|
358
|
+
while (low <= high) {
|
|
359
|
+
const middle = (low + high) >> 1;
|
|
360
|
+
if (fits(blocks.slice(0, middle).join(''), minimal)) {
|
|
361
|
+
best = middle;
|
|
362
|
+
low = middle + 1;
|
|
363
|
+
}
|
|
364
|
+
else
|
|
365
|
+
high = middle - 1;
|
|
366
|
+
}
|
|
367
|
+
if (best > 0)
|
|
368
|
+
answer = blocks.slice(0, best).join('');
|
|
369
|
+
else {
|
|
370
|
+
// One oversized block, such as a long code listing: cut it by size.
|
|
371
|
+
let budget = 20_000;
|
|
372
|
+
answer = head(this.answer, budget);
|
|
373
|
+
while (answer && !fits(answer, minimal))
|
|
374
|
+
answer = head(this.answer, budget = Math.floor(budget * 0.7));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (minimal) {
|
|
378
|
+
let budget = Math.min(FINAL_STEP_BYTES, CARD_BYTES - bytes(this.card({ answer, steps: '', truncated })) - 800);
|
|
379
|
+
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);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return this.card({ answer, steps: minimal, truncated });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
//# sourceMappingURL=live-reply.js.map
|
|
@@ -8,7 +8,7 @@ import { LarkCli } from '../lark-cli.js';
|
|
|
8
8
|
import { LockHeldError } from '../lock.js';
|
|
9
9
|
import { BOT_SCOPES, CARD_SCOPE, LarkPlatformError } from './lark-platform.js';
|
|
10
10
|
import { createChallenge, PairingError, waitForPairing } from './pairing.js';
|
|
11
|
-
import {
|
|
11
|
+
import { defaultScopesReady } from '../rich-scopes.js';
|
|
12
12
|
const restoreDelay = (attempt) => Math.min(10 * 60_000, 5_000 * 2 ** Math.min(attempt, 10));
|
|
13
13
|
const messages = {
|
|
14
14
|
idle: '把助手带到飞书,先用飞书确认创建。',
|
|
@@ -167,7 +167,7 @@ export class ConnectionController {
|
|
|
167
167
|
this.documentJob = this.deps.platform.inspect(record.profile, aborter.signal).then(inspection => {
|
|
168
168
|
if (!aborter.signal.aborted && !this.closed && this.record?.appId === record.appId) {
|
|
169
169
|
if (inspection.appId === record.appId && (!record.botOpenId || inspection.botOpenId === record.botOpenId)) {
|
|
170
|
-
this.documentsReady = inspection.scopeStatus === 'verified' &&
|
|
170
|
+
this.documentsReady = inspection.scopeStatus === 'verified' && defaultScopesReady(inspection.grantedScopes);
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
}).catch(() => { })
|
|
@@ -202,13 +202,13 @@ export class ConnectionController {
|
|
|
202
202
|
if (this.documentJob)
|
|
203
203
|
return this.snapshot();
|
|
204
204
|
const record = this.record, aborter = this.documentAborter = new AbortController();
|
|
205
|
-
this.documentSetup = { phase: 'preparing', message: '
|
|
205
|
+
this.documentSetup = { phase: 'preparing', message: '正在准备默认权限授权,聊天连接继续保持。' };
|
|
206
206
|
this.documentJob = this.deps.platform.enableDocuments(record.profile, info => {
|
|
207
207
|
const url = officialDestination(info.url), expiresAt = Date.now() + info.expireIn * 1000;
|
|
208
208
|
void (this.deps.qr ?? (url => QRCode.toDataURL(url, { width: 256, margin: 2 })))(url).then(image => {
|
|
209
209
|
if (aborter.signal.aborted || this.closed || this.documentAborter !== aborter)
|
|
210
210
|
return;
|
|
211
|
-
this.documentSetup = { phase: 'scanning', message: '
|
|
211
|
+
this.documentSetup = { phase: 'scanning', message: '用飞书扫码补充实时回复、文档与画板权限,无需重新创建或配对机器人。', qr: { url, image, expiresAt } };
|
|
212
212
|
}).catch(() => { if (!aborter.signal.aborted) {
|
|
213
213
|
this.documentSetup = { phase: 'error', message: '二维码未能生成,请重试。' };
|
|
214
214
|
aborter.abort();
|
|
@@ -352,7 +352,7 @@ export class ConnectionController {
|
|
|
352
352
|
this.assertCurrent(signal);
|
|
353
353
|
let record = this.record;
|
|
354
354
|
let inspection = await this.deps.platform.inspect(record.profile, signal);
|
|
355
|
-
this.documentsReady = inspection.scopeStatus === 'verified' &&
|
|
355
|
+
this.documentsReady = inspection.scopeStatus === 'verified' && defaultScopesReady(inspection.grantedScopes);
|
|
356
356
|
this.assertCurrent(signal);
|
|
357
357
|
if (inspection.appId !== record.appId || (record.botOpenId && inspection.botOpenId && record.botOpenId !== inspection.botOpenId)) {
|
|
358
358
|
throw new SetupError('机器人身份发生变化,请断开后重新绑定。', false);
|
|
@@ -442,8 +442,10 @@ export class ConnectionController {
|
|
|
442
442
|
const cwd = join(directory, 'workspace');
|
|
443
443
|
await mkdir(cwd, { recursive: true, mode: 0o700 });
|
|
444
444
|
return parseConfig({ profile: record.profile, botOpenId: record.botOpenId, stateDir: join(directory, 'state'),
|
|
445
|
-
executable: this.deps.executable ?? 'lark-cli', users: [{ openId: record.ownerOpenId, direct: true,
|
|
446
|
-
groupChats: [], cwd, allowedTools: [] }], cardInteractions: record.cards, loadingReaction: record.loading, botMenu: this.menuReady
|
|
445
|
+
executable: this.deps.executable ?? 'lark-cli', users: [{ openId: record.ownerOpenId, direct: true, directMode: 'harness',
|
|
446
|
+
groupChats: [], cwd, allowedTools: [] }], cardInteractions: record.cards, loadingReaction: record.loading, botMenu: this.menuReady,
|
|
447
|
+
// Without the CardKit scope a live card cannot be created and replies fall back to ordinary messages.
|
|
448
|
+
streamingCards: true }, directory);
|
|
447
449
|
}
|
|
448
450
|
async bind(message, signal) {
|
|
449
451
|
this.assertCurrent(signal);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { BOT_MENU_EVENT } from '../bot-menu.js';
|
|
3
|
-
import { DOCUMENT_SCOPES,
|
|
3
|
+
import { DOCUMENT_SCOPES, STREAMING_SCOPE, defaultScopesReady } from '../rich-scopes.js';
|
|
4
4
|
export class LarkPlatformError extends Error {
|
|
5
5
|
code;
|
|
6
6
|
constructor(code) {
|
|
@@ -197,7 +197,7 @@ export class LarkPlatform {
|
|
|
197
197
|
appPreset: { name: request.name, desc: request.description ?? '在飞书中与 dsh 个人助手对话' },
|
|
198
198
|
addons: {
|
|
199
199
|
preset: false,
|
|
200
|
-
scopes: { tenant: [...new Set([...BOT_SCOPES, ...SETUP_SCOPES, ...DOCUMENT_SCOPES, ...(request.cardInteractions ? [CARD_SCOPE] : []),
|
|
200
|
+
scopes: { tenant: [...new Set([...BOT_SCOPES, ...SETUP_SCOPES, ...DOCUMENT_SCOPES, STREAMING_SCOPE, ...(request.cardInteractions ? [CARD_SCOPE] : []),
|
|
201
201
|
...(request.loadingReaction ? [REACTION_SCOPE] : [])])] },
|
|
202
202
|
events: { items: { tenant: ['im.message.receive_v1', BOT_MENU_EVENT] } },
|
|
203
203
|
...(request.cardInteractions ? { callbacks: { items: ['card.action.trigger'] } } : {}),
|
|
@@ -250,10 +250,10 @@ export class LarkPlatform {
|
|
|
250
250
|
if (current.appId !== selected.appId)
|
|
251
251
|
fail('APP_MISMATCH');
|
|
252
252
|
// Another host may already have completed the upgrade; never ask for another scan just to refresh UI state.
|
|
253
|
-
if (current.scopeStatus === 'verified' &&
|
|
253
|
+
if (current.scopeStatus === 'verified' && defaultScopesReady(current.grantedScopes))
|
|
254
254
|
return;
|
|
255
255
|
const result = await this.registrar({ source: 'dsh-larkbot', appId: selected.appId, signal,
|
|
256
|
-
addons: { preset: false, scopes: { tenant: [...DOCUMENT_SCOPES] } },
|
|
256
|
+
addons: { preset: false, scopes: { tenant: [...DOCUMENT_SCOPES, STREAMING_SCOPE] } },
|
|
257
257
|
onQRCodeReady: info => {
|
|
258
258
|
checkSignal(signal);
|
|
259
259
|
let url;
|
|
@@ -274,7 +274,7 @@ export class LarkPlatform {
|
|
|
274
274
|
if (result.client_id !== selected.appId || (result.user_info?.tenant_brand && result.user_info.tenant_brand !== selected.brand))
|
|
275
275
|
fail('APP_MISMATCH');
|
|
276
276
|
const inspection = await this.inspect(profile, signal);
|
|
277
|
-
if (inspection.appId !== selected.appId || inspection.scopeStatus !== 'verified' || !
|
|
277
|
+
if (inspection.appId !== selected.appId || inspection.scopeStatus !== 'verified' || !defaultScopesReady(inspection.grantedScopes))
|
|
278
278
|
fail('MISSING_SCOPE');
|
|
279
279
|
}
|
|
280
280
|
catch (error) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PermissionOption } from './dsh-runtime.js';
|
|
2
|
+
import type { ReplyAction, ReplyPanel } from './replies.js';
|
|
3
|
+
/** What the chat shows: the effective preset when a session is live, else the chat's saved choice. */
|
|
4
|
+
export interface PermissionView {
|
|
5
|
+
options: PermissionOption[];
|
|
6
|
+
current?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Presets that let Harness act without asking get a second confirmation. */
|
|
9
|
+
export declare const unattended: (id: string) => boolean;
|
|
10
|
+
export declare function permissionStateText(view: PermissionView): string;
|
|
11
|
+
type Issue = (label: string, command: string, primary: boolean, confirm?: ReplyAction['confirm']) => ReplyAction | undefined;
|
|
12
|
+
export declare function permissionPanel(view: PermissionView, issue: Issue, changed?: boolean): ReplyPanel;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Presets that let Harness act without asking get a second confirmation. */
|
|
2
|
+
export const unattended = (id) => /full|danger|auto/.test(id);
|
|
3
|
+
function currentName(view) {
|
|
4
|
+
if (!view.current)
|
|
5
|
+
return '跟随电脑上的默认设置';
|
|
6
|
+
return view.options.find(option => option.id === view.current)?.name ?? '自定义(在电脑上调整过)';
|
|
7
|
+
}
|
|
8
|
+
export function permissionStateText(view) {
|
|
9
|
+
return `当前权限模式:${currentName(view)}。\n可选:${view.options.map(option => option.name).join('、')}。只影响这个私聊里的对话。`;
|
|
10
|
+
}
|
|
11
|
+
export function permissionPanel(view, issue, changed = false) {
|
|
12
|
+
const actions = [];
|
|
13
|
+
for (const option of view.options) {
|
|
14
|
+
const current = option.id === view.current;
|
|
15
|
+
const action = issue(`${current ? '✓ ' : ''}${option.name}`, `/permission use ${option.id}`, current, !current && unattended(option.id) ? { title: `切换到「${option.name}」?`,
|
|
16
|
+
text: '之后 Harness 执行命令、修改文件都不再询问,在飞书发一句话就能直接操作这台电脑。随时可以切回。' } : undefined);
|
|
17
|
+
if (action)
|
|
18
|
+
actions.push(action);
|
|
19
|
+
}
|
|
20
|
+
const summary = `当前:${currentName(view)}`;
|
|
21
|
+
return { title: changed ? '权限模式已更新' : '权限模式', subtitle: '只影响这个私聊里的对话', summary,
|
|
22
|
+
sections: [{ text: summary }, ...view.options.filter(option => option.description).map(option => ({ title: option.name, text: option.description }))],
|
|
23
|
+
actions, actionColumns: 1,
|
|
24
|
+
note: '需要确认的操作由这里的模式决定;电脑上其他会话和默认设置不受影响。' };
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=permission-controls.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Both keys must be derived by the bridge from the verified sender and chat. */
|
|
2
|
+
export interface PermissionPreferenceContext {
|
|
3
|
+
ownerKey: string;
|
|
4
|
+
conversationKey: string;
|
|
5
|
+
}
|
|
6
|
+
/** Safe for chat replies: no identity keys, paths or I/O details. */
|
|
7
|
+
export declare class PermissionPreferencesError extends Error {
|
|
8
|
+
readonly code: 'INVALID_INPUT' | 'CAPACITY' | 'CORRUPT' | 'WRITE_FAILED' | 'UNAVAILABLE';
|
|
9
|
+
constructor(code: 'INVALID_INPUT' | 'CAPACITY' | 'CORRUPT' | 'WRITE_FAILED' | 'UNAVAILABLE');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The permission preset a private chat chose in Feishu. It applies to that
|
|
13
|
+
* chat's sessions only; the host's own default for other sessions is untouched.
|
|
14
|
+
* One bot process owns this store under its state-directory lock.
|
|
15
|
+
*/
|
|
16
|
+
export declare class PermissionPreferencesStore {
|
|
17
|
+
private readonly directory;
|
|
18
|
+
private document;
|
|
19
|
+
private tail;
|
|
20
|
+
private uncertain;
|
|
21
|
+
private constructor();
|
|
22
|
+
static open(directory: string): Promise<PermissionPreferencesStore>;
|
|
23
|
+
get(context: PermissionPreferenceContext): Promise<string | undefined>;
|
|
24
|
+
set(context: PermissionPreferenceContext, preset: string): Promise<void>;
|
|
25
|
+
private persist;
|
|
26
|
+
}
|