@xmanrui/dsh-im 0.10.0 → 0.12.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.
Files changed (68) hide show
  1. package/README.en.md +36 -6
  2. package/README.md +36 -6
  3. package/lib/client.js +377 -27
  4. package/lib/index.js +145 -135
  5. package/package.json +31 -2
  6. package/plugin-src/client/channels/dingtalk/api.js +19 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +67 -10
  8. package/plugin-src/client/channels/feishu/index.js +35 -5
  9. package/plugin-src/client/channels/qq/api.js +21 -0
  10. package/plugin-src/client/channels/qq/index.js +39 -1
  11. package/plugin-src/client/channels/shared/token-channel.js +29 -3
  12. package/plugin-src/client/channels/wecom/api.js +11 -0
  13. package/plugin-src/client/channels/wecom/index.js +71 -1
  14. package/plugin-src/client/channels/weixin/api.js +11 -0
  15. package/plugin-src/client/channels/weixin/index.js +40 -4
  16. package/plugin-src/client/channels/whatsapp/index.js +41 -2
  17. package/plugin-src/client/i18n.js +13 -0
  18. package/plugin-src/host/channels/dingtalk/production.mjs +8 -1
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
  20. package/plugin-src/host/channels/feishu/production.mjs +8 -1
  21. package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
  22. package/plugin-src/host/channels/qq/production.mjs +8 -1
  23. package/plugin-src/host/channels/qq/rpc.mjs +35 -2
  24. package/plugin-src/host/channels/shared/production.mjs +8 -1
  25. package/plugin-src/host/channels/shared/rpc.mjs +25 -1
  26. package/plugin-src/host/channels/slack/production.mjs +8 -1
  27. package/plugin-src/host/channels/slack/rpc.mjs +28 -2
  28. package/plugin-src/host/channels/wecom/production.mjs +8 -1
  29. package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
  30. package/plugin-src/host/channels/weixin/production.mjs +8 -1
  31. package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
  32. package/plugin-src/host/channels/whatsapp/production.mjs +8 -1
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
  34. package/plugin-src/host/harness-session-coordinator.mjs +134 -0
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +85 -0
  36. package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
  37. package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
  38. package/src/channels/discord/discord-api.mjs +1 -1
  39. package/src/channels/discord/discord-runtime.mjs +10 -0
  40. package/src/channels/feishu/bridge.mjs +87 -0
  41. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  42. package/src/channels/feishu/message-utils.mjs +94 -7
  43. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  44. package/src/channels/qq/qq-bridge.mjs +91 -0
  45. package/src/channels/qq/qq-controller.mjs +18 -0
  46. package/src/channels/qq/qq-runtime.mjs +26 -0
  47. package/src/channels/shared/bot-workspace-store.mjs +47 -1
  48. package/src/channels/shared/connection-test.mjs +54 -0
  49. package/src/channels/shared/control-command.mjs +84 -0
  50. package/src/channels/shared/harness-approval.mjs +4 -0
  51. package/src/channels/shared/harness-client.mjs +342 -35
  52. package/src/channels/shared/model-command.mjs +314 -0
  53. package/src/channels/shared/session-binding-lock.mjs +40 -0
  54. package/src/channels/shared/text-harness-bridge.mjs +100 -0
  55. package/src/channels/shared/token-bot-controller.mjs +21 -0
  56. package/src/channels/shared/workspace-session.mjs +21 -9
  57. package/src/channels/slack/slack-controller.mjs +19 -0
  58. package/src/channels/slack/slack-runtime.mjs +10 -0
  59. package/src/channels/telegram/telegram-runtime.mjs +10 -0
  60. package/src/channels/wecom/wecom-bridge.mjs +79 -0
  61. package/src/channels/wecom/wecom-controller.mjs +18 -0
  62. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  63. package/src/channels/weixin/weixin-api.mjs +1 -1
  64. package/src/channels/weixin/weixin-bridge.mjs +85 -0
  65. package/src/channels/weixin/weixin-controller.mjs +18 -0
  66. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  67. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  68. package/src/channels/whatsapp/whatsapp-runtime.mjs +16 -0
