@xmanrui/dsh-im 4.19.2 → 4.20.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.en.md +79 -1
- package/README.md +79 -1
- package/lib/client.js +594 -217
- package/lib/index.js +279 -276
- package/package.json +9 -1
- package/plugin-src/client/channels/weixin/api.js +35 -17
- package/plugin-src/client/channels/weixin/connection-error.js +68 -0
- package/plugin-src/client/channels/weixin/index.js +36 -12
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/host/channels/shared/startup.mjs +7 -4
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
- package/plugin-src/host/channels/weixin/index.mjs +12 -3
- package/plugin-src/host/channels/weixin/production.mjs +53 -3
- package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
- package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
- package/src/channels/feishu/bridge.mjs +44 -13
- package/src/channels/qq/qq-bridge.mjs +12 -4
- package/src/channels/qq/qq-menu.mjs +11 -8
- package/src/channels/shared/bot-workspace-store.mjs +532 -40
- package/src/channels/shared/command-catalog.mjs +5 -0
- package/src/channels/shared/compact-command.mjs +14 -4
- package/src/channels/shared/control-command.mjs +1 -1
- package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
- package/src/channels/shared/history-command.mjs +1 -1
- package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
- package/src/channels/shared/i18n-en/wecom.mjs +1 -1
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/model-command.mjs +5 -3
- package/src/channels/shared/workspace-command.mjs +114 -9
- package/src/channels/shared/workspace-session.mjs +55 -5
- package/src/channels/telegram/telegram-runtime.mjs +1 -1
- package/src/channels/wecom/wecom-bridge.mjs +14 -46
- package/src/channels/wecom/wecom-runtime.mjs +1 -1
- package/src/channels/weixin/connection-error.en.mjs +116 -0
- package/src/channels/weixin/connection-error.mjs +204 -0
- package/src/channels/weixin/diagnostic-details.mjs +40 -0
- package/src/channels/weixin/state-store.mjs +4 -3
- package/src/channels/weixin/weixin-api.mjs +20 -8
- package/src/channels/weixin/weixin-bridge.mjs +3 -2
- package/src/channels/weixin/weixin-controller.mjs +133 -104
- package/src/channels/weixin/weixin-runtime.mjs +35 -24
|
@@ -35,6 +35,11 @@ export const SHARED_COMMAND_CATALOG = Object.freeze([
|
|
|
35
35
|
defineCatalogCommand('workspace', '切换工作区', [
|
|
36
36
|
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
37
37
|
], { aliases: ['ws'] }),
|
|
38
|
+
defineCatalogCommand('conv', '设置当前对话专属工作区', [
|
|
39
|
+
'/conv 或 /conversation 查看当前对话工作区',
|
|
40
|
+
'/conv 工作区绝对路径或序号 设置当前对话专属工作区',
|
|
41
|
+
'/conv clear 清除专属工作区,回到 bot 默认工作区',
|
|
42
|
+
], { aliases: ['conversation', 'thread'] }),
|
|
38
43
|
defineCatalogCommand('workspacelist', '列出工作区绝对路径', [
|
|
39
44
|
'/workspacelist 列出工作区绝对路径',
|
|
40
45
|
'/ws、/wsl、/workspaces 工作区命令别名',
|
|
@@ -80,11 +80,21 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
|
|
|
80
80
|
if (typeof sessionId !== 'string' || !sessionId) {
|
|
81
81
|
return commandResult(t('当前聊天还没有可压缩的会话,请先发送一条消息。'));
|
|
82
82
|
}
|
|
83
|
-
if (typeof harness?.executeCommand !== 'function') {
|
|
84
|
-
return commandResult(t('当前机器人暂不支持上下文压缩。'));
|
|
85
|
-
}
|
|
86
83
|
try {
|
|
87
|
-
|
|
84
|
+
let execution;
|
|
85
|
+
if (typeof harness?.workspaceSession === 'function') {
|
|
86
|
+
const session = harness.workspaceSession(sessionId, conversationKey);
|
|
87
|
+
if (typeof session?.executeCommand !== 'function') {
|
|
88
|
+
return commandResult(t('当前机器人暂不支持上下文压缩。'));
|
|
89
|
+
}
|
|
90
|
+
execution = await session.executeCommand('/compact', options);
|
|
91
|
+
} else {
|
|
92
|
+
// Legacy Harnesses have no scoped Session handle to carry the route fence.
|
|
93
|
+
if (typeof harness?.executeCommand !== 'function') {
|
|
94
|
+
return commandResult(t('当前机器人暂不支持上下文压缩。'));
|
|
95
|
+
}
|
|
96
|
+
execution = await harness.executeCommand(sessionId, '/compact', options);
|
|
97
|
+
}
|
|
88
98
|
if (execution === undefined) {
|
|
89
99
|
return commandResult(t('当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。'));
|
|
90
100
|
}
|
|
@@ -24,7 +24,7 @@ function boundSession(harness, state, key) {
|
|
|
24
24
|
if (typeof harness?.workspaceSession !== 'function') {
|
|
25
25
|
throw new TypeError('Harness does not support workspace sessions');
|
|
26
26
|
}
|
|
27
|
-
const session = harness.workspaceSession(sessionId);
|
|
27
|
+
const session = harness.workspaceSession(sessionId, key);
|
|
28
28
|
if (!session || typeof session !== 'object') {
|
|
29
29
|
throw new TypeError('Harness returned an invalid workspace session');
|
|
30
30
|
}
|
|
@@ -128,7 +128,7 @@ export function createDeferredDeliveryCoordinator({
|
|
|
128
128
|
entry = { ...entry, turn: outcome.turn };
|
|
129
129
|
}
|
|
130
130
|
if (stopping) {
|
|
131
|
-
const session = harness.workspaceSession?.(entry.sessionId);
|
|
131
|
+
const session = harness.workspaceSession?.(entry.sessionId, entry.key);
|
|
132
132
|
const stopped = typeof session?.stopDeferredTurn === 'function'
|
|
133
133
|
? await session.stopDeferredTurn({ turn: entry.turn, promptRpcId: entry.promptRpcId }, {
|
|
134
134
|
signal: activeSignal, isCurrent: () => bound(entry),
|
|
@@ -124,7 +124,7 @@ export async function runHistoryCommand(text, harness, state, key, {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
try {
|
|
127
|
-
const session = harness?.workspaceSession?.(sessionId);
|
|
127
|
+
const session = harness?.workspaceSession?.(sessionId, key);
|
|
128
128
|
if (typeof session?.readHistory !== 'function') {
|
|
129
129
|
return commandResult(t('当前 Harness 暂不支持读取会话历史。'));
|
|
130
130
|
}
|
|
@@ -125,6 +125,47 @@ export default {
|
|
|
125
125
|
'/compact 压缩当前会话的较早上下文': '/compact Compact the earlier context of the current session',
|
|
126
126
|
'/workspace 工作区序号或绝对路径 切换工作区':
|
|
127
127
|
'/workspace <workspace index or absolute path> Switch workspace',
|
|
128
|
+
'设置当前对话专属工作区': 'Set a workspace dedicated to this conversation',
|
|
129
|
+
'/conv 或 /conversation 查看当前对话工作区':
|
|
130
|
+
'/conv or /conversation Show the workspace of this conversation',
|
|
131
|
+
'/conv 工作区绝对路径或序号 设置当前对话专属工作区':
|
|
132
|
+
'/conv <workspace absolute path or index> Set a workspace dedicated to this conversation',
|
|
133
|
+
'/conv clear 清除专属工作区,回到 bot 默认工作区':
|
|
134
|
+
'/conv clear Clear the dedicated workspace and fall back to the bot default',
|
|
135
|
+
'对话专属:/conv 工作区序号或绝对路径(仅影响当前对话)':
|
|
136
|
+
'Dedicated to this conversation: /conv <workspace index or absolute path> (affects this conversation only)',
|
|
137
|
+
'当前对话工作区:{workspace}': 'This conversation uses the workspace: {workspace}',
|
|
138
|
+
'状态:已为该对话显式绑定,之后修改 bot 默认工作区不会影响本对话。':
|
|
139
|
+
'Status: explicitly bound for this conversation; changing the bot default workspace later will not affect it.',
|
|
140
|
+
'状态:未显式绑定,当前跟随 bot 默认工作区。':
|
|
141
|
+
'Status: not explicitly bound; this conversation currently follows the bot default workspace.',
|
|
142
|
+
'当前对话工作区已切换为:{workspace}': 'This conversation now uses the workspace: {workspace}',
|
|
143
|
+
'已清除对话专属工作区,当前使用 bot 默认工作区:{workspace}(之后默认工作区的变化会同步到本对话)':
|
|
144
|
+
'Cleared the dedicated workspace. This conversation now uses the bot default: {workspace} (later changes to that default follow here as well)',
|
|
145
|
+
'可切换的工作区({count}):': 'Available workspaces ({count}):',
|
|
146
|
+
'用法:/conv 工作区序号或绝对路径': 'Usage: /conv <workspace index or absolute path>',
|
|
147
|
+
'清除:/conv clear': 'Clear: /conv clear',
|
|
148
|
+
'{message}\n用法:/conv 工作区绝对路径': '{message}\nUsage: /conv <workspace absolute path>',
|
|
149
|
+
'当前机器人暂不支持按对话设置专属工作区。':
|
|
150
|
+
'This bot does not support per-conversation workspaces yet.',
|
|
151
|
+
'当前机器人暂不支持设置对话工作区。':
|
|
152
|
+
'This bot does not support setting a conversation workspace yet.',
|
|
153
|
+
'当前消息缺少可设置的对话上下文。':
|
|
154
|
+
'This message has no conversation context to bind a workspace to.',
|
|
155
|
+
'暂时无法读取当前对话工作区,请稍后重试。':
|
|
156
|
+
'This conversation workspace is temporarily unavailable. Please try again later.',
|
|
157
|
+
'暂时无法清除对话工作区,请稍后重试。':
|
|
158
|
+
'The conversation workspace could not be cleared right now. Please try again later.',
|
|
159
|
+
'机器人正在移除或已重新接入,无法读取对话工作区。':
|
|
160
|
+
'The bot is being removed or was reconnected, so the conversation workspace cannot be read.',
|
|
161
|
+
'机器人正在移除或已重新接入,无法清除对话工作区。':
|
|
162
|
+
'The bot is being removed or was reconnected, so the conversation workspace cannot be cleared.',
|
|
163
|
+
'机器人正在移除或已重新接入,无法切换对话工作区。':
|
|
164
|
+
'The bot is being removed or was reconnected, so the conversation workspace cannot be switched.',
|
|
165
|
+
'不带工作区参数时,/sessionlist 默认列出当前对话的有效工作区。':
|
|
166
|
+
'Without a workspace argument, /sessionlist lists the workspace this conversation effectively uses.',
|
|
167
|
+
'不带工作区参数时,默认列出当前对话的有效工作区(未设置对话专属工作区时即 bot 默认工作区)。':
|
|
168
|
+
'Without a workspace argument it lists the workspace this conversation effectively uses (the bot default when no conversation workspace is set).',
|
|
128
169
|
'/workspacelist 列出工作区绝对路径': '/workspacelist List absolute workspace paths',
|
|
129
170
|
'/ws、/wsl、/workspaces 工作区命令别名': '/ws, /wsl, /workspaces Workspace command aliases',
|
|
130
171
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题':
|
|
@@ -23,7 +23,7 @@ export default {
|
|
|
23
23
|
'暂无可用选项。': 'No options are available.',
|
|
24
24
|
'预设仅用于之后的新会话。': 'Presets apply to future new sessions.',
|
|
25
25
|
'部分模型暂不可用,可稍后重试。': 'Some models are unavailable. Try again later.',
|
|
26
|
-
'
|
|
26
|
+
'工作区已变化,请发送 /m 重新打开菜单后选择。': 'The workspace changed. Send /m to reopen the menu and select again.',
|
|
27
27
|
// Help text (wecom-bridge.mjs)
|
|
28
28
|
'企业微信机器人已连接 DeepSeek Harness。': 'The Enterprise WeChat bot is connected to DeepSeek Harness.',
|
|
29
29
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import diagnostics from '../../weixin/connection-error.en.mjs';
|
|
1
2
|
// English translations (weixin area). Keys are exact Chinese literals passed to t().
|
|
2
3
|
export default {
|
|
4
|
+
...diagnostics,
|
|
3
5
|
// weixin-bridge.mjs
|
|
4
6
|
'微信已连接 DeepSeek Harness。': 'WeChat is connected to DeepSeek Harness.',
|
|
5
7
|
'结果文件「{name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。': 'The result file "{name}" was generated, but the WeChat bot currently has no permission to send file messages. Please check the bot\'s file messaging capability.',
|
|
@@ -443,7 +443,7 @@ async function boundSession(harness, state, key, options) {
|
|
|
443
443
|
if (typeof harness?.workspaceSession !== 'function') {
|
|
444
444
|
throw new TypeError('Harness does not support workspace sessions');
|
|
445
445
|
}
|
|
446
|
-
const session = harness.workspaceSession(sessionId);
|
|
446
|
+
const session = harness.workspaceSession(sessionId, key);
|
|
447
447
|
if (!session || typeof session.sessionExists !== 'function') {
|
|
448
448
|
throw new TypeError('Harness returned an invalid workspace session');
|
|
449
449
|
}
|
|
@@ -705,11 +705,13 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|
|
705
705
|
throw new TypeError('Harness cannot create a conversation session');
|
|
706
706
|
}
|
|
707
707
|
// An explicit choice must remain usable even when the saved bot model expires.
|
|
708
|
-
const sessionId = await harness.createSession({
|
|
708
|
+
const sessionId = await harness.createSession({
|
|
709
|
+
...requestOptions, inheritBotModel: false, conversationKey: key,
|
|
710
|
+
});
|
|
709
711
|
if (typeof sessionId !== 'string' || !sessionId) {
|
|
710
712
|
throw new TypeError('Harness returned an invalid session id');
|
|
711
713
|
}
|
|
712
|
-
const session = harness.workspaceSession(sessionId);
|
|
714
|
+
const session = harness.workspaceSession(sessionId, key);
|
|
713
715
|
applied = await selectAndVerifyModel(session, selection, requestOptions);
|
|
714
716
|
const currentSessionId = state.sessionFor(key);
|
|
715
717
|
if (typeof currentSessionId === 'string' && currentSessionId) {
|
|
@@ -6,6 +6,7 @@ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
|
|
6
6
|
|
|
7
7
|
const WORKSPACE_COMMAND = /^\/(?:workspace|ws)(?:\s+([\s\S]+))?$/i;
|
|
8
8
|
const WORKSPACE_LIST_COMMAND = /^\/(?:workspacelist|workspaces|wsl)(?:\s+([\s\S]+))?$/i;
|
|
9
|
+
const THREAD_WORKSPACE_COMMAND = /^\/(?:conv|conversation|thread)(?:\s+([\s\S]+))?$/i;
|
|
9
10
|
const SESSION_LIST_COMMAND = /^\/(?:sessionlist|sessions)(?:\s+([\s\S]+))?$/i;
|
|
10
11
|
const SESSION_BIND_PREFIX = /^\/session(?=$|\s)/i;
|
|
11
12
|
const SESSION_BIND_COMMAND = /^\/session[ \t]+([^\s]+)$/i;
|
|
@@ -21,6 +22,7 @@ const SESSION_LIST_USAGE = [
|
|
|
21
22
|
'/sessionlist --limit N 列出当前工作区前 N 个会话(N 为正整数)',
|
|
22
23
|
'/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
|
|
23
24
|
'/sessionlist 工作区绝对路径 列出指定工作区会话',
|
|
25
|
+
'不带工作区参数时,默认列出当前对话的有效工作区(未设置对话专属工作区时即 bot 默认工作区)。',
|
|
24
26
|
].join('\n');
|
|
25
27
|
|
|
26
28
|
function commandResult(message, messages = [message]) {
|
|
@@ -158,7 +160,9 @@ async function runWorkspaceListCommand(match, harness) {
|
|
|
158
160
|
)),
|
|
159
161
|
'',
|
|
160
162
|
t('切换用法:/workspace 工作区序号或绝对路径'),
|
|
163
|
+
t('对话专属:/conv 工作区序号或绝对路径(仅影响当前对话)'),
|
|
161
164
|
t('查看会话:/sessionlist 工作区序号或绝对路径'),
|
|
165
|
+
t('不带工作区参数时,/sessionlist 默认列出当前对话的有效工作区。'),
|
|
162
166
|
];
|
|
163
167
|
const message = lines.join('\n');
|
|
164
168
|
return commandResult(message, splitWorkspaceCommandMessage(message));
|
|
@@ -170,8 +174,22 @@ async function runWorkspaceListCommand(match, harness) {
|
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
|
|
177
|
+
async function conversationEffectiveWorkspace(harness, conversationKey) {
|
|
178
|
+
if (typeof harness?.currentConversationWorkspace !== 'function'
|
|
179
|
+
|| typeof conversationKey !== 'string' || !conversationKey) return null;
|
|
180
|
+
const [current] = await existingWorkspacePaths([harness.currentConversationWorkspace(conversationKey)]);
|
|
181
|
+
harness.assertWorkspaceScope?.();
|
|
182
|
+
return current ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
173
185
|
export async function resolveSessionListWorkspace(selector, harness, options = {}) {
|
|
174
186
|
if (!selector) {
|
|
187
|
+
// With no explicit selector the listing follows the conversation's effective
|
|
188
|
+
// workspace, so /conv B makes /sessionlist list B instead of the bot default.
|
|
189
|
+
if (options.conversationKey) {
|
|
190
|
+
const effective = await conversationEffectiveWorkspace(harness, options.conversationKey);
|
|
191
|
+
if (effective) return { workspace: effective };
|
|
192
|
+
}
|
|
175
193
|
if (typeof harness?.currentWorkspace !== 'function') {
|
|
176
194
|
return { error: t('当前机器人没有可用的工作区。') };
|
|
177
195
|
}
|
|
@@ -242,21 +260,23 @@ function sessionListMessage(workspace, sessions, { currentWorkspace = false } =
|
|
|
242
260
|
].join('\n');
|
|
243
261
|
}
|
|
244
262
|
|
|
245
|
-
async function currentSessionListWorkspace(harness) {
|
|
263
|
+
async function currentSessionListWorkspace(harness, conversationKey) {
|
|
264
|
+
const effective = await conversationEffectiveWorkspace(harness, conversationKey);
|
|
265
|
+
if (effective) return effective;
|
|
246
266
|
if (typeof harness?.currentWorkspace !== 'function') return null;
|
|
247
267
|
const [current] = await existingWorkspacePaths([harness.currentWorkspace()]);
|
|
248
268
|
harness.assertWorkspaceScope?.();
|
|
249
269
|
return current ?? null;
|
|
250
270
|
}
|
|
251
271
|
|
|
252
|
-
async function runSessionListCommand(match, harness) {
|
|
272
|
+
async function runSessionListCommand(match, harness, conversationKey) {
|
|
253
273
|
const request = parseSessionListArgument(match[1]);
|
|
254
274
|
if (request.error) return commandResult(request.error);
|
|
255
275
|
if (typeof harness?.listWorkspaceSessions !== 'function') {
|
|
256
276
|
return commandResult(t('当前机器人暂不支持列出工作区会话。'));
|
|
257
277
|
}
|
|
258
278
|
try {
|
|
259
|
-
const resolved = await resolveSessionListWorkspace(request.selector, harness);
|
|
279
|
+
const resolved = await resolveSessionListWorkspace(request.selector, harness, { conversationKey });
|
|
260
280
|
if (resolved.error) return commandResult(resolved.error);
|
|
261
281
|
const listed = await harness.listWorkspaceSessions(resolved.workspace);
|
|
262
282
|
if (!listed || !Array.isArray(listed.sessions)) {
|
|
@@ -264,7 +284,7 @@ async function runSessionListCommand(match, harness) {
|
|
|
264
284
|
}
|
|
265
285
|
harness.assertWorkspaceScope?.();
|
|
266
286
|
const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
|
|
267
|
-
const currentWorkspace = await currentSessionListWorkspace(harness);
|
|
287
|
+
const currentWorkspace = await currentSessionListWorkspace(harness, conversationKey);
|
|
268
288
|
const sessions = request.limit === null
|
|
269
289
|
? listed.sessions
|
|
270
290
|
: listed.sessions.slice(0, request.limit);
|
|
@@ -311,13 +331,15 @@ async function runSessionBindCommand(command, harness, conversationKey) {
|
|
|
311
331
|
const match = SESSION_BIND_COMMAND.exec(command);
|
|
312
332
|
let sessionId = match?.[1];
|
|
313
333
|
if (typeof sessionId === 'string' && /^\d+$/u.test(sessionId)) {
|
|
314
|
-
//
|
|
334
|
+
// Use the same effective-workspace resolver as /sessionlist, so its indexes
|
|
335
|
+
// cannot select a session from the bot default after /conv sets an override.
|
|
315
336
|
if (typeof harness?.listWorkspaceSessions !== 'function'
|
|
316
|
-
|| typeof harness?.currentWorkspace !== 'function'
|
|
337
|
+
|| (typeof harness?.currentWorkspace !== 'function'
|
|
338
|
+
&& typeof harness?.currentConversationWorkspace !== 'function')) {
|
|
317
339
|
return commandResult(t('当前机器人暂不支持按序号绑定,请使用 /session Session ID。'));
|
|
318
340
|
}
|
|
319
341
|
try {
|
|
320
|
-
const selected = await
|
|
342
|
+
const selected = await resolveSessionListWorkspace('', harness, { conversationKey });
|
|
321
343
|
if (selected.error) return commandResult(selected.error);
|
|
322
344
|
const listed = await harness.listWorkspaceSessions(selected.workspace);
|
|
323
345
|
if (!listed || !Array.isArray(listed.sessions)) {
|
|
@@ -371,14 +393,96 @@ async function runSessionBindCommand(command, harness, conversationKey) {
|
|
|
371
393
|
}
|
|
372
394
|
}
|
|
373
395
|
|
|
396
|
+
async function runConversationWorkspaceCommand(command, harness, conversationKey) {
|
|
397
|
+
if (typeof harness?.currentConversationWorkspace !== 'function') {
|
|
398
|
+
return commandResult(t('当前机器人暂不支持按对话设置专属工作区。'));
|
|
399
|
+
}
|
|
400
|
+
if (typeof conversationKey !== 'string' || !conversationKey) {
|
|
401
|
+
return commandResult(t('当前消息缺少可设置的对话上下文。'));
|
|
402
|
+
}
|
|
403
|
+
const argument = THREAD_WORKSPACE_COMMAND.exec(command)?.[1]?.trim() ?? '';
|
|
404
|
+
if (!argument) {
|
|
405
|
+
try {
|
|
406
|
+
const current = harness.currentConversationWorkspace(conversationKey);
|
|
407
|
+
harness.assertWorkspaceScope?.();
|
|
408
|
+
const lines = [t('当前对话工作区:{workspace}', { workspace: current })];
|
|
409
|
+
// 明确区分「显式绑定」与「回落 bot 默认」,否则用户无法判断之后改默认值会不会影响本对话。
|
|
410
|
+
const bound = typeof harness?.hasConversationWorkspaceOverride === 'function'
|
|
411
|
+
&& harness.hasConversationWorkspaceOverride(conversationKey) === true;
|
|
412
|
+
lines.push(bound
|
|
413
|
+
? t('状态:已为该对话显式绑定,之后修改 bot 默认工作区不会影响本对话。')
|
|
414
|
+
: t('状态:未显式绑定,当前跟随 bot 默认工作区。'));
|
|
415
|
+
// 顺带列出可切换的工作区,省得先跑一次 /workspacelist 再回来切。
|
|
416
|
+
try {
|
|
417
|
+
const { paths } = await workspacePathSnapshot(harness);
|
|
418
|
+
if (paths.length > 0) {
|
|
419
|
+
lines.push('', t('可切换的工作区({count}):', { count: paths.length }));
|
|
420
|
+
for (const [index, workspace] of paths.entries()) {
|
|
421
|
+
lines.push(`${index + 1}. ${workspace}${workspace === current ? t('(当前)') : ''}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} catch { /* 列表拿不到不影响主信息 */ }
|
|
425
|
+
lines.push('', t('用法:/conv 工作区序号或绝对路径'), t('清除:/conv clear'));
|
|
426
|
+
const message = lines.join('\n');
|
|
427
|
+
return commandResult(message, splitWorkspaceCommandMessage(message));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error?.code === 'workspace-bot-not-found') {
|
|
430
|
+
return commandResult(t('机器人正在移除或已重新接入,无法读取对话工作区。'));
|
|
431
|
+
}
|
|
432
|
+
return commandResult(t('暂时无法读取当前对话工作区,请稍后重试。'));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (/^(?:--default|clear)$/iu.test(argument)) {
|
|
436
|
+
try {
|
|
437
|
+
const current = await harness.clearConversationWorkspace(conversationKey);
|
|
438
|
+
harness.assertWorkspaceScope?.();
|
|
439
|
+
return commandResult(t('已清除对话专属工作区,当前使用 bot 默认工作区:{workspace}(之后默认工作区的变化会同步到本对话)', { workspace: current }));
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error?.code === 'workspace-bot-not-found') {
|
|
442
|
+
return commandResult(t('机器人正在移除或已重新接入,无法清除对话工作区。'));
|
|
443
|
+
}
|
|
444
|
+
return commandResult(t('暂时无法清除对话工作区,请稍后重试。'));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (typeof harness?.switchConversationWorkspace !== 'function') {
|
|
448
|
+
return commandResult(t('当前机器人暂不支持设置对话工作区。'));
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
let selected = argument;
|
|
452
|
+
if (/^\d+$/u.test(argument)) {
|
|
453
|
+
const { paths } = await workspacePathSnapshot(harness);
|
|
454
|
+
const position = Number(argument);
|
|
455
|
+
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
|
|
456
|
+
return commandResult(t('工作区序号不存在,请先执行 /workspacelist。'));
|
|
457
|
+
}
|
|
458
|
+
selected = paths[position - 1];
|
|
459
|
+
}
|
|
460
|
+
const current = await harness.switchConversationWorkspace(conversationKey, selected);
|
|
461
|
+
return commandResult(t('当前对话工作区已切换为:{workspace}', { workspace: current }));
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
|
|
464
|
+
return commandResult(t(`{message}
|
|
465
|
+
用法:/conv 工作区绝对路径`, { message: error.message }));
|
|
466
|
+
}
|
|
467
|
+
if (error?.code === 'workspace-bot-not-found') {
|
|
468
|
+
return commandResult(t('机器人正在移除或已重新接入,无法切换对话工作区。'));
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
374
474
|
export async function runWorkspaceCommand(text, harness, conversationKey) {
|
|
375
475
|
if (!isWorkspaceCommand(text)) return null;
|
|
376
476
|
const command = text.trim();
|
|
477
|
+
const threadMatch = THREAD_WORKSPACE_COMMAND.exec(command);
|
|
478
|
+
if (threadMatch) {
|
|
479
|
+
return runConversationWorkspaceCommand(command, harness, conversationKey);
|
|
480
|
+
}
|
|
377
481
|
if (SESSION_BIND_PREFIX.test(command)) {
|
|
378
482
|
return runSessionBindCommand(command, harness, conversationKey);
|
|
379
483
|
}
|
|
380
484
|
const sessionListMatch = SESSION_LIST_COMMAND.exec(command);
|
|
381
|
-
if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness);
|
|
485
|
+
if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness, conversationKey);
|
|
382
486
|
const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
|
|
383
487
|
if (listMatch) return runWorkspaceListCommand(listMatch, harness);
|
|
384
488
|
const match = WORKSPACE_COMMAND.exec(command);
|
|
@@ -423,5 +527,6 @@ export function isWorkspaceCommand(text) {
|
|
|
423
527
|
return SESSION_BIND_PREFIX.test(command)
|
|
424
528
|
|| SESSION_LIST_COMMAND.test(command)
|
|
425
529
|
|| WORKSPACE_LIST_COMMAND.test(command)
|
|
426
|
-
|| WORKSPACE_COMMAND.test(command)
|
|
530
|
+
|| WORKSPACE_COMMAND.test(command)
|
|
531
|
+
|| THREAD_WORKSPACE_COMMAND.test(command);
|
|
427
532
|
}
|
|
@@ -3,9 +3,45 @@ import { initialSessionTitle } from './session-title.mjs';
|
|
|
3
3
|
|
|
4
4
|
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
|
5
5
|
|
|
6
|
-
function
|
|
6
|
+
function workspaceSessionStaleError() {
|
|
7
|
+
const error = new Error('The conversation workspace changed before the prompt was sent.');
|
|
8
|
+
error.code = WORKSPACE_SESSION_STALE;
|
|
9
|
+
return error;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the conversation's effective-workspace generation. A bot-scoped Harness
|
|
14
|
+
* exposes it; anything else (plain fixtures, older Harnesses) yields null and
|
|
15
|
+
* disables the conversation-level fence without changing existing behavior.
|
|
16
|
+
*/
|
|
17
|
+
function readConversationGeneration(harness, conversationKey) {
|
|
18
|
+
return typeof harness?.conversationWorkspaceGeneration === 'function'
|
|
19
|
+
? harness.conversationWorkspaceGeneration(conversationKey) ?? null
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function conversationGenerationMoved(harness, conversationKey, generation) {
|
|
24
|
+
if (generation === null) return false;
|
|
25
|
+
return readConversationGeneration(harness, conversationKey) !== generation;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wait for a conversation-level workspace switch that is still committing.
|
|
30
|
+
* An explicit /conv publishes its fence before it persists, so a message that
|
|
31
|
+
* is already in flight must settle on the new workspace instead of resolving a
|
|
32
|
+
* session in the one being left behind.
|
|
33
|
+
*/
|
|
34
|
+
async function awaitPendingConversationSwitch(harness, conversationKey) {
|
|
35
|
+
if (typeof harness?.pendingConversationWorkspaceSwitch !== 'function') return;
|
|
36
|
+
const pending = harness.pendingConversationWorkspaceSwitch(conversationKey);
|
|
37
|
+
if (pending && typeof pending.then === 'function') await pending.catch(() => undefined);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function workspaceSession(harness, sessionId, conversationKey) {
|
|
7
41
|
if (typeof harness.workspaceSession === 'function') {
|
|
8
|
-
return
|
|
42
|
+
return conversationKey
|
|
43
|
+
? harness.workspaceSession(sessionId, conversationKey)
|
|
44
|
+
: harness.workspaceSession(sessionId);
|
|
9
45
|
}
|
|
10
46
|
const session = {
|
|
11
47
|
sessionId,
|
|
@@ -67,12 +103,20 @@ export async function askInWorkspaceSession({
|
|
|
67
103
|
while (true) {
|
|
68
104
|
try {
|
|
69
105
|
const binding = await withSessionBindingLock(state, key, async () => {
|
|
106
|
+
await awaitPendingConversationSwitch(harness, key);
|
|
70
107
|
let sessionId = state.sessionFor(key);
|
|
71
|
-
let session = sessionId ? workspaceSession(harness, sessionId) : null;
|
|
108
|
+
let session = sessionId ? workspaceSession(harness, sessionId, key) : null;
|
|
72
109
|
if (!session || !(await sessionExists(session, existsOptions))) {
|
|
73
|
-
sessionId = await createSession(harness,
|
|
110
|
+
sessionId = await createSession(harness, {
|
|
111
|
+
conversationKey: key,
|
|
112
|
+
...(createOptions ?? {}),
|
|
113
|
+
});
|
|
74
114
|
if (await state.setSession(key, sessionId) === false) return null;
|
|
75
|
-
|
|
115
|
+
// Binding committed: capture the conversation's effective-workspace
|
|
116
|
+
// generation together with the session, so the prompt below is fenced
|
|
117
|
+
// against a conversation switch that only commits after the bind.
|
|
118
|
+
const conversationGeneration = readConversationGeneration(harness, key);
|
|
119
|
+
session = workspaceSession(harness, sessionId, key);
|
|
76
120
|
if (initialTitle && typeof session.renameTitle === 'function') {
|
|
77
121
|
try {
|
|
78
122
|
await session.renameTitle(initialTitle, renameOptions);
|
|
@@ -83,6 +127,12 @@ export async function askInWorkspaceSession({
|
|
|
83
127
|
console.warn('[dsh-im] unable to set the initial Session title:', error?.message ?? error);
|
|
84
128
|
}
|
|
85
129
|
}
|
|
130
|
+
// A switch that committed while the title was being set has already
|
|
131
|
+
// cleared the mapping and must not receive this prompt.
|
|
132
|
+
if (conversationGenerationMoved(harness, key, conversationGeneration)) {
|
|
133
|
+
throw workspaceSessionStaleError();
|
|
134
|
+
}
|
|
135
|
+
return { sessionId, session };
|
|
86
136
|
}
|
|
87
137
|
return { sessionId, session };
|
|
88
138
|
});
|
|
@@ -1062,7 +1062,7 @@ export class TelegramRuntime {
|
|
|
1062
1062
|
}
|
|
1063
1063
|
const sessionId = this.#state.sessionFor(key);
|
|
1064
1064
|
const session = typeof sessionId === 'string' && sessionId
|
|
1065
|
-
? this.#harness.workspaceSession?.(sessionId)
|
|
1065
|
+
? this.#harness.workspaceSession?.(sessionId, key)
|
|
1066
1066
|
: null;
|
|
1067
1067
|
const text = await recoverAssistantTextByTimestamp({ session, quotedAt, signal });
|
|
1068
1068
|
return text ? { content: text } : { unavailableReason: 'not-delivered' };
|
|
@@ -608,27 +608,17 @@ export class WecomHarnessBridge {
|
|
|
608
608
|
return structuredClone(this.#status);
|
|
609
609
|
}
|
|
610
610
|
|
|
611
|
-
#
|
|
612
|
-
return wecomMenu({
|
|
613
|
-
workspace: this.#harness.currentWorkspace?.(),
|
|
614
|
-
workspaces: [[t('更多选项…'), '/menu workspaces']],
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
async #showMain(frame, { welcome = false } = {}) {
|
|
611
|
+
async #showMain(frame) {
|
|
619
612
|
const previousFailure = this.#status.lastMessageError;
|
|
620
613
|
const key = conversationKey(frame);
|
|
621
614
|
const workspace = this.#harness.currentWorkspace?.();
|
|
622
615
|
const sessionId = this.#state.sessionFor(key);
|
|
623
|
-
// The welcome reply has a five-second deadline. Show immediate controls,
|
|
624
|
-
// then load the selectors independently of that reply window.
|
|
625
|
-
if (welcome) await this.#sendMenu(frame, this.#mainMenu(), { welcome: true });
|
|
626
616
|
const options = { signal: this.#signal };
|
|
627
617
|
const settled = await Promise.allSettled([
|
|
628
618
|
workspacePathSnapshot(this.#harness, options),
|
|
629
619
|
this.#harness.listWorkspaceSessions?.(workspace, options),
|
|
630
620
|
(async () => {
|
|
631
|
-
const session = sessionId ? this.#harness.workspaceSession?.(sessionId) : null;
|
|
621
|
+
const session = sessionId ? this.#harness.workspaceSession?.(sessionId, key) : null;
|
|
632
622
|
return typeof session?.models === 'function'
|
|
633
623
|
? session.models(options) : this.#harness.listModels?.(options);
|
|
634
624
|
})(),
|
|
@@ -650,12 +640,10 @@ export class WecomHarnessBridge {
|
|
|
650
640
|
currentPreset: currentPreset ? `/preset ${/^\d+$/u.test(currentPreset) ? 'id:' : ''}${currentPreset}` : '/preset --default',
|
|
651
641
|
presetLabel: settings?.agentPresetCatalog?.items.find((item) => item.id === currentPreset)?.label,
|
|
652
642
|
});
|
|
653
|
-
await this.#sendMenu(frame, settingsMenu
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
}), { active: true });
|
|
658
|
-
}
|
|
643
|
+
await this.#sendMenu(frame, settingsMenu);
|
|
644
|
+
await this.#sendMenu(frame, wecomMenu({ workspace,
|
|
645
|
+
workspaces: (paths?.paths ?? (workspace ? [workspace] : [])).map((path) => [path, `/workspace ${path}`]),
|
|
646
|
+
}), { active: true });
|
|
659
647
|
this.#clearMenuFailure(previousFailure);
|
|
660
648
|
}
|
|
661
649
|
|
|
@@ -682,17 +670,14 @@ export class WecomHarnessBridge {
|
|
|
682
670
|
return wecomTemplateCard(menu, taskId);
|
|
683
671
|
}
|
|
684
672
|
|
|
685
|
-
async #sendMenu(frame, menu, {
|
|
673
|
+
async #sendMenu(frame, menu, { active = false } = {}) {
|
|
686
674
|
this.#signal?.throwIfAborted();
|
|
687
675
|
const body = bodyOf(frame);
|
|
688
676
|
const chatId = body.chattype === 'group' ? body.chatid : body.from.userid;
|
|
689
677
|
const card = this.#rememberMenu(frame, menu);
|
|
690
|
-
const operation =
|
|
691
|
-
: active || this.#cardFrames.has(frame) ? 'sendMessage' : 'replyTemplateCard';
|
|
678
|
+
const operation = active || this.#cardFrames.has(frame) ? 'sendMessage' : 'replyTemplateCard';
|
|
692
679
|
try {
|
|
693
|
-
if (
|
|
694
|
-
await this.#client.replyWelcome(frame, { msgtype: 'template_card', template_card: card });
|
|
695
|
-
} else if (active || this.#cardFrames.has(frame)) {
|
|
680
|
+
if (active || this.#cardFrames.has(frame)) {
|
|
696
681
|
await this.#client.sendMessage(chatId, { msgtype: 'template_card', template_card: card });
|
|
697
682
|
} else {
|
|
698
683
|
await this.#client.replyTemplateCard(frame, card);
|
|
@@ -704,16 +689,7 @@ export class WecomHarnessBridge {
|
|
|
704
689
|
// Only a definite card rejection can safely fall back to text.
|
|
705
690
|
if (error.code !== 'channel-delivery-failed' || error.providerCode === undefined) throw error;
|
|
706
691
|
this.#logger.warn?.('[dsh-im:wecom] menu delivery failed; using text:', wecomSendDiagnostic(error));
|
|
707
|
-
|
|
708
|
-
const content = wecomMenuText(menu);
|
|
709
|
-
try {
|
|
710
|
-
await this.#client.replyWelcome(frame, { msgtype: 'text', text: { content } });
|
|
711
|
-
} catch (cause) {
|
|
712
|
-
throw wecomSendError(cause, 'replyWelcome');
|
|
713
|
-
}
|
|
714
|
-
} else {
|
|
715
|
-
await this.#sendImmediate(frame, chatId, wecomMenuText(menu));
|
|
716
|
-
}
|
|
692
|
+
await this.#sendImmediate(frame, chatId, wecomMenuText(menu));
|
|
717
693
|
}
|
|
718
694
|
}
|
|
719
695
|
|
|
@@ -744,7 +720,7 @@ export class WecomHarnessBridge {
|
|
|
744
720
|
const body = bodyOf(frame);
|
|
745
721
|
const senderId = nonEmptyString(body.from?.userid);
|
|
746
722
|
const type = body.event?.eventtype;
|
|
747
|
-
if (!senderId ||
|
|
723
|
+
if (!senderId || type !== 'template_card_event') return;
|
|
748
724
|
const chattype = body.chattype ?? (body.chatid ? 'group' : 'single');
|
|
749
725
|
if (!['single', 'group'].includes(chattype) || (chattype === 'group' && !body.chatid)) return;
|
|
750
726
|
const normalized = { ...frame, body: { ...body, chattype } };
|
|
@@ -753,17 +729,13 @@ export class WecomHarnessBridge {
|
|
|
753
729
|
conversationType: chattype === 'single' ? 'direct' : 'group', senderIds: senderId, isCommand: true,
|
|
754
730
|
});
|
|
755
731
|
if (!access.allowed) {
|
|
756
|
-
if (
|
|
732
|
+
if (access.reason === 'command-not-allowed') {
|
|
757
733
|
await this.#sendActive(chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE));
|
|
758
734
|
}
|
|
759
735
|
return;
|
|
760
736
|
}
|
|
761
737
|
await this.#state.markSeen(body.msgid);
|
|
762
738
|
const key = conversationKey(normalized);
|
|
763
|
-
if (type === 'enter_chat') {
|
|
764
|
-
if (chattype === 'single') await this.#showMain(normalized, { welcome: true });
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
739
|
// Live callbacks nest these fields; older SDK examples show them flat.
|
|
768
740
|
const callback = body.event.template_card_event ?? body.event;
|
|
769
741
|
const entry = this.#menus.get(callback.task_id);
|
|
@@ -807,7 +779,7 @@ export class WecomHarnessBridge {
|
|
|
807
779
|
const navigation = commands.find((command) => parseWecomMenu(command));
|
|
808
780
|
if (navigation) commands = [navigation];
|
|
809
781
|
if (!navigation && entry.workspace !== this.#harness.currentWorkspace?.()) {
|
|
810
|
-
await this.#sendActive(chatId, t('
|
|
782
|
+
await this.#sendActive(chatId, t('工作区已变化,请发送 /m 重新打开菜单后选择。'));
|
|
811
783
|
} else {
|
|
812
784
|
for (const [index, command] of commands.entries()) {
|
|
813
785
|
const commandFrame = { ...normalized, body: { ...normalized.body,
|
|
@@ -818,10 +790,6 @@ export class WecomHarnessBridge {
|
|
|
818
790
|
}
|
|
819
791
|
if (!commands.length) await this.#sendActive(chatId, t('设置未改变。'));
|
|
820
792
|
}
|
|
821
|
-
if (!navigation) {
|
|
822
|
-
this.#cardFrames.add(normalized);
|
|
823
|
-
await this.#showMain(normalized);
|
|
824
|
-
}
|
|
825
793
|
}
|
|
826
794
|
|
|
827
795
|
async #runMenuCommand(frame, text, _harness, _state, key) {
|
|
@@ -852,7 +820,7 @@ export class WecomHarnessBridge {
|
|
|
852
820
|
} else if (menu.section === 'models') {
|
|
853
821
|
title = t('🧠 切换模型');
|
|
854
822
|
const sessionId = this.#state.sessionFor(key);
|
|
855
|
-
const session = sessionId ? this.#harness.workspaceSession?.(sessionId) : null;
|
|
823
|
+
const session = sessionId ? this.#harness.workspaceSession?.(sessionId, key) : null;
|
|
856
824
|
const catalog = typeof session?.models === 'function'
|
|
857
825
|
? await session.models(options) : await this.#harness.listModels(options);
|
|
858
826
|
entries = catalog.groups.flatMap((group) => group.models.map((model) => [
|
|
@@ -168,7 +168,7 @@ export class WecomRuntime {
|
|
|
168
168
|
client.on('reconnecting', onReconnecting);
|
|
169
169
|
client.on('error', onError);
|
|
170
170
|
client.on('message', onMessage);
|
|
171
|
-
|
|
171
|
+
// Menus are opened explicitly with /m or /menu; chat entry stays silent.
|
|
172
172
|
client.on('event.template_card_event', (frame) => this.#bridge?.acceptEvent(frame));
|
|
173
173
|
|
|
174
174
|
let timer;
|