dsh-lark-agent 0.3.2 → 0.4.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 +4 -4
- package/dist/bot-menu.d.ts +12 -0
- package/dist/bot-menu.js +14 -0
- package/dist/bot-service.js +4 -2
- package/dist/bridge.d.ts +25 -0
- package/dist/bridge.js +316 -57
- package/dist/card-actions.js +3 -1
- package/dist/client.cjs +23 -1
- package/dist/config.d.ts +2 -1
- package/dist/config.js +2 -2
- package/dist/dsh-runtime.js +2 -0
- package/dist/lark-cli.d.ts +13 -1
- package/dist/lark-cli.js +63 -1
- package/dist/live-reply.js +8 -5
- package/dist/onboarding/controller.d.ts +2 -0
- package/dist/onboarding/controller.js +7 -1
- package/dist/onboarding/host.js +5 -17
- package/dist/onboarding/lark-platform.d.ts +2 -0
- package/dist/onboarding/lark-platform.js +5 -0
- package/dist/onboarding/model-check.d.ts +5 -0
- package/dist/onboarding/model-check.js +51 -0
- package/dist/onboarding/protocol.d.ts +9 -0
- package/dist/replies.d.ts +16 -0
- package/dist/replies.js +37 -11
- package/dist/session-controls.d.ts +34 -0
- package/dist/session-controls.js +66 -0
- package/dist/session-store.d.ts +2 -0
- package/dist/session-store.js +12 -7
- package/dist/topic-cards.d.ts +3 -0
- package/dist/topic-cards.js +8 -0
- package/dist/topic-store.d.ts +40 -0
- package/dist/topic-store.js +192 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -15,8 +15,13 @@ import { PresentationError, presentationDraft, presentationCard, presentationTex
|
|
|
15
15
|
import { LiveReply } from './live-reply.js';
|
|
16
16
|
import { answeredText, approvalCard, questionAnswers, questionCard, simpleQuestion } from './interaction-cards.js';
|
|
17
17
|
import { permissionPanel, permissionStateText } from './permission-controls.js';
|
|
18
|
+
import { TopicStoreError } from './topic-store.js';
|
|
19
|
+
import { sessionControlPanel, sessionPage, sessionQuery, sessionViewCommandParse } from './session-controls.js';
|
|
18
20
|
class ArtifactDeliveryError extends Error {
|
|
19
21
|
}
|
|
22
|
+
/** A creation may have reached Lark. Never send another message as a fallback. */
|
|
23
|
+
class TopicDeliveryError extends Error {
|
|
24
|
+
}
|
|
20
25
|
const APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
21
26
|
const QUESTION_TIMEOUT_MS = 10 * 60_000;
|
|
22
27
|
// After a live card cannot be created (for example, before the CardKit scope is
|
|
@@ -27,7 +32,7 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
|
27
32
|
const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
|
|
28
33
|
const STOPPING_TEXT = '连接正在关闭,本次处理已停止。';
|
|
29
34
|
// 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
|
|
35
|
+
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(?: page [1-9][0-9]?(?: [A-Za-z0-9_-]{1,428})?)?|new|resume [a-f0-9]{12})$/;
|
|
31
36
|
// A switch that finishes sooner goes straight to its result, without a loading frame.
|
|
32
37
|
const PANEL_PROGRESS_DELAY_MS = 150;
|
|
33
38
|
const STALE_SETTINGS = '设置刚刚变化,已刷新为最新选项,请重新选择。';
|
|
@@ -37,7 +42,7 @@ class StaleCardError extends Error {
|
|
|
37
42
|
}
|
|
38
43
|
function commandError(error) {
|
|
39
44
|
return error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError
|
|
40
|
-
|| error instanceof ModelPreferencesError || error instanceof StaleCardError
|
|
45
|
+
|| error instanceof ModelPreferencesError || error instanceof StaleCardError || error instanceof TopicStoreError || error instanceof TopicDeliveryError
|
|
41
46
|
? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
|
|
42
47
|
}
|
|
43
48
|
/** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
|
|
@@ -73,15 +78,16 @@ const HELP = `直接把想做的事告诉我就好。
|
|
|
73
78
|
|
|
74
79
|
比如:帮我理清一个问题、整理一段文字,或者接着刚才的话题继续。你也可以在私聊告诉我,以后希望我怎样配合你。
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
私聊底部菜单可以新建话题、搜索和选择历史对话,或调整模型和思考深度。
|
|
82
|
+
私聊和群聊的新问题都会开一个飞书话题;新话题卡片下方点「回复话题」继续,群聊中仍需 @我。
|
|
77
83
|
也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
|
|
78
84
|
const HARNESS_HELP = `
|
|
79
85
|
|
|
80
86
|
这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。`;
|
|
81
87
|
const ADVANCED_HELP = `高级命令(按需使用):
|
|
82
|
-
/new 或 /reset
|
|
83
|
-
/sessions
|
|
84
|
-
/resume <ID
|
|
88
|
+
/new 或 /reset:创建新的飞书话题,在新话题里开始;旧话题和长期记忆保留
|
|
89
|
+
/sessions:搜索、翻页并选择以前的对话;/sessions detail 查看 ID,/sessions page <页码> 查看其他页
|
|
90
|
+
/resume <ID>:打开列表中会话对应的话题,旧会话首次使用时补建话题入口
|
|
85
91
|
/rename <标题>:给当前会话命名
|
|
86
92
|
/status:看看我是否在线;/status detail 查看会话和记忆诊断
|
|
87
93
|
/stop:停止当前处理并取消排队消息
|
|
@@ -149,6 +155,8 @@ export class BotBridge {
|
|
|
149
155
|
progress;
|
|
150
156
|
interactions = new Map();
|
|
151
157
|
liveUnavailableUntil = 0;
|
|
158
|
+
topicCreations = new Map();
|
|
159
|
+
topicResumes = new Map();
|
|
152
160
|
constructor(config, runtime, transport, ledger, state, audit = () => undefined, progressTransport, artifactPublisher) {
|
|
153
161
|
this.config = config;
|
|
154
162
|
this.runtime = runtime;
|
|
@@ -167,7 +175,7 @@ export class BotBridge {
|
|
|
167
175
|
if (!this.config.cardInteractions || !this.state.actions || !raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
168
176
|
return;
|
|
169
177
|
const data = raw;
|
|
170
|
-
if (data.type !== 'card.action.trigger' || data.action_tag !== 'button'
|
|
178
|
+
if (data.type !== 'card.action.trigger' || (data.action_tag !== 'button' && data.action_tag !== 'select_static')
|
|
171
179
|
|| typeof data.event_id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(data.event_id)
|
|
172
180
|
|| typeof data.message_id !== 'string' || !/^om_[a-zA-Z0-9_-]+$/.test(data.message_id)
|
|
173
181
|
|| typeof data.action_value !== 'string' || data.action_value.length > 4000)
|
|
@@ -187,6 +195,39 @@ export class BotBridge {
|
|
|
187
195
|
this.audit({ stage: 'card-action-rejected' });
|
|
188
196
|
return;
|
|
189
197
|
}
|
|
198
|
+
let command = binding.command;
|
|
199
|
+
let sessionView;
|
|
200
|
+
const browser = sessionViewCommandParse(command);
|
|
201
|
+
if (data.action_tag === 'select_static') {
|
|
202
|
+
// A selector can only navigate this owner's catalog, never invoke another signed capability.
|
|
203
|
+
if (browser?.kind !== 'pick' || data.action_name !== 'dsh_session_picker'
|
|
204
|
+
|| typeof data.option !== 'string' || !/^[a-f0-9]{12}$/.test(data.option))
|
|
205
|
+
return;
|
|
206
|
+
sessionView = browser.view;
|
|
207
|
+
command = `/resume ${data.option}`;
|
|
208
|
+
}
|
|
209
|
+
else if (browser?.kind === 'pick')
|
|
210
|
+
return;
|
|
211
|
+
else if (browser)
|
|
212
|
+
sessionView = browser.view;
|
|
213
|
+
else if (command === '/sessions search') {
|
|
214
|
+
if (data.action_name !== 'dsh_sessions_search' || typeof data.form_value !== 'string' || data.form_value.length > 1000)
|
|
215
|
+
return;
|
|
216
|
+
let fields;
|
|
217
|
+
try {
|
|
218
|
+
fields = JSON.parse(data.form_value);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length !== 1 || !('query' in fields))
|
|
224
|
+
return;
|
|
225
|
+
const query = sessionQuery(fields.query);
|
|
226
|
+
if (query === undefined)
|
|
227
|
+
return;
|
|
228
|
+
sessionView = { page: 0, query };
|
|
229
|
+
command = '/sessions';
|
|
230
|
+
}
|
|
190
231
|
let formValues;
|
|
191
232
|
if (binding.command.startsWith('/answer ') || binding.command.startsWith('/reply ')) {
|
|
192
233
|
if (data.action_name !== 'dsh_submit' || typeof data.form_value !== 'string' || data.form_value.length > 8000)
|
|
@@ -200,12 +241,12 @@ export class BotBridge {
|
|
|
200
241
|
if (!formValues || typeof formValues !== 'object' || Array.isArray(formValues))
|
|
201
242
|
return;
|
|
202
243
|
}
|
|
203
|
-
const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(
|
|
244
|
+
const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(command);
|
|
204
245
|
return { messageId: data.message_id, senderId: binding.ownerId, chatId: binding.chatId,
|
|
205
|
-
chatType: binding.chatType, threadId: binding.threadId, text:
|
|
246
|
+
chatType: binding.chatType, threadId: binding.threadId, text: command,
|
|
206
247
|
requestId: `card:${data.event_id}`, actionNonce: mutation ? binding.nonce : undefined,
|
|
207
248
|
expectedSessionId: binding.expectedSessionId, expectedModelRevision: binding.expectedModelRevision,
|
|
208
|
-
...(formValues === undefined ? {} : { formValues }) };
|
|
249
|
+
...(formValues === undefined ? {} : { formValues }), ...(sessionView ? { sessionView } : {}) };
|
|
209
250
|
}
|
|
210
251
|
accept(raw) {
|
|
211
252
|
const admission = this.acceptMessage(raw);
|
|
@@ -270,7 +311,26 @@ export class BotBridge {
|
|
|
270
311
|
await this.answerInteraction(message);
|
|
271
312
|
return;
|
|
272
313
|
}
|
|
273
|
-
|
|
314
|
+
if (this.topicsEnabled() && message.threadId) {
|
|
315
|
+
// A thread event can arrive as soon as the opening reply is visible.
|
|
316
|
+
// Wait for its binding, not the model turn that follows it.
|
|
317
|
+
message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
|
|
318
|
+
if (!message.topic) {
|
|
319
|
+
await Promise.all(this.topicCreations.get(this.topicScope(message).chatKey) ?? []);
|
|
320
|
+
if (this.stopped)
|
|
321
|
+
return;
|
|
322
|
+
message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
|
|
323
|
+
}
|
|
324
|
+
message.foreignTopic = !message.topic && await this.state.topics.occupied(this.topicScope(message).chatKey, message.threadId);
|
|
325
|
+
}
|
|
326
|
+
// Recognized settings questions use the entry lane before any provisional topic context is allocated.
|
|
327
|
+
if (this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
|
|
328
|
+
message.text = '/model current';
|
|
329
|
+
const startsTopic = this.topicsEnabled() && !message.threadId && !message.text.startsWith('/');
|
|
330
|
+
const key = message.topic?.laneKey ?? (startsTopic ? this.newTopicKey(message) : sessionKey(message, this.config));
|
|
331
|
+
if (startsTopic)
|
|
332
|
+
message.conversationKey = key;
|
|
333
|
+
message.laneKey = key;
|
|
274
334
|
let lane = this.lanes.get(key);
|
|
275
335
|
if (message.text === '/stop') {
|
|
276
336
|
if (lane) {
|
|
@@ -319,6 +379,11 @@ export class BotBridge {
|
|
|
319
379
|
current.pending--;
|
|
320
380
|
this.pending--;
|
|
321
381
|
current.lastUsed = Date.now();
|
|
382
|
+
// Entry commands create no native session. Do not let their empty lane
|
|
383
|
+
// take the last slot needed by the topic they just opened.
|
|
384
|
+
if (this.topicsEnabled() && !current.pending && !current.session && !current.saved && !current.unavailable
|
|
385
|
+
&& this.lanes.get(key) === current)
|
|
386
|
+
this.lanes.delete(key);
|
|
322
387
|
});
|
|
323
388
|
current.tail = job;
|
|
324
389
|
// Release ingress before waiting for generation, while pending remains bounded.
|
|
@@ -333,6 +398,12 @@ export class BotBridge {
|
|
|
333
398
|
}
|
|
334
399
|
async process(message, lane) {
|
|
335
400
|
const generation = lane.generation;
|
|
401
|
+
if (this.topicsEnabled() && message.threadId && !message.foreignTopic) {
|
|
402
|
+
// The first turn may have published its session while this message queued.
|
|
403
|
+
message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
|
|
404
|
+
if (!message.topic)
|
|
405
|
+
message.foreignTopic = await this.state.topics.occupied(this.topicScope(message).chatKey, message.threadId);
|
|
406
|
+
}
|
|
336
407
|
if (message.chatType === 'group') {
|
|
337
408
|
message.groupAccessKey = await this.state.memories.groupAccessKey(this.context(message));
|
|
338
409
|
if (lane.groupAccessKey !== message.groupAccessKey) {
|
|
@@ -342,8 +413,15 @@ export class BotBridge {
|
|
|
342
413
|
lane.groupAccessKey = message.groupAccessKey;
|
|
343
414
|
}
|
|
344
415
|
}
|
|
345
|
-
if (this.
|
|
346
|
-
message.text
|
|
416
|
+
if (this.topicsEnabled() && (message.foreignTopic || (message.topic && message.topic.groupAccessKey !== message.groupAccessKey))) {
|
|
417
|
+
const fresh = /^\/(?:new|reset|clear)$/i.test(message.text) && message.expectedSessionId === undefined;
|
|
418
|
+
if (!fresh)
|
|
419
|
+
return this.reply(message, message.foreignTopic
|
|
420
|
+
? '这个话题已有独立的会话。请在主聊天中 @我,或发送 /new 开始你自己的话题。'
|
|
421
|
+
: '记忆授权已变化,这个话题的旧上下文已停用。请发送 /new 开始新话题。');
|
|
422
|
+
message.topic = undefined;
|
|
423
|
+
message.conversationKey = this.entryKey(message);
|
|
424
|
+
}
|
|
347
425
|
let text = message.text;
|
|
348
426
|
const inPlace = this.inPlace(message);
|
|
349
427
|
// A stale card is redrawn with the current state instead of answered with another message.
|
|
@@ -408,16 +486,23 @@ export class BotBridge {
|
|
|
408
486
|
if (text.startsWith('/')) {
|
|
409
487
|
if (inPlace)
|
|
410
488
|
return this.panelInPlace(message, lane);
|
|
411
|
-
const feedback = this.
|
|
489
|
+
const feedback = this.topicsEnabled() && /^\/(?:new|reset|clear|resume)(?:\s|$)/i.test(text)
|
|
490
|
+
? undefined : this.commandFeedback(message);
|
|
412
491
|
let response;
|
|
413
492
|
let panel;
|
|
414
493
|
let failed = false;
|
|
415
494
|
try {
|
|
416
495
|
response = await this.command(message, lane);
|
|
496
|
+
// A newly posted entry card already confirms the operation and owns its thread.
|
|
497
|
+
// A clicked private picker is handled above and still updates its existing card.
|
|
498
|
+
if (message.topicResult?.newEntry)
|
|
499
|
+
return;
|
|
417
500
|
if (this.config.responseMode !== 'text' && (message.menu ? this.transport.sendRichUser : this.transport.replyRich))
|
|
418
501
|
panel = await this.commandPanel(message, lane);
|
|
419
502
|
}
|
|
420
503
|
catch (error) {
|
|
504
|
+
if (error instanceof TopicDeliveryError)
|
|
505
|
+
throw error;
|
|
421
506
|
failed = true;
|
|
422
507
|
response = commandError(error);
|
|
423
508
|
this.audit({ stage: 'command-failed', messageId: message.messageId });
|
|
@@ -429,13 +514,33 @@ export class BotBridge {
|
|
|
429
514
|
}
|
|
430
515
|
if (text.length > this.config.maxInputChars)
|
|
431
516
|
return this.reply(message, '消息过长,请缩短后重试。');
|
|
517
|
+
if (this.stopped || lane.generation !== generation)
|
|
518
|
+
return;
|
|
519
|
+
if (this.topicsEnabled()) {
|
|
520
|
+
if (!message.threadId) {
|
|
521
|
+
const binding = await this.createTopic(message, preview(text, 40) || '新话题', message.conversationKey);
|
|
522
|
+
message.requestId ??= message.messageId;
|
|
523
|
+
message.messageId = binding.messageId;
|
|
524
|
+
message.threadId = binding.threadId;
|
|
525
|
+
message.topic = binding;
|
|
526
|
+
}
|
|
527
|
+
else if (!message.topic) {
|
|
528
|
+
// A user may start from a native topic that already exists in Lark.
|
|
529
|
+
const saved = await this.state.sessions.current(this.sessionContext(message));
|
|
530
|
+
message.topic = await this.state.topics.record({ ...this.topicScope(message), threadId: message.threadId,
|
|
531
|
+
messageId: message.messageId, laneKey: message.laneKey, conversationKey: this.context(message).conversationKey,
|
|
532
|
+
...(saved ? { sessionId: saved.id } : {}), ...(message.groupAccessKey ? { groupAccessKey: message.groupAccessKey } : {}) });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
432
535
|
if (this.stopped || lane.generation !== generation)
|
|
433
536
|
return;
|
|
434
537
|
const controller = new AbortController();
|
|
435
538
|
lane.controller = controller;
|
|
436
|
-
let timedOut = false;
|
|
437
|
-
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.config.turnTimeoutMs);
|
|
438
539
|
const harness = message.chatType === 'p2p' && lane.user.directMode === 'harness';
|
|
540
|
+
let timedOut = false;
|
|
541
|
+
// Like the App, a turn runs until it ends or someone sends /stop. Only a configured cap bounds a restricted turn.
|
|
542
|
+
const deadline = harness ? undefined : this.config.turnTimeoutMs;
|
|
543
|
+
const timer = deadline === undefined ? undefined : setTimeout(() => { timedOut = true; controller.abort(); }, deadline);
|
|
439
544
|
let live;
|
|
440
545
|
// Once a live card exists it carries this turn's whole outcome; nothing else is sent as text.
|
|
441
546
|
const conclude = async (final) => {
|
|
@@ -798,11 +903,95 @@ export class BotBridge {
|
|
|
798
903
|
this.audit({ stage: 'feedback-updated', messageId });
|
|
799
904
|
});
|
|
800
905
|
}
|
|
906
|
+
topicsEnabled() { return Boolean(this.state.topics && this.transport.openTopic); }
|
|
907
|
+
topicScope(message) {
|
|
908
|
+
return { ownerKey: ownerKey(message, this.config), chatKey: createHash('sha256').update(JSON.stringify([
|
|
909
|
+
this.config.profile, this.config.botOpenId, message.chatType, message.chatId,
|
|
910
|
+
])).digest('hex') };
|
|
911
|
+
}
|
|
912
|
+
newTopicKey(message) {
|
|
913
|
+
return createHash('sha256').update(JSON.stringify([sessionKey({ ...message, threadId: '' }, this.config),
|
|
914
|
+
message.requestId ?? message.messageId, 'lark-topic-v1'])).digest('hex');
|
|
915
|
+
}
|
|
916
|
+
entryKey(message) {
|
|
917
|
+
return createHash('sha256').update(JSON.stringify([sessionKey({ ...message, threadId: '' }, this.config), 'lark-entry-v1'])).digest('hex');
|
|
918
|
+
}
|
|
919
|
+
reserveTopic(message) {
|
|
920
|
+
const key = this.topicScope(message).chatKey;
|
|
921
|
+
const pending = this.topicCreations.get(key) ?? new Set();
|
|
922
|
+
let done;
|
|
923
|
+
const promise = new Promise(resolve => { done = resolve; });
|
|
924
|
+
pending.add(promise);
|
|
925
|
+
this.topicCreations.set(key, pending);
|
|
926
|
+
return () => { done(); pending.delete(promise); if (!pending.size && this.topicCreations.get(key) === pending)
|
|
927
|
+
this.topicCreations.delete(key); };
|
|
928
|
+
}
|
|
929
|
+
async createTopic(message, title, conversationKey, sessionId) {
|
|
930
|
+
const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId,
|
|
931
|
+
message.requestId ?? message.messageId, 'topic', sessionId ?? 'new'])).digest('hex').slice(0, 40);
|
|
932
|
+
const target = { chatId: message.chatId,
|
|
933
|
+
...(!message.threadId && !message.menu && !message.requestId?.startsWith('card:') && !sessionId && !message.text.startsWith('/')
|
|
934
|
+
? { messageId: message.messageId } : {}) };
|
|
935
|
+
let receipt;
|
|
936
|
+
const release = this.reserveTopic(message);
|
|
937
|
+
try {
|
|
938
|
+
if (message.chatType === 'p2p') {
|
|
939
|
+
const context = this.permissionContext(message);
|
|
940
|
+
for (const lane of this.lanes.values()) {
|
|
941
|
+
if (lane.permissionContext?.ownerKey === context.ownerKey && lane.permissionContext.conversationKey === context.conversationKey) {
|
|
942
|
+
await this.adoptPermission(lane);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (!sessionId && this.state.modelPreferences) {
|
|
947
|
+
const user = this.config.users.find(item => item.openId === message.senderId);
|
|
948
|
+
if (!this.sharedModel(message, { user })) {
|
|
949
|
+
const targetContext = { ownerKey: ownerKey(message, this.config), conversationKey };
|
|
950
|
+
// Capture the current topic's choice, or the main chat's selection,
|
|
951
|
+
// once. Later main-chat changes must not retarget an existing topic.
|
|
952
|
+
const source = await this.state.modelPreferences.get(this.context(message))
|
|
953
|
+
?? await this.state.modelPreferences.get({ ...targetContext, conversationKey: this.entryKey(message) })
|
|
954
|
+
?? await this.state.modelPreferences.get({ ...targetContext, conversationKey: sessionKey({ ...message, threadId: '' }, this.config) });
|
|
955
|
+
if (source && !await this.state.modelPreferences.get(targetContext)) {
|
|
956
|
+
await this.state.modelPreferences.set(targetContext, source.selection, { expectedRevision: null });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const send = async () => {
|
|
961
|
+
if (!this.stopped)
|
|
962
|
+
receipt = await this.transport.openTopic(target, title, { idempotencyKey, chatType: message.chatType, textOnly: this.config.responseMode === 'text' });
|
|
963
|
+
};
|
|
964
|
+
// Do not read or mutate MemoryStore inside its authorization/send critical section.
|
|
965
|
+
if (message.chatType === 'group')
|
|
966
|
+
await this.state.memories.withGroupAccess(this.context(message), message.groupAccessKey, send);
|
|
967
|
+
else
|
|
968
|
+
await send();
|
|
969
|
+
if (!receipt)
|
|
970
|
+
throw new Error();
|
|
971
|
+
return await this.state.topics.record({ ...this.topicScope(message), threadId: receipt.threadId, messageId: receipt.messageId,
|
|
972
|
+
...(receipt.link ? { link: receipt.link } : {}),
|
|
973
|
+
laneKey: sessionId ? createHash('sha256').update(JSON.stringify([conversationKey, sessionId, 'topic-lane-v1'])).digest('hex') : conversationKey,
|
|
974
|
+
conversationKey, ...(sessionId ? { sessionId } : {}), ...(message.groupAccessKey ? { groupAccessKey: message.groupAccessKey } : {}) });
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
this.audit({ stage: 'topic-creation-unconfirmed', messageId: message.messageId });
|
|
978
|
+
throw new TopicDeliveryError('话题创建结果未能确认,已停止处理;请检查聊天记录。');
|
|
979
|
+
}
|
|
980
|
+
finally {
|
|
981
|
+
release();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
801
984
|
context(message) {
|
|
802
|
-
|
|
985
|
+
const routed = message;
|
|
986
|
+
return { ownerKey: ownerKey(message, this.config), conversationKey: routed.topic?.conversationKey ?? routed.conversationKey
|
|
987
|
+
?? (this.topicsEnabled() && !message.threadId ? this.entryKey(message) : sessionKey(message, this.config)), privateChat: message.chatType === 'p2p' };
|
|
803
988
|
}
|
|
804
989
|
sessionContext(message) {
|
|
805
|
-
|
|
990
|
+
return { ...this.catalogContext(message, this.context(message).conversationKey),
|
|
991
|
+
...(message.topic?.sessionId ? { sessionId: message.topic.sessionId } : {}) };
|
|
992
|
+
}
|
|
993
|
+
catalogContext(message, conversationKey) {
|
|
994
|
+
const context = { ownerKey: ownerKey(message, this.config), conversationKey, privateChat: message.chatType === 'p2p' };
|
|
806
995
|
if (message.chatType === 'p2p')
|
|
807
996
|
return context;
|
|
808
997
|
if (!message.groupAccessKey)
|
|
@@ -813,6 +1002,50 @@ export class BotBridge {
|
|
|
813
1002
|
context.conversationKey, 'group-memory-v1', message.groupAccessKey,
|
|
814
1003
|
])).digest('hex') };
|
|
815
1004
|
}
|
|
1005
|
+
/** Only this user's chat and current disclosure epoch participate in the picker. */
|
|
1006
|
+
async topicSessions(message) {
|
|
1007
|
+
const topics = (await this.state.topics.list(this.topicScope(message)))
|
|
1008
|
+
.filter(item => item.groupAccessKey === message.groupAccessKey);
|
|
1009
|
+
const contexts = new Set([sessionKey({ ...message, threadId: '' }, this.config), this.context(message).conversationKey,
|
|
1010
|
+
...topics.map(item => item.conversationKey)]);
|
|
1011
|
+
const sessions = await Promise.all([...contexts].map(async (conversationKey) => (await this.state.sessions.list(this.catalogContext(message, conversationKey)))
|
|
1012
|
+
.map(session => ({ ...session, conversationKey, topic: topics.find(item => item.sessionId === session.id) }))));
|
|
1013
|
+
return [...new Map(sessions.flat().map(session => [session.id, session])).values()]
|
|
1014
|
+
.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
1015
|
+
}
|
|
1016
|
+
async continueTopic(message, session) {
|
|
1017
|
+
let binding = session.topic;
|
|
1018
|
+
if (!binding) {
|
|
1019
|
+
const key = JSON.stringify([this.topicScope(message), session.id]);
|
|
1020
|
+
let pending = this.topicResumes.get(key);
|
|
1021
|
+
if (!pending) {
|
|
1022
|
+
pending = (async () => {
|
|
1023
|
+
// Another lane may have finished since this picker took its snapshot.
|
|
1024
|
+
const existing = (await this.state.topics.list(this.topicScope(message)))
|
|
1025
|
+
.find(item => item.sessionId === session.id && item.groupAccessKey === message.groupAccessKey);
|
|
1026
|
+
return existing ?? this.createTopic(message, session.title, session.conversationKey, session.id);
|
|
1027
|
+
})();
|
|
1028
|
+
this.topicResumes.set(key, pending);
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
binding = await pending;
|
|
1032
|
+
}
|
|
1033
|
+
finally {
|
|
1034
|
+
if (this.topicResumes.get(key) === pending)
|
|
1035
|
+
this.topicResumes.delete(key);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (!binding.link && this.transport.topicLink) {
|
|
1039
|
+
const link = await this.transport.topicLink(binding.messageId).catch(() => undefined);
|
|
1040
|
+
if (link)
|
|
1041
|
+
binding = await this.state.topics.record({ ...binding, link });
|
|
1042
|
+
}
|
|
1043
|
+
return binding;
|
|
1044
|
+
}
|
|
1045
|
+
topicNavigation(binding, title) {
|
|
1046
|
+
return binding.link ? `已准备好「${title}」。[打开话题继续](${binding.link})。`
|
|
1047
|
+
: `已准备好「${title}」。请在聊天记录中打开对应话题继续。`;
|
|
1048
|
+
}
|
|
816
1049
|
policy(message, lane, selection) {
|
|
817
1050
|
const context = this.context(message);
|
|
818
1051
|
return {
|
|
@@ -853,6 +1086,8 @@ export class BotBridge {
|
|
|
853
1086
|
}
|
|
854
1087
|
if (lane.session) {
|
|
855
1088
|
await this.adoptPermission(lane);
|
|
1089
|
+
if (this.topicsEnabled())
|
|
1090
|
+
await this.applyPermission(message, lane, lane.session, false);
|
|
856
1091
|
return;
|
|
857
1092
|
}
|
|
858
1093
|
}
|
|
@@ -871,6 +1106,9 @@ export class BotBridge {
|
|
|
871
1106
|
// before submitting the first prompt. An abandoned creation has no work.
|
|
872
1107
|
lane.saved = saved ? await this.bindRestoredSession(context, saved, handle)
|
|
873
1108
|
: await this.state.sessions.add(context, handle.id, preview(message.text, 40) || '新会话');
|
|
1109
|
+
if (message.topic && !message.topic.sessionId) {
|
|
1110
|
+
message.topic = await this.state.topics.record({ ...message.topic, sessionId: lane.saved.id });
|
|
1111
|
+
}
|
|
874
1112
|
lane.restored = Boolean(saved);
|
|
875
1113
|
lane.session = handle;
|
|
876
1114
|
}
|
|
@@ -937,6 +1175,11 @@ export class BotBridge {
|
|
|
937
1175
|
if (command === 'new' || command === 'reset' || command === 'clear') {
|
|
938
1176
|
if (args)
|
|
939
1177
|
return '用法:/new;创建新会话后可用 /rename <标题> 命名。';
|
|
1178
|
+
if (this.topicsEnabled()) {
|
|
1179
|
+
const binding = await this.createTopic(message, '新话题', this.newTopicKey(message));
|
|
1180
|
+
message.topicResult = { binding, title: '新话题', newEntry: true };
|
|
1181
|
+
return this.topicNavigation(binding, '新话题');
|
|
1182
|
+
}
|
|
940
1183
|
await this.unload(lane);
|
|
941
1184
|
await this.state.sessions.archive(conversation);
|
|
942
1185
|
lane.saved = undefined;
|
|
@@ -944,14 +1187,17 @@ export class BotBridge {
|
|
|
944
1187
|
return '好,我们换个话题。之前的对话还在,需要时可以用 /sessions 找回来。';
|
|
945
1188
|
}
|
|
946
1189
|
if (command === 'sessions') {
|
|
947
|
-
const sessions = await this.state.sessions.list(conversation);
|
|
1190
|
+
const sessions = this.topicsEnabled() ? await this.topicSessions(message) : await this.state.sessions.list(conversation);
|
|
948
1191
|
const current = await this.state.sessions.current(conversation);
|
|
1192
|
+
const view = message.sessionView ?? sessionViewCommandParse(message.text)?.view ?? { page: 0, query: '' };
|
|
1193
|
+
const page = sessionPage(sessions, view);
|
|
949
1194
|
if (args !== 'detail')
|
|
950
|
-
return sessions.length ? '
|
|
951
|
-
.map(s => `${s.id === current?.id ? '正在聊 · ' : ''}${s.title}`).join('\n')
|
|
952
|
-
+ '\n
|
|
1195
|
+
return sessions.length ? `当前聊天的话题(${page.total} 个${view.query ? '匹配' : ''},第 ${page.view.page + 1}/${page.pages} 页):\n` + page.items
|
|
1196
|
+
.map(s => `${s.id === current?.id ? '正在聊 · ' : ''}${s.title}\n/resume ${s.id}`).join('\n')
|
|
1197
|
+
+ '\n可在卡片中搜索或选择话题。文字操作:/sessions page <页码>。'
|
|
953
1198
|
: '这里还没有以前的对话,直接开始聊就好。';
|
|
954
|
-
return sessions.length ?
|
|
1199
|
+
return sessions.length ? `当前聊天的会话(${sessions.length} 个):\n` + sessions.slice(0, 20).map(s => `${s.id}${s.id === current?.id ? ' [当前]' : ''} · ${s.title}`).join('\n')
|
|
1200
|
+
+ (this.topicsEnabled() ? '\n用 /resume <ID> 打开对应话题;只显示你在当前聊天有权访问的历史。' : '\n用 /resume <ID> 切换;只显示你在当前聊天/话题的历史。') : '当前聊天/话题还没有保存的会话。';
|
|
955
1201
|
}
|
|
956
1202
|
if (command === 'rename') {
|
|
957
1203
|
if (!args)
|
|
@@ -963,10 +1209,26 @@ export class BotBridge {
|
|
|
963
1209
|
if (command === 'resume') {
|
|
964
1210
|
if (!/^[a-f0-9]{12}$/.test(args))
|
|
965
1211
|
return '用法:/resume <ID>;请从当前聊天的 /sessions 列表选择。';
|
|
1212
|
+
if (this.topicsEnabled()) {
|
|
1213
|
+
const sessions = await this.topicSessions(message);
|
|
1214
|
+
const target = (message.sessionView ? sessionPage(sessions, message.sessionView).items : sessions).find(session => session.id === args);
|
|
1215
|
+
if (!target) {
|
|
1216
|
+
if (message.sessionView)
|
|
1217
|
+
throw new SessionStoreError('NOT_FOUND');
|
|
1218
|
+
return '在你当前的聊天中找不到这个会话。';
|
|
1219
|
+
}
|
|
1220
|
+
const binding = await this.continueTopic(message, target);
|
|
1221
|
+
message.topicResult = { binding, title: target.title, newEntry: !target.topic };
|
|
1222
|
+
return this.topicNavigation(binding, target.title);
|
|
1223
|
+
}
|
|
966
1224
|
// Resolve authorization before touching the running handle or native log.
|
|
967
|
-
const
|
|
968
|
-
|
|
1225
|
+
const sessions = await this.state.sessions.list(conversation);
|
|
1226
|
+
const target = (message.sessionView ? sessionPage(sessions, message.sessionView).items : sessions).find(s => s.id === args);
|
|
1227
|
+
if (!target) {
|
|
1228
|
+
if (message.sessionView)
|
|
1229
|
+
throw new SessionStoreError('NOT_FOUND');
|
|
969
1230
|
return '在你当前的聊天/话题中找不到这个会话。';
|
|
1231
|
+
}
|
|
970
1232
|
if (!lane.unavailable && lane.session && lane.saved?.id === target.id)
|
|
971
1233
|
return `已经在会话「${target.title}」中。`;
|
|
972
1234
|
await this.unload(lane);
|
|
@@ -1080,7 +1342,7 @@ export class BotBridge {
|
|
|
1080
1342
|
return;
|
|
1081
1343
|
if (view.current)
|
|
1082
1344
|
return view;
|
|
1083
|
-
const saved = await this.state.permissionPreferences?.get(this.
|
|
1345
|
+
const saved = await this.state.permissionPreferences?.get(this.permissionContext(message));
|
|
1084
1346
|
return saved && view.options.some(option => option.id === saved) ? { ...view, current: saved } : view;
|
|
1085
1347
|
}
|
|
1086
1348
|
/** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
|
|
@@ -1097,7 +1359,7 @@ export class BotBridge {
|
|
|
1097
1359
|
if (!option)
|
|
1098
1360
|
return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
|
|
1099
1361
|
// 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.
|
|
1362
|
+
await this.state.permissionPreferences?.set(this.permissionContext(message), option.id);
|
|
1101
1363
|
if (lane.session?.setPermission) {
|
|
1102
1364
|
lane.session.setPermission(option.id);
|
|
1103
1365
|
lane.permissionSeen = option.id;
|
|
@@ -1109,7 +1371,7 @@ export class BotBridge {
|
|
|
1109
1371
|
async applyPermission(message, lane, handle, fresh) {
|
|
1110
1372
|
if (!this.harness(message, lane) || !handle.setPermission)
|
|
1111
1373
|
return;
|
|
1112
|
-
const { ownerKey, conversationKey } = this.
|
|
1374
|
+
const { ownerKey, conversationKey } = this.permissionContext(message);
|
|
1113
1375
|
lane.permissionContext = { ownerKey, conversationKey };
|
|
1114
1376
|
const saved = await this.state.permissionPreferences?.get(lane.permissionContext);
|
|
1115
1377
|
const state = handle.permissionState?.();
|
|
@@ -1119,6 +1381,10 @@ export class BotBridge {
|
|
|
1119
1381
|
handle.setPermission(target);
|
|
1120
1382
|
lane.permissionSeen = handle.permissionState?.()?.current;
|
|
1121
1383
|
}
|
|
1384
|
+
permissionContext(message) {
|
|
1385
|
+
return this.topicsEnabled() ? { ownerKey: ownerKey(message, this.config),
|
|
1386
|
+
conversationKey: sessionKey({ ...message, threadId: '' }, this.config) } : this.context(message);
|
|
1387
|
+
}
|
|
1122
1388
|
/** A preset changed in the App on this chat's live session becomes the chat's choice, so new sessions keep it. */
|
|
1123
1389
|
async adoptPermission(lane) {
|
|
1124
1390
|
const live = lane.permissionContext ? lane.session?.permissionState?.() : undefined;
|
|
@@ -1245,6 +1511,12 @@ export class BotBridge {
|
|
|
1245
1511
|
async commandPanel(message, lane, card) {
|
|
1246
1512
|
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
|
|
1247
1513
|
const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
|
|
1514
|
+
if (message.topicResult && command !== 'new' && !message.sessionView && !card?.pending && !card?.error && !card?.notice) {
|
|
1515
|
+
const { binding, title } = message.topicResult;
|
|
1516
|
+
const text = this.topicNavigation(binding, title);
|
|
1517
|
+
return { title: '话题已就绪', tone: 'success', summary: `打开「${preview(title, 40)}」继续`,
|
|
1518
|
+
sections: [{ title: preview(title, 40), text }], note: message.chatType === 'group' ? '在话题里继续 @机器人。' : '在对应话题中发消息,就能接着之前的上下文。' };
|
|
1519
|
+
}
|
|
1248
1520
|
const choice = /^use (\S+)$/.exec(args)?.[1];
|
|
1249
1521
|
const pending = card?.pending ? choice : undefined;
|
|
1250
1522
|
const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
|
|
@@ -1281,44 +1553,31 @@ export class BotBridge {
|
|
|
1281
1553
|
}
|
|
1282
1554
|
// Ordinary conversation and diagnostics stay out of management cards.
|
|
1283
1555
|
// A card is useful here because the user explicitly wants to pick a topic.
|
|
1284
|
-
const topic = card && (command === 'resume' || command === 'new');
|
|
1556
|
+
const topic = (card || message.sessionView || message.topicResult) && (command === 'resume' || command === 'new');
|
|
1285
1557
|
if ((command !== 'sessions' || args === 'detail') && !topic)
|
|
1286
1558
|
return;
|
|
1287
1559
|
const conversation = this.sessionContext(message);
|
|
1288
1560
|
const current = await this.state.sessions.current(conversation);
|
|
1289
|
-
const sessions = await this.state.sessions.list(conversation);
|
|
1290
|
-
if (!sessions.length)
|
|
1291
|
-
return;
|
|
1561
|
+
const sessions = this.topicsEnabled() ? await this.topicSessions(message) : await this.state.sessions.list(conversation);
|
|
1292
1562
|
const busy = Boolean(card?.pending);
|
|
1293
1563
|
const target = command === 'resume' ? sessions.find(s => s.id === args) : undefined;
|
|
1294
1564
|
const status = card?.error ? { text: card.error, tone: 'error' }
|
|
1295
|
-
: busy ? { text: target ?
|
|
1296
|
-
:
|
|
1297
|
-
: topic
|
|
1298
|
-
:
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
}
|
|
1565
|
+
: busy ? { text: target ? `正在打开「${preview(target.title, 40)}」…` : command === 'new' ? '正在开始新话题…' : '正在查找话题…', tone: 'pending' }
|
|
1566
|
+
: card?.notice ? { text: card.notice }
|
|
1567
|
+
: topic && command === 'resume' && target ? undefined
|
|
1568
|
+
: command === 'new' && message.topicResult ? { text: message.topicResult.binding.link
|
|
1569
|
+
? `新话题已创建。[进入新话题](${message.topicResult.binding.link})` : '新话题已创建,请在新卡片下方回复话题。' }
|
|
1570
|
+
: topic ? { text: '好,换个话题。直接发消息就会开始新的对话,之前的还在这里。', tone: 'success' }
|
|
1571
|
+
: undefined;
|
|
1572
|
+
return sessionControlPanel(sessions.map(session => ({ ...session, ...('topic' in session && session.topic?.link
|
|
1573
|
+
? { link: session.topic.link } : {}) })), current?.id, (label, command) => {
|
|
1305
1574
|
if (!interactive)
|
|
1306
1575
|
return;
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
for (const session of sessions.filter(s => s.id !== current?.id).slice(0, 5))
|
|
1313
|
-
add(`继续 ${preview(session.title, 14)}`, `/resume ${session.id}`, { expectedSessionId: current?.id ?? null, confirm: { title: '接着这个话题聊?', text: `继续「${preview(session.title, 40)}」,现在的对话会保留。` } });
|
|
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,
|
|
1319
|
-
sections: sessions.slice(0, 5).map(s => ({ title: preview(s.title, 40), text: s.id === current?.id ? '正在聊这个话题' : '可以接着聊' })),
|
|
1320
|
-
...(!interactive || sessions.length > 5 ? { note: '完整列表和更多操作:/sessions detail' } : {}),
|
|
1321
|
-
};
|
|
1576
|
+
if (busy)
|
|
1577
|
+
return { label, value: {}, disabled: true };
|
|
1578
|
+
return { label, value: { token: issue(command, /^(?:\/new|\/sessions pick )/.test(command) ? { expectedSessionId: current?.id ?? null } : {}) } };
|
|
1579
|
+
}, { view: message.sessionView ?? sessionViewCommandParse(message.text)?.view,
|
|
1580
|
+
selectedId: !card?.error && !card?.notice && !busy ? target?.id : undefined, busy, status });
|
|
1322
1581
|
}
|
|
1323
1582
|
async reply(message, text, signal) {
|
|
1324
1583
|
return this.sendPayloads(message, renderAnswer(text, this.config.chunkBytes), signal);
|
package/dist/card-actions.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { chmod, lstat, mkdir, open } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { sessionViewCommandParse } from './session-controls.js';
|
|
5
6
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
6
7
|
const MAX_TOKEN_LENGTH = 3000;
|
|
7
8
|
const HEX_ID = /^[a-f0-9]{12}$/;
|
|
@@ -31,7 +32,8 @@ function binding(value) {
|
|
|
31
32
|
|| (value.chatType !== 'p2p' && value.chatType !== 'group')
|
|
32
33
|
|| !(value.threadId === '' || validId(value.threadId, 'om') || validId(value.threadId, 'omt'))
|
|
33
34
|
|| !(validId(value.sourceMessageId, 'om') || (typeof value.sourceMessageId === 'string' && /^menu:[a-zA-Z0-9_-]{1,128}$/.test(value.sourceMessageId)))
|
|
34
|
-
|| typeof value.command !== 'string' || /[\u0000-\u001f\u007f]/.test(value.command)
|
|
35
|
+
|| typeof value.command !== 'string' || /[\u0000-\u001f\u007f]/.test(value.command)
|
|
36
|
+
|| !(COMMAND.test(value.command) || value.command === '/sessions search' || sessionViewCommandParse(value.command))
|
|
35
37
|
|| !(value.expectedSessionId === undefined || value.expectedSessionId === null
|
|
36
38
|
|| (typeof value.expectedSessionId === 'string' && value.expectedSessionId.length === 12 && HEX_ID.test(value.expectedSessionId)))
|
|
37
39
|
|| !(value.expectedModelRevision === undefined || value.expectedModelRevision === null
|