@yfwu2020/dsh-selection-explain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +306 -0
- package/cordis.patch.yml +31 -0
- package/lib/client.js +4904 -0
- package/lib/index.js +2292 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +251 -0
- package/package.json +50 -0
- package/scripts/build.sh +102 -0
- package/scripts/dump-prompt.mjs +143 -0
- package/scripts/test-client.mjs +2486 -0
- package/scripts/test-filters.mjs +229 -0
- package/scripts/test-guard.mjs +48 -0
- package/scripts/test-transcript.mjs +78 -0
- package/skills/web-design/SKILL.md +74 -0
- package/src/client/index.js +4904 -0
- package/src/index.ts +2560 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,2560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @yfwu2020/dsh-selection-explain — host half(划词解读)。
|
|
3
|
+
*
|
|
4
|
+
* 为浏览器端的「划词解读」提供两个同源 HTTP 路由:
|
|
5
|
+
* POST /selection-explain/api/analyze 选中文字 + 上下文 → SSE 流式返回
|
|
6
|
+
* 「专业中英翻译」与「语境含义详解」
|
|
7
|
+
* GET /selection-explain/api/ping 健康检查:当前模型路由 + 限制(弹窗页脚用)
|
|
8
|
+
*
|
|
9
|
+
* 模型路由默认取 agentDefaultModel.currentSelection()(用户在设置里选的默认模型);
|
|
10
|
+
* 调用过一次真实 LLM 后,也会从 llm/stream 事件里捕获路由作为兜底。
|
|
11
|
+
* 流式 text-delta 直接透传,前端边收边渲染,首字延迟 ≈ 模型首 token 时间。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { lookup } from 'node:dns/promises'
|
|
15
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join, resolve, sep } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
21
|
+
import type { AgentDefaultModelConfig } from '@deepseek-ai/dsh-agent-default-model'
|
|
22
|
+
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
|
|
23
|
+
import { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
24
|
+
import type { ContentBlock, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
|
25
|
+
import z from '@deepseek-ai/schemastery'
|
|
26
|
+
|
|
27
|
+
/** 插件名(= package.json name,客户端 bundle 的模块 id 也是它)。 */
|
|
28
|
+
export const name = '@yfwu2020/dsh-selection-explain'
|
|
29
|
+
|
|
30
|
+
/** 路由注册与模型调用所需的服务。 */
|
|
31
|
+
export const inject = ['webServer', 'llm']
|
|
32
|
+
|
|
33
|
+
/** 路由前缀。 */
|
|
34
|
+
const API_PREFIX = '/selection-explain/api'
|
|
35
|
+
|
|
36
|
+
/** 单次请求体上限(字节)。 */
|
|
37
|
+
const BODY_LIMIT = 512 * 1024
|
|
38
|
+
|
|
39
|
+
/** 插件配置。 */
|
|
40
|
+
export interface Config {
|
|
41
|
+
/** 固定 provider 路由;留空 = 跟随默认模型。 */
|
|
42
|
+
provider: string
|
|
43
|
+
/** 固定 model id;留空 = 跟随默认模型。 */
|
|
44
|
+
model: string
|
|
45
|
+
/** 推理档位(off / low / high / max);留空 = 适配器默认。 */
|
|
46
|
+
reasoningEffort: string
|
|
47
|
+
translationReasoningEffort: string
|
|
48
|
+
historyMaxEntries: number
|
|
49
|
+
/** 采样温度。 */
|
|
50
|
+
temperature: number
|
|
51
|
+
/** 输出上限。 */
|
|
52
|
+
maxTokens: number
|
|
53
|
+
/** 单次调用超时(毫秒)。 */
|
|
54
|
+
timeoutMs: number
|
|
55
|
+
/** 选中文字长度上限(字符)。 */
|
|
56
|
+
maxSelectionChars: number
|
|
57
|
+
/** 上下文长度上限(字符)。 */
|
|
58
|
+
maxContextChars: number
|
|
59
|
+
/** 每分钟最大请求数(本地限流,防止误触发刷爆额度)。 */
|
|
60
|
+
maxRequestsPerMinute: number
|
|
61
|
+
/** 是否注入当前会话的对话背景(判断指代与前提的关键)。 */
|
|
62
|
+
sessionContext: boolean
|
|
63
|
+
/** 会话背景最多保留多少条消息(从最新往前取);第二阶段(详解)用这个值。 */
|
|
64
|
+
sessionContextMaxMessages: number
|
|
65
|
+
/** 第一阶段(仅翻译)用的背景条数:更少 → 首字更快;点「展开详解」时才用上面那个上限。 */
|
|
66
|
+
sessionContextFastMessages: number
|
|
67
|
+
/** 会话背景字符上限;0 = 不限(默认),只按消息条数取窗口。 */
|
|
68
|
+
sessionContextMaxChars: number
|
|
69
|
+
/** 共享结果缓存 TTL(毫秒):同会话+同选中+同局部片段+同背景时直接回放;0 = 关闭。 */
|
|
70
|
+
resultCacheTtlMs: number
|
|
71
|
+
/** 是否允许解读过程调用工具(联网搜索等)。 */
|
|
72
|
+
tools: boolean
|
|
73
|
+
/** 允许调用的工具名(逗号分隔);默认只放联网搜索与抓取。 */
|
|
74
|
+
toolNames: string
|
|
75
|
+
fallbackToolNames: string
|
|
76
|
+
/** 最多几轮工具调用。 */
|
|
77
|
+
maxToolRounds: number
|
|
78
|
+
/** 单个工具结果注入模型前的字符上限。 */
|
|
79
|
+
toolResultMaxChars: number
|
|
80
|
+
/** 文件类工具(read/grep/glob)的读取边界:'' = 会话工作目录,'*' = 不限制,其他 = 该目录 */
|
|
81
|
+
toolReadRoot: string
|
|
82
|
+
/** 追问(对话小窗)用的推理档位:通常比首次解读轻,响应更快。 */
|
|
83
|
+
chatReasoningEffort: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 配置 schema(缺省值即推荐值)。 */
|
|
87
|
+
export const Config = z.object({
|
|
88
|
+
provider: z.string().default(''),
|
|
89
|
+
model: z.string().default(''),
|
|
90
|
+
reasoningEffort: z.string().default('high'),
|
|
91
|
+
/** 首轮「翻译」单独一档:这一轮只挑英文片段做翻译,不需要深度推理,默认压低换首字速度。 */
|
|
92
|
+
translationReasoningEffort: z.string().default('low'),
|
|
93
|
+
/**
|
|
94
|
+
* 小窗对话历史(A′):把整段对话落到插件自己的目录,重划同一个词直接回放。
|
|
95
|
+
* 0 = 关闭历史。只留最近 N 个「划词条目」(每个条目内含它自己的全部追问轮次)。
|
|
96
|
+
*/
|
|
97
|
+
historyMaxEntries: z.number().min(0).max(500).default(20),
|
|
98
|
+
/** 采样温度。**默认 -1 = 不传**(交给供应商默认,与主会话一致)。0~2 之间才真的传。 */
|
|
99
|
+
temperature: z.number().min(-1).max(2).default(-1),
|
|
100
|
+
/**
|
|
101
|
+
* 单次调用的输出上限(tokens)。**默认 0 = 不限制** —— 不传这个参数,交给模型/供应商自己的上限。
|
|
102
|
+
*
|
|
103
|
+
* 为什么不默认给个数:这是**每次调用**的上限,联网检索后的答案经常要 2~4k tokens,
|
|
104
|
+
* 之前默认 1200 会让回答"悄悄截断在句子中间"(实测);而给死一个更大的数也一样会在别的场景截断。
|
|
105
|
+
* 想限制(控成本/控时延)就填正数,仍然生效。
|
|
106
|
+
*/
|
|
107
|
+
maxTokens: z.number().min(0).max(32000).default(0),
|
|
108
|
+
timeoutMs: z.number().min(5000).max(600000).default(300000),
|
|
109
|
+
maxSelectionChars: z.number().min(20).max(20000).default(4000),
|
|
110
|
+
maxContextChars: z.number().min(200).max(60000).default(12000),
|
|
111
|
+
maxRequestsPerMinute: z.number().min(1).max(600).default(40),
|
|
112
|
+
sessionContext: z.boolean().default(true),
|
|
113
|
+
sessionContextMaxMessages: z.number().min(1).max(200).default(24),
|
|
114
|
+
sessionContextFastMessages: z.number().min(1).max(200).default(8),
|
|
115
|
+
sessionContextMaxChars: z.number().min(0).max(2000000).default(0),
|
|
116
|
+
resultCacheTtlMs: z.number().min(0).max(86400000).default(600000),
|
|
117
|
+
tools: z.boolean().default(true),
|
|
118
|
+
/**
|
|
119
|
+
* 工具白名单(逗号分隔)。
|
|
120
|
+
*
|
|
121
|
+
* 默认改用 **free-search 插件**的工具(`advanced_search` / `platform_search`),不再用 `web_search`:
|
|
122
|
+
* 会话里的 `web_search` 是官方 DeepSeek 搜索(复用 @deepseek-ai/dsh-tool-web 的 schema),
|
|
123
|
+
* 而新装的 dsh-free-search 只注册了 advanced_search / platform_search / free_search_test ——
|
|
124
|
+
* 引擎更多(ddg/bing/searxng/anysearch/exa/tavily/keenable/firecrawl/parallel…)、失败自动换引擎、
|
|
125
|
+
* 还支持时间过滤与平台搜索。
|
|
126
|
+
*/
|
|
127
|
+
toolNames: z.string().default('advanced_search,platform_search,read,grep,glob'),
|
|
128
|
+
/**
|
|
129
|
+
* **备用**工具清单:只在"首选里的联网工具一个都不在"时才补进来。
|
|
130
|
+
*
|
|
131
|
+
* 场景:没装 free-search 插件(或它没注册工具)时,会话里通常仍有官方 `web_search`;
|
|
132
|
+
* 不补的话小窗会变成完全不能联网。装了 free-search 就完全用不到这一项。
|
|
133
|
+
*/
|
|
134
|
+
fallbackToolNames: z.string().default('web_search'),
|
|
135
|
+
maxToolRounds: z.number().min(0).max(8).default(2),
|
|
136
|
+
toolResultMaxChars: z.number().min(200).max(20000).default(3000),
|
|
137
|
+
toolReadRoot: z.string().default(''),
|
|
138
|
+
chatReasoningEffort: z.string().default('high'),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/** 联网类工具名:用来判断"首选里的联网工具在不在"。 */
|
|
142
|
+
const SEARCH_TOOL_NAMES = ['advanced_search', 'platform_search', 'web_search']
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 决定这一轮给模型哪些工具(纯函数,便于单测)。
|
|
146
|
+
*
|
|
147
|
+
* 规则:
|
|
148
|
+
* ① 首选清单里**确实存在**的直接放行;
|
|
149
|
+
* ② 如果首选里的**联网工具一个都不在**(例如没装 free-search 插件),
|
|
150
|
+
* 就把备用清单里存在的补上 —— 否则小窗会变成完全不能联网;
|
|
151
|
+
* ③ 备用只补"联网"这一类,不会顺手把别的东西带进来。
|
|
152
|
+
*/
|
|
153
|
+
function resolveToolNames(preferred: string[], fallback: string[], visible: string[]): string[] {
|
|
154
|
+
const present = new Set(visible)
|
|
155
|
+
const out = preferred.filter((name) => present.has(name))
|
|
156
|
+
const hasSearch = preferred.some((name) => SEARCH_TOOL_NAMES.includes(name) && present.has(name))
|
|
157
|
+
if (!hasSearch) {
|
|
158
|
+
for (const name of fallback) {
|
|
159
|
+
if (name && present.has(name) && !out.includes(name)) out.push(name)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 系统提示词:固定文本,两节结构,前端按标题切分。 */
|
|
166
|
+
/** 公共前言与尾部规则(两套提示词共用,避免重复维护)。 */
|
|
167
|
+
const PROMPT_HEAD = [
|
|
168
|
+
'你是一位跨领域的资深解读专家,覆盖中英双语语言学、软件工程与系统架构、数据与算法、产品与交互设计。',
|
|
169
|
+
'先判断【选中文字】实际属于哪个领域,再按该领域资深从业者的标准解读:',
|
|
170
|
+
'术语取该领域的准确定义(必要时补一句通用含义做对照),关注点也对齐该领域的关注点——',
|
|
171
|
+
'代码看行为、边界与副作用,架构看约束与取舍,设计看目标与权衡,语言看语域、搭配与言外之意。',
|
|
172
|
+
'领域判断是内部动作:**不要把判断过程写出来**,也不要写「这属于工程领域」这类交代,直接给解读。',
|
|
173
|
+
'',
|
|
174
|
+
'用户消息可能是:先给一段【会话背景】(可选,最前面),最后给出要解读的【选中文字】及其上下文。',
|
|
175
|
+
].join('\n')
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 「总结 / 详解」的表达要求:既要专业,也要说人话。
|
|
179
|
+
*
|
|
180
|
+
* 只讲术语等于没解释(换个说法还是不懂),只打比方又拿不去用(不知道确切边界)——
|
|
181
|
+
* 所以**内容上两层都要在**。但**形式上不设模板**:不要求小标题、不要求固定句式、
|
|
182
|
+
* 不要求先后顺序,由内容自己决定怎么说最自然;否则每段都长成同一个模子,读起来很僵。
|
|
183
|
+
*/
|
|
184
|
+
const TWO_LAYER_RULE = [
|
|
185
|
+
'**写法:内容有要求,形式随内容走。**',
|
|
186
|
+
'- 要让人看懂:出现术语、缩写、行话,就用日常话把它说清楚——',
|
|
187
|
+
' 顺在句子里讲明白就行,不必另起一句、也不必加小标题分栏。',
|
|
188
|
+
'- 反过来,只给了比方或感觉,就把准确的说法和边界补上(叫什么、在什么前提下成立、到什么程度)。',
|
|
189
|
+
'- 专业体现在**说得准**,不是用词生僻:术语用对、该给英文原词或全称就给、',
|
|
190
|
+
' 该有的限定不能省;也不要同义反复(「是指……的一种……」)或用术语解释术语。',
|
|
191
|
+
'- **别写成同一个模子**:不必每段都按同一个顺序铺陈,也不必凑够三条要点;',
|
|
192
|
+
' 一句能说清就一句,需要展开再展开;不要为整齐而分点,不要用「第一/第二」这类序号。',
|
|
193
|
+
].join('\n')
|
|
194
|
+
|
|
195
|
+
/** 只输出「翻译」一节时用。 */
|
|
196
|
+
const TRANSLATION_PROMPT = [
|
|
197
|
+
PROMPT_HEAD,
|
|
198
|
+
'',
|
|
199
|
+
'**本轮只输出一个章节**(标题见下),不要输出其它章节,不要前言与结语。',
|
|
200
|
+
'先看【选中文字】里**有没有需要翻译的英文**,据此决定标题与内容(判断过程不要写出来):',
|
|
201
|
+
'',
|
|
202
|
+
'### 情形一:选中文字里有英文(含中英混排)→ 标题写 `## 翻译`',
|
|
203
|
+
'- **以中文为主的句子**(可能夹着英文词):中文部分**一律不翻译**、也不要复述原句;',
|
|
204
|
+
' 只把【选中文字】**自己**含着的**英文**片段(由拉丁字母组成的单词、词组或整句)挑出来逐条译成',
|
|
205
|
+
' 中文,一个片段一条,且**层级分明**:',
|
|
206
|
+
' · 是**单词** → 父项写「词头 + 音标」(IPA,斜杠包起来),词性与义项**缩进两个空格**作为子项,',
|
|
207
|
+
' 每行以 `**n.**`、`**v.**` 这样的词性标记开头(**词性必须标**,一词多义就多行):',
|
|
208
|
+
' 形如 `- **migration** /maɪˈɡreɪʃn/`,下一行缩进写 `- **n.** 迁移;移居`;',
|
|
209
|
+
' · 是**词组或整句** → 一行 `- **原英文**:中文意思` 即可(不标音标、不拆词性)。',
|
|
210
|
+
' 音标拿不准就不写(缩写、专有名词尤其常见);宁可不标也不要编。',
|
|
211
|
+
' 中文词句即使有对应的英文说法(如「缓存层」「灰度」)也**不要列**,不要到上下文或标题里去找英文;',
|
|
212
|
+
' 写完自查:列出的每一条都必须①由英文字母构成、②能在【选中文字】里逐字找到,否则删掉。',
|
|
213
|
+
'- **以英文为主**(整句、词组或单词都算):',
|
|
214
|
+
' · 句子或词组 → 译文**单独成段**,段落以译文的第一个字开头(长句可拆成 2~3 行;',
|
|
215
|
+
' 句中的中文词原样保留,关键术语用括号注明原词);',
|
|
216
|
+
' · 单词 → **两级结构,层级要看得出来**:',
|
|
217
|
+
' 第一行是**词头 + 音标**(IPA,斜杠包起来),它是父项:`- **migrate** /maɪˈɡreɪt/`;',
|
|
218
|
+
' 英式与美式读音不同就都写(`/英 …/ `/美 …/` 并列,标上「英」「美」),相同只写一个;拿不准就不写,不要编。',
|
|
219
|
+
' 下面**每个词性义项各占一行、缩进两个空格**作为子项,行首是 `**v.**` 这样的词性标记,',
|
|
220
|
+
' 后面跟中文义项,常用义项在前;词头行本身**不要再写义项**。',
|
|
221
|
+
' 形如:父项 `- **long** /英 lɒŋ/ /美 lɔːŋ/`,子项缩进两格 `- **adj.** 长的;远的`、`- **v.** 渴望,极想`。',
|
|
222
|
+
'- **以中英之外的语言为主**(日、韩、法、德等):译成中文。',
|
|
223
|
+
'',
|
|
224
|
+
'### 情形二:选中文字**全是中文**(纯汉字的词、短语或句子)→ 标题写 `## 解读`',
|
|
225
|
+
'- **不要翻译**:不译成英文,不给英文说法,也不列义项、不做词典式解释;',
|
|
226
|
+
'- 只是把这段中文**在当前上下文里是什么意思**讲清楚,一两句就够,直接说结论。',
|
|
227
|
+
'',
|
|
228
|
+
'两种情形的**最后一行都固定**写成引用行,作为本节的总结(**这一行是本节的重点**):',
|
|
229
|
+
'> 在本句中:<它在这段上下文里到底是什么意思>',
|
|
230
|
+
'- 一行说清:用日常话讲明白,专业上的准确说法和必要限定顺在里面带出来即可,',
|
|
231
|
+
' 不用套固定句式、也不用刻意分两截;',
|
|
232
|
+
'- **不要照抄原句**,不要写「可能」「取决于」:给确定答案;',
|
|
233
|
+
'- 说清楚为止:该长就长、该短就短,不卡字数;也不要为凑字数把一行写成一段。',
|
|
234
|
+
'',
|
|
235
|
+
'规则:',
|
|
236
|
+
'- 只处理选中文字,不要整段翻译「上下文」,也不要复述指令。',
|
|
237
|
+
'- 正文里不要出现「翻译:」「中文句子:」这类分类标签,直接给内容。',
|
|
238
|
+
'- 用简洁 Markdown:要点可以用「- 」,但别把什么都做成列表;强调用 **加粗**,别整段加粗;',
|
|
239
|
+
' 整段输出不要用代码块包裹。',
|
|
240
|
+
'- 不要出现「以下是」「希望对你有所帮助」之类的客套话。',
|
|
241
|
+
'- **不要叙述查资料的过程**("让我先搜一下""我再确认一下"这类一律不写);查到什么直接给结论,',
|
|
242
|
+
' 有链接就自然带在结论里。',
|
|
243
|
+
].join('\n')
|
|
244
|
+
|
|
245
|
+
/** 选中文字是代码时用:代码块原样呈现 + 逐句注释 + 小结(首轮,只要快)。 */
|
|
246
|
+
const CODE_PROMPT = [
|
|
247
|
+
PROMPT_HEAD,
|
|
248
|
+
'',
|
|
249
|
+
'**本轮只输出「## 注释」这一节**,不要输出「## 详解」或其它章节,不要前言与结语:',
|
|
250
|
+
'',
|
|
251
|
+
'## 注释',
|
|
252
|
+
'**整节只有一个代码块**(用 ``` 围起来,尽可能标上语言),**注释就写在代码块里面**,',
|
|
253
|
+
'贴着它解释的那条语句,看起来像一份"带批注的代码清单":',
|
|
254
|
+
'',
|
|
255
|
+
'```',
|
|
256
|
+
'// 这条语句在做什么(用该语言的注释符号)',
|
|
257
|
+
'<原代码这一句>',
|
|
258
|
+
'// 下一条语句在做什么',
|
|
259
|
+
'<原代码下一句>',
|
|
260
|
+
'```',
|
|
261
|
+
'',
|
|
262
|
+
'块内规则:',
|
|
263
|
+
'- 原代码**逐字不变**:缩进、空行、注释、变量名全部保留;不要补全、不要精简、不要加行号、不要改写成伪代码。',
|
|
264
|
+
'- 在**每一条语句/逻辑块之前**加一行注释,用**该语言的注释符号**(`//`、`#`、`--`、`<!-- -->`、`<!--` 等),',
|
|
265
|
+
' 注释紧贴代码、中间不插空行;注释的缩进跟着它解释的那行代码走。',
|
|
266
|
+
'- **每一句都要覆盖**,不要跳句;讲「这行为什么这么写、和整体什么关系、有什么副作用或坑」,',
|
|
267
|
+
' 不要复述语言基础语法("这是一个 if 语句"这种不要写)。',
|
|
268
|
+
'- 太长的语句可以在其后补一行注释,但不要撕裂代码本身。',
|
|
269
|
+
'- 每条注释尽量一两句说完;说不完就换行再起一行注释(小窗很窄,一条注释太长会被折成好几行),不要写成一大段。',
|
|
270
|
+
'',
|
|
271
|
+
'代码块**外面**只留最后一行,固定写成引用行作为总结:',
|
|
272
|
+
'> 小结:<这段代码整体在干什么、值得点出的地方>',
|
|
273
|
+
'- 一行说清,话要顺:用日常话讲它在做什么,顺带点出关键路径、复杂度、副作用或坑;',
|
|
274
|
+
' 没什么可点就只说在做什么,别硬凑,也别把这一行拆成好几段;',
|
|
275
|
+
'- 用自己的话讲,不要照抄代码,也不要复述块内的逐句注释;',
|
|
276
|
+
'- 说清楚为止:该长就长、该短就短,不卡字数;也不要为整齐而拉长。',
|
|
277
|
+
'',
|
|
278
|
+
'规则:',
|
|
279
|
+
'- 只处理【选中文字】这段代码;上下文只用来理解它,不要注释上下文里的其它代码。',
|
|
280
|
+
'- 除了最后那一行小结,**不要在代码块外再写逐句注释**(注释已经在块内了)。',
|
|
281
|
+
'- 用中文讲解;术语、API 名保留原文。',
|
|
282
|
+
'- 不要出现「以下是」「希望对你有所帮助」之类的客套话。',
|
|
283
|
+
].join('\n')
|
|
284
|
+
|
|
285
|
+
/** 只输出「详解」一节时用(第二阶段,带完整会话背景)。 */
|
|
286
|
+
const DETAIL_PROMPT = [
|
|
287
|
+
PROMPT_HEAD,
|
|
288
|
+
'',
|
|
289
|
+
'**本轮只输出「## 详解」这一节**,不要输出「## 翻译」或其它章节,不要前言与结语:',
|
|
290
|
+
'',
|
|
291
|
+
'## 详解',
|
|
292
|
+
'选中的这段文字是什么意思?把意思讲透——既专业,也让人看得懂;该长就长、该短就短。',
|
|
293
|
+
'(先看一眼它是怎么来的:真实对话/正文,还是示例、测试用例、演示输出、引用别人的话;',
|
|
294
|
+
'是后者就先说一句,再按它实际的意思解释。)',
|
|
295
|
+
'选中文字是单个英文单词时,带上音标(IPA;英式美式不同就都写,拿不准就不写)。',
|
|
296
|
+
].join('\n')
|
|
297
|
+
|
|
298
|
+
/** 一次请求的入参。 */
|
|
299
|
+
interface AnalyzeBody {
|
|
300
|
+
text?: unknown
|
|
301
|
+
context?: unknown
|
|
302
|
+
label?: unknown
|
|
303
|
+
title?: unknown
|
|
304
|
+
url?: unknown
|
|
305
|
+
/** 当前会话 id(客户端从 ctx.sessions.list 快照取),用于拉取会话背景。 */
|
|
306
|
+
sessionId?: unknown
|
|
307
|
+
/** 「重新生成」用:为 true 时跳过共享结果缓存。 */
|
|
308
|
+
refresh?: unknown
|
|
309
|
+
/** 两阶段:'translation'(首轮,仅翻译 + 8 条背景)| 'detail'(按需,详解 + 完整背景)。 */
|
|
310
|
+
stage?: unknown
|
|
311
|
+
/** 'code' | 'text':选中文字是不是代码(代码走「注释」提示词:代码块 + 逐句注释 + 小结)。 */
|
|
312
|
+
kind?: unknown
|
|
313
|
+
/** 本会话已用过的工具结果摘要(追问时带上,避免重复联网)。 */
|
|
314
|
+
toolDigest?: unknown
|
|
315
|
+
/** 小窗对话历史(A′):整段对话的持久化条目。 */
|
|
316
|
+
key?: unknown
|
|
317
|
+
parts?: unknown
|
|
318
|
+
pinned?: unknown
|
|
319
|
+
/** 单次请求的推理档位覆盖(off / low / high / max);不传用插件配置。 */
|
|
320
|
+
effort?: unknown
|
|
321
|
+
/** 小窗的「模型」控件:显式指定 provider + model(按清单校验,缺省跟随会话默认)。 */
|
|
322
|
+
provider?: unknown
|
|
323
|
+
model?: unknown
|
|
324
|
+
/** 网页模式(小窗里的开关):复杂问题默认用网页回答,并注入设计规范。 */
|
|
325
|
+
webAnswer?: unknown
|
|
326
|
+
/** 追问模式下用户这一轮的问题。 */
|
|
327
|
+
question?: unknown
|
|
328
|
+
/** 追问模式下的历史轮次:[{ role: 'user' | 'assistant', text }]。 */
|
|
329
|
+
history?: unknown
|
|
330
|
+
/** 升格为正式会话时的入参。 */
|
|
331
|
+
translation?: unknown
|
|
332
|
+
detail?: unknown
|
|
333
|
+
turns?: unknown
|
|
334
|
+
/** 诊断开关:为 true 时把实际发给模型的提示词回传在 SSE 的 start 事件里(客户端不传)。 */
|
|
335
|
+
debug?: unknown
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 会话背景读取结果。 */
|
|
339
|
+
interface SessionBackground {
|
|
340
|
+
/** 已格式化的对话记录(时间顺序,最后一条最新)。 */
|
|
341
|
+
transcript: string
|
|
342
|
+
/** 是否在记录里标出了选中文字所在的消息。 */
|
|
343
|
+
marked: boolean
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 会话背景读取入参。 */
|
|
347
|
+
interface BackgroundOptions {
|
|
348
|
+
maxMessages: number
|
|
349
|
+
maxChars: number
|
|
350
|
+
/** 用于在记录里定位选中位置(归一化后做包含匹配)。 */
|
|
351
|
+
marker: string
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** ctx.sessionQuery 的宽松视图(只用到 readSurface)。 */
|
|
355
|
+
interface SessionQueryLike {
|
|
356
|
+
readSurface?: (sessionId: string) => Promise<{ events?: readonly unknown[] }>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 解析出的模型路由。 */
|
|
360
|
+
interface Route {
|
|
361
|
+
provider: string
|
|
362
|
+
model: string
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 写一条 SSE 事件。 */
|
|
366
|
+
function sse(res: ServerResponse, payload: unknown): void {
|
|
367
|
+
if (res.writableEnded) return
|
|
368
|
+
try {
|
|
369
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
|
370
|
+
} catch {
|
|
371
|
+
/* 客户端已断开 */
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** 返回一个 JSON 响应。 */
|
|
376
|
+
/**
|
|
377
|
+
* 把模型**内联在正文里**的思考块拆出来。
|
|
378
|
+
*
|
|
379
|
+
* 为什么要它:有的模型(实测 opencode-go 上的 minimax-m3 偶发)不走 reasoning 通道,
|
|
380
|
+
* 而是把整段思考当正文吐出来,形如 `<think>The user wants…</think>`——
|
|
381
|
+
* 不处理的话,用户看到的"答案"就是一大段思考(实测报过"输出错乱、把思考也输出了")。
|
|
382
|
+
*
|
|
383
|
+
* 流式难点是标签可能被切成两块("...<thi" + "nk>..."),所以没找到标签时**留一小截尾巴**再输出。
|
|
384
|
+
*/
|
|
385
|
+
const THINK_TAGS = 'think|thinking|reasoning|analysis|scratchpad'
|
|
386
|
+
const THINK_OPEN = new RegExp(`<(${THINK_TAGS})\\s*>`, 'i')
|
|
387
|
+
const THINK_CLOSE = new RegExp(`</(${THINK_TAGS})\\s*>`, 'i')
|
|
388
|
+
const THINK_TAIL = 24
|
|
389
|
+
|
|
390
|
+
function createThinkFilter(): { push: (text: string) => { text: string; think: string }; flush: () => { text: string; think: string } } {
|
|
391
|
+
let buffer = ''
|
|
392
|
+
let inside = false
|
|
393
|
+
const drain = (final: boolean): { text: string; think: string } => {
|
|
394
|
+
let text = ''
|
|
395
|
+
let think = ''
|
|
396
|
+
for (;;) {
|
|
397
|
+
if (inside) {
|
|
398
|
+
const close = THINK_CLOSE.exec(buffer)
|
|
399
|
+
if (!close) {
|
|
400
|
+
if (final) {
|
|
401
|
+
think += buffer
|
|
402
|
+
buffer = ''
|
|
403
|
+
} else if (buffer.length > THINK_TAIL) {
|
|
404
|
+
think += buffer.slice(0, buffer.length - THINK_TAIL)
|
|
405
|
+
buffer = buffer.slice(-THINK_TAIL)
|
|
406
|
+
}
|
|
407
|
+
break
|
|
408
|
+
}
|
|
409
|
+
think += buffer.slice(0, close.index)
|
|
410
|
+
buffer = buffer.slice(close.index + close[0].length)
|
|
411
|
+
inside = false
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
const open = THINK_OPEN.exec(buffer)
|
|
415
|
+
if (!open) {
|
|
416
|
+
if (final) {
|
|
417
|
+
text += buffer
|
|
418
|
+
buffer = ''
|
|
419
|
+
} else if (buffer.length > THINK_TAIL) {
|
|
420
|
+
text += buffer.slice(0, buffer.length - THINK_TAIL)
|
|
421
|
+
buffer = buffer.slice(-THINK_TAIL)
|
|
422
|
+
}
|
|
423
|
+
break
|
|
424
|
+
}
|
|
425
|
+
text += buffer.slice(0, open.index)
|
|
426
|
+
buffer = buffer.slice(open.index + open[0].length)
|
|
427
|
+
inside = true
|
|
428
|
+
}
|
|
429
|
+
return { text, think }
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
push: (chunk: string) => {
|
|
433
|
+
buffer += chunk
|
|
434
|
+
return drain(false)
|
|
435
|
+
},
|
|
436
|
+
flush: () => drain(true),
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 检测"思考被写进正文"的泄漏段。
|
|
442
|
+
*
|
|
443
|
+
* 实测 omen-alpha 在 `off` 档(上游 none)时不走 reasoning 通道,把整段英文推理当正文吐出来:
|
|
444
|
+
* 「The user is asking which model I am. According to my instructions…我是 Omen Alpha。」
|
|
445
|
+
* —— 这种文本**不带任何标签**,靠 `<think>` 过滤器抓不到,只能按"开头是自我盘算"来认。
|
|
446
|
+
*
|
|
447
|
+
* 保守三条件,缺一不裁(宁可留着,也不误删正文):
|
|
448
|
+
* ① 逐句扫描,从开头起连续命中"元推理"句式;
|
|
449
|
+
* ② 命中段以英文为主(≥80% ASCII 字母);
|
|
450
|
+
* ③ 剩下的正文里有中文(说明真正的答案是中文,被裁的不是答案)。
|
|
451
|
+
*/
|
|
452
|
+
const META_SENTENCE =
|
|
453
|
+
/^\s*(?:The user|The question|The request|The person|According to (?:my|the)|Based on (?:my|the)|Per (?:my|the)|My (?:instructions|system prompt|guidelines)|I (?:should|must|need|will|am asked|always|usually|will just)|So I|Thus I|Therefore I|Instructed to|Now I (?:have|need|know|see)|Let me (?:verify|check|think|see|confirm)|I have (?:the|a|enough)|First,? I|OK,? (?:I|let)|用户(?:问|要求)的?是|根据(?:我的)?(?:指令|系统提示)|我应该|我需要|按照(?:我的)?(?:指令|系统提示))/i
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 宽松判据:开头是**英文盘算**、后面才是**中文答案**。
|
|
457
|
+
* 只用来"记住这个模型在关档会泄漏"(下次自动抬档),**不用来裁剪** ——
|
|
458
|
+
* 裁错了会误删正文,而记错了只是下次少一个档位可选,代价不对称。
|
|
459
|
+
*/
|
|
460
|
+
function looksLikeLeakedReasoning(text: string): boolean {
|
|
461
|
+
const body = String(text || '')
|
|
462
|
+
if (body.length < 40) return false
|
|
463
|
+
const cut = body.search(/[\u4e00-\u9fff]/)
|
|
464
|
+
if (cut < 30) return false
|
|
465
|
+
const head = body.slice(0, cut)
|
|
466
|
+
const tail = body.slice(cut)
|
|
467
|
+
if (!/[\u4e00-\u9fff]/.test(tail)) return false
|
|
468
|
+
const letters = head.replace(/[^A-Za-z]/g, '').length
|
|
469
|
+
return letters / head.length >= 0.5
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function splitLeakedReasoning(text: string): { prefix: string; rest: string } {
|
|
473
|
+
const body = String(text || '')
|
|
474
|
+
if (body.length < 40) return { prefix: '', rest: body }
|
|
475
|
+
// 注意:不能用 split(/(?<=[.。!!??])\s*/) —— 它会把句末空白吃掉,
|
|
476
|
+
// 于是拼回来的 prefix 与真正流出去的正文差一个空格,客户端 indexOf 匹配不上(实测踩过)。
|
|
477
|
+
// 这里保留分隔符本身,保证 parts.join('') === body。
|
|
478
|
+
const parts = body.match(/[^.。!!??]*[.。!!??]\s*|[^.。!!??]+$/g) ?? [body]
|
|
479
|
+
// 从句首开始,连续吃"不含中文的句子"作为泄漏段。
|
|
480
|
+
// 为什么不是"必须命中元推理句式":实测泄漏段里会夹着 "Previously I said something incorrect … — that was wrong."
|
|
481
|
+
// 这种自述句,逐句匹配元句式会在这里断掉、只裁掉前半段。结构判据(英文盘算 + 中文答案)更稳。
|
|
482
|
+
// 但仍然要求:整段里**至少有一句**命中元推理句式,避免把"英文开头的正常回答"误裁。
|
|
483
|
+
let index = 0
|
|
484
|
+
let ascii = 0
|
|
485
|
+
let metaHit = false
|
|
486
|
+
while (index < parts.length && !/[\u4e00-\u9fff]/.test(parts[index])) {
|
|
487
|
+
if (META_SENTENCE.test(parts[index])) metaHit = true
|
|
488
|
+
ascii += (parts[index].match(/[A-Za-z]/g) || []).length
|
|
489
|
+
index += 1
|
|
490
|
+
}
|
|
491
|
+
if (index === 0 || !metaHit) return { prefix: '', rest: body }
|
|
492
|
+
const head = parts.slice(0, index).join('')
|
|
493
|
+
const tail = parts.slice(index).join('')
|
|
494
|
+
if (!tail.trim()) return { prefix: '', rest: body } // 整篇都是盘算:不裁(可能是模型只说了这些)
|
|
495
|
+
const letters = head.replace(/[^A-Za-z]/g, '').length
|
|
496
|
+
if (head.length > 0 && letters / head.length < 0.5) return { prefix: '', rest: body }
|
|
497
|
+
if (!/[\u4e00-\u9fff]/.test(tail)) return { prefix: '', rest: body }
|
|
498
|
+
return { prefix: head, rest: tail }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* 正文里不该出现的"工具调用残渣"。
|
|
503
|
+
*
|
|
504
|
+
* 收尾轮**不带工具**(为了逼模型给结论),模型这时会把"还想再 read 一下"当正文吐出来,
|
|
505
|
+
* 而且用的可能是两种格式之一:
|
|
506
|
+
* ① 官方 safety 包装:`<ds_safety_tool_call>…</ds_safety_tool_call>`(客户端以前只认这个)
|
|
507
|
+
* ② DeepSeek 原生 DSML:全角竖线包起来的 `tool_calls / invoke / parameter` 块(实测漏过正文)
|
|
508
|
+
*
|
|
509
|
+
* 这里在**流里**就把它滤掉:客户端连 delta 都收不到,存盘/升格的内容也是干净的。
|
|
510
|
+
* (客户端那份 sanitizeToolResidue 留作兜底:旧缓存、历史回放这些路径不经过 host。)
|
|
511
|
+
*/
|
|
512
|
+
/** 全角竖线(U+FF5C)×2 + DSML + ×2:DSML 标记本身,用它拼正则避免源码里出现字面量。 */
|
|
513
|
+
const DSML_MARK = '\uFF5C\uFF5CDSML\uFF5C\uFF5C'
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* "残渣标签"的两种形态。
|
|
517
|
+
*
|
|
518
|
+
* DSML 这边**不能只认 `tool_calls`**:实测模型吐出来的是**逐标签式**的,而且 marker 与标签名之间
|
|
519
|
+
* **有一个空格** —— `<MARK parameter name="limit" string="false">40</MARK parameter>`、
|
|
520
|
+
* `<MARK invoke name="read">…</MARK invoke>`、`<MARK calls>…</MARK calls>`。
|
|
521
|
+
* 我第一版只匹配 `<\uFF5CDSML\uFF5Ctool_calls>`(无空格)→ 一个都没命中,残渣照漏(实测 4 轮漏 3 轮)。
|
|
522
|
+
*/
|
|
523
|
+
const DSML_TAG = new RegExp(`<\\/?\\s*${DSML_MARK}\\s*(?:tool_calls|calls|invoke|parameter)\\b[^>]*>`, 'g')
|
|
524
|
+
const SAFETY_TAG = /<\/?\s*ds_safety_tool_call\s*>/gi
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* 把正文里的"工具调用残渣"整段丢掉。
|
|
528
|
+
*
|
|
529
|
+
* 为什么必须在流里做:收尾轮不带工具(为了逼模型给结论),模型会把"还想再调一次工具"当正文吐出来,
|
|
530
|
+
* 客户端现有的 sanitizeToolResidue 只认官方 safety 包装,不认 DSML —— 用户看到的就是一段乱码标记。
|
|
531
|
+
*
|
|
532
|
+
* 算法:**按标签栈计数**。遇到开标签 depth+1、闭标签 depth-1;depth>0 期间的一切(含嵌套标签与参数值)
|
|
533
|
+
* 全部丢弃;流在 depth>0 时结束 → 剩下的整段丢掉。这样三种实测形态(整块/缺包裹/单个标签)都能盖住。
|
|
534
|
+
*/
|
|
535
|
+
function createResidueFilter(): { push: (text: string) => string; flush: () => string; dropped: () => number } {
|
|
536
|
+
const HOLD = 32 // 标签可能被切开,没命中时留一小截尾巴再输出
|
|
537
|
+
let buffer = ''
|
|
538
|
+
let depth = 0
|
|
539
|
+
let droppedChars = 0
|
|
540
|
+
const scan = (text: string): RegExpExecArray | null => {
|
|
541
|
+
DSML_TAG.lastIndex = 0
|
|
542
|
+
const dsml = DSML_TAG.exec(text)
|
|
543
|
+
SAFETY_TAG.lastIndex = 0
|
|
544
|
+
const safety = SAFETY_TAG.exec(text)
|
|
545
|
+
if (dsml && safety) return dsml.index <= safety.index ? dsml : safety
|
|
546
|
+
return dsml ?? safety
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* 该留多长的尾巴:除了固定 32 字,还要**精确扣住"半个标签"**。
|
|
550
|
+
* 踩过的坑:DSML 标签本身有 40+ 字(`<MARK parameter name="include" string="true">`),
|
|
551
|
+
* 只留 32 字时标签被切在中间 → 前半截被当正文发出去,后半截又不成标签 → 残渣漏进正文(实测漏过)。
|
|
552
|
+
* 判据:末尾最后一个 '<' 之后没有 '>',就认为它是未完成的标签,从那个 '<' 起全部扣住。
|
|
553
|
+
*/
|
|
554
|
+
const holdOf = (text: string): number => {
|
|
555
|
+
const lastLt = text.lastIndexOf('<')
|
|
556
|
+
if (lastLt >= 0 && text.indexOf('>', lastLt) < 0) return Math.max(HOLD, text.length - lastLt)
|
|
557
|
+
return HOLD
|
|
558
|
+
}
|
|
559
|
+
const drain = (final: boolean): string => {
|
|
560
|
+
let out = ''
|
|
561
|
+
for (;;) {
|
|
562
|
+
const hit = scan(buffer)
|
|
563
|
+
if (!hit) {
|
|
564
|
+
const hold = holdOf(buffer)
|
|
565
|
+
if (depth > 0) {
|
|
566
|
+
// 在残渣块内部:丢掉,只留足以认出闭合标签的尾巴
|
|
567
|
+
if (final) buffer = ''
|
|
568
|
+
else buffer = buffer.length > hold ? buffer.slice(-hold) : buffer
|
|
569
|
+
break
|
|
570
|
+
}
|
|
571
|
+
if (final) {
|
|
572
|
+
out += buffer
|
|
573
|
+
buffer = ''
|
|
574
|
+
} else if (buffer.length > hold) {
|
|
575
|
+
out += buffer.slice(0, buffer.length - hold)
|
|
576
|
+
buffer = buffer.slice(-hold)
|
|
577
|
+
}
|
|
578
|
+
break
|
|
579
|
+
}
|
|
580
|
+
if (depth === 0) out += buffer.slice(0, hit.index)
|
|
581
|
+
const closing = hit[0].charAt(1) === '/'
|
|
582
|
+
if (!closing) droppedChars += hit[0].length
|
|
583
|
+
depth = closing ? Math.max(0, depth - 1) : depth + 1
|
|
584
|
+
buffer = buffer.slice(hit.index + hit[0].length)
|
|
585
|
+
}
|
|
586
|
+
return out
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
push: (text: string) => {
|
|
590
|
+
buffer += text
|
|
591
|
+
return drain(false)
|
|
592
|
+
},
|
|
593
|
+
flush: () => {
|
|
594
|
+
const out = drain(true)
|
|
595
|
+
depth = 0 // 本轮结束就把状态归零:未闭合的残渣块不许影响下一轮
|
|
596
|
+
buffer = ''
|
|
597
|
+
return out
|
|
598
|
+
},
|
|
599
|
+
/** 本轮被丢掉了多少字(用来判断"末尾残渣被删掉后句子没写完")。 */
|
|
600
|
+
dropped: () => droppedChars,
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
605
|
+
const text = JSON.stringify(body)
|
|
606
|
+
res.writeHead(status, {
|
|
607
|
+
'content-type': 'application/json; charset=utf-8',
|
|
608
|
+
'content-length': Buffer.byteLength(text),
|
|
609
|
+
'cache-control': 'no-store',
|
|
610
|
+
})
|
|
611
|
+
res.end(text)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 读取请求体(带长度上限)。 */
|
|
615
|
+
function readBody(req: IncomingMessage, limit: number): Promise<string> {
|
|
616
|
+
return new Promise((resolve, reject) => {
|
|
617
|
+
let size = 0
|
|
618
|
+
const chunks: Buffer[] = []
|
|
619
|
+
req.on('data', (chunk: Buffer) => {
|
|
620
|
+
size += chunk.length
|
|
621
|
+
if (size > limit) {
|
|
622
|
+
reject(new Error('请求体过大'))
|
|
623
|
+
req.destroy()
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
chunks.push(chunk)
|
|
627
|
+
})
|
|
628
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
629
|
+
req.on('error', reject)
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** 截断到指定字符数(保留省略标记)。 */
|
|
634
|
+
function clampText(raw: unknown, max: number): string {
|
|
635
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
636
|
+
return text.length > max ? `${text.slice(0, max)}\n…(已截断)` : text
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* 「网页模式」的设计规范:从插件自带的 `skills/web-design/SKILL.md` 读(按 mtime 缓存)。
|
|
641
|
+
*
|
|
642
|
+
* 做成文件而不是写死在代码里:改规范不用重新构建,用户也能自己替换成喜欢的版本。
|
|
643
|
+
* 读不到就退回一段精简规则(宁可少讲,也不能让请求挂掉)。
|
|
644
|
+
*/
|
|
645
|
+
let designSkillCache: { at: number; path: string; text: string } = { at: 0, path: '', text: '' }
|
|
646
|
+
const DESIGN_SKILL_FALLBACK = [
|
|
647
|
+
'单列、宽度 100%、不要横向滚动;不引外部资源;一套主题(别深浅混用)。',
|
|
648
|
+
'主标题 20–22px、正文 14px、行高 1.6–1.75;一个主色 + 中性灰阶。',
|
|
649
|
+
'只在内容真是序列时才编号;避免"什么内容都套同一张圆角卡片"这类模板感。',
|
|
650
|
+
].join('\n')
|
|
651
|
+
/** 模块级读文件失败时的告警(host 侧 logger 在 apply 里拿不到,走 console)。 */
|
|
652
|
+
function ctxLoggerWarn(message: string): void {
|
|
653
|
+
try {
|
|
654
|
+
console.warn(`[dsh-selection-explain] ${message}`)
|
|
655
|
+
} catch {
|
|
656
|
+
/* noop */
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function loadDesignSkill(): Promise<string> {
|
|
661
|
+
const path = fileURLToPath(new URL('../skills/web-design/SKILL.md', import.meta.url))
|
|
662
|
+
try {
|
|
663
|
+
const info = await stat(path)
|
|
664
|
+
if (designSkillCache.path === path && designSkillCache.at === info.mtimeMs && designSkillCache.text) {
|
|
665
|
+
return designSkillCache.text
|
|
666
|
+
}
|
|
667
|
+
const raw = await readFile(path, 'utf8')
|
|
668
|
+
// 去掉 frontmatter,只把规则喂给模型
|
|
669
|
+
const body = raw.replace(/^---[\s\S]*?---\s*/, '').trim()
|
|
670
|
+
designSkillCache = { at: info.mtimeMs, path, text: body }
|
|
671
|
+
return body
|
|
672
|
+
} catch (error) {
|
|
673
|
+
ctxLoggerWarn(`读取设计规范失败(${path}):${String((error as Error)?.message ?? error)}`)
|
|
674
|
+
return DESIGN_SKILL_FALLBACK
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** 追问模式(对话小窗)的系统提示词:不再要求两节结构,就是围绕这段文字聊天。 */
|
|
679
|
+
const CHAT_SYSTEM_PROMPT = [
|
|
680
|
+
'你是 DeepSeek Harness 中「划词解读」小窗里的助手,正在辅助用户对一段【选中文字】及其上下文进行理解。',
|
|
681
|
+
'用户问什么就答什么:可以问这段文字本身,也可以顺着它问到别的,不必把话题拉回选中文字。',
|
|
682
|
+
'回答简洁、准确、具体;前情里已经给过的翻译/详解不用重复,引用时一句话带过即可。',
|
|
683
|
+
'要画图、做可视化或写页面时,直接给**完整的单文件 HTML**(放进 ```html 代码块,样式和脚本都内联),',
|
|
684
|
+
'小窗会把它当场渲染成可交互的网页;不要只给片段,也不要说"你可以把它保存成 .html 再打开"。',
|
|
685
|
+
].join('\n')
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 粗略判断"是不是公网地址",口径与 dsh-web-fetch-http 的 SSRF 防护一致(非 unicast 一律拒绝)。
|
|
689
|
+
*
|
|
690
|
+
* 为什么要这个:本机若跑着 TUN/fake-IP 代理(DNS 服务器是 198.18.0.2 这类),所有域名都会
|
|
691
|
+
* 解析进 198.18.0.0/15 保留段 → web_fetch 每次都秒失败("resolves to a non-public IP address"),
|
|
692
|
+
* 而那个防护**没有配置开关**。与其让模型白跑一轮,不如探测出来直接不给它这个工具。
|
|
693
|
+
*/
|
|
694
|
+
function isPublicAddress(address: string): boolean {
|
|
695
|
+
const value = String(address || '').trim()
|
|
696
|
+
if (!value) return false
|
|
697
|
+
if (value.indexOf(':') >= 0) {
|
|
698
|
+
const lower = value.toLowerCase()
|
|
699
|
+
if (lower === '::1' || lower === '::') return false
|
|
700
|
+
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return false
|
|
701
|
+
if (/^fe[89ab][0-9a-f]:/.test(lower)) return false
|
|
702
|
+
return true
|
|
703
|
+
}
|
|
704
|
+
const parts = value.split('.').map((part) => Number(part))
|
|
705
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
|
|
706
|
+
const a = parts[0] as number
|
|
707
|
+
const b = parts[1] as number
|
|
708
|
+
const c = parts[2] as number
|
|
709
|
+
if (a === 0 || a === 10 || a === 127) return false
|
|
710
|
+
if (a === 100 && b >= 64 && b <= 127) return false
|
|
711
|
+
if (a === 169 && b === 254) return false
|
|
712
|
+
if (a === 172 && b >= 16 && b <= 31) return false
|
|
713
|
+
if (a === 192 && (b === 168 || b === 0)) return false
|
|
714
|
+
if (a === 198 && (b === 18 || b === 19)) return false // 198.18.0.0/15 基准测试段 ← fake-IP 落这里
|
|
715
|
+
if (a === 198 && b === 51 && c === 100) return false
|
|
716
|
+
if (a === 203 && b === 0 && c === 113) return false
|
|
717
|
+
if (a >= 224) return false
|
|
718
|
+
return true
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** 本机能否真正抓取网页(结果缓存 5 分钟,探测失败则不动它)。 */
|
|
722
|
+
let fetchProbe: { at: number; usable: boolean; sample: string } = { at: 0, usable: true, sample: '' }
|
|
723
|
+
async function webFetchUsable(): Promise<{ usable: boolean; sample: string }> {
|
|
724
|
+
if (Date.now() - fetchProbe.at < 300_000) return fetchProbe
|
|
725
|
+
try {
|
|
726
|
+
const addresses = await lookup('example.com', { all: true })
|
|
727
|
+
const usable = addresses.length > 0 && addresses.every((entry) => isPublicAddress(entry.address))
|
|
728
|
+
fetchProbe = { at: Date.now(), usable, sample: addresses.map((entry) => entry.address).join(', ') }
|
|
729
|
+
} catch {
|
|
730
|
+
fetchProbe = { at: Date.now(), usable: true, sample: '' }
|
|
731
|
+
}
|
|
732
|
+
return fetchProbe
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* 路径是否落在允许的根目录里(root 为空 = 不设限)。
|
|
737
|
+
*
|
|
738
|
+
* 为什么需要它:read/grep/glob 能读**用户有权读的任何文件**(`~/.ssh/id_rsa`、`.env`、
|
|
739
|
+
* `~/.dsh/settings.yaml`……),而划词的触发源(选中文字/上下文/搜索结果)可能是别人写的。
|
|
740
|
+
* 这里只做词法判断(`resolve` 不跟随符号链接),挡住"顺着一段被注入的文字去读项目外的机密"。
|
|
741
|
+
*/
|
|
742
|
+
export function isInsideRoot(root: string, target: string): boolean {
|
|
743
|
+
if (!root) return true
|
|
744
|
+
const raw = String(target ?? '').trim()
|
|
745
|
+
if (!raw) return true
|
|
746
|
+
// `~` 一律拒:要么指向家目录(多数情况下在项目外),要么后端根本不展开
|
|
747
|
+
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) return false
|
|
748
|
+
const base = resolve(root)
|
|
749
|
+
const full = resolve(base, raw)
|
|
750
|
+
return full === base || full.startsWith(base.endsWith(sep) ? base : base + sep)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** 文件类工具:这些工具的参数里有 path,需要过边界检查。 */
|
|
754
|
+
const FS_TOOL_NAMES = ['read', 'grep', 'glob']
|
|
755
|
+
|
|
756
|
+
/** 工具调用的人类可读描述(给前端状态栏用)。 */
|
|
757
|
+
function describeToolCall(name: string, args: unknown): string {
|
|
758
|
+
const record = (args ?? {}) as Record<string, unknown>
|
|
759
|
+
// command 也要认:bash 这类工具没有 query/url,不认的话摘要里只剩「bash」两个字
|
|
760
|
+
const raw =
|
|
761
|
+
record.query ??
|
|
762
|
+
record.queries ??
|
|
763
|
+
record.q ??
|
|
764
|
+
record.url ??
|
|
765
|
+
record.prompt ??
|
|
766
|
+
record.command ??
|
|
767
|
+
record.file_path ??
|
|
768
|
+
record.path ??
|
|
769
|
+
record.pattern
|
|
770
|
+
const query = Array.isArray(raw) ? raw[0] : raw
|
|
771
|
+
return typeof query === 'string' ? query.slice(0, 120) : name
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** 从工具结果里抽出:文本长度 / 预览 / 链接(前端只用它拼追问摘要,**不渲染**)。 */
|
|
775
|
+
function digestToolResult(content: ContentBlock[]): { chars: number; preview: string; urls: string[] } {
|
|
776
|
+
const text = content
|
|
777
|
+
.map((block) => (block.type === 'text' ? block.text : ''))
|
|
778
|
+
.join('\n')
|
|
779
|
+
.trim()
|
|
780
|
+
const urls: string[] = []
|
|
781
|
+
const matches = text.match(/https?:\/\/[^\s<>"')\]]+/g) ?? []
|
|
782
|
+
for (const raw of matches) {
|
|
783
|
+
const url = raw.replace(/[.,;:)\]}]+$/, '')
|
|
784
|
+
if (!urls.includes(url)) urls.push(url)
|
|
785
|
+
if (urls.length >= 5) break
|
|
786
|
+
}
|
|
787
|
+
return { chars: text.length, preview: text.slice(0, 600), urls }
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** 执行一个工具并把结果压成模型可读的内容块(带长度上限)。 */
|
|
791
|
+
async function runTool(
|
|
792
|
+
ctx: Context,
|
|
793
|
+
name: string,
|
|
794
|
+
args: unknown,
|
|
795
|
+
signal: AbortSignal,
|
|
796
|
+
maxChars: number,
|
|
797
|
+
agent: unknown,
|
|
798
|
+
): Promise<{ content: ContentBlock[]; isError: boolean }> {
|
|
799
|
+
const registry = ctx.get('tools') as
|
|
800
|
+
| {
|
|
801
|
+
execute?: (input: {
|
|
802
|
+
callId: string
|
|
803
|
+
name: string
|
|
804
|
+
arguments: unknown
|
|
805
|
+
signal: AbortSignal
|
|
806
|
+
agent?: unknown
|
|
807
|
+
}) => Promise<{ isError: boolean; content?: ContentBlock[] }>
|
|
808
|
+
}
|
|
809
|
+
| undefined
|
|
810
|
+
if (typeof registry?.execute !== 'function') {
|
|
811
|
+
return { content: [{ type: 'text', text: '(工具不可用)' }], isError: true }
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const result = await registry.execute({
|
|
815
|
+
callId: `sel-tool-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
816
|
+
name,
|
|
817
|
+
arguments: args,
|
|
818
|
+
signal,
|
|
819
|
+
...(agent ? { agent } : {}),
|
|
820
|
+
})
|
|
821
|
+
const blocks = Array.isArray(result.content) ? result.content : []
|
|
822
|
+
let used = 0
|
|
823
|
+
const clamped: ContentBlock[] = []
|
|
824
|
+
for (const block of blocks) {
|
|
825
|
+
if (block.type !== 'text') continue
|
|
826
|
+
const remain = maxChars - used
|
|
827
|
+
if (remain <= 0) break
|
|
828
|
+
const text = block.text.length > remain ? `${block.text.slice(0, remain)}
|
|
829
|
+
…(已截断)` : block.text
|
|
830
|
+
used += text.length
|
|
831
|
+
clamped.push({ type: 'text', text })
|
|
832
|
+
}
|
|
833
|
+
if (clamped.length === 0) clamped.push({ type: 'text', text: '(工具没有返回文本内容)' })
|
|
834
|
+
return { content: clamped, isError: result.isError === true }
|
|
835
|
+
} catch (error) {
|
|
836
|
+
return { content: [{ type: 'text', text: `工具执行失败:${String((error as Error)?.message ?? error)}` }], isError: true }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** 请求里带的会话 id(空串 = 客户端没取到)。 */
|
|
841
|
+
function sessionIdOf(body: AnalyzeBody): string {
|
|
842
|
+
return typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** 空白归一化(用于在会话记录里定位选中文字)。 */
|
|
846
|
+
function normalizeSpace(text: string): string {
|
|
847
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** 从消息 content 里取纯文本(只取 text 块;工具调用/结果不进来,避免噪音挤占预算)。 */
|
|
851
|
+
function messageText(content: unknown): string {
|
|
852
|
+
if (!Array.isArray(content)) return ''
|
|
853
|
+
const parts: string[] = []
|
|
854
|
+
for (const block of content) {
|
|
855
|
+
if (!block || typeof block !== 'object') continue
|
|
856
|
+
const typed = block as { type?: unknown; text?: unknown }
|
|
857
|
+
if (typed.type === 'text' && typeof typed.text === 'string') parts.push(typed.text)
|
|
858
|
+
}
|
|
859
|
+
return parts.join('\n').trim()
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** 会话背景里的一条对话。 */
|
|
863
|
+
interface TranscriptEntry {
|
|
864
|
+
role: string
|
|
865
|
+
text: string
|
|
866
|
+
marked: boolean
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* 把会话的当前模型界面整理成「会话背景」文本。
|
|
871
|
+
*
|
|
872
|
+
* 只保留**真实对话**:用户真正发的话(source.kind === 'user')与助手回复的正文。
|
|
873
|
+
* 三类内容一律剔除,否则会把有效窗口挤没(实测:430 条 surface 事件里绝大多数是
|
|
874
|
+
* 工具调用/结果,且 harness 注入的 system-reminder 单条可达 1.2 万字):
|
|
875
|
+
* - tool/result 与纯工具调用的助手步骤(无正文)
|
|
876
|
+
* - system/message(系统提示词)
|
|
877
|
+
* - source.kind !== 'user' 的用户角色消息(运行环境快照、skill 目录等 harness 注入)
|
|
878
|
+
*/
|
|
879
|
+
export function transcriptOf(events: readonly unknown[], options: BackgroundOptions): SessionBackground {
|
|
880
|
+
const entries: TranscriptEntry[] = []
|
|
881
|
+
for (const event of events) {
|
|
882
|
+
if (!event || typeof event !== 'object') continue
|
|
883
|
+
const typed = event as { type?: unknown; data?: unknown }
|
|
884
|
+
const data = (typed.data ?? {}) as {
|
|
885
|
+
message?: { content?: unknown }
|
|
886
|
+
content?: unknown
|
|
887
|
+
source?: { kind?: unknown }
|
|
888
|
+
}
|
|
889
|
+
let role = ''
|
|
890
|
+
let content: unknown
|
|
891
|
+
if (typed.type === 'user/message') {
|
|
892
|
+
if (data.source && data.source.kind !== 'user') continue // harness 注入的上下文
|
|
893
|
+
role = '用户'
|
|
894
|
+
content = data.content
|
|
895
|
+
} else if (typed.type === 'assistant/message') {
|
|
896
|
+
role = '助手'
|
|
897
|
+
content = data.message?.content
|
|
898
|
+
} else {
|
|
899
|
+
continue
|
|
900
|
+
}
|
|
901
|
+
const text = messageText(content)
|
|
902
|
+
if (!text) continue
|
|
903
|
+
entries.push({ role, text, marked: false })
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const needle = normalizeSpace(options.marker)
|
|
907
|
+
let marked = false
|
|
908
|
+
if (needle.length >= 2) {
|
|
909
|
+
for (const entry of entries) {
|
|
910
|
+
if (normalizeSpace(entry.text).includes(needle)) {
|
|
911
|
+
entry.marked = true
|
|
912
|
+
marked = true
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// 窗口 = 选中文字所在的那条消息 **向上** 数 maxMessages 条(含它自己),
|
|
918
|
+
// 之后的对话不进背景;找不到选中位置时退回「最近 maxMessages 条」。
|
|
919
|
+
// 默认不截断(maxChars = 0):消息整条进背景,不做单条/总量裁剪。
|
|
920
|
+
let anchorIndex = -1
|
|
921
|
+
if (marked) {
|
|
922
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
923
|
+
if (entries[i]?.marked) {
|
|
924
|
+
anchorIndex = i
|
|
925
|
+
break
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
const endIndex = anchorIndex >= 0 ? anchorIndex : entries.length - 1
|
|
930
|
+
const startIndex = Math.max(0, endIndex - options.maxMessages + 1)
|
|
931
|
+
const picked: TranscriptEntry[] = entries.slice(startIndex, endIndex + 1).map((entry) => ({ ...entry }))
|
|
932
|
+
|
|
933
|
+
// 可选总量上限(配置里显式设 >0 时才生效):超了就丢更早的消息
|
|
934
|
+
if (options.maxChars > 0) {
|
|
935
|
+
let used = picked.reduce((sum, entry) => sum + entry.text.length, 0)
|
|
936
|
+
while (picked.length > 1 && used > options.maxChars) {
|
|
937
|
+
const dropped = picked.shift()
|
|
938
|
+
used -= dropped ? dropped.text.length : 0
|
|
939
|
+
}
|
|
940
|
+
for (const entry of picked) {
|
|
941
|
+
if (entry.text.length > options.maxChars) entry.text = `${entry.text.slice(0, options.maxChars)}…`
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (picked.length === 0) return { transcript: '', marked: false }
|
|
946
|
+
const omitted = Math.max(0, entries.length - picked.length)
|
|
947
|
+
if (omitted > 0) picked.unshift({ role: '提示', text: `(选中位置之前还有 ${omitted} 条更早的消息,未包含)`, marked: false })
|
|
948
|
+
|
|
949
|
+
const transcript = picked.map((entry) => `${entry.marked ? '▶ ' : ' '}${entry.role}:${entry.text}`).join('\n')
|
|
950
|
+
return { transcript, marked: picked.some((entry) => entry.marked) }
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** 注册路由、解析模型路由、转发 LLM 流。 */
|
|
954
|
+
export function apply(ctx: Context, rawConfig: Config): void {
|
|
955
|
+
const config: Config = {
|
|
956
|
+
provider: rawConfig?.provider ?? '',
|
|
957
|
+
model: rawConfig?.model ?? '',
|
|
958
|
+
reasoningEffort: rawConfig?.reasoningEffort ?? 'high',
|
|
959
|
+
translationReasoningEffort: rawConfig?.translationReasoningEffort ?? 'low',
|
|
960
|
+
historyMaxEntries: rawConfig?.historyMaxEntries ?? 20,
|
|
961
|
+
temperature: rawConfig?.temperature ?? -1,
|
|
962
|
+
maxTokens: rawConfig?.maxTokens ?? 0,
|
|
963
|
+
timeoutMs: rawConfig?.timeoutMs ?? 300000,
|
|
964
|
+
maxSelectionChars: rawConfig?.maxSelectionChars ?? 4000,
|
|
965
|
+
maxContextChars: rawConfig?.maxContextChars ?? 12000,
|
|
966
|
+
maxRequestsPerMinute: rawConfig?.maxRequestsPerMinute ?? 40,
|
|
967
|
+
sessionContext: rawConfig?.sessionContext ?? true,
|
|
968
|
+
sessionContextMaxMessages: rawConfig?.sessionContextMaxMessages ?? 24,
|
|
969
|
+
sessionContextFastMessages: rawConfig?.sessionContextFastMessages ?? 8,
|
|
970
|
+
sessionContextMaxChars: rawConfig?.sessionContextMaxChars ?? 0,
|
|
971
|
+
resultCacheTtlMs: rawConfig?.resultCacheTtlMs ?? 600000,
|
|
972
|
+
tools: rawConfig?.tools ?? true,
|
|
973
|
+
toolNames: rawConfig?.toolNames ?? 'advanced_search,platform_search,read,grep,glob',
|
|
974
|
+
fallbackToolNames: rawConfig?.fallbackToolNames ?? 'web_search',
|
|
975
|
+
maxToolRounds: rawConfig?.maxToolRounds ?? 2,
|
|
976
|
+
toolResultMaxChars: rawConfig?.toolResultMaxChars ?? 3000,
|
|
977
|
+
toolReadRoot: rawConfig?.toolReadRoot ?? '',
|
|
978
|
+
chatReasoningEffort: rawConfig?.chatReasoningEffort ?? 'high',
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** 兜底路由:第一次真实 LLM 调用后捕获。 */
|
|
982
|
+
let captured: Route | null = null
|
|
983
|
+
ctx.on('llm/stream', (options, next) => {
|
|
984
|
+
captured = { provider: options.provider, model: options.model }
|
|
985
|
+
return next()
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
/** 解析本次调用使用的模型路由。 */
|
|
989
|
+
/**
|
|
990
|
+
* 可选模型清单(带每个模型支持的推理等级)。
|
|
991
|
+
*
|
|
992
|
+
* 用 `llm.listProviders()`(同步)→ `llm.listModels(provider)`(异步,可能触发端点发现)→
|
|
993
|
+
* `llm.resolveModel(provider, model)` 拿 `reasoning.efforts`。因为可能慢,这里做 60 秒缓存,
|
|
994
|
+
* 并且**只在 /models 路由里调用**,不进 ping / analyze 的热路径。
|
|
995
|
+
*/
|
|
996
|
+
type ModelChoice = {
|
|
997
|
+
provider: string
|
|
998
|
+
providerName: string
|
|
999
|
+
model: string
|
|
1000
|
+
name: string
|
|
1001
|
+
efforts: Array<{ id: string; name: string }>
|
|
1002
|
+
defaultEffort: string | null
|
|
1003
|
+
}
|
|
1004
|
+
let modelCache: { at: number; items: ModelChoice[] } = { at: 0, items: [] }
|
|
1005
|
+
/** 哪些 provider/model 在 "关" 档会把思考写进正文(实测过一次就记住,之后自动改用「低」)。 */
|
|
1006
|
+
const leakyOnOff = new Set<string>()
|
|
1007
|
+
const leakyFile = (): string => join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'leaky-off.json')
|
|
1008
|
+
let leakyLoaded = false
|
|
1009
|
+
|
|
1010
|
+
/** 载入"关档会泄漏思考"的模型清单(跨重启保留;文件坏了就当空)。 */
|
|
1011
|
+
async function loadLeaky(): Promise<void> {
|
|
1012
|
+
if (leakyLoaded) return
|
|
1013
|
+
leakyLoaded = true
|
|
1014
|
+
try {
|
|
1015
|
+
const raw = await readFile(leakyFile(), 'utf8')
|
|
1016
|
+
const parsed = JSON.parse(raw) as { routes?: unknown }
|
|
1017
|
+
if (Array.isArray(parsed?.routes)) {
|
|
1018
|
+
for (const item of parsed.routes) if (typeof item === 'string' && item) leakyOnOff.add(item)
|
|
1019
|
+
}
|
|
1020
|
+
} catch {
|
|
1021
|
+
/* 没有这个文件是常态 */
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function saveLeaky(): void {
|
|
1026
|
+
const body = JSON.stringify({ version: 1, routes: Array.from(leakyOnOff) }, null, 0)
|
|
1027
|
+
void writeFile(leakyFile(), body, 'utf8').catch((error: unknown) => {
|
|
1028
|
+
ctx.logger?.warn?.(`[${name}] 泄漏清单写盘失败:${String((error as Error)?.message ?? error)}`)
|
|
1029
|
+
})
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** 记住一个"关档会泄漏"的模型(内存 + 落盘)。 */
|
|
1033
|
+
function markLeaky(route: string): void {
|
|
1034
|
+
if (leakyOnOff.has(route)) return
|
|
1035
|
+
leakyOnOff.add(route)
|
|
1036
|
+
if (leakyLoaded) saveLeaky()
|
|
1037
|
+
}
|
|
1038
|
+
const MODEL_CACHE_MS = 60_000
|
|
1039
|
+
|
|
1040
|
+
async function listModelChoices(force = false): Promise<ModelChoice[]> {
|
|
1041
|
+
if (!force && modelCache.items.length > 0 && Date.now() - modelCache.at < MODEL_CACHE_MS) return modelCache.items
|
|
1042
|
+
const llm = ctx.get('llm') as
|
|
1043
|
+
| {
|
|
1044
|
+
listProviders?: () => Array<{ id?: string; name?: string }>
|
|
1045
|
+
listModels?: (provider: string) => Promise<Array<{ id?: string; name?: string }>>
|
|
1046
|
+
/** 服务上的公开方法叫 resolveModelInfo;resolveModel 是 adapter 侧的(实测踩过:调错名字 → efforts 永远为空) */
|
|
1047
|
+
resolveModelInfo?: (
|
|
1048
|
+
provider: string,
|
|
1049
|
+
model: string,
|
|
1050
|
+
signal?: AbortSignal,
|
|
1051
|
+
) => Promise<{ reasoning?: { efforts?: Array<{ id?: string; name?: string }>; defaultEffort?: string } } | null>
|
|
1052
|
+
resolveModel?: (
|
|
1053
|
+
provider: string,
|
|
1054
|
+
model: string,
|
|
1055
|
+
) => Promise<{ reasoning?: { efforts?: Array<{ id?: string; name?: string }>; defaultEffort?: string } } | null>
|
|
1056
|
+
}
|
|
1057
|
+
| undefined
|
|
1058
|
+
const items: ModelChoice[] = []
|
|
1059
|
+
const providers = llm?.listProviders?.() ?? []
|
|
1060
|
+
for (const provider of providers) {
|
|
1061
|
+
if (!provider?.id || typeof llm?.listModels !== 'function') continue
|
|
1062
|
+
let models: Array<{ id?: string; name?: string }> = []
|
|
1063
|
+
try {
|
|
1064
|
+
models = (await llm.listModels(provider.id)) ?? []
|
|
1065
|
+
} catch {
|
|
1066
|
+
models = []
|
|
1067
|
+
}
|
|
1068
|
+
for (const model of models) {
|
|
1069
|
+
if (!model?.id) continue
|
|
1070
|
+
let efforts: Array<{ id: string; name: string }> = []
|
|
1071
|
+
let defaultEffort: string | null = null
|
|
1072
|
+
try {
|
|
1073
|
+
// 两个方法签名不同,各自显式标注,避免联合类型把返回值退化成 any
|
|
1074
|
+
type Resolved = { reasoning?: { efforts?: Array<{ id?: string; name?: string }>; defaultEffort?: string } } | null
|
|
1075
|
+
let info: Resolved = null
|
|
1076
|
+
if (typeof llm.resolveModelInfo === 'function') {
|
|
1077
|
+
info = (await llm.resolveModelInfo(provider.id, model.id)) as Resolved
|
|
1078
|
+
} else if (typeof llm.resolveModel === 'function') {
|
|
1079
|
+
info = (await llm.resolveModel(provider.id, model.id)) as Resolved
|
|
1080
|
+
}
|
|
1081
|
+
const list = info?.reasoning?.efforts ?? []
|
|
1082
|
+
efforts = list
|
|
1083
|
+
.filter((item) => typeof item?.id === 'string' && item.id.length > 0)
|
|
1084
|
+
.map((item) => ({ id: String(item.id), name: String(item.name ?? item.id) }))
|
|
1085
|
+
defaultEffort = typeof info?.reasoning?.defaultEffort === 'string' ? info.reasoning.defaultEffort : null
|
|
1086
|
+
} catch {
|
|
1087
|
+
efforts = []
|
|
1088
|
+
}
|
|
1089
|
+
items.push({
|
|
1090
|
+
provider: provider.id,
|
|
1091
|
+
providerName: String(provider.name ?? provider.id),
|
|
1092
|
+
model: model.id,
|
|
1093
|
+
name: String(model.name ?? model.id),
|
|
1094
|
+
efforts,
|
|
1095
|
+
defaultEffort,
|
|
1096
|
+
})
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (items.length > 0) modelCache = { at: Date.now(), items }
|
|
1100
|
+
return items
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** 请求里带 provider+model 时优先用它(并按清单校验;清单还没加载出来就只做形状校验)。 */
|
|
1104
|
+
const resolveRoute = (override?: { provider?: string; model?: string }): Route | null => {
|
|
1105
|
+
if (override?.provider && override?.model) {
|
|
1106
|
+
const wanted = { provider: override.provider, model: override.model }
|
|
1107
|
+
const known = modelCache.items.some((item) => item.provider === wanted.provider && item.model === wanted.model)
|
|
1108
|
+
if (known || modelCache.items.length === 0) return wanted
|
|
1109
|
+
}
|
|
1110
|
+
if (config.provider && config.model) return { provider: config.provider, model: config.model }
|
|
1111
|
+
const service = ctx.get('agentDefaultModel') as AgentDefaultModelConfig | undefined
|
|
1112
|
+
const selection = service?.currentSelection?.()
|
|
1113
|
+
if (selection?.provider && selection?.model) {
|
|
1114
|
+
return { provider: selection.provider, model: selection.model }
|
|
1115
|
+
}
|
|
1116
|
+
const llm = ctx.get('llm') as
|
|
1117
|
+
| { listProviders?: () => Array<{ id?: string; models?: Array<{ id?: string }> }> }
|
|
1118
|
+
| undefined
|
|
1119
|
+
const first = llm?.listProviders?.()?.find((item) => (item.models?.length ?? 0) > 0)
|
|
1120
|
+
if (first?.id && first.models?.[0]?.id) return { provider: first.id, model: first.models[0].id }
|
|
1121
|
+
return captured
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** 分钟级限流。 */
|
|
1125
|
+
const hits: number[] = []
|
|
1126
|
+
const allow = (): boolean => {
|
|
1127
|
+
const now = Date.now()
|
|
1128
|
+
while (hits.length > 0 && now - hits[0] > 60_000) hits.shift()
|
|
1129
|
+
if (hits.length >= config.maxRequestsPerMinute) return false
|
|
1130
|
+
hits.push(now)
|
|
1131
|
+
return true
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* 共享结果缓存(进程级,跨标签页/刷新可用):
|
|
1136
|
+
* 同会话 + 同选中文字 + 同局部片段 + 同会话背景 → 直接回放上次结果,不再调用模型。
|
|
1137
|
+
*/
|
|
1138
|
+
const resultCache = new Map<string, { at: number; text: string }>()
|
|
1139
|
+
const resultKeyOf = (provider: string, model: string, sessionId: string, backgroundText: string, text: string, context: string): string =>
|
|
1140
|
+
createHash('sha1').update([provider, model, sessionId, backgroundText, text, context].join('\u0000')).digest('hex')
|
|
1141
|
+
|
|
1142
|
+
/** 会话背景短缓存:同一会话 10 秒内复用(连续划词不重复读日志)。 */
|
|
1143
|
+
const backgroundCache = new Map<string, { at: number; events: readonly unknown[] }>()
|
|
1144
|
+
|
|
1145
|
+
/** 读取当前会话的模型界面事件(live 优先;失败/超时一律降级为「无背景」)。 */
|
|
1146
|
+
const readSurfaceEvents = async (sessionId: string): Promise<readonly unknown[]> => {
|
|
1147
|
+
const cached = backgroundCache.get(sessionId)
|
|
1148
|
+
if (cached && Date.now() - cached.at < 10_000) return cached.events
|
|
1149
|
+
const sessionQuery = ctx.get('sessionQuery') as SessionQueryLike | undefined
|
|
1150
|
+
if (typeof sessionQuery?.readSurface !== 'function') return []
|
|
1151
|
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000))
|
|
1152
|
+
try {
|
|
1153
|
+
const surface = await Promise.race([sessionQuery.readSurface(sessionId), timeout])
|
|
1154
|
+
const events = surface && Array.isArray(surface.events) ? surface.events : []
|
|
1155
|
+
backgroundCache.set(sessionId, { at: Date.now(), events })
|
|
1156
|
+
if (backgroundCache.size > 8) {
|
|
1157
|
+
const oldest = backgroundCache.keys().next().value
|
|
1158
|
+
if (oldest !== undefined) backgroundCache.delete(oldest)
|
|
1159
|
+
}
|
|
1160
|
+
return events
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
ctx.logger?.warn?.(`[${name}] 读取会话背景失败(${sessionId}):${String((error as Error)?.message ?? error)}`)
|
|
1163
|
+
return []
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** 组装会话背景文本。 */
|
|
1168
|
+
const loadSessionBackground = async (
|
|
1169
|
+
sessionId: string,
|
|
1170
|
+
options: BackgroundOptions,
|
|
1171
|
+
): Promise<SessionBackground | null> => {
|
|
1172
|
+
const events = await readSurfaceEvents(sessionId)
|
|
1173
|
+
if (events.length === 0) return null
|
|
1174
|
+
const background = transcriptOf(events, options)
|
|
1175
|
+
return background.transcript ? background : null
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ───────────────────────── 小窗对话历史(A′) ─────────────────────────
|
|
1179
|
+
//
|
|
1180
|
+
// 为什么不建成会话:会话是重型对象(侧栏条目 + 磁盘目录 + 索引),而且当前
|
|
1181
|
+
// 部署**没有删除会话的接口**——一个高频功能每次划词建一个会话,只会堆积成
|
|
1182
|
+
// 一堆删不掉的死会话。所以这里把"整段对话"存在插件自己的目录里:
|
|
1183
|
+
// · 一个条目 = 一次划词解读(含它自己的全部追问轮次)
|
|
1184
|
+
// · 只留最近 `historyMaxEntries` 个条目(LRU),升格过的不参与淘汰
|
|
1185
|
+
// · 重划同一个词 → 直接回放,不调模型
|
|
1186
|
+
|
|
1187
|
+
interface HistoryTurn {
|
|
1188
|
+
role: string
|
|
1189
|
+
text: string
|
|
1190
|
+
}
|
|
1191
|
+
/** 落库入参:一律宽松收,内部统一裁剪(来源是浏览器,不能信)。 */
|
|
1192
|
+
interface HistoryInput {
|
|
1193
|
+
key?: unknown
|
|
1194
|
+
text?: unknown
|
|
1195
|
+
context?: unknown
|
|
1196
|
+
label?: unknown
|
|
1197
|
+
parts?: { translation?: unknown; detail?: unknown }
|
|
1198
|
+
turns?: { role?: unknown; text?: unknown }[]
|
|
1199
|
+
toolDigest?: unknown
|
|
1200
|
+
pinned?: unknown
|
|
1201
|
+
}
|
|
1202
|
+
interface HistoryEntry {
|
|
1203
|
+
key: string
|
|
1204
|
+
text: string
|
|
1205
|
+
context: string
|
|
1206
|
+
label: string
|
|
1207
|
+
at: number
|
|
1208
|
+
parts: { translation: string; detail: string }
|
|
1209
|
+
turns: HistoryTurn[]
|
|
1210
|
+
toolDigest: string
|
|
1211
|
+
pinned: boolean
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const history = new Map<string, HistoryEntry>()
|
|
1215
|
+
let historyLoaded = false
|
|
1216
|
+
let historyTimer: ReturnType<typeof setTimeout> | null = null
|
|
1217
|
+
|
|
1218
|
+
const historyFile = (): string =>
|
|
1219
|
+
join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain', 'history.json')
|
|
1220
|
+
|
|
1221
|
+
const clampText = (value: unknown, max: number): string => (typeof value === 'string' ? value.slice(0, max) : '')
|
|
1222
|
+
|
|
1223
|
+
async function loadHistory(): Promise<void> {
|
|
1224
|
+
if (historyLoaded) return
|
|
1225
|
+
historyLoaded = true
|
|
1226
|
+
try {
|
|
1227
|
+
await loadLeaky()
|
|
1228
|
+
const raw = await readFile(historyFile(), 'utf8')
|
|
1229
|
+
const parsed = JSON.parse(raw) as { entries?: unknown }
|
|
1230
|
+
const list = Array.isArray(parsed?.entries) ? parsed.entries : []
|
|
1231
|
+
for (const item of list) {
|
|
1232
|
+
const entry = item as Partial<HistoryEntry>
|
|
1233
|
+
if (typeof entry?.key !== 'string' || entry.key.length === 0) continue
|
|
1234
|
+
history.set(entry.key, {
|
|
1235
|
+
key: entry.key,
|
|
1236
|
+
text: clampText(entry.text, 4000),
|
|
1237
|
+
context: clampText(entry.context, 20000),
|
|
1238
|
+
label: clampText(entry.label, 60),
|
|
1239
|
+
at: typeof entry.at === 'number' ? entry.at : Date.now(),
|
|
1240
|
+
parts: {
|
|
1241
|
+
translation: clampText(entry.parts?.translation, 20000),
|
|
1242
|
+
detail: clampText(entry.parts?.detail, 20000),
|
|
1243
|
+
},
|
|
1244
|
+
turns: Array.isArray(entry.turns)
|
|
1245
|
+
? entry.turns.slice(0, 60).map((turn) => ({
|
|
1246
|
+
role: turn?.role === 'assistant' ? 'assistant' : 'user',
|
|
1247
|
+
text: clampText(turn?.text, 20000),
|
|
1248
|
+
}))
|
|
1249
|
+
: [],
|
|
1250
|
+
toolDigest: clampText(entry.toolDigest, 2000),
|
|
1251
|
+
pinned: entry.pinned === true,
|
|
1252
|
+
})
|
|
1253
|
+
}
|
|
1254
|
+
ctx.logger?.info?.(`[${name}] 小窗历史已载入 ${String(history.size)} 条(${historyFile()})`)
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
const code = (error as { code?: string })?.code
|
|
1257
|
+
if (code !== 'ENOENT') {
|
|
1258
|
+
ctx.logger?.warn?.(`[${name}] 读取小窗历史失败:${String((error as Error)?.message ?? error)}`)
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function scheduleHistoryWrite(): void {
|
|
1264
|
+
if (historyTimer) clearTimeout(historyTimer)
|
|
1265
|
+
historyTimer = setTimeout(() => {
|
|
1266
|
+
historyTimer = null
|
|
1267
|
+
const entries = [...history.values()].sort((a, b) => b.at - a.at)
|
|
1268
|
+
void (async () => {
|
|
1269
|
+
try {
|
|
1270
|
+
await mkdir(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain'), { recursive: true })
|
|
1271
|
+
await writeFile(historyFile(), JSON.stringify({ version: 1, entries }, null, 0), 'utf8')
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
ctx.logger?.warn?.(`[${name}] 写入小窗历史失败:${String((error as Error)?.message ?? error)}`)
|
|
1274
|
+
}
|
|
1275
|
+
})()
|
|
1276
|
+
}, 800)
|
|
1277
|
+
historyTimer.unref?.()
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** 存一条对话(整段覆盖),并按 LRU 淘汰超出上限的条目(升格过的不淘汰)。 */
|
|
1281
|
+
function saveHistoryEntry(input: HistoryInput): number {
|
|
1282
|
+
if (config.historyMaxEntries <= 0) return history.size
|
|
1283
|
+
const key = clampText(input.key, 200)
|
|
1284
|
+
if (!key) return history.size
|
|
1285
|
+
const previous = history.get(key)
|
|
1286
|
+
const entry: HistoryEntry = {
|
|
1287
|
+
key,
|
|
1288
|
+
text: clampText(input.text, 4000),
|
|
1289
|
+
context: clampText(input.context, 20000),
|
|
1290
|
+
label: clampText(input.label, 60),
|
|
1291
|
+
at: Date.now(),
|
|
1292
|
+
parts: {
|
|
1293
|
+
translation: clampText(input.parts?.translation, 20000),
|
|
1294
|
+
detail: clampText(input.parts?.detail, 20000),
|
|
1295
|
+
},
|
|
1296
|
+
turns: Array.isArray(input.turns)
|
|
1297
|
+
? input.turns.slice(0, 60).map((turn) => ({
|
|
1298
|
+
role: turn?.role === 'assistant' ? 'assistant' : 'user',
|
|
1299
|
+
text: clampText(turn?.text, 20000),
|
|
1300
|
+
}))
|
|
1301
|
+
: [],
|
|
1302
|
+
toolDigest: clampText(input.toolDigest, 2000),
|
|
1303
|
+
pinned: input.pinned === true || previous?.pinned === true,
|
|
1304
|
+
}
|
|
1305
|
+
history.delete(key)
|
|
1306
|
+
history.set(key, entry)
|
|
1307
|
+
// LRU:超出上限时丢最久没用过的(Map 的插入顺序就是使用顺序)
|
|
1308
|
+
while (history.size > config.historyMaxEntries) {
|
|
1309
|
+
let victim: string | undefined
|
|
1310
|
+
for (const candidate of history.keys()) {
|
|
1311
|
+
const item = history.get(candidate)
|
|
1312
|
+
if (item && !item.pinned) {
|
|
1313
|
+
victim = candidate
|
|
1314
|
+
break
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (victim === undefined) break
|
|
1318
|
+
history.delete(victim)
|
|
1319
|
+
}
|
|
1320
|
+
scheduleHistoryWrite()
|
|
1321
|
+
return history.size
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const historyMeta = (entry: HistoryEntry): Record<string, unknown> => ({
|
|
1325
|
+
key: entry.key,
|
|
1326
|
+
text: entry.text.length > 60 ? `${entry.text.slice(0, 60)}…` : entry.text,
|
|
1327
|
+
label: entry.label,
|
|
1328
|
+
at: entry.at,
|
|
1329
|
+
turns: entry.turns.filter((turn) => turn.role === 'user').length,
|
|
1330
|
+
hasDetail: entry.parts.detail.length > 0,
|
|
1331
|
+
pinned: entry.pinned,
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
const handleHistory = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1335
|
+
void (async () => {
|
|
1336
|
+
await loadHistory()
|
|
1337
|
+
if (config.historyMaxEntries <= 0) {
|
|
1338
|
+
sendJson(res, 200, { ok: true, entries: [] })
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
1342
|
+
if (req.method === 'DELETE') {
|
|
1343
|
+
// 删除一条(?key=)或清空(?all=1)。
|
|
1344
|
+
// 返回被删条目的**快照**,客户端据此做"撤销"(撤销就是把它原样 POST 回来)。
|
|
1345
|
+
const all = url.searchParams.get('all') === '1'
|
|
1346
|
+
if (all) {
|
|
1347
|
+
const removed = Array.from(history.values())
|
|
1348
|
+
history.clear()
|
|
1349
|
+
scheduleHistoryWrite()
|
|
1350
|
+
sendJson(res, 200, { ok: true, removed: removed.length, entries: removed.map(historyMeta) })
|
|
1351
|
+
return
|
|
1352
|
+
}
|
|
1353
|
+
const key = url.searchParams.get('key') ?? ''
|
|
1354
|
+
if (!key) {
|
|
1355
|
+
sendJson(res, 400, { ok: false, error: '缺少 key' })
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
const entry = history.get(key)
|
|
1359
|
+
if (!entry) {
|
|
1360
|
+
sendJson(res, 200, { ok: true, removed: 0, entry: null })
|
|
1361
|
+
return
|
|
1362
|
+
}
|
|
1363
|
+
history.delete(key)
|
|
1364
|
+
scheduleHistoryWrite()
|
|
1365
|
+
sendJson(res, 200, { ok: true, removed: 1, entry })
|
|
1366
|
+
return
|
|
1367
|
+
}
|
|
1368
|
+
if (req.method === 'POST' && url.searchParams.get('restore') === '1') {
|
|
1369
|
+
// 撤销删除:把客户端手里的快照原样写回(含 at/pinned,恢复原来的位置与标记)
|
|
1370
|
+
const raw = await readBody(req, 2_000_000)
|
|
1371
|
+
let body: AnalyzeBody = {}
|
|
1372
|
+
try {
|
|
1373
|
+
body = raw ? (JSON.parse(raw) as AnalyzeBody) : {}
|
|
1374
|
+
} catch {
|
|
1375
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
1376
|
+
return
|
|
1377
|
+
}
|
|
1378
|
+
const entry = (body as { entry?: HistoryEntry }).entry
|
|
1379
|
+
if (!entry || typeof entry.key !== 'string' || !entry.key) {
|
|
1380
|
+
sendJson(res, 400, { ok: false, error: '缺少 entry' })
|
|
1381
|
+
return
|
|
1382
|
+
}
|
|
1383
|
+
history.delete(entry.key)
|
|
1384
|
+
history.set(entry.key, entry)
|
|
1385
|
+
scheduleHistoryWrite()
|
|
1386
|
+
sendJson(res, 200, { ok: true, size: history.size })
|
|
1387
|
+
return
|
|
1388
|
+
}
|
|
1389
|
+
if (req.method === 'POST') {
|
|
1390
|
+
const raw = await readBody(req, 2_000_000)
|
|
1391
|
+
let body: AnalyzeBody = {}
|
|
1392
|
+
try {
|
|
1393
|
+
body = raw ? (JSON.parse(raw) as AnalyzeBody) : {}
|
|
1394
|
+
} catch {
|
|
1395
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
const size = saveHistoryEntry({
|
|
1399
|
+
key: typeof body?.key === 'string' ? body.key : '',
|
|
1400
|
+
text: typeof body?.text === 'string' ? body.text : '',
|
|
1401
|
+
context: typeof body?.context === 'string' ? body.context : '',
|
|
1402
|
+
label: typeof body?.label === 'string' ? body.label : '',
|
|
1403
|
+
parts: (body?.parts ?? {}) as { translation?: unknown; detail?: unknown },
|
|
1404
|
+
turns: (Array.isArray(body?.turns) ? body.turns : []) as { role?: unknown; text?: unknown }[],
|
|
1405
|
+
toolDigest: typeof body?.toolDigest === 'string' ? body.toolDigest : '',
|
|
1406
|
+
pinned: body?.pinned === true,
|
|
1407
|
+
})
|
|
1408
|
+
sendJson(res, 200, { ok: true, size })
|
|
1409
|
+
return
|
|
1410
|
+
}
|
|
1411
|
+
const key = url.searchParams.get('key')
|
|
1412
|
+
if (key) {
|
|
1413
|
+
const entry = history.get(key)
|
|
1414
|
+
sendJson(res, 200, { ok: true, entry: entry ?? null })
|
|
1415
|
+
return
|
|
1416
|
+
}
|
|
1417
|
+
const entries = [...history.values()].sort((a, b) => b.at - a.at).map(historyMeta)
|
|
1418
|
+
sendJson(res, 200, { ok: true, entries })
|
|
1419
|
+
})().catch((error: unknown) => {
|
|
1420
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
1421
|
+
})
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** GET /models:给「模型」菜单用(带每个模型支持的推理等级;60 秒缓存)。 */
|
|
1425
|
+
async function handleModels(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1426
|
+
await loadLeaky() // 不 await 的话,这条路由在读"关档会泄漏"的清单时文件还没载入(实测报空)
|
|
1427
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
1428
|
+
const force = url.searchParams.get('refresh') === '1'
|
|
1429
|
+
let items: ModelChoice[] = []
|
|
1430
|
+
let error = ''
|
|
1431
|
+
try {
|
|
1432
|
+
items = await listModelChoices(force)
|
|
1433
|
+
} catch (thrown) {
|
|
1434
|
+
error = String((thrown as Error)?.message ?? thrown)
|
|
1435
|
+
}
|
|
1436
|
+
const current = resolveRoute()
|
|
1437
|
+
sendJson(res, 200, {
|
|
1438
|
+
ok: true,
|
|
1439
|
+
current,
|
|
1440
|
+
cached: Date.now() - modelCache.at < MODEL_CACHE_MS,
|
|
1441
|
+
...(error ? { error } : {}),
|
|
1442
|
+
models: items,
|
|
1443
|
+
// 进程内学到的"关档会把思考写进正文"的模型(客户端据此在菜单里标注;重启后由客户端本地记忆兜着)
|
|
1444
|
+
leakyOff: Array.from(leakyOnOff),
|
|
1445
|
+
// 前端按 provider+model 匹配"当前档"
|
|
1446
|
+
stages: {
|
|
1447
|
+
chat: config.chatReasoningEffort || config.reasoningEffort,
|
|
1448
|
+
translation: config.translationReasoningEffort || config.reasoningEffort,
|
|
1449
|
+
detail: config.reasoningEffort,
|
|
1450
|
+
},
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const handlePing = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1455
|
+
const sessionQuery = ctx.get('sessionQuery') as SessionQueryLike | undefined
|
|
1456
|
+
// 可以带 ?sessionId=xxx:工具是**按会话作用域**注册的,不带 id 只能看到全局视图(里面没有 web_search)
|
|
1457
|
+
let pingSessionId = ''
|
|
1458
|
+
try {
|
|
1459
|
+
pingSessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('sessionId') ?? ''
|
|
1460
|
+
} catch {
|
|
1461
|
+
/* 解析不了就当没带 */
|
|
1462
|
+
}
|
|
1463
|
+
// 小窗能调哪些工具:白名单是配置,实际放行还要看本机能不能抓网页(见下)。
|
|
1464
|
+
// 用 async 包一层:探测 DNS 是异步的,出错也要给出结构化响应(和 history 路由同一写法)。
|
|
1465
|
+
void (async () => {
|
|
1466
|
+
const whitelist = config.toolNames
|
|
1467
|
+
.split(',')
|
|
1468
|
+
.map((item) => item.trim())
|
|
1469
|
+
.filter((item) => item.length > 0)
|
|
1470
|
+
const agents = ctx.get('agents') as { get?: (id: string) => unknown; list?: () => unknown[] } | undefined
|
|
1471
|
+
// 复刻 handleAnalyze 的作用域回退:本会话 agent 取不到就用任一活跃 agent
|
|
1472
|
+
let pingAgent = pingSessionId ? agents?.get?.(pingSessionId) : undefined
|
|
1473
|
+
let pingScope = pingAgent ? 'session' : ''
|
|
1474
|
+
if (!pingAgent) {
|
|
1475
|
+
pingAgent = agents?.list?.()?.[0]
|
|
1476
|
+
pingScope = pingAgent ? 'fallback' : 'global'
|
|
1477
|
+
}
|
|
1478
|
+
let visible: string[] = []
|
|
1479
|
+
let visibleError = ''
|
|
1480
|
+
let toolInfo: Array<{ name: string; desc: string }> = []
|
|
1481
|
+
let toolNames: string[] = []
|
|
1482
|
+
let sessionToolCount = 0
|
|
1483
|
+
try {
|
|
1484
|
+
// 必须带着 registry 调(`const f = registry.schemas; f(...)` 会把 this 丢掉 → 拿到空表)
|
|
1485
|
+
const registry = ctx.get('tools') as { schemas?: (scope?: unknown) => ToolSchema[] } | undefined
|
|
1486
|
+
const all = registry?.schemas?.(pingAgent) ?? []
|
|
1487
|
+
sessionToolCount = all.length
|
|
1488
|
+
visible = all.map((schema) => schema.name).filter((toolName) => whitelist.includes(toolName))
|
|
1489
|
+
// 诊断用:会话里这几个工具到底是哪个插件提供的(描述前 70 字能认出来)
|
|
1490
|
+
toolInfo = all.map((schema) => ({
|
|
1491
|
+
name: schema.name,
|
|
1492
|
+
desc: String(schema.description ?? '').replace(/\s+/g, ' ').slice(0, 70),
|
|
1493
|
+
}))
|
|
1494
|
+
toolNames = all.map((schema) => schema.name)
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
visibleError = String((error as Error)?.message ?? error)
|
|
1497
|
+
}
|
|
1498
|
+
// 本机抓不了网页时 web_fetch 会被摘掉(探针缓存 5 分钟,这里复用同一个)
|
|
1499
|
+
let fetchBlocked = ''
|
|
1500
|
+
if (whitelist.includes('web_fetch')) {
|
|
1501
|
+
const probe = await webFetchUsable()
|
|
1502
|
+
if (!probe.usable) fetchBlocked = probe.sample
|
|
1503
|
+
}
|
|
1504
|
+
sendJson(res, 200, {
|
|
1505
|
+
ok: true,
|
|
1506
|
+
plugin: name,
|
|
1507
|
+
route: resolveRoute(),
|
|
1508
|
+
reasoningEffort: config.reasoningEffort,
|
|
1509
|
+
modelsRoute: `${API_PREFIX}/models`,
|
|
1510
|
+
history: { maxEntries: config.historyMaxEntries, file: historyFile() },
|
|
1511
|
+
// 三档分开报:一眼看出"首轮为什么快、详解为什么慢"
|
|
1512
|
+
reasoningEffortByStage: {
|
|
1513
|
+
translation: config.translationReasoningEffort,
|
|
1514
|
+
detail: config.reasoningEffort,
|
|
1515
|
+
chat: config.chatReasoningEffort,
|
|
1516
|
+
},
|
|
1517
|
+
// 小窗能用哪些工具:whitelist=配置允许,offered=这一轮真会交给模型的
|
|
1518
|
+
tools: {
|
|
1519
|
+
enabled: config.tools && config.maxToolRounds > 0,
|
|
1520
|
+
whitelist,
|
|
1521
|
+
offered: visible.filter((toolName) => !(toolName === 'web_fetch' && fetchBlocked)),
|
|
1522
|
+
// 文件类工具的读取边界:'' = 会话工作目录,'*' = 不限制
|
|
1523
|
+
readRoot: config.toolReadRoot === '*' ? '(不限)' : config.toolReadRoot || '(会话工作目录)',
|
|
1524
|
+
visible: visibleError ? '读取工具表失败:' + visibleError : visible.length,
|
|
1525
|
+
// 备用联网工具是否启用(没装 free-search 时会补上 web_search)
|
|
1526
|
+
fallback: config.fallbackToolNames,
|
|
1527
|
+
preferredSearchPresent: visible.includes('advanced_search') || visible.includes('platform_search'),
|
|
1528
|
+
toolInfo,
|
|
1529
|
+
toolNames,
|
|
1530
|
+
// 这个会话一共能看到多少工具(放行的是白名单里那几个,其余一律不可用)
|
|
1531
|
+
sessionTools: sessionToolCount,
|
|
1532
|
+
scope: pingScope,
|
|
1533
|
+
maxRounds: config.maxToolRounds,
|
|
1534
|
+
fetchBlocked,
|
|
1535
|
+
},
|
|
1536
|
+
sessionContext: config.sessionContext && typeof sessionQuery?.readSurface === 'function',
|
|
1537
|
+
limits: {
|
|
1538
|
+
maxSelectionChars: config.maxSelectionChars,
|
|
1539
|
+
maxContextChars: config.maxContextChars,
|
|
1540
|
+
sessionContextMaxMessages: config.sessionContextMaxMessages,
|
|
1541
|
+
sessionContextFastMessages: config.sessionContextFastMessages,
|
|
1542
|
+
sessionContextMaxChars: config.sessionContextMaxChars,
|
|
1543
|
+
},
|
|
1544
|
+
})
|
|
1545
|
+
})().catch((error: unknown) => {
|
|
1546
|
+
sendJson(res, 500, { ok: false, error: String((error as Error)?.message ?? error) })
|
|
1547
|
+
})
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** 解析一个会话的工作目录(同项目 = 同 cwd)。 */
|
|
1551
|
+
const resolveSessionCwd = async (sessionId: string): Promise<string | undefined> => {
|
|
1552
|
+
if (!sessionId) return undefined
|
|
1553
|
+
const sessions = ctx.get('sessions') as
|
|
1554
|
+
| { get?: (id: string) => { header?: { cwd?: string } } | undefined }
|
|
1555
|
+
| undefined
|
|
1556
|
+
const live = sessions?.get?.(sessionId)?.header?.cwd
|
|
1557
|
+
if (live) return live
|
|
1558
|
+
const sessionQuery = ctx.get('sessionQuery') as
|
|
1559
|
+
| { readSession?: (id: string) => Promise<{ session?: { cwd?: string } }> }
|
|
1560
|
+
| undefined
|
|
1561
|
+
try {
|
|
1562
|
+
const log = await sessionQuery?.readSession?.(sessionId)
|
|
1563
|
+
return log?.session?.cwd
|
|
1564
|
+
} catch {
|
|
1565
|
+
return undefined
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** 可写的 live 会话(只用到 append)。 */
|
|
1570
|
+
interface LiveSessionLike {
|
|
1571
|
+
append?: (type: string, data: unknown, options?: unknown) => unknown
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* 把划词解读小窗「升格」成正式会话(建在来源会话同一个项目下):
|
|
1576
|
+
* ① 建会话 → ② 注入上下文消息(选中文字/所在位置/上下文片段,plugin 来源的 context 消息)
|
|
1577
|
+
* → ③ 把首轮解读写成助手消息 → ④ 把追问逐轮写成真实的 user/assistant 历史消息
|
|
1578
|
+
* → ⑤ 送一条短开场消息让 agent 接手(这样整段历史才会落盘)。
|
|
1579
|
+
* 这样新会话里看到的就是一段真实的多轮对话,而不是一大坨打包文本。
|
|
1580
|
+
* live 会话对象拿不到时,退回「打包成一条消息」的老路径,保证升格不会整体失败。
|
|
1581
|
+
*/
|
|
1582
|
+
const handlePromote = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1583
|
+
if (req.method !== 'POST') {
|
|
1584
|
+
sendJson(res, 405, { error: '只支持 POST' })
|
|
1585
|
+
return
|
|
1586
|
+
}
|
|
1587
|
+
let body: AnalyzeBody
|
|
1588
|
+
try {
|
|
1589
|
+
body = JSON.parse(await readBody(req, BODY_LIMIT)) as AnalyzeBody
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
sendJson(res, 400, { error: `请求体解析失败:${String((error as Error)?.message ?? error)}` })
|
|
1592
|
+
return
|
|
1593
|
+
}
|
|
1594
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
1595
|
+
if (!text) {
|
|
1596
|
+
sendJson(res, 400, { error: '缺少 text(选中文字)' })
|
|
1597
|
+
return
|
|
1598
|
+
}
|
|
1599
|
+
const controller = ctx.get('sessionController') as
|
|
1600
|
+
| {
|
|
1601
|
+
create?: (request: { workspaceId?: string; cwd?: string; agentPreset?: string }) => Promise<{ sessionId?: string }>
|
|
1602
|
+
rename?: (request: { sessionId: string; title: string }) => Promise<unknown>
|
|
1603
|
+
prompt?: (
|
|
1604
|
+
request: {
|
|
1605
|
+
requestId: string
|
|
1606
|
+
sessionId: string
|
|
1607
|
+
mode: 'queue' | 'steer'
|
|
1608
|
+
content: Array<{ type: 'text'; text: string }>
|
|
1609
|
+
},
|
|
1610
|
+
signal: AbortSignal,
|
|
1611
|
+
) => Promise<unknown>
|
|
1612
|
+
}
|
|
1613
|
+
| undefined
|
|
1614
|
+
if (typeof controller?.create !== 'function' || typeof controller?.prompt !== 'function') {
|
|
1615
|
+
sendJson(res, 503, { error: '当前部署不支持创建会话(sessionController 不可用)' })
|
|
1616
|
+
return
|
|
1617
|
+
}
|
|
1618
|
+
const sourceSessionId = sessionIdOf(body)
|
|
1619
|
+
const cwd = await resolveSessionCwd(sourceSessionId)
|
|
1620
|
+
// 会话在侧栏里的「项目分组」= workspace 的 sessionIds(按 canonical cwd 索引)。
|
|
1621
|
+
// 只传 cwd 不够:还要带上 workspaceId,并在建好后把它挂到该 workspace 上(双保险)。
|
|
1622
|
+
const registry = ctx.get('workspaceRegistry') as
|
|
1623
|
+
| {
|
|
1624
|
+
resolveByPath?: (path: string) => Promise<{ id?: string } | undefined>
|
|
1625
|
+
get?: (id: string) => { sessionIds?: readonly string[]; attachSession?: (sessionId: string) => Promise<void> } | undefined
|
|
1626
|
+
}
|
|
1627
|
+
| undefined
|
|
1628
|
+
let workspaceId: string | undefined
|
|
1629
|
+
if (cwd) {
|
|
1630
|
+
try {
|
|
1631
|
+
const workspace = await registry?.resolveByPath?.(cwd)
|
|
1632
|
+
if (typeof workspace?.id === 'string') workspaceId = workspace.id
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
ctx.logger?.warn?.(`[${name}] 解析 workspace 失败(${cwd}):${String((error as Error)?.message ?? error)}`)
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
try {
|
|
1638
|
+
// 注意:session.create 的 workspaceId 与 cwd 互斥(同时传会报
|
|
1639
|
+
// "session.create accepts workspaceId or cwd, not both")——有 workspace 就只传它
|
|
1640
|
+
const created = await controller.create(workspaceId ? { workspaceId } : cwd ? { cwd } : {})
|
|
1641
|
+
const newSessionId = typeof created?.sessionId === 'string' ? created.sessionId : ''
|
|
1642
|
+
if (!newSessionId) {
|
|
1643
|
+
sendJson(res, 500, { error: '创建会话失败:未返回会话 id' })
|
|
1644
|
+
return
|
|
1645
|
+
}
|
|
1646
|
+
// 兜底:若 cwd 索引还没把它算进该 workspace,显式 attach 一次
|
|
1647
|
+
if (workspaceId) {
|
|
1648
|
+
try {
|
|
1649
|
+
const workspace = registry?.get?.(workspaceId)
|
|
1650
|
+
if (workspace && !(workspace.sessionIds ?? []).includes(newSessionId)) {
|
|
1651
|
+
await workspace.attachSession?.(newSessionId)
|
|
1652
|
+
}
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
ctx.logger?.warn?.(`[${name}] 挂载会话到 workspace 失败:${String((error as Error)?.message ?? error)}`)
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
const title = `划词解读:${text.replace(/\s+/g, ' ').slice(0, 24)}`
|
|
1658
|
+
try {
|
|
1659
|
+
await controller.rename?.({ sessionId: newSessionId, title })
|
|
1660
|
+
} catch {
|
|
1661
|
+
/* 标题失败不影响升格 */
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const label = clampText(body.label, 80) || '页面内容'
|
|
1665
|
+
const contextText = clampText(body.context, config.maxContextChars) || '(未记录)'
|
|
1666
|
+
const translation = clampText(body.translation, 8000)
|
|
1667
|
+
const detail = clampText(body.detail, 12000)
|
|
1668
|
+
const turns = (Array.isArray(body.turns) ? body.turns : [])
|
|
1669
|
+
.slice(-20)
|
|
1670
|
+
.map((turn) => turn as { role?: unknown; text?: unknown; seed?: unknown })
|
|
1671
|
+
.filter((turn) => turn.seed !== true && typeof turn.text === 'string' && turn.text.trim().length > 0)
|
|
1672
|
+
.map((turn) => ({ role: turn.role === 'assistant' ? 'assistant' : 'user', text: (turn.text as string).trim() }))
|
|
1673
|
+
|
|
1674
|
+
const live = (ctx.get('sessions') as { get?: (id: string) => LiveSessionLike | undefined } | undefined)?.get?.(
|
|
1675
|
+
newSessionId,
|
|
1676
|
+
)
|
|
1677
|
+
let seeded = false
|
|
1678
|
+
if (live && typeof live.append === 'function') {
|
|
1679
|
+
const stamp = Date.now().toString(36)
|
|
1680
|
+
/**
|
|
1681
|
+
* 把一轮写进日志:turn/start → step/start → 消息 → step/end → turn/end。
|
|
1682
|
+
* ⚠️ 必须带这层骨架:客户端会话视图是按 turn/step 事件给消息定位的,
|
|
1683
|
+
* 裸写 user/message、assistant/message 会因为拿不到坐标而不显示成历史对话。
|
|
1684
|
+
*/
|
|
1685
|
+
// 轮号从 1000 起:客户端按「轮号」分组(Map<number>),而新建会话里 agent 的第一轮
|
|
1686
|
+
// 一定从 1 开始(phase.turn 初始为 0),所以历史轮必须避开低位号段,
|
|
1687
|
+
// 否则历史的 turn 1 会和真实的 turn 1 并成同一组、看起来"历史没进去"。
|
|
1688
|
+
// 同时这也是 resume 语义安全的:resume 时 phase.turn = lastTurn + 1 = 1000+N+1,仍不与历史重号。
|
|
1689
|
+
let turnNo = 1000
|
|
1690
|
+
const appendTurn = (userEvent: Record<string, unknown> | null, assistantText: string): void => {
|
|
1691
|
+
turnNo += 1
|
|
1692
|
+
const turn = turnNo
|
|
1693
|
+
live.append?.('turn/start', { turn })
|
|
1694
|
+
live.append?.('step/start', { turn, step: 1 })
|
|
1695
|
+
if (userEvent) live.append?.('user/message', userEvent, { surfaceOp: 'append' })
|
|
1696
|
+
live.append?.(
|
|
1697
|
+
'assistant/message',
|
|
1698
|
+
{
|
|
1699
|
+
turn,
|
|
1700
|
+
step: 1,
|
|
1701
|
+
stream: [],
|
|
1702
|
+
message: {
|
|
1703
|
+
id: `sel-a-${stamp}-${turn}`,
|
|
1704
|
+
role: 'assistant',
|
|
1705
|
+
source: { kind: 'model', provider: 'dsh-selection-explain', model: '划词解读小窗' },
|
|
1706
|
+
content: [{ type: 'text', text: assistantText }],
|
|
1707
|
+
},
|
|
1708
|
+
},
|
|
1709
|
+
{ surfaceOp: 'append' },
|
|
1710
|
+
)
|
|
1711
|
+
live.append?.('step/end', { turn, step: 1 })
|
|
1712
|
+
live.append?.('turn/end', { turn, reason: { kind: 'completed' } })
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// 第 1 轮:上下文消息(plugin 来源 + snapshot 呈现)+ 首轮解读
|
|
1716
|
+
const contextBody = [
|
|
1717
|
+
`选中文字:${text}`,
|
|
1718
|
+
`所在位置:${label}`,
|
|
1719
|
+
`来源:划词解读小窗${sourceSessionId ? `(来源会话 ${sourceSessionId})` : ''}`,
|
|
1720
|
+
'',
|
|
1721
|
+
'选中处的上下文片段:',
|
|
1722
|
+
contextText,
|
|
1723
|
+
].join('\n')
|
|
1724
|
+
const explanation = ['翻译', translation || '(未记录)', '', '详解', detail || '(未记录)'].join('\n')
|
|
1725
|
+
appendTurn(
|
|
1726
|
+
{
|
|
1727
|
+
id: `sel-ctx-${stamp}`,
|
|
1728
|
+
role: 'user',
|
|
1729
|
+
source: {
|
|
1730
|
+
kind: 'plugin',
|
|
1731
|
+
plugin: name,
|
|
1732
|
+
form: 'snapshot',
|
|
1733
|
+
sections: [
|
|
1734
|
+
{ name: '划词解读 · 选中文字', text },
|
|
1735
|
+
{ name: '划词解读 · 所在位置', text: label },
|
|
1736
|
+
{ name: '划词解读 · 上下文片段', text: contextText },
|
|
1737
|
+
],
|
|
1738
|
+
},
|
|
1739
|
+
content: [{ type: 'text', text: contextBody }],
|
|
1740
|
+
},
|
|
1741
|
+
explanation,
|
|
1742
|
+
)
|
|
1743
|
+
// 后续轮:小窗里的追问逐轮写成真实问答
|
|
1744
|
+
for (let i = 0; i < turns.length; i += 1) {
|
|
1745
|
+
const turn = turns[i]
|
|
1746
|
+
if (!turn) continue
|
|
1747
|
+
if (turn.role === 'user') {
|
|
1748
|
+
appendTurn(
|
|
1749
|
+
{
|
|
1750
|
+
id: `sel-u-${stamp}-${i}`,
|
|
1751
|
+
role: 'user',
|
|
1752
|
+
source: { kind: 'user' },
|
|
1753
|
+
content: [{ type: 'text', text: turn.text }],
|
|
1754
|
+
},
|
|
1755
|
+
turns[i + 1] && turns[i + 1]?.role === 'assistant' ? (turns[i + 1]?.text ?? '') : '(未记录)',
|
|
1756
|
+
)
|
|
1757
|
+
i += 1 // 助手回答已随本轮写入
|
|
1758
|
+
} else {
|
|
1759
|
+
// 落单的助手消息(历史里没有对应提问):单独成一轮
|
|
1760
|
+
appendTurn(null, turn.text)
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
seeded = true
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// ⑤ 开场消息:让 agent 接手(也是整段历史落盘的触发点)
|
|
1767
|
+
const opener = seeded
|
|
1768
|
+
? '(本会话由划词解读小窗升格而来,以上是这段文字的上下文与我们的讨论)请用一句话确认,然后等我继续提问。'
|
|
1769
|
+
: [
|
|
1770
|
+
'【划词解读 · 升格为会话】',
|
|
1771
|
+
'下面这段文字来自一次划词解读小窗;上下文、结论与小窗里的追问一并附上,作为本次会话的既有背景。',
|
|
1772
|
+
'',
|
|
1773
|
+
'■ 选中文字',
|
|
1774
|
+
text,
|
|
1775
|
+
'',
|
|
1776
|
+
'■ 所在位置',
|
|
1777
|
+
label,
|
|
1778
|
+
'',
|
|
1779
|
+
'■ 选中处的上下文片段',
|
|
1780
|
+
contextText,
|
|
1781
|
+
'',
|
|
1782
|
+
'■ 解读 · 翻译',
|
|
1783
|
+
translation || '(未记录)',
|
|
1784
|
+
'',
|
|
1785
|
+
'■ 解读 · 详解',
|
|
1786
|
+
detail || '(未记录)',
|
|
1787
|
+
...(turns.length > 0 ? ['', '■ 小窗里的追问', ...turns.map((t) => `${t.role === 'assistant' ? '答' : '我问'}:${t.text}`)] : []),
|
|
1788
|
+
'',
|
|
1789
|
+
'以上是背景,请把它作为本次会话的既有上下文;先不需要展开,等我继续提问。',
|
|
1790
|
+
].join('\n')
|
|
1791
|
+
const promoteController = new AbortController()
|
|
1792
|
+
await controller.prompt(
|
|
1793
|
+
{
|
|
1794
|
+
requestId: `sel-promote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1795
|
+
sessionId: newSessionId,
|
|
1796
|
+
mode: 'queue',
|
|
1797
|
+
content: [{ type: 'text', text: opener }],
|
|
1798
|
+
},
|
|
1799
|
+
promoteController.signal,
|
|
1800
|
+
)
|
|
1801
|
+
sendJson(res, 200, {
|
|
1802
|
+
ok: true,
|
|
1803
|
+
sessionId: newSessionId,
|
|
1804
|
+
cwd: cwd ?? null,
|
|
1805
|
+
workspaceId: workspaceId ?? null,
|
|
1806
|
+
mode: seeded ? 'history' : 'packed',
|
|
1807
|
+
})
|
|
1808
|
+
} catch (error) {
|
|
1809
|
+
sendJson(res, 500, { error: `升格失败:${String((error as Error)?.message ?? error)}` })
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const handleAnalyze = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1814
|
+
if (req.method !== 'POST') {
|
|
1815
|
+
sendJson(res, 405, { error: '只支持 POST' })
|
|
1816
|
+
return
|
|
1817
|
+
}
|
|
1818
|
+
let body: AnalyzeBody
|
|
1819
|
+
try {
|
|
1820
|
+
body = JSON.parse(await readBody(req, BODY_LIMIT)) as AnalyzeBody
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
sendJson(res, 400, { error: `请求体解析失败:${String((error as Error)?.message ?? error)}` })
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
1826
|
+
if (text.length === 0) {
|
|
1827
|
+
sendJson(res, 400, { error: '缺少 text(选中文字)' })
|
|
1828
|
+
return
|
|
1829
|
+
}
|
|
1830
|
+
if (text.length > config.maxSelectionChars) {
|
|
1831
|
+
sendJson(res, 413, { error: `选中文字过长(${text.length} > ${config.maxSelectionChars} 字符)` })
|
|
1832
|
+
return
|
|
1833
|
+
}
|
|
1834
|
+
if (!allow()) {
|
|
1835
|
+
sendJson(res, 429, { error: `请求过于频繁(上限 ${config.maxRequestsPerMinute}/分钟)` })
|
|
1836
|
+
return
|
|
1837
|
+
}
|
|
1838
|
+
const override =
|
|
1839
|
+
typeof body.provider === 'string' && typeof body.model === 'string' && body.provider && body.model
|
|
1840
|
+
? { provider: body.provider, model: body.model }
|
|
1841
|
+
: undefined
|
|
1842
|
+
const route = resolveRoute(override)
|
|
1843
|
+
if (!route) {
|
|
1844
|
+
sendJson(res, 503, { error: '尚未确定模型路由:请先在设置里选择默认模型后重试' })
|
|
1845
|
+
return
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const context = clampText(body.context, config.maxContextChars)
|
|
1849
|
+
const label = clampText(body.label, 80)
|
|
1850
|
+
// 页面标题/地址不写进提示词:模型会把它当成「选中文字里的英文」去翻译(实测把标题 DSH 译了一遍)
|
|
1851
|
+
|
|
1852
|
+
// 会话背景:从 sessionQuery 读取当前会话的模型界面(live 优先,含持久化会话)
|
|
1853
|
+
let background: SessionBackground | null = null
|
|
1854
|
+
if (config.sessionContext) {
|
|
1855
|
+
const sessionId = sessionIdOf(body)
|
|
1856
|
+
if (sessionId) {
|
|
1857
|
+
// 背景预算按阶段:首轮只要翻译(8 条够定指代);详解与追问都是深度交互,吃满 24 条
|
|
1858
|
+
const stage = typeof body.stage === 'string' ? body.stage : ''
|
|
1859
|
+
const wantsFull = stage === 'detail' || typeof body.question === 'string'
|
|
1860
|
+
background = await loadSessionBackground(sessionId, {
|
|
1861
|
+
maxMessages: wantsFull ? config.sessionContextMaxMessages : config.sessionContextFastMessages,
|
|
1862
|
+
maxChars: config.sessionContextMaxChars,
|
|
1863
|
+
marker: text,
|
|
1864
|
+
})
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// 顺序即缓存策略:前缀必须“只增不变”才能复用 ——
|
|
1869
|
+
// ① 会话背景(同一会话内基本不变)→ ② 固定说明 → ③ 动态尾部(选中文字/局部片段)
|
|
1870
|
+
// 反过来写(动态在前、背景在后)会让缓存前缀在“选中文字”处就断掉:
|
|
1871
|
+
// 实测同会话换一处选中,命中量从 5760 tokens 掉到 640~768。
|
|
1872
|
+
const stablePrefix: string[] = []
|
|
1873
|
+
if (background && background.transcript) {
|
|
1874
|
+
stablePrefix.push(
|
|
1875
|
+
`【会话背景】(按时间顺序;${background.marked ? '最后一条即选中文字所在的消息,已用 ▶ 标出,其后的对话未包含' : '未能定位选中位置,取的是最近的对话'})`,
|
|
1876
|
+
background.transcript,
|
|
1877
|
+
'',
|
|
1878
|
+
'上面是会话背景;下面是需要你解读的【选中文字】及其上下文。',
|
|
1879
|
+
'',
|
|
1880
|
+
)
|
|
1881
|
+
}
|
|
1882
|
+
const userText = [
|
|
1883
|
+
...stablePrefix,
|
|
1884
|
+
'【选中文字】',
|
|
1885
|
+
text,
|
|
1886
|
+
'',
|
|
1887
|
+
`【所在位置】${label || '页面内容'}`,
|
|
1888
|
+
'',
|
|
1889
|
+
'【选中处的上下文片段】(选中部分用【】标出)',
|
|
1890
|
+
context || '(未能取到上下文片段,请仅根据选中文字与会话背景作答)',
|
|
1891
|
+
].join('\n')
|
|
1892
|
+
|
|
1893
|
+
const effort = typeof body.effort === 'string' && body.effort.length > 0 ? body.effort : config.reasoningEffort
|
|
1894
|
+
// 追问模式:客户端带 question 时,就是「就这段选中文字继续聊」
|
|
1895
|
+
const question = typeof body.question === 'string' ? body.question.trim() : ''
|
|
1896
|
+
const isChat = question.length > 0
|
|
1897
|
+
const historyMessages: Message[] = []
|
|
1898
|
+
if (isChat) {
|
|
1899
|
+
const rawHistory = Array.isArray(body.history) ? body.history : []
|
|
1900
|
+
for (const turn of rawHistory.slice(-20)) {
|
|
1901
|
+
const item = turn as { role?: unknown; text?: unknown }
|
|
1902
|
+
const text = typeof item.text === 'string' ? item.text : ''
|
|
1903
|
+
if (!text) continue
|
|
1904
|
+
if (item.role === 'assistant') {
|
|
1905
|
+
historyMessages.push(createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: route.provider, model: route.model } }))
|
|
1906
|
+
} else {
|
|
1907
|
+
historyMessages.push(createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] }))
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
const cacheKey = resultKeyOf(
|
|
1913
|
+
route.provider,
|
|
1914
|
+
route.model,
|
|
1915
|
+
sessionIdOf(body) + '|' + effort + '|' + (typeof body.stage === 'string' ? body.stage : 'translation'),
|
|
1916
|
+
background?.transcript ?? '',
|
|
1917
|
+
text,
|
|
1918
|
+
context,
|
|
1919
|
+
)
|
|
1920
|
+
const useResultCache = !isChat && config.resultCacheTtlMs > 0 && body.refresh !== true
|
|
1921
|
+
const cached = useResultCache ? resultCache.get(cacheKey) : undefined
|
|
1922
|
+
if (cached && Date.now() - cached.at < config.resultCacheTtlMs) {
|
|
1923
|
+
res.writeHead(200, {
|
|
1924
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1925
|
+
'cache-control': 'no-store, no-transform',
|
|
1926
|
+
connection: 'keep-alive',
|
|
1927
|
+
'x-accel-buffering': 'no',
|
|
1928
|
+
})
|
|
1929
|
+
sse(res, {
|
|
1930
|
+
type: 'start',
|
|
1931
|
+
provider: route.provider,
|
|
1932
|
+
model: route.model,
|
|
1933
|
+
cached: true,
|
|
1934
|
+
context: { chars: context.length, label, session: background ? { chars: background.transcript.length, marked: background.marked } : null },
|
|
1935
|
+
})
|
|
1936
|
+
sse(res, { type: 'delta', text: cached.text })
|
|
1937
|
+
sse(res, { type: 'done', chars: cached.text.length, cached: true })
|
|
1938
|
+
res.end()
|
|
1939
|
+
return
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
res.writeHead(200, {
|
|
1943
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1944
|
+
'cache-control': 'no-store, no-transform',
|
|
1945
|
+
connection: 'keep-alive',
|
|
1946
|
+
'x-accel-buffering': 'no',
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
const controller = new AbortController()
|
|
1950
|
+
let finished = false
|
|
1951
|
+
/** 诊断:客户端(浏览器/探针)在流还没结束时就把连接关了 —— 会让上游被 abort,表现为"输出到一半停了"。 */
|
|
1952
|
+
let clientClosedEarly = false
|
|
1953
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
|
|
1954
|
+
res.on('close', () => {
|
|
1955
|
+
if (!finished) {
|
|
1956
|
+
clientClosedEarly = true
|
|
1957
|
+
ctx.logger?.warn?.(`[${name}] 客户端提前断开(流未结束):已中止上游,避免继续消耗 token`)
|
|
1958
|
+
controller.abort()
|
|
1959
|
+
}
|
|
1960
|
+
})
|
|
1961
|
+
|
|
1962
|
+
// 可用工具(白名单,默认只有联网搜索/抓取):交给模型自己决定要不要用。
|
|
1963
|
+
// 注意工具是「按会话作用域」注册的(全局视图里看不到 web_search 等),
|
|
1964
|
+
// 所以要拿该会话的 agent 当 scope 去查,并把同一个 agent 传给 execute。
|
|
1965
|
+
const agents = ctx.get('agents') as { get?: (id: string) => unknown; list?: () => unknown[] } | undefined
|
|
1966
|
+
const sessionId = sessionIdOf(body)
|
|
1967
|
+
let agent = sessionId ? agents?.get?.(sessionId) : undefined
|
|
1968
|
+
let agentScope = agent ? 'session' : ''
|
|
1969
|
+
if (!agent) {
|
|
1970
|
+
// 回退:本会话还没起 agent(刚开的新会话/agent 已释放)时,用任一活跃 agent 当作用域。
|
|
1971
|
+
// 不这么做的话,session-scoped 的 web_search/web_fetch 在全局视图里根本不可见 →
|
|
1972
|
+
// 工具静默为空,模型还会把工具调用当正文吐出来(实测见过 <ds_safety_tool_call>)。
|
|
1973
|
+
const live = agents?.list?.() ?? []
|
|
1974
|
+
if (live.length > 0) {
|
|
1975
|
+
agent = live[0]
|
|
1976
|
+
agentScope = 'fallback'
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
const toolSchemas: ToolSchema[] = []
|
|
1980
|
+
/** 诊断:这一轮是不是走了"备用联网工具"(首选联网工具一个都不在)。 */
|
|
1981
|
+
let toolFallbackUsed = false
|
|
1982
|
+
if (config.tools && config.maxToolRounds > 0) {
|
|
1983
|
+
const registry = ctx.get('tools') as { schemas?: (scope?: unknown) => ToolSchema[] } | undefined
|
|
1984
|
+
const split = (value: string): string[] =>
|
|
1985
|
+
value.split(',').map((item) => item.trim()).filter((item) => item.length > 0)
|
|
1986
|
+
let visible: ToolSchema[] = []
|
|
1987
|
+
try {
|
|
1988
|
+
visible = registry?.schemas?.(agent) ?? []
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
ctx.logger?.warn?.(`[${name}] 读取工具表失败:${String((error as Error)?.message ?? error)}`)
|
|
1991
|
+
}
|
|
1992
|
+
const allowed = resolveToolNames(
|
|
1993
|
+
split(config.toolNames),
|
|
1994
|
+
split(config.fallbackToolNames),
|
|
1995
|
+
visible.map((schema) => schema.name),
|
|
1996
|
+
)
|
|
1997
|
+
toolFallbackUsed = !allowed.includes('advanced_search') && !allowed.includes('platform_search')
|
|
1998
|
+
for (const schema of visible) {
|
|
1999
|
+
if (allowed.includes(schema.name)) toolSchemas.push(schema)
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// 本机抓不了网页(fake-IP 代理 / SSRF 防护)时,把 web_fetch 摘掉:
|
|
2004
|
+
// 否则模型每次都要白跑一轮(5~15s)才拿到"resolves to a non-public IP address"。
|
|
2005
|
+
let fetchBlockedSample = ''
|
|
2006
|
+
if (toolSchemas.some((schema) => schema.name === 'web_fetch')) {
|
|
2007
|
+
const probe = await webFetchUsable()
|
|
2008
|
+
if (!probe.usable) {
|
|
2009
|
+
fetchBlockedSample = probe.sample
|
|
2010
|
+
for (let i = toolSchemas.length - 1; i >= 0; i -= 1) {
|
|
2011
|
+
if (toolSchemas[i]?.name === 'web_fetch') toolSchemas.splice(i, 1)
|
|
2012
|
+
}
|
|
2013
|
+
ctx.logger?.warn?.(`[${name}] 网页抓取不可用(域名解析到 ${probe.sample},属保留地址段),已从工具表移除 web_fetch`)
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// 文件类工具的边界:默认只允许会话工作目录;'*' = 不限制;配置了具体目录就用它。
|
|
2018
|
+
// 拿不到工作目录时**不设限**(否则新会话里 read 直接全废),但会打日志说明。
|
|
2019
|
+
const wantsFsTools = toolSchemas.some((schema) => FS_TOOL_NAMES.includes(schema.name))
|
|
2020
|
+
let readRoot = ''
|
|
2021
|
+
if (wantsFsTools) {
|
|
2022
|
+
if (config.toolReadRoot === '*') {
|
|
2023
|
+
readRoot = ''
|
|
2024
|
+
} else {
|
|
2025
|
+
readRoot = config.toolReadRoot || (await resolveSessionCwd(sessionIdOf(body))) || ''
|
|
2026
|
+
if (!readRoot) ctx.logger?.warn?.(`[${name}] 拿不到会话工作目录,文件工具本次不设边界`)
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const stage = typeof body.stage === 'string' ? body.stage : ''
|
|
2031
|
+
// 选中文字是代码:首轮走「注释」提示词(代码块 + 逐句注释 + 小结)
|
|
2032
|
+
const isCode = body.kind === 'code'
|
|
2033
|
+
const basePrompt = isChat
|
|
2034
|
+
? CHAT_SYSTEM_PROMPT
|
|
2035
|
+
: stage === 'detail'
|
|
2036
|
+
? DETAIL_PROMPT
|
|
2037
|
+
: isCode
|
|
2038
|
+
? CODE_PROMPT
|
|
2039
|
+
: TRANSLATION_PROMPT
|
|
2040
|
+
// 一个工具都没有时明确禁止伪造调用(模型会照着自己的习惯吐 <ds_safety_tool_call> 之类的格式)
|
|
2041
|
+
// 正文里不许写"盘算过程"(模型在工具轮里很容易把"I should search again…"这类写进正文)
|
|
2042
|
+
const narrateRule =
|
|
2043
|
+
'\n\n【正文只写结论】不要把思考、盘算、自查过程写进正文("我还得再搜一次""先看看拿到的标题""让我确认一下"这类一律不写);需要工具就直接调用,调用前后都不要解说。'
|
|
2044
|
+
// 输出偏好(小窗开关):左档 Markdown / 右档 网页。
|
|
2045
|
+
// 关档也要明确说"用 Markdown"——否则标签写着 Markdown、行为却可能自己给整页 HTML,开关就名不副实。
|
|
2046
|
+
let webRule = ''
|
|
2047
|
+
if (isChat && body.webAnswer !== true) {
|
|
2048
|
+
webRule =
|
|
2049
|
+
'\n\n【输出偏好:Markdown】用户把输出偏好设为 Markdown:默认用普通文字/Markdown 回答' +
|
|
2050
|
+
'(标题、列表、表格、代码块都可以)。' +
|
|
2051
|
+
'**这不禁止 HTML**——如果这个问题用一页 HTML 明显讲得更清楚,照样可以用网页回答;' +
|
|
2052
|
+
'只是不要单纯为了"好看"就把简单问题做成页面。'
|
|
2053
|
+
}
|
|
2054
|
+
if (isChat && body.webAnswer === true) {
|
|
2055
|
+
const skill = await loadDesignSkill()
|
|
2056
|
+
webRule =
|
|
2057
|
+
'\n\n【输出偏好:网页】这不是"一律用网页",而是**网页优先**:\n' +
|
|
2058
|
+
'- **复杂问题**(需要对比、流程、层级、图表,或者文字答案会长到不好读)→ 直接给完整的单文件 HTML' +
|
|
2059
|
+
'(放进 ```html 代码块),**页面本身就是答案**,不要再用文字复述一遍;\n' +
|
|
2060
|
+
'- **简单问题**(一两段话能说清、不需要并列或图示)→ **仍然直接用文字回答**。' +
|
|
2061
|
+
'不要为了用网页而用网页,也不要把简单问题包装成页面。\n' +
|
|
2062
|
+
'拿不准时按"能不能用一小段话讲清"来判断:能,就文字。\n' +
|
|
2063
|
+
'用网页时,页面会渲染在大约 **500px 宽**的小窗里,按下面的设计规范做:\n\n' +
|
|
2064
|
+
skill
|
|
2065
|
+
}
|
|
2066
|
+
const fileRule = wantsFsTools
|
|
2067
|
+
? `\n\n【文件访问】你可以用 read/grep/glob 查看当前项目里的文件(范围:${readRoot || '不限'})。超出该范围的路径会被拒绝;引用文件内容时只取必要的一两行,不要整段粘贴。`
|
|
2068
|
+
: ''
|
|
2069
|
+
const fetchRule = fetchBlockedSample
|
|
2070
|
+
? `\n\n【抓取网页不可用】本机域名解析到保留地址段(${fetchBlockedSample}),网页抓取会被安全策略拒绝:只做联网搜索,**不要尝试抓取具体网址**(省掉一次注定失败的往返)。`
|
|
2071
|
+
: ''
|
|
2072
|
+
const systemPrompt =
|
|
2073
|
+
(toolSchemas.length > 0
|
|
2074
|
+
? basePrompt
|
|
2075
|
+
: `${basePrompt}\n\n【本轮没有可用工具】不要输出任何工具调用格式(如 <ds_safety_tool_call>、tool_calls、JSON 形式的调用),也不要声称你查过;需要实时/外部信息时直接说明你无法联网或无法读取,只基于已知信息作答。`) +
|
|
2076
|
+
narrateRule +
|
|
2077
|
+
webRule +
|
|
2078
|
+
fileRule +
|
|
2079
|
+
fetchRule
|
|
2080
|
+
// 三档分开:翻译(默认 low,只要快)/详解(默认 reasoningEffort=high,要深)/追问(chatReasoningEffort=high,要准)
|
|
2081
|
+
// 三档分开:翻译(默认 low,只要快)/详解(默认 reasoningEffort=high,要深)/追问(默认 chatReasoningEffort=high)
|
|
2082
|
+
// 但**追问档**允许请求覆盖(小窗的「推理等级(追问档)」控件靠这条生效):
|
|
2083
|
+
// 首轮/详解仍按各自的配置走 —— 首轮故意用低档换速度,不该被追问档的设置带跑。
|
|
2084
|
+
await loadLeaky()
|
|
2085
|
+
let bodyEffort = typeof body.effort === 'string' && body.effort.length > 0 ? body.effort : ''
|
|
2086
|
+
// 这个模型在"关"档会把思考写进正文(实测过):别再发 none,直接抬到 low —— 既没泄漏,也不慢
|
|
2087
|
+
const routeKey = `${route.provider}/${route.model}`
|
|
2088
|
+
if (isChat && bodyEffort === 'off' && leakyOnOff.has(routeKey)) {
|
|
2089
|
+
bodyEffort = 'low'
|
|
2090
|
+
sse(res, {
|
|
2091
|
+
type: 'notice',
|
|
2092
|
+
code: 'effort-clamped',
|
|
2093
|
+
tier: 'off',
|
|
2094
|
+
text: '该模型在「关」档会把思考写进正文,已自动改用「低」档',
|
|
2095
|
+
})
|
|
2096
|
+
}
|
|
2097
|
+
const requestEffort = isChat
|
|
2098
|
+
? bodyEffort || config.chatReasoningEffort || effort
|
|
2099
|
+
: stage === 'detail'
|
|
2100
|
+
? effort
|
|
2101
|
+
: config.translationReasoningEffort || effort
|
|
2102
|
+
|
|
2103
|
+
sse(res, {
|
|
2104
|
+
type: 'start',
|
|
2105
|
+
provider: route.provider,
|
|
2106
|
+
model: route.model,
|
|
2107
|
+
mode: isChat ? 'chat' : stage === 'detail' ? 'detail' : isCode ? 'code' : 'translation',
|
|
2108
|
+
stage: stage === 'detail' ? 'detail' : 'translation',
|
|
2109
|
+
backgroundMessages: background ? background.transcript.split('\n').filter((line) => /^\s*(▶\s*)?(用户|助手):/.test(line)).length : 0,
|
|
2110
|
+
effort: requestEffort,
|
|
2111
|
+
tools: toolSchemas.map((schema) => schema.name),
|
|
2112
|
+
toolScope: agentScope,
|
|
2113
|
+
...(toolFallbackUsed ? { toolFallbackUsed: true } : {}),
|
|
2114
|
+
...(wantsFsTools ? { readRoot: readRoot || '(不限)' } : {}),
|
|
2115
|
+
...(fetchBlockedSample ? { fetchBlocked: fetchBlockedSample } : {}),
|
|
2116
|
+
context: {
|
|
2117
|
+
chars: context.length,
|
|
2118
|
+
label,
|
|
2119
|
+
session: background
|
|
2120
|
+
? {
|
|
2121
|
+
chars: background.transcript.length,
|
|
2122
|
+
marked: background.marked,
|
|
2123
|
+
// 背景短哈希:用于核对「前缀是否真的没变」(缓存命中的前提)
|
|
2124
|
+
hash: createHash('sha1').update(background.transcript).digest('hex').slice(0, 10),
|
|
2125
|
+
}
|
|
2126
|
+
: null,
|
|
2127
|
+
},
|
|
2128
|
+
...(body.debug === true
|
|
2129
|
+
? { debug: { system: systemPrompt, user: userText, sizes: { system: systemPrompt.length, user: userText.length } } }
|
|
2130
|
+
: {}),
|
|
2131
|
+
})
|
|
2132
|
+
let chars = 0
|
|
2133
|
+
const baseMessage = createUserMessage({
|
|
2134
|
+
source: { kind: 'user' },
|
|
2135
|
+
content: [{ type: 'text', text: userText }],
|
|
2136
|
+
})
|
|
2137
|
+
// 上一轮查过的东西一并带上:否则追问会反复重查同样的内容(一次工具往返 20~34s)
|
|
2138
|
+
const toolDigest = typeof body.toolDigest === 'string' ? body.toolDigest.trim().slice(0, 2000) : ''
|
|
2139
|
+
const questionText = toolDigest
|
|
2140
|
+
? `${question}\n\n【本会话已经查到的结果】(同一会话里查过的,不要重复查同样的内容)\n${toolDigest}`
|
|
2141
|
+
: question
|
|
2142
|
+
const openingMessages: Message[] = isChat
|
|
2143
|
+
? [...historyMessages, createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: questionText }] })]
|
|
2144
|
+
: []
|
|
2145
|
+
try {
|
|
2146
|
+
let errored = false
|
|
2147
|
+
let usage: unknown = null
|
|
2148
|
+
let answer = ''
|
|
2149
|
+
const toolMessages: Message[] = []
|
|
2150
|
+
let toolCalls = 0
|
|
2151
|
+
// 允许的工具轮 + 1 个**收尾轮**:收尾轮不带任何工具。
|
|
2152
|
+
// 不加这一轮的话,模型每轮都调工具时循环直接结束,正文是空的——
|
|
2153
|
+
// 用户看到的就是"转了半天,但没有任何回答"。
|
|
2154
|
+
let errorMessage = ''
|
|
2155
|
+
let errorCode: string | undefined
|
|
2156
|
+
const toolDigestLines: string[] = []
|
|
2157
|
+
/** 被残渣过滤器丢掉的字数:末尾那些"模型顺手又调了一次工具"的标记会让正文停在半句上。 */
|
|
2158
|
+
let residueDropped = 0
|
|
2159
|
+
/**
|
|
2160
|
+
* 逐轮诊断(放进 done 事件):原始字数 / 过滤后字数 / 思考字数 / 残渣丢弃 / finish 原因 / 输出 tokens。
|
|
2161
|
+
* 用来回答"到底是模型没吐、还是被我们的过滤器吃掉了、还是上游掐流"——不靠猜。
|
|
2162
|
+
*/
|
|
2163
|
+
const roundDiag: Array<Record<string, unknown>> = []
|
|
2164
|
+
let conclusionInfo: { tried: boolean; chars: number; rounds: number; error: string } = {
|
|
2165
|
+
tried: false,
|
|
2166
|
+
chars: 0,
|
|
2167
|
+
rounds: 0,
|
|
2168
|
+
error: '',
|
|
2169
|
+
}
|
|
2170
|
+
const toolRounds = config.maxToolRounds + 1
|
|
2171
|
+
// 档位被上游拒绝时(400/401/500)去掉档位重试一次:清单里声明的档位是**建议值**,
|
|
2172
|
+
// 实测很多模型并不接受 max(qwen3.7-max 直接 400、minimax-m2.7/gpt-5.6-luna 500)。
|
|
2173
|
+
const canRetryEffort = isChat && bodyEffort.length > 0
|
|
2174
|
+
let effortRetried = false
|
|
2175
|
+
let roundEffort = requestEffort
|
|
2176
|
+
for (let round = 0; round <= toolRounds; round += 1) {
|
|
2177
|
+
const roundStartedAt = Date.now()
|
|
2178
|
+
const wrapUp = round === toolRounds
|
|
2179
|
+
const stream = ctx.llm.stream({
|
|
2180
|
+
provider: route.provider,
|
|
2181
|
+
model: route.model,
|
|
2182
|
+
// 带上会话 id:主会话 agent loop 一直传它,适配器会据此设置上游的会话头。
|
|
2183
|
+
// 插件以前没传,结果是所有小窗请求共用静态会话头 —— 而中继是按会话跟踪的(实测长回答被掐)。
|
|
2184
|
+
...(sessionId ? { sessionId: sessionId as never } : {}),
|
|
2185
|
+
system: undefined,
|
|
2186
|
+
messages: [
|
|
2187
|
+
createSystemMessage(
|
|
2188
|
+
wrapUp
|
|
2189
|
+
? `${systemPrompt}\n\n【收尾】工具轮次已用完:直接基于已经拿到的信息给出结论,不要再请求工具,也不要复述查询过程。` +
|
|
2190
|
+
'正文里不要出现任何工具调用格式(包括 DeepSeek 的 DSML 写法,如 invoke/parameter 标记)——那些会被当作乱码展示给用户。'
|
|
2191
|
+
: systemPrompt,
|
|
2192
|
+
name,
|
|
2193
|
+
),
|
|
2194
|
+
...(round === 0 ? [baseMessage, ...openingMessages] : [baseMessage, ...openingMessages, ...toolMessages]),
|
|
2195
|
+
],
|
|
2196
|
+
...(!wrapUp && toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
|
|
2197
|
+
...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
|
|
2198
|
+
...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
|
|
2199
|
+
...(roundEffort ? { reasoningEffort: ReasoningEffortId(roundEffort) } : {}),
|
|
2200
|
+
signal: controller.signal,
|
|
2201
|
+
})
|
|
2202
|
+
const assembler = new BlockAssembler()
|
|
2203
|
+
// 本轮吐出的正文字数:这一轮如果最后是要调工具,那这些字其实是"过程旁白"
|
|
2204
|
+
// ("我还得再搜一次""先看看拿到的标题"这类),要被撤回,不能当成答案留在小窗里
|
|
2205
|
+
let roundText = ''
|
|
2206
|
+
// 有的模型把思考内联在正文里(<think>…</think>):拆出来走 thought,别当答案
|
|
2207
|
+
// 每轮一个过滤器:未闭合的残渣块只在本轮内作废。
|
|
2208
|
+
// 共用一个的话,某一轮吐了未闭合的 DSML(实测收尾轮很常见)会让 depth 一直 >0,
|
|
2209
|
+
// 后面所有轮次的正文都被吞掉 —— 表现就是"只完成了工具查询,没有产出正文"(用户截图报的)。
|
|
2210
|
+
const think = createThinkFilter()
|
|
2211
|
+
const residue = createResidueFilter()
|
|
2212
|
+
let roundRaw = 0
|
|
2213
|
+
let roundEmitted = 0
|
|
2214
|
+
let roundThink = 0
|
|
2215
|
+
let roundFinish = ''
|
|
2216
|
+
/** 诊断用:本轮最后若干分片的类型与大小(看上游是发了 finish 还是直接断流)。 */
|
|
2217
|
+
const tailChunks: string[] = []
|
|
2218
|
+
const emit = (part: { text: string; think: string }): void => {
|
|
2219
|
+
if (part.think) {
|
|
2220
|
+
roundThink += part.think.length
|
|
2221
|
+
sse(res, { type: 'thought', text: part.think })
|
|
2222
|
+
}
|
|
2223
|
+
// 残渣过滤放在 think 之后:先分出思考,再把正文里的工具调用标记丢掉
|
|
2224
|
+
const text = part.text ? residue.push(part.text) : ''
|
|
2225
|
+
if (text) {
|
|
2226
|
+
roundEmitted += text.length
|
|
2227
|
+
chars += text.length
|
|
2228
|
+
answer += text
|
|
2229
|
+
roundText += text
|
|
2230
|
+
sse(res, { type: 'delta', text: text })
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
for await (const chunk of stream) {
|
|
2234
|
+
assembler.push(chunk)
|
|
2235
|
+
if (body.debug === true) {
|
|
2236
|
+
const size = chunk.type === 'text-delta' ? chunk.text.length : 0
|
|
2237
|
+
tailChunks.push(`${chunk.type}:${size}`)
|
|
2238
|
+
if (tailChunks.length > 14) tailChunks.shift()
|
|
2239
|
+
}
|
|
2240
|
+
if (chunk.type === 'text-delta') {
|
|
2241
|
+
roundRaw += chunk.text.length
|
|
2242
|
+
emit(think.push(chunk.text))
|
|
2243
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
2244
|
+
// 思考过程也透传:首字前那几秒如果只有转圈,用户会以为卡死了。
|
|
2245
|
+
// 客户端只留尾部一小段当"正在想什么"的提示,不进正文。
|
|
2246
|
+
sse(res, { type: 'thought', text: chunk.text })
|
|
2247
|
+
} else if (chunk.type === 'usage') {
|
|
2248
|
+
usage = chunk.usage
|
|
2249
|
+
} else if (chunk.type === 'finish') {
|
|
2250
|
+
roundFinish = String(chunk.reason.kind)
|
|
2251
|
+
if (chunk.reason.kind === 'error' || chunk.reason.kind === 'aborted') {
|
|
2252
|
+
errored = true
|
|
2253
|
+
// 先只记下来:如果接下来要"去掉档位重试",这条错误就不该发给用户
|
|
2254
|
+
// (否则客户端先渲染一条失败提示,重试的正文就贴在失败提示后面了)
|
|
2255
|
+
errorMessage =
|
|
2256
|
+
chunk.reason.failure?.message ?? `模型调用${chunk.reason.kind === 'aborted' ? '被中断' : '失败'}`
|
|
2257
|
+
errorCode = chunk.reason.failure?.code
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
emit(think.flush())
|
|
2262
|
+
// 注意:residue.flush() 吐出的正文**不能再走 emit()** —— emit 会把它再喂回同一个过滤器,
|
|
2263
|
+
// 于是 HOLD=32 又扣住尾巴,而本轮到此结束 → 最后 32 字永远不会发出去(实测 raw−emitted 恒为 32)。
|
|
2264
|
+
{
|
|
2265
|
+
const tailText = residue.flush()
|
|
2266
|
+
if (tailText) {
|
|
2267
|
+
chars += tailText.length
|
|
2268
|
+
answer += tailText
|
|
2269
|
+
roundText += tailText
|
|
2270
|
+
roundEmitted += tailText.length
|
|
2271
|
+
sse(res, { type: 'delta', text: tailText })
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
residueDropped += residue.dropped()
|
|
2275
|
+
roundDiag.push({
|
|
2276
|
+
round,
|
|
2277
|
+
wrapUp,
|
|
2278
|
+
ms: Date.now() - roundStartedAt,
|
|
2279
|
+
raw: roundRaw,
|
|
2280
|
+
emitted: roundEmitted,
|
|
2281
|
+
think: roundThink,
|
|
2282
|
+
residueDropped: residue.dropped(),
|
|
2283
|
+
finish: roundFinish || '(none)',
|
|
2284
|
+
tools: assembler.blocks().filter((block) => block.type === 'tool-call').length,
|
|
2285
|
+
...(body.debug === true ? { tailChunks } : {}),
|
|
2286
|
+
outTokens: (usage as { outputTokens?: number } | null)?.outputTokens,
|
|
2287
|
+
})
|
|
2288
|
+
if (errored) {
|
|
2289
|
+
// 一个字都没吐出来 + 是显式档位导致的失败 → 去掉档位重试**同一轮**
|
|
2290
|
+
// (保守:已经吐出正文的失败不重试,避免把半截答案丢掉)
|
|
2291
|
+
if (canRetryEffort && !effortRetried && roundText.length === 0) {
|
|
2292
|
+
effortRetried = true
|
|
2293
|
+
roundEffort = ''
|
|
2294
|
+
errored = false
|
|
2295
|
+
sse(res, {
|
|
2296
|
+
type: 'notice',
|
|
2297
|
+
code: 'effort-rejected',
|
|
2298
|
+
tier: bodyEffort,
|
|
2299
|
+
text: `「${bodyEffort}」档位该模型不支持,已按默认档位重试`,
|
|
2300
|
+
})
|
|
2301
|
+
round -= 1
|
|
2302
|
+
continue
|
|
2303
|
+
}
|
|
2304
|
+
sse(res, { type: 'error', message: errorMessage, code: errorCode })
|
|
2305
|
+
break
|
|
2306
|
+
}
|
|
2307
|
+
const blocks: ContentBlock[] = assembler.blocks()
|
|
2308
|
+
const calls = blocks.filter((block) => block.type === 'tool-call')
|
|
2309
|
+
if (calls.length === 0) {
|
|
2310
|
+
// 最终答案到手:如果用的是 "关" 档,检查有没有把思考写进正文
|
|
2311
|
+
if (isChat && requestEffort === 'off') {
|
|
2312
|
+
const split = splitLeakedReasoning(answer)
|
|
2313
|
+
if (split.prefix) {
|
|
2314
|
+
markLeaky(routeKey)
|
|
2315
|
+
answer = split.rest
|
|
2316
|
+
chars = answer.length
|
|
2317
|
+
sse(res, { type: 'strip', text: split.prefix })
|
|
2318
|
+
sse(res, {
|
|
2319
|
+
type: 'notice',
|
|
2320
|
+
code: 'reasoning-leak',
|
|
2321
|
+
tier: 'off',
|
|
2322
|
+
text: '该模型在「关」档会把思考写进正文,已撤回泄漏段;以后这一档会自动改用「低」',
|
|
2323
|
+
})
|
|
2324
|
+
} else if (looksLikeLeakedReasoning(answer)) {
|
|
2325
|
+
// 句式没认准(模型每次换说法)→ 这次不裁(怕误删正文),但记住它,下次自动抬档
|
|
2326
|
+
markLeaky(routeKey)
|
|
2327
|
+
sse(res, {
|
|
2328
|
+
type: 'notice',
|
|
2329
|
+
code: 'reasoning-leak',
|
|
2330
|
+
tier: 'off',
|
|
2331
|
+
text: '这个模型在「关」档会把思考写进正文;这一档以后自动改用「低」',
|
|
2332
|
+
})
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
break
|
|
2336
|
+
}
|
|
2337
|
+
// 既然这一轮要调工具,它刚才流出来的正文就是"过程旁白",不是答案:
|
|
2338
|
+
// 让前端把这部分撤回去(顺带当成思考尾巴显示),免得"我怎么想的"混进小窗正文。
|
|
2339
|
+
// 实测踩过:追问"搜索今天的新闻"时,模型在工具轮里先吐了一段英文盘算
|
|
2340
|
+
// (I have sources with dates… Maybe I should do one more search…),
|
|
2341
|
+
// 然后才给中文结论,这段英文被当正文渲染进了消息框。
|
|
2342
|
+
if (roundText.length > 0) {
|
|
2343
|
+
answer = answer.slice(0, Math.max(0, answer.length - roundText.length))
|
|
2344
|
+
sse(res, { type: 'drop', chars: roundText.length, text: roundText.slice(-2000), round })
|
|
2345
|
+
}
|
|
2346
|
+
// 执行工具→把结果回灌,再让模型继续(上限 maxToolRounds 轮)
|
|
2347
|
+
toolMessages.push(
|
|
2348
|
+
createAssistantMessage({
|
|
2349
|
+
content: blocks,
|
|
2350
|
+
source: { provider: route.provider, model: route.model },
|
|
2351
|
+
}),
|
|
2352
|
+
)
|
|
2353
|
+
for (const call of calls) {
|
|
2354
|
+
if (call.type !== 'tool-call') continue
|
|
2355
|
+
toolCalls += 1
|
|
2356
|
+
let parsed: unknown = {}
|
|
2357
|
+
try {
|
|
2358
|
+
parsed = call.arguments ? JSON.parse(call.arguments) : {}
|
|
2359
|
+
} catch {
|
|
2360
|
+
parsed = {}
|
|
2361
|
+
}
|
|
2362
|
+
const label2 = describeToolCall(call.name, parsed)
|
|
2363
|
+
const callId = String(call.id ?? `round${String(round)}-${String(toolCalls)}`)
|
|
2364
|
+
sse(res, { type: 'tool', phase: 'start', callId, name: call.name, detail: label2, round })
|
|
2365
|
+
const startedAt = Date.now()
|
|
2366
|
+
// 参数名有讲究:read/write/edit 用 file_path,grep/glob 用 path。
|
|
2367
|
+
// 只认 path 的话 read 的 file_path 会是 undefined → 边界检查被静默绕过(实测踩过)。
|
|
2368
|
+
const rawPath = (parsed as { file_path?: unknown; path?: unknown })?.file_path ?? (parsed as { path?: unknown })?.path
|
|
2369
|
+
const wantPath = typeof rawPath === 'string' ? rawPath : ''
|
|
2370
|
+
const execution =
|
|
2371
|
+
FS_TOOL_NAMES.includes(call.name) && !isInsideRoot(readRoot, wantPath)
|
|
2372
|
+
? {
|
|
2373
|
+
content: [
|
|
2374
|
+
{
|
|
2375
|
+
type: 'text' as const,
|
|
2376
|
+
text: `(越界已拒绝:文件类工具只能访问当前项目目录${readRoot ? `(${readRoot})` : ''},请求的路径是 ${wantPath || '(未给)'})`,
|
|
2377
|
+
},
|
|
2378
|
+
],
|
|
2379
|
+
isError: true,
|
|
2380
|
+
}
|
|
2381
|
+
: await runTool(ctx, call.name, parsed, controller.signal, config.toolResultMaxChars, agent)
|
|
2382
|
+
// 结果也回传:小窗里能看见"查了什么、命中什么、链接是什么",否则工具是个黑盒
|
|
2383
|
+
const digest = digestToolResult(execution.content)
|
|
2384
|
+
sse(res, {
|
|
2385
|
+
type: 'tool',
|
|
2386
|
+
phase: 'done',
|
|
2387
|
+
callId,
|
|
2388
|
+
name: call.name,
|
|
2389
|
+
detail: label2,
|
|
2390
|
+
round,
|
|
2391
|
+
ok: !execution.isError,
|
|
2392
|
+
ms: Date.now() - startedAt,
|
|
2393
|
+
chars: digest.chars,
|
|
2394
|
+
preview: digest.preview,
|
|
2395
|
+
urls: digest.urls,
|
|
2396
|
+
})
|
|
2397
|
+
toolMessages.push(createToolResultMessage({ callId: call.id, content: execution.content, isError: execution.isError }))
|
|
2398
|
+
// 结论轮要用:把"查了什么 + 拿到了什么"写成纯文本,避免重放工具调用链
|
|
2399
|
+
toolDigestLines.push(`· ${call.name}(${label2 || '查询'}):${digest.preview}`)
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
if (!errored && answer.trim().length === 0 && toolCalls > 0 && !effortRetried) {
|
|
2403
|
+
// 收尾轮常常"只吐一段工具调用、不写正文"(实测「今天的新闻」一个字都没有)。
|
|
2404
|
+
// 这里自己补结论轮:**不重放工具调用链**,而是把结果写成一段自包含的提示 ——
|
|
2405
|
+
// 重放消息链的方式实测也不稳(模型面对 tool-call 历史仍可能继续吐工具调用)。
|
|
2406
|
+
conclusionInfo.tried = true
|
|
2407
|
+
sse(res, { type: 'notice', code: 'conclusion-retry', text: '查询已完成,正在整理结论…' })
|
|
2408
|
+
const conclusionUser = [
|
|
2409
|
+
userText,
|
|
2410
|
+
'',
|
|
2411
|
+
'【已经查到的资料】',
|
|
2412
|
+
...toolDigestLines.slice(0, 12),
|
|
2413
|
+
'',
|
|
2414
|
+
'请**直接输出**给用户的最终答案:结论先行、必要处带来源链接。',
|
|
2415
|
+
'不要再请求工具,不要输出任何工具调用格式,也不要写"我先看看""让我核对一下"这类过程。',
|
|
2416
|
+
].join('\n')
|
|
2417
|
+
// 先按当前档位试一次;空则用「关」档再试(有的模型在这一档更"直接给答案")
|
|
2418
|
+
for (const attemptEffort of [roundEffort, 'off']) {
|
|
2419
|
+
if (answer.trim().length > 0) break
|
|
2420
|
+
conclusionInfo.rounds += 1
|
|
2421
|
+
try {
|
|
2422
|
+
const resume = ctx.llm.stream({
|
|
2423
|
+
provider: route.provider,
|
|
2424
|
+
model: route.model,
|
|
2425
|
+
...(sessionId ? { sessionId: sessionId as never } : {}),
|
|
2426
|
+
system: undefined,
|
|
2427
|
+
messages: [
|
|
2428
|
+
createSystemMessage(systemPrompt, name),
|
|
2429
|
+
createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: conclusionUser }] }),
|
|
2430
|
+
],
|
|
2431
|
+
...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
|
|
2432
|
+
...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
|
|
2433
|
+
...(attemptEffort ? { reasoningEffort: ReasoningEffortId(attemptEffort) } : {}),
|
|
2434
|
+
signal: controller.signal,
|
|
2435
|
+
})
|
|
2436
|
+
const thinkAgain = createThinkFilter()
|
|
2437
|
+
const residueAgain = createResidueFilter()
|
|
2438
|
+
for await (const chunk of resume) {
|
|
2439
|
+
if (chunk.type === 'text-delta') {
|
|
2440
|
+
const part = thinkAgain.push(chunk.text)
|
|
2441
|
+
if (part.think) sse(res, { type: 'thought', text: part.think })
|
|
2442
|
+
const text = part.text ? residueAgain.push(part.text) : ''
|
|
2443
|
+
if (text) {
|
|
2444
|
+
chars += text.length
|
|
2445
|
+
answer += text
|
|
2446
|
+
conclusionInfo.chars += text.length
|
|
2447
|
+
sse(res, { type: 'delta', text: text })
|
|
2448
|
+
}
|
|
2449
|
+
} else if (chunk.type === 'usage') {
|
|
2450
|
+
usage = chunk.usage
|
|
2451
|
+
} else if (chunk.type === 'finish') {
|
|
2452
|
+
if (chunk.reason.kind === 'error') {
|
|
2453
|
+
conclusionInfo.error = chunk.reason.failure?.message ?? '模型调用失败'
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
for (const tail of [thinkAgain.flush()]) if (tail.think) sse(res, { type: 'thought', text: tail.think })
|
|
2458
|
+
const tailText = residueAgain.flush()
|
|
2459
|
+
if (tailText) {
|
|
2460
|
+
chars += tailText.length
|
|
2461
|
+
answer += tailText
|
|
2462
|
+
conclusionInfo.chars += tailText.length
|
|
2463
|
+
sse(res, { type: 'delta', text: tailText })
|
|
2464
|
+
}
|
|
2465
|
+
residueDropped += residueAgain.dropped()
|
|
2466
|
+
// 结论轮也可能把"英文核对过程"写进正文:一律按泄漏裁一次
|
|
2467
|
+
const leaked = splitLeakedReasoning(answer)
|
|
2468
|
+
if (leaked.prefix) {
|
|
2469
|
+
answer = leaked.rest
|
|
2470
|
+
chars = answer.length
|
|
2471
|
+
sse(res, { type: 'strip', text: leaked.prefix })
|
|
2472
|
+
sse(res, { type: 'notice', code: 'reasoning-leak', text: '已去掉模型写进正文的核对过程' })
|
|
2473
|
+
}
|
|
2474
|
+
} catch (error) {
|
|
2475
|
+
conclusionInfo.error = String((error as Error)?.message ?? error)
|
|
2476
|
+
ctx.logger?.warn?.(`[${name}] 结论轮失败:${conclusionInfo.error}`)
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
if (!errored && answer.trim().length === 0) {
|
|
2481
|
+
// 兜底:连结论轮都没吐字时,至少给一句话(否则面板空白,看起来像"没返回结果")
|
|
2482
|
+
const note =
|
|
2483
|
+
toolCalls > 0
|
|
2484
|
+
? '(工具查询完成了,但模型没有产出正文。可以再追问一句"直接说结论",我按已查到的结果回答。)'
|
|
2485
|
+
: '(模型这次没有返回内容,可以点「重新生成」再试一次。)'
|
|
2486
|
+
answer = note
|
|
2487
|
+
chars += note.length
|
|
2488
|
+
sse(res, { type: 'delta', text: note })
|
|
2489
|
+
}
|
|
2490
|
+
if (!errored) {
|
|
2491
|
+
sse(res, {
|
|
2492
|
+
type: 'done',
|
|
2493
|
+
chars,
|
|
2494
|
+
usage,
|
|
2495
|
+
toolCalls,
|
|
2496
|
+
// 诊断用:结论轮/续写各试了几次、拿到多少字、失败原因
|
|
2497
|
+
...(conclusionInfo.tried ? { conclusion: conclusionInfo } : {}),
|
|
2498
|
+
...(roundDiag.length > 0 ? { rounds: roundDiag } : {}),
|
|
2499
|
+
clientClosedEarly,
|
|
2500
|
+
})
|
|
2501
|
+
if (useResultCache && answer.length > 0) {
|
|
2502
|
+
resultCache.set(cacheKey, { at: Date.now(), text: answer })
|
|
2503
|
+
if (resultCache.size > 200) {
|
|
2504
|
+
const oldest = resultCache.keys().next().value
|
|
2505
|
+
if (oldest !== undefined) resultCache.delete(oldest)
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
} catch (error) {
|
|
2510
|
+
const err = error as Error
|
|
2511
|
+
const aborted = err?.name === 'AbortError'
|
|
2512
|
+
sse(res, {
|
|
2513
|
+
type: 'error',
|
|
2514
|
+
message: aborted ? `调用超时或已取消(${config.timeoutMs}ms)` : String(err?.message ?? err),
|
|
2515
|
+
})
|
|
2516
|
+
} finally {
|
|
2517
|
+
finished = true
|
|
2518
|
+
clearTimeout(timer)
|
|
2519
|
+
res.end()
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
ctx.effect(
|
|
2524
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/analyze`, handler: handleAnalyze }),
|
|
2525
|
+
`${name}: analyze route`,
|
|
2526
|
+
)
|
|
2527
|
+
ctx.effect(
|
|
2528
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/ping`, handler: handlePing }),
|
|
2529
|
+
`${name}: ping route`,
|
|
2530
|
+
)
|
|
2531
|
+
ctx.effect(
|
|
2532
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/history`, handler: handleHistory }),
|
|
2533
|
+
`${name}: history route`,
|
|
2534
|
+
)
|
|
2535
|
+
ctx.effect(
|
|
2536
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/models`, handler: handleModels }),
|
|
2537
|
+
`${name}: models route`,
|
|
2538
|
+
)
|
|
2539
|
+
ctx.effect(
|
|
2540
|
+
() => ctx.webServer.register({ kind: 'exact', path: `${API_PREFIX}/promote`, handler: handlePromote }),
|
|
2541
|
+
`${name}: promote route`,
|
|
2542
|
+
)
|
|
2543
|
+
|
|
2544
|
+
ctx.logger?.info?.(`[${name}] 划词解读路由就绪:${API_PREFIX}/analyze`)
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/** 类型引用(供声明文件 re-export,避免仅用于类型增强的 import 被判为未使用)。 */
|
|
2548
|
+
export type { WebServer }
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* 纯函数内部件,仅供单测直接调用(Cordis 只用 default export,多这几个具名导出没有副作用)。
|
|
2552
|
+
* 这三个都是"流式文本手术刀",靠真实请求验证会被模型的随机发挥带偏,必须能确定性单测。
|
|
2553
|
+
*/
|
|
2554
|
+
export const __internals = {
|
|
2555
|
+
createThinkFilter,
|
|
2556
|
+
createResidueFilter,
|
|
2557
|
+
splitLeakedReasoning,
|
|
2558
|
+
looksLikeLeakedReasoning,
|
|
2559
|
+
resolveToolNames,
|
|
2560
|
+
}
|