@yfwu2020/dsh-selection-explain 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -5
- package/lib/chat-context.js +86 -0
- package/lib/chat-context.js.map +1 -0
- package/lib/client.js +1978 -168
- package/lib/client.js.map +4 -4
- package/lib/config-values.js +6 -0
- package/lib/config-values.js.map +1 -1
- package/lib/host/analyze.js +199 -47
- package/lib/host/analyze.js.map +1 -1
- package/lib/host/chat-context.js +160 -0
- package/lib/host/chat-context.js.map +1 -0
- package/lib/host/chat-materials.js +64 -0
- package/lib/host/chat-materials.js.map +1 -0
- package/lib/host/config.js +8 -1
- package/lib/host/config.js.map +1 -1
- package/lib/host/history-attachments.js +17 -53
- package/lib/host/history-attachments.js.map +1 -1
- package/lib/host/history-store.js +93 -68
- package/lib/host/history-store.js.map +1 -1
- package/lib/host/http.js +2 -1
- package/lib/host/http.js.map +1 -1
- package/lib/host/plugin.js +31 -0
- package/lib/host/plugin.js.map +1 -1
- package/lib/host/promote.js +0 -1
- package/lib/host/promote.js.map +1 -1
- package/lib/host/settings-store.js +17 -18
- package/lib/host/settings-store.js.map +1 -1
- package/lib/host/shot.js +968 -0
- package/lib/host/shot.js.map +1 -0
- package/lib/prompts.js +54 -1
- package/lib/prompts.js.map +1 -1
- package/lib/settings.js +14 -1
- package/lib/settings.js.map +1 -1
- package/lib/types/chat-context.d.ts +53 -0
- package/lib/types/config-values.d.ts +30 -0
- package/lib/types/host/chat-context.d.ts +31 -0
- package/lib/types/host/chat-materials.d.ts +16 -0
- package/lib/types/host/config.d.ts +16 -2
- package/lib/types/host/history-attachments.d.ts +7 -40
- package/lib/types/host/history-store.d.ts +1 -0
- package/lib/types/host/shot.d.ts +73 -0
- package/lib/types/host/types.d.ts +17 -0
- package/lib/types/prompts.d.ts +21 -0
- package/package.json +4 -2
package/lib/config-values.js
CHANGED
|
@@ -5,6 +5,7 @@ export const CONFIG_DEFAULTS = Object.freeze({
|
|
|
5
5
|
chatModel: '',
|
|
6
6
|
reasoningEffort: 'high',
|
|
7
7
|
translationReasoningEffort: 'off',
|
|
8
|
+
chatContextTokens: 200000,
|
|
8
9
|
historyMaxEntries: 20,
|
|
9
10
|
temperature: -1,
|
|
10
11
|
maxTokens: 0,
|
|
@@ -32,8 +33,12 @@ export const CONFIG_DEFAULTS = Object.freeze({
|
|
|
32
33
|
pillIdleMs: 10000,
|
|
33
34
|
voiceCancelOnSilence: true,
|
|
34
35
|
voiceAutoSend: true,
|
|
36
|
+
shotCardScope: 'window',
|
|
37
|
+
shotPanelScope: 'selection',
|
|
38
|
+
shotMargin: 48,
|
|
35
39
|
});
|
|
36
40
|
export const CONFIG_LIMITS = {
|
|
41
|
+
chatContextTokens: { min: 4096, max: 262144 },
|
|
37
42
|
historyMaxEntries: { min: 0, max: 500 },
|
|
38
43
|
temperature: { min: -1, max: 2 },
|
|
39
44
|
maxTokens: { min: 0, max: 32000 },
|
|
@@ -51,5 +56,6 @@ export const CONFIG_LIMITS = {
|
|
|
51
56
|
maxToolRounds: { min: 0, max: 8 },
|
|
52
57
|
toolResultMaxChars: { min: 200, max: 20000 },
|
|
53
58
|
pillIdleMs: { min: 2000, max: 600000 },
|
|
59
|
+
shotMargin: { min: 0, max: 400 },
|
|
54
60
|
};
|
|
55
61
|
//# sourceMappingURL=config-values.js.map
|
package/lib/config-values.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config-values.js","sourceRoot":"","sources":["../src/config-values.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"config-values.js","sourceRoot":"","sources":["../src/config-values.ts"],"names":[],"mappings":"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"}
|
package/lib/host/analyze.js
CHANGED
|
@@ -4,13 +4,16 @@ import { sessionIdOf } from './context.js';
|
|
|
4
4
|
import { createAssistantMessage, createUserMessage, ReasoningEffortId, BlockAssembler, createToolResultMessage } from '@deepseek-ai/dsh-llm';
|
|
5
5
|
import { name } from './constants.js';
|
|
6
6
|
import { resolveToolNames, webFetchUsable, FS_TOOL_NAMES, describeToolCall, isReadAllowed, runTool, digestToolResult } from './tools.js';
|
|
7
|
-
import { CHAT_SYSTEM_PROMPT, DETAIL_PROMPT, CODE_PROMPT, TRANSLATION_PROMPT } from '../prompts.js';
|
|
7
|
+
import { CHAT_SYSTEM_PROMPT, DETAIL_PROMPT, CODE_PROMPT, TRANSLATION_PROMPT, shotPromptFor, shotTitlePromptFor } from '../prompts.js';
|
|
8
8
|
import { loadDesignSkill } from './design-skill.js';
|
|
9
9
|
import { systemMessageOf } from './messages.js';
|
|
10
10
|
import { createThinkFilter, splitLeakedReasoning, looksLikeLeakedReasoning } from './filters.js';
|
|
11
11
|
import { resolve } from 'node:path';
|
|
12
12
|
import { looksLikeReasoningRequired, looksLikeUnsupportedTier, EFFORT_LABEL } from './effort.js';
|
|
13
13
|
import { historyContentBlocks, looksLikeAttachmentFailure, readHistoryAttachments } from './history-attachments.js';
|
|
14
|
+
import { contextSource, EMPTY_DIGEST, estimateTextTokens, readContextState } from '../chat-context.js';
|
|
15
|
+
import { resolveChatCapacity, fitBackground, planChatContext, estimateRequestTokens } from './chat-context.js';
|
|
16
|
+
import { readMaterialCatalog, materialIndex, MATERIAL_TOOL, restoreMaterial } from './chat-materials.js';
|
|
14
17
|
/** Owns analyze state for one plugin instance. */
|
|
15
18
|
export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
16
19
|
/** 分钟级限流。 */
|
|
@@ -69,11 +72,24 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
69
72
|
const context = historyStore.clampText(body.context, config.maxContextChars);
|
|
70
73
|
const label = historyStore.clampText(body.label, 80);
|
|
71
74
|
// 页面标题/地址不写进提示词:模型会把它当成「选中文字里的英文」去翻译(实测把标题 DSH 译了一遍)
|
|
75
|
+
const initialExplanation = typeof body.initialExplanation === 'string' ? body.initialExplanation : '';
|
|
76
|
+
const source = contextSource(sessionIdOf(body), text, typeof body.context === 'string' ? body.context : '', initialExplanation);
|
|
77
|
+
const restoredState = readContextState(body.contextState);
|
|
78
|
+
const historyStart = Number.isSafeInteger(body.historyStart) && Number(body.historyStart) >= 0 ? Number(body.historyStart) : 0;
|
|
79
|
+
if (isChatRound && historyStart > 0 && (!restoredState || restoredState.source !== source || restoredState.through !== historyStart)) {
|
|
80
|
+
sendJson(res, 409, { error: '对话记忆进度与当前来源不一致,请重新打开这段历史后重试' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let contextState = restoredState && restoredState.source === source && restoredState.through === historyStart
|
|
84
|
+
? { ...restoredState, sessionId: sessionIdOf(body) } : { version: 1, sessionId: sessionIdOf(body), source, through: 0, digest: EMPTY_DIGEST, summary: '', background: null };
|
|
85
|
+
const capacity = isChatRound ? await resolveChatCapacity(ctx, config, route) : null;
|
|
72
86
|
// 会话背景:从 sessionQuery 读取当前会话的模型界面(live 优先,含持久化会话)
|
|
73
87
|
let background = null;
|
|
74
88
|
if (config.sessionContext) {
|
|
75
89
|
const sessionId = sessionIdOf(body);
|
|
76
|
-
if (
|
|
90
|
+
if (isChatRound && restoredState && restoredState.source === source)
|
|
91
|
+
background = contextState.background;
|
|
92
|
+
else if (sessionId) {
|
|
77
93
|
// 背景预算按阶段:首轮只要翻译(8 条够定指代);详解与追问都是深度交互,吃满 24 条
|
|
78
94
|
const stage = typeof body.stage === 'string' ? body.stage : '';
|
|
79
95
|
const wantsFull = stage === 'detail' || typeof body.question === 'string';
|
|
@@ -84,6 +100,10 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
84
100
|
});
|
|
85
101
|
}
|
|
86
102
|
}
|
|
103
|
+
if (isChatRound && capacity && !restoredState)
|
|
104
|
+
background = fitBackground(background, Math.max(128, capacity.budget * 0.2));
|
|
105
|
+
if (isChatRound)
|
|
106
|
+
contextState = { ...contextState, background };
|
|
87
107
|
// 顺序即缓存策略:前缀必须“只增不变”才能复用 ——
|
|
88
108
|
// ① 会话背景(同一会话内基本不变)→ ② 固定说明 → ③ 动态尾部(选中文字/局部片段)
|
|
89
109
|
// 反过来写(动态在前、背景在后)会让缓存前缀在“选中文字”处就断掉:
|
|
@@ -92,16 +112,36 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
92
112
|
if (background && background.transcript) {
|
|
93
113
|
stablePrefix.push(`【会话背景】(按时间顺序;${background.marked ? '最后一条即选中文字所在的消息,已用 ▶ 标出,其后的对话未包含' : '未能定位选中位置,取的是最近的对话'})`, background.transcript, '', '上面是会话背景;下面是需要你解读的【选中文字】及其上下文。', '');
|
|
94
114
|
}
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
const isShotMode = body.kind === 'shot';
|
|
116
|
+
/**
|
|
117
|
+
* 截图取景范围:决定提示词口径(有没有红框、图是不是只有选区)。
|
|
118
|
+
* 老客户端不带时按"整窗 + 红框"兜底;非法值也退到整窗,避免把提示词写坏。
|
|
119
|
+
*/
|
|
120
|
+
const shotScope = body.shotScope === 'screen' || body.shotScope === 'window' || body.shotScope === 'margin' || body.shotScope === 'selection'
|
|
121
|
+
? body.shotScope
|
|
122
|
+
: 'window';
|
|
123
|
+
const shotFrame = body.shotFrame !== false;
|
|
124
|
+
/**
|
|
125
|
+
* 取标题(截图提问的第二次调用)。
|
|
126
|
+
*
|
|
127
|
+
* 关键:**前缀必须与回答那次逐字节一致**(同样的 system、同样的图、同样的提问),
|
|
128
|
+
* 这样图那部分能命中前缀缓存;只在最后追加一句"只输出标题"。
|
|
129
|
+
* 并且工具轮次强制为 0 —— 这次不该去调工具。
|
|
130
|
+
*/
|
|
131
|
+
const titleOnly = isShotMode && body.titleOnly === true;
|
|
132
|
+
// 截图提问:**没有选中文字**,这轮的文字就是用户说的那句话,正文不该套"选中文字"模板
|
|
133
|
+
const userText = isShotMode
|
|
134
|
+
? text
|
|
135
|
+
: [
|
|
136
|
+
...stablePrefix,
|
|
137
|
+
'【选中文字】',
|
|
138
|
+
text,
|
|
139
|
+
'',
|
|
140
|
+
`【所在位置】${label || '页面内容'}`,
|
|
141
|
+
'',
|
|
142
|
+
'【选中处的上下文片段】(选中部分用【】标出)',
|
|
143
|
+
context || '(未能取到上下文片段,请仅根据选中文字与会话背景作答)',
|
|
144
|
+
].join('\n');
|
|
105
145
|
const effort = typeof body.effort === 'string' && body.effort.length > 0 ? body.effort : config.reasoningEffort;
|
|
106
146
|
// 追问模式:客户端带 question 时,就是「就这段选中文字继续聊」
|
|
107
147
|
const question = typeof body.question === 'string' ? body.question.trim() : '';
|
|
@@ -109,7 +149,16 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
109
149
|
// 这一轮的附件(追问):/api/attach 存好之后的持久引用。形状在这里**收敛一次**,
|
|
110
150
|
// 历史那条路用的也是同一个收敛器(见 history-attachments.ts)。
|
|
111
151
|
const currentAttachments = readHistoryAttachments(body.attachments);
|
|
112
|
-
const rawHistory = Array.isArray(body.history) ? body.history : []
|
|
152
|
+
const rawHistory = (Array.isArray(body.history) ? body.history : []).map(raw => {
|
|
153
|
+
const item = (raw || {});
|
|
154
|
+
const attachments = readHistoryAttachments(item.attachments);
|
|
155
|
+
return { role: item.role === 'assistant' ? 'assistant' : 'user', text: typeof item.text === 'string' ? item.text : '',
|
|
156
|
+
...(attachments.length ? { attachments } : {}) };
|
|
157
|
+
});
|
|
158
|
+
let selectedHistory = rawHistory;
|
|
159
|
+
const catalog = readMaterialCatalog([...(Array.isArray(body.materials) ? body.materials : []),
|
|
160
|
+
...rawHistory.flatMap((t, i) => (t.attachments || []).map(a => ({ ...a, turn: historyStart + i }))),
|
|
161
|
+
...currentAttachments.map(a => ({ ...a, turn: historyStart + rawHistory.length }))]);
|
|
113
162
|
// 预算规划的结果(图片几张回带、几张降级),只用于上报与诊断
|
|
114
163
|
let historyImages = { kept: 0, omitted: 0, files: 0 };
|
|
115
164
|
// 历史里有没有真带过附件:决定"附件读不回来"时值不值得降级重试一次
|
|
@@ -125,7 +174,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
125
174
|
function planHistory() {
|
|
126
175
|
if (!isChat)
|
|
127
176
|
return { blocks: [], kept: [], keptImages: 0, omittedImages: 0, keptFiles: 0, omittedFiles: 0 };
|
|
128
|
-
const window =
|
|
177
|
+
const window = selectedHistory;
|
|
129
178
|
const turns = historyDropped
|
|
130
179
|
? window.map((turn) => {
|
|
131
180
|
const item = (turn ?? {});
|
|
@@ -141,7 +190,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
141
190
|
const out = [];
|
|
142
191
|
if (!isChat)
|
|
143
192
|
return out;
|
|
144
|
-
const window =
|
|
193
|
+
const window = selectedHistory;
|
|
145
194
|
const plan = planHistory();
|
|
146
195
|
window.forEach((turn, index) => {
|
|
147
196
|
const item = turn;
|
|
@@ -177,7 +226,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
177
226
|
for (const item of currentAttachments)
|
|
178
227
|
if (item.kind === 'file')
|
|
179
228
|
refs.push(item.ref);
|
|
180
|
-
for (const item of
|
|
229
|
+
for (const item of catalog)
|
|
181
230
|
if (item.kind === 'file')
|
|
182
231
|
refs.push(item.ref);
|
|
183
232
|
for (const ref of refs) {
|
|
@@ -283,6 +332,9 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
283
332
|
ctx.logger?.warn?.(`[${name}] 网页抓取不可用(域名解析到 ${probe.sample},属保留地址段),已从工具表移除 web_fetch`);
|
|
284
333
|
}
|
|
285
334
|
}
|
|
335
|
+
if (isChat && config.tools && config.maxToolRounds > 0 && (rawHistory.length > 0 || catalog.length > 0 || contextState.through > 0)) {
|
|
336
|
+
toolSchemas.push(MATERIAL_TOOL);
|
|
337
|
+
}
|
|
286
338
|
// 文件类工具的边界:默认只允许会话工作目录;'*' = 不限制;配置了具体目录就用它。
|
|
287
339
|
// 拿不到工作目录时**不设限**(否则新会话里 read 直接全废),但会打日志说明。
|
|
288
340
|
const wantsFsTools = toolSchemas.some((schema) => FS_TOOL_NAMES.includes(schema.name));
|
|
@@ -300,13 +352,17 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
300
352
|
const stage = typeof body.stage === 'string' ? body.stage : '';
|
|
301
353
|
// 选中文字是代码:首轮走「注释」提示词(代码块 + 逐句注释 + 小结)
|
|
302
354
|
const isCode = body.kind === 'code';
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
355
|
+
// 截图提问是**独立模式**:没有选中文字,只有一张图 + 一个问题,不能用"追问"那套提示词
|
|
356
|
+
const isShot = body.kind === 'shot';
|
|
357
|
+
const basePrompt = isShot
|
|
358
|
+
? shotPromptFor(shotScope, shotFrame)
|
|
359
|
+
: isChat
|
|
360
|
+
? CHAT_SYSTEM_PROMPT
|
|
361
|
+
: stage === 'detail'
|
|
362
|
+
? DETAIL_PROMPT
|
|
363
|
+
: isCode
|
|
364
|
+
? CODE_PROMPT
|
|
365
|
+
: TRANSLATION_PROMPT;
|
|
310
366
|
// 一个工具都没有时明确禁止伪造调用(模型会照着自己的习惯吐 <ds_safety_tool_call> 之类的格式)
|
|
311
367
|
// 正文里不许写"盘算过程"(模型在工具轮里很容易把"I should search again…"这类写进正文)
|
|
312
368
|
const narrateRule = '\n\n【正文只写结论】不要把思考、盘算、自查过程写进正文("我还得再搜一次""先看看拿到的标题""让我确认一下"这类一律不写);需要工具就直接调用,调用前后都不要解说。';
|
|
@@ -347,11 +403,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
347
403
|
// 所以改成**先把可读范围说全**(项目 + 本次附带的文件),再说"除此之外会被拒"。
|
|
348
404
|
let attachedPaths = collectAttachedPaths();
|
|
349
405
|
const fileRule = wantsFsTools
|
|
350
|
-
?
|
|
351
|
-
? `\n\n【文件访问】你可以用 read/grep/glob 读文件。**可读范围 = 当前项目(${readRoot || '不限'})加上用户这次附带的文件**(它们的只读副本在附件仓里,路径就在消息里给出)。除此之外的路径会被拒绝。` +
|
|
352
|
-
`\n · 附带的文件**直接 read 那个路径**就行 —— 不要还没试就断定"项目外读不了",也不要反复换 grep/glob 重试;` +
|
|
353
|
-
`\n · 引用文件内容时只取必要的一两行,不要整段粘贴。`
|
|
354
|
-
: `\n\n【文件访问】你可以用 read/grep/glob 查看当前项目里的文件(范围:${readRoot || '不限'})。超出该范围的路径会被拒绝;引用文件内容时只取必要的一两行,不要整段粘贴。`
|
|
406
|
+
? `\n\n【文件访问】你可以用read/grep/glob读取当前项目(${readRoot || '不限'})和本小窗已提供的文件附件。附件路径以经过验证的文件引用或selection_material返回为准。需要旧文件时先用selection_material取回路径,再按问题读取必要部分;上传成功不等于格式可解析,不能读取时明确说明。除此之外的路径会被拒绝。`
|
|
355
407
|
: '';
|
|
356
408
|
const fetchRule = fetchBlockedSample
|
|
357
409
|
? `\n\n【抓取网页不可用】本机域名解析到保留地址段(${fetchBlockedSample}),网页抓取会被安全策略拒绝:只做联网搜索,**不要尝试抓取具体网址**(省掉一次注定失败的往返)。`
|
|
@@ -433,9 +485,55 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
433
485
|
: {}),
|
|
434
486
|
});
|
|
435
487
|
let chars = 0;
|
|
488
|
+
let contextEstimate = 0;
|
|
489
|
+
if (isChat && capacity) {
|
|
490
|
+
try {
|
|
491
|
+
const fixedTokens = 256 + estimateTextTokens(systemPrompt + userText + initialExplanation + question)
|
|
492
|
+
+ estimateTextTokens(JSON.stringify(toolSchemas))
|
|
493
|
+
+ estimateTextTokens(materialIndex(catalog, historyStart + rawHistory.length))
|
|
494
|
+
+ estimateTextTokens(typeof body.toolDigest === 'string' ? body.toolDigest.slice(0, 2000) : '')
|
|
495
|
+
+ currentAttachments.reduce((n, a) => n + capacity.attachmentPrice(a), 0);
|
|
496
|
+
const planned = await planChatContext(ctx, route, capacity, { state: contextState, history: rawHistory, fixedTokens, signal: controller.signal, effort: sentEffort });
|
|
497
|
+
contextState = planned.state;
|
|
498
|
+
selectedHistory = planned.history;
|
|
499
|
+
contextEstimate = planned.estimatedTokens;
|
|
500
|
+
sse(res, { type: 'context-state', state: contextState,
|
|
501
|
+
budget: capacity.budget, estimatedTokens: contextEstimate, modelWindow: capacity.modelWindow,
|
|
502
|
+
compacted: planned.compacted, retainedMessages: selectedHistory.length });
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
sse(res, { type: 'error', code: 'context-rejected', message: String(error?.message || error) });
|
|
506
|
+
finished = true;
|
|
507
|
+
clearTimeout(timer);
|
|
508
|
+
res.end();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const memoryMessages = isChat ? [
|
|
513
|
+
...(initialExplanation ? [createAssistantMessage({ source: historySource, content: [{ type: 'text', text: initialExplanation }] })] : []),
|
|
514
|
+
...(contextState.summary ? [createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text',
|
|
515
|
+
text: '【较早对话摘要,可能遗漏细节】\n' + contextState.summary + '\n' + materialIndex(catalog, contextState.through) }] })] : []),
|
|
516
|
+
] : [];
|
|
517
|
+
let upstreamStarted = false;
|
|
518
|
+
function checkedMessages(messages) {
|
|
519
|
+
if (capacity && estimateRequestTokens(messages, capacity, toolSchemas) > capacity.budget) {
|
|
520
|
+
throw Object.assign(new Error('本轮材料与工具结果超过上下文容量,请缩小问题或引用范围'), { code: 'context-capacity' });
|
|
521
|
+
}
|
|
522
|
+
return messages;
|
|
523
|
+
}
|
|
524
|
+
// ⚠️ 截图模式:图必须**在这一条消息里**。
|
|
525
|
+
// 原先只有 isChat(追问) 才拼 openingMessages,截图走的是 baseMessage 纯文本那条 ——
|
|
526
|
+
// 结果图根本没进请求,模型只会说"我没收到截图"(实测踩到)。
|
|
527
|
+
const shotImageBlocks = isShotMode
|
|
528
|
+
? currentAttachments.map((item) => item.kind === 'image'
|
|
529
|
+
? { type: 'image', attachment: item.ref }
|
|
530
|
+
: { type: 'file', attachment: item.ref })
|
|
531
|
+
: [];
|
|
436
532
|
const baseMessage = createUserMessage({
|
|
437
533
|
source: { kind: 'user' },
|
|
438
|
-
content:
|
|
534
|
+
content: shotImageBlocks.length > 0
|
|
535
|
+
? [...shotImageBlocks, { type: 'text', text: userText }]
|
|
536
|
+
: [{ type: 'text', text: userText }],
|
|
439
537
|
});
|
|
440
538
|
// 上一轮查过的东西一并带上:否则追问会反复重查同样的内容(一次工具往返 20~34s)
|
|
441
539
|
const toolDigest = typeof body.toolDigest === 'string' ? body.toolDigest.trim().slice(0, 2000) : '';
|
|
@@ -450,13 +548,27 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
450
548
|
const currentBlocks = currentAttachments.map((item) => item.kind === 'image' ? { type: 'image', attachment: item.ref } : { type: 'file', attachment: item.ref });
|
|
451
549
|
const questionContent = currentBlocks.length > 0 ? [...currentBlocks, { type: 'text', text: questionText }] : [{ type: 'text', text: questionText }];
|
|
452
550
|
let openingMessages = isChat
|
|
453
|
-
? [
|
|
551
|
+
? [
|
|
552
|
+
...memoryMessages,
|
|
553
|
+
...buildHistoryMessages(),
|
|
554
|
+
// 截图首轮:图 + 问题已经放进 baseMessage(只需要一份,避免图片和问题各发两遍);
|
|
555
|
+
// 追问不走截图模式,当前问题仍然放在这里。
|
|
556
|
+
...(isShot ? [] : [createUserMessage({ source: { kind: 'user' }, content: questionContent })]),
|
|
557
|
+
]
|
|
454
558
|
: [];
|
|
559
|
+
if (titleOnly) {
|
|
560
|
+
// 截图:base 那条消息里已经带了图 + 提问,这里只多一句"只输出标题"
|
|
561
|
+
openingMessages = [
|
|
562
|
+
createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: shotTitlePromptFor(shotScope, shotFrame) }] }),
|
|
563
|
+
];
|
|
564
|
+
}
|
|
455
565
|
try {
|
|
456
566
|
let errored = false;
|
|
457
567
|
let usage = null;
|
|
458
568
|
let answer = '';
|
|
459
569
|
const toolMessages = [];
|
|
570
|
+
const recoveredMaterialBlocks = [];
|
|
571
|
+
let toolContextTokens = 0;
|
|
460
572
|
let toolCalls = 0;
|
|
461
573
|
// 允许的工具轮 + 1 个**收尾轮**:收尾轮不带任何工具。
|
|
462
574
|
// 不加这一轮的话,模型每轮都调工具时循环直接结束,正文是空的——
|
|
@@ -495,9 +607,10 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
495
607
|
const triedEfforts = new Set(sentEffort ? [sentEffort] : []);
|
|
496
608
|
const firstSentEffort = sentEffort;
|
|
497
609
|
let roundEffort = sentEffort;
|
|
498
|
-
|
|
610
|
+
const effectiveToolRounds = titleOnly ? 0 : toolRounds;
|
|
611
|
+
for (let round = 0; round <= effectiveToolRounds; round += 1) {
|
|
499
612
|
const roundStartedAt = Date.now();
|
|
500
|
-
const wrapUp = round ===
|
|
613
|
+
const wrapUp = round === effectiveToolRounds;
|
|
501
614
|
const stream = ctx.llm.stream({
|
|
502
615
|
provider: route.provider,
|
|
503
616
|
model: route.model,
|
|
@@ -505,7 +618,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
505
618
|
// 插件以前没传,结果是所有小窗请求共用静态会话头 —— 而中继是按会话跟踪的(实测长回答被掐)。
|
|
506
619
|
...(sessionId ? { sessionId: sessionId } : {}),
|
|
507
620
|
system: undefined,
|
|
508
|
-
messages: [
|
|
621
|
+
messages: checkedMessages([
|
|
509
622
|
// 第二参数(组装这段提示词的插件名):**新老版本签名不一样,两边都得能编译**——
|
|
510
623
|
// · npm 上按 peer 范围 `>=0.1.5-rc <2` 解析到的是 0.1.5-rc.3:`(text, plugin)` 两个必填
|
|
511
624
|
// (CI 就是拿这份类型做构建校验的,少传就 TS2554);
|
|
@@ -516,7 +629,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
516
629
|
'正文里不要出现任何工具调用格式(包括 DeepSeek 的 DSML 写法,如 invoke/parameter 标记)——那些会被当作乱码展示给用户。'
|
|
517
630
|
: systemPrompt, name),
|
|
518
631
|
...(round === 0 ? [baseMessage, ...openingMessages] : [baseMessage, ...openingMessages, ...toolMessages]),
|
|
519
|
-
],
|
|
632
|
+
]),
|
|
520
633
|
// 工具**每轮都在请求里**(照主会话的做法):收尾轮以前故意不带工具"逼结论",
|
|
521
634
|
// 结果模型退到文本层、把工具调用标记写进正文(实测 wrapUp 轮 raw=726 → emitted=0)。
|
|
522
635
|
// 主会话从不摘工具,模型也就从不写这种标记 —— 这里与它保持一致。
|
|
@@ -526,6 +639,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
526
639
|
...(roundEffort ? { reasoningEffort: ReasoningEffortId(roundEffort) } : {}),
|
|
527
640
|
signal: controller.signal,
|
|
528
641
|
});
|
|
642
|
+
upstreamStarted = true;
|
|
529
643
|
const assembler = new BlockAssembler();
|
|
530
644
|
// 本轮吐出的正文字数:这一轮如果最后是要调工具,那这些字其实是"过程旁白"
|
|
531
645
|
// ("我还得再搜一次""先看看拿到的标题"这类),要被撤回,不能当成答案留在小窗里
|
|
@@ -611,7 +725,11 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
611
725
|
if (roundText.length === 0 && historyHadAttachments && !historyDropped && looksLikeAttachmentFailure(errorMessage, errorCode)) {
|
|
612
726
|
historyDropped = true;
|
|
613
727
|
historyImages = { kept: 0, omitted: 0, files: 0 };
|
|
614
|
-
openingMessages = [
|
|
728
|
+
openingMessages = [
|
|
729
|
+
...memoryMessages,
|
|
730
|
+
...buildHistoryMessages(),
|
|
731
|
+
...(isShot ? [] : [createUserMessage({ source: { kind: 'user' }, content: questionContent })]),
|
|
732
|
+
];
|
|
615
733
|
// 历史附件被丢掉了 → 名单跟着缩小(只放"还看得见的"那些)
|
|
616
734
|
attachedPaths = collectAttachedPaths();
|
|
617
735
|
errored = false;
|
|
@@ -713,6 +831,8 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
713
831
|
content: blocks,
|
|
714
832
|
source: { provider: route.provider, model: route.model },
|
|
715
833
|
}));
|
|
834
|
+
const restoredBlocks = [];
|
|
835
|
+
toolContextTokens += estimateTextTokens(JSON.stringify(blocks));
|
|
716
836
|
for (const call of calls) {
|
|
717
837
|
if (call.type !== 'tool-call')
|
|
718
838
|
continue;
|
|
@@ -732,7 +852,15 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
732
852
|
// 只认 path 的话 read 的 file_path 会是 undefined → 边界检查被静默绕过(实测踩过)。
|
|
733
853
|
const rawPath = parsed?.file_path ?? parsed?.path;
|
|
734
854
|
const wantPath = typeof rawPath === 'string' ? rawPath : '';
|
|
735
|
-
|
|
855
|
+
let restored;
|
|
856
|
+
if (call.name === MATERIAL_TOOL.name && toolSchemas.some(schema => schema.name === call.name)) {
|
|
857
|
+
restored = await restoreMaterial(ctx, catalog, parsed, { through: contextState.through, key: typeof body.key === 'string' ? body.key : '',
|
|
858
|
+
maxChars: config.toolResultMaxChars, readMessage: historyStore.readMessage });
|
|
859
|
+
if (restored.restored && capacity && capacity.attachmentPrice(restored.restored) + contextEstimate + toolContextTokens > capacity.budget * 0.95) {
|
|
860
|
+
restored = { content: [{ type: 'text', text: '这份材料超过本轮剩余容量,请减少本轮材料后重新查看。' }], isError: true };
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const execution = restored || (!toolSchemas.some((schema) => schema.name === call.name)
|
|
736
864
|
? { content: [{ type: 'text', text: `(调用已拒绝:${call.name} 不在本次允许的工具中)` }], isError: true }
|
|
737
865
|
: FS_TOOL_NAMES.includes(call.name) && !isReadAllowed(readRoot, wantPath, attachedPaths)
|
|
738
866
|
? {
|
|
@@ -744,7 +872,21 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
744
872
|
],
|
|
745
873
|
isError: true,
|
|
746
874
|
}
|
|
747
|
-
: await runTool(ctx, call.name, parsed, controller.signal, config.toolResultMaxChars, agent);
|
|
875
|
+
: await runTool(ctx, call.name, parsed, controller.signal, config.toolResultMaxChars, agent));
|
|
876
|
+
toolContextTokens += estimateTextTokens(JSON.stringify(execution.content));
|
|
877
|
+
if (restored?.restored) {
|
|
878
|
+
const item = restored.restored;
|
|
879
|
+
restoredBlocks.push(item.kind === 'image' ? { type: 'image', attachment: item.ref } : { type: 'file', attachment: item.ref });
|
|
880
|
+
if (capacity)
|
|
881
|
+
toolContextTokens += capacity.attachmentPrice(item);
|
|
882
|
+
if (item.kind === 'file') {
|
|
883
|
+
const store = ctx.get('attachments');
|
|
884
|
+
const path = store?.fileHostPath?.(item.ref);
|
|
885
|
+
if (path)
|
|
886
|
+
attachedPaths.add(resolve(path));
|
|
887
|
+
}
|
|
888
|
+
sse(res, { type: 'material-restored', attachment: item });
|
|
889
|
+
}
|
|
748
890
|
// 结果也回传:小窗里能看见"查了什么、命中什么、链接是什么",否则工具是个黑盒
|
|
749
891
|
const digest = digestToolResult(execution.content);
|
|
750
892
|
sse(res, {
|
|
@@ -764,6 +906,9 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
764
906
|
// 结论轮要用:把"查了什么 + 拿到了什么"写成纯文本,避免重放工具调用链
|
|
765
907
|
toolDigestLines.push(`· ${call.name}(${label2 || '查询'}):${digest.preview}`);
|
|
766
908
|
}
|
|
909
|
+
recoveredMaterialBlocks.push(...restoredBlocks);
|
|
910
|
+
if (restoredBlocks.length > 0)
|
|
911
|
+
toolMessages.push(createUserMessage({ source: { kind: 'user' }, content: restoredBlocks }));
|
|
767
912
|
}
|
|
768
913
|
if (!errored && answer.trim().length === 0 && toolCalls > 0 && triedEfforts.size === 1) {
|
|
769
914
|
// 收尾轮常常"只吐一段工具调用、不写正文"(实测「今天的新闻」一个字都没有)。
|
|
@@ -772,6 +917,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
772
917
|
conclusionInfo.tried = true;
|
|
773
918
|
sse(res, { type: 'notice', code: 'conclusion-retry', text: '查询已完成,正在整理结论…' });
|
|
774
919
|
const conclusionUser = [
|
|
920
|
+
'【补充结论】',
|
|
775
921
|
// ⚠️ **必须带上用户这一轮问的是什么**:这一段是最终正文的来源,而它以前只有
|
|
776
922
|
// 「选区块 + 检索资料 + 请给出最终答案」—— 模型看不到问题,就只能顺着选中文字写。
|
|
777
923
|
// 实测症状:问"搜索今天的AI新闻。",正文却在解释选区那个词(如 diagLog / exit)。
|
|
@@ -779,9 +925,6 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
779
925
|
// 缺了问题就等于每次都答非所问。
|
|
780
926
|
question ? `【用户这一轮的问题】(这就是你要回答的对象)\n${question}` : '',
|
|
781
927
|
'',
|
|
782
|
-
'【选中文字】(只是背景资料:问题需要它时才引用,不要拿它当回答的对象)',
|
|
783
|
-
userText,
|
|
784
|
-
'',
|
|
785
928
|
'【已经查到的资料】',
|
|
786
929
|
...toolDigestLines.slice(0, 12),
|
|
787
930
|
'',
|
|
@@ -810,11 +953,11 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
810
953
|
// 以前这一轮不带工具,模型要调工具时只能退回文本层 —— 那正是正文里出现
|
|
811
954
|
// DSML / ds_safety_tool_call 标记的唯一来源。带上之后它走协议层,正文就是正文。
|
|
812
955
|
...(toolSchemas.length > 0 ? { tools: toolSchemas } : {}),
|
|
813
|
-
messages: [
|
|
814
|
-
// 同上:多传的插件名在老版本运行时会被忽略,新版本类型上却是必填
|
|
956
|
+
messages: checkedMessages([
|
|
815
957
|
systemMessageOf(systemPrompt, name),
|
|
816
|
-
|
|
817
|
-
|
|
958
|
+
baseMessage, ...openingMessages,
|
|
959
|
+
createUserMessage({ source: { kind: 'user' }, content: [...recoveredMaterialBlocks, { type: 'text', text: conclusionUser }] }),
|
|
960
|
+
]),
|
|
818
961
|
...(config.temperature >= 0 ? { temperature: config.temperature } : {}),
|
|
819
962
|
...(config.maxTokens > 0 ? { maxTokens: config.maxTokens } : {}),
|
|
820
963
|
...(attemptEffort ? { reasoningEffort: ReasoningEffortId(attemptEffort) } : {}),
|
|
@@ -843,9 +986,15 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
843
986
|
}
|
|
844
987
|
}
|
|
845
988
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
989
|
+
const tail = thinkAgain.flush();
|
|
990
|
+
if (tail.think)
|
|
991
|
+
sse(res, { type: 'thought', text: tail.think });
|
|
992
|
+
if (tail.text) {
|
|
993
|
+
chars += tail.text.length;
|
|
994
|
+
answer += tail.text;
|
|
995
|
+
conclusionInfo.chars += tail.text.length;
|
|
996
|
+
sse(res, { type: 'delta', text: tail.text });
|
|
997
|
+
}
|
|
849
998
|
// 结论轮也可能把"英文核对过程"写进正文:一律按泄漏裁一次
|
|
850
999
|
const leaked = splitLeakedReasoning(answer);
|
|
851
1000
|
if (leaked.prefix) {
|
|
@@ -856,6 +1005,8 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
856
1005
|
}
|
|
857
1006
|
}
|
|
858
1007
|
catch (error) {
|
|
1008
|
+
if (error?.code === 'context-capacity')
|
|
1009
|
+
throw error;
|
|
859
1010
|
conclusionInfo.error = String(error?.message ?? error);
|
|
860
1011
|
ctx.logger?.warn?.(`[${name}] 结论轮失败:${conclusionInfo.error}`);
|
|
861
1012
|
}
|
|
@@ -896,6 +1047,7 @@ export function createAnalyze(ctx, config, models, sessions, historyStore) {
|
|
|
896
1047
|
const aborted = err?.name === 'AbortError';
|
|
897
1048
|
sse(res, {
|
|
898
1049
|
type: 'error',
|
|
1050
|
+
...(!upstreamStarted && err?.code === 'context-capacity' ? { code: 'context-rejected' } : {}),
|
|
899
1051
|
message: aborted ? `调用超时或已取消(${config.timeoutMs}ms)` : String(err?.message ?? err),
|
|
900
1052
|
});
|
|
901
1053
|
}
|