@sidleo3/dsh-chat 0.0.4
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/client/bot-list.js +243 -0
- package/client/bot-settings.js +175 -0
- package/client/bot-shared-settings.js +561 -0
- package/client/chat-ui.js +134 -0
- package/client/context-enhancement.js +435 -0
- package/client/delivery-targets.js +334 -0
- package/client/diagnostics.js +160 -0
- package/client/i18n.js +371 -0
- package/client/index.js +77 -0
- package/client/list-order.js +144 -0
- package/client/rpc.js +52 -0
- package/client/scoped-mode-editor.js +111 -0
- package/client/section.js +250 -0
- package/client/session-badges.js +263 -0
- package/client/styles.js +960 -0
- package/client/version-panel.js +97 -0
- package/cordis.patch.yml +5 -0
- package/host/bot-model.mjs +53 -0
- package/host/bot-settings.mjs +247 -0
- package/host/channel-registry.mjs +237 -0
- package/host/commands.mjs +857 -0
- package/host/deferred.mjs +291 -0
- package/host/delivery.mjs +377 -0
- package/host/file-log.mjs +169 -0
- package/host/guidance.mjs +73 -0
- package/host/index.mjs +7 -0
- package/host/interactions.mjs +330 -0
- package/host/json-store.mjs +144 -0
- package/host/log-tail.mjs +63 -0
- package/host/panel.mjs +1012 -0
- package/host/paths.mjs +50 -0
- package/host/plugin.mjs +873 -0
- package/host/prompt-context.mjs +70 -0
- package/host/rpc.mjs +147 -0
- package/host/session-keys.mjs +25 -0
- package/host/session-store.mjs +187 -0
- package/host/sessions.mjs +1348 -0
- package/host/tools.mjs +283 -0
- package/lib/client.js +4431 -0
- package/lib/index.js +5676 -0
- package/package.json +63 -0
- package/shared/access-policy.mjs +263 -0
- package/shared/channel-rail.mjs +156 -0
- package/shared/context-enhancement.mjs +415 -0
- package/shared/contract.mjs +120 -0
- package/shared/panel-sections.mjs +76 -0
- package/shared/reply-reference.mjs +115 -0
|
@@ -0,0 +1,1348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话桥(host 侧):把一次 IM 消息变成一次 DSH 会话回合。
|
|
3
|
+
*
|
|
4
|
+
* 契约依据(DSH 0.1.5-rc.2 实测源码,见 UPSTREAM.md):
|
|
5
|
+
* - `gateway.invoke({ namespace, method, args, signal })` 走一元方法,返回原始业务值,
|
|
6
|
+
* 失败抛 `RemoteError`(读 `error.code`);args 的键名必须与描述符 wire 完全一致,
|
|
7
|
+
* 因此绝大多数方法都要包一层 `request`,且 `session/list` 的 wire 是 `_request`;
|
|
8
|
+
* - `session/follow`、`session/control`、`workspace/follow` 是 **stream** 方法,
|
|
9
|
+
* 必须用 `gateway.stream()`;
|
|
10
|
+
* - 一轮结束 = `turn/end` 事件;最终答案是**该轮所有 `assistant/message` 的 text 块按顺序拼接**
|
|
11
|
+
* (工具调用前后各有一段正文是常态,只取最后一段会丢内容——上游 Issue #112 同一根因);
|
|
12
|
+
* - 工具过程 = `tool/call` / `tool/result` 事件;
|
|
13
|
+
* - 审批与提问不是 Remote 方法,而是 agent 作用域的 Cordis waterfall 事件
|
|
14
|
+
* (`approval/request`、`user-questions/request`),由 root 上的 listener 参与应答。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-chat/host/sessions
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
|
|
21
|
+
import { normalizeBotModel } from './bot-model.mjs';
|
|
22
|
+
|
|
23
|
+
const MAX_ASSISTANT_TEXT = 200_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 关流的宽限时间:`ask()` 已经拿到结果后,绝不允许"关闭订阅"把返回值拖住。
|
|
27
|
+
* 真机上出现过 follow 流的 return() 永不落地,导致回合明明跑完、渠道却永远收不到
|
|
28
|
+
* 结果(用户看到的就是"发了没反应",而且日志里连渠道侧一行都没有)。
|
|
29
|
+
*/
|
|
30
|
+
const STREAM_CLOSE_GRACE_MS = 1_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 回合"没有进展"多久算卡死(默认 15 分钟)。
|
|
34
|
+
*
|
|
35
|
+
* 注意判的是**静默时长**而不是总时长:一次合法的长任务(几十次工具调用、单个工具跑几分钟)
|
|
36
|
+
* 只要一直在产出事件就不该被打断。旧的"整轮 10 分钟"上限把真机上一次 10 分 20 秒的
|
|
37
|
+
* 帆软排障误判成超时并中断了。
|
|
38
|
+
*/
|
|
39
|
+
const TURN_IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
40
|
+
|
|
41
|
+
/** 绝对上限(默认 2 小时):防死循环,正常任务碰不到。 */
|
|
42
|
+
const TURN_TOTAL_TIMEOUT_MS = 2 * 60 * 60_000;
|
|
43
|
+
|
|
44
|
+
/** 把 DSH 的 RemoteError 折成带 code 的普通错误,便于渠道判断。 */
|
|
45
|
+
function sessionError(error, fallbackCode = 'chat/session-failed') {
|
|
46
|
+
const code = typeof error?.code === 'string' ? error.code : fallbackCode;
|
|
47
|
+
const wrapped = new Error(typeof error?.message === 'string' && error.message
|
|
48
|
+
? error.message
|
|
49
|
+
: '会话操作失败。');
|
|
50
|
+
wrapped.code = code;
|
|
51
|
+
wrapped.details = error?.details ?? {};
|
|
52
|
+
return wrapped;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 把一批字节上传成"本会话可引用的文件",换回 DSH 的 `receiptId`。
|
|
57
|
+
*
|
|
58
|
+
* 为什么要它:`session/prompt` 的文件内容块是 `{ type:'file', receiptId }`,
|
|
59
|
+
* 而 receipt 必须由**同一会话**的上传产生——所以入站文件(飞书/微信里的附件)
|
|
60
|
+
* 只能走这条路交给模型。
|
|
61
|
+
*/
|
|
62
|
+
function fileUploadFailure(error) {
|
|
63
|
+
const wrapped = new Error(typeof error?.message === 'string' && error.message
|
|
64
|
+
? `上传文件失败:${error.message}`
|
|
65
|
+
: '上传文件失败。');
|
|
66
|
+
wrapped.code = typeof error?.code === 'string' ? error.code : 'chat/upload-failed';
|
|
67
|
+
return wrapped;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 图片回退:当前会话模型不收图片时,把**同样的字节**上传成本会话的文件再试一次。
|
|
72
|
+
*
|
|
73
|
+
* 为什么需要:`session/prompt` 会拿**会话当前模型**的模态直接拒掉图片内容块
|
|
74
|
+
* (`session/attachment-invalid` + `details.reason = MODEL_DOES_NOT_SUPPORT_IMAGES`),
|
|
75
|
+
* 而文件内容块对纯文本模型是可用的——DSH 会把它变成"只读副本已保存在 <path>"的文本,
|
|
76
|
+
* 模型可以用工具(读字节、图像处理、OCR)去分析。丢了图片等于问题没送出去。
|
|
77
|
+
*/
|
|
78
|
+
const MODEL_IMAGE_REJECTION = 'MODEL_DOES_NOT_SUPPORT_IMAGES';
|
|
79
|
+
|
|
80
|
+
/** 认不出的媒体类型:仍然给它一个后缀,至少模型知道这是个文件。 */
|
|
81
|
+
const IMAGE_FILE_EXTENSION_FALLBACK = '.img';
|
|
82
|
+
|
|
83
|
+
/** 媒体类型 → 文件扩展名:没有扩展名的名字模型不知道该按图片读。 */
|
|
84
|
+
const IMAGE_FILE_EXTENSIONS = new Map([
|
|
85
|
+
['image/png', '.png'],
|
|
86
|
+
['image/jpeg', '.jpg'],
|
|
87
|
+
['image/gif', '.gif'],
|
|
88
|
+
['image/webp', '.webp'],
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** 图片内容块统一用 base64 传字节,所以这类问题不该因为编码差异漏判。 */
|
|
92
|
+
function hasImageParts(content) {
|
|
93
|
+
return Array.isArray(content) && content.some((part) => part?.type === 'image');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 这一轮是不是被"当前模型不支持图片"拒了?
|
|
98
|
+
*
|
|
99
|
+
* 主判据是 DSH 给的 `details.reason`(与官方 UI 的判据一致);再留一条按 code+文案的兜底,
|
|
100
|
+
* 防止某个版本只带文案不带 reason——误判的代价只是"多存一份文件、多一句说明",不会丢消息。
|
|
101
|
+
*/
|
|
102
|
+
function imageRejectionOf(error) {
|
|
103
|
+
if (error?.details?.reason === MODEL_IMAGE_REJECTION) return { reason: MODEL_IMAGE_REJECTION };
|
|
104
|
+
if (error?.code === 'session/attachment-invalid'
|
|
105
|
+
&& /does not support image input/i.test(String(error?.message ?? ''))) {
|
|
106
|
+
return { reason: MODEL_IMAGE_REJECTION };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 给图片起个带扩展名的文件名(渠道给的名字常常是 `feishu-image` 这种没有后缀的)。 */
|
|
112
|
+
function imageFileName(name, mediaType, index) {
|
|
113
|
+
const extension = IMAGE_FILE_EXTENSIONS.get(
|
|
114
|
+
typeof mediaType === 'string' ? mediaType.trim().toLowerCase() : '',
|
|
115
|
+
) ?? IMAGE_FILE_EXTENSION_FALLBACK;
|
|
116
|
+
const base = typeof name === 'string' && name.trim() ? name.trim() : `image-${index + 1}`;
|
|
117
|
+
return /\.[A-Za-z0-9]{2,5}$/.test(base) ? base : `${base}${extension}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function textOfAssistantMessage(message) {
|
|
121
|
+
const content = message?.content;
|
|
122
|
+
if (!Array.isArray(content)) return '';
|
|
123
|
+
return content
|
|
124
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
125
|
+
.map((block) => block.text)
|
|
126
|
+
.join('');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 把内容块数组里的文本拼起来(用于入站消息文本)。 */
|
|
130
|
+
function contentText(content) {
|
|
131
|
+
if (!Array.isArray(content)) return '';
|
|
132
|
+
return content
|
|
133
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
134
|
+
.map((block) => block.text)
|
|
135
|
+
.join('')
|
|
136
|
+
.trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function deltaTextOf(chunk) {
|
|
140
|
+
if (typeof chunk?.text === 'string') return chunk.text;
|
|
141
|
+
if (typeof chunk?.delta === 'string') return chunk.delta;
|
|
142
|
+
return '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 创建会话桥。
|
|
147
|
+
*
|
|
148
|
+
* @param options - { ctx, logger, store, guidance }。
|
|
149
|
+
* `store` 为 `session-store`;`guidance` 为每会话来源提示词登记表。
|
|
150
|
+
* @returns 契约规定的 sessions 面。
|
|
151
|
+
*/
|
|
152
|
+
/** 从 `present` 的参数里解出文件清单(解析失败就当没有,绝不抛)。 */
|
|
153
|
+
function filesOfPresentArgs(args) {
|
|
154
|
+
let parsed = args;
|
|
155
|
+
if (typeof args === 'string') {
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(args);
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const files = Array.isArray(parsed?.files) ? parsed.files : [];
|
|
163
|
+
return files
|
|
164
|
+
.filter((file) => typeof file?.path === 'string' && file.path)
|
|
165
|
+
.map((file) => ({
|
|
166
|
+
path: file.path,
|
|
167
|
+
...(typeof file.description === 'string' && file.description
|
|
168
|
+
? { description: file.description }
|
|
169
|
+
: {}),
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 从 `session/page` 的记录里挑出对话消息(`/history` 用)。
|
|
175
|
+
*
|
|
176
|
+
* 只保留**用户真正说的**与**模型回复的文本**:注入的上下文(`user/message` 但
|
|
177
|
+
* `source.kind !== 'user'`)不算一轮对话,思考/工具调用也不进历史。
|
|
178
|
+
*
|
|
179
|
+
* @param records - `session/page` 的 `records`。
|
|
180
|
+
* @param limit - 最多保留多少条消息(从最新往回数)。
|
|
181
|
+
* @returns `[{ role: 'user'|'assistant', text }]`(按时间正序)。
|
|
182
|
+
*/
|
|
183
|
+
function historyMessagesOf(records, limit) {
|
|
184
|
+
const messages = [];
|
|
185
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
186
|
+
const event = record?.event ?? record;
|
|
187
|
+
const data = event?.data;
|
|
188
|
+
if (event?.type === 'user/message') {
|
|
189
|
+
if (data?.source?.kind !== 'user') continue;
|
|
190
|
+
const text = contentText(data?.content);
|
|
191
|
+
if (text) messages.push({ role: 'user', text });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (event?.type === 'assistant/message') {
|
|
195
|
+
const text = textOfAssistantMessage(data?.message);
|
|
196
|
+
if (text) messages.push({ role: 'assistant', text });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return limit > 0 ? messages.slice(-limit) : messages;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 组装会话桥服务。
|
|
204
|
+
*
|
|
205
|
+
* @param options - { ctx, logger, store, guidance, interactions }。
|
|
206
|
+
* @returns 会话桥。
|
|
207
|
+
*/
|
|
208
|
+
export function createSessionBridge({
|
|
209
|
+
ctx, logger = console, store, settings = null, guidance, interactions, deferred = null,
|
|
210
|
+
}) {
|
|
211
|
+
const gateway = ctx?.typertGateway;
|
|
212
|
+
if (typeof gateway?.invoke !== 'function') {
|
|
213
|
+
throw new TypeError('会话桥需要 context 的 typertGateway.invoke(请在 inject 中声明)。');
|
|
214
|
+
}
|
|
215
|
+
/** @type {Map<string, AbortController>} 会话键 → 当前回合的中断控制器。 */
|
|
216
|
+
const activeTurns = new Map();
|
|
217
|
+
/**
|
|
218
|
+
* 会话键 → 该会话的回合队列(尾部的 promise)与排队条数。
|
|
219
|
+
*
|
|
220
|
+
* DSH 侧的 `session/prompt` 本来就支持 `mode: 'queue'`,但**渠道侧**每条消息都会各自
|
|
221
|
+
* 开一条 `follow` 流等自己的答案:两个回合同时在飞会互相抢答案(第二条会看到第一条的
|
|
222
|
+
* 结果、第一条可能永远等不到)。所以同一个会话的回合必须在 hub 里串起来。
|
|
223
|
+
*/
|
|
224
|
+
const turnQueues = new Map();
|
|
225
|
+
const queueDepth = new Map();
|
|
226
|
+
/** 已经标过渠道的工作区 / 会话(进程内只标一次,避免每轮都发 rename)。 */
|
|
227
|
+
const namedWorkspaces = new Set();
|
|
228
|
+
/** 会话 id → **已经应用的标题前缀**:前缀变了(比如后来才查到群名)才允许再 rename 一次。 */
|
|
229
|
+
const namedSessions = new Map();
|
|
230
|
+
|
|
231
|
+
/** 拼标题/工作区名的一段段(空段丢掉)。 */
|
|
232
|
+
function titleParts(...parts) {
|
|
233
|
+
return parts.map((part) => String(part ?? '').trim()).filter(Boolean).join(' · ');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function escapeRegExp(text) {
|
|
237
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 去掉标题里已经存在的「渠道 · 聊天 · 」前缀(旧形态「渠道 · 」也去)。
|
|
242
|
+
*
|
|
243
|
+
* 为什么必须能去:会话名里的**聊天名可能是后补的**(第一轮还没查到群名,先按掩码 id 标;
|
|
244
|
+
* 下一轮拿到真名要能换成真名)。不能去就会一层层叠加:`飞书 · 群 甲 · 飞书 · 群 乙 · 标题`。
|
|
245
|
+
*/
|
|
246
|
+
function stripTitlePrefix(title, channelLabel) {
|
|
247
|
+
const channel = String(channelLabel ?? '').trim();
|
|
248
|
+
if (!channel) return title;
|
|
249
|
+
const withChat = new RegExp(`^${escapeRegExp(channel)} · (?:群|私聊) .+? · `);
|
|
250
|
+
if (withChat.test(title)) return title.replace(withChat, '');
|
|
251
|
+
const channelOnly = new RegExp(`^${escapeRegExp(channel)} · `);
|
|
252
|
+
return title.replace(channelOnly, '');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 给会话标题补上「渠道 · 聊天 · 」前缀(幂等,且能**升级**)。
|
|
257
|
+
*
|
|
258
|
+
* 会话列表里一堆同名会话时,光有渠道名分不出"是哪个群/哪个人"——真机上用户要的是
|
|
259
|
+
* 「飞书 · 群 张三 · 日报整理」这种能区分对象的标题,所以前缀里带上聊天身份
|
|
260
|
+
* (渠道算出来的 `chatLabel`,如 `群 张三` / `私聊 张三`;查不到名字时是 `群 oc_…` 掩码)。
|
|
261
|
+
*
|
|
262
|
+
* 幂等口径:**按前缀**记,不是按会话记——前缀没变就跳过;前缀变了(后来才查到群名、
|
|
263
|
+
* 或用户改了聊天)就替换掉旧前缀再加一次,绝不允许一层层叠加。
|
|
264
|
+
*
|
|
265
|
+
* @param sessionId - 会话 id。
|
|
266
|
+
* @param labels - `{ channelLabel, chatLabel }`(`chatLabel` 可为空:那就只标渠道)。
|
|
267
|
+
* @returns 'renamed'(补上/升级了)/ 'skipped'(前缀已经是这个)/ 'no-title'(还没有标题,
|
|
268
|
+
* 等下一轮)/ 'failed'(失败,只留日志,下轮会再试)。返回值给 `/retitle` 用。
|
|
269
|
+
*/
|
|
270
|
+
async function markSessionChannel(sessionId, labels, signal) {
|
|
271
|
+
const channelLabel = String(labels?.channelLabel ?? '').trim();
|
|
272
|
+
const chatLabel = String(labels?.chatLabel ?? '').trim();
|
|
273
|
+
const prefix = titleParts(channelLabel, chatLabel);
|
|
274
|
+
if (!prefix || namedSessions.get(sessionId) === prefix) return 'skipped';
|
|
275
|
+
try {
|
|
276
|
+
// 标题只有 `session/list` 的投影里有(`session/page` 不带投影)。
|
|
277
|
+
const listed = await invoke('session', 'list', { _request: {} }, signal);
|
|
278
|
+
const item = (listed?.items ?? []).find((entry) => entry?.sessionId === sessionId);
|
|
279
|
+
const title = item?.projections?.values?.title;
|
|
280
|
+
// 标题要等第一轮跑完才生成:这时**不能**记成"已标记",否则同一个会话
|
|
281
|
+
// 在这个进程里再也不会重试,前缀就永远补不上了。
|
|
282
|
+
if (typeof title !== 'string' || !title.trim()) return 'no-title';
|
|
283
|
+
const body = stripTitlePrefix(title, channelLabel);
|
|
284
|
+
const next = `${prefix} · ${body}`;
|
|
285
|
+
if (next === title) {
|
|
286
|
+
namedSessions.set(sessionId, prefix);
|
|
287
|
+
return 'skipped';
|
|
288
|
+
}
|
|
289
|
+
await invoke('session', 'rename', { request: { sessionId, title: next } }, signal);
|
|
290
|
+
namedSessions.set(sessionId, prefix);
|
|
291
|
+
logger.info?.(`[dsh-chat] 会话标题已标聊天:${sessionId} → ${next}`);
|
|
292
|
+
return 'renamed';
|
|
293
|
+
} catch (error) {
|
|
294
|
+
// 命名是锦上添花:失败只留日志、且不记"已标记",下一轮还会再试,绝不影响消息处理。
|
|
295
|
+
logger.warn?.(`[dsh-chat] 标记会话标题失败:${sessionId} ${error?.message ?? error}`);
|
|
296
|
+
return 'failed';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 这台机器人绑定过的会话(`/retitle` 这类"对历史会话补一刀"的动作要用)。
|
|
302
|
+
*
|
|
303
|
+
* @returns `[{ key, sessionId, workspacePath }]`。
|
|
304
|
+
*/
|
|
305
|
+
function boundSessions(channelId, botId) {
|
|
306
|
+
const entries = store?.entries?.(channelId, botId) ?? {};
|
|
307
|
+
return Object.entries(entries).map(([key, entry]) => ({
|
|
308
|
+
key, sessionId: entry?.sessionId ?? null, workspacePath: entry?.workspacePath ?? null,
|
|
309
|
+
})).filter((row) => typeof row.sessionId === 'string' && row.sessionId);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 调用一个一元 DSH Remote 方法。
|
|
314
|
+
*
|
|
315
|
+
* @param namespace - 'session' | 'workspace'。
|
|
316
|
+
* @param method - 方法名。
|
|
317
|
+
* @param args - wire 参数(键名必须与描述符一致)。
|
|
318
|
+
* @param signal - AbortSignal。
|
|
319
|
+
* @returns 原始业务值。
|
|
320
|
+
*/
|
|
321
|
+
async function invoke(namespace, method, args = {}, signal) {
|
|
322
|
+
const request = { namespace, method, args };
|
|
323
|
+
if (signal !== undefined) request.signal = signal;
|
|
324
|
+
try {
|
|
325
|
+
return await gateway.invoke(request);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
throw sessionError(error, 'chat/gateway-failed');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 打开一个 stream 方法。
|
|
333
|
+
*
|
|
334
|
+
* @returns AsyncIterable。
|
|
335
|
+
*/
|
|
336
|
+
async function stream(namespace, method, args = {}, signal) {
|
|
337
|
+
if (typeof gateway.stream !== 'function') {
|
|
338
|
+
const error = new Error('当前 Host 不支持 stream 调用。');
|
|
339
|
+
error.code = 'chat/stream-unavailable';
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
const request = { namespace, method, args };
|
|
343
|
+
if (signal !== undefined) request.signal = signal;
|
|
344
|
+
try {
|
|
345
|
+
return await gateway.stream(request);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
throw sessionError(error, 'chat/gateway-stream-failed');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 按路径拿到(或创建)工作区 id。`workspace/create` 按路径幂等。
|
|
353
|
+
*
|
|
354
|
+
* 顺带把工作区命名成「渠道 · 机器人」(`飞书 · 张三-DSH`)——web 侧边栏就是按工作区
|
|
355
|
+
* 分组的,这样一眼能看出这个工作区属于哪个渠道的哪个机器人。命名同样是幂等的。
|
|
356
|
+
*
|
|
357
|
+
* @param path - 工作区路径。
|
|
358
|
+
* @param signal - 取消信号。
|
|
359
|
+
* @param label - 可选:`飞书 · 张三-DSH`(渠道与机器人中文名)。
|
|
360
|
+
*/
|
|
361
|
+
async function resolveWorkspaceId(path, signal, label = '') {
|
|
362
|
+
const result = await invoke('workspace', 'create', { request: { path } }, signal);
|
|
363
|
+
const workspaceId = result?.workspace?.workspaceId;
|
|
364
|
+
if (typeof workspaceId !== 'string' || !workspaceId) {
|
|
365
|
+
const error = new Error('DSH 未返回工作区标识。');
|
|
366
|
+
error.code = 'chat/workspace-unresolved';
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
const title = typeof label === 'string' ? label.trim() : '';
|
|
370
|
+
if (title && !namedWorkspaces.has(workspaceId)) {
|
|
371
|
+
namedWorkspaces.add(workspaceId);
|
|
372
|
+
try {
|
|
373
|
+
await invoke('workspace', 'rename', { request: { workspaceId, title } }, signal);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
logger.warn?.(`[dsh-chat] 工作区命名失败:${workspaceId} ${error?.message ?? error}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return workspaceId;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 判断一个 Session 是否仍然存在(不激活 Agent)。 */
|
|
382
|
+
async function sessionExists(sessionId, signal) {
|
|
383
|
+
try {
|
|
384
|
+
await invoke('session', 'page', {
|
|
385
|
+
request: { address: { kind: 'session', sessionId }, throughSeq: -1, maxMessages: 1 },
|
|
386
|
+
}, signal);
|
|
387
|
+
return true;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error.code === 'session/not-found') return false;
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 找到或创建该会话键对应的 DSH 会话。
|
|
396
|
+
*
|
|
397
|
+
* @param options - { channelId, botId, key, workspacePath, signal }。
|
|
398
|
+
* @returns { sessionId, created }。
|
|
399
|
+
*/
|
|
400
|
+
/**
|
|
401
|
+
* 新建会话,带上机器人设置的 Agent Preset。
|
|
402
|
+
*
|
|
403
|
+
* 预设可能已经被删掉/改名:那样 `session.create` 会失败,**不能因此让机器人一个会话都建不出来**。
|
|
404
|
+
* 所以失败时记一条 warn、退回 Host 默认预设重试一次。
|
|
405
|
+
*/
|
|
406
|
+
async function createSession({ workspaceId, agentPreset, signal, channelId, botId }) {
|
|
407
|
+
const request = { workspaceId, ...(agentPreset ? { agentPreset } : {}) };
|
|
408
|
+
try {
|
|
409
|
+
return await invoke('session', 'create', { request }, signal);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (!agentPreset) throw error;
|
|
412
|
+
logger.warn?.(`[dsh-chat] 机器人 ${channelId}/${botId} 的 Agent Preset「${agentPreset}」不可用`
|
|
413
|
+
+ `(${error?.message ?? error}),本次退回 Host 默认。`);
|
|
414
|
+
return invoke('session', 'create', { request: { workspaceId } }, signal);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 这个错误是不是"会话选中的模型没了"(模型被删/改名/下线)。 */
|
|
419
|
+
function modelUnavailableOf(error) {
|
|
420
|
+
if (error?.code !== 'session/model-unavailable') return null;
|
|
421
|
+
return {
|
|
422
|
+
provider: typeof error.details?.provider === 'string' ? error.details.provider : null,
|
|
423
|
+
model: typeof error.details?.model === 'string' ? error.details.model : null,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 模型没了就自救:切回一个**当前 Host 真的可用**的模型,然后把这一轮重试一次。
|
|
429
|
+
*
|
|
430
|
+
* 为什么值得做:模型是会话级设置,一旦那个模型被删/改名,这个会话**每次**都失败——
|
|
431
|
+
* 用户看到的是"机器人哑了",而且他自己未必知道该去 `/model` 改(真机上就是这样卡住的)。
|
|
432
|
+
* 兜底后的选择写回会话(`selectModel`),所以下一轮不用再自救;同时给用户一句可见的说明。
|
|
433
|
+
*
|
|
434
|
+
* @returns `{ provider, model, reasoningEffort }` 或 null(找不到可用的替代)。
|
|
435
|
+
*/
|
|
436
|
+
async function recoverUnavailableModel({ sessionId, failed, signal }) {
|
|
437
|
+
let options = [];
|
|
438
|
+
let hostDefault = null;
|
|
439
|
+
try {
|
|
440
|
+
const catalog = await invoke('session', 'modelCatalog', {}, signal);
|
|
441
|
+
hostDefault = catalog?.default?.provider && catalog?.default?.model ? catalog.default : null;
|
|
442
|
+
for (const group of catalog?.groups ?? []) {
|
|
443
|
+
const provider = group?.id;
|
|
444
|
+
for (const model of group?.models ?? []) {
|
|
445
|
+
if (provider && model?.id) options.push({ provider, model: model.id });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
logger.warn?.(`[dsh-chat] 模型自救时读不到模型目录:${error?.message ?? error}`);
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const usable = (candidate) => candidate && candidate.provider && candidate.model
|
|
453
|
+
// 换一个**不一样**的:把同一个失效模型再选一次没有任何意义。
|
|
454
|
+
&& !(failed?.provider && failed?.model
|
|
455
|
+
&& candidate.provider === failed.provider && candidate.model === failed.model);
|
|
456
|
+
const target = [hostDefault, ...options].find(usable);
|
|
457
|
+
if (!target) return null;
|
|
458
|
+
try {
|
|
459
|
+
await invoke('session', 'selectModel', {
|
|
460
|
+
request: { sessionId, provider: target.provider, model: target.model },
|
|
461
|
+
}, signal);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
logger.warn?.(`[dsh-chat] 模型自救失败(切到 ${target.provider}/${target.model}):`
|
|
464
|
+
+ `${error?.message ?? error}`);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
logger.warn?.(`[dsh-chat] 会话 ${sessionId} 的模型不可用(${failed?.provider ?? '?'}/${failed?.model ?? '?'}),`
|
|
468
|
+
+ `已自动切到 ${target.provider}/${target.model} 并重试这一轮`);
|
|
469
|
+
return { provider: target.provider, model: target.model };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 把"机器人默认模型"应用到刚建好的会话。
|
|
474
|
+
*
|
|
475
|
+
* 面板与 `/model` 在没有会话时选的是**机器人默认模型**(DSH 的 `session/create` 没有模型参数,
|
|
476
|
+
* 只能在建好之后 `selectModel`)。失败不能让会话建不出来——记 warn 后退回 Host 默认,
|
|
477
|
+
* 用户下一条消息照样能跑(真正的现场在日志里)。
|
|
478
|
+
*/
|
|
479
|
+
async function applyBotModel({ sessionId, botModel, signal, channelId, botId }) {
|
|
480
|
+
if (!botModel) return;
|
|
481
|
+
const label = `${botModel.provider}/${botModel.model}`
|
|
482
|
+
+ `${botModel.reasoningEffort ? ` · 推理 ${botModel.reasoningEffort}` : ''}`;
|
|
483
|
+
try {
|
|
484
|
+
await invoke('session', 'selectModel', {
|
|
485
|
+
request: {
|
|
486
|
+
sessionId,
|
|
487
|
+
provider: botModel.provider,
|
|
488
|
+
model: botModel.model,
|
|
489
|
+
...(botModel.reasoningEffort ? { reasoningEffort: botModel.reasoningEffort } : {}),
|
|
490
|
+
},
|
|
491
|
+
}, signal);
|
|
492
|
+
logger.info?.(`[dsh-chat] 新会话应用机器人默认模型:${label}(${channelId}/${botId})`);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
logger.warn?.(`[dsh-chat] 应用机器人默认模型失败(${label},${channelId}/${botId}):`
|
|
495
|
+
+ `${error?.message ?? error}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function ensure({
|
|
500
|
+
channelId, botId, key, workspacePath, signal, channelLabel = '', botLabel = '',
|
|
501
|
+
}) {
|
|
502
|
+
if (!store) throw new TypeError('会话桥缺少会话绑定表。');
|
|
503
|
+
const existing = store.get(channelId, botId, key);
|
|
504
|
+
if (existing) {
|
|
505
|
+
if (await sessionExists(existing.sessionId, signal)) {
|
|
506
|
+
return { sessionId: existing.sessionId, created: false };
|
|
507
|
+
}
|
|
508
|
+
// 会话已被删除:解绑后重建。
|
|
509
|
+
await store.unbind(channelId, botId, key);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* 工作区与 Agent Preset 都取机器人自己的设置(调用方传的 workspacePath 优先)。
|
|
513
|
+
* 两者都**只在新建会话时生效**——已有绑定保持原样,设置页必须把这一点讲清楚。
|
|
514
|
+
*/
|
|
515
|
+
const record = settings?.read?.(channelId, botId) ?? {};
|
|
516
|
+
const targetWorkspace = typeof workspacePath === 'string' && workspacePath.trim()
|
|
517
|
+
? workspacePath
|
|
518
|
+
: record.workspace;
|
|
519
|
+
if (typeof targetWorkspace !== 'string' || !targetWorkspace.trim()) {
|
|
520
|
+
const error = new Error('该机器人还没有设置工作区,无法创建会话。');
|
|
521
|
+
error.code = 'chat/workspace-required';
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
const workspaceTitle = [channelLabel, botLabel].map((part) => String(part ?? '').trim())
|
|
525
|
+
.filter(Boolean).join(' · ');
|
|
526
|
+
const workspaceId = await resolveWorkspaceId(targetWorkspace, signal, workspaceTitle);
|
|
527
|
+
const agentPreset = typeof record.agentPreset === 'string' && record.agentPreset
|
|
528
|
+
? record.agentPreset
|
|
529
|
+
: null;
|
|
530
|
+
const created = await createSession({
|
|
531
|
+
workspaceId, agentPreset, signal, botId, channelId,
|
|
532
|
+
});
|
|
533
|
+
const sessionId = created?.sessionId;
|
|
534
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
535
|
+
const error = new Error('DSH 未返回会话标识。');
|
|
536
|
+
error.code = 'chat/session-unresolved';
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
await store.bind(channelId, botId, key, { sessionId, workspacePath });
|
|
540
|
+
await applyBotModel({ sessionId, botModel: normalizeBotModel(record.model), signal, channelId, botId });
|
|
541
|
+
return { sessionId, created: true };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 发送一条 prompt(一元方法,返回 accepted 不等于已回答)。 */
|
|
545
|
+
async function prompt({ sessionId, content, mode = 'queue', requestId = randomUUID(), signal }) {
|
|
546
|
+
if (!Array.isArray(content) || content.length === 0) {
|
|
547
|
+
const error = new Error('prompt 内容不能为空。');
|
|
548
|
+
error.code = 'chat/empty-prompt';
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
return invoke('session', 'prompt', {
|
|
552
|
+
request: { requestId, sessionId, mode, content },
|
|
553
|
+
}, signal);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** 停止当前回合。 */
|
|
557
|
+
async function cancel({ channelId, botId, key, signal }) {
|
|
558
|
+
const bound = store?.get(channelId, botId, key);
|
|
559
|
+
activeTurns.get(`${channelId}:${botId}:${key}`)?.abort?.();
|
|
560
|
+
// 用户明确停了:这条会话的待交付记录一并作废,别过一会儿又冒出一条补充结果。
|
|
561
|
+
await deferred?.forgetKey?.({ channelId, botId, key, reason: '用户 /stop' })
|
|
562
|
+
?.catch?.((error) => logger.warn?.(`[dsh-chat] 作废待交付记录失败:${error?.message ?? error}`));
|
|
563
|
+
if (!bound) return { accepted: false };
|
|
564
|
+
try {
|
|
565
|
+
return await invoke('session', 'cancel', { request: { sessionId: bound.sessionId } }, signal);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (error.code === 'session/not-found') return { accepted: false };
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** 该会话当前是否在运行。 */
|
|
573
|
+
async function isRunning(sessionId, signal) {
|
|
574
|
+
const result = await invoke('session', 'list', { _request: {} }, signal);
|
|
575
|
+
const item = Array.isArray(result?.items)
|
|
576
|
+
? result.items.find((entry) => entry?.sessionId === sessionId)
|
|
577
|
+
: undefined;
|
|
578
|
+
return item?.running === true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** 重命名会话标题。 */
|
|
582
|
+
async function rename(sessionId, title, signal) {
|
|
583
|
+
return invoke('session', 'rename', { request: { sessionId, title } }, signal);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** 解除绑定(`/new`)。 */
|
|
587
|
+
async function reset({ channelId, botId, key }) {
|
|
588
|
+
await store.unbind(channelId, botId, key);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* 跑一次完整回合:先开 follow 拿基线,再发 prompt,边消费事件边回调,
|
|
593
|
+
* 直到本轮的 `turn/end`。
|
|
594
|
+
*
|
|
595
|
+
* @param options - {
|
|
596
|
+
* channelId, botId, key, workspacePath, content, sourceGuidance,
|
|
597
|
+
* mode, signal, channelLabel?, botLabel?, chatLabel?, handlers: {
|
|
598
|
+
* onTurnStart?, onAssistantMessage?, onToolCall?, onToolResult?,
|
|
599
|
+
* onDelta?, onEvent?, onTurnEnd?,
|
|
600
|
+
* },
|
|
601
|
+
* turnTimeoutMs?,
|
|
602
|
+
* }。
|
|
603
|
+
* @returns { sessionId, text, reason, aborted }。
|
|
604
|
+
*/
|
|
605
|
+
async function ask({
|
|
606
|
+
channelId,
|
|
607
|
+
botId,
|
|
608
|
+
key,
|
|
609
|
+
workspacePath,
|
|
610
|
+
content,
|
|
611
|
+
sourceGuidance,
|
|
612
|
+
mode = 'queue',
|
|
613
|
+
signal,
|
|
614
|
+
handlers = {},
|
|
615
|
+
turnTimeoutMs,
|
|
616
|
+
channelLabel = '',
|
|
617
|
+
chatLabel = '',
|
|
618
|
+
botLabel = '',
|
|
619
|
+
onQueued,
|
|
620
|
+
}) {
|
|
621
|
+
// 先排队再干活:同一会话的第二个回合必须等第一个真正结束。
|
|
622
|
+
const queueKey = `${channelId}:${botId}:${key}`;
|
|
623
|
+
const ahead = queueDepth.get(queueKey) ?? 0;
|
|
624
|
+
queueDepth.set(queueKey, ahead + 1);
|
|
625
|
+
let release;
|
|
626
|
+
const mine = new Promise((resolve) => {
|
|
627
|
+
release = resolve;
|
|
628
|
+
});
|
|
629
|
+
const previous = turnQueues.get(queueKey) ?? Promise.resolve();
|
|
630
|
+
turnQueues.set(queueKey, previous.then(() => mine));
|
|
631
|
+
if (ahead > 0) {
|
|
632
|
+
// 让渠道能立刻回一句"前面还有几条",而不是让用户对着已读不回猜。
|
|
633
|
+
try {
|
|
634
|
+
onQueued?.(ahead);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
logger.warn?.(`[dsh-chat] 排队提示回调失败:${error?.message ?? error}`);
|
|
637
|
+
}
|
|
638
|
+
logger.info?.(`[dsh-chat] 回合排队:${queueKey} 前面还有 ${ahead} 条`);
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
await previous;
|
|
642
|
+
} catch {
|
|
643
|
+
// 前一个回合失败不该把后面的拖死。
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* 跑一轮;如果这一轮是因为"选中的模型不可用"失败,切回可用模型**重试一次**。
|
|
647
|
+
*
|
|
648
|
+
* 两条失败形态都要认:① 提示词收据直接抛 `session/model-unavailable`;
|
|
649
|
+
* ② 事件流以 error 收尾(`reason.kind === 'error'`)。
|
|
650
|
+
*/
|
|
651
|
+
const startedAt = Date.now();
|
|
652
|
+
let recovered = null;
|
|
653
|
+
let imageFallback = null;
|
|
654
|
+
try {
|
|
655
|
+
const runOnce = async () => {
|
|
656
|
+
try {
|
|
657
|
+
return await runTurn();
|
|
658
|
+
} catch (error) {
|
|
659
|
+
// ensure() 已经绑定过会话,所以这里拿得到 sessionId(两种自救都要用它)。
|
|
660
|
+
const sessionId = store?.get?.(channelId, botId, key)?.sessionId ?? null;
|
|
661
|
+
const failed = modelUnavailableOf(error);
|
|
662
|
+
if (failed) {
|
|
663
|
+
return {
|
|
664
|
+
sessionId,
|
|
665
|
+
text: '',
|
|
666
|
+
reason: { kind: 'error', error: sessionError(error) },
|
|
667
|
+
tools: [],
|
|
668
|
+
files: [],
|
|
669
|
+
aborted: false,
|
|
670
|
+
failed,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 当前模型不收图片(`session/prompt` 直接用会话当前模型的模态拒掉图片内容块):
|
|
679
|
+
* 把图片换成"本会话的文件"再试一次。转换失败就**原样抛**——不能静默把用户的图片丢掉。
|
|
680
|
+
*
|
|
681
|
+
* 只试一次:重试时内容里已经没有图片块了。
|
|
682
|
+
*/
|
|
683
|
+
const runWithImageFallback = async () => {
|
|
684
|
+
try {
|
|
685
|
+
return await runOnce();
|
|
686
|
+
} catch (error) {
|
|
687
|
+
const rejected = imageRejectionOf(error);
|
|
688
|
+
const sessionId = store?.get?.(channelId, botId, key)?.sessionId ?? null;
|
|
689
|
+
if (!rejected || !hasImageParts(content) || typeof sessionId !== 'string' || !sessionId) {
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
const fallback = await imagesAsFiles({ sessionId, content, signal }).catch((failure) => {
|
|
693
|
+
logger.warn?.(`[dsh-chat] 把图片转成会话文件失败:${failure?.message ?? failure}`);
|
|
694
|
+
return null;
|
|
695
|
+
});
|
|
696
|
+
if (!fallback) throw error;
|
|
697
|
+
imageFallback = fallback;
|
|
698
|
+
content = fallback.content;
|
|
699
|
+
logger.info?.(`[dsh-chat] 当前模型不支持图片,已改为作为文件交给会话:`
|
|
700
|
+
+ `${queueKey} 会话=${sessionId} 图片=${fallback.saved}`
|
|
701
|
+
+ `${fallback.failed.length > 0 ? ` 失败=${fallback.failed.length}` : ''}`);
|
|
702
|
+
return await runOnce();
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
let result = await runWithImageFallback();
|
|
707
|
+
const failed = result?.failed ?? modelUnavailableOf(result?.reason?.error);
|
|
708
|
+
if (failed && typeof result?.sessionId === 'string' && result.sessionId) {
|
|
709
|
+
const target = await recoverUnavailableModel({ sessionId: result.sessionId, failed, signal });
|
|
710
|
+
if (target) {
|
|
711
|
+
recovered = { failed, target };
|
|
712
|
+
// 换完的模型也可能不收图片:同一条回退路径再走一次。
|
|
713
|
+
result = await runWithImageFallback();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* 超时 ≠ 结束:那一轮**可能之后才跑完**("回合跑完但用户没收到"这条故障线栽过两次)。
|
|
718
|
+
* 这里只登记一条待交付记录,交给延迟交付服务有界复查;不重问、不重跑。
|
|
719
|
+
*/
|
|
720
|
+
if (deferred && result?.reason?.kind === 'timeout' && typeof result.sessionId === 'string') {
|
|
721
|
+
try {
|
|
722
|
+
await deferred.schedule({
|
|
723
|
+
channelId,
|
|
724
|
+
botId,
|
|
725
|
+
key,
|
|
726
|
+
sessionId: result.sessionId,
|
|
727
|
+
turn: result.reason.turn ?? null,
|
|
728
|
+
startedAt,
|
|
729
|
+
reason: 'timeout',
|
|
730
|
+
});
|
|
731
|
+
} catch (error) {
|
|
732
|
+
// 登记失败不该把超时结果本身弄丢:记日志,用户仍会收到"回合未正常结束"。
|
|
733
|
+
logger.warn?.(`[dsh-chat] 登记延迟交付失败:${error?.message ?? error}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// 说明放在答案前面:用户必须知道这次回复换了模型 / 图片是怎么处理的(失败必须可见)。
|
|
737
|
+
const notices = [];
|
|
738
|
+
if (recovered) {
|
|
739
|
+
notices.push(`⚠️ 会话原来选的模型 ${recovered.failed.provider ?? '?'}/${recovered.failed.model ?? '?'}`
|
|
740
|
+
+ ` 已不可用,已自动切到 ${recovered.target.provider}/${recovered.target.model} 并重试了这一轮。`);
|
|
741
|
+
}
|
|
742
|
+
if (imageFallback) {
|
|
743
|
+
notices.push(`⚠️ 当前模型不支持图片输入,已把 ${imageFallback.saved} 张图片作为文件交给会话`
|
|
744
|
+
+ '(用工具分析后回答)'
|
|
745
|
+
+ `${imageFallback.failed.length > 0 ? `;另有 ${imageFallback.failed.length} 张没能交给会话` : ''}。`
|
|
746
|
+
+ '想直接看图,用 /model 换一个支持图片的模型再发一次。');
|
|
747
|
+
}
|
|
748
|
+
if (notices.length === 0) return result;
|
|
749
|
+
return {
|
|
750
|
+
...result,
|
|
751
|
+
text: `${notices.join('\n\n')}\n\n${result.text ?? ''}`.trim(),
|
|
752
|
+
...(recovered ? { recovered } : {}),
|
|
753
|
+
...(imageFallback ? { imageFallback: { saved: imageFallback.saved, failed: imageFallback.failed } } : {}),
|
|
754
|
+
};
|
|
755
|
+
} finally {
|
|
756
|
+
const left = (queueDepth.get(queueKey) ?? 1) - 1;
|
|
757
|
+
if (left <= 0) {
|
|
758
|
+
queueDepth.delete(queueKey);
|
|
759
|
+
turnQueues.delete(queueKey);
|
|
760
|
+
} else {
|
|
761
|
+
queueDepth.set(queueKey, left);
|
|
762
|
+
}
|
|
763
|
+
release();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function runTurn() {
|
|
767
|
+
const { sessionId } = await ensure({
|
|
768
|
+
channelId, botId, key, workspacePath, signal, channelLabel, botLabel,
|
|
769
|
+
});
|
|
770
|
+
// 提示词按会话发布:host 会把它物化成该 Session 的动态提示词上下文。
|
|
771
|
+
guidance?.publish?.(sessionId, sourceGuidance ?? '');
|
|
772
|
+
|
|
773
|
+
const turnKey = `${channelId}:${botId}:${key}`;
|
|
774
|
+
const controller = new AbortController();
|
|
775
|
+
const abort = () => controller.abort();
|
|
776
|
+
signal?.addEventListener?.('abort', abort, { once: true });
|
|
777
|
+
activeTurns.set(turnKey, controller);
|
|
778
|
+
|
|
779
|
+
const frames = await stream('session', 'follow', {
|
|
780
|
+
request: {
|
|
781
|
+
address: { kind: 'session', sessionId },
|
|
782
|
+
maxMessages: 50,
|
|
783
|
+
assistantStream: true,
|
|
784
|
+
},
|
|
785
|
+
}, controller.signal);
|
|
786
|
+
|
|
787
|
+
let cursor = -1;
|
|
788
|
+
let promptSent = false;
|
|
789
|
+
/** 我们自己主动收摊时为 true:此时事件流中断属于正常,不该报成异常。 */
|
|
790
|
+
let closing = false;
|
|
791
|
+
let currentTurn = null;
|
|
792
|
+
const assistantText = new Map();
|
|
793
|
+
const tools = [];
|
|
794
|
+
/**
|
|
795
|
+
* 本轮 agent 通过 `present` 交付的文件(DSH 会 append `deliverables/presented`)。
|
|
796
|
+
* 渠道拿它把成品当附件发出去——只写在回复文字里,用户拿不到文件。
|
|
797
|
+
*/
|
|
798
|
+
const presented = [];
|
|
799
|
+
/**
|
|
800
|
+
* 兜底:从 `present` 工具调用的参数里记下的文件。
|
|
801
|
+
* 万一某个版本的事件流不带 `deliverables/presented`,也不能让交付文件静默丢掉。
|
|
802
|
+
*/
|
|
803
|
+
const presentCalls = [];
|
|
804
|
+
let settled = false;
|
|
805
|
+
let settle;
|
|
806
|
+
const finished = new Promise((resolve) => {
|
|
807
|
+
settle = resolve;
|
|
808
|
+
});
|
|
809
|
+
/**
|
|
810
|
+
* 唯一的收尾入口:任何结束路径都要留下可检索的一行。
|
|
811
|
+
* "回合跑完了但用户没收到"这类问题,就靠这行 + 渠道侧的呈现日志对上。
|
|
812
|
+
*/
|
|
813
|
+
const finishTurn = (value) => {
|
|
814
|
+
if (settled) return;
|
|
815
|
+
settled = true;
|
|
816
|
+
const reason = value?.reason?.kind ?? 'unknown';
|
|
817
|
+
// 事件没来就退回工具参数(两者都按 path 去重,绝不把同一个文件发两遍)。
|
|
818
|
+
const files = presented.length > 0 ? presented : presentCalls;
|
|
819
|
+
logger.info?.(`[dsh-chat] 回合结束:${turnKey} turn=${currentTurn} reason=${reason}`
|
|
820
|
+
+ ` 文本=${(value?.text ?? '').length}字 工具=${value?.tools?.length ?? 0}`
|
|
821
|
+
+ ` 交付文件=${files.length}`);
|
|
822
|
+
settle({ ...value, files: [...files] });
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* 兜底超时:防的是"流断了/回合卡死",**不是**长任务。
|
|
827
|
+
*
|
|
828
|
+
* 真机教训:原先按"整轮总时长 10 分钟"掐,把一次合法的帆软排障(10 分 20 秒、
|
|
829
|
+
* 231 个事件、几次 90 秒的工具调用)在第 620 秒直接中断,用户看到的却是
|
|
830
|
+
* 「任务未正常完成(timeout)」——这是把"卡住"和"干得久"混为一谈了。
|
|
831
|
+
*
|
|
832
|
+
* 现在按**静默时长**判定:只要还有事件进来(工具结果、模型增量都算),就一直等;
|
|
833
|
+
* 连续 IDLE 没有任何进展才判定卡死。另留一个很大的绝对上限兜住死循环。
|
|
834
|
+
*/
|
|
835
|
+
const effectiveIdleTimeoutMs = Number.isFinite(turnTimeoutMs) && turnTimeoutMs > 0
|
|
836
|
+
? turnTimeoutMs
|
|
837
|
+
: TURN_IDLE_TIMEOUT_MS;
|
|
838
|
+
const effectiveTotalTimeoutMs = Number.isFinite(turnTimeoutMs) && turnTimeoutMs > 0
|
|
839
|
+
? Math.max(turnTimeoutMs * 6, TURN_TOTAL_TIMEOUT_MS)
|
|
840
|
+
: TURN_TOTAL_TIMEOUT_MS;
|
|
841
|
+
let lastProgressAt = Date.now();
|
|
842
|
+
let idleTimer = null;
|
|
843
|
+
const markProgress = () => {
|
|
844
|
+
lastProgressAt = Date.now();
|
|
845
|
+
};
|
|
846
|
+
/** 每次"有进展"都重置静默计时;到点说明这条路已经没人往前走了。 */
|
|
847
|
+
function armIdleTimer() {
|
|
848
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
849
|
+
idleTimer = setTimeout(function tick() {
|
|
850
|
+
const idleMs = Date.now() - lastProgressAt;
|
|
851
|
+
if (idleMs >= effectiveIdleTimeoutMs) {
|
|
852
|
+
finishTurn({
|
|
853
|
+
sessionId,
|
|
854
|
+
text: '',
|
|
855
|
+
// 带上 turn:超时后要靠它复查"这一轮"的终态(延迟交付)。
|
|
856
|
+
reason: { kind: 'timeout', turn: currentTurn, idleMs, idleTimeoutMs: effectiveIdleTimeoutMs },
|
|
857
|
+
tools: [...tools],
|
|
858
|
+
aborted: true,
|
|
859
|
+
});
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
idleTimer = setTimeout(tick, Math.max(1_000, effectiveIdleTimeoutMs - idleMs));
|
|
863
|
+
}, effectiveIdleTimeoutMs);
|
|
864
|
+
idleTimer.unref?.();
|
|
865
|
+
}
|
|
866
|
+
armIdleTimer();
|
|
867
|
+
const totalTimer = setTimeout(() => {
|
|
868
|
+
finishTurn({
|
|
869
|
+
sessionId,
|
|
870
|
+
text: '',
|
|
871
|
+
reason: {
|
|
872
|
+
kind: 'timeout', turn: currentTurn,
|
|
873
|
+
timeoutMs: effectiveTotalTimeoutMs, idleMs: Date.now() - lastProgressAt,
|
|
874
|
+
},
|
|
875
|
+
tools: [...tools],
|
|
876
|
+
aborted: true,
|
|
877
|
+
});
|
|
878
|
+
}, effectiveTotalTimeoutMs);
|
|
879
|
+
totalTimer.unref?.();
|
|
880
|
+
|
|
881
|
+
const pump = (async () => {
|
|
882
|
+
try {
|
|
883
|
+
for await (const frame of frames) {
|
|
884
|
+
// 任何一帧(工具结果、模型增量、状态事件)都算"还在往前走"。
|
|
885
|
+
markProgress();
|
|
886
|
+
if (frame?.type === 'snapshot') {
|
|
887
|
+
cursor = Number.isInteger(frame.cursor) ? frame.cursor : cursor;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (frame?.type === 'assistant-stream') {
|
|
891
|
+
const inner = frame.frame;
|
|
892
|
+
if (inner?.type === 'chunk' && inner.chunk?.type === 'text-delta') {
|
|
893
|
+
const text = deltaTextOf(inner.chunk);
|
|
894
|
+
if (text) handlers.onDelta?.(text, inner);
|
|
895
|
+
}
|
|
896
|
+
handlers.onEvent?.(frame);
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const event = frame?.event;
|
|
900
|
+
if (!event) continue;
|
|
901
|
+
if (Number.isInteger(event.seq)) {
|
|
902
|
+
if (event.seq <= cursor) continue; // 重开流时去重
|
|
903
|
+
cursor = event.seq;
|
|
904
|
+
}
|
|
905
|
+
handlers.onEvent?.(event);
|
|
906
|
+
switch (event.type) {
|
|
907
|
+
case 'turn/start':
|
|
908
|
+
currentTurn = event.data?.turn ?? null;
|
|
909
|
+
assistantText.set(currentTurn, []);
|
|
910
|
+
handlers.onTurnStart?.(event);
|
|
911
|
+
break;
|
|
912
|
+
case 'assistant/message': {
|
|
913
|
+
const turn = event.data?.turn ?? currentTurn;
|
|
914
|
+
const text = textOfAssistantMessage(event.data?.message);
|
|
915
|
+
if (text) {
|
|
916
|
+
const bucket = assistantText.get(turn) ?? [];
|
|
917
|
+
bucket.push(text);
|
|
918
|
+
assistantText.set(turn, bucket);
|
|
919
|
+
}
|
|
920
|
+
handlers.onAssistantMessage?.(event, text);
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
case 'tool/call':
|
|
924
|
+
tools.push({ name: event.data?.name, arguments: event.data?.arguments });
|
|
925
|
+
if (event.data?.name === 'present') {
|
|
926
|
+
for (const file of filesOfPresentArgs(event.data?.arguments)) {
|
|
927
|
+
if (!presentCalls.some((seen) => seen.path === file.path)) presentCalls.push(file);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
handlers.onToolCall?.(event);
|
|
931
|
+
break;
|
|
932
|
+
case 'tool/result':
|
|
933
|
+
handlers.onToolResult?.(event, tools.at(-1));
|
|
934
|
+
break;
|
|
935
|
+
case 'deliverables/presented': {
|
|
936
|
+
const files = Array.isArray(event.data?.files) ? event.data.files : [];
|
|
937
|
+
const accepted = [];
|
|
938
|
+
for (const file of files) {
|
|
939
|
+
if (typeof file?.path !== 'string' || !file.path) continue;
|
|
940
|
+
accepted.push({
|
|
941
|
+
path: file.path,
|
|
942
|
+
...(typeof file.description === 'string' && file.description
|
|
943
|
+
? { description: file.description }
|
|
944
|
+
: {}),
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
presented.push(...accepted);
|
|
948
|
+
if (accepted.length > 0) handlers.onDeliverables?.(accepted);
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case 'turn/end': {
|
|
952
|
+
const turn = event.data?.turn ?? currentTurn;
|
|
953
|
+
const texts = assistantText.get(turn) ?? [];
|
|
954
|
+
/**
|
|
955
|
+
* 一轮里每个 step 各有一条定稿 `assistant/message`:**全部带回**,用空行隔开。
|
|
956
|
+
*
|
|
957
|
+
* 曾经只取 `texts.at(-1)`(最后一个 step),真机表现是"多步回答只剩最后一段"——
|
|
958
|
+
* 前面写在工具调用之前的正文整个丢了(上游 Issue #112 是同一个根因)。
|
|
959
|
+
* 模型偶尔会把同一段话再说一遍,所以相邻完全相同的段只留一次,不贴两遍。
|
|
960
|
+
*/
|
|
961
|
+
const merged = [];
|
|
962
|
+
for (const piece of texts) {
|
|
963
|
+
const trimmed = String(piece ?? '').trim();
|
|
964
|
+
if (!trimmed || merged.at(-1) === trimmed) continue;
|
|
965
|
+
merged.push(trimmed);
|
|
966
|
+
}
|
|
967
|
+
const text = merged.join('\n\n').slice(0, MAX_ASSISTANT_TEXT);
|
|
968
|
+
handlers.onTurnEnd?.(event, text);
|
|
969
|
+
assistantText.delete(turn);
|
|
970
|
+
if (promptSent) {
|
|
971
|
+
finishTurn({
|
|
972
|
+
sessionId,
|
|
973
|
+
text,
|
|
974
|
+
reason: event.data?.reason ?? null,
|
|
975
|
+
tools: [...tools],
|
|
976
|
+
aborted: false,
|
|
977
|
+
});
|
|
978
|
+
} else {
|
|
979
|
+
// 提示词还没发出去就收到了 turn/end:属于上一轮(可能是重启前中断的那轮)的尾巴。
|
|
980
|
+
logger.info?.(`[dsh-chat] 忽略提示词之前的 turn/end:${turnKey} turn=${turn}`);
|
|
981
|
+
}
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
default:
|
|
985
|
+
break;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
finishTurn({
|
|
989
|
+
sessionId,
|
|
990
|
+
text: '',
|
|
991
|
+
reason: { kind: 'stream-ended' },
|
|
992
|
+
tools: [...tools],
|
|
993
|
+
files: [...presented],
|
|
994
|
+
aborted: false,
|
|
995
|
+
});
|
|
996
|
+
} catch (error) {
|
|
997
|
+
const wasSettled = settled;
|
|
998
|
+
finishTurn({
|
|
999
|
+
sessionId,
|
|
1000
|
+
text: '',
|
|
1001
|
+
reason: { kind: 'error', error: sessionError(error) },
|
|
1002
|
+
tools: [...tools],
|
|
1003
|
+
files: [...presented],
|
|
1004
|
+
aborted: true,
|
|
1005
|
+
});
|
|
1006
|
+
if (wasSettled && !closing) {
|
|
1007
|
+
logger.warn?.(`[dsh-chat] 会话 ${sessionId} 的事件流中断:${error?.message ?? error}`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
})();
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
promptSent = true;
|
|
1014
|
+
logger.info?.(`[dsh-chat] 发送提示词:${turnKey} 会话=${sessionId}`
|
|
1015
|
+
+ ` 内容=${content.map((part) => part?.type ?? '?').join('+')} mode=${mode}`);
|
|
1016
|
+
// 提示词的返回值只是"投递收据":回合结束不该等它(它可能迟迟不回),
|
|
1017
|
+
// 但它失败时必须立刻抛出来,否则消息会像被吞掉一样。
|
|
1018
|
+
const receiptFailure = prompt({ sessionId, content, mode, signal: controller.signal })
|
|
1019
|
+
.then(() => new Promise(() => {}), (error) => ({ error }));
|
|
1020
|
+
const first = await Promise.race([
|
|
1021
|
+
finished.then((value) => ({ value })),
|
|
1022
|
+
receiptFailure,
|
|
1023
|
+
]);
|
|
1024
|
+
if (first.error) throw first.error;
|
|
1025
|
+
return first.value;
|
|
1026
|
+
} finally {
|
|
1027
|
+
clearTimeout(totalTimer);
|
|
1028
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1029
|
+
// 回合结束后标题已经生成,这时标渠道前缀最稳(幂等:每个会话只做一次)。
|
|
1030
|
+
void markSessionChannel(sessionId, { channelLabel, chatLabel });
|
|
1031
|
+
signal?.removeEventListener?.('abort', abort);
|
|
1032
|
+
activeTurns.delete(turnKey);
|
|
1033
|
+
closing = true;
|
|
1034
|
+
// 主动中止这条订阅,然后用一个有界的宽限时间等它收摊:宁可放手,也不能卡住返回值。
|
|
1035
|
+
try {
|
|
1036
|
+
controller.abort();
|
|
1037
|
+
} catch {
|
|
1038
|
+
// 已经中止过。
|
|
1039
|
+
}
|
|
1040
|
+
const closing0 = typeof frames?.return === 'function' ? frames.return() : null;
|
|
1041
|
+
if (closing0) {
|
|
1042
|
+
let graceTimer;
|
|
1043
|
+
try {
|
|
1044
|
+
await Promise.race([
|
|
1045
|
+
Promise.resolve(closing0).catch(() => {}),
|
|
1046
|
+
// 故意不 unref:这是"让调用方拿到结果"的兜底时限,必须真的会到点。
|
|
1047
|
+
new Promise((resolve) => {
|
|
1048
|
+
graceTimer = setTimeout(resolve, STREAM_CLOSE_GRACE_MS);
|
|
1049
|
+
}),
|
|
1050
|
+
]);
|
|
1051
|
+
} finally {
|
|
1052
|
+
clearTimeout(graceTimer);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
void pump;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* 注册某渠道的"人在环"处理器:审批与提问经它回传到 IM。
|
|
1062
|
+
*
|
|
1063
|
+
* @param channelId - 渠道 id。
|
|
1064
|
+
* @param handle - async ({ kind, channelId, botId, key, request }) =>
|
|
1065
|
+
* 审批返回 'allowed-once'|'rejected'|'cancelled';提问返回 `{ answers }`。
|
|
1066
|
+
* @returns 注销函数。
|
|
1067
|
+
*/
|
|
1068
|
+
|
|
1069
|
+
/** 在 root 上参与审批/提问的 waterfall;只接管自己名下的会话,其余委派给浏览器 UI。 */
|
|
1070
|
+
function installInteractionRelays() {
|
|
1071
|
+
if (typeof ctx?.on !== 'function') {
|
|
1072
|
+
logger.warn?.('[dsh-chat] 当前 Host 不支持事件订阅,审批/提问无法回传到 IM。');
|
|
1073
|
+
return () => {};
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* 只有"这条会话属于某个已绑定的 IM 会话"且"该渠道接入了 IM 回传"时才认领,
|
|
1077
|
+
* 否则一律让给浏览器 UI——绝不能把一个没人能回答的问题留在 IM 里卡住整轮。
|
|
1078
|
+
*/
|
|
1079
|
+
const locateFor = (request) => {
|
|
1080
|
+
if (typeof interactions?.handle !== 'function') return null;
|
|
1081
|
+
const sessionId = request?.agent?.session?.id;
|
|
1082
|
+
const located = store?.locate?.(sessionId);
|
|
1083
|
+
if (!located) return null;
|
|
1084
|
+
return interactions.has?.(located.channelId) ? located : null;
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// 必须**前置注册**:浏览器的应答器(api-remotes 转发器)注册得更早,一旦轮到它会
|
|
1088
|
+
// 把提问扣在网页 UI 上等回答(forwardWaterfall 直到浏览器答复或拒绝才继续),
|
|
1089
|
+
// 于是 IM 这条中继永远轮不到——真机上就是这样:日志里既没有"已发往 IM"也没有
|
|
1090
|
+
// "回传失败",问题只出现在网页里。
|
|
1091
|
+
const offApproval = ctx.on('approval/request', async (request, next) => {
|
|
1092
|
+
const target = locateFor(request);
|
|
1093
|
+
logger.info?.(`[dsh-chat] 收到审批请求:会话=${request?.agent?.session?.id ?? '未知'}`
|
|
1094
|
+
+ ` 工具=${request?.toolName ?? '?'} 认领=${target ? '是' : '否'}`);
|
|
1095
|
+
if (!target) return next();
|
|
1096
|
+
try {
|
|
1097
|
+
const outcome = await interactions.handle({
|
|
1098
|
+
kind: 'approval',
|
|
1099
|
+
channelId: target.channelId,
|
|
1100
|
+
botId: target.botId,
|
|
1101
|
+
key: target.key,
|
|
1102
|
+
request,
|
|
1103
|
+
});
|
|
1104
|
+
return outcome ?? next();
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
logger.warn?.(`[dsh-chat] 审批回传失败,交由其他应答方:${error?.message ?? error}`);
|
|
1107
|
+
return next();
|
|
1108
|
+
}
|
|
1109
|
+
}, { prepend: true });
|
|
1110
|
+
|
|
1111
|
+
const offQuestions = ctx.on('user-questions/request', async (request, next) => {
|
|
1112
|
+
const target = locateFor(request);
|
|
1113
|
+
logger.info?.(`[dsh-chat] 收到提问请求:会话=${request?.agent?.session?.id ?? '未知'}`
|
|
1114
|
+
+ ` 问题数=${request?.questions?.length ?? 0} 认领=${target ? '是' : '否'}`);
|
|
1115
|
+
if (!target) return next();
|
|
1116
|
+
try {
|
|
1117
|
+
const answers = await interactions.handle({
|
|
1118
|
+
kind: 'question',
|
|
1119
|
+
channelId: target.channelId,
|
|
1120
|
+
botId: target.botId,
|
|
1121
|
+
key: target.key,
|
|
1122
|
+
request,
|
|
1123
|
+
});
|
|
1124
|
+
if (!answers) return next();
|
|
1125
|
+
return answers;
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
logger.warn?.(`[dsh-chat] 提问回传失败,交由其他应答方:${error?.message ?? error}`);
|
|
1128
|
+
return next();
|
|
1129
|
+
}
|
|
1130
|
+
}, { prepend: true });
|
|
1131
|
+
|
|
1132
|
+
return () => {
|
|
1133
|
+
try {
|
|
1134
|
+
offApproval?.();
|
|
1135
|
+
} catch { /* 已释放 */ }
|
|
1136
|
+
try {
|
|
1137
|
+
offQuestions?.();
|
|
1138
|
+
} catch { /* 已释放 */ }
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* 上传一段字节到指定会话,返回可放进提示词的 `{ receiptId, file }`。
|
|
1144
|
+
*
|
|
1145
|
+
* `fileUploads` 是 DSH 的可选服务(没装就没有);缺席时给出可读错误,
|
|
1146
|
+
* 调用方据此把"这个渠道暂时收不了文件"告诉用户,而不是静默丢消息。
|
|
1147
|
+
*
|
|
1148
|
+
* @param options - { sessionId, name, bytes, signal }。
|
|
1149
|
+
* @returns { receiptId, file }。
|
|
1150
|
+
*/
|
|
1151
|
+
async function uploadFile({ sessionId, name, bytes, signal }) {
|
|
1152
|
+
const service = typeof ctx?.get === 'function' ? ctx.get('fileUploads') : undefined;
|
|
1153
|
+
if (typeof service?.uploadStream !== 'function') {
|
|
1154
|
+
const error = new Error('当前 Host 没有 fileUploads 服务,无法把文件交给会话。');
|
|
1155
|
+
error.code = 'chat/upload-unavailable';
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
1159
|
+
const error = new Error('上传文件需要 sessionId。');
|
|
1160
|
+
error.code = 'chat/bad-request';
|
|
1161
|
+
throw error;
|
|
1162
|
+
}
|
|
1163
|
+
const data = bytes instanceof Uint8Array ? bytes : null;
|
|
1164
|
+
if (!data || data.byteLength === 0) {
|
|
1165
|
+
const error = new Error('上传文件的内容为空。');
|
|
1166
|
+
error.code = 'chat/bad-request';
|
|
1167
|
+
throw error;
|
|
1168
|
+
}
|
|
1169
|
+
try {
|
|
1170
|
+
return await service.uploadStream({
|
|
1171
|
+
sessionId,
|
|
1172
|
+
name: typeof name === 'string' && name.trim() ? name.trim() : undefined,
|
|
1173
|
+
data: (async function* chunks() { yield data; })(),
|
|
1174
|
+
signal,
|
|
1175
|
+
});
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
throw fileUploadFailure(error);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* 把这一轮里的图片块换成"本会话的文件"(`{ type:'file', receiptId }`)。
|
|
1183
|
+
*
|
|
1184
|
+
* 只在 `session/prompt` 因模型不支持图片被拒后调用一次:字节不变、只是换一条路——
|
|
1185
|
+
* 纯文本模型拿到的是"只读副本已保存在 <path>",可以用工具去分析。
|
|
1186
|
+
*
|
|
1187
|
+
* @returns `{ content, saved, failed }`;一张都没存下时返回 null(由调用方原样抛错)。
|
|
1188
|
+
*/
|
|
1189
|
+
async function imagesAsFiles({ sessionId, content: parts, signal }) {
|
|
1190
|
+
const images = parts.filter((part) => part?.type === 'image');
|
|
1191
|
+
if (images.length === 0) return null;
|
|
1192
|
+
const files = [];
|
|
1193
|
+
const failed = [];
|
|
1194
|
+
for (const [index, part] of images.entries()) {
|
|
1195
|
+
const name = imageFileName(part.name, part.mediaType, index);
|
|
1196
|
+
try {
|
|
1197
|
+
const uploaded = await uploadFile({
|
|
1198
|
+
sessionId,
|
|
1199
|
+
name,
|
|
1200
|
+
bytes: Buffer.from(typeof part.data === 'string' ? part.data : '', 'base64'),
|
|
1201
|
+
signal,
|
|
1202
|
+
});
|
|
1203
|
+
if (!uploaded?.receiptId) throw new Error('上传后没有拿到 receiptId');
|
|
1204
|
+
files.push({ type: 'file', receiptId: uploaded.receiptId });
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
failed.push({ name, reason: error?.message ?? String(error) });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (files.length === 0) return null;
|
|
1210
|
+
const lines = [
|
|
1211
|
+
`当前会话模型不支持直接接收图片输入。用户发的 ${files.length} 张图片已作为只读文件保存到会话`
|
|
1212
|
+
+ '(见下面的文件说明)。请用可用工具读取这些图片文件后回答(例如用代码读取字节、解析元数据、'
|
|
1213
|
+
+ '调用图像处理或 OCR 库),**不要假设自己能直接看到图片内容**。',
|
|
1214
|
+
];
|
|
1215
|
+
if (failed.length > 0) {
|
|
1216
|
+
lines.push(`另有 ${failed.length} 张图片没能保存:`
|
|
1217
|
+
+ failed.map((row) => `${row.name}(${row.reason})`).join('、') + '。');
|
|
1218
|
+
}
|
|
1219
|
+
return {
|
|
1220
|
+
// 原始的文本/上下文块保持在原位,图片位置换成文件,最后补一段"该怎么用它们"。
|
|
1221
|
+
content: [...parts.filter((part) => part?.type !== 'image'), ...files, { type: 'text', text: lines.join('') }],
|
|
1222
|
+
saved: files.length,
|
|
1223
|
+
failed,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* 读最近的对话历史(`/history` 用)。
|
|
1229
|
+
*
|
|
1230
|
+
* 走 `session/follow` 的**首个 snapshot**:它一次就把尾部 N 条记录和游标都给了我们
|
|
1231
|
+
* (`session/page` 需要先知道会话的 seq,而 seq 只能从 follow 的 snapshot 里拿,
|
|
1232
|
+
* 用 `throughSeq: -1` 只会拿到空页——那是"探测会话是否存在"的用法)。
|
|
1233
|
+
* 读完立刻关流,且关流**必须有界**——否则一条命令就能把聊天卡住。
|
|
1234
|
+
*
|
|
1235
|
+
* @param options - { channelId, botId, key, maxMessages, signal }。
|
|
1236
|
+
* @returns `{ sessionId, messages }`;没有绑定会话时 sessionId 为 null。
|
|
1237
|
+
*/
|
|
1238
|
+
async function history({ channelId, botId, key, maxMessages = 12, signal } = {}) {
|
|
1239
|
+
const bound = store?.get?.(channelId, botId, key);
|
|
1240
|
+
if (!bound?.sessionId) return { sessionId: null, messages: [] };
|
|
1241
|
+
const controller = new AbortController();
|
|
1242
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
1243
|
+
if (signal?.aborted) throw abortError(signal);
|
|
1244
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
1245
|
+
let frames = null;
|
|
1246
|
+
try {
|
|
1247
|
+
frames = await stream('session', 'follow', {
|
|
1248
|
+
request: {
|
|
1249
|
+
address: { kind: 'session', sessionId: bound.sessionId },
|
|
1250
|
+
maxMessages: Math.max(1, Math.min(50, maxMessages)),
|
|
1251
|
+
// 注意:wire 上 `assistantStream` 只接受 `true`(或省略),传 false 会被
|
|
1252
|
+
// 边界校验直接拒掉。历史只需要 snapshot,所以这里不传。
|
|
1253
|
+
},
|
|
1254
|
+
}, controller.signal);
|
|
1255
|
+
let records = [];
|
|
1256
|
+
for await (const frame of frames) {
|
|
1257
|
+
if (frame?.type === 'snapshot') {
|
|
1258
|
+
records = Array.isArray(frame.records) ? frame.records : [];
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return { sessionId: bound.sessionId, messages: historyMessagesOf(records, maxMessages) };
|
|
1263
|
+
} finally {
|
|
1264
|
+
controller.abort();
|
|
1265
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
1266
|
+
if (frames && typeof frames.return === 'function') {
|
|
1267
|
+
// 关流可以慢,但不能永远不回:拿到历史就先返回(与 ask 的收尾同一套有界策略)。
|
|
1268
|
+
const closing = Promise.resolve(frames.return()).catch(() => {});
|
|
1269
|
+
await Promise.race([
|
|
1270
|
+
closing,
|
|
1271
|
+
new Promise((resolve) => { setTimeout(resolve, STREAM_CLOSE_GRACE_MS); }),
|
|
1272
|
+
]);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* 执行一条 DSH 斜杠命令(不经过模型,例如 `/compact`)。
|
|
1279
|
+
*
|
|
1280
|
+
* @param options - { channelId, botId, key, line, signal }。
|
|
1281
|
+
* @returns `{ matched, kind, text }`;`matched=false` 表示当前部署没注册这条命令。
|
|
1282
|
+
*/
|
|
1283
|
+
async function runCommand({ channelId, botId, key, line, signal } = {}) {
|
|
1284
|
+
const bound = store?.get?.(channelId, botId, key);
|
|
1285
|
+
if (!bound?.sessionId) {
|
|
1286
|
+
const error = new Error('当前聊天还没有会话(先发一条消息即可创建)。');
|
|
1287
|
+
error.code = 'chat/session-required';
|
|
1288
|
+
throw error;
|
|
1289
|
+
}
|
|
1290
|
+
const result = await invoke('commands', 'execute', {
|
|
1291
|
+
agentId: bound.sessionId,
|
|
1292
|
+
line,
|
|
1293
|
+
submittedAttachments: [],
|
|
1294
|
+
}, signal);
|
|
1295
|
+
if (result === undefined || result === null) return { matched: false };
|
|
1296
|
+
return {
|
|
1297
|
+
matched: true,
|
|
1298
|
+
commandId: result.commandId,
|
|
1299
|
+
kind: result.result?.kind ?? 'error',
|
|
1300
|
+
text: result.result?.text ?? '',
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
return Object.freeze({
|
|
1305
|
+
invoke,
|
|
1306
|
+
stream,
|
|
1307
|
+
uploadFile,
|
|
1308
|
+
resolveWorkspaceId,
|
|
1309
|
+
sessionExists,
|
|
1310
|
+
ensure,
|
|
1311
|
+
prompt,
|
|
1312
|
+
ask,
|
|
1313
|
+
cancel,
|
|
1314
|
+
isRunning,
|
|
1315
|
+
rename,
|
|
1316
|
+
markSessionChannel,
|
|
1317
|
+
boundSessions,
|
|
1318
|
+
/**
|
|
1319
|
+
* 逐轮终态探针(延迟交付用):会话在不在、还在跑吗、最后一条助手正文是什么。
|
|
1320
|
+
*
|
|
1321
|
+
* 只读叶子字段,不碰活对象;会话没了返回 `exists:false`,还让调用方据此清记录。
|
|
1322
|
+
*/
|
|
1323
|
+
probeTurn: async ({ channelId, botId, key, sessionId, maxMessages = 4 } = {}) => {
|
|
1324
|
+
const listed = await invoke('session', 'list', { _request: {} });
|
|
1325
|
+
const item = (listed?.items ?? []).find((entry) => entry?.sessionId === sessionId);
|
|
1326
|
+
if (!item) return { exists: false, running: false, text: '' };
|
|
1327
|
+
if (item.running === true) return { exists: true, running: true, text: '' };
|
|
1328
|
+
/**
|
|
1329
|
+
* 这个聊天必须**仍然绑着原会话**才读历史并补发:换绑/解绑之后,
|
|
1330
|
+
* 把旧会话的结果发到新会话里是错的(上游同一条规则)。
|
|
1331
|
+
*/
|
|
1332
|
+
const bound = store?.get?.(channelId, botId, key);
|
|
1333
|
+
if (!bound || bound.sessionId !== sessionId) {
|
|
1334
|
+
return { exists: true, running: false, text: '', rebound: true };
|
|
1335
|
+
}
|
|
1336
|
+
const { messages } = await history({ channelId, botId, key, maxMessages });
|
|
1337
|
+
const last = [...messages].reverse()
|
|
1338
|
+
.find((row) => row.role === 'assistant' && String(row.text ?? '').trim());
|
|
1339
|
+
return { exists: true, running: false, text: last?.text ?? '' };
|
|
1340
|
+
},
|
|
1341
|
+
reset,
|
|
1342
|
+
history,
|
|
1343
|
+
runCommand,
|
|
1344
|
+
/** 会话绑定表:渠道可用它接管旧实现的绑定(`adopt`)。 */
|
|
1345
|
+
bindings: store,
|
|
1346
|
+
installInteractionRelays,
|
|
1347
|
+
});
|
|
1348
|
+
}
|