@@ -0,0 +1,314 @@
1
+ import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
2
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
3
+ import { withSessionBindingLock } from './session-binding-lock.mjs';
4
+
5
+ const MODEL_COMMAND = /^\/model(?=$|\s)/i;
6
+ const MODELS_COMMAND = /^\/models(?=$|\s)/i;
7
+ const MODEL_USAGE = '用法:/model <provider>/<model>';
8
+ const MODELS_USAGE = '用法:/models(不带参数)';
9
+ const SESSION_BINDING_CHANGED = 'session-binding-changed';
10
+ const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
11
+
12
+ function commandResult(message) {
13
+ return {
14
+ handled: true,
15
+ message,
16
+ messages: splitWorkspaceCommandMessage(message),
17
+ };
18
+ }
19
+
20
+ function safeDisplayText(value) {
21
+ if (typeof value !== 'string') return '';
22
+ return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
23
+ }
24
+
25
+ function rpcOptions(signal) {
26
+ return signal ? { signal } : {};
27
+ }
28
+
29
+ function normalizeCatalog(value, { requireCurrent = false } = {}) {
30
+ if (!value || typeof value !== 'object'
31
+ || !Array.isArray(value.groups) || !Array.isArray(value.failures)) {
32
+ throw new TypeError('Harness returned an invalid model catalog');
33
+ }
34
+ const groups = value.groups.map((group) => {
35
+ if (!group || typeof group !== 'object'
36
+ || typeof group.id !== 'string' || !group.id
37
+ || typeof group.name !== 'string' || !group.name
38
+ || !Array.isArray(group.models)) {
39
+ throw new TypeError('Harness returned an invalid model provider group');
40
+ }
41
+ return {
42
+ id: group.id,
43
+ name: group.name,
44
+ models: group.models.map((model) => {
45
+ if (!model || typeof model !== 'object'
46
+ || typeof model.id !== 'string' || !model.id
47
+ || typeof model.name !== 'string' || !model.name) {
48
+ throw new TypeError('Harness returned an invalid model');
49
+ }
50
+ return { id: model.id, name: model.name };
51
+ }),
52
+ };
53
+ });
54
+ const failures = value.failures.map((failure) => {
55
+ if (!failure || typeof failure !== 'object'
56
+ || typeof failure.id !== 'string' || !failure.id
57
+ || typeof failure.name !== 'string' || !failure.name) {
58
+ throw new TypeError('Harness returned an invalid model provider failure');
59
+ }
60
+ return { id: failure.id, name: failure.name };
61
+ });
62
+ let current = null;
63
+ if (value.current !== undefined) {
64
+ if (!value.current || typeof value.current !== 'object'
65
+ || typeof value.current.provider !== 'string' || !value.current.provider
66
+ || typeof value.current.model !== 'string' || !value.current.model) {
67
+ throw new TypeError('Harness returned an invalid current model');
68
+ }
69
+ current = { provider: value.current.provider, model: value.current.model };
70
+ } else if (requireCurrent) {
71
+ throw new TypeError('Harness returned no current model');
72
+ }
73
+ return { groups, failures, current };
74
+ }
75
+
76
+ function modelId(provider, model) {
77
+ return `${provider}/${model}`;
78
+ }
79
+
80
+ function matchingModel(catalog, requested) {
81
+ for (const group of catalog.groups) {
82
+ for (const model of group.models) {
83
+ if (modelId(group.id, model.id) === requested) {
84
+ return { provider: group.id, model: model.id };
85
+ }
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function formatCatalog(catalog) {
92
+ const currentId = catalog.current
93
+ ? modelId(catalog.current.provider, catalog.current.model)
94
+ : null;
95
+ const lines = ['可用模型:'];
96
+ if (catalog.groups.length === 0) lines.push('', '当前没有可用模型。');
97
+ for (const group of catalog.groups) {
98
+ lines.push('', safeDisplayText(group.name) || safeDisplayText(group.id));
99
+ for (const model of group.models) {
100
+ const id = modelId(group.id, model.id);
101
+ lines.push(`- ${safeDisplayText(id)}${id === currentId ? '(当前)' : ''}`);
102
+ }
103
+ }
104
+ if (catalog.failures.length > 0) {
105
+ lines.push('', '以下模型提供方暂时不可用:');
106
+ for (const failure of catalog.failures) {
107
+ lines.push(`- ${safeDisplayText(failure.name) || safeDisplayText(failure.id)}`);
108
+ }
109
+ }
110
+ return lines.join('\n');
111
+ }
112
+
113
+ function currentModelMessage(current) {
114
+ return [
115
+ '当前模型:',
116
+ modelId(current.provider, current.model),
117
+ '',
118
+ '查看全部模型:/models',
119
+ '切换模型:/model <provider>/<model>',
120
+ ].join('\n');
121
+ }
122
+
123
+ function noSessionMessage() {
124
+ return [
125
+ '当前聊天还没有会话。',
126
+ '',
127
+ '查看模型:/models',
128
+ '选择模型:/model <provider>/<model>',
129
+ ].join('\n');
130
+ }
131
+
132
+ function errorCode(error) {
133
+ return error?.code ?? error?.failure?.code;
134
+ }
135
+
136
+ function modelErrorMessage(error, action) {
137
+ const code = errorCode(error);
138
+ if (code === 'agent-busy') {
139
+ return '当前任务正在运行,请等待完成或先发送 /stop。';
140
+ }
141
+ if (code === 'session-not-found') {
142
+ return '当前聊天绑定的会话已不存在,请重试。';
143
+ }
144
+ if (code === 'model-unavailable') {
145
+ return '无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。';
146
+ }
147
+ if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
148
+ return '工作区或机器人状态已发生变化,请重试。';
149
+ }
150
+ if (code === SESSION_BINDING_CHANGED) {
151
+ return '当前聊天绑定的会话已发生变化,请重试。';
152
+ }
153
+ if (code === 'cancelled' || error?.name === 'AbortError') {
154
+ return action === 'list' ? '获取模型列表已取消。' : '模型切换已取消。';
155
+ }
156
+ return action === 'list'
157
+ ? '暂时无法获取模型列表,请稍后重试。'
158
+ : '模型切换失败,请稍后重试。';
159
+ }
160
+
161
+ async function boundSession(harness, state, key, options) {
162
+ if (typeof state?.sessionFor !== 'function') return null;
163
+ const sessionId = state.sessionFor(key);
164
+ if (typeof sessionId !== 'string' || !sessionId) return null;
165
+ if (typeof harness?.workspaceSession !== 'function') {
166
+ throw new TypeError('Harness does not support workspace sessions');
167
+ }
168
+ const session = harness.workspaceSession(sessionId);
169
+ if (!session || typeof session.sessionExists !== 'function') {
170
+ throw new TypeError('Harness returned an invalid workspace session');
171
+ }
172
+ if (await session.sessionExists(options)) return { sessionId, session };
173
+ if (typeof state.clearSession === 'function' && state.sessionFor(key) === sessionId) {
174
+ await state.clearSession(key);
175
+ }
176
+ return null;
177
+ }
178
+
179
+ async function sessionIsBusy(session, control, options) {
180
+ if (typeof session?.isRunning !== 'function'
181
+ || typeof session?.hasActiveTurn !== 'function') {
182
+ throw new TypeError('Harness session does not expose run state');
183
+ }
184
+ if (await session.isRunning(options)) return true;
185
+ return Boolean(await session.hasActiveTurn(control, options));
186
+ }
187
+
188
+ async function listCatalog(harness, options) {
189
+ if (typeof harness?.listModels !== 'function') {
190
+ throw new TypeError('Harness does not support listing models');
191
+ }
192
+ return normalizeCatalog(await harness.listModels(options));
193
+ }
194
+
195
+ async function sessionCatalog(session, options) {
196
+ if (typeof session?.models !== 'function') {
197
+ throw new TypeError('Harness session does not support listing models');
198
+ }
199
+ return normalizeCatalog(await session.models(options), { requireCurrent: true });
200
+ }
201
+
202
+ function isModelsCommand(command) {
203
+ return MODELS_COMMAND.test(command);
204
+ }
205
+
206
+ export function isModelCommand(text) {
207
+ if (typeof text !== 'string') return false;
208
+ const command = text.trim();
209
+ return MODELS_COMMAND.test(command) || MODEL_COMMAND.test(command);
210
+ }
211
+
212
+ export async function runModelCommand(text, harness, state, key, options = {}) {
213
+ if (!isModelCommand(text)) return null;
214
+ const command = text.trim();
215
+ if (options.hasImages) {
216
+ return commandResult('模型命令仅支持纯文字,请移除图片后重试。');
217
+ }
218
+ const requestOptions = rpcOptions(options.signal);
219
+
220
+ if (isModelsCommand(command)) {
221
+ if (!/^\/models[ \t]*$/iu.test(command)) return commandResult(MODELS_USAGE);
222
+ try {
223
+ const bound = await boundSession(harness, state, key, requestOptions);
224
+ const catalog = bound
225
+ ? await sessionCatalog(bound.session, requestOptions)
226
+ : await listCatalog(harness, requestOptions);
227
+ return commandResult(formatCatalog(catalog));
228
+ } catch (error) {
229
+ return commandResult(modelErrorMessage(error, 'list'));
230
+ }
231
+ }
232
+
233
+ const match = /^\/model(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
234
+ if (!match) return commandResult(MODEL_USAGE);
235
+ const requested = match[1];
236
+ if (!requested) {
237
+ try {
238
+ const bound = await boundSession(harness, state, key, requestOptions);
239
+ if (!bound) return commandResult(noSessionMessage());
240
+ const catalog = await sessionCatalog(bound.session, requestOptions);
241
+ return commandResult(currentModelMessage(catalog.current));
242
+ } catch (error) {
243
+ return commandResult(modelErrorMessage(error, 'select'));
244
+ }
245
+ }
246
+ if (!requested.includes('/') || requested.startsWith('/') || requested.endsWith('/')) {
247
+ return commandResult(MODEL_USAGE);
248
+ }
249
+ if (options.pendingInteraction) {
250
+ return commandResult([
251
+ '当前任务正在等待你的回答或审批。',
252
+ '',
253
+ '请先处理当前请求,或者发送 /stop 停止任务。',
254
+ ].join('\n'));
255
+ }
256
+
257
+ try {
258
+ return await withSessionBindingLock(state, key, async () => {
259
+ const bound = await boundSession(harness, state, key, requestOptions);
260
+ if (bound && await sessionIsBusy(bound.session, options.control, requestOptions)) {
261
+ return commandResult('当前任务正在运行,请等待完成或先发送 /stop。');
262
+ }
263
+
264
+ const catalog = bound
265
+ ? await sessionCatalog(bound.session, requestOptions)
266
+ : await listCatalog(harness, requestOptions);
267
+ const selection = matchingModel(catalog, requested);
268
+ if (!selection) {
269
+ return commandResult([
270
+ `没有找到模型:${safeDisplayText(requested)}`,
271
+ '',
272
+ '请发送 /models 查看可用模型。',
273
+ ].join('\n'));
274
+ }
275
+
276
+ if (bound) {
277
+ if (typeof bound.session.selectModel !== 'function') {
278
+ throw new TypeError('Harness session does not support model selection');
279
+ }
280
+ await bound.session.selectModel(selection, requestOptions);
281
+ } else {
282
+ if (typeof harness?.createSession !== 'function'
283
+ || typeof harness?.workspaceSession !== 'function'
284
+ || typeof state?.sessionFor !== 'function'
285
+ || typeof state?.setSession !== 'function') {
286
+ throw new TypeError('Harness cannot create a conversation session');
287
+ }
288
+ const sessionId = await harness.createSession(requestOptions);
289
+ if (typeof sessionId !== 'string' || !sessionId) {
290
+ throw new TypeError('Harness returned an invalid session id');
291
+ }
292
+ const session = harness.workspaceSession(sessionId);
293
+ if (!session || typeof session.selectModel !== 'function') {
294
+ throw new TypeError('Harness session does not support model selection');
295
+ }
296
+ await session.selectModel(selection, requestOptions);
297
+ const currentSessionId = state.sessionFor(key);
298
+ if (typeof currentSessionId === 'string' && currentSessionId) {
299
+ const changed = new Error('Conversation binding changed during model selection');
300
+ changed.code = SESSION_BINDING_CHANGED;
301
+ throw changed;
302
+ }
303
+ if (await state.setSession(key, sessionId) === false) {
304
+ const stale = new Error('Workspace changed while binding the new session');
305
+ stale.code = WORKSPACE_SESSION_STALE;
306
+ throw stale;
307
+ }
308
+ }
309
+ return commandResult(`模型已切换为:\n${modelId(selection.provider, selection.model)}\n\n后续消息将使用该模型。`);
310
+ });
311
+ } catch (error) {
312
+ return commandResult(modelErrorMessage(error, 'select'));
313
+ }
314
+ }
@@ -0,0 +1,40 @@
1
+ const bindingLocks = new WeakMap();
2
+
3
+ function stateLocks(state) {
4
+ let locks = bindingLocks.get(state);
5
+ if (!locks) {
6
+ locks = new Map();
7
+ bindingLocks.set(state, locks);
8
+ }
9
+ return locks;
10
+ }
11
+
12
+ /**
13
+ * Serialize the short Session binding transaction for one conversation.
14
+ * The caller must keep long-running Session work, such as ask(), outside.
15
+ */
16
+ export async function withSessionBindingLock(state, key, operation) {
17
+ const stateType = typeof state;
18
+ if ((stateType !== 'object' && stateType !== 'function') || state === null) {
19
+ throw new TypeError('state is required');
20
+ }
21
+ if (typeof key !== 'string' || !key) throw new TypeError('conversation key is required');
22
+ if (typeof operation !== 'function') throw new TypeError('operation is required');
23
+
24
+ const locks = stateLocks(state);
25
+ const previous = locks.get(key) ?? Promise.resolve();
26
+ let release;
27
+ const current = new Promise((resolve) => { release = resolve; });
28
+ locks.set(key, current);
29
+
30
+ await previous;
31
+ try {
32
+ return await operation();
33
+ } finally {
34
+ release();
35
+ if (locks.get(key) === current) {
36
+ locks.delete(key);
37
+ if (locks.size === 0) bindingLocks.delete(state);
38
+ }
39
+ }
40
+ }
@@ -1,5 +1,17 @@
1
1
  import { runWorkspaceCommand } from './workspace-command.mjs';
2
2
  import { runCompactCommand } from './compact-command.mjs';
3
+ import {
4
+ isControlCommand,
5
+ runControlCommand,
6
+ } from './control-command.mjs';
7
+ import {
8
+ rememberConnectionTestTarget,
9
+ sendRememberedConnectionTest,
10
+ } from './connection-test.mjs';
11
+ import {
12
+ isModelCommand,
13
+ runModelCommand,
14
+ } from './model-command.mjs';
3
15
  import { askInWorkspaceSession } from './workspace-session.mjs';
4
16
  import { HarnessApprovalQueue } from './harness-approval.mjs';
5
17
  import {
@@ -52,6 +64,7 @@ export class TextHarnessBridge {
52
64
  #interactionKeys = new Map();
53
65
  #acceptedMessageIds = new Set();
54
66
  #approvalTasks = new Set();
67
+ #commandTasks = new Set();
55
68
  #approvals;
56
69
 
57
70
  constructor({
@@ -97,9 +110,33 @@ export class TextHarnessBridge {
97
110
  return Promise.resolve();
98
111
  }
99
112
  this.#acceptedMessageIds.add(messageId);
113
+ if (normalized.kind === 'direct') {
114
+ rememberConnectionTestTarget(
115
+ this.#state,
116
+ normalized.connectionTestTarget ?? normalized.replyTarget,
117
+ );
118
+ }
100
119
 
101
120
  const key = `${kind}:${conversationId}`;
102
121
  const pending = this.#pendingInteractions.get(key);
122
+ const text = cleanText(normalized.content);
123
+ const commandRunner = isControlCommand(text)
124
+ ? runControlCommand
125
+ : (isModelCommand(text) ? runModelCommand : null);
126
+ if (commandRunner && (normalized.kind !== 'group' || normalized.addressed === true)) {
127
+ let task;
128
+ task = this.#processFastCommand(
129
+ normalized,
130
+ messageId,
131
+ key,
132
+ commandRunner,
133
+ ).finally(() => {
134
+ this.#acceptedMessageIds.delete(messageId);
135
+ this.#commandTasks.delete(task);
136
+ });
137
+ this.#commandTasks.add(task);
138
+ return task;
139
+ }
103
140
  const approval = this.#approvals.claimReply({
104
141
  key,
105
142
  actor: senderId,
@@ -191,9 +228,57 @@ export class TextHarnessBridge {
191
228
  pending.queue ? [pending.queue] : []
192
229
  )),
193
230
  ...this.#approvalTasks,
231
+ ...this.#commandTasks,
194
232
  ]);
195
233
  }
196
234
 
235
+ async #processFastCommand(message, messageId, key, runner) {
236
+ if (this.#state.hasSeen(messageId)) return;
237
+ await this.#state.markSeen(messageId);
238
+ this.#status.messagesReceived += 1;
239
+ this.#status.lastMessageAt = new Date().toISOString();
240
+ const target = message.replyTarget;
241
+ try {
242
+ const result = await runner(
243
+ cleanText(message.content),
244
+ this.#harness,
245
+ this.#state,
246
+ key,
247
+ {
248
+ signal: this.#signal,
249
+ hasImages: hasInboundImages(message),
250
+ pendingInteraction: this.#pendingInteractions.has(key)
251
+ || this.#approvals.hasPending(key),
252
+ control: { owner: this, key },
253
+ },
254
+ );
255
+ if (result?.stopped) {
256
+ await Promise.allSettled([
257
+ this.#cancelPendingInteraction(key),
258
+ this.#approvals.closeRoute(key),
259
+ ]);
260
+ }
261
+ for (const reply of result?.messages ?? [result?.message]) {
262
+ if (reply) await this.#bot.sendText(target, reply);
263
+ }
264
+ this.#status.lastError = null;
265
+ } catch (error) {
266
+ if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
267
+ this.#status.lastError = error?.message ?? String(error);
268
+ this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
269
+ await this.#bot.sendText(target, '消息处理失败,请稍后重试。').catch(() => undefined);
270
+ }
271
+ }
272
+
273
+ sendConnectionTest(text) {
274
+ return sendRememberedConnectionTest({
275
+ state: this.#state,
276
+ text,
277
+ channelLabel: `${this.#descriptor.label}机器人`,
278
+ send: (target, message) => this.#bot.sendText(target, message),
279
+ });
280
+ }
281
+
197
282
  async #process(message, messageId, senderId, conversationKey, {
198
283
  alreadyRecorded = false,
199
284
  } = {}) {
@@ -231,6 +316,10 @@ export class TextHarnessBridge {
231
316
  '/workspacelist 列出工作区绝对路径',
232
317
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
233
318
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
319
+ '/models 列出所有可用模型',
320
+ '/model [模型ID] 查看或切换当前会话模型',
321
+ '/stop 停止当前任务',
322
+ '/steer 补充指令 纠偏当前任务',
234
323
  '/status 检查连接状态',
235
324
  '/help 显示本帮助',
236
325
  ].join('\n'));
