dsh-lark-agent 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/bot-menu.d.ts +2 -1
- package/dist/bot-menu.js +3 -2
- package/dist/bridge.d.ts +30 -1
- package/dist/bridge.js +269 -49
- package/dist/client/ConnectionCard.d.ts +6 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client.cjs +77 -7
- package/dist/dsh-runtime.d.ts +20 -2
- package/dist/dsh-runtime.js +113 -11
- package/dist/interaction-cards.d.ts +8 -3
- package/dist/interaction-cards.js +4 -4
- package/dist/live-reply.d.ts +23 -9
- package/dist/live-reply.js +183 -54
- package/dist/model-controls.d.ts +9 -1
- package/dist/model-controls.js +45 -20
- package/dist/onboarding/host.d.ts +5 -1
- package/dist/onboarding/host.js +30 -3
- package/dist/onboarding/protocol.d.ts +5 -0
- package/dist/onboarding/updates.d.ts +23 -0
- package/dist/onboarding/updates.js +67 -0
- package/dist/permission-controls.d.ts +8 -1
- package/dist/permission-controls.js +18 -7
- package/dist/replies.d.ts +10 -0
- package/dist/replies.js +18 -5
- package/package.json +7 -7
package/dist/bridge.js
CHANGED
|
@@ -26,6 +26,20 @@ const MAX_FILE_BYTES = 30 * 1024 * 1024;
|
|
|
26
26
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
27
27
|
const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
|
|
28
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
|
+
}
|
|
29
43
|
/** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
|
|
30
44
|
async function uploadable(workspace, path) {
|
|
31
45
|
let real;
|
|
@@ -63,7 +77,7 @@ const HELP = `直接把想做的事告诉我就好。
|
|
|
63
77
|
也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
|
|
64
78
|
const HARNESS_HELP = `
|
|
65
79
|
|
|
66
|
-
这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission
|
|
80
|
+
这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。`;
|
|
67
81
|
const ADVANCED_HELP = `高级命令(按需使用):
|
|
68
82
|
/new 或 /reset:归档当前会话,下一条消息开启新会话;长期记忆保留
|
|
69
83
|
/sessions:选择以前的对话;/sessions detail 查看完整列表和 ID
|
|
@@ -331,16 +345,27 @@ export class BotBridge {
|
|
|
331
345
|
if (this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
|
|
332
346
|
message.text = '/model current';
|
|
333
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.
|
|
334
350
|
if (message.expectedSessionId !== undefined) {
|
|
335
351
|
const current = await this.state.sessions.current(this.sessionContext(message));
|
|
336
352
|
if ((current?.id ?? null) !== message.expectedSessionId) {
|
|
353
|
+
if (inPlace)
|
|
354
|
+
return this.refreshPanel(message, lane, '话题刚刚变化,已刷新为最新状态,请重新选择。');
|
|
337
355
|
return this.reply(message, '这张卡片对应的会话状态已改变,请重新发送 /status 或 /sessions 后操作。');
|
|
338
356
|
}
|
|
339
357
|
}
|
|
340
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
|
+
}
|
|
341
363
|
const current = await this.state.modelPreferences?.get(this.context(message));
|
|
342
|
-
if ((current?.revision ?? null) !== message.expectedModelRevision)
|
|
343
|
-
|
|
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
|
+
}
|
|
344
369
|
}
|
|
345
370
|
if (this.state.artifacts && (/^\/(?:result|publish|answer) [a-f0-9]{12}$/.test(text) || text === '/results')) {
|
|
346
371
|
try {
|
|
@@ -381,6 +406,8 @@ export class BotBridge {
|
|
|
381
406
|
}
|
|
382
407
|
}
|
|
383
408
|
if (text.startsWith('/')) {
|
|
409
|
+
if (inPlace)
|
|
410
|
+
return this.panelInPlace(message, lane);
|
|
384
411
|
const feedback = this.commandFeedback(message);
|
|
385
412
|
let response;
|
|
386
413
|
let panel;
|
|
@@ -392,8 +419,7 @@ export class BotBridge {
|
|
|
392
419
|
}
|
|
393
420
|
catch (error) {
|
|
394
421
|
failed = true;
|
|
395
|
-
response = error
|
|
396
|
-
? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
|
|
422
|
+
response = commandError(error);
|
|
397
423
|
this.audit({ stage: 'command-failed', messageId: message.messageId });
|
|
398
424
|
}
|
|
399
425
|
const payload = panel ? renderPanel(panel) : renderPanel({ title: failed ? '暂时未完成' : '已处理',
|
|
@@ -432,8 +458,12 @@ export class BotBridge {
|
|
|
432
458
|
if (this.streams(message, lane.user)) {
|
|
433
459
|
const candidate = new LiveReply(this.liveTransport(), { messageId: message.messageId, inThread: Boolean(message.threadId),
|
|
434
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 }) });
|
|
435
|
-
if (await candidate.open())
|
|
461
|
+
if (await candidate.open()) {
|
|
436
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
|
+
}
|
|
437
467
|
else
|
|
438
468
|
this.liveUnavailableUntil = Date.now() + LIVE_RETRY_MS;
|
|
439
469
|
}
|
|
@@ -501,6 +531,21 @@ export class BotBridge {
|
|
|
501
531
|
lane.controller = undefined;
|
|
502
532
|
}
|
|
503
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
|
+
}
|
|
504
549
|
/** Whether this message's reply streams into a live card, which makes the typing reaction redundant. */
|
|
505
550
|
streams(message, user) {
|
|
506
551
|
return message.chatType === 'p2p' && user.directMode === 'harness' && this.config.streamingCards && this.config.responseMode !== 'text'
|
|
@@ -534,8 +579,9 @@ export class BotBridge {
|
|
|
534
579
|
}
|
|
535
580
|
const outcome = await pending.result;
|
|
536
581
|
const allowed = outcome.kind === 'answer' && outcome.value;
|
|
537
|
-
await this.closeInteraction(card, approvalCard(prompt, undefined, outcome.kind === 'answer'
|
|
538
|
-
|
|
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' }));
|
|
539
585
|
return allowed;
|
|
540
586
|
}
|
|
541
587
|
/** Ask the owner a Harness question. Without an answer the host gets an error and continues. */
|
|
@@ -555,8 +601,8 @@ export class BotBridge {
|
|
|
555
601
|
throw new Error(unavailable);
|
|
556
602
|
}
|
|
557
603
|
const outcome = await pending.result;
|
|
558
|
-
await this.closeInteraction(card, questionCard(questions, undefined, outcome.kind === 'answer' ? answeredText(questions, outcome.value)
|
|
559
|
-
: outcome.kind === 'timeout' ? '10 分钟内没有回答,已按未回答继续。' : '本次处理已停止,提问已关闭。'));
|
|
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' }));
|
|
560
606
|
if (outcome.kind !== 'answer')
|
|
561
607
|
throw new Error(outcome.kind === 'timeout' ? '主人没有在 10 分钟内回答。' : '提问已取消。');
|
|
562
608
|
return outcome.value;
|
|
@@ -656,6 +702,71 @@ export class BotBridge {
|
|
|
656
702
|
if (notes.length)
|
|
657
703
|
await this.reply(message, `有 ${notes.length} 个文件没有发到这里:\n${notes.join('\n')}`, signal);
|
|
658
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
|
+
}
|
|
659
770
|
commandFeedback(message) {
|
|
660
771
|
// Private controls contain no model-generated output. Group replies retain
|
|
661
772
|
// their existing authorization gate and native typing reaction.
|
|
@@ -733,26 +844,29 @@ export class BotBridge {
|
|
|
733
844
|
if (lane.unavailable)
|
|
734
845
|
await this.unload(lane);
|
|
735
846
|
if (lane.session) {
|
|
736
|
-
|
|
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)) {
|
|
737
849
|
if (lane.session.setModelSelection)
|
|
738
850
|
lane.session.setModelSelection(modelPreference.selection);
|
|
739
851
|
else
|
|
740
852
|
await this.unload(lane);
|
|
741
853
|
}
|
|
742
|
-
if (lane.session)
|
|
854
|
+
if (lane.session) {
|
|
855
|
+
await this.adoptPermission(lane);
|
|
743
856
|
return;
|
|
857
|
+
}
|
|
744
858
|
}
|
|
745
859
|
const context = this.sessionContext(message);
|
|
746
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;
|
|
747
863
|
const handle = saved
|
|
748
|
-
? await this.runtime.resume(this.policy(message, lane,
|
|
749
|
-
: 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));
|
|
750
866
|
try {
|
|
751
867
|
await handle.checkpoint();
|
|
752
|
-
await this.applyPermission(message, lane, handle);
|
|
753
|
-
|
|
754
|
-
await this.state.modelPreferences.set(this.context(message), handle.modelSelection(), { expectedRevision: null });
|
|
755
|
-
}
|
|
868
|
+
await this.applyPermission(message, lane, handle, !saved);
|
|
869
|
+
await this.mirrorModel(message, handle, modelPreference);
|
|
756
870
|
// Publish a route only after even the empty native log is durable, and
|
|
757
871
|
// before submitting the first prompt. An abandoned creation has no work.
|
|
758
872
|
lane.saved = saved ? await this.bindRestoredSession(context, saved, handle)
|
|
@@ -781,6 +895,8 @@ export class BotBridge {
|
|
|
781
895
|
lane.unavailable = false;
|
|
782
896
|
return;
|
|
783
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' }));
|
|
784
900
|
lane.unavailable = true;
|
|
785
901
|
try {
|
|
786
902
|
await handle.dispose();
|
|
@@ -855,12 +971,11 @@ export class BotBridge {
|
|
|
855
971
|
return `已经在会话「${target.title}」中。`;
|
|
856
972
|
await this.unload(lane);
|
|
857
973
|
const preference = await this.state.modelPreferences?.get(context);
|
|
858
|
-
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);
|
|
859
975
|
try {
|
|
860
976
|
await handle.checkpoint();
|
|
861
|
-
await this.applyPermission(message, lane, handle);
|
|
862
|
-
|
|
863
|
-
await this.state.modelPreferences.set(context, handle.modelSelection(), { expectedRevision: null });
|
|
977
|
+
await this.applyPermission(message, lane, handle, false);
|
|
978
|
+
await this.mirrorModel(message, handle, preference);
|
|
864
979
|
await this.bindRestoredSession(conversation, target, handle);
|
|
865
980
|
lane.saved = await this.state.sessions.activate(conversation, target.id);
|
|
866
981
|
lane.session = handle;
|
|
@@ -931,6 +1046,22 @@ export class BotBridge {
|
|
|
931
1046
|
harness(message, lane) {
|
|
932
1047
|
return message.chatType === 'p2p' && lane.user.directMode === 'harness';
|
|
933
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
|
+
}
|
|
934
1065
|
/** The host's presets, from the live session when there is one. Undefined when the host offers none. */
|
|
935
1066
|
permissionView(lane) {
|
|
936
1067
|
const live = lane.session?.permissionState?.();
|
|
@@ -943,6 +1074,7 @@ export class BotBridge {
|
|
|
943
1074
|
async permissionChoice(message, lane) {
|
|
944
1075
|
if (lane.unavailable)
|
|
945
1076
|
await this.unload(lane);
|
|
1077
|
+
await this.adoptPermission(lane);
|
|
946
1078
|
const view = this.permissionView(lane);
|
|
947
1079
|
if (!view)
|
|
948
1080
|
return;
|
|
@@ -954,7 +1086,7 @@ export class BotBridge {
|
|
|
954
1086
|
/** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
|
|
955
1087
|
async permissionCommand(message, lane, args) {
|
|
956
1088
|
if (!this.harness(message, lane))
|
|
957
|
-
return '权限模式只能在你和我的私聊里切换。';
|
|
1089
|
+
return message.chatType === 'p2p' ? '这个私聊使用管理员配置的助手,没有可切换的权限模式。' : '权限模式只能在你和我的私聊里切换。';
|
|
958
1090
|
const view = await this.permissionChoice(message, lane);
|
|
959
1091
|
if (!view)
|
|
960
1092
|
throw new DshRuntimeError('PERMISSION_UNAVAILABLE');
|
|
@@ -966,18 +1098,35 @@ export class BotBridge {
|
|
|
966
1098
|
return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
|
|
967
1099
|
// Save first: a later session in this chat starts in the chosen mode even if the live switch fails.
|
|
968
1100
|
await this.state.permissionPreferences?.set(this.context(message), option.id);
|
|
969
|
-
lane.session?.setPermission
|
|
1101
|
+
if (lane.session?.setPermission) {
|
|
1102
|
+
lane.session.setPermission(option.id);
|
|
1103
|
+
lane.permissionSeen = option.id;
|
|
1104
|
+
}
|
|
970
1105
|
message.permissionChanged = true;
|
|
971
|
-
return `已切换为「${option.name}
|
|
1106
|
+
return `已切换为「${option.name}」。这个私聊里的对话都会使用它,电脑上打开这段对话也会看到同样的模式。`;
|
|
972
1107
|
}
|
|
973
|
-
/** Bring a newly opened harness session to the chat's
|
|
974
|
-
async applyPermission(message, lane, handle) {
|
|
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) {
|
|
975
1110
|
if (!this.harness(message, lane) || !handle.setPermission)
|
|
976
1111
|
return;
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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);
|
|
981
1130
|
}
|
|
982
1131
|
async modelState(message, lane) {
|
|
983
1132
|
if (!this.runtime.catalog || !this.runtime.resolveModelSelection || !this.state.modelPreferences)
|
|
@@ -989,6 +1138,17 @@ export class BotBridge {
|
|
|
989
1138
|
const catalog = await this.runtime.catalog();
|
|
990
1139
|
const context = this.context(message);
|
|
991
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
|
+
}
|
|
992
1152
|
if (!preference) {
|
|
993
1153
|
let selection = lane.session?.modelSelection?.();
|
|
994
1154
|
if (!selection) {
|
|
@@ -1013,12 +1173,21 @@ export class BotBridge {
|
|
|
1013
1173
|
return message.modelView = { catalog, selection: preference.selection, preference };
|
|
1014
1174
|
}
|
|
1015
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
|
+
}
|
|
1016
1183
|
const state = await this.modelState(message, lane);
|
|
1017
1184
|
if (!args || args === 'current' || /^page [0-9]{1,4}$/.test(args))
|
|
1018
1185
|
return modelStateText(state.selection, state.catalog);
|
|
1019
|
-
const match = /^use ([a-f0-9]{16})$/.exec(args);
|
|
1020
1186
|
if (!match)
|
|
1021
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();
|
|
1022
1191
|
let candidate = kind === 'model' ? modelChoice(state.catalog, match[1]) : thinkingChoice(state.catalog, state.selection, match[1]);
|
|
1023
1192
|
if (!candidate)
|
|
1024
1193
|
throw new DshRuntimeError('INVALID_MODEL_SELECTION');
|
|
@@ -1027,7 +1196,31 @@ export class BotBridge {
|
|
|
1027
1196
|
const selection = await this.runtime.resolveModelSelection(candidate);
|
|
1028
1197
|
if (!sameSelection(selection, state.selection)) {
|
|
1029
1198
|
const saved = await this.state.modelPreferences.set(this.context(message), selection, { expectedRevision: state.preference.revision });
|
|
1030
|
-
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) {
|
|
1031
1224
|
if (lane.session.setModelSelection) {
|
|
1032
1225
|
try {
|
|
1033
1226
|
lane.session.setModelSelection(saved.selection);
|
|
@@ -1042,17 +1235,26 @@ export class BotBridge {
|
|
|
1042
1235
|
message.modelView = { catalog: state.catalog, selection: saved.selection, preference: saved };
|
|
1043
1236
|
}
|
|
1044
1237
|
message.modelChanged = true;
|
|
1045
|
-
return `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n
|
|
1238
|
+
return shared ? `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留;电脑上的 Harness 也会用这个设置。`
|
|
1239
|
+
: `设置已保存,下一条回复生效。\n${modelStateText(selection, state.catalog)}\n已有对话保留,只影响你在当前聊天中的选择。`;
|
|
1046
1240
|
}
|
|
1047
|
-
|
|
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) {
|
|
1048
1246
|
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
|
|
1049
1247
|
const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
|
|
1248
|
+
const choice = /^use (\S+)$/.exec(args)?.[1];
|
|
1249
|
+
const pending = card?.pending ? choice : undefined;
|
|
1050
1250
|
const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
|
|
1051
1251
|
const issue = (target, extra = {}) => this.state.actions.issue({
|
|
1052
1252
|
ownerId: message.senderId, chatId: message.chatId, chatType: message.chatType, threadId: message.threadId,
|
|
1053
1253
|
sourceMessageId: message.messageId, command: target, ...extra
|
|
1054
1254
|
});
|
|
1055
|
-
if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged)) {
|
|
1255
|
+
if ((command === 'model' || command === 'thinking') && (!args || /^page [0-9]{1,4}$/.test(args) || message.modelChanged || card)) {
|
|
1256
|
+
if (card?.pending && !pending)
|
|
1257
|
+
return;
|
|
1056
1258
|
const state = await this.modelState(message, lane);
|
|
1057
1259
|
const current = await this.state.sessions.current(this.sessionContext(message));
|
|
1058
1260
|
const panel = modelControlPanel(command, state.catalog, state.selection, (label, target, mutation, primary) => {
|
|
@@ -1061,43 +1263,59 @@ export class BotBridge {
|
|
|
1061
1263
|
const token = issue(target, { expectedSessionId: current?.id ?? null,
|
|
1062
1264
|
...(mutation ? { expectedModelRevision: state.preference?.revision ?? null } : {}) });
|
|
1063
1265
|
return { label, value: { token }, primary };
|
|
1064
|
-
}, /^page /.test(args) ? Number(args.slice(5)) :
|
|
1065
|
-
// The settings menu reaches the permission switch from here
|
|
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.
|
|
1066
1268
|
if (interactive && this.harness(message, lane) && this.permissionView(lane))
|
|
1067
|
-
panel.actions = [...panel.actions ?? [],
|
|
1269
|
+
panel.actions = [...panel.actions ?? [],
|
|
1270
|
+
pending ? { label: '权限模式', value: {}, disabled: true } : { label: '权限模式', value: { token: issue('/permission') } }];
|
|
1068
1271
|
return panel;
|
|
1069
1272
|
}
|
|
1070
|
-
if (command === 'permission' && (!args || message.permissionChanged) && this.harness(message, lane)) {
|
|
1273
|
+
if (command === 'permission' && (!args || message.permissionChanged || card) && this.harness(message, lane)) {
|
|
1274
|
+
if (card?.pending && !pending)
|
|
1275
|
+
return;
|
|
1071
1276
|
const view = await this.permissionChoice(message, lane);
|
|
1072
1277
|
if (!view)
|
|
1073
1278
|
return;
|
|
1074
1279
|
return permissionPanel(view, (label, target, primary, confirm) => interactive
|
|
1075
|
-
? { label, value: { token: issue(target) }, primary, ...(confirm ? { confirm } : {}) } : undefined, message.permissionChanged);
|
|
1280
|
+
? { label, value: { token: issue(target) }, primary, ...(confirm ? { confirm } : {}) } : undefined, { changed: message.permissionChanged, pending, error: card?.error, notice: card?.notice });
|
|
1076
1281
|
}
|
|
1077
1282
|
// Ordinary conversation and diagnostics stay out of management cards.
|
|
1078
1283
|
// A card is useful here because the user explicitly wants to pick a topic.
|
|
1079
|
-
|
|
1284
|
+
const topic = card && (command === 'resume' || command === 'new');
|
|
1285
|
+
if ((command !== 'sessions' || args === 'detail') && !topic)
|
|
1080
1286
|
return;
|
|
1081
1287
|
const conversation = this.sessionContext(message);
|
|
1082
1288
|
const current = await this.state.sessions.current(conversation);
|
|
1083
1289
|
const sessions = await this.state.sessions.list(conversation);
|
|
1084
1290
|
if (!sessions.length)
|
|
1085
1291
|
return;
|
|
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;
|
|
1086
1299
|
const actions = [];
|
|
1087
|
-
const add = (label,
|
|
1300
|
+
const add = (label, command, options = {}) => {
|
|
1301
|
+
if (busy) {
|
|
1302
|
+
actions.push({ label, value: {}, disabled: true });
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1088
1305
|
if (!interactive)
|
|
1089
1306
|
return;
|
|
1090
1307
|
const token = this.state.actions.issue({ ownerId: message.senderId, chatId: message.chatId,
|
|
1091
1308
|
chatType: message.chatType, threadId: message.threadId, sourceMessageId: message.messageId,
|
|
1092
|
-
command
|
|
1093
|
-
actions.push({ label, value: { token },
|
|
1309
|
+
command, ...(options.expectedSessionId === undefined ? {} : { expectedSessionId: options.expectedSessionId }) });
|
|
1310
|
+
actions.push({ label, value: { token }, confirm: options.confirm });
|
|
1094
1311
|
};
|
|
1095
1312
|
for (const session of sessions.filter(s => s.id !== current?.id).slice(0, 5))
|
|
1096
1313
|
add(`继续 ${preview(session.title, 14)}`, `/resume ${session.id}`, { expectedSessionId: current?.id ?? null, confirm: { title: '接着这个话题聊?', text: `继续「${preview(session.title, 40)}」,现在的对话会保留。` } });
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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,
|
|
1101
1319
|
sections: sessions.slice(0, 5).map(s => ({ title: preview(s.title, 40), text: s.id === current?.id ? '正在聊这个话题' : '可以接着聊' })),
|
|
1102
1320
|
...(!interactive || sessions.length > 5 ? { note: '完整列表和更多操作:/sessions detail' } : {}),
|
|
1103
1321
|
};
|
|
@@ -1287,6 +1505,8 @@ export class BotBridge {
|
|
|
1287
1505
|
await Promise.all([...this.lanes.values()].map(lane => lane.tail));
|
|
1288
1506
|
await this.sweepTail;
|
|
1289
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)));
|
|
1290
1510
|
const sessions = new Set([...this.failedDisposals, ...[...this.lanes.values()].flatMap(lane => lane.session ? [lane.session] : [])]);
|
|
1291
1511
|
const results = await Promise.allSettled([...sessions].map(session => session.dispose()));
|
|
1292
1512
|
if (results.some(result => result.status === 'rejected'))
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { ConnectionApi } from './api.js';
|
|
2
|
+
/** `app` is the desktop App; `openPlugins` switches to its plugin list and reports whether it could. */
|
|
2
3
|
export interface ConnectionCardProps {
|
|
3
4
|
api: ConnectionApi;
|
|
5
|
+
host?: {
|
|
6
|
+
app: boolean;
|
|
7
|
+
openPlugins?: () => boolean;
|
|
8
|
+
};
|
|
4
9
|
}
|
|
5
10
|
/** The host is authoritative for every transition, including readiness and identity. */
|
|
6
|
-
export declare function ConnectionCard({ api }: ConnectionCardProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export declare function ConnectionCard({ api, host }: ConnectionCardProps): import("react/jsx-runtime").JSX.Element;
|
package/dist/client/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ClientContext {
|
|
|
20
20
|
inject: () => ConnectionCardProps;
|
|
21
21
|
}, component: ComponentType<ConnectionCardProps>): () => void;
|
|
22
22
|
};
|
|
23
|
+
/** Optional services by name, such as the App's plugin page navigation. */
|
|
24
|
+
get?(name: string): unknown;
|
|
23
25
|
}
|
|
24
26
|
export declare const inject: string[];
|
|
25
27
|
/** Add a dedicated settings tab; both host versions own this public slot. */
|