@yfwu2020/dsh-selection-explain 0.10.3 → 0.10.5

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 (119) hide show
  1. package/README.md +13 -11
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +10694 -12424
  4. package/lib/client.js.map +7 -0
  5. package/lib/config-values.js +55 -0
  6. package/lib/config-values.js.map +1 -0
  7. package/lib/host/analyze.js +795 -0
  8. package/lib/host/analyze.js.map +1 -0
  9. package/lib/host/config.js +81 -0
  10. package/lib/host/config.js.map +1 -0
  11. package/lib/host/constants.js +25 -0
  12. package/lib/host/constants.js.map +1 -0
  13. package/lib/host/context.js +237 -0
  14. package/lib/host/context.js.map +1 -0
  15. package/lib/host/design-skill.js +42 -0
  16. package/lib/host/design-skill.js.map +1 -0
  17. package/lib/host/effort.js +239 -0
  18. package/lib/host/effort.js.map +1 -0
  19. package/lib/host/filters.js +132 -0
  20. package/lib/host/filters.js.map +1 -0
  21. package/lib/host/history-store.js +404 -0
  22. package/lib/host/history-store.js.map +1 -0
  23. package/lib/host/http.js +46 -0
  24. package/lib/host/http.js.map +1 -0
  25. package/lib/host/messages.js +14 -0
  26. package/lib/host/messages.js.map +1 -0
  27. package/lib/host/mic.js +246 -0
  28. package/lib/host/mic.js.map +1 -0
  29. package/lib/host/models.js +246 -0
  30. package/lib/host/models.js.map +1 -0
  31. package/lib/host/plugin.js +41 -0
  32. package/lib/host/plugin.js.map +1 -0
  33. package/lib/host/promote.js +219 -0
  34. package/lib/host/promote.js.map +1 -0
  35. package/lib/host/routes.js +237 -0
  36. package/lib/host/routes.js.map +1 -0
  37. package/lib/host/sessions.js +181 -0
  38. package/lib/host/sessions.js.map +1 -0
  39. package/lib/host/settings-store.js +140 -0
  40. package/lib/host/settings-store.js.map +1 -0
  41. package/lib/host/speech.js +320 -0
  42. package/lib/host/speech.js.map +1 -0
  43. package/lib/host/tools.js +179 -0
  44. package/lib/host/tools.js.map +1 -0
  45. package/lib/host/types.js +2 -0
  46. package/lib/host/types.js.map +1 -0
  47. package/lib/index.js +10 -3691
  48. package/lib/index.js.map +1 -1
  49. package/lib/prompts.js +1 -1
  50. package/lib/settings.js +17 -27
  51. package/lib/settings.js.map +1 -1
  52. package/lib/types/config-values.d.ts +175 -0
  53. package/lib/types/host/analyze.d.ts +10 -0
  54. package/lib/types/host/config.d.ts +147 -0
  55. package/lib/types/host/constants.d.ts +24 -0
  56. package/lib/types/host/context.d.ts +41 -0
  57. package/lib/types/host/design-skill.d.ts +1 -0
  58. package/lib/types/host/effort.d.ts +61 -0
  59. package/lib/types/host/filters.d.ts +20 -0
  60. package/lib/types/host/history-store.d.ts +13 -0
  61. package/lib/types/host/http.d.ts +8 -0
  62. package/lib/types/host/messages.d.ts +13 -0
  63. package/lib/types/host/mic.d.ts +17 -0
  64. package/lib/types/host/models.d.ts +57 -0
  65. package/lib/types/host/plugin.d.ts +3 -0
  66. package/lib/types/host/promote.d.ts +9 -0
  67. package/lib/types/host/routes.d.ts +14 -0
  68. package/lib/types/host/sessions.d.ts +14 -0
  69. package/lib/types/host/settings-store.d.ts +8 -0
  70. package/lib/types/host/speech.d.ts +23 -0
  71. package/lib/types/host/tools.d.ts +39 -0
  72. package/lib/types/host/types.d.ts +89 -0
  73. package/lib/types/index.d.ts +12 -441
  74. package/lib/types/prompts.d.ts +1 -1
  75. package/lib/types/settings.d.ts +1 -1
  76. package/package.json +31 -21
  77. package/assets/code.png +0 -0
  78. package/assets/detail.png +0 -0
  79. package/assets/quotes.png +0 -0
  80. package/assets/stage1.png +0 -0
  81. package/assets/stage2.png +0 -0
  82. package/assets/translation.png +0 -0
  83. package/assets/voice-card-light.png +0 -0
  84. package/assets/voice-inserted.png +0 -0
  85. package/assets/voice-settings.png +0 -0
  86. package/assets/voice.png +0 -0
  87. package/assets/web.png +0 -0
  88. package/scripts/build-demo-pages.mjs +0 -350
  89. package/scripts/build-vcard-lab.mjs +0 -834
  90. package/scripts/build.sh +0 -147
  91. package/scripts/crop-png.mjs +0 -142
  92. package/scripts/dump-prompt.mjs +0 -143
  93. package/scripts/fixtures/fake-mic-probe.sh +0 -9
  94. package/scripts/gen-star-path.mjs +0 -71
  95. package/scripts/measure-cost.mjs +0 -251
  96. package/scripts/smoke-bridge.mjs +0 -365
  97. package/scripts/smoke-panel.mjs +0 -1903
  98. package/scripts/smoke-voice.mjs +0 -774
  99. package/scripts/test-bridge.mjs +0 -412
  100. package/scripts/test-client.mjs +0 -6175
  101. package/scripts/test-effort-map.mjs +0 -129
  102. package/scripts/test-effort-retry.mjs +0 -109
  103. package/scripts/test-escape-focus.mjs +0 -101
  104. package/scripts/test-filters.mjs +0 -146
  105. package/scripts/test-format.mjs +0 -93
  106. package/scripts/test-guard.mjs +0 -48
  107. package/scripts/test-mic-idle.mjs +0 -158
  108. package/scripts/test-model-follow.mjs +0 -193
  109. package/scripts/test-prompt.mjs +0 -193
  110. package/scripts/test-route-cache.mjs +0 -138
  111. package/scripts/test-settings-behavior.mjs +0 -188
  112. package/scripts/test-settings-host.mjs +0 -48
  113. package/scripts/test-settings.mjs +0 -329
  114. package/scripts/test-speech.mjs +0 -220
  115. package/scripts/test-transcript.mjs +0 -151
  116. package/src/client/index.js +0 -12580
  117. package/src/index.ts +0 -4179
  118. package/src/prompts.ts +0 -144
  119. package/src/settings.ts +0 -313
