dsh-lark-agent 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/dist/bot-menu.d.ts +3 -1
- package/dist/bot-menu.js +4 -1
- package/dist/bot-service.js +4 -2
- package/dist/bridge.d.ts +71 -1
- package/dist/bridge.js +588 -46
- 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 +94 -3
- package/dist/dsh-runtime.js +450 -25
- package/dist/interaction-cards.d.ts +28 -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 +101 -0
- package/dist/live-reply.js +518 -0
- package/dist/model-controls.d.ts +9 -1
- package/dist/model-controls.js +45 -20
- package/dist/onboarding/controller.js +9 -7
- package/dist/onboarding/lark-platform.js +5 -5
- package/dist/permission-controls.d.ts +20 -0
- package/dist/permission-controls.js +37 -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 +15 -0
- package/dist/replies.js +27 -6
- package/dist/rich-scopes.d.ts +4 -0
- package/dist/rich-scopes.js +6 -0
- package/package.json +7 -7
package/dist/bridge.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, isAbsolute, relative, sep } from 'node:path';
|
|
2
4
|
import { DshRuntimeError } from './dsh-runtime.js';
|
|
3
5
|
import { authorize, chunksByBytes, ownerKey, parseMessage, sessionKey } from './message.js';
|
|
4
6
|
import { MemoryStoreError } from './memory-store.js';
|
|
@@ -10,14 +12,72 @@ import { modelChoice, thinkingChoice, modelControlPanel, modelStateText, sameSel
|
|
|
10
12
|
import { botMenuCommand, BOT_MENU_EVENT } from './bot-menu.js';
|
|
11
13
|
import { CommandFeedback } from './command-feedback.js';
|
|
12
14
|
import { PresentationError, presentationDraft, presentationCard, presentationText } from './presentation.js';
|
|
15
|
+
import { LiveReply } from './live-reply.js';
|
|
16
|
+
import { answeredText, approvalCard, questionAnswers, questionCard, simpleQuestion } from './interaction-cards.js';
|
|
17
|
+
import { permissionPanel, permissionStateText } from './permission-controls.js';
|
|
13
18
|
class ArtifactDeliveryError extends Error {
|
|
14
19
|
}
|
|
20
|
+
const APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
21
|
+
const QUESTION_TIMEOUT_MS = 10 * 60_000;
|
|
22
|
+
// After a live card cannot be created (for example, before the CardKit scope is
|
|
23
|
+
// granted), use ordinary replies for a while instead of failing every turn.
|
|
24
|
+
const LIVE_RETRY_MS = 10 * 60_000;
|
|
25
|
+
const MAX_FILE_BYTES = 30 * 1024 * 1024;
|
|
26
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
27
|
+
const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
|
|
28
|
+
const STOPPING_TEXT = '连接正在关闭,本次处理已停止。';
|
|
29
|
+
// Clicks on these panels update the clicked card itself; anything else answers with a new message.
|
|
30
|
+
const IN_PLACE = /^\/(?:(?:model|thinking)(?: (?:use [a-f0-9]{16}|page [0-9]{1,4}))?|permission(?: use [a-z][a-z0-9-]{0,39})?|sessions|new|resume [a-f0-9]{12})$/;
|
|
31
|
+
// A switch that finishes sooner goes straight to its result, without a loading frame.
|
|
32
|
+
const PANEL_PROGRESS_DELAY_MS = 150;
|
|
33
|
+
const STALE_SETTINGS = '设置刚刚变化,已刷新为最新选项,请重新选择。';
|
|
34
|
+
/** The card's model settings changed after it was drawn, so its choice is not applied. */
|
|
35
|
+
class StaleCardError extends Error {
|
|
36
|
+
constructor() { super('模型设置已变化,请重新打开模型或思考深度菜单后操作。'); }
|
|
37
|
+
}
|
|
38
|
+
function commandError(error) {
|
|
39
|
+
return error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError
|
|
40
|
+
|| error instanceof ModelPreferencesError || error instanceof StaleCardError
|
|
41
|
+
? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
|
|
42
|
+
}
|
|
43
|
+
/** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
|
|
44
|
+
async function uploadable(workspace, path) {
|
|
45
|
+
let real;
|
|
46
|
+
try {
|
|
47
|
+
real = await realpath(path);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { reason: '找不到这个文件' };
|
|
51
|
+
}
|
|
52
|
+
const inside = relative(workspace, real);
|
|
53
|
+
if (!inside || inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside))
|
|
54
|
+
return { reason: '不在这个私聊的工作区内,没有上传' };
|
|
55
|
+
let info;
|
|
56
|
+
try {
|
|
57
|
+
info = await stat(real);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { reason: '无法读取' };
|
|
61
|
+
}
|
|
62
|
+
if (!info.isFile())
|
|
63
|
+
return { reason: '不是普通文件' };
|
|
64
|
+
if (info.size === 0)
|
|
65
|
+
return { reason: '是空文件' };
|
|
66
|
+
if (info.size > MAX_FILE_BYTES)
|
|
67
|
+
return { reason: '超过飞书 30 MB 的文件上限' };
|
|
68
|
+
if (/[\u0000-\u001f\u007f]/.test(basename(real)))
|
|
69
|
+
return { reason: '文件名含有无法发送的字符' };
|
|
70
|
+
return { path: real, image: /\.(?:png|jpe?g|gif|webp)$/i.test(real) && info.size <= MAX_IMAGE_BYTES };
|
|
71
|
+
}
|
|
15
72
|
const HELP = `直接把想做的事告诉我就好。
|
|
16
73
|
|
|
17
74
|
比如:帮我理清一个问题、整理一段文字,或者接着刚才的话题继续。你也可以在私聊告诉我,以后希望我怎样配合你。
|
|
18
75
|
|
|
19
76
|
私聊底部菜单可以新建话题、找历史对话,或调整模型和思考深度。
|
|
20
77
|
也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
|
|
78
|
+
const HARNESS_HELP = `
|
|
79
|
+
|
|
80
|
+
这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。`;
|
|
21
81
|
const ADVANCED_HELP = `高级命令(按需使用):
|
|
22
82
|
/new 或 /reset:归档当前会话,下一条消息开启新会话;长期记忆保留
|
|
23
83
|
/sessions:选择以前的对话;/sessions detail 查看完整列表和 ID
|
|
@@ -27,6 +87,7 @@ const ADVANCED_HELP = `高级命令(按需使用):
|
|
|
27
87
|
/stop:停止当前处理并取消排队消息
|
|
28
88
|
/results:查看当前话题的作品;/result <ID>:查看内容;/publish <ID>:在本人私聊生成飞书文档或画板
|
|
29
89
|
/model:查看当前模型并选择;/thinking:选择当前模型支持的思考深度
|
|
90
|
+
/permission:本人私聊切换 Harness 权限模式(如仅可查看、工作区内修改、完全权限)
|
|
30
91
|
/remember <内容>:私聊保存私密个人记忆;群聊仅保存当前聊天记忆
|
|
31
92
|
/remember --chat <内容>:只在当前聊天/话题保存记忆
|
|
32
93
|
/memory:列出可用记忆(最多 20 条)
|
|
@@ -86,6 +147,8 @@ export class BotBridge {
|
|
|
86
147
|
failedDisposals = new Set();
|
|
87
148
|
cardActionsReady = false;
|
|
88
149
|
progress;
|
|
150
|
+
interactions = new Map();
|
|
151
|
+
liveUnavailableUntil = 0;
|
|
89
152
|
constructor(config, runtime, transport, ledger, state, audit = () => undefined, progressTransport, artifactPublisher) {
|
|
90
153
|
this.config = config;
|
|
91
154
|
this.runtime = runtime;
|
|
@@ -125,7 +188,7 @@ export class BotBridge {
|
|
|
125
188
|
return;
|
|
126
189
|
}
|
|
127
190
|
let formValues;
|
|
128
|
-
if (binding.command.startsWith('/answer ')) {
|
|
191
|
+
if (binding.command.startsWith('/answer ') || binding.command.startsWith('/reply ')) {
|
|
129
192
|
if (data.action_name !== 'dsh_submit' || typeof data.form_value !== 'string' || data.form_value.length > 8000)
|
|
130
193
|
return;
|
|
131
194
|
try {
|
|
@@ -137,7 +200,7 @@ export class BotBridge {
|
|
|
137
200
|
if (!formValues || typeof formValues !== 'object' || Array.isArray(formValues))
|
|
138
201
|
return;
|
|
139
202
|
}
|
|
140
|
-
const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking) use )/.test(binding.command);
|
|
203
|
+
const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(binding.command);
|
|
141
204
|
return { messageId: data.message_id, senderId: binding.ownerId, chatId: binding.chatId,
|
|
142
205
|
chatType: binding.chatType, threadId: binding.threadId, text: binding.command,
|
|
143
206
|
requestId: `card:${data.event_id}`, actionNonce: mutation ? binding.nonce : undefined,
|
|
@@ -201,6 +264,12 @@ export class BotBridge {
|
|
|
201
264
|
return;
|
|
202
265
|
if (!message.menu && message.chatType === 'p2p')
|
|
203
266
|
await this.state.directChats?.record(message.senderId, message.chatId);
|
|
267
|
+
// A running turn holds its queue while it waits for its approval or
|
|
268
|
+
// question, so those answers must not queue behind it.
|
|
269
|
+
if (message.requestId?.startsWith('card:') && /^\/(?:approve|deny|choose|reply) /.test(message.text)) {
|
|
270
|
+
await this.answerInteraction(message);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
204
273
|
const key = sessionKey(message, this.config);
|
|
205
274
|
let lane = this.lanes.get(key);
|
|
206
275
|
if (message.text === '/stop') {
|
|
@@ -228,7 +297,7 @@ export class BotBridge {
|
|
|
228
297
|
const generation = lane.generation;
|
|
229
298
|
current.pending++;
|
|
230
299
|
this.pending++;
|
|
231
|
-
const finishProgress = message.menu ? () => undefined : this.progress.begin(message.messageId);
|
|
300
|
+
const finishProgress = message.menu || this.streams(message, user) ? () => undefined : this.progress.begin(message.messageId);
|
|
232
301
|
const acceptedAt = Date.now();
|
|
233
302
|
const job = current.tail.then(async () => {
|
|
234
303
|
if (this.stopped || generation !== current.generation)
|
|
@@ -276,16 +345,27 @@ export class BotBridge {
|
|
|
276
345
|
if (this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
|
|
277
346
|
message.text = '/model current';
|
|
278
347
|
let text = message.text;
|
|
348
|
+
const inPlace = this.inPlace(message);
|
|
349
|
+
// A stale card is redrawn with the current state instead of answered with another message.
|
|
279
350
|
if (message.expectedSessionId !== undefined) {
|
|
280
351
|
const current = await this.state.sessions.current(this.sessionContext(message));
|
|
281
352
|
if ((current?.id ?? null) !== message.expectedSessionId) {
|
|
353
|
+
if (inPlace)
|
|
354
|
+
return this.refreshPanel(message, lane, '话题刚刚变化,已刷新为最新状态,请重新选择。');
|
|
282
355
|
return this.reply(message, '这张卡片对应的会话状态已改变,请重新发送 /status 或 /sessions 后操作。');
|
|
283
356
|
}
|
|
284
357
|
}
|
|
285
358
|
if (message.expectedModelRevision !== undefined) {
|
|
359
|
+
// A shared session may have been switched in the App after this card was drawn.
|
|
360
|
+
if (lane.session?.sharesModelSelection && !lane.unavailable) {
|
|
361
|
+
await this.mirrorModel(message, lane.session, await this.state.modelPreferences?.get(this.context(message)));
|
|
362
|
+
}
|
|
286
363
|
const current = await this.state.modelPreferences?.get(this.context(message));
|
|
287
|
-
if ((current?.revision ?? null) !== message.expectedModelRevision)
|
|
288
|
-
|
|
364
|
+
if ((current?.revision ?? null) !== message.expectedModelRevision) {
|
|
365
|
+
if (inPlace)
|
|
366
|
+
return this.refreshPanel(message, lane, STALE_SETTINGS);
|
|
367
|
+
return this.reply(message, new StaleCardError().message);
|
|
368
|
+
}
|
|
289
369
|
}
|
|
290
370
|
if (this.state.artifacts && (/^\/(?:result|publish|answer) [a-f0-9]{12}$/.test(text) || text === '/results')) {
|
|
291
371
|
try {
|
|
@@ -326,6 +406,8 @@ export class BotBridge {
|
|
|
326
406
|
}
|
|
327
407
|
}
|
|
328
408
|
if (text.startsWith('/')) {
|
|
409
|
+
if (inPlace)
|
|
410
|
+
return this.panelInPlace(message, lane);
|
|
329
411
|
const feedback = this.commandFeedback(message);
|
|
330
412
|
let response;
|
|
331
413
|
let panel;
|
|
@@ -337,8 +419,7 @@ export class BotBridge {
|
|
|
337
419
|
}
|
|
338
420
|
catch (error) {
|
|
339
421
|
failed = true;
|
|
340
|
-
response = error
|
|
341
|
-
? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
|
|
422
|
+
response = commandError(error);
|
|
342
423
|
this.audit({ stage: 'command-failed', messageId: message.messageId });
|
|
343
424
|
}
|
|
344
425
|
const payload = panel ? renderPanel(panel) : renderPanel({ title: failed ? '暂时未完成' : '已处理',
|
|
@@ -354,48 +435,92 @@ export class BotBridge {
|
|
|
354
435
|
lane.controller = controller;
|
|
355
436
|
let timedOut = false;
|
|
356
437
|
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.config.turnTimeoutMs);
|
|
438
|
+
const harness = message.chatType === 'p2p' && lane.user.directMode === 'harness';
|
|
439
|
+
let live;
|
|
440
|
+
// Once a live card exists it carries this turn's whole outcome; nothing else is sent as text.
|
|
441
|
+
const conclude = async (final) => {
|
|
442
|
+
const card = live;
|
|
443
|
+
live = undefined;
|
|
444
|
+
if (!await card.finish(final))
|
|
445
|
+
this.audit({ stage: 'live-card-unconfirmed', messageId: message.messageId });
|
|
446
|
+
};
|
|
357
447
|
try {
|
|
358
448
|
await this.ensureSession(message, lane);
|
|
359
449
|
if (controller.signal.aborted) {
|
|
360
450
|
if (timedOut)
|
|
361
|
-
await this.reply(message,
|
|
451
|
+
await this.reply(message, TIMEOUT_TEXT);
|
|
362
452
|
return;
|
|
363
453
|
}
|
|
364
454
|
const context = this.context(message);
|
|
365
455
|
const memoryText = promptMemories(await this.state.memories.list(context));
|
|
366
456
|
const artifactsText = this.state.artifacts && lane.saved ? JSON.stringify((await this.state.artifacts.list(this.artifactContext(message, lane.saved.id)))
|
|
367
457
|
.map(row => ({ id: row.id, title: row.draft.title, kind: row.draft.kind }))) : undefined;
|
|
368
|
-
|
|
458
|
+
if (this.streams(message, lane.user)) {
|
|
459
|
+
const candidate = new LiveReply(this.liveTransport(), { messageId: message.messageId, inThread: Boolean(message.threadId),
|
|
460
|
+
idempotencyKey: createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'live'])).digest('hex').slice(0, 48) }, { audit: stage => this.audit({ stage, messageId: message.messageId }) });
|
|
461
|
+
if (await candidate.open()) {
|
|
462
|
+
live = candidate;
|
|
463
|
+
// Under the answer: what this reply runs with. Names may wait for the model catalog.
|
|
464
|
+
void this.settingsLine(lane).then(line => { if (line)
|
|
465
|
+
candidate.setFooter(line); }, () => undefined);
|
|
466
|
+
}
|
|
467
|
+
else
|
|
468
|
+
this.liveUnavailableUntil = Date.now() + LIVE_RETRY_MS;
|
|
469
|
+
}
|
|
470
|
+
const turnContext = { memoryText, ...(artifactsText ? { artifactsText } : {}) };
|
|
471
|
+
// Only the owner's harness-mode chat can be asked anything; a restricted session gets no hooks at all.
|
|
472
|
+
const observer = live || harness ? {
|
|
473
|
+
...(live ? { stream: (event) => live?.push(event) } : {}),
|
|
474
|
+
...(harness ? { approve: (prompt, signal) => this.approve(message, prompt, signal),
|
|
475
|
+
ask: (questions, signal) => this.ask(message, questions, signal) } : {}),
|
|
476
|
+
} : undefined;
|
|
477
|
+
const result = await (observer ? lane.session.run(text, controller.signal, turnContext, observer)
|
|
478
|
+
: lane.session.run(text, controller.signal, turnContext));
|
|
369
479
|
// Native conversation data must be durable before acknowledging a turn.
|
|
370
480
|
await lane.session.checkpoint();
|
|
371
481
|
await this.state.sessions.touch(this.sessionContext(message), lane.saved.id);
|
|
372
482
|
clearTimeout(timer);
|
|
373
483
|
if (controller.signal.aborted || this.stopped) {
|
|
374
|
-
if (
|
|
375
|
-
await
|
|
484
|
+
if (live)
|
|
485
|
+
await conclude({ phase: 'stopped', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT : '已停止。' });
|
|
486
|
+
else if (timedOut && !this.stopped)
|
|
487
|
+
await this.reply(message, TIMEOUT_TEXT);
|
|
376
488
|
return;
|
|
377
489
|
}
|
|
378
490
|
const answer = typeof result === 'string' ? result : result.text;
|
|
379
491
|
const artifacts = typeof result === 'string' ? [] : result.artifacts;
|
|
492
|
+
const files = typeof result === 'string' ? [] : result.files ?? [];
|
|
380
493
|
const bounded = Array.from(answer).slice(0, this.config.maxOutputChars).join('');
|
|
494
|
+
const truncated = bounded.length < answer.length;
|
|
381
495
|
// The answer is already in native history. Send it before artifact work
|
|
382
496
|
// that can fail, so a storage limit cannot swallow a complete reply.
|
|
383
|
-
if (
|
|
384
|
-
await
|
|
497
|
+
if (live)
|
|
498
|
+
await conclude({ phase: 'done', answer: bounded || (artifacts.length || files.length ? '已整理好,见下方。' : 'dsh 本次没有返回文本。'),
|
|
499
|
+
...(typeof result !== 'string' && result.reasoning ? { reasoning: result.reasoning } : {}), ...(truncated ? { note: '回复已截断。' } : {}) });
|
|
500
|
+
else if (bounded || !artifacts.length)
|
|
501
|
+
await this.reply(message, bounded ? bounded + (truncated ? '\n\n(回复已截断)' : '') : 'dsh 本次没有返回文本。', controller.signal);
|
|
385
502
|
await this.presentArtifacts(message, lane, artifacts, controller.signal);
|
|
503
|
+
await this.deliverFiles(message, lane, files, controller.signal);
|
|
386
504
|
this.audit({ stage: 'replied', messageId: message.messageId });
|
|
387
505
|
}
|
|
388
506
|
catch (error) {
|
|
389
507
|
clearTimeout(timer);
|
|
390
508
|
if (error instanceof ArtifactDeliveryError)
|
|
391
509
|
throw error;
|
|
510
|
+
const known = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError;
|
|
511
|
+
if (live) {
|
|
512
|
+
const stopped = timedOut || controller.signal.aborted || this.stopped;
|
|
513
|
+
await conclude({ phase: stopped ? 'stopped' : 'failed', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT
|
|
514
|
+
: controller.signal.aborted ? '已停止。' : known ? error.message : '处理没有完成,请稍后重试。' }).catch(() => undefined);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
392
517
|
if (this.stopped)
|
|
393
518
|
return;
|
|
394
519
|
if (timedOut)
|
|
395
|
-
await this.reply(message,
|
|
520
|
+
await this.reply(message, TIMEOUT_TEXT);
|
|
396
521
|
else if (!controller.signal.aborted) {
|
|
397
522
|
// Transport failures may have delivered partial output. Do not send an extra ambiguous retry.
|
|
398
|
-
if (
|
|
523
|
+
if (known)
|
|
399
524
|
await this.reply(message, error.message);
|
|
400
525
|
else
|
|
401
526
|
this.audit({ stage: 'delivery-or-runtime-failed', messageId: message.messageId });
|
|
@@ -406,6 +531,242 @@ export class BotBridge {
|
|
|
406
531
|
lane.controller = undefined;
|
|
407
532
|
}
|
|
408
533
|
}
|
|
534
|
+
/** The model, thinking depth and permission mode a reply runs with, by name. */
|
|
535
|
+
async settingsLine(lane) {
|
|
536
|
+
const session = lane.session;
|
|
537
|
+
const selection = session?.modelSelection?.();
|
|
538
|
+
if (!session || !selection)
|
|
539
|
+
return;
|
|
540
|
+
const catalog = await this.runtime.catalog?.().catch(() => undefined);
|
|
541
|
+
const model = catalog?.models.find(item => item.provider === selection.provider && item.model === selection.model);
|
|
542
|
+
const efforts = model?.reasoning?.efforts ?? [];
|
|
543
|
+
const effort = selection.reasoningEffort ? efforts.find(item => item.id === selection.reasoningEffort)?.name ?? selection.reasoningEffort
|
|
544
|
+
: efforts.length ? '模型默认' : undefined;
|
|
545
|
+
const permission = session.permissionState?.();
|
|
546
|
+
const preset = permission && (permission.options.find(option => option.id === permission.current)?.name ?? (permission.current === 'custom' ? '自定义' : permission.current));
|
|
547
|
+
return [model?.name ?? selection.model, effort && `思考深度 ${effort}`, preset && `权限 ${preset}`].filter(Boolean).join(' · ');
|
|
548
|
+
}
|
|
549
|
+
/** Whether this message's reply streams into a live card, which makes the typing reaction redundant. */
|
|
550
|
+
streams(message, user) {
|
|
551
|
+
return message.chatType === 'p2p' && user.directMode === 'harness' && this.config.streamingCards && this.config.responseMode !== 'text'
|
|
552
|
+
&& !message.text.startsWith('/') && Date.now() >= this.liveUnavailableUntil && this.liveTransport() !== undefined;
|
|
553
|
+
}
|
|
554
|
+
liveTransport() {
|
|
555
|
+
const transport = this.transport;
|
|
556
|
+
return transport.createCardEntity && transport.replyCardEntity && transport.streamCardText && transport.updateCardEntity
|
|
557
|
+
? transport : undefined;
|
|
558
|
+
}
|
|
559
|
+
canInteract(message) {
|
|
560
|
+
return message.chatType === 'p2p' && !this.stopped && this.cardActionsReady && this.config.cardInteractions && this.config.responseMode !== 'text'
|
|
561
|
+
&& Boolean(this.state.actions && this.transport.createFeedback && this.transport.updateFeedback);
|
|
562
|
+
}
|
|
563
|
+
issueAction(message, command) {
|
|
564
|
+
return this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType,
|
|
565
|
+
threadId: message.threadId, sourceMessageId: message.messageId, command });
|
|
566
|
+
}
|
|
567
|
+
/** Ask the owner about one Harness approval. Anything but an explicit allow denies the call. */
|
|
568
|
+
async approve(message, prompt, signal) {
|
|
569
|
+
if (!this.canInteract(message) || signal.aborted)
|
|
570
|
+
return false;
|
|
571
|
+
const id = randomBytes(6).toString('hex');
|
|
572
|
+
// Register before the card exists, so even an immediate click finds it.
|
|
573
|
+
const pending = this.waitForAnswer(id, message, answer => answer.verb === 'approve' ? true : answer.verb === 'deny' ? false : undefined, signal, APPROVAL_TIMEOUT_MS);
|
|
574
|
+
const card = await this.openInteraction(message, id, approvalCard(prompt, { allow: this.issueAction(message, `/approve ${id}`),
|
|
575
|
+
deny: this.issueAction(message, `/deny ${id}`) }));
|
|
576
|
+
if (!card) {
|
|
577
|
+
pending.cancel();
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
const outcome = await pending.result;
|
|
581
|
+
const allowed = outcome.kind === 'answer' && outcome.value;
|
|
582
|
+
await this.closeInteraction(card, approvalCard(prompt, undefined, outcome.kind === 'answer'
|
|
583
|
+
? (allowed ? { text: '已允许这一次。', tone: 'success' } : { text: '已拒绝,这次不会执行。', tone: 'error' })
|
|
584
|
+
: { text: outcome.kind === 'timeout' ? '5 分钟内没有确认,已按拒绝处理。' : '本次处理已停止,没有执行。', tone: 'muted' }));
|
|
585
|
+
return allowed;
|
|
586
|
+
}
|
|
587
|
+
/** Ask the owner a Harness question. Without an answer the host gets an error and continues. */
|
|
588
|
+
async ask(message, questions, signal) {
|
|
589
|
+
const unavailable = '这里无法用卡片提问。请把问题写在回复里,由主人直接回答。';
|
|
590
|
+
if (!this.canInteract(message))
|
|
591
|
+
throw new Error(unavailable);
|
|
592
|
+
const id = randomBytes(6).toString('hex');
|
|
593
|
+
const first = questions[0];
|
|
594
|
+
const tokens = simpleQuestion(questions)
|
|
595
|
+
? { choose: Array.from({ length: first.options.length + 1 }, (_unused, index) => this.issueAction(message, `/choose ${id} ${index}`)) }
|
|
596
|
+
: { reply: this.issueAction(message, `/reply ${id}`) };
|
|
597
|
+
const pending = this.waitForAnswer(id, message, answer => questionAnswers(questions, answer), signal, QUESTION_TIMEOUT_MS);
|
|
598
|
+
const card = await this.openInteraction(message, id, questionCard(questions, tokens));
|
|
599
|
+
if (!card) {
|
|
600
|
+
pending.cancel();
|
|
601
|
+
throw new Error(unavailable);
|
|
602
|
+
}
|
|
603
|
+
const outcome = await pending.result;
|
|
604
|
+
await this.closeInteraction(card, questionCard(questions, undefined, outcome.kind === 'answer' ? { text: answeredText(questions, outcome.value), tone: 'success' }
|
|
605
|
+
: { text: outcome.kind === 'timeout' ? '10 分钟内没有回答,已按未回答继续。' : '本次处理已停止,提问已关闭。', tone: 'muted' }));
|
|
606
|
+
if (outcome.kind !== 'answer')
|
|
607
|
+
throw new Error(outcome.kind === 'timeout' ? '主人没有在 10 分钟内回答。' : '提问已取消。');
|
|
608
|
+
return outcome.value;
|
|
609
|
+
}
|
|
610
|
+
waitForAnswer(id, message, parse, signal, timeoutMs) {
|
|
611
|
+
let finish;
|
|
612
|
+
const result = new Promise(resolve => {
|
|
613
|
+
let done = false;
|
|
614
|
+
const timer = setTimeout(() => finish({ kind: 'timeout' }), timeoutMs);
|
|
615
|
+
const onAbort = () => finish({ kind: 'cancelled' });
|
|
616
|
+
const entry = { ownerId: message.senderId, chatId: message.chatId, stop: onAbort,
|
|
617
|
+
settle: answer => {
|
|
618
|
+
const value = parse(answer);
|
|
619
|
+
if (value === undefined || done)
|
|
620
|
+
return false;
|
|
621
|
+
finish({ kind: 'answer', value });
|
|
622
|
+
return true;
|
|
623
|
+
} };
|
|
624
|
+
finish = outcome => {
|
|
625
|
+
if (done)
|
|
626
|
+
return;
|
|
627
|
+
done = true;
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
signal.removeEventListener('abort', onAbort);
|
|
630
|
+
if (this.interactions.get(id) === entry)
|
|
631
|
+
this.interactions.delete(id);
|
|
632
|
+
resolve(outcome);
|
|
633
|
+
};
|
|
634
|
+
this.interactions.set(id, entry);
|
|
635
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
636
|
+
if (signal.aborted)
|
|
637
|
+
onAbort();
|
|
638
|
+
});
|
|
639
|
+
return { result, cancel: () => finish({ kind: 'cancelled' }) };
|
|
640
|
+
}
|
|
641
|
+
async openInteraction(message, id, payload) {
|
|
642
|
+
if (payload.kind !== 'card' || this.stopped)
|
|
643
|
+
return;
|
|
644
|
+
const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'interaction', id]))
|
|
645
|
+
.digest('hex').slice(0, 48);
|
|
646
|
+
try {
|
|
647
|
+
return await this.transport.createFeedback({ messageId: message.messageId, inThread: Boolean(message.threadId) }, payload, { idempotencyKey });
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
this.audit({ stage: 'interaction-card-failed', messageId: message.messageId });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async closeInteraction(messageId, payload) {
|
|
655
|
+
if (payload.kind !== 'card')
|
|
656
|
+
return;
|
|
657
|
+
try {
|
|
658
|
+
await this.transport.updateFeedback(messageId, payload);
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
this.audit({ stage: 'interaction-update-failed', messageId });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
async answerInteraction(message) {
|
|
665
|
+
const match = /^\/(approve|deny|choose|reply) ([a-f0-9]{12})(?: ([0-9]{1,2}))?$/.exec(message.text);
|
|
666
|
+
const pending = match ? this.interactions.get(match[2]) : undefined;
|
|
667
|
+
if (!match || !pending || pending.ownerId !== message.senderId || pending.chatId !== message.chatId) {
|
|
668
|
+
await this.reply(message, '这张卡片已经失效。需要的话,直接在聊天里告诉我。');
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (!pending.settle({ verb: match[1], ...(match[3] === undefined ? {} : { index: Number(match[3]) }),
|
|
672
|
+
...(message.formValues === undefined ? {} : { formValues: message.formValues }) }))
|
|
673
|
+
await this.reply(message, '这次选择无效,请在卡片上重新选择后提交。');
|
|
674
|
+
}
|
|
675
|
+
/** Send files the host's delivery tool presented, from this user's workspace only. */
|
|
676
|
+
async deliverFiles(message, lane, files, signal) {
|
|
677
|
+
if (!files.length || signal.aborted || this.stopped)
|
|
678
|
+
return;
|
|
679
|
+
const workspace = await realpath(lane.user.cwd).catch(() => undefined);
|
|
680
|
+
const notes = [];
|
|
681
|
+
for (const [index, file] of files.entries()) {
|
|
682
|
+
if (signal.aborted || this.stopped)
|
|
683
|
+
return;
|
|
684
|
+
const name = basename(file.path);
|
|
685
|
+
const checked = workspace && this.transport.replyFile ? await uploadable(workspace, file.path) : { reason: '这个连接不能发送文件' };
|
|
686
|
+
if ('reason' in checked) {
|
|
687
|
+
notes.push(`${name}:${checked.reason}(电脑上的位置:${file.path})`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId, message.requestId ?? message.messageId, 'file', index, checked.path]))
|
|
691
|
+
.digest('hex').slice(0, 48);
|
|
692
|
+
try {
|
|
693
|
+
await this.transport.replyFile(message.messageId, checked.path, { idempotencyKey, inThread: Boolean(message.threadId), image: checked.image });
|
|
694
|
+
this.audit({ stage: 'file-sent', messageId: message.messageId });
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
// The upload may still have arrived. Name it instead of sending again.
|
|
698
|
+
notes.push(`${name}:发送结果没有确认,请在电脑上查看 ${file.path}`);
|
|
699
|
+
this.audit({ stage: 'file-send-unknown', messageId: message.messageId });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (notes.length)
|
|
703
|
+
await this.reply(message, `有 ${notes.length} 个文件没有发到这里:\n${notes.join('\n')}`, signal);
|
|
704
|
+
}
|
|
705
|
+
/** A private-chat click on a settings or topic panel, which can update the clicked card. */
|
|
706
|
+
inPlace(message) {
|
|
707
|
+
return message.chatType === 'p2p' && Boolean(message.requestId?.startsWith('card:')) && this.config.responseMode !== 'text'
|
|
708
|
+
&& Boolean(this.transport.updateFeedback) && IN_PLACE.test(message.text);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Run a panel click and show its progress and result on the clicked card. That
|
|
712
|
+
* card exists, so a failed progress frame never holds back the result, and an
|
|
713
|
+
* unconfirmed update is never followed by another message.
|
|
714
|
+
*/
|
|
715
|
+
async panelInPlace(message, lane) {
|
|
716
|
+
const target = message.messageId;
|
|
717
|
+
const mutation = /^\/(?:(?:model|thinking|permission) use |resume |new$)/.test(message.text);
|
|
718
|
+
const progress = mutation ? await this.commandPanel(message, lane, { pending: true }).catch(() => undefined) : undefined;
|
|
719
|
+
const feedback = new CommandFeedback(async () => {
|
|
720
|
+
if (progress && !this.stopped) {
|
|
721
|
+
try {
|
|
722
|
+
await this.transport.updateFeedback(target, renderPanel(progress));
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
this.audit({ stage: 'panel-progress-failed', messageId: target });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return target;
|
|
729
|
+
}, (_id, payload) => this.transport.updateFeedback(target, payload), PANEL_PROGRESS_DELAY_MS);
|
|
730
|
+
let panel, response, failed = false;
|
|
731
|
+
try {
|
|
732
|
+
response = await this.command(message, lane);
|
|
733
|
+
panel = await this.commandPanel(message, lane);
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
// A card that went stale while it ran is refreshed, not reported as a failure.
|
|
737
|
+
const stale = error instanceof StaleCardError;
|
|
738
|
+
failed = !stale;
|
|
739
|
+
response = stale ? STALE_SETTINGS : commandError(error);
|
|
740
|
+
if (failed)
|
|
741
|
+
this.audit({ stage: 'command-failed', messageId: message.messageId });
|
|
742
|
+
panel = await this.commandPanel(message, lane, stale ? { notice: response } : { error: response }).catch(() => undefined);
|
|
743
|
+
if (stale && !panel)
|
|
744
|
+
panel = { title: '卡片已过期', tone: 'muted', status: { text: response, tone: 'muted' }, summary: response, sections: [] };
|
|
745
|
+
}
|
|
746
|
+
const payload = renderPanel(this.stopped ? { title: '连接正在关闭', tone: 'muted', summary: '连接恢复后可重新打开设置,已保存的选择保留',
|
|
747
|
+
sections: [{ text: '连接恢复后可重新打开菜单。已保存的选择会保留。' }] }
|
|
748
|
+
: panel ?? { title: failed ? '暂时未完成' : '已处理', tone: failed ? 'error' : 'success', status: { text: response, tone: failed ? 'error' : 'success' },
|
|
749
|
+
summary: response, sections: [] });
|
|
750
|
+
try {
|
|
751
|
+
await feedback.finish(payload, () => this.transport.updateFeedback(target, payload));
|
|
752
|
+
this.audit({ stage: 'panel-updated', messageId: target });
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
this.audit({ stage: 'panel-update-failed', messageId: target });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/** Redraw a stale card with the current state, without running its command. */
|
|
759
|
+
async refreshPanel(message, lane, notice) {
|
|
760
|
+
const panel = await this.commandPanel(message, lane, { notice }).catch(() => undefined);
|
|
761
|
+
const payload = renderPanel(panel ?? { title: '卡片已过期', tone: 'muted', status: { text: notice, tone: 'muted' }, summary: notice, sections: [] });
|
|
762
|
+
try {
|
|
763
|
+
await this.transport.updateFeedback(message.messageId, payload);
|
|
764
|
+
this.audit({ stage: 'panel-refreshed', messageId: message.messageId });
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
this.audit({ stage: 'panel-update-failed', messageId: message.messageId });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
409
770
|
commandFeedback(message) {
|
|
410
771
|
// Private controls contain no model-generated output. Group replies retain
|
|
411
772
|
// their existing authorization gate and native typing reaction.
|
|
@@ -456,6 +817,7 @@ export class BotBridge {
|
|
|
456
817
|
const context = this.context(message);
|
|
457
818
|
return {
|
|
458
819
|
cwd: lane.user.cwd, allowedTools: lane.user.allowedTools,
|
|
820
|
+
mode: message.chatType === 'p2p' && lane.user.directMode === 'harness' ? 'harness' : 'restricted',
|
|
459
821
|
provider: this.config.provider, model: this.config.model,
|
|
460
822
|
presentation: Boolean(this.state.artifacts),
|
|
461
823
|
...selection,
|
|
@@ -482,25 +844,29 @@ export class BotBridge {
|
|
|
482
844
|
if (lane.unavailable)
|
|
483
845
|
await this.unload(lane);
|
|
484
846
|
if (lane.session) {
|
|
485
|
-
|
|
847
|
+
// A session that shares its model with the App follows its own record instead of the chat's copy.
|
|
848
|
+
if (!lane.session.sharesModelSelection && modelPreference && lane.session.modelSelection && !sameSelection(lane.session.modelSelection(), modelPreference.selection)) {
|
|
486
849
|
if (lane.session.setModelSelection)
|
|
487
850
|
lane.session.setModelSelection(modelPreference.selection);
|
|
488
851
|
else
|
|
489
852
|
await this.unload(lane);
|
|
490
853
|
}
|
|
491
|
-
if (lane.session)
|
|
854
|
+
if (lane.session) {
|
|
855
|
+
await this.adoptPermission(lane);
|
|
492
856
|
return;
|
|
857
|
+
}
|
|
493
858
|
}
|
|
494
859
|
const context = this.sessionContext(message);
|
|
495
860
|
const saved = await this.state.sessions.current(context);
|
|
861
|
+
// Like the App: a shared session starts on the host default and resumes on its own record.
|
|
862
|
+
const selection = this.sharedModel(message, lane) ? undefined : modelPreference?.selection;
|
|
496
863
|
const handle = saved
|
|
497
|
-
? await this.runtime.resume(this.policy(message, lane,
|
|
498
|
-
: await this.runtime.create(this.policy(message, lane,
|
|
864
|
+
? await this.runtime.resume(this.policy(message, lane, selection), saved.nativeId)
|
|
865
|
+
: await this.runtime.create(this.policy(message, lane, selection));
|
|
499
866
|
try {
|
|
500
867
|
await handle.checkpoint();
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
868
|
+
await this.applyPermission(message, lane, handle, !saved);
|
|
869
|
+
await this.mirrorModel(message, handle, modelPreference);
|
|
504
870
|
// Publish a route only after even the empty native log is durable, and
|
|
505
871
|
// before submitting the first prompt. An abandoned creation has no work.
|
|
506
872
|
lane.saved = saved ? await this.bindRestoredSession(context, saved, handle)
|
|
@@ -529,6 +895,8 @@ export class BotBridge {
|
|
|
529
895
|
lane.unavailable = false;
|
|
530
896
|
return;
|
|
531
897
|
}
|
|
898
|
+
// Keep a preset changed in the App before this session goes away (/new, /resume, idle sweep).
|
|
899
|
+
await this.adoptPermission(lane).catch(() => this.audit({ stage: 'permission-adopt-failed' }));
|
|
532
900
|
lane.unavailable = true;
|
|
533
901
|
try {
|
|
534
902
|
await handle.dispose();
|
|
@@ -550,9 +918,12 @@ export class BotBridge {
|
|
|
550
918
|
const context = this.context(message);
|
|
551
919
|
const conversation = this.sessionContext(message);
|
|
552
920
|
if (command === 'help')
|
|
553
|
-
return args === 'advanced' ? ADVANCED_HELP
|
|
921
|
+
return args === 'advanced' ? ADVANCED_HELP
|
|
922
|
+
: HELP + (message.chatType === 'p2p' && lane.user.directMode === 'harness' ? HARNESS_HELP : '');
|
|
554
923
|
if (command === 'model' || command === 'thinking')
|
|
555
924
|
return this.modelCommand(message, lane, command, args);
|
|
925
|
+
if (command === 'permission')
|
|
926
|
+
return this.permissionCommand(message, lane, args);
|
|
556
927
|
if (command === 'whoami')
|
|
557
928
|
return `用户:${message.senderId}\n聊天:${message.chatId}\n范围:${message.chatType === 'p2p' ? '私聊' : '群聊'}${message.threadId ? '/独立话题' : ''}\n身份由飞书验证。个人记忆默认仅本人私聊可读,群聊仅可用本人明确授权的条目。`;
|
|
558
929
|
if (command === 'status') {
|
|
@@ -600,11 +971,11 @@ export class BotBridge {
|
|
|
600
971
|
return `已经在会话「${target.title}」中。`;
|
|
601
972
|
await this.unload(lane);
|
|
602
973
|
const preference = await this.state.modelPreferences?.get(context);
|
|
603
|
-
const handle = await this.runtime.resume(this.policy(message, lane, preference?.selection), target.nativeId);
|
|
974
|
+
const handle = await this.runtime.resume(this.policy(message, lane, this.sharedModel(message, lane) ? undefined : preference?.selection), target.nativeId);
|
|
604
975
|
try {
|
|
605
976
|
await handle.checkpoint();
|
|
606
|
-
|
|
607
|
-
|
|
977
|
+
await this.applyPermission(message, lane, handle, false);
|
|
978
|
+
await this.mirrorModel(message, handle, preference);
|
|
608
979
|
await this.bindRestoredSession(conversation, target, handle);
|
|
609
980
|
lane.saved = await this.state.sessions.activate(conversation, target.id);
|
|
610
981
|
lane.session = handle;
|
|
@@ -672,6 +1043,91 @@ export class BotBridge {
|
|
|
672
1043
|
}
|
|
673
1044
|
return '未知命令。发送 /help 查看可用命令。';
|
|
674
1045
|
}
|
|
1046
|
+
harness(message, lane) {
|
|
1047
|
+
return message.chatType === 'p2p' && lane.user.directMode === 'harness';
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* The owner's harness chat on the App host shares model and thinking depth
|
|
1051
|
+
* with the App, like any App session; an administrator's fixed route does not.
|
|
1052
|
+
*/
|
|
1053
|
+
sharedModel(message, lane) {
|
|
1054
|
+
return this.harness(message, lane) && Boolean(this.runtime.sharesModelSelection?.()) && !(this.config.provider && this.config.model);
|
|
1055
|
+
}
|
|
1056
|
+
/** Record what an opened session runs as the chat's copy: the first capture, or a shared session's own record. */
|
|
1057
|
+
async mirrorModel(message, handle, preference) {
|
|
1058
|
+
if (!handle.modelSelection || !this.state.modelPreferences || (preference && !handle.sharesModelSelection))
|
|
1059
|
+
return;
|
|
1060
|
+
const selection = handle.modelSelection();
|
|
1061
|
+
if (preference && sameSelection(preference.selection, selection))
|
|
1062
|
+
return;
|
|
1063
|
+
await this.state.modelPreferences.set(this.context(message), selection, { expectedRevision: preference?.revision ?? null });
|
|
1064
|
+
}
|
|
1065
|
+
/** The host's presets, from the live session when there is one. Undefined when the host offers none. */
|
|
1066
|
+
permissionView(lane) {
|
|
1067
|
+
const live = lane.session?.permissionState?.();
|
|
1068
|
+
if (live)
|
|
1069
|
+
return live;
|
|
1070
|
+
const options = this.runtime.permissionOptions?.() ?? [];
|
|
1071
|
+
return options.length ? { options } : undefined;
|
|
1072
|
+
}
|
|
1073
|
+
/** What this chat runs: the live session's preset, else the chat's saved choice. */
|
|
1074
|
+
async permissionChoice(message, lane) {
|
|
1075
|
+
if (lane.unavailable)
|
|
1076
|
+
await this.unload(lane);
|
|
1077
|
+
await this.adoptPermission(lane);
|
|
1078
|
+
const view = this.permissionView(lane);
|
|
1079
|
+
if (!view)
|
|
1080
|
+
return;
|
|
1081
|
+
if (view.current)
|
|
1082
|
+
return view;
|
|
1083
|
+
const saved = await this.state.permissionPreferences?.get(this.context(message));
|
|
1084
|
+
return saved && view.options.some(option => option.id === saved) ? { ...view, current: saved } : view;
|
|
1085
|
+
}
|
|
1086
|
+
/** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
|
|
1087
|
+
async permissionCommand(message, lane, args) {
|
|
1088
|
+
if (!this.harness(message, lane))
|
|
1089
|
+
return message.chatType === 'p2p' ? '这个私聊使用管理员配置的助手,没有可切换的权限模式。' : '权限模式只能在你和我的私聊里切换。';
|
|
1090
|
+
const view = await this.permissionChoice(message, lane);
|
|
1091
|
+
if (!view)
|
|
1092
|
+
throw new DshRuntimeError('PERMISSION_UNAVAILABLE');
|
|
1093
|
+
if (!args)
|
|
1094
|
+
return permissionStateText(view);
|
|
1095
|
+
const choice = /^use ([a-z][a-z0-9-]{0,39})$/.exec(args)?.[1];
|
|
1096
|
+
const option = view.options.find(item => item.id === choice);
|
|
1097
|
+
if (!option)
|
|
1098
|
+
return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
|
|
1099
|
+
// Save first: a later session in this chat starts in the chosen mode even if the live switch fails.
|
|
1100
|
+
await this.state.permissionPreferences?.set(this.context(message), option.id);
|
|
1101
|
+
if (lane.session?.setPermission) {
|
|
1102
|
+
lane.session.setPermission(option.id);
|
|
1103
|
+
lane.permissionSeen = option.id;
|
|
1104
|
+
}
|
|
1105
|
+
message.permissionChanged = true;
|
|
1106
|
+
return `已切换为「${option.name}」。这个私聊里的对话都会使用它,电脑上打开这段对话也会看到同样的模式。`;
|
|
1107
|
+
}
|
|
1108
|
+
/** Bring a newly opened harness session to the chat's choice, or a new one without a choice to the App's default. */
|
|
1109
|
+
async applyPermission(message, lane, handle, fresh) {
|
|
1110
|
+
if (!this.harness(message, lane) || !handle.setPermission)
|
|
1111
|
+
return;
|
|
1112
|
+
const { ownerKey, conversationKey } = this.context(message);
|
|
1113
|
+
lane.permissionContext = { ownerKey, conversationKey };
|
|
1114
|
+
const saved = await this.state.permissionPreferences?.get(lane.permissionContext);
|
|
1115
|
+
const state = handle.permissionState?.();
|
|
1116
|
+
// The App's own new sessions start on its default preset; a resumed one keeps what it recorded.
|
|
1117
|
+
const target = saved ?? (fresh ? state?.defaultPreset : undefined);
|
|
1118
|
+
if (state && target && state.current !== target && state.options.some(option => option.id === target))
|
|
1119
|
+
handle.setPermission(target);
|
|
1120
|
+
lane.permissionSeen = handle.permissionState?.()?.current;
|
|
1121
|
+
}
|
|
1122
|
+
/** A preset changed in the App on this chat's live session becomes the chat's choice, so new sessions keep it. */
|
|
1123
|
+
async adoptPermission(lane) {
|
|
1124
|
+
const live = lane.permissionContext ? lane.session?.permissionState?.() : undefined;
|
|
1125
|
+
if (!live || live.current === lane.permissionSeen)
|
|
1126
|
+
return;
|
|
1127
|
+
lane.permissionSeen = live.current;
|
|
1128
|
+
if (live.options.some(option => option.id === live.current))
|
|
1129
|
+
await this.state.permissionPreferences?.set(lane.permissionContext, live.current);
|
|
1130
|
+
}
|
|
675
1131
|
async modelState(message, lane) {
|
|
676
1132
|
if (!this.runtime.catalog || !this.runtime.resolveModelSelection || !this.state.modelPreferences)
|
|
677
1133
|
throw new DshRuntimeError('MODEL_UNAVAILABLE');
|
|
@@ -682,6 +1138,17 @@ export class BotBridge {
|
|
|
682
1138
|
const catalog = await this.runtime.catalog();
|
|
683
1139
|
const context = this.context(message);
|
|
684
1140
|
let preference = await this.state.modelPreferences.get(context);
|
|
1141
|
+
if (this.sharedModel(message, lane)) {
|
|
1142
|
+
// As in the App: the live session's own record; without one, what this chat last ran,
|
|
1143
|
+
// and for a chat that will start a new session, the host default.
|
|
1144
|
+
const live = lane.session?.sharesModelSelection ? lane.session.modelSelection?.() : undefined;
|
|
1145
|
+
const fresh = !live && !await this.state.sessions.current(this.sessionContext(message));
|
|
1146
|
+
const selection = live ?? (fresh || !preference ? catalog.defaultSelection ?? await this.runtime.resolveModelSelection() : preference.selection);
|
|
1147
|
+
if (!preference || !sameSelection(preference.selection, selection)) {
|
|
1148
|
+
preference = await this.state.modelPreferences.set(context, selection, { expectedRevision: preference?.revision ?? null });
|
|
1149
|
+
}
|
|
1150
|
+
return message.modelView = { catalog, selection: preference.selection, preference };
|
|
1151
|
+
}
|
|
685
1152
|
if (!preference) {
|
|
686
1153
|
let selection = lane.session?.modelSelection?.();
|
|
687
1154
|
if (!selection) {
|
|
@@ -706,12 +1173,21 @@ export class BotBridge {
|
|
|
706
1173
|
return message.modelView = { catalog, selection: preference.selection, preference };
|
|
707
1174
|
}
|
|
708
1175
|
async modelCommand(message, lane, kind, args) {
|
|
1176
|
+
const shared = this.sharedModel(message, lane);
|
|
1177
|
+
const match = /^use ([a-f0-9]{16})$/.exec(args);
|
|
1178
|
+
// Like the App's selector, a pick goes into this chat's own session, so open it first.
|
|
1179
|
+
if (match && shared && !lane.session && await this.state.sessions.current(this.sessionContext(message))) {
|
|
1180
|
+
await this.ensureSession(message, lane);
|
|
1181
|
+
message.modelView = undefined;
|
|
1182
|
+
}
|
|
709
1183
|
const state = await this.modelState(message, lane);
|
|
710
1184
|
if (!args || args === 'current' || /^page [0-9]{1,4}$/.test(args))
|
|
711
1185
|
return modelStateText(state.selection, state.catalog);
|
|
712
|
-
const match = /^use ([a-f0-9]{16})$/.exec(args);
|
|
713
1186
|
if (!match)
|
|
714
1187
|
return `请通过菜单选择,或发送 /${kind} 查看当前可用选项。`;
|
|
1188
|
+
// Reopening the session may have brought in a choice made in the App since the card was drawn.
|
|
1189
|
+
if (message.expectedModelRevision !== undefined && state.preference.revision !== message.expectedModelRevision)
|
|
1190
|
+
throw new StaleCardError();
|
|
715
1191
|
let candidate = kind === 'model' ? modelChoice(state.catalog, match[1]) : thinkingChoice(state.catalog, state.selection, match[1]);
|
|
716
1192
|
if (!candidate)
|
|
717
1193
|
throw new DshRuntimeError('INVALID_MODEL_SELECTION');
|
|
@@ -720,7 +1196,31 @@ export class BotBridge {
|
|
|
720
1196
|
const selection = await this.runtime.resolveModelSelection(candidate);
|
|
721
1197
|
if (!sameSelection(selection, state.selection)) {
|
|
722
1198
|
const saved = await this.state.modelPreferences.set(this.context(message), selection, { expectedRevision: state.preference.revision });
|
|
723
|
-
if (
|
|
1199
|
+
if (shared) {
|
|
1200
|
+
// Recorded in the session, then made the App's default for new sessions, as the App's selector does.
|
|
1201
|
+
const recorded = Boolean(lane.session?.sharesModelSelection);
|
|
1202
|
+
if (recorded) {
|
|
1203
|
+
lane.session.setModelSelection(saved.selection);
|
|
1204
|
+
// An unconfirmed pick must not drive another turn: the session is closed and reopened first.
|
|
1205
|
+
try {
|
|
1206
|
+
await lane.session.checkpoint();
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
lane.unavailable = true;
|
|
1210
|
+
throw error;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
await this.runtime.saveDefaultModel?.(saved.selection);
|
|
1215
|
+
}
|
|
1216
|
+
catch (error) {
|
|
1217
|
+
// Without a session holding the pick, the App default is the only place it lives.
|
|
1218
|
+
if (!recorded)
|
|
1219
|
+
throw error;
|
|
1220
|
+
this.audit({ stage: 'model-default-unsaved', messageId: message.messageId });
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
else if (lane.session) {
|
|
724
1224
|
if (lane.session.setModelSelection) {
|
|
725
1225
|
try {
|
|
726
1226
|
lane.session.setModelSelection(saved.selection);
|
|
@@ -735,49 +1235,87 @@ export class BotBridge {
|
|
|
735
1235
|
message.modelView = { catalog: state.catalog, selection: saved.selection, preference: saved };
|
|
736
1236
|
}
|
|
737
1237
|
message.modelChanged = true;
|
|
738
|
-
return `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n
|
|
1238
|
+
return shared ? `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留;电脑上的 Harness 也会用这个设置。`
|
|
1239
|
+
: `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留,只影响你在当前聊天中的选择。`;
|
|
739
1240
|
}
|
|
740
|
-
|
|
1241
|
+
/**
|
|
1242
|
+
* The management card for a command. `card` describes a click being shown on
|
|
1243
|
+
* its own card: the switch in flight, its refusal, or a refresh of a stale card.
|
|
1244
|
+
*/
|
|
1245
|
+
async commandPanel(message, lane, card) {
|
|
741
1246
|
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
|
|
742
1247
|
const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
|
|
743
|
-
|
|
1248
|
+
const choice = /^use (\S+)$/.exec(args)?.[1];
|
|
1249
|
+
const pending = card?.pending ? choice : undefined;
|
|
1250
|
+
const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
|
|
1251
|
+
const issue = (target, extra = {}) => this.state.actions.issue({
|
|
1252
|
+
ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType, threadId: message.threadId,
|
|
1253
|
+
sourceMessageId: message.messageId, command: target, ...extra
|
|
1254
|
+
});
|
|
1255
|
+
if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged || card)) {
|
|
1256
|
+
if (card?.pending && !pending)
|
|
1257
|
+
return;
|
|
744
1258
|
const state = await this.modelState(message, lane);
|
|
745
1259
|
const current = await this.state.sessions.current(this.sessionContext(message));
|
|
746
|
-
const
|
|
747
|
-
return modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
|
|
1260
|
+
const panel = modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
|
|
748
1261
|
if (!interactive)
|
|
749
1262
|
return;
|
|
750
|
-
const token =
|
|
751
|
-
threadId: message.threadId, sourceMessageId: message.messageId, command: target, expectedSessionId: current?.id ?? null,
|
|
1263
|
+
const token = issue(target, { expectedSessionId: current?.id ?? null,
|
|
752
1264
|
...(mutation ? { expectedModelRevision: state.preference?.revision ?? null } : {}) });
|
|
753
1265
|
return { label, value: { token }, primary };
|
|
754
|
-
}, /^page /.test(args) ? Number(args.slice(5)) :
|
|
1266
|
+
}, { ...(/^page /.test(args) ? { page: Number(args.slice(5)) } : {}), changed: message.modelChanged, pending, error: card?.error, notice: card?.notice });
|
|
1267
|
+
// The settings menu reaches the permission switch from here too.
|
|
1268
|
+
if (interactive && this.harness(message, lane) && this.permissionView(lane))
|
|
1269
|
+
panel.actions = [...panel.actions ?? [],
|
|
1270
|
+
pending ? { label: '权限模式', value: {}, disabled: true } : { label: '权限模式', value: { token: issue('/permission') } }];
|
|
1271
|
+
return panel;
|
|
1272
|
+
}
|
|
1273
|
+
if (command === 'permission' && (!args || message.permissionChanged || card) && this.harness(message, lane)) {
|
|
1274
|
+
if (card?.pending && !pending)
|
|
1275
|
+
return;
|
|
1276
|
+
const view = await this.permissionChoice(message, lane);
|
|
1277
|
+
if (!view)
|
|
1278
|
+
return;
|
|
1279
|
+
return permissionPanel(view, (label, target, primary, confirm) => interactive
|
|
1280
|
+
? { label, value: { token: issue(target) }, primary, ...(confirm ? { confirm } : {}) } : undefined, { changed: message.permissionChanged, pending, error: card?.error, notice: card?.notice });
|
|
755
1281
|
}
|
|
756
1282
|
// Ordinary conversation and diagnostics stay out of management cards.
|
|
757
1283
|
// A card is useful here because the user explicitly wants to pick a topic.
|
|
758
|
-
|
|
1284
|
+
const topic = card && (command === 'resume' || command === 'new');
|
|
1285
|
+
if ((command !== 'sessions' || args === 'detail') && !topic)
|
|
759
1286
|
return;
|
|
760
1287
|
const conversation = this.sessionContext(message);
|
|
761
1288
|
const current = await this.state.sessions.current(conversation);
|
|
762
1289
|
const sessions = await this.state.sessions.list(conversation);
|
|
763
1290
|
if (!sessions.length)
|
|
764
1291
|
return;
|
|
765
|
-
const
|
|
1292
|
+
const busy = Boolean(card?.pending);
|
|
1293
|
+
const target = command === 'resume' ? sessions.find(s => s.id === args) : undefined;
|
|
1294
|
+
const status = card?.error ? { text: card.error, tone: 'error' }
|
|
1295
|
+
: busy ? { text: target ? `正在切换到「${preview(target.title, 40)}」…` : '正在开始新话题…', tone: 'pending' }
|
|
1296
|
+
: topic && command === 'resume' && target ? { text: `已切换到「${preview(target.title, 40)}」,可以接着聊了。`, tone: 'success' }
|
|
1297
|
+
: topic ? { text: '好,换个话题。直接发消息就会开始新的对话,之前的还在这里。', tone: 'success' }
|
|
1298
|
+
: card?.notice ? { text: card.notice } : undefined;
|
|
766
1299
|
const actions = [];
|
|
767
|
-
const add = (label,
|
|
1300
|
+
const add = (label, command, options = {}) => {
|
|
1301
|
+
if (busy) {
|
|
1302
|
+
actions.push({ label, value: {}, disabled: true });
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
768
1305
|
if (!interactive)
|
|
769
1306
|
return;
|
|
770
1307
|
const token = this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId,
|
|
771
1308
|
chatType: message.chatType, threadId: message.threadId, sourceMessageId: message.messageId,
|
|
772
|
-
command
|
|
773
|
-
actions.push({ label, value: { token },
|
|
1309
|
+
command, ...(options.expectedSessionId === undefined ? {} : { expectedSessionId: options.expectedSessionId }) });
|
|
1310
|
+
actions.push({ label, value: { token }, confirm: options.confirm });
|
|
774
1311
|
};
|
|
775
1312
|
for (const session of sessions.filter(s => s.id !== current?.id).slice(0, 5))
|
|
776
1313
|
add(`继续 ${preview(session.title, 14)}`, `/resume ${session.id}`, { expectedSessionId: current?.id ?? null, confirm: { title: '接着这个话题聊?', text: `继续「${preview(session.title, 40)}」,现在的对话会保留。` } });
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1314
|
+
if (current)
|
|
1315
|
+
add('换个话题', '/new', { expectedSessionId: current.id,
|
|
1316
|
+
confirm: { title: '开始新话题?', text: '之前的对话会保留,需要时可以再找回来。' } });
|
|
1317
|
+
return { title: '以前的对话', subtitle: '选一个话题,接着聊', tone: status?.tone ?? 'normal', ...(status ? { status } : {}),
|
|
1318
|
+
summary: status?.text ?? '选择以前的话题继续,或开始一段新对话', actions,
|
|
781
1319
|
sections: sessions.slice(0, 5).map(s => ({ title: preview(s.title, 40), text: s.id === current?.id ? '正在聊这个话题' : '可以接着聊' })),
|
|
782
1320
|
...(!interactive || sessions.length > 5 ? { note: '完整列表和更多操作:/sessions detail' } : {}),
|
|
783
1321
|
};
|
|
@@ -961,10 +1499,14 @@ export class BotBridge {
|
|
|
961
1499
|
lane.controller?.abort();
|
|
962
1500
|
lane.session?.cancel();
|
|
963
1501
|
}
|
|
1502
|
+
for (const interaction of [...this.interactions.values()])
|
|
1503
|
+
interaction.stop();
|
|
964
1504
|
await Promise.allSettled([...this.admissions]);
|
|
965
1505
|
await Promise.all([...this.lanes.values()].map(lane => lane.tail));
|
|
966
1506
|
await this.sweepTail;
|
|
967
1507
|
await this.progress.drain();
|
|
1508
|
+
// Shutdown disposes sessions directly; keep presets changed in the App first.
|
|
1509
|
+
await Promise.allSettled([...this.lanes.values()].map(lane => this.adoptPermission(lane)));
|
|
968
1510
|
const sessions = new Set([...this.failedDisposals, ...[...this.lanes.values()].flatMap(lane => lane.session ? [lane.session] : [])]);
|
|
969
1511
|
const results = await Promise.allSettled([...sessions].map(session => session.dispose()));
|
|
970
1512
|
if (results.some(result => result.status === 'rejected'))
|