dsh-lark-agent 0.3.3 → 0.5.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 -5
- package/dist/bot-service.js +4 -2
- package/dist/bridge.d.ts +27 -1
- package/dist/bridge.js +333 -66
- package/dist/card-actions.js +3 -1
- package/dist/client/AgentSettings.d.ts +12 -0
- package/dist/client/ConnectionCard.d.ts +5 -1
- package/dist/client/ManagerPanel.d.ts +3 -0
- package/dist/client/api.d.ts +14 -2
- package/dist/client/index.d.ts +1 -1
- package/dist/client/manager-styles.d.ts +2 -0
- package/dist/client/use-connection.d.ts +2 -2
- package/dist/client.cjs +727 -258
- package/dist/config.d.ts +4 -0
- package/dist/config.js +8 -0
- package/dist/dsh-runtime.d.ts +3 -0
- package/dist/dsh-runtime.js +46 -4
- package/dist/lark-cli.d.ts +20 -1
- package/dist/lark-cli.js +168 -2
- package/dist/media-input.d.ts +28 -0
- package/dist/media-input.js +127 -0
- package/dist/media-scopes.d.ts +5 -0
- package/dist/media-scopes.js +6 -0
- package/dist/message.d.ts +7 -0
- package/dist/message.js +27 -3
- package/dist/onboarding/agents.d.ts +9 -0
- package/dist/onboarding/agents.js +100 -0
- package/dist/onboarding/controller.d.ts +15 -2
- package/dist/onboarding/controller.js +155 -14
- package/dist/onboarding/host.d.ts +14 -2
- package/dist/onboarding/host.js +49 -32
- package/dist/onboarding/lark-platform.d.ts +6 -0
- package/dist/onboarding/lark-platform.js +15 -4
- package/dist/onboarding/manager.d.ts +32 -0
- package/dist/onboarding/manager.js +129 -0
- package/dist/onboarding/model-check.d.ts +5 -0
- package/dist/onboarding/model-check.js +51 -0
- package/dist/onboarding/protocol.d.ts +51 -1
- package/dist/onboarding/settings-store.d.ts +11 -0
- package/dist/onboarding/settings-store.js +67 -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 +7 -1
- package/dist/session-store.js +44 -11
- 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,14 @@ 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';
|
|
20
|
+
import { MediaInputError, prepareMedia } from './media-input.js';
|
|
18
21
|
class ArtifactDeliveryError extends Error {
|
|
19
22
|
}
|
|
23
|
+
/** A creation may have reached Lark. Never send another message as a fallback. */
|
|
24
|
+
class TopicDeliveryError extends Error {
|
|
25
|
+
}
|
|
20
26
|
const APPROVAL_TIMEOUT_MS = 5 * 60_000;
|
|
21
27
|
const QUESTION_TIMEOUT_MS = 10 * 60_000;
|
|
22
28
|
// After a live card cannot be created (for example, before the CardKit scope is
|
|
@@ -27,7 +33,7 @@ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
|
27
33
|
const TIMEOUT_TEXT = '本次处理超时,已请求取消。请缩小问题范围后重试。';
|
|
28
34
|
const STOPPING_TEXT = '连接正在关闭,本次处理已停止。';
|
|
29
35
|
// 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
|
|
36
|
+
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
37
|
// A switch that finishes sooner goes straight to its result, without a loading frame.
|
|
32
38
|
const PANEL_PROGRESS_DELAY_MS = 150;
|
|
33
39
|
const STALE_SETTINGS = '设置刚刚变化,已刷新为最新选项,请重新选择。';
|
|
@@ -37,7 +43,7 @@ class StaleCardError extends Error {
|
|
|
37
43
|
}
|
|
38
44
|
function commandError(error) {
|
|
39
45
|
return error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError
|
|
40
|
-
|| error instanceof ModelPreferencesError || error instanceof StaleCardError
|
|
46
|
+
|| error instanceof ModelPreferencesError || error instanceof StaleCardError || error instanceof TopicStoreError || error instanceof TopicDeliveryError
|
|
41
47
|
? error.message : '操作未完成,请稍后重试;尚未确认保存成功。';
|
|
42
48
|
}
|
|
43
49
|
/** Only a regular file inside the user's own workspace is uploaded; anything else is just named. */
|
|
@@ -73,15 +79,18 @@ const HELP = `直接把想做的事告诉我就好。
|
|
|
73
79
|
|
|
74
80
|
比如:帮我理清一个问题、整理一段文字,或者接着刚才的话题继续。你也可以在私聊告诉我,以后希望我怎样配合你。
|
|
75
81
|
|
|
76
|
-
|
|
82
|
+
私聊底部菜单可以新建话题、搜索和选择历史对话,或调整模型和思考深度。
|
|
83
|
+
多个机器人与 Agent 可在电脑的「飞书 agent」设置中分别管理。
|
|
84
|
+
私聊和群聊的新问题都会开一个飞书话题;新话题卡片下方点「回复话题」继续,群聊中仍需 @我。
|
|
77
85
|
也可以发 /new 换话题、/sessions 找对话、/model 看模型、/thinking 调深度、/stop 停下来。`;
|
|
78
86
|
const HARNESS_HELP = `
|
|
79
87
|
|
|
80
|
-
这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness
|
|
88
|
+
这里连着你电脑上的 Harness:可以让我查资料、读网页、处理工作区里的文件,回复会实时显示,思考过程折叠在卡片里;需要你确认的操作,我会先发卡片问你。发 /permission(或点底部菜单 设置 → 权限模式)可以切换权限,比如改成不再询问的「完全权限」。模型、思考深度和权限模式与电脑上的 Harness 同步。
|
|
89
|
+
可直接发图片(需要宿主与模型支持图片输入);在电脑设置中启用语音后,可发送 60 秒以内语音,识别成文字继续对话。`;
|
|
81
90
|
const ADVANCED_HELP = `高级命令(按需使用):
|
|
82
|
-
/new 或 /reset
|
|
83
|
-
/sessions
|
|
84
|
-
/resume <ID
|
|
91
|
+
/new 或 /reset:创建新的飞书话题,在新话题里开始;旧话题和长期记忆保留
|
|
92
|
+
/sessions:搜索、翻页并选择以前的对话;/sessions detail 查看 ID,/sessions page <页码> 查看其他页
|
|
93
|
+
/resume <ID>:打开列表中会话对应的话题,旧会话首次使用时补建话题入口
|
|
85
94
|
/rename <标题>:给当前会话命名
|
|
86
95
|
/status:看看我是否在线;/status detail 查看会话和记忆诊断
|
|
87
96
|
/stop:停止当前处理并取消排队消息
|
|
@@ -149,6 +158,8 @@ export class BotBridge {
|
|
|
149
158
|
progress;
|
|
150
159
|
interactions = new Map();
|
|
151
160
|
liveUnavailableUntil = 0;
|
|
161
|
+
topicCreations = new Map();
|
|
162
|
+
topicResumes = new Map();
|
|
152
163
|
constructor(config, runtime, transport, ledger, state, audit = () => undefined, progressTransport, artifactPublisher) {
|
|
153
164
|
this.config = config;
|
|
154
165
|
this.runtime = runtime;
|
|
@@ -167,7 +178,7 @@ export class BotBridge {
|
|
|
167
178
|
if (!this.config.cardInteractions || !this.state.actions || !raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
168
179
|
return;
|
|
169
180
|
const data = raw;
|
|
170
|
-
if (data.type !== 'card.action.trigger' || data.action_tag !== 'button'
|
|
181
|
+
if (data.type !== 'card.action.trigger' || (data.action_tag !== 'button' && data.action_tag !== 'select_static')
|
|
171
182
|
|| typeof data.event_id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(data.event_id)
|
|
172
183
|
|| typeof data.message_id !== 'string' || !/^om_[a-zA-Z0-9_-]+$/.test(data.message_id)
|
|
173
184
|
|| typeof data.action_value !== 'string' || data.action_value.length > 4000)
|
|
@@ -187,6 +198,39 @@ export class BotBridge {
|
|
|
187
198
|
this.audit({ stage: 'card-action-rejected' });
|
|
188
199
|
return;
|
|
189
200
|
}
|
|
201
|
+
let command = binding.command;
|
|
202
|
+
let sessionView;
|
|
203
|
+
const browser = sessionViewCommandParse(command);
|
|
204
|
+
if (data.action_tag === 'select_static') {
|
|
205
|
+
// A selector can only navigate this owner's catalog, never invoke another signed capability.
|
|
206
|
+
if (browser?.kind !== 'pick' || data.action_name !== 'dsh_session_picker'
|
|
207
|
+
|| typeof data.option !== 'string' || !/^[a-f0-9]{12}$/.test(data.option))
|
|
208
|
+
return;
|
|
209
|
+
sessionView = browser.view;
|
|
210
|
+
command = `/resume ${data.option}`;
|
|
211
|
+
}
|
|
212
|
+
else if (browser?.kind === 'pick')
|
|
213
|
+
return;
|
|
214
|
+
else if (browser)
|
|
215
|
+
sessionView = browser.view;
|
|
216
|
+
else if (command === '/sessions search') {
|
|
217
|
+
if (data.action_name !== 'dsh_sessions_search' || typeof data.form_value !== 'string' || data.form_value.length > 1000)
|
|
218
|
+
return;
|
|
219
|
+
let fields;
|
|
220
|
+
try {
|
|
221
|
+
fields = JSON.parse(data.form_value);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length !== 1 || !('query' in fields))
|
|
227
|
+
return;
|
|
228
|
+
const query = sessionQuery(fields.query);
|
|
229
|
+
if (query === undefined)
|
|
230
|
+
return;
|
|
231
|
+
sessionView = { page: 0, query };
|
|
232
|
+
command = '/sessions';
|
|
233
|
+
}
|
|
190
234
|
let formValues;
|
|
191
235
|
if (binding.command.startsWith('/answer ') || binding.command.startsWith('/reply ')) {
|
|
192
236
|
if (data.action_name !== 'dsh_submit' || typeof data.form_value !== 'string' || data.form_value.length > 8000)
|
|
@@ -200,12 +244,12 @@ export class BotBridge {
|
|
|
200
244
|
if (!formValues || typeof formValues !== 'object' || Array.isArray(formValues))
|
|
201
245
|
return;
|
|
202
246
|
}
|
|
203
|
-
const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(
|
|
247
|
+
const mutation = /^\/(?:(?:new|resume|forget|answer)(?: |$)|(?:model|thinking|permission) use )/.test(command);
|
|
204
248
|
return { messageId: data.message_id, senderId: binding.ownerId, chatId: binding.chatId,
|
|
205
|
-
chatType: binding.chatType, threadId: binding.threadId, text:
|
|
249
|
+
chatType: binding.chatType, threadId: binding.threadId, text: command,
|
|
206
250
|
requestId: `card:${data.event_id}`, actionNonce: mutation ? binding.nonce : undefined,
|
|
207
251
|
expectedSessionId: binding.expectedSessionId, expectedModelRevision: binding.expectedModelRevision,
|
|
208
|
-
...(formValues === undefined ? {} : { formValues }) };
|
|
252
|
+
...(formValues === undefined ? {} : { formValues }), ...(sessionView ? { sessionView } : {}) };
|
|
209
253
|
}
|
|
210
254
|
accept(raw) {
|
|
211
255
|
const admission = this.acceptMessage(raw);
|
|
@@ -270,7 +314,26 @@ export class BotBridge {
|
|
|
270
314
|
await this.answerInteraction(message);
|
|
271
315
|
return;
|
|
272
316
|
}
|
|
273
|
-
|
|
317
|
+
if (this.topicsEnabled() && message.threadId) {
|
|
318
|
+
// A thread event can arrive as soon as the opening reply is visible.
|
|
319
|
+
// Wait for its binding, not the model turn that follows it.
|
|
320
|
+
message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
|
|
321
|
+
if (!message.topic) {
|
|
322
|
+
await Promise.all(this.topicCreations.get(this.topicScope(message).chatKey) ?? []);
|
|
323
|
+
if (this.stopped)
|
|
324
|
+
return;
|
|
325
|
+
message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
|
|
326
|
+
}
|
|
327
|
+
message.foreignTopic = !message.topic && await this.state.topics.occupied(this.topicScope(message).chatKey, message.threadId);
|
|
328
|
+
}
|
|
329
|
+
// Recognized settings questions use the entry lane before any provisional topic context is allocated.
|
|
330
|
+
if (!message.resources?.length && this.state.modelPreferences && this.runtime.catalog && isModelQuestion(message.text))
|
|
331
|
+
message.text = '/model current';
|
|
332
|
+
const startsTopic = this.topicsEnabled() && !message.threadId && (!message.text.startsWith('/') || Boolean(message.resources?.length));
|
|
333
|
+
const key = message.topic?.laneKey ?? (startsTopic ? this.newTopicKey(message) : sessionKey(message, this.config));
|
|
334
|
+
if (startsTopic)
|
|
335
|
+
message.conversationKey = key;
|
|
336
|
+
message.laneKey = key;
|
|
274
337
|
let lane = this.lanes.get(key);
|
|
275
338
|
if (message.text === '/stop') {
|
|
276
339
|
if (lane) {
|
|
@@ -319,6 +382,11 @@ export class BotBridge {
|
|
|
319
382
|
current.pending--;
|
|
320
383
|
this.pending--;
|
|
321
384
|
current.lastUsed = Date.now();
|
|
385
|
+
// Entry commands create no native session. Do not let their empty lane
|
|
386
|
+
// take the last slot needed by the topic they just opened.
|
|
387
|
+
if (this.topicsEnabled() && !current.pending && !current.session && !current.saved && !current.unavailable
|
|
388
|
+
&& this.lanes.get(key) === current)
|
|
389
|
+
this.lanes.delete(key);
|
|
322
390
|
});
|
|
323
391
|
current.tail = job;
|
|
324
392
|
// Release ingress before waiting for generation, while pending remains bounded.
|
|
@@ -333,6 +401,12 @@ export class BotBridge {
|
|
|
333
401
|
}
|
|
334
402
|
async process(message, lane) {
|
|
335
403
|
const generation = lane.generation;
|
|
404
|
+
if (this.topicsEnabled() && message.threadId && !message.foreignTopic) {
|
|
405
|
+
// The first turn may have published its session while this message queued.
|
|
406
|
+
message.topic = await this.state.topics.get(this.topicScope(message), message.threadId);
|
|
407
|
+
if (!message.topic)
|
|
408
|
+
message.foreignTopic = await this.state.topics.occupied(this.topicScope(message).chatKey, message.threadId);
|
|
409
|
+
}
|
|
336
410
|
if (message.chatType === 'group') {
|
|
337
411
|
message.groupAccessKey = await this.state.memories.groupAccessKey(this.context(message));
|
|
338
412
|
if (lane.groupAccessKey !== message.groupAccessKey) {
|
|
@@ -342,9 +416,19 @@ export class BotBridge {
|
|
|
342
416
|
lane.groupAccessKey = message.groupAccessKey;
|
|
343
417
|
}
|
|
344
418
|
}
|
|
345
|
-
if (this.
|
|
346
|
-
message.text
|
|
419
|
+
if (this.topicsEnabled() && (message.foreignTopic || (message.topic && message.topic.groupAccessKey !== message.groupAccessKey))) {
|
|
420
|
+
const fresh = /^\/(?:new|reset|clear)$/i.test(message.text) && message.expectedSessionId === undefined;
|
|
421
|
+
if (!fresh)
|
|
422
|
+
return this.reply(message, message.foreignTopic
|
|
423
|
+
? '这个话题已有独立的会话。请在主聊天中 @我,或发送 /new 开始你自己的话题。'
|
|
424
|
+
: '记忆授权已变化,这个话题的旧上下文已停用。请发送 /new 开始新话题。');
|
|
425
|
+
message.topic = undefined;
|
|
426
|
+
message.conversationKey = this.entryKey(message);
|
|
427
|
+
}
|
|
347
428
|
let text = message.text;
|
|
429
|
+
if (message.resources?.length && (message.chatType !== 'p2p' || lane.user.directMode !== 'harness')) {
|
|
430
|
+
return this.reply(message, '图片和语音输入仅用于已验证本人的 Harness 私聊,请改发文字。');
|
|
431
|
+
}
|
|
348
432
|
const inPlace = this.inPlace(message);
|
|
349
433
|
// A stale card is redrawn with the current state instead of answered with another message.
|
|
350
434
|
if (message.expectedSessionId !== undefined) {
|
|
@@ -405,19 +489,26 @@ export class BotBridge {
|
|
|
405
489
|
return this.reply(message, error instanceof PresentationError ? error.message : '作品暂时无法读取,已有内容会保留。');
|
|
406
490
|
}
|
|
407
491
|
}
|
|
408
|
-
if (text.startsWith('/')) {
|
|
492
|
+
if (text.startsWith('/') && !message.resources?.length) {
|
|
409
493
|
if (inPlace)
|
|
410
494
|
return this.panelInPlace(message, lane);
|
|
411
|
-
const feedback = this.
|
|
495
|
+
const feedback = this.topicsEnabled() && /^\/(?:new|reset|clear|resume)(?:\s|$)/i.test(text)
|
|
496
|
+
? undefined : this.commandFeedback(message);
|
|
412
497
|
let response;
|
|
413
498
|
let panel;
|
|
414
499
|
let failed = false;
|
|
415
500
|
try {
|
|
416
501
|
response = await this.command(message, lane);
|
|
502
|
+
// A newly posted entry card already confirms the operation and owns its thread.
|
|
503
|
+
// A clicked private picker is handled above and still updates its existing card.
|
|
504
|
+
if (message.topicResult?.newEntry)
|
|
505
|
+
return;
|
|
417
506
|
if (this.config.responseMode !== 'text' && (message.menu ? this.transport.sendRichUser : this.transport.replyRich))
|
|
418
507
|
panel = await this.commandPanel(message, lane);
|
|
419
508
|
}
|
|
420
509
|
catch (error) {
|
|
510
|
+
if (error instanceof TopicDeliveryError)
|
|
511
|
+
throw error;
|
|
421
512
|
failed = true;
|
|
422
513
|
response = commandError(error);
|
|
423
514
|
this.audit({ stage: 'command-failed', messageId: message.messageId });
|
|
@@ -429,6 +520,24 @@ export class BotBridge {
|
|
|
429
520
|
}
|
|
430
521
|
if (text.length > this.config.maxInputChars)
|
|
431
522
|
return this.reply(message, '消息过长,请缩短后重试。');
|
|
523
|
+
if (this.stopped || lane.generation !== generation)
|
|
524
|
+
return;
|
|
525
|
+
if (this.topicsEnabled()) {
|
|
526
|
+
if (!message.threadId) {
|
|
527
|
+
const binding = await this.createTopic(message, preview(text, 40) || '新话题', message.conversationKey);
|
|
528
|
+
message.requestId ??= message.messageId;
|
|
529
|
+
message.messageId = binding.messageId;
|
|
530
|
+
message.threadId = binding.threadId;
|
|
531
|
+
message.topic = binding;
|
|
532
|
+
}
|
|
533
|
+
else if (!message.topic) {
|
|
534
|
+
// A user may start from a native topic that already exists in Lark.
|
|
535
|
+
const saved = await this.state.sessions.current(this.sessionContext(message));
|
|
536
|
+
message.topic = await this.state.topics.record({ ...this.topicScope(message), threadId: message.threadId,
|
|
537
|
+
messageId: message.messageId, laneKey: message.laneKey, conversationKey: this.context(message).conversationKey,
|
|
538
|
+
...(saved ? { sessionId: saved.id } : {}), ...(message.groupAccessKey ? { groupAccessKey: message.groupAccessKey } : {}) });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
432
541
|
if (this.stopped || lane.generation !== generation)
|
|
433
542
|
return;
|
|
434
543
|
const controller = new AbortController();
|
|
@@ -447,6 +556,8 @@ export class BotBridge {
|
|
|
447
556
|
this.audit({ stage: 'live-card-unconfirmed', messageId: message.messageId });
|
|
448
557
|
};
|
|
449
558
|
try {
|
|
559
|
+
const input = await prepareMedia({ ...message, text }, this.transport, controller.signal, { images: this.config.imageInput !== false, voice: this.config.voiceInput === true, maxText: this.config.maxInputChars });
|
|
560
|
+
text = input.text;
|
|
450
561
|
await this.ensureSession(message, lane);
|
|
451
562
|
if (controller.signal.aborted) {
|
|
452
563
|
if (timedOut)
|
|
@@ -469,7 +580,7 @@ export class BotBridge {
|
|
|
469
580
|
else
|
|
470
581
|
this.liveUnavailableUntil = Date.now() + LIVE_RETRY_MS;
|
|
471
582
|
}
|
|
472
|
-
const turnContext = { memoryText, ...(artifactsText ? { artifactsText } : {}) };
|
|
583
|
+
const turnContext = { memoryText, ...(artifactsText ? { artifactsText } : {}), ...(input.images ? { images: input.images } : {}) };
|
|
473
584
|
// Only the owner's harness-mode chat can be asked anything; a restricted session gets no hooks at all.
|
|
474
585
|
const observer = live || harness ? {
|
|
475
586
|
...(live ? { stream: (event) => live?.push(event) } : {}),
|
|
@@ -509,7 +620,7 @@ export class BotBridge {
|
|
|
509
620
|
clearTimeout(timer);
|
|
510
621
|
if (error instanceof ArtifactDeliveryError)
|
|
511
622
|
throw error;
|
|
512
|
-
const known = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError;
|
|
623
|
+
const known = error instanceof DshRuntimeError || error instanceof MemoryStoreError || error instanceof SessionStoreError || error instanceof ModelPreferencesError || error instanceof PresentationError || error instanceof MediaInputError;
|
|
513
624
|
if (live) {
|
|
514
625
|
const stopped = timedOut || controller.signal.aborted || this.stopped;
|
|
515
626
|
await conclude({ phase: stopped ? 'stopped' : 'failed', note: timedOut ? TIMEOUT_TEXT : this.stopped ? STOPPING_TEXT
|
|
@@ -678,7 +789,7 @@ export class BotBridge {
|
|
|
678
789
|
async deliverFiles(message, lane, files, signal) {
|
|
679
790
|
if (!files.length || signal.aborted || this.stopped)
|
|
680
791
|
return;
|
|
681
|
-
const workspace = await realpath(lane.user.cwd).catch(() => undefined);
|
|
792
|
+
const workspace = await realpath(lane.saved?.cwd ?? lane.user.cwd).catch(() => undefined);
|
|
682
793
|
const notes = [];
|
|
683
794
|
for (const [index, file] of files.entries()) {
|
|
684
795
|
if (signal.aborted || this.stopped)
|
|
@@ -800,11 +911,95 @@ export class BotBridge {
|
|
|
800
911
|
this.audit({ stage: 'feedback-updated', messageId });
|
|
801
912
|
});
|
|
802
913
|
}
|
|
914
|
+
topicsEnabled() { return Boolean(this.state.topics && this.transport.openTopic); }
|
|
915
|
+
topicScope(message) {
|
|
916
|
+
return { ownerKey: ownerKey(message, this.config), chatKey: createHash('sha256').update(JSON.stringify([
|
|
917
|
+
this.config.profile, this.config.botOpenId, message.chatType, message.chatId,
|
|
918
|
+
])).digest('hex') };
|
|
919
|
+
}
|
|
920
|
+
newTopicKey(message) {
|
|
921
|
+
return createHash('sha256').update(JSON.stringify([sessionKey({ ...message, threadId: '' }, this.config),
|
|
922
|
+
message.requestId ?? message.messageId, 'lark-topic-v1'])).digest('hex');
|
|
923
|
+
}
|
|
924
|
+
entryKey(message) {
|
|
925
|
+
return createHash('sha256').update(JSON.stringify([sessionKey({ ...message, threadId: '' }, this.config), 'lark-entry-v1'])).digest('hex');
|
|
926
|
+
}
|
|
927
|
+
reserveTopic(message) {
|
|
928
|
+
const key = this.topicScope(message).chatKey;
|
|
929
|
+
const pending = this.topicCreations.get(key) ?? new Set();
|
|
930
|
+
let done;
|
|
931
|
+
const promise = new Promise(resolve => { done = resolve; });
|
|
932
|
+
pending.add(promise);
|
|
933
|
+
this.topicCreations.set(key, pending);
|
|
934
|
+
return () => { done(); pending.delete(promise); if (!pending.size && this.topicCreations.get(key) === pending)
|
|
935
|
+
this.topicCreations.delete(key); };
|
|
936
|
+
}
|
|
937
|
+
async createTopic(message, title, conversationKey, sessionId) {
|
|
938
|
+
const idempotencyKey = createHash('sha256').update(JSON.stringify([this.config.botOpenId,
|
|
939
|
+
message.requestId ?? message.messageId, 'topic', sessionId ?? 'new'])).digest('hex').slice(0, 40);
|
|
940
|
+
const target = { chatId: message.chatId,
|
|
941
|
+
...(!message.threadId && !message.menu && !message.requestId?.startsWith('card:') && !sessionId && !message.text.startsWith('/')
|
|
942
|
+
? { messageId: message.messageId } : {}) };
|
|
943
|
+
let receipt;
|
|
944
|
+
const release = this.reserveTopic(message);
|
|
945
|
+
try {
|
|
946
|
+
if (message.chatType === 'p2p') {
|
|
947
|
+
const context = this.permissionContext(message);
|
|
948
|
+
for (const lane of this.lanes.values()) {
|
|
949
|
+
if (lane.permissionContext?.ownerKey === context.ownerKey && lane.permissionContext.conversationKey === context.conversationKey) {
|
|
950
|
+
await this.adoptPermission(lane);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (!sessionId && this.state.modelPreferences) {
|
|
955
|
+
const user = this.config.users.find(item => item.openId === message.senderId);
|
|
956
|
+
if (!this.sharedModel(message, { user })) {
|
|
957
|
+
const targetContext = { ownerKey: ownerKey(message, this.config), conversationKey };
|
|
958
|
+
// Capture the current topic's choice, or the main chat's selection,
|
|
959
|
+
// once. Later main-chat changes must not retarget an existing topic.
|
|
960
|
+
const source = await this.state.modelPreferences.get(this.context(message))
|
|
961
|
+
?? await this.state.modelPreferences.get({ ...targetContext, conversationKey: this.entryKey(message) })
|
|
962
|
+
?? await this.state.modelPreferences.get({ ...targetContext, conversationKey: sessionKey({ ...message, threadId: '' }, this.config) });
|
|
963
|
+
if (source && !await this.state.modelPreferences.get(targetContext)) {
|
|
964
|
+
await this.state.modelPreferences.set(targetContext, source.selection, { expectedRevision: null });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const send = async () => {
|
|
969
|
+
if (!this.stopped)
|
|
970
|
+
receipt = await this.transport.openTopic(target, title, { idempotencyKey, chatType: message.chatType, textOnly: this.config.responseMode === 'text' });
|
|
971
|
+
};
|
|
972
|
+
// Do not read or mutate MemoryStore inside its authorization/send critical section.
|
|
973
|
+
if (message.chatType === 'group')
|
|
974
|
+
await this.state.memories.withGroupAccess(this.context(message), message.groupAccessKey, send);
|
|
975
|
+
else
|
|
976
|
+
await send();
|
|
977
|
+
if (!receipt)
|
|
978
|
+
throw new Error();
|
|
979
|
+
return await this.state.topics.record({ ...this.topicScope(message), threadId: receipt.threadId, messageId: receipt.messageId,
|
|
980
|
+
...(receipt.link ? { link: receipt.link } : {}),
|
|
981
|
+
laneKey: sessionId ? createHash('sha256').update(JSON.stringify([conversationKey, sessionId, 'topic-lane-v1'])).digest('hex') : conversationKey,
|
|
982
|
+
conversationKey, ...(sessionId ? { sessionId } : {}), ...(message.groupAccessKey ? { groupAccessKey: message.groupAccessKey } : {}) });
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
this.audit({ stage: 'topic-creation-unconfirmed', messageId: message.messageId });
|
|
986
|
+
throw new TopicDeliveryError('话题创建结果未能确认,已停止处理;请检查聊天记录。');
|
|
987
|
+
}
|
|
988
|
+
finally {
|
|
989
|
+
release();
|
|
990
|
+
}
|
|
991
|
+
}
|
|
803
992
|
context(message) {
|
|
804
|
-
|
|
993
|
+
const routed = message;
|
|
994
|
+
return { ownerKey: ownerKey(message, this.config), conversationKey: routed.topic?.conversationKey ?? routed.conversationKey
|
|
995
|
+
?? (this.topicsEnabled() && !message.threadId ? this.entryKey(message) : sessionKey(message, this.config)), privateChat: message.chatType === 'p2p' };
|
|
805
996
|
}
|
|
806
997
|
sessionContext(message) {
|
|
807
|
-
|
|
998
|
+
return { ...this.catalogContext(message, this.context(message).conversationKey),
|
|
999
|
+
...(message.topic?.sessionId ? { sessionId: message.topic.sessionId } : {}) };
|
|
1000
|
+
}
|
|
1001
|
+
catalogContext(message, conversationKey) {
|
|
1002
|
+
const context = { ownerKey: ownerKey(message, this.config), conversationKey, privateChat: message.chatType === 'p2p' };
|
|
808
1003
|
if (message.chatType === 'p2p')
|
|
809
1004
|
return context;
|
|
810
1005
|
if (!message.groupAccessKey)
|
|
@@ -815,11 +1010,57 @@ export class BotBridge {
|
|
|
815
1010
|
context.conversationKey, 'group-memory-v1', message.groupAccessKey,
|
|
816
1011
|
])).digest('hex') };
|
|
817
1012
|
}
|
|
818
|
-
|
|
1013
|
+
/** Only this user's chat and current disclosure epoch participate in the picker. */
|
|
1014
|
+
async topicSessions(message) {
|
|
1015
|
+
const topics = (await this.state.topics.list(this.topicScope(message)))
|
|
1016
|
+
.filter(item => item.groupAccessKey === message.groupAccessKey);
|
|
1017
|
+
const contexts = new Set([sessionKey({ ...message, threadId: '' }, this.config), this.context(message).conversationKey,
|
|
1018
|
+
...topics.map(item => item.conversationKey)]);
|
|
1019
|
+
const sessions = await Promise.all([...contexts].map(async (conversationKey) => (await this.state.sessions.list(this.catalogContext(message, conversationKey)))
|
|
1020
|
+
.map(session => ({ ...session, conversationKey, topic: topics.find(item => item.sessionId === session.id) }))));
|
|
1021
|
+
return [...new Map(sessions.flat().map(session => [session.id, session])).values()]
|
|
1022
|
+
.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
1023
|
+
}
|
|
1024
|
+
async continueTopic(message, session) {
|
|
1025
|
+
let binding = session.topic;
|
|
1026
|
+
if (!binding) {
|
|
1027
|
+
const key = JSON.stringify([this.topicScope(message), session.id]);
|
|
1028
|
+
let pending = this.topicResumes.get(key);
|
|
1029
|
+
if (!pending) {
|
|
1030
|
+
pending = (async () => {
|
|
1031
|
+
// Another lane may have finished since this picker took its snapshot.
|
|
1032
|
+
const existing = (await this.state.topics.list(this.topicScope(message)))
|
|
1033
|
+
.find(item => item.sessionId === session.id && item.groupAccessKey === message.groupAccessKey);
|
|
1034
|
+
return existing ?? this.createTopic(message, session.title, session.conversationKey, session.id);
|
|
1035
|
+
})();
|
|
1036
|
+
this.topicResumes.set(key, pending);
|
|
1037
|
+
}
|
|
1038
|
+
try {
|
|
1039
|
+
binding = await pending;
|
|
1040
|
+
}
|
|
1041
|
+
finally {
|
|
1042
|
+
if (this.topicResumes.get(key) === pending)
|
|
1043
|
+
this.topicResumes.delete(key);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (!binding.link && this.transport.topicLink) {
|
|
1047
|
+
const link = await this.transport.topicLink(binding.messageId).catch(() => undefined);
|
|
1048
|
+
if (link)
|
|
1049
|
+
binding = await this.state.topics.record({ ...binding, link });
|
|
1050
|
+
}
|
|
1051
|
+
return binding;
|
|
1052
|
+
}
|
|
1053
|
+
topicNavigation(binding, title) {
|
|
1054
|
+
return binding.link ? `已准备好「${title}」。[打开话题继续](${binding.link})。`
|
|
1055
|
+
: `已准备好「${title}」。请在聊天记录中打开对应话题继续。`;
|
|
1056
|
+
}
|
|
1057
|
+
policy(message, lane, selection, cwd) {
|
|
819
1058
|
const context = this.context(message);
|
|
1059
|
+
const harness = message.chatType === 'p2p' && lane.user.directMode === 'harness';
|
|
820
1060
|
return {
|
|
821
|
-
cwd: lane.user.cwd, allowedTools: lane.user.allowedTools,
|
|
822
|
-
mode:
|
|
1061
|
+
cwd: harness && cwd ? cwd : lane.user.cwd, allowedTools: lane.user.allowedTools,
|
|
1062
|
+
mode: harness ? 'harness' : 'restricted',
|
|
1063
|
+
...(harness && lane.user.agentPreset ? { agentPreset: lane.user.agentPreset } : {}),
|
|
823
1064
|
provider: this.config.provider, model: this.config.model,
|
|
824
1065
|
presentation: Boolean(this.state.artifacts),
|
|
825
1066
|
...selection,
|
|
@@ -855,6 +1096,8 @@ export class BotBridge {
|
|
|
855
1096
|
}
|
|
856
1097
|
if (lane.session) {
|
|
857
1098
|
await this.adoptPermission(lane);
|
|
1099
|
+
if (this.topicsEnabled())
|
|
1100
|
+
await this.applyPermission(message, lane, lane.session, false);
|
|
858
1101
|
return;
|
|
859
1102
|
}
|
|
860
1103
|
}
|
|
@@ -863,7 +1106,7 @@ export class BotBridge {
|
|
|
863
1106
|
// Like the App: a shared session starts on the host default and resumes on its own record.
|
|
864
1107
|
const selection = this.sharedModel(message, lane) ? undefined : modelPreference?.selection;
|
|
865
1108
|
const handle = saved
|
|
866
|
-
? await this.runtime.resume(this.policy(message, lane, selection), saved.nativeId)
|
|
1109
|
+
? await this.runtime.resume(this.policy(message, lane, selection, saved.cwd), saved.nativeId)
|
|
867
1110
|
: await this.runtime.create(this.policy(message, lane, selection));
|
|
868
1111
|
try {
|
|
869
1112
|
await handle.checkpoint();
|
|
@@ -872,7 +1115,10 @@ export class BotBridge {
|
|
|
872
1115
|
// Publish a route only after even the empty native log is durable, and
|
|
873
1116
|
// before submitting the first prompt. An abandoned creation has no work.
|
|
874
1117
|
lane.saved = saved ? await this.bindRestoredSession(context, saved, handle)
|
|
875
|
-
: await this.state.sessions.add(context, handle.id, preview(message.text, 40) || '新会话');
|
|
1118
|
+
: await this.state.sessions.add(context, handle.id, preview(message.text, 40) || '新会话', lane.user.cwd);
|
|
1119
|
+
if (message.topic && !message.topic.sessionId) {
|
|
1120
|
+
message.topic = await this.state.topics.record({ ...message.topic, sessionId: lane.saved.id });
|
|
1121
|
+
}
|
|
876
1122
|
lane.restored = Boolean(saved);
|
|
877
1123
|
lane.session = handle;
|
|
878
1124
|
}
|
|
@@ -939,6 +1185,11 @@ export class BotBridge {
|
|
|
939
1185
|
if (command === 'new' || command === 'reset' || command === 'clear') {
|
|
940
1186
|
if (args)
|
|
941
1187
|
return '用法:/new;创建新会话后可用 /rename <标题> 命名。';
|
|
1188
|
+
if (this.topicsEnabled()) {
|
|
1189
|
+
const binding = await this.createTopic(message, '新话题', this.newTopicKey(message));
|
|
1190
|
+
message.topicResult = { binding, title: '新话题', newEntry: true };
|
|
1191
|
+
return this.topicNavigation(binding, '新话题');
|
|
1192
|
+
}
|
|
942
1193
|
await this.unload(lane);
|
|
943
1194
|
await this.state.sessions.archive(conversation);
|
|
944
1195
|
lane.saved = undefined;
|
|
@@ -946,14 +1197,17 @@ export class BotBridge {
|
|
|
946
1197
|
return '好,我们换个话题。之前的对话还在,需要时可以用 /sessions 找回来。';
|
|
947
1198
|
}
|
|
948
1199
|
if (command === 'sessions') {
|
|
949
|
-
const sessions = await this.state.sessions.list(conversation);
|
|
1200
|
+
const sessions = this.topicsEnabled() ? await this.topicSessions(message) : await this.state.sessions.list(conversation);
|
|
950
1201
|
const current = await this.state.sessions.current(conversation);
|
|
1202
|
+
const view = message.sessionView ?? sessionViewCommandParse(message.text)?.view ?? { page: 0, query: '' };
|
|
1203
|
+
const page = sessionPage(sessions, view);
|
|
951
1204
|
if (args !== 'detail')
|
|
952
|
-
return sessions.length ? '
|
|
953
|
-
.map(s => `${s.id === current?.id ? '正在聊 · ' : ''}${s.title}`).join('\n')
|
|
954
|
-
+ '\n
|
|
1205
|
+
return sessions.length ? `当前聊天的话题(${page.total} 个${view.query ? '匹配' : ''},第 ${page.view.page + 1}/${page.pages} 页):\n` + page.items
|
|
1206
|
+
.map(s => `${s.id === current?.id ? '正在聊 · ' : ''}${s.title}\n/resume ${s.id}`).join('\n')
|
|
1207
|
+
+ '\n可在卡片中搜索或选择话题。文字操作:/sessions page <页码>。'
|
|
955
1208
|
: '这里还没有以前的对话,直接开始聊就好。';
|
|
956
|
-
return sessions.length ?
|
|
1209
|
+
return sessions.length ? `当前聊天的会话(${sessions.length} 个):\n` + sessions.slice(0, 20).map(s => `${s.id}${s.id === current?.id ? ' [当前]' : ''} · ${s.title}`).join('\n')
|
|
1210
|
+
+ (this.topicsEnabled() ? '\n用 /resume <ID> 打开对应话题;只显示你在当前聊天有权访问的历史。' : '\n用 /resume <ID> 切换;只显示你在当前聊天/话题的历史。') : '当前聊天/话题还没有保存的会话。';
|
|
957
1211
|
}
|
|
958
1212
|
if (command === 'rename') {
|
|
959
1213
|
if (!args)
|
|
@@ -965,15 +1219,31 @@ export class BotBridge {
|
|
|
965
1219
|
if (command === 'resume') {
|
|
966
1220
|
if (!/^[a-f0-9]{12}$/.test(args))
|
|
967
1221
|
return '用法:/resume <ID>;请从当前聊天的 /sessions 列表选择。';
|
|
1222
|
+
if (this.topicsEnabled()) {
|
|
1223
|
+
const sessions = await this.topicSessions(message);
|
|
1224
|
+
const target = (message.sessionView ? sessionPage(sessions, message.sessionView).items : sessions).find(session => session.id === args);
|
|
1225
|
+
if (!target) {
|
|
1226
|
+
if (message.sessionView)
|
|
1227
|
+
throw new SessionStoreError('NOT_FOUND');
|
|
1228
|
+
return '在你当前的聊天中找不到这个会话。';
|
|
1229
|
+
}
|
|
1230
|
+
const binding = await this.continueTopic(message, target);
|
|
1231
|
+
message.topicResult = { binding, title: target.title, newEntry: !target.topic };
|
|
1232
|
+
return this.topicNavigation(binding, target.title);
|
|
1233
|
+
}
|
|
968
1234
|
// Resolve authorization before touching the running handle or native log.
|
|
969
|
-
const
|
|
970
|
-
|
|
1235
|
+
const sessions = await this.state.sessions.list(conversation);
|
|
1236
|
+
const target = (message.sessionView ? sessionPage(sessions, message.sessionView).items : sessions).find(s => s.id === args);
|
|
1237
|
+
if (!target) {
|
|
1238
|
+
if (message.sessionView)
|
|
1239
|
+
throw new SessionStoreError('NOT_FOUND');
|
|
971
1240
|
return '在你当前的聊天/话题中找不到这个会话。';
|
|
1241
|
+
}
|
|
972
1242
|
if (!lane.unavailable && lane.session && lane.saved?.id === target.id)
|
|
973
1243
|
return `已经在会话「${target.title}」中。`;
|
|
974
1244
|
await this.unload(lane);
|
|
975
1245
|
const preference = await this.state.modelPreferences?.get(context);
|
|
976
|
-
const handle = await this.runtime.resume(this.policy(message, lane, this.sharedModel(message, lane) ? undefined : preference?.selection), target.nativeId);
|
|
1246
|
+
const handle = await this.runtime.resume(this.policy(message, lane, this.sharedModel(message, lane) ? undefined : preference?.selection, target.cwd), target.nativeId);
|
|
977
1247
|
try {
|
|
978
1248
|
await handle.checkpoint();
|
|
979
1249
|
await this.applyPermission(message, lane, handle, false);
|
|
@@ -1082,7 +1352,7 @@ export class BotBridge {
|
|
|
1082
1352
|
return;
|
|
1083
1353
|
if (view.current)
|
|
1084
1354
|
return view;
|
|
1085
|
-
const saved = await this.state.permissionPreferences?.get(this.
|
|
1355
|
+
const saved = await this.state.permissionPreferences?.get(this.permissionContext(message));
|
|
1086
1356
|
return saved && view.options.some(option => option.id === saved) ? { ...view, current: saved } : view;
|
|
1087
1357
|
}
|
|
1088
1358
|
/** Owner-only: switch this private chat's Harness permission preset, like the App's selector. */
|
|
@@ -1099,7 +1369,7 @@ export class BotBridge {
|
|
|
1099
1369
|
if (!option)
|
|
1100
1370
|
return '请通过权限模式卡片选择,或发送 /permission 查看可用选项。';
|
|
1101
1371
|
// Save first: a later session in this chat starts in the chosen mode even if the live switch fails.
|
|
1102
|
-
await this.state.permissionPreferences?.set(this.
|
|
1372
|
+
await this.state.permissionPreferences?.set(this.permissionContext(message), option.id);
|
|
1103
1373
|
if (lane.session?.setPermission) {
|
|
1104
1374
|
lane.session.setPermission(option.id);
|
|
1105
1375
|
lane.permissionSeen = option.id;
|
|
@@ -1111,7 +1381,7 @@ export class BotBridge {
|
|
|
1111
1381
|
async applyPermission(message, lane, handle, fresh) {
|
|
1112
1382
|
if (!this.harness(message, lane) || !handle.setPermission)
|
|
1113
1383
|
return;
|
|
1114
|
-
const { ownerKey, conversationKey } = this.
|
|
1384
|
+
const { ownerKey, conversationKey } = this.permissionContext(message);
|
|
1115
1385
|
lane.permissionContext = { ownerKey, conversationKey };
|
|
1116
1386
|
const saved = await this.state.permissionPreferences?.get(lane.permissionContext);
|
|
1117
1387
|
const state = handle.permissionState?.();
|
|
@@ -1121,6 +1391,10 @@ export class BotBridge {
|
|
|
1121
1391
|
handle.setPermission(target);
|
|
1122
1392
|
lane.permissionSeen = handle.permissionState?.()?.current;
|
|
1123
1393
|
}
|
|
1394
|
+
permissionContext(message) {
|
|
1395
|
+
return this.topicsEnabled() ? { ownerKey: ownerKey(message, this.config),
|
|
1396
|
+
conversationKey: sessionKey({ ...message, threadId: '' }, this.config) } : this.context(message);
|
|
1397
|
+
}
|
|
1124
1398
|
/** A preset changed in the App on this chat's live session becomes the chat's choice, so new sessions keep it. */
|
|
1125
1399
|
async adoptPermission(lane) {
|
|
1126
1400
|
const live = lane.permissionContext ? lane.session?.permissionState?.() : undefined;
|
|
@@ -1247,6 +1521,12 @@ export class BotBridge {
|
|
|
1247
1521
|
async commandPanel(message, lane, card) {
|
|
1248
1522
|
const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(message.text);
|
|
1249
1523
|
const command = match?.[1]?.toLowerCase(), args = (match?.[2] ?? '').trim();
|
|
1524
|
+
if (message.topicResult && command !== 'new' && !message.sessionView && !card?.pending && !card?.error && !card?.notice) {
|
|
1525
|
+
const { binding, title } = message.topicResult;
|
|
1526
|
+
const text = this.topicNavigation(binding, title);
|
|
1527
|
+
return { title: '话题已就绪', tone: 'success', summary: `打开「${preview(title, 40)}」继续`,
|
|
1528
|
+
sections: [{ title: preview(title, 40), text }], note: message.chatType === 'group' ? '在话题里继续 @机器人。' : '在对应话题中发消息,就能接着之前的上下文。' };
|
|
1529
|
+
}
|
|
1250
1530
|
const choice = /^use (\S+)$/.exec(args)?.[1];
|
|
1251
1531
|
const pending = card?.pending ? choice : undefined;
|
|
1252
1532
|
const interactive = this.cardActionsReady && this.config.cardInteractions && Boolean(this.state.actions);
|
|
@@ -1283,44 +1563,31 @@ export class BotBridge {
|
|
|
1283
1563
|
}
|
|
1284
1564
|
// Ordinary conversation and diagnostics stay out of management cards.
|
|
1285
1565
|
// A card is useful here because the user explicitly wants to pick a topic.
|
|
1286
|
-
const topic = card && (command === 'resume' || command === 'new');
|
|
1566
|
+
const topic = (card || message.sessionView || message.topicResult) && (command === 'resume' || command === 'new');
|
|
1287
1567
|
if ((command !== 'sessions' || args === 'detail') && !topic)
|
|
1288
1568
|
return;
|
|
1289
1569
|
const conversation = this.sessionContext(message);
|
|
1290
1570
|
const current = await this.state.sessions.current(conversation);
|
|
1291
|
-
const sessions = await this.state.sessions.list(conversation);
|
|
1292
|
-
if (!sessions.length)
|
|
1293
|
-
return;
|
|
1571
|
+
const sessions = this.topicsEnabled() ? await this.topicSessions(message) : await this.state.sessions.list(conversation);
|
|
1294
1572
|
const busy = Boolean(card?.pending);
|
|
1295
1573
|
const target = command === 'resume' ? sessions.find(s => s.id === args) : undefined;
|
|
1296
1574
|
const status = card?.error ? { text: card.error, tone: 'error' }
|
|
1297
|
-
: busy ? { text: target ?
|
|
1298
|
-
:
|
|
1299
|
-
: topic
|
|
1300
|
-
:
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
}
|
|
1575
|
+
: busy ? { text: target ? `正在打开「${preview(target.title, 40)}」…` : command === 'new' ? '正在开始新话题…' : '正在查找话题…', tone: 'pending' }
|
|
1576
|
+
: card?.notice ? { text: card.notice }
|
|
1577
|
+
: topic && command === 'resume' && target ? undefined
|
|
1578
|
+
: command === 'new' && message.topicResult ? { text: message.topicResult.binding.link
|
|
1579
|
+
? `新话题已创建。[进入新话题](${message.topicResult.binding.link})` : '新话题已创建,请在新卡片下方回复话题。' }
|
|
1580
|
+
: topic ? { text: '好,换个话题。直接发消息就会开始新的对话,之前的还在这里。', tone: 'success' }
|
|
1581
|
+
: undefined;
|
|
1582
|
+
return sessionControlPanel(sessions.map(session => ({ ...session, ...('topic' in session && session.topic?.link
|
|
1583
|
+
? { link: session.topic.link } : {}) })), current?.id, (label, command) => {
|
|
1307
1584
|
if (!interactive)
|
|
1308
1585
|
return;
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
for (const session of sessions.filter(s => s.id !== current?.id).slice(0, 5))
|
|
1315
|
-
add(`继续 ${preview(session.title, 14)}`, `/resume ${session.id}`, { expectedSessionId: current?.id ?? null, confirm: { title: '接着这个话题聊?', text: `继续「${preview(session.title, 40)}」,现在的对话会保留。` } });
|
|
1316
|
-
if (current)
|
|
1317
|
-
add('换个话题', '/new', { expectedSessionId: current.id,
|
|
1318
|
-
confirm: { title: '开始新话题?', text: '之前的对话会保留,需要时可以再找回来。' } });
|
|
1319
|
-
return { title: '以前的对话', subtitle: '选一个话题,接着聊', tone: status?.tone ?? 'normal', ...(status ? { status } : {}),
|
|
1320
|
-
summary: status?.text ?? '选择以前的话题继续,或开始一段新对话', actions,
|
|
1321
|
-
sections: sessions.slice(0, 5).map(s => ({ title: preview(s.title, 40), text: s.id === current?.id ? '正在聊这个话题' : '可以接着聊' })),
|
|
1322
|
-
...(!interactive || sessions.length > 5 ? { note: '完整列表和更多操作:/sessions detail' } : {}),
|
|
1323
|
-
};
|
|
1586
|
+
if (busy)
|
|
1587
|
+
return { label, value: {}, disabled: true };
|
|
1588
|
+
return { label, value: { token: issue(command, /^(?:\/new|\/sessions pick )/.test(command) ? { expectedSessionId: current?.id ?? null } : {}) } };
|
|
1589
|
+
}, { view: message.sessionView ?? sessionViewCommandParse(message.text)?.view,
|
|
1590
|
+
selectedId: !card?.error && !card?.notice && !busy ? target?.id : undefined, busy, status });
|
|
1324
1591
|
}
|
|
1325
1592
|
async reply(message, text, signal) {
|
|
1326
1593
|
return this.sendPayloads(message, renderAnswer(text, this.config.chunkBytes), signal);
|