@@ -297,6 +386,7 @@ export class TextHarnessBridge {
297
386
  askOptions: {
298
387
  timeoutMs: this.#replyTimeoutMs,
299
388
  signal: this.#signal,
389
+ control: { owner: this, key: conversationKey },
300
390
  onUpdate: stream ? async (update) => {
301
391
  const progress = update.type === 'text' ? update.text
302
392
  : update.type === 'tool' ? `正在使用${update.name}…` : update.text;
@@ -328,6 +418,16 @@ export class TextHarnessBridge {
328
418
  this.#status.lastReplyAt = new Date().toISOString();
329
419
  this.#status.lastError = null;
330
420
  } catch (error) {
421
+ if (error?.code === 'turn-stopped') {
422
+ if (stream) {
423
+ try {
424
+ await stream.finish('已停止。');
425
+ } catch {
426
+ stream.cancel?.();
427
+ }
428
+ }
429
+ return;
430
+ }
331
431
  stream?.cancel?.();
332
432
  if (this.#signal?.aborted) return;
333
433
  this.#status.lastError = error?.message ?? String(error);
@@ -1,3 +1,5 @@
1
+ import { connectionTestMessage } from './connection-test.mjs';
2
+
1
3
  function cleanString(value) {
2
4
  return typeof value === 'string' && value.trim() ? value.trim() : null;
3
5
  }
@@ -161,6 +163,25 @@ export class TokenBotController {
161
163
  return this.status();
162
164
  }
163
165
 
166
+ async sendConnectionTest(botId) {
167
+ const config = this.#configStore.get(botId);
168
+ if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
169
+ return this.#withBotTransition(botId, async () => {
170
+ const runtime = this.#runtimes.get(botId);
171
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
172
+ const error = new Error(`${this.#descriptor.label}机器人尚未连接`);
173
+ error.code = 'test-target-unavailable';
174
+ throw error;
175
+ }
176
+ const cardLabel = `${config.name}(${this.#maskPlatformId(config.platformId)})`;
177
+ await runtime.sendConnectionTest(connectionTestMessage(
178
+ cardLabel,
179
+ `${this.#descriptor.label}机器人`,
180
+ ));
181
+ return { sent: true };
182
+ });
183
+ }
184
+
164
185
  async deleteBot(botId) {
165
186
  const config = this.#configStore.get(botId);
166
187
  if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
@@ -1,3 +1,5 @@
1
+ import { withSessionBindingLock } from './session-binding-lock.mjs';
2
+
1
3
  export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
2
4
 
3
5
  function workspaceSession(harness, sessionId) {
@@ -7,6 +9,12 @@ function workspaceSession(harness, sessionId) {
7
9
  return Object.freeze({
8
10
  sessionId,
9
11
  sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
12
+ models: (...args) => harness.getSessionModels(sessionId, ...args),
13
+ selectModel: (...args) => harness.selectSessionModel(sessionId, ...args),
14
+ isRunning: (...args) => harness.isSessionRunning(sessionId, ...args),
15
+ hasActiveTurn: (...args) => harness.hasActiveTurn(sessionId, ...args),
16
+ stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
17
+ steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
10
18
  ask: (...args) => harness.ask(sessionId, ...args),
11
19
  });
12
20
  }
@@ -40,16 +48,20 @@ export async function askInWorkspaceSession({
40
48
  }) {
41
49
  while (true) {
42
50
  try {
43
- let sessionId = state.sessionFor(key);
44
- let session = sessionId ? workspaceSession(harness, sessionId) : null;
45
- if (!session || !(await sessionExists(session, existsOptions))) {
46
- sessionId = await createSession(harness, createOptions);
47
- if (await state.setSession(key, sessionId) === false) continue;
48
- session = workspaceSession(harness, sessionId);
49
- }
51
+ const binding = await withSessionBindingLock(state, key, async () => {
52
+ let sessionId = state.sessionFor(key);
53
+ let session = sessionId ? workspaceSession(harness, sessionId) : null;
54
+ if (!session || !(await sessionExists(session, existsOptions))) {
55
+ sessionId = await createSession(harness, createOptions);
56
+ if (await state.setSession(key, sessionId) === false) return null;
57
+ session = workspaceSession(harness, sessionId);
58
+ }
59
+ return { sessionId, session };
60
+ });
61
+ if (!binding) continue;
50
62
  return {
51
- sessionId,
52
- answer: await session.ask(content ?? text, askOptions),
63
+ sessionId: binding.sessionId,
64
+ answer: await binding.session.ask(content ?? text, askOptions),
53
65
  };
54
66
  } catch (error) {
55
67
  if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
@@ -1,3 +1,4 @@
1
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
1
2
  import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
2
3
  import { inspectSlackCredentials } from './slack-api.mjs';
3
4
  import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
@@ -168,6 +169,24 @@ export class SlackController {
168
169
  return this.status();
169
170
  }
170
171
 
172
+ async sendConnectionTest(botId) {
173
+ const config = this.#configStore.get(botId);
174
+ if (!config) throw new Error('Unknown Slack bot');
175
+ return this.#withBotTransition(botId, async () => {
176
+ const runtime = this.#runtimes.get(botId);
177
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
178
+ const error = new Error('Slack机器人尚未连接');
179
+ error.code = 'test-target-unavailable';
180
+ throw error;
181
+ }
182
+ await runtime.sendConnectionTest(connectionTestMessage(
183
+ `${config.name}(${maskSlackBotId(config.platformId)})`,
184
+ 'Slack机器人',
185
+ ));
186
+ return { sent: true };
187
+ });
188
+ }
189
+
171
190
  async deleteBot(botId) {
172
191
  const config = this.#configStore.get(botId);
173
192
  if (!config) throw new Error('Unknown Slack bot');
@@ -83,6 +83,7 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
83
83
  recipientUserId: String(event.user),
84
84
  recipientTeamId: String(event.user_team ?? payload.team_id ?? ''),
85
85
  },
86
+ connectionTestTarget: { channelId: String(event.channel) },
86
87
  };
87
88
  }
88
89
 
@@ -293,6 +294,15 @@ export class SlackRuntime {
293
294
  return structuredClone(this.#status);
294
295
  }
295
296
 
297
+ async sendConnectionTest(text) {
298
+ if (!this.#status.ready || !this.#bridge) {
299
+ const error = new Error('Slack bot is not connected');
300
+ error.code = 'test-target-unavailable';
301
+ throw error;
302
+ }
303
+ return this.#bridge.sendConnectionTest(text);
304
+ }
305
+
296
306
  async start() {
297
307
  if (this.#status.ready && this.#socket) return this.status;
298
308
  if (this.#starting) return this.#starting;
@@ -111,6 +111,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
111
111
  replyToMessageId: messageId,
112
112
  messageThreadId,
113
113
  },
114
+ connectionTestTarget: { chatId, messageThreadId },
114
115
  };
115
116
  }
116
117
 
@@ -229,6 +230,15 @@ export class TelegramRuntime {
229
230
  return structuredClone(this.#status);
230
231
  }
231
232
 
233
+ async sendConnectionTest(text) {
234
+ if (!this.#status.ready || !this.#bridge) {
235
+ const error = new Error('Telegram bot is not connected');
236
+ error.code = 'test-target-unavailable';
237
+ throw error;
238
+ }
239
+ return this.#bridge.sendConnectionTest(text);
240
+ }
241
+
232
242
  async start() {
233
243
  if (this.#status.ready && this.#pollTask) return this.status;
234
244
  if (this.#starting) return this.#starting;