@xmanrui/dsh-im 0.11.0 → 0.13.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.en.md +71 -37
- package/README.md +73 -39
- package/lib/client.js +2 -2
- package/lib/index.js +134 -124
- package/package.json +31 -2
- package/plugin-src/client/i18n.js +1 -1
- package/plugin-src/client/index.js +1 -1
- package/plugin-src/host/channels/dingtalk/production.mjs +7 -0
- package/plugin-src/host/channels/feishu/production.mjs +7 -0
- package/plugin-src/host/channels/qq/production.mjs +7 -0
- package/plugin-src/host/channels/shared/production.mjs +7 -0
- package/plugin-src/host/channels/slack/production.mjs +7 -0
- package/plugin-src/host/channels/wecom/production.mjs +7 -0
- package/plugin-src/host/channels/weixin/production.mjs +7 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +7 -0
- package/plugin-src/host/harness-session-coordinator.mjs +134 -0
- package/scripts/verify-package.mjs +34 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +82 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +82 -0
- package/src/channels/qq/qq-bridge.mjs +85 -0
- package/src/channels/shared/bot-workspace-store.mjs +45 -1
- package/src/channels/shared/control-command.mjs +84 -0
- package/src/channels/shared/harness-approval.mjs +4 -0
- package/src/channels/shared/harness-client.mjs +336 -29
- package/src/channels/shared/model-command.mjs +352 -0
- package/src/channels/shared/session-binding-lock.mjs +40 -0
- package/src/channels/shared/text-harness-bridge.mjs +82 -0
- package/src/channels/shared/workspace-session.mjs +21 -9
- package/src/channels/wecom/wecom-bridge.mjs +76 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +82 -0
|
@@ -0,0 +1,352 @@
|
|
|
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 <序号> 或 /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 modelAt(catalog, requestedIndex) {
|
|
92
|
+
let index = 0;
|
|
93
|
+
for (const group of catalog.groups) {
|
|
94
|
+
for (const model of group.models) {
|
|
95
|
+
index += 1;
|
|
96
|
+
if (index === requestedIndex) {
|
|
97
|
+
return { provider: group.id, model: model.id };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function modelNumberRequest(requested) {
|
|
105
|
+
if (!/^\d+$/u.test(requested)) return null;
|
|
106
|
+
const index = Number(requested);
|
|
107
|
+
return { index: Number.isSafeInteger(index) && index > 0 ? index : null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function invalidModelNumberMessage(requested) {
|
|
111
|
+
return [
|
|
112
|
+
`模型序号无效:${safeDisplayText(requested)}`,
|
|
113
|
+
'',
|
|
114
|
+
'请发送 /models 查看并输入有效的正整数序号。',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatCatalog(catalog) {
|
|
119
|
+
const currentId = catalog.current
|
|
120
|
+
? modelId(catalog.current.provider, catalog.current.model)
|
|
121
|
+
: null;
|
|
122
|
+
const lines = ['可用模型:'];
|
|
123
|
+
let index = 0;
|
|
124
|
+
if (catalog.groups.length === 0) lines.push('', '当前没有可用模型。');
|
|
125
|
+
for (const group of catalog.groups) {
|
|
126
|
+
lines.push('', safeDisplayText(group.name) || safeDisplayText(group.id));
|
|
127
|
+
for (const model of group.models) {
|
|
128
|
+
index += 1;
|
|
129
|
+
const id = modelId(group.id, model.id);
|
|
130
|
+
lines.push(`${index}. ${safeDisplayText(id)}${id === currentId ? '(当前)' : ''}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (catalog.failures.length > 0) {
|
|
134
|
+
lines.push('', '以下模型提供方暂时不可用:');
|
|
135
|
+
for (const failure of catalog.failures) {
|
|
136
|
+
lines.push(`- ${safeDisplayText(failure.name) || safeDisplayText(failure.id)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (index > 0) lines.push('', '切换模型:/model <序号>');
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function currentModelMessage(current) {
|
|
144
|
+
return [
|
|
145
|
+
'当前模型:',
|
|
146
|
+
modelId(current.provider, current.model),
|
|
147
|
+
'',
|
|
148
|
+
'查看全部模型:/models',
|
|
149
|
+
'切换模型:/model <序号>',
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function noSessionMessage() {
|
|
154
|
+
return [
|
|
155
|
+
'当前聊天还没有会话。',
|
|
156
|
+
'',
|
|
157
|
+
'查看模型:/models',
|
|
158
|
+
'选择模型:/model <序号>',
|
|
159
|
+
].join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function errorCode(error) {
|
|
163
|
+
return error?.code ?? error?.failure?.code;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function modelErrorMessage(error, action) {
|
|
167
|
+
const code = errorCode(error);
|
|
168
|
+
if (code === 'agent-busy') {
|
|
169
|
+
return '当前任务正在运行,请等待完成或先发送 /stop。';
|
|
170
|
+
}
|
|
171
|
+
if (code === 'session-not-found') {
|
|
172
|
+
return '当前聊天绑定的会话已不存在,请重试。';
|
|
173
|
+
}
|
|
174
|
+
if (code === 'model-unavailable') {
|
|
175
|
+
return '无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。';
|
|
176
|
+
}
|
|
177
|
+
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
|
|
178
|
+
return '工作区或机器人状态已发生变化,请重试。';
|
|
179
|
+
}
|
|
180
|
+
if (code === SESSION_BINDING_CHANGED) {
|
|
181
|
+
return '当前聊天绑定的会话已发生变化,请重试。';
|
|
182
|
+
}
|
|
183
|
+
if (code === 'cancelled' || error?.name === 'AbortError') {
|
|
184
|
+
return action === 'list' ? '获取模型列表已取消。' : '模型切换已取消。';
|
|
185
|
+
}
|
|
186
|
+
return action === 'list'
|
|
187
|
+
? '暂时无法获取模型列表,请稍后重试。'
|
|
188
|
+
: '模型切换失败,请稍后重试。';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function boundSession(harness, state, key, options) {
|
|
192
|
+
if (typeof state?.sessionFor !== 'function') return null;
|
|
193
|
+
const sessionId = state.sessionFor(key);
|
|
194
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
195
|
+
if (typeof harness?.workspaceSession !== 'function') {
|
|
196
|
+
throw new TypeError('Harness does not support workspace sessions');
|
|
197
|
+
}
|
|
198
|
+
const session = harness.workspaceSession(sessionId);
|
|
199
|
+
if (!session || typeof session.sessionExists !== 'function') {
|
|
200
|
+
throw new TypeError('Harness returned an invalid workspace session');
|
|
201
|
+
}
|
|
202
|
+
if (await session.sessionExists(options)) return { sessionId, session };
|
|
203
|
+
if (typeof state.clearSession === 'function' && state.sessionFor(key) === sessionId) {
|
|
204
|
+
await state.clearSession(key);
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function sessionIsBusy(session, control, options) {
|
|
210
|
+
if (typeof session?.isRunning !== 'function'
|
|
211
|
+
|| typeof session?.hasActiveTurn !== 'function') {
|
|
212
|
+
throw new TypeError('Harness session does not expose run state');
|
|
213
|
+
}
|
|
214
|
+
if (await session.isRunning(options)) return true;
|
|
215
|
+
return Boolean(await session.hasActiveTurn(control, options));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function listCatalog(harness, options) {
|
|
219
|
+
if (typeof harness?.listModels !== 'function') {
|
|
220
|
+
throw new TypeError('Harness does not support listing models');
|
|
221
|
+
}
|
|
222
|
+
return normalizeCatalog(await harness.listModels(options));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function sessionCatalog(session, options) {
|
|
226
|
+
if (typeof session?.models !== 'function') {
|
|
227
|
+
throw new TypeError('Harness session does not support listing models');
|
|
228
|
+
}
|
|
229
|
+
return normalizeCatalog(await session.models(options), { requireCurrent: true });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isModelsCommand(command) {
|
|
233
|
+
return MODELS_COMMAND.test(command);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function isModelCommand(text) {
|
|
237
|
+
if (typeof text !== 'string') return false;
|
|
238
|
+
const command = text.trim();
|
|
239
|
+
return MODELS_COMMAND.test(command) || MODEL_COMMAND.test(command);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function runModelCommand(text, harness, state, key, options = {}) {
|
|
243
|
+
if (!isModelCommand(text)) return null;
|
|
244
|
+
const command = text.trim();
|
|
245
|
+
if (options.hasImages) {
|
|
246
|
+
return commandResult('模型命令仅支持纯文字,请移除图片后重试。');
|
|
247
|
+
}
|
|
248
|
+
const requestOptions = rpcOptions(options.signal);
|
|
249
|
+
|
|
250
|
+
if (isModelsCommand(command)) {
|
|
251
|
+
if (!/^\/models[ \t]*$/iu.test(command)) return commandResult(MODELS_USAGE);
|
|
252
|
+
try {
|
|
253
|
+
const bound = await boundSession(harness, state, key, requestOptions);
|
|
254
|
+
const catalog = bound
|
|
255
|
+
? await sessionCatalog(bound.session, requestOptions)
|
|
256
|
+
: await listCatalog(harness, requestOptions);
|
|
257
|
+
return commandResult(formatCatalog(catalog));
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return commandResult(modelErrorMessage(error, 'list'));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const match = /^\/model(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
|
|
264
|
+
if (!match) return commandResult(MODEL_USAGE);
|
|
265
|
+
const requested = match[1];
|
|
266
|
+
if (!requested) {
|
|
267
|
+
try {
|
|
268
|
+
const bound = await boundSession(harness, state, key, requestOptions);
|
|
269
|
+
if (!bound) return commandResult(noSessionMessage());
|
|
270
|
+
const catalog = await sessionCatalog(bound.session, requestOptions);
|
|
271
|
+
return commandResult(currentModelMessage(catalog.current));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return commandResult(modelErrorMessage(error, 'select'));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const numberRequest = modelNumberRequest(requested);
|
|
277
|
+
if (numberRequest?.index === null) {
|
|
278
|
+
return commandResult(invalidModelNumberMessage(requested));
|
|
279
|
+
}
|
|
280
|
+
if (!numberRequest
|
|
281
|
+
&& (!requested.includes('/') || requested.startsWith('/') || requested.endsWith('/'))) {
|
|
282
|
+
return commandResult(MODEL_USAGE);
|
|
283
|
+
}
|
|
284
|
+
if (options.pendingInteraction) {
|
|
285
|
+
return commandResult([
|
|
286
|
+
'当前任务正在等待你的回答或审批。',
|
|
287
|
+
'',
|
|
288
|
+
'请先处理当前请求,或者发送 /stop 停止任务。',
|
|
289
|
+
].join('\n'));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
return await withSessionBindingLock(state, key, async () => {
|
|
294
|
+
const bound = await boundSession(harness, state, key, requestOptions);
|
|
295
|
+
if (bound && await sessionIsBusy(bound.session, options.control, requestOptions)) {
|
|
296
|
+
return commandResult('当前任务正在运行,请等待完成或先发送 /stop。');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const catalog = bound
|
|
300
|
+
? await sessionCatalog(bound.session, requestOptions)
|
|
301
|
+
: await listCatalog(harness, requestOptions);
|
|
302
|
+
const selection = numberRequest
|
|
303
|
+
? modelAt(catalog, numberRequest.index)
|
|
304
|
+
: matchingModel(catalog, requested);
|
|
305
|
+
if (!selection) {
|
|
306
|
+
if (numberRequest) return commandResult(invalidModelNumberMessage(requested));
|
|
307
|
+
return commandResult([
|
|
308
|
+
`没有找到模型:${safeDisplayText(requested)}`,
|
|
309
|
+
'',
|
|
310
|
+
'请发送 /models 查看可用模型。',
|
|
311
|
+
].join('\n'));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (bound) {
|
|
315
|
+
if (typeof bound.session.selectModel !== 'function') {
|
|
316
|
+
throw new TypeError('Harness session does not support model selection');
|
|
317
|
+
}
|
|
318
|
+
await bound.session.selectModel(selection, requestOptions);
|
|
319
|
+
} else {
|
|
320
|
+
if (typeof harness?.createSession !== 'function'
|
|
321
|
+
|| typeof harness?.workspaceSession !== 'function'
|
|
322
|
+
|| typeof state?.sessionFor !== 'function'
|
|
323
|
+
|| typeof state?.setSession !== 'function') {
|
|
324
|
+
throw new TypeError('Harness cannot create a conversation session');
|
|
325
|
+
}
|
|
326
|
+
const sessionId = await harness.createSession(requestOptions);
|
|
327
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
328
|
+
throw new TypeError('Harness returned an invalid session id');
|
|
329
|
+
}
|
|
330
|
+
const session = harness.workspaceSession(sessionId);
|
|
331
|
+
if (!session || typeof session.selectModel !== 'function') {
|
|
332
|
+
throw new TypeError('Harness session does not support model selection');
|
|
333
|
+
}
|
|
334
|
+
await session.selectModel(selection, requestOptions);
|
|
335
|
+
const currentSessionId = state.sessionFor(key);
|
|
336
|
+
if (typeof currentSessionId === 'string' && currentSessionId) {
|
|
337
|
+
const changed = new Error('Conversation binding changed during model selection');
|
|
338
|
+
changed.code = SESSION_BINDING_CHANGED;
|
|
339
|
+
throw changed;
|
|
340
|
+
}
|
|
341
|
+
if (await state.setSession(key, sessionId) === false) {
|
|
342
|
+
const stale = new Error('Workspace changed while binding the new session');
|
|
343
|
+
stale.code = WORKSPACE_SESSION_STALE;
|
|
344
|
+
throw stale;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return commandResult(`模型已切换为:\n${modelId(selection.provider, selection.model)}\n\n后续消息将使用该模型。`);
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
return commandResult(modelErrorMessage(error, 'select'));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -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,9 +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';
|
|
3
7
|
import {
|
|
4
8
|
rememberConnectionTestTarget,
|
|
5
9
|
sendRememberedConnectionTest,
|
|
6
10
|
} from './connection-test.mjs';
|
|
11
|
+
import {
|
|
12
|
+
isModelCommand,
|
|
13
|
+
runModelCommand,
|
|
14
|
+
} from './model-command.mjs';
|
|
7
15
|
import { askInWorkspaceSession } from './workspace-session.mjs';
|
|
8
16
|
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
|
9
17
|
import {
|
|
@@ -56,6 +64,7 @@ export class TextHarnessBridge {
|
|
|
56
64
|
#interactionKeys = new Map();
|
|
57
65
|
#acceptedMessageIds = new Set();
|
|
58
66
|
#approvalTasks = new Set();
|
|
67
|
+
#commandTasks = new Set();
|
|
59
68
|
#approvals;
|
|
60
69
|
|
|
61
70
|
constructor({
|
|
@@ -110,6 +119,24 @@ export class TextHarnessBridge {
|
|
|
110
119
|
|
|
111
120
|
const key = `${kind}:${conversationId}`;
|
|
112
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
|
+
}
|
|
113
140
|
const approval = this.#approvals.claimReply({
|
|
114
141
|
key,
|
|
115
142
|
actor: senderId,
|
|
@@ -201,9 +228,48 @@ export class TextHarnessBridge {
|
|
|
201
228
|
pending.queue ? [pending.queue] : []
|
|
202
229
|
)),
|
|
203
230
|
...this.#approvalTasks,
|
|
231
|
+
...this.#commandTasks,
|
|
204
232
|
]);
|
|
205
233
|
}
|
|
206
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
|
+
|
|
207
273
|
sendConnectionTest(text) {
|
|
208
274
|
return sendRememberedConnectionTest({
|
|
209
275
|
state: this.#state,
|
|
@@ -250,6 +316,11 @@ export class TextHarnessBridge {
|
|
|
250
316
|
'/workspacelist 列出工作区绝对路径',
|
|
251
317
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
252
318
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
319
|
+
'/models 按序号列出所有可用模型',
|
|
320
|
+
'/model [序号或完整模型ID] 查看或切换当前会话模型',
|
|
321
|
+
'示例:先发 /models,再发 /model 2',
|
|
322
|
+
'/stop 停止当前任务',
|
|
323
|
+
'/steer 补充指令 纠偏当前任务',
|
|
253
324
|
'/status 检查连接状态',
|
|
254
325
|
'/help 显示本帮助',
|
|
255
326
|
].join('\n'));
|
|
@@ -316,6 +387,7 @@ export class TextHarnessBridge {
|
|
|
316
387
|
askOptions: {
|
|
317
388
|
timeoutMs: this.#replyTimeoutMs,
|
|
318
389
|
signal: this.#signal,
|
|
390
|
+
control: { owner: this, key: conversationKey },
|
|
319
391
|
onUpdate: stream ? async (update) => {
|
|
320
392
|
const progress = update.type === 'text' ? update.text
|
|
321
393
|
: update.type === 'tool' ? `正在使用${update.name}…` : update.text;
|
|
@@ -347,6 +419,16 @@ export class TextHarnessBridge {
|
|
|
347
419
|
this.#status.lastReplyAt = new Date().toISOString();
|
|
348
420
|
this.#status.lastError = null;
|
|
349
421
|
} catch (error) {
|
|
422
|
+
if (error?.code === 'turn-stopped') {
|
|
423
|
+
if (stream) {
|
|
424
|
+
try {
|
|
425
|
+
await stream.finish('已停止。');
|
|
426
|
+
} catch {
|
|
427
|
+
stream.cancel?.();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
350
432
|
stream?.cancel?.();
|
|
351
433
|
if (this.#signal?.aborted) return;
|
|
352
434
|
this.#status.lastError = error?.message ?? String(error);
|
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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;
|