@@ -0,0 +1,795 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sendJson, readBody, BODY_LIMIT, sse } from './http.js';
3
+ import { sessionIdOf } from './context.js';
4
+ import { createAssistantMessage, createUserMessage, ReasoningEffortId, BlockAssembler, createToolResultMessage } from '@deepseek-ai/dsh-llm';
5
+ import { name } from './constants.js';
6
+ import { resolveToolNames, webFetchUsable, FS_TOOL_NAMES, describeToolCall, isInsideRoot, runTool, digestToolResult } from './tools.js';
7
+ import { CHAT_SYSTEM_PROMPT, DETAIL_PROMPT, CODE_PROMPT, TRANSLATION_PROMPT } from '../prompts.js';
8
+ import { loadDesignSkill } from './design-skill.js';
9
+ import { systemMessageOf } from './messages.js';
10
+ import { createThinkFilter, splitLeakedReasoning, looksLikeLeakedReasoning } from './filters.js';
11
+ import { looksLikeReasoningRequired, looksLikeUnsupportedTier, EFFORT_LABEL } from './effort.js';
12
+ /** Owns analyze state for one plugin instance. */
13
+ export function createAnalyze(ctx, config, models, sessions, historyStore) {
14
+ /** 分钟级限流。 */
15
+ const hits = [];
16
+ const allow = () => {
17
+ const now = Date.now();
18
+ while (hits.length > 0 && now - hits[0] > 60_000)
19
+ hits.shift();
20
+ if (hits.length >= config.maxRequestsPerMinute)
21
+ return false;
22
+ hits.push(now);
23
+ return true;
24
+ };
25
+ /**
26
+ * 共享结果缓存(进程级,跨标签页/刷新可用):
27
+ * 同会话 + 同选中文字 + 同局部片段 + 同会话背景 → 直接回放上次结果,不再调用模型。
28
+ */
29
+ const resultCache = new Map();
30
+ const resultKeyOf = (provider, model, sessionId, backgroundText, text, context) => createHash('sha1').update([provider, model, sessionId, backgroundText, text, context].join('\u0000')).digest('hex');
31
+ const handleAnalyze = async (req, res) => {
32
+ if (req.method !== 'POST') {
33
+ sendJson(res, 405, { error: '只支持 POST' });
34
+ return;
35
+ }
36
+ let body;
37
+ try {
38
+ body = JSON.parse(await readBody(req, BODY_LIMIT));
39
+ }
40
+ catch (error) {
41
+ sendJson(res, 400, { error: `请求体解析失败:${String(error?.message ?? error)}` });
42
+ return;
43
+ }
44
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
45
+ if (text.length === 0) {
46
+ sendJson(res, 400, { error: '缺少 text(选中文字)' });
47
+ return;
48
+ }
49
+ if (text.length > config.maxSelectionChars) {
50
+ sendJson(res, 413, { error: `选中文字过长(${text.length} > ${config.maxSelectionChars} 字符)` });
51
+ return;
52
+ }
53
+ if (!allow()) {
54
+ sendJson(res, 429, { error: `请求过于频繁(上限 ${config.maxRequestsPerMinute}/分钟)` });
55
+ return;
56
+ }
57
+ const override = typeof body.provider === 'string' && typeof body.model === 'string' && body.provider && body.model
58
+ ? { provider: body.provider, model: body.model }
59
+ : undefined;
60
+ // 名字避开下面的 isChat(那里判的是"提示词用追问那套",此处判的是"模型用追问那个")
61
+ const isChatRound = typeof body.question === 'string' && body.question.trim().length > 0;
62
+ const route = models.resolveRoute(override, await sessions.sessionModelOf(sessionIdOf(body)), isChatRound);
63
+ if (!route) {
64
+ sendJson(res, 503, { error: '尚未确定模型路由:请先在设置里选择默认模型后重试' });
65
+ return;
66
+ }
67
+ const context = historyStore.clampText(body.context, config.maxContextChars);
68
+ const label = historyStore.clampText(body.label, 80);
69
+ // 页面标题/地址不写进提示词:模型会把它当成「选中文字里的英文」去翻译(实测把标题 DSH 译了一遍)
70
+ // 会话背景:从 sessionQuery 读取当前会话的模型界面(live 优先,含持久化会话)
71
+ let background = null;
72
+ if (config.sessionContext) {
73
+ const sessionId = sessionIdOf(body);
74
+ if (sessionId) {
75
+ // 背景预算按阶段:首轮只要翻译(8 条够定指代);详解与追问都是深度交互,吃满 24 条
76
+ const stage = typeof body.stage === 'string' ? body.stage : '';
77
+ const wantsFull = stage === 'detail' || typeof body.question === 'string';
78
+ background = await sessions.loadSessionBackground(sessionId, {
79
+ maxMessages: wantsFull ? config.sessionContextMaxMessages : config.sessionContextFastMessages,
80
+ maxChars: config.sessionContextMaxChars,
81
+ marker: text,
82
+ });
83
+ }
84
+ }
85
+ // 顺序即缓存策略:前缀必须“只增不变”才能复用 ——
86
+ // ① 会话背景(同一会话内基本不变)→ ② 固定说明 → ③ 动态尾部(选中文字/局部片段)
87
+ // 反过来写(动态在前、背景在后)会让缓存前缀在“选中文字”处就断掉:
88
+ // 实测同会话换一处选中,命中量从 5760 tokens 掉到 640~768。
89
+ const stablePrefix = [];
90
+ if (background && background.transcript) {
91
+ stablePrefix.push(`【会话背景】(按时间顺序;${background.marked ? '最后一条即选中文字所在的消息,已用 ▶ 标出,其后的对话未包含' : '未能定位选中位置,取的是最近的对话'})`, background.transcript, '', '上面是会话背景;下面是需要你解读的【选中文字】及其上下文。', '');
92
+ }
93
+ const userText = [
94
+ ...stablePrefix,
95
+ '【选中文字】',
96
+ text,
97
+ '',
98
+ `【所在位置】${label || '页面内容'}`,
99
+ '',
100
+ '【选中处的上下文片段】(选中部分用【】标出)',
101
+ context || '(未能取到上下文片段,请仅根据选中文字与会话背景作答)',
102
+ ].join('\n');
103
+ const effort = typeof body.effort === 'string' && body.effort.length > 0 ? body.effort : config.reasoningEffort;
104
+ // 追问模式:客户端带 question 时,就是「就这段选中文字继续聊」
105
+ const question = typeof body.question === 'string' ? body.question.trim() : '';
106
+ const isChat = question.length > 0;
107
+ const historyMessages = [];
108
+ if (isChat) {
109
+ const rawHistory = Array.isArray(body.history) ? body.history : [];
110
+ for (const turn of rawHistory.slice(-20)) {
111
+ const item = turn;
112
+ const text = typeof item.text === 'string' ? item.text : '';
113
+ if (!text)
114
+ continue;
115
+ if (item.role === 'assistant') {
116
+ historyMessages.push(createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: route.provider, model: route.model } }));
117
+ }
118
+ else {
119
+ historyMessages.push(createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] }));
120
+ }
121
+ }
122
+ }
123
+ const cacheKey = resultKeyOf(route.provider, route.model, sessionIdOf(body) + '|' + effort + '|' + (typeof body.stage === 'string' ? body.stage : 'translation'), background?.transcript ?? '', text, context);
124
+ const useResultCache = !isChat && config.resultCacheTtlMs > 0 && body.refresh !== true;
125
+ const cached = useResultCache ? resultCache.get(cacheKey) : undefined;
126
+ if (cached && Date.now() - cached.at < config.resultCacheTtlMs) {
127
+ res.writeHead(200, {
128
+ 'content-type': 'text/event-stream; charset=utf-8',
129
+ 'cache-control': 'no-store, no-transform',
130
+ connection: 'keep-alive',
131
+ 'x-accel-buffering': 'no',
132
+ });
133
+ sse(res, {
134
+ type: 'start',
135
+ provider: route.provider,
136
+ model: route.model,
137
+ cached: true,
138
+ context: { chars: context.length, label, session: background ? { chars: background.transcript.length, marked: background.marked } : null },
139
+ });
140
+ sse(res, { type: 'delta', text: cached.text });
141
+ sse(res, { type: 'done', chars: cached.text.length, cached: true });
142
+ res.end();
143
+ return;
144
+ }
145
+ res.writeHead(200, {
146
+ 'content-type': 'text/event-stream; charset=utf-8',
147
+ 'cache-control': 'no-store, no-transform',
148
+ connection: 'keep-alive',
149
+ 'x-accel-buffering': 'no',
150
+ });
151
+ const controller = new AbortController();
152
+ let finished = false;
153
+ /** 诊断:客户端(浏览器/探针)在流还没结束时就把连接关了 —— 会让上游被 abort,表现为"输出到一半停了"。 */
154
+ let clientClosedEarly = false;
155
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
156
+ res.on('close', () => {
157
+ if (!finished) {
158
+ clientClosedEarly = true;
159
+ ctx.logger?.warn?.(`[${name}] 客户端提前断开(流未结束):已中止上游,避免继续消耗 token`);
160
+ controller.abort();
161
+ }
162
+ });
163
+ // 可用工具(白名单,默认只有联网搜索/抓取):交给模型自己决定要不要用。
164
+ // 注意工具是「按会话作用域」注册的(全局视图里看不到 web_search 等),
165
+ // 所以要拿该会话的 agent 当 scope 去查,并把同一个 agent 传给 execute。
166
+ const agents = ctx.get('agents');
167
+ const sessionId = sessionIdOf(body);
168
+ let agent = sessionId ? agents?.get?.(sessionId) : undefined;
169
+ let agentScope = agent ? 'session' : '';
170
+ if (!agent) {
171
+ // 回退:本会话还没起 agent(刚开的新会话/agent 已释放)时,用任一活跃 agent 当作用域。
172
+ // 不这么做的话,session-scoped 的 web_search/web_fetch 在全局视图里根本不可见 →
173
+ // 工具静默为空,模型还会把工具调用当正文吐出来(实测见过 <ds_safety_tool_call>)。
174
+ const live = agents?.list?.() ?? [];
175
+ if (live.length > 0) {
176
+ agent = live[0];
177
+ agentScope = 'fallback';
178
+ }
179
+ }
180
+ const toolSchemas = [];
181
+ /** 诊断:这一轮是不是走了"备用联网工具"(首选联网工具一个都不在)。 */
182
+ let toolFallbackUsed = false;
183
+ if (config.tools && config.maxToolRounds > 0) {
184
+ const registry = ctx.get('tools');
185
+ const split = (value) => value.split(',').map((item) => item.trim()).filter((item) => item.length > 0);
186
+ let visible = [];
187
+ try {
188
+ visible = registry?.schemas?.(agent) ?? [];
189
+ }
190
+ catch (error) {
191
+ ctx.logger?.warn?.(`[${name}] 读取工具表失败:${String(error?.message ?? error)}`);
192
+ }
193
+ const allowed = resolveToolNames(split(config.toolNames), split(config.fallbackToolNames), visible.map((schema) => schema.name));
194
+ toolFallbackUsed = !allowed.includes('advanced_search') && !allowed.includes('platform_search');
195
+ for (const schema of visible) {
196
+ if (allowed.includes(schema.name))
197
+ toolSchemas.push(schema);
198
+ }
199
+ }
200
+ // 本机抓不了网页(fake-IP 代理 / SSRF 防护)时,把 web_fetch 摘掉:
201
+ // 否则模型每次都要白跑一轮(5~15s)才拿到"resolves to a non-public IP address"。
202
+ let fetchBlockedSample = '';
203
+ if (toolSchemas.some((schema) => schema.name === 'web_fetch')) {
204
+ const probe = await webFetchUsable();
205
+ if (!probe.usable) {
206
+ fetchBlockedSample = probe.sample;
207
+ for (let i = toolSchemas.length - 1; i >= 0; i -= 1) {
208
+ if (toolSchemas[i]?.name === 'web_fetch')
209
+ toolSchemas.splice(i, 1);
210
+ }
211
+ ctx.logger?.warn?.(`[${name}] 网页抓取不可用(域名解析到 ${probe.sample},属保留地址段),已从工具表移除 web_fetch`);
212
+ }
213
+ }
214
+ // 文件类工具的边界:默认只允许会话工作目录;'*' = 不限制;配置了具体目录就用它。
215
+ // 拿不到工作目录时**不设限**(否则新会话里 read 直接全废),但会打日志说明。
216
+ const wantsFsTools = toolSchemas.some((schema) => FS_TOOL_NAMES.includes(schema.name));
217
+ let readRoot = '';
218
+ if (wantsFsTools) {
219
+ if (config.toolReadRoot === '*') {
220
+ readRoot = '';
221
+ }
222
+ else {
223
+ readRoot = config.toolReadRoot || (await sessions.resolveSessionCwd(sessionIdOf(body))) || '';
224
+ if (!readRoot)
225
+ ctx.logger?.warn?.(`[${name}] 拿不到会话工作目录,文件工具本次不设边界`);
226
+ }
227
+ }
228
+ const stage = typeof body.stage === 'string' ? body.stage : '';
229
+ // 选中文字是代码:首轮走「注释」提示词(代码块 + 逐句注释 + 小结)
230
+ const isCode = body.kind === 'code';
231
+ const basePrompt = isChat
232
+ ? CHAT_SYSTEM_PROMPT
233
+ : stage === 'detail'
234
+ ? DETAIL_PROMPT
235
+ : isCode
236
+ ? CODE_PROMPT
237
+ : TRANSLATION_PROMPT;
238
+ // 一个工具都没有时明确禁止伪造调用(模型会照着自己的习惯吐 <ds_safety_tool_call> 之类的格式)
239
+ // 正文里不许写"盘算过程"(模型在工具轮里很容易把"I should search again…"这类写进正文)
240
+ const narrateRule = '\n\n【正文只写结论】不要把思考、盘算、自查过程写进正文("我还得再搜一次""先看看拿到的标题""让我确认一下"这类一律不写);需要工具就直接调用,调用前后都不要解说。';
241
+ // 输出偏好(小窗开关):左档 Markdown / 右档 网页。
242
+ // 关档也要明确说"用 Markdown"——否则标签写着 Markdown、行为却可能自己给整页 HTML,开关就名不副实。
243
+ let webRule = '';
244
+ if (isChat && body.webAnswer !== true) {
245
+ webRule =
246
+ '\n\n【输出偏好:Markdown】用户把输出偏好设为 Markdown:默认用普通文字/Markdown 回答' +
247
+ '(标题、列表、表格、代码块都可以)。' +
248
+ '**这不禁止 HTML**——如果这个问题用一页 HTML 明显讲得更清楚,照样可以用网页回答;' +
249
+ '只是不要单纯为了"好看"就把简单问题做成页面。';
250
+ }
251
+ if (isChat && body.webAnswer === true) {
252
+ const skill = await loadDesignSkill();
253
+ webRule =
254
+ '\n\n【输出偏好:网页】这不是"一律用网页",而是**网页优先**:\n' +
255
+ '- **复杂问题**(需要对比、流程、层级、图表,或者文字答案会长到不好读)→ 直接给完整的单文件 HTML' +
256
+ '(放进 ```html 代码块),**页面本身就是答案**,不要再用文字复述一遍;\n' +
257
+ '- **简单问题**(一两段话能说清、不需要并列或图示)→ **仍然直接用文字回答**。' +
258
+ '不要为了用网页而用网页,也不要把简单问题包装成页面。\n' +
259
+ '拿不准时按"能不能用一小段话讲清"来判断:能,就文字。\n' +
260
+ '用网页时,页面会渲染在大约 **500px 宽**的小窗里,按下面的设计规范做:\n\n' +
261
+ skill +
262
+ (body.theme === 'dark'
263
+ ? '\n\n【配色:跟随宿主,当前是深色】宿主界面此刻是**深色模式**,页面必须是深色:' +
264
+ '背景用 #16181c~#1f2126 一档,正文 #e6e6e6,次级文字 #a1a1aa,分隔线 rgba(255,255,255,.12),' +
265
+ '卡片底色比页面底再亮一档。**不要白底黑字**,也不要只写 prefers-color-scheme(宿主的深浅由用户设置决定,与系统不一定一致)。\n'
266
+ : '\n\n【配色:跟随宿主,当前是浅色】宿主界面此刻是**浅色模式**,页面必须是浅色:' +
267
+ '背景用 #ffffff~#f7f7f8 一档,正文 #171717,次级文字 #6b7280,分隔线 rgba(0,0,0,.10),' +
268
+ '卡片底色与页面底拉开一档。**不要深色底**,也不要只写 prefers-color-scheme(宿主的深浅由用户设置决定,与系统不一定一致)。\n');
269
+ }
270
+ const fileRule = wantsFsTools
271
+ ? `\n\n【文件访问】你可以用 read/grep/glob 查看当前项目里的文件(范围:${readRoot || '不限'})。超出该范围的路径会被拒绝;引用文件内容时只取必要的一两行,不要整段粘贴。`
272
+ : '';
273
+ const fetchRule = fetchBlockedSample
274
+ ? `\n\n【抓取网页不可用】本机域名解析到保留地址段(${fetchBlockedSample}),网页抓取会被安全策略拒绝:只做联网搜索,**不要尝试抓取具体网址**(省掉一次注定失败的往返)。`
275
+ : '';
276
+ const systemPrompt = (toolSchemas.length > 0
277
+ ? basePrompt
278
+ : `${basePrompt}\n\n【本轮没有可用工具】不要输出任何工具调用格式(如 <ds_safety_tool_call>、tool_calls、JSON 形式的调用),也不要声称你查过;需要实时/外部信息时直接说明你无法联网或无法读取,只基于已知信息作答。`) +
279
+ narrateRule +
280
+ webRule +
281
+ fileRule +
282
+ fetchRule;
283
+ // 三档分开:翻译(默认 low,只要快)/详解(默认 reasoningEffort=high,要深)/追问(chatReasoningEffort=high,要准)
284
+ // 三档分开:翻译(默认 low,只要快)/详解(默认 reasoningEffort=high,要深)/追问(默认 chatReasoningEffort=high)
285
+ // 但**追问档**允许请求覆盖(小窗的「推理等级(追问档)」控件靠这条生效):
286
+ // 首轮/详解仍按各自的配置走 —— 首轮故意用低档换速度,不该被追问档的设置带跑。
287
+ await models.loadLeaky();
288
+ let bodyEffort = typeof body.effort === 'string' && body.effort.length > 0 ? body.effort : '';
289
+ // 这个模型在"关"档会把思考写进正文(实测过):别再发 none,直接抬到 low —— 既没泄漏,也不慢
290
+ const routeKey = `${route.provider}/${route.model}`;
291
+ if (isChat && bodyEffort === 'off' && models.leakyOnOff.has(routeKey)) {
292
+ bodyEffort = 'low';
293
+ sse(res, {
294
+ type: 'notice',
295
+ code: 'effort-clamped',
296
+ tier: 'off',
297
+ text: '该模型在「关」档会把思考写进正文,已自动改用「低」档',
298
+ });
299
+ }
300
+ let requestEffort = isChat
301
+ ? bodyEffort || config.chatReasoningEffort || effort
302
+ : stage === 'detail'
303
+ ? effort
304
+ : config.translationReasoningEffort || effort;
305
+ // 已知这个模型不接受"关"档(之前撞过一次并记下来了):直接换 low,不再先失败一轮。
306
+ // 首轮默认就是 off,所以这条对"默认配置 + 不吃 off 的模型"是必经之路。
307
+ if (requestEffort === 'off' && models.noOffRoutes.has(routeKey))
308
+ requestEffort = 'low';
309
+ // 规范四格 → 这个模型真实声明的档位(每次请求现算;'' = 这个模型不支持推理,干脆不带档位)。
310
+ // 注意:leaky / noOff 两条守卫仍在**规范档位**这一层判断(它们记的是"关档"的语义),
311
+ // 映射放在最后一步。
312
+ const sentEffort = models.effortForRoute(routeKey, requestEffort);
313
+ // 「关」这一格在这个模型上到底是哪个真实档位:重试判定与泄漏检查都要按**真实档位**比
314
+ // (模型可能没有 off,或叫 none/minimal —— 那就不能拿字符串 'off' 去比)
315
+ const offSlotEffort = requestEffort === 'off' ? sentEffort : models.effortForRoute(routeKey, 'off');
316
+ sse(res, {
317
+ type: 'start',
318
+ provider: route.provider,
319
+ model: route.model,
320
+ mode: isChat ? 'chat' : stage === 'detail' ? 'detail' : isCode ? 'code' : 'translation',
321
+ stage: stage === 'detail' ? 'detail' : 'translation',
322
+ backgroundMessages: background ? background.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line)).length : 0,
323
+ effort: sentEffort,
324
+ tools: toolSchemas.map((schema) => schema.name),
325
+ toolScope: agentScope,
326
+ ...(toolFallbackUsed ? { toolFallbackUsed: true } : {}),
327
+ ...(wantsFsTools ? { readRoot: readRoot || '(不限)' } : {}),
328
+ ...(fetchBlockedSample ? { fetchBlocked: fetchBlockedSample } : {}),
329
+ context: {
330
+ chars: context.length,
331
+ label,
332
+ session: background
333
+ ? {
334
+ chars: background.transcript.length,
335
+ marked: background.marked,
336
+ // 背景短哈希:用于核对「前缀是否真的没变」(缓存命中的前提)
337
+ hash: createHash('sha1').update(background.transcript).digest('hex').slice(0, 10),
338
+ }
339
+ : null,
340
+ },
341
+ ...(body.debug === true
342
+ ? { debug: { system: systemPrompt, user: userText, sizes: { system: systemPrompt.length, user: userText.length } } }
343
+ : {}),
344
+ });
345
+ let chars = 0;
346
+ const baseMessage = createUserMessage({
347
+ source: { kind: 'user' },
348
+ content: [{ type: 'text', text: userText }],
349
+ });
350
+ // 上一轮查过的东西一并带上:否则追问会反复重查同样的内容(一次工具往返 20~34s)
351
+ const toolDigest = typeof body.toolDigest === 'string' ? body.toolDigest.trim().slice(0, 2000) : '';
352
+ // 顺序很重要:把"已经查到的结果"放**前面**、问题放**最后**。
353
+ // 反过来的话,模型最后读到的是原始检索结果,容易"整理资料"而不是回答问题
354
+ //(实测:问新闻却把上一次的检索结果当成要总结的对象)。
355
+ const questionText = toolDigest
356
+ ? `【本会话已经查到的结果】(同一会话里查过的,不要重复查同样的内容;只在与本轮问题相关时才用)\n${toolDigest}\n\n【用户这一轮的问题】\n${question}`
357
+ : question;
358
+ const openingMessages = isChat
359
+ ? [...historyMessages, createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: questionText }] })]
360
+ : [];
361
+ try {
362
+ let errored = false;
363
+ let usage = null;
364
+ let answer = '';
365
+ const toolMessages = [];
366
+ let toolCalls = 0;
367
+ // 允许的工具轮 + 1 个**收尾轮**:收尾轮不带任何工具。
368
+ // 不加这一轮的话,模型每轮都调工具时循环直接结束,正文是空的——
369
+ // 用户看到的就是"转了半天,但没有任何回答"。
370
+ let errorMessage = '';
371
+ let errorCode;
372
+ const toolDigestLines = [];
373
+ /** 被残渣过滤器丢掉的字数:末尾那些"模型顺手又调了一次工具"的标记会让正文停在半句上。 */
374
+ /**
375
+ * 逐轮诊断(放进 done 事件):原始字数 / 过滤后字数 / 思考字数 / 残渣丢弃 / finish 原因 / 输出 tokens。
376
+ * 用来回答"到底是模型没吐、还是被我们的过滤器吃掉了、还是上游掐流"——不靠猜。
377
+ */
378
+ const roundDiag = [];
379
+ let conclusionInfo = {
380
+ tried: false,
381
+ chars: 0,
382
+ rounds: 0,
383
+ error: '',
384
+ };
385
+ const toolRounds = config.maxToolRounds;
386
+ // 档位被上游拒绝时(400/401/500)重试一次:清单里声明的档位是**建议值**,
387
+ // 实测很多模型并不接受 max(qwen3.7-max 直接 400、minimax-m2.7/gpt-5.6-luna 500)。
388
+ //
389
+ // 以前这里限定了 `isChat && bodyEffort.length > 0` —— 只有追问、且客户端显式传了档位才重试。
390
+ // 于是**首轮**(档位来自配置、默认 off)撞上不吃 off 的模型时无人兜底,整个划词解读全挂。
391
+ // 现在按"这一轮实际在发显式档位"判,三种轮次一视同仁。
392
+ //
393
+ // 2026-10-09 改:重试**不再"去掉档位"**,而是换一个没试过的档(见 nextEffortFor)。
394
+ // 老代码对非 off 档的重试是 `roundEffort = ''`(省略 reasoning),
395
+ // 而省略对"必须开推理"的端点就是关掉推理 —— 实测 20 次重试 0 次成功。
396
+ // 同时给两次机会:上游偶发失败约一半,两次能把成功率抬到 ~90%(代价是多一次往返)。
397
+ const EFFORT_RETRY_LIMIT = 2;
398
+ // 重试与"泄漏检查"都看**真正发出去**的那个档位(sentEffort,映射之后的值):
399
+ // 规范档位是"关"、但这个模型的关档叫 none/minimal 时,检查的必须是发出去的那个。
400
+ let effortRetries = sentEffort.length > 0 ? EFFORT_RETRY_LIMIT : 0;
401
+ const triedEfforts = new Set(sentEffort ? [sentEffort] : []);
402
+ const firstSentEffort = sentEffort;
403
+ let roundEffort = sentEffort;
404
+ for (let round = 0; round <= toolRounds; round += 1) {
405
+ const roundStartedAt = Date.now();
406
+ const wrapUp = round === toolRounds;
407
+ const stream = ctx.llm.stream({
408
+ provider: route.provider,
409
+ model: route.model,
410
+ // 带上会话 id:主会话 agent loop 一直传它,适配器会据此设置上游的会话头。
411
+ // 插件以前没传,结果是所有小窗请求共用静态会话头 —— 而中继是按会话跟踪的(实测长回答被掐)。
412
+ ...(sessionId ? { sessionId: sessionId } : {}),
413
+ system: undefined,
414
+ messages: [
415
+ // 第二参数(组装这段提示词的插件名):**新老版本签名不一样,两边都得能编译**——
416
+ // · npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的是 0.1.5-rc.3:`(text, plugin)` 两个必填
417
+ // (CI 就是拿这份类型做构建校验的,少传就 TS2554);
418
+ // · 本机 / 新版运行时(0.1.7-rc.x):只剩 `(text)`,多传的参数会被忽略。
419
+ // 所以统一走 systemMessageOf 这个宽松别名(见文件下方的定义),两边都调得动。
420
+ systemMessageOf(wrapUp
421
+ ? `${systemPrompt}\n\n【收尾】工具轮次已用完:直接基于已经拿到的信息给出结论,不要再请求工具,也不要复述查询过程。` +
422
+ '正文里不要出现任何工具调用格式(包括 DeepSeek 的 DSML 写法,如 invoke/parameter 标记)——那些会被当作乱码展示给用户。'
423
+ : systemPrompt, name),
424
+ ...(round === 0 ? [baseMessage, ...openingMessages] : [baseMessage, ...openingMessages, ...toolMessages]),
425
+ ],
426
+ // 工具**每轮都在请求里**(照主会话的做法):收尾轮以前故意不带工具"逼结论",
427
+ // 结果模型退到文本层、把工具调用标记写进正文(实测 wrapUp 轮 raw=726 → emitted=0)。
428
+ // 主会话从不摘工具,模型也就从不写这种标记 —— 这里与它保持一致。
429
+ ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
430
+ ...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
431
+ ...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
432
+ ...(roundEffort ? { reasoningEffort: ReasoningEffortId(roundEffort) } : {}),
433
+ signal: controller.signal,
434
+ });
435
+ const assembler = new BlockAssembler();
436
+ // 本轮吐出的正文字数:这一轮如果最后是要调工具,那这些字其实是"过程旁白"
437
+ // ("我还得再搜一次""先看看拿到的标题"这类),要被撤回,不能当成答案留在小窗里
438
+ let roundText = '';
439
+ // 有的模型把思考内联在正文里(<think>…</think>):拆出来走 thought,别当答案
440
+ // 每轮一个过滤器:未闭合的残渣块只在本轮内作废。
441
+ // 共用一个的话,某一轮吐了未闭合的 DSML(实测收尾轮很常见)会让 depth 一直 >0,
442
+ // 后面所有轮次的正文都被吞掉 —— 表现就是"只完成了工具查询,没有产出正文"(用户截图报的)。
443
+ const think = createThinkFilter();
444
+ let roundRaw = 0;
445
+ let roundEmitted = 0;
446
+ let roundThink = 0;
447
+ let roundFinish = '';
448
+ /** 诊断用:本轮最后若干分片的类型与大小(看上游是发了 finish 还是直接断流)。 */
449
+ const tailChunks = [];
450
+ const emit = (part) => {
451
+ if (part.think) {
452
+ roundThink += part.think.length;
453
+ sse(res, { type: 'thought', text: part.think });
454
+ }
455
+ // 残渣过滤放在 think 之后:先分出思考,再把正文里的工具调用标记丢掉
456
+ const text = part.text;
457
+ if (text) {
458
+ roundEmitted += text.length;
459
+ chars += text.length;
460
+ answer += text;
461
+ roundText += text;
462
+ sse(res, { type: 'delta', text: text });
463
+ }
464
+ };
465
+ for await (const chunk of stream) {
466
+ assembler.push(chunk);
467
+ if (body.debug === true) {
468
+ const size = chunk.type === 'text-delta' ? chunk.text.length : 0;
469
+ tailChunks.push(`${chunk.type}:${size}`);
470
+ if (tailChunks.length > 14)
471
+ tailChunks.shift();
472
+ }
473
+ if (chunk.type === 'text-delta') {
474
+ roundRaw += chunk.text.length;
475
+ emit(think.push(chunk.text));
476
+ }
477
+ else if (chunk.type === 'reasoning-delta') {
478
+ // 思考过程也透传:首字前那几秒如果只有转圈,用户会以为卡死了。
479
+ // 客户端只留尾部一小段当"正在想什么"的提示,不进正文。
480
+ sse(res, { type: 'thought', text: chunk.text });
481
+ }
482
+ else if (chunk.type === 'usage') {
483
+ usage = chunk.usage;
484
+ }
485
+ else if (chunk.type === 'finish') {
486
+ roundFinish = String(chunk.reason.kind);
487
+ if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
488
+ errored = true;
489
+ // 先只记下来:如果接下来要"换档重试",这条错误就不该发给用户
490
+ // (否则客户端先渲染一条失败提示,重试的正文就贴在失败提示后面了)
491
+ errorMessage =
492
+ chunk.reason.failure?.message ?? `模型调用${chunk.reason.kind === 'aborted' ? '被中断' : '失败'}`;
493
+ errorCode = chunk.reason.failure?.code;
494
+ }
495
+ }
496
+ }
497
+ emit(think.flush());
498
+ roundDiag.push({
499
+ round,
500
+ wrapUp,
501
+ ms: Date.now() - roundStartedAt,
502
+ raw: roundRaw,
503
+ emitted: roundEmitted,
504
+ think: roundThink,
505
+ finish: roundFinish || '(none)',
506
+ tools: assembler.blocks().filter((block) => block.type === 'tool-call').length,
507
+ ...(body.debug === true ? { tailChunks } : {}),
508
+ outTokens: usage?.outputTokens,
509
+ });
510
+ if (errored) {
511
+ // 一个字都没吐出来 → 可以安全重试**同一轮**(保守:已经吐出正文的失败不重试,
512
+ // 免得把半截答案丢掉)。重试换一个没试过的档位,而不是把档位去掉。
513
+ if (roundText.length === 0 && effortRetries > 0) {
514
+ const rejected = roundEffort;
515
+ const next = models.nextEffortFor(routeKey, rejected, triedEfforts);
516
+ if (next) {
517
+ effortRetries -= 1;
518
+ triedEfforts.add(next);
519
+ // 只有"我们发的这个档位确实不被接受"才值得记成事实:
520
+ // · 发的是关档 + 上游说"必须开推理" → 这个 route 不吃关档,记下来;
521
+ // · 发的是开着的档(low/high/max)+ 上游还在说"推理不能关" → **这句不是冲我们来的**
522
+ // (我们没关它),是上游偶发抽风(实测同档位 5/8 成功)。这种情况不记、不诊断,
523
+ // 只如实说"上游返回了错误,已换档重试"。
524
+ // "必须开推理"这句只有在我们发的是**「关」那一格对应的真实档位**时才算数
525
+ // (模型没有 off、关档落在 minimal/low 上时,字符串比较会漏判)
526
+ const rejectedOffSlot = rejected === offSlotEffort;
527
+ const tierIssue = rejectedOffSlot
528
+ ? looksLikeReasoningRequired(errorMessage)
529
+ : looksLikeUnsupportedTier(errorMessage);
530
+ if (rejectedOffSlot && tierIssue)
531
+ models.markNoOff(routeKey);
532
+ requestEffort = next;
533
+ roundEffort = next;
534
+ errored = false;
535
+ sse(res, {
536
+ type: 'notice',
537
+ code: tierIssue ? 'effort-rejected' : 'upstream-retry',
538
+ ...(tierIssue ? { tier: rejected } : {}),
539
+ text: tierIssue
540
+ ? rejectedOffSlot
541
+ ? `该模型不接受「关」档,已自动改用「${EFFORT_LABEL[next] ?? next}」档(以后不再尝试关档)`
542
+ : `「${EFFORT_LABEL[rejected] ?? rejected}」档该模型不支持,已改用「${EFFORT_LABEL[next] ?? next}」档重试`
543
+ : '上游这次返回了错误,已自动重试',
544
+ });
545
+ round -= 1;
546
+ continue;
547
+ }
548
+ }
549
+ sse(res, { type: 'error', message: errorMessage, code: errorCode });
550
+ break;
551
+ }
552
+ const blocks = assembler.blocks();
553
+ const calls = blocks.filter((block) => block.type === 'tool-call');
554
+ if (calls.length === 0) {
555
+ // 最终答案到手:如果发出去的是「关」那一格对应的真实档位,检查有没有把思考写进正文
556
+ if (isChat && firstSentEffort === offSlotEffort) {
557
+ const split = splitLeakedReasoning(answer);
558
+ if (split.prefix) {
559
+ models.markLeaky(routeKey);
560
+ answer = split.rest;
561
+ chars = answer.length;
562
+ sse(res, { type: 'strip', text: split.prefix });
563
+ sse(res, {
564
+ type: 'notice',
565
+ code: 'reasoning-leak',
566
+ // 报**真实档位**(模型可能没有 off,叫 none/minimal):客户端据此标对那个档位按钮
567
+ tier: firstSentEffort,
568
+ text: '该模型在「关」档会把思考写进正文,已撤回泄漏段;以后这一档会自动改用「低」',
569
+ });
570
+ }
571
+ else if (looksLikeLeakedReasoning(answer)) {
572
+ // 句式没认准(模型每次换说法)→ 这次不裁(怕误删正文),但记住它,下次自动抬档
573
+ models.markLeaky(routeKey);
574
+ sse(res, {
575
+ type: 'notice',
576
+ code: 'reasoning-leak',
577
+ tier: firstSentEffort,
578
+ text: '这个模型在「关」档会把思考写进正文;这一档以后自动改用「低」',
579
+ });
580
+ }
581
+ }
582
+ break;
583
+ }
584
+ // 既然这一轮要调工具,它刚才流出来的正文就是"过程旁白",不是答案:
585
+ // 让前端把这部分撤回去(顺带当成思考尾巴显示),免得"我怎么想的"混进小窗正文。
586
+ // 实测踩过:追问"搜索今天的新闻"时,模型在工具轮里先吐了一段英文盘算
587
+ // (I have sources with dates… Maybe I should do one more search…),
588
+ // 然后才给中文结论,这段英文被当正文渲染进了消息框。
589
+ if (roundText.length > 0) {
590
+ answer = answer.slice(0, Math.max(0, answer.length - roundText.length));
591
+ sse(res, { type: 'drop', chars: roundText.length, text: roundText.slice(-2000), round });
592
+ }
593
+ // 有的模型不带工具定义也会返回调用;执行端同样遵守开关和轮数上限。
594
+ if (wrapUp || toolSchemas.length === 0)
595
+ break;
596
+ // 执行工具→把结果回灌,再让模型继续(上限 maxToolRounds 轮)
597
+ toolMessages.push(createAssistantMessage({
598
+ content: blocks,
599
+ source: { provider: route.provider, model: route.model },
600
+ }));
601
+ for (const call of calls) {
602
+ if (call.type !== 'tool-call')
603
+ continue;
604
+ toolCalls += 1;
605
+ let parsed = {};
606
+ try {
607
+ parsed = call.arguments ? JSON.parse(call.arguments) : {};
608
+ }
609
+ catch {
610
+ parsed = {};
611
+ }
612
+ const label2 = describeToolCall(call.name, parsed);
613
+ const callId = String(call.id ?? `round${String(round)}-${String(toolCalls)}`);
614
+ sse(res, { type: 'tool', phase: 'start', callId, name: call.name, detail: label2, round });
615
+ const startedAt = Date.now();
616
+ // 参数名有讲究:read/write/edit 用 file_path,grep/glob 用 path。
617
+ // 只认 path 的话 read 的 file_path 会是 undefined → 边界检查被静默绕过(实测踩过)。
618
+ const rawPath = parsed?.file_path ?? parsed?.path;
619
+ const wantPath = typeof rawPath === 'string' ? rawPath : '';
620
+ const execution = !toolSchemas.some((schema) => schema.name === call.name)
621
+ ? { content: [{ type: 'text', text: `(调用已拒绝:${call.name} 不在本次允许的工具中)` }], isError: true }
622
+ : FS_TOOL_NAMES.includes(call.name) && !isInsideRoot(readRoot, wantPath)
623
+ ? {
624
+ content: [
625
+ {
626
+ type: 'text',
627
+ text: `(越界已拒绝:文件类工具只能访问当前项目目录${readRoot ? `(${readRoot})` : ''},请求的路径是 ${wantPath || '(未给)'})`,
628
+ },
629
+ ],
630
+ isError: true,
631
+ }
632
+ : await runTool(ctx, call.name, parsed, controller.signal, config.toolResultMaxChars, agent);
633
+ // 结果也回传:小窗里能看见"查了什么、命中什么、链接是什么",否则工具是个黑盒
634
+ const digest = digestToolResult(execution.content);
635
+ sse(res, {
636
+ type: 'tool',
637
+ phase: 'done',
638
+ callId,
639
+ name: call.name,
640
+ detail: label2,
641
+ round,
642
+ ok: !execution.isError,
643
+ ms: Date.now() - startedAt,
644
+ chars: digest.chars,
645
+ preview: digest.preview,
646
+ urls: digest.urls,
647
+ });
648
+ toolMessages.push(createToolResultMessage({ callId: call.id, content: execution.content, isError: execution.isError }));
649
+ // 结论轮要用:把"查了什么 + 拿到了什么"写成纯文本,避免重放工具调用链
650
+ toolDigestLines.push(`· ${call.name}(${label2 || '查询'}):${digest.preview}`);
651
+ }
652
+ }
653
+ if (!errored && answer.trim().length === 0 && toolCalls > 0 && triedEfforts.size === 1) {
654
+ // 收尾轮常常"只吐一段工具调用、不写正文"(实测「今天的新闻」一个字都没有)。
655
+ // 这里自己补结论轮:**不重放工具调用链**,而是把结果写成一段自包含的提示 ——
656
+ // 重放消息链的方式实测也不稳(模型面对 tool-call 历史仍可能继续吐工具调用)。
657
+ conclusionInfo.tried = true;
658
+ sse(res, { type: 'notice', code: 'conclusion-retry', text: '查询已完成,正在整理结论…' });
659
+ const conclusionUser = [
660
+ // ⚠️ **必须带上用户这一轮问的是什么**:这一段是最终正文的来源,而它以前只有
661
+ // 「选区块 + 检索资料 + 请给出最终答案」—— 模型看不到问题,就只能顺着选中文字写。
662
+ // 实测症状:问"搜索今天的AI新闻。",正文却在解释选区那个词(如 diagLog / exit)。
663
+ // 特别是收尾轮也带上工具之后(照主会话的做法),正文**每次都**由这里产出,
664
+ // 缺了问题就等于每次都答非所问。
665
+ question ? `【用户这一轮的问题】(这就是你要回答的对象)\n${question}` : '',
666
+ '',
667
+ '【选中文字】(只是背景资料:问题需要它时才引用,不要拿它当回答的对象)',
668
+ userText,
669
+ '',
670
+ '【已经查到的资料】',
671
+ ...toolDigestLines.slice(0, 12),
672
+ '',
673
+ '请**直接回答上面那条「用户这一轮的问题」**:结论先行、必要处带来源链接。',
674
+ '不要复述选中文字,也不要解释它是什么 —— 除非问题问的就是它。',
675
+ '不要再请求工具,不要输出任何工具调用格式,也不要写"我先看看""让我核对一下"这类过程。',
676
+ ].join('\n');
677
+ // 先按当前档位试一次;空则**换一个档位**再试(同一档重发没意义)。
678
+ // 以前这里写死 `'off'`:对"必须开推理"的端点(noOffRoutes 里的那些)等于注定失败一轮 ——
679
+ // 用户看到的"工具查询完成了,但模型没有产出正文"就常有它一份。
680
+ const conclusionEfforts = [
681
+ roundEffort,
682
+ models.nextEffortFor(routeKey, roundEffort, new Set(roundEffort ? [roundEffort] : [])),
683
+ ].filter((id) => id.length > 0);
684
+ for (const attemptEffort of conclusionEfforts) {
685
+ if (answer.trim().length > 0)
686
+ break;
687
+ conclusionInfo.rounds += 1;
688
+ try {
689
+ const resume = ctx.llm.stream({
690
+ provider: route.provider,
691
+ model: route.model,
692
+ ...(sessionId ? { sessionId: sessionId } : {}),
693
+ system: undefined,
694
+ // 工具**依然带着**(照主会话:任何一轮都不摘工具)。
695
+ // 以前这一轮不带工具,模型要调工具时只能退回文本层 —— 那正是正文里出现
696
+ // DSML / ds_safety_tool_call 标记的唯一来源。带上之后它走协议层,正文就是正文。
697
+ ...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
698
+ messages: [
699
+ // 同上:多传的插件名在老版本运行时会被忽略,新版本类型上却是必填
700
+ systemMessageOf(systemPrompt, name),
701
+ createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: conclusionUser }] }),
702
+ ],
703
+ ...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
704
+ ...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
705
+ ...(attemptEffort ? { reasoningEffort: ReasoningEffortId(attemptEffort) } : {}),
706
+ signal: controller.signal,
707
+ });
708
+ const thinkAgain = createThinkFilter();
709
+ for await (const chunk of resume) {
710
+ if (chunk.type === 'text-delta') {
711
+ const part = thinkAgain.push(chunk.text);
712
+ if (part.think)
713
+ sse(res, { type: 'thought', text: part.think });
714
+ const text = part.text;
715
+ if (text) {
716
+ chars += text.length;
717
+ answer += text;
718
+ conclusionInfo.chars += text.length;
719
+ sse(res, { type: 'delta', text: text });
720
+ }
721
+ }
722
+ else if (chunk.type === 'usage') {
723
+ usage = chunk.usage;
724
+ }
725
+ else if (chunk.type === 'finish') {
726
+ if (chunk.reason.kind === 'error') {
727
+ conclusionInfo.error = chunk.reason.failure?.message ?? '模型调用失败';
728
+ }
729
+ }
730
+ }
731
+ for (const tail of [thinkAgain.flush()])
732
+ if (tail.think)
733
+ sse(res, { type: 'thought', text: tail.think });
734
+ // 结论轮也可能把"英文核对过程"写进正文:一律按泄漏裁一次
735
+ const leaked = splitLeakedReasoning(answer);
736
+ if (leaked.prefix) {
737
+ answer = leaked.rest;
738
+ chars = answer.length;
739
+ sse(res, { type: 'strip', text: leaked.prefix });
740
+ sse(res, { type: 'notice', code: 'reasoning-leak', text: '已去掉模型写进正文的核对过程' });
741
+ }
742
+ }
743
+ catch (error) {
744
+ conclusionInfo.error = String(error?.message ?? error);
745
+ ctx.logger?.warn?.(`[${name}] 结论轮失败:${conclusionInfo.error}`);
746
+ }
747
+ }
748
+ }
749
+ if (!errored && answer.trim().length === 0) {
750
+ // 兜底:连结论轮都没吐字时,至少给一句话(否则面板空白,看起来像"没返回结果")
751
+ const note = toolCalls > 0
752
+ ? '(工具查询完成了,但模型没有产出正文。可以再追问一句"直接说结论",我按已查到的结果回答。)'
753
+ : '(模型这次没有返回内容,可以点「重新生成」再试一次。)';
754
+ answer = note;
755
+ chars += note.length;
756
+ sse(res, { type: 'delta', text: note });
757
+ }
758
+ if (!errored) {
759
+ sse(res, {
760
+ type: 'done',
761
+ chars,
762
+ usage,
763
+ toolCalls,
764
+ // 诊断用:结论轮/续写各试了几次、拿到多少字、失败原因
765
+ ...(conclusionInfo.tried ? { conclusion: conclusionInfo } : {}),
766
+ ...(roundDiag.length > 0 ? { rounds: roundDiag } : {}),
767
+ clientClosedEarly,
768
+ });
769
+ if (useResultCache && answer.length > 0) {
770
+ resultCache.set(cacheKey, { at: Date.now(), text: answer });
771
+ if (resultCache.size > 200) {
772
+ const oldest = resultCache.keys().next().value;
773
+ if (oldest !== undefined)
774
+ resultCache.delete(oldest);
775
+ }
776
+ }
777
+ }
778
+ }
779
+ catch (error) {
780
+ const err = error;
781
+ const aborted = err?.name === 'AbortError';
782
+ sse(res, {
783
+ type: 'error',
784
+ message: aborted ? `调用超时或已取消(${config.timeoutMs}ms)` : String(err?.message ?? err),
785
+ });
786
+ }
787
+ finally {
788
+ finished = true;
789
+ clearTimeout(timer);
790
+ res.end();
791
+ }
792
+ };
793
+ return { handleAnalyze };
794
+ }
795
+ //# sourceMappingURL=analyze.js.map