mingdao-harness 0.1.67 → 0.1.68
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/package.json +3 -2
- package/src/agent.js +54 -6
- package/src/compact.js +43 -8
- package/src/routing.js +6 -2
- package/src/tools/fs-tools.js +24 -0
- package/src/web/index.html +9 -9
- package/src/web/server.js +1 -1
- package/src/web/web-io.js +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mingdao-harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.68",
|
|
4
4
|
"description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -53,7 +53,8 @@
|
|
|
53
53
|
"prepublishOnly": "node test/smoke.js && node test/e2e-local.js && node test/e2e-schedule.js",
|
|
54
54
|
"desktop": "npm --prefix desktop start",
|
|
55
55
|
"desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
|
|
56
|
-
"desktop:sync": "node scripts/sync-versions.mjs"
|
|
56
|
+
"desktop:sync": "node scripts/sync-versions.mjs",
|
|
57
|
+
"bench": "node test/bench/bench-tokenizer.mjs && node test/bench/bench-routing.mjs && node test/bench/bench-compaction.mjs"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@types/node": "^26.2.0",
|
package/src/agent.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// → PostToolUse 钩子 → 结果回填 → 循环,直到模型给出纯文本回复。
|
|
3
3
|
// 附带:子代理(task 工具)、todo 清单状态、undo 备份仓、Ctrl+C 中断。
|
|
4
4
|
|
|
5
|
-
import { trimMessages, clampText } from './context.js';
|
|
5
|
+
import { trimMessages, clampText, messageTokens } from './context.js';
|
|
6
6
|
import { compactConversation } from './compact.js';
|
|
7
7
|
import { toolSchemas, dispatch } from './tools/index.js';
|
|
8
8
|
import { modelPreset } from './models.js';
|
|
@@ -11,7 +11,8 @@ import { createHooks } from './hooks.js';
|
|
|
11
11
|
import { createIO, style, C } from './ui.js';
|
|
12
12
|
import { subagentModel } from './routing.js';
|
|
13
13
|
import { writeAudit, redactSecrets } from './audit.js';
|
|
14
|
-
import { checkCostGuard } from './cost-guard.js';
|
|
14
|
+
import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
|
|
15
|
+
import { estimateCost } from './pricing.js';
|
|
15
16
|
|
|
16
17
|
const MAX_STEPS = 24;
|
|
17
18
|
const SUBAGENT_MAX_STEPS = 12;
|
|
@@ -119,6 +120,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
119
120
|
try {
|
|
120
121
|
while (steps < stepLimit) {
|
|
121
122
|
steps += 1;
|
|
123
|
+
// 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
|
|
124
|
+
const turnToolCache = new Map();
|
|
122
125
|
// 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
|
|
123
126
|
// 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
|
|
124
127
|
if (cfg.autoCompact !== false) {
|
|
@@ -151,6 +154,39 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
151
154
|
}
|
|
152
155
|
const trimmed = trimMessages(messages, budget, count);
|
|
153
156
|
|
|
157
|
+
// reasoning 回填防护(MiniMax P0-2):推理链只在 UI 流式展示、不回传消息(本就不回填);
|
|
158
|
+
// 防御性保证——任何携带 reasoning_content 的消息发送前裁剪:>4000 字仅留概括,>1000 字截尾 500。
|
|
159
|
+
let sanitized = trimmed;
|
|
160
|
+
for (const m of sanitized) {
|
|
161
|
+
const rc = m.reasoning_content;
|
|
162
|
+
if (typeof rc === 'string' && rc.length > 4000) {
|
|
163
|
+
sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: `[思考过程已省略(原 ${rc.length} 字)]` } : x));
|
|
164
|
+
} else if (typeof rc === 'string' && rc.length > 1000) {
|
|
165
|
+
sanitized = sanitized.map((x) => (x === m ? { ...x, reasoning_content: rc.slice(-500) + ' …[思考过程已截断]' } : x));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 护栏前置预估(Kimi P2-E):发送前按本轮最坏成本估算(trimmed prompt × 未命中输入价
|
|
170
|
+
// + maxOutput × 输出价,峰谷按当前时段);「今日已用 + 最坏成本」超上限时发送前拦截,
|
|
171
|
+
// 而不是等 200K 上下文的贵请求发出后才 block。
|
|
172
|
+
if (cfg.costGuard) {
|
|
173
|
+
const g = costGuardConfig();
|
|
174
|
+
if (g && Number(g.dailyLimitYuan) > 0) {
|
|
175
|
+
let promptTokens = 0;
|
|
176
|
+
for (const m of sanitized) promptTokens += messageTokens(m, count);
|
|
177
|
+
const worst = estimateCost(modelName, promptTokens, maxOutput, null, new Date());
|
|
178
|
+
const used = todayCost();
|
|
179
|
+
if (used + worst >= Number(g.dailyLimitYuan)) {
|
|
180
|
+
stripOrphanCalls();
|
|
181
|
+
return {
|
|
182
|
+
text: null, reasoning: '', usage, steps, finish, truncated: false, aborted: false,
|
|
183
|
+
note: `⛔ 护栏前置拦截:本轮最坏成本 ≈¥${worst.toFixed(4)},今日已用 ≈¥${used.toFixed(4)},合计将超过上限 ¥${Number(g.dailyLimitYuan).toFixed(2)}——请求未发出。可调高 config.costGuard.dailyLimitYuan 或改用更小模型。`,
|
|
184
|
+
durationMs: Date.now() - startedAt, perf: perf(),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
154
190
|
// 费用护栏(A2):每轮开始前按今日实际费用检查;block 时暂停本轮并明确告知
|
|
155
191
|
if (cfg.costGuard) {
|
|
156
192
|
const guard = checkCostGuard();
|
|
@@ -186,7 +222,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
186
222
|
try {
|
|
187
223
|
res = await provider.chat({
|
|
188
224
|
model: modelName,
|
|
189
|
-
messages:
|
|
225
|
+
messages: sanitized,
|
|
190
226
|
tools: [...toolSchemas(), ...mcpSchemas()],
|
|
191
227
|
temperature,
|
|
192
228
|
maxTokens: maxOutput,
|
|
@@ -299,14 +335,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
299
335
|
}
|
|
300
336
|
|
|
301
337
|
// 执行单个工具(渲染「执行中」→ dispatch → 捕获异常转错误结果)
|
|
338
|
+
// 审计 Hermes C4:同回合相同参数的只读工具(read/ls/glob/grep/skill)合并执行一次,
|
|
339
|
+
// 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
|
|
302
340
|
async function runTool(prep) {
|
|
303
341
|
io.renderToolStart?.(prep.name, prep.args);
|
|
342
|
+
const dedupKey = !prep.isMcp && READONLY_TOOLS_SET.has(prep.name) ? prep.name + ':' + JSON.stringify(prep.args || {}) : null;
|
|
343
|
+
if (dedupKey && turnToolCache.has(dedupKey)) {
|
|
344
|
+
prep.cached = true;
|
|
345
|
+
return turnToolCache.get(dedupKey);
|
|
346
|
+
}
|
|
304
347
|
try {
|
|
348
|
+
let result;
|
|
305
349
|
if (prep.isMcp) {
|
|
306
350
|
if (!mcp) throw new Error('MCP 工具未启用');
|
|
307
|
-
|
|
351
|
+
result = await mcp.call(prep.name, prep.args);
|
|
352
|
+
} else {
|
|
353
|
+
result = await dispatch(prep.name, prep.args, ctx);
|
|
308
354
|
}
|
|
309
|
-
|
|
355
|
+
if (dedupKey) turnToolCache.set(dedupKey, result);
|
|
356
|
+
return result;
|
|
310
357
|
} catch (err) {
|
|
311
358
|
return JSON.stringify({ ok: false, error: String(err?.message || err) });
|
|
312
359
|
}
|
|
@@ -344,7 +391,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
344
391
|
});
|
|
345
392
|
}
|
|
346
393
|
const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
|
|
347
|
-
|
|
394
|
+
const prefix = prep.cached ? '(与同回合相同调用结果一致,已复用)\n' : '';
|
|
395
|
+
messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text) });
|
|
348
396
|
}
|
|
349
397
|
|
|
350
398
|
let i = 0;
|
package/src/compact.js
CHANGED
|
@@ -99,16 +99,51 @@ export async function compactConversation({ messages, budget, count, provider, e
|
|
|
99
99
|
.join('\n')
|
|
100
100
|
.slice(0, INPUT_MAX_CHARS);
|
|
101
101
|
|
|
102
|
+
// —— 增量压缩(Kimi P2-D):已压缩过的会话只压「旧摘要之后的新增段」,与旧摘要合并(摘要的摘要),
|
|
103
|
+
// 不再反复把早期内容送进摘要输入——第 N 次压缩输入从 O(历史) 降为 O(增量)。 ——
|
|
104
|
+
const sumIdx = messages.findIndex(
|
|
105
|
+
(m) => m.role === 'user' && typeof m.content === 'string' && m.content.includes('<conversation_summary>')
|
|
106
|
+
);
|
|
107
|
+
let incremental = false;
|
|
102
108
|
let summary = null;
|
|
103
109
|
let usage = null;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
if (sumIdx > 0 && sumIdx + 1 < boundary) {
|
|
111
|
+
incremental = true;
|
|
112
|
+
const m = /<conversation_summary>\n([\s\S]*?)\n<\/conversation_summary>/.exec(String(messages[sumIdx].content));
|
|
113
|
+
const oldSummary = (m && m[1]) || '';
|
|
114
|
+
const newText = messages
|
|
115
|
+
.slice(sumIdx + 1, boundary)
|
|
116
|
+
.map((msg) => {
|
|
117
|
+
if (msg.role === 'tool') {
|
|
118
|
+
return `工具结果(${msg.tool_call_id ?? ''}): ${clampText(String(msg.content ?? ''), TOOL_OUTPUT_CAP)}`;
|
|
119
|
+
}
|
|
120
|
+
const head = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? 'MingDao' : String(msg.role);
|
|
121
|
+
return `${head}: ${String(msg.content ?? '')}`;
|
|
122
|
+
})
|
|
123
|
+
.join('\n')
|
|
124
|
+
.slice(0, INPUT_MAX_CHARS);
|
|
125
|
+
try {
|
|
126
|
+
const r = await summarizeConversation(
|
|
127
|
+
provider,
|
|
128
|
+
executorModel,
|
|
129
|
+
`【既有摘要】\n${oldSummary.slice(0, SUMMARY_MAX_CHARS)}\n\n【新增对话记录】\n${newText}`
|
|
130
|
+
);
|
|
131
|
+
summary = r.text;
|
|
132
|
+
usage = r.usage;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (!summary) return null;
|
|
137
|
+
} else {
|
|
138
|
+
try {
|
|
139
|
+
const r = await summarizeConversation(provider, executorModel, convoText);
|
|
140
|
+
summary = r.text;
|
|
141
|
+
usage = r.usage;
|
|
142
|
+
} catch {
|
|
143
|
+
return null; // 摘要失败:退回普通裁剪
|
|
144
|
+
}
|
|
145
|
+
if (!summary) return null;
|
|
110
146
|
}
|
|
111
|
-
if (!summary) return null;
|
|
112
147
|
|
|
113
148
|
// 组装:system + 摘要(user) + 保留段(清洗保留段头部因裁剪而孤立的 tool 消息)
|
|
114
149
|
const kept = cleanToolPairing(messages.slice(boundary));
|
|
@@ -123,5 +158,5 @@ export async function compactConversation({ messages, budget, count, provider, e
|
|
|
123
158
|
},
|
|
124
159
|
...kept,
|
|
125
160
|
];
|
|
126
|
-
return { messages: next, droppedCount, droppedTokens, summary, usage };
|
|
161
|
+
return { messages: next, droppedCount: incremental ? boundary - (sumIdx + 1) : droppedCount, droppedTokens, summary, usage, incremental };
|
|
127
162
|
}
|
package/src/routing.js
CHANGED
|
@@ -19,7 +19,7 @@ export function routingConfig(cfg) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const PLAN_HINTS =
|
|
22
|
-
|
|
22
|
+
/设计|架构|重构|审查|规划|分析|方案|评估|优化|排查|疑难|报错|怎么修|修复|设计模式|选型|技术债|roadmap|review|design|refactor|plan|architecture|方案设计|评审/;
|
|
23
23
|
|
|
24
24
|
// 生成类任务(游戏/网页/文档等)需要大输出(planner 32K vs executor 8K),即使短句也路由 planner
|
|
25
25
|
const GENERATION_HINTS =
|
|
@@ -28,7 +28,11 @@ const GENERATION_HINTS =
|
|
|
28
28
|
export function heuristicRoute(text, rc) {
|
|
29
29
|
const s = String(text ?? '');
|
|
30
30
|
if (GENERATION_HINTS.test(s)) return rc.planner; // 生成类:大输出优先
|
|
31
|
-
if (
|
|
31
|
+
if (PLAN_HINTS.test(s)) {
|
|
32
|
+
// 规划类关键词优先(审计:此前要求 length>=40,导致「设计一个缓存方案」这类
|
|
33
|
+
// 13 字短句被误路由到 flash 硬扛——关键词强度优先于文本长度)
|
|
34
|
+
return s.length >= 40 || /设计|规划|分析|评估|审查|架构|重构|方案|优化|报错|修复/.test(s) ? rc.planner : null;
|
|
35
|
+
}
|
|
32
36
|
if (s.length <= 60) return rc.executor;
|
|
33
37
|
return null; // 需要分类器
|
|
34
38
|
}
|
package/src/tools/fs-tools.js
CHANGED
|
@@ -70,6 +70,13 @@ export function undo(args, ctx) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
const READ_CACHE_MAX = 200; // 会话级 read 缓存条数上限(超出清最旧,防止无界增长)
|
|
74
|
+
const readCache = new Map(); // 绝对路径 → { mtimeMs, size, lines }
|
|
75
|
+
|
|
76
|
+
export function invalidateReadCache(p) {
|
|
77
|
+
readCache.delete(p);
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
export function read(args, ctx) {
|
|
74
81
|
try {
|
|
75
82
|
const p = resolvePath(ctx.cwd, args.path ?? '');
|
|
@@ -82,9 +89,24 @@ export function read(args, ctx) {
|
|
|
82
89
|
error: `"${p}" 大小 ${(st.size / 1024 / 1024).toFixed(1)}MB,超过 ${MAX_FILE_BYTES / 1024 / 1024}MB 上限。请用 grep 搜索或 bash 分块查看。`,
|
|
83
90
|
};
|
|
84
91
|
}
|
|
92
|
+
// 重复读取去重(审计 MiniMax P2-2):同一文件 mtime+size 未变且非强制重读时,
|
|
93
|
+
// 返回「内容未变化」标记(省下整段重复内容回填的 prompt token);force=true 强制重读。
|
|
94
|
+
// 注意:带 offset/limit 的切片读取不能走缓存标记(必须返回所请求的切片)。
|
|
95
|
+
const wantsSlice = args.offset !== undefined || args.limit !== undefined;
|
|
96
|
+
const cached = readCache.get(p);
|
|
97
|
+
if (!args.force && !wantsSlice && cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
|
98
|
+
return { ok: true, output: `[内容与上次读取一致(未变化,共 ${cached.lines} 行)——如需强制重读请传 force:true]`, totalLines: cached.lines, cached: true };
|
|
99
|
+
}
|
|
85
100
|
const buf = fs.readFileSync(p);
|
|
86
101
|
if (isProbablyBinary(buf)) return { ok: false, error: `"${p}" 疑似二进制文件,无法按文本读取。` };
|
|
87
102
|
const lines = buf.toString('utf8').split('\n');
|
|
103
|
+
if (!wantsSlice) {
|
|
104
|
+
if (readCache.size >= READ_CACHE_MAX) {
|
|
105
|
+
const first = readCache.keys().next().value;
|
|
106
|
+
if (first !== undefined) readCache.delete(first);
|
|
107
|
+
}
|
|
108
|
+
readCache.set(p, { mtimeMs: st.mtimeMs, size: st.size, lines: lines.length });
|
|
109
|
+
}
|
|
88
110
|
const offset = Math.max(1, Number(args.offset) || 1);
|
|
89
111
|
const limit = Math.max(1, Number(args.limit) || 400);
|
|
90
112
|
// 审计质量项:offset 超出文件行数时明确提示,而非返回空内容让模型误以为文件为空
|
|
@@ -125,6 +147,7 @@ export function write(args, ctx) {
|
|
|
125
147
|
}
|
|
126
148
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
127
149
|
fs.writeFileSync(p, content);
|
|
150
|
+
invalidateReadCache(p);
|
|
128
151
|
return { ok: true, output: `已写入 ${p}(${Buffer.byteLength(content)} 字节)。` };
|
|
129
152
|
} catch (err) {
|
|
130
153
|
return { ok: false, error: `写入失败:${err?.message || err}` };
|
|
@@ -163,6 +186,7 @@ export function edit(args, ctx) {
|
|
|
163
186
|
const next = replaceAll ? text.split(oldString).join(newString) : text.replace(oldString, newString);
|
|
164
187
|
backup(ctx, p);
|
|
165
188
|
fs.writeFileSync(p, next);
|
|
189
|
+
invalidateReadCache(p);
|
|
166
190
|
const idx = text.indexOf(oldString);
|
|
167
191
|
const lineStart = text.slice(0, idx).split('\n').length - 1;
|
|
168
192
|
const before = regionAround(text, lineStart, oldString.split('\n').length);
|
package/src/web/index.html
CHANGED
|
@@ -40,18 +40,18 @@ header select{flex:none}
|
|
|
40
40
|
@media (max-width:860px){main{padding:14px 0 8px}#chat{padding:0 10px}#envBadge{display:none!important}#sessionSearch{max-width:110px}#modelSel{max-width:110px}#permSel{max-width:120px}#sessions{max-width:110px}#wsSel{max-width:96px}header{gap:6px;padding:8px 8px}header .logo span{display:none}}
|
|
41
41
|
#sessionSearch:focus{outline:none;border-color:var(--accent2)}
|
|
42
42
|
#tasksPanel{position:fixed;right:0;top:52px;bottom:0;width:280px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
|
|
43
|
-
/*
|
|
44
|
-
#
|
|
43
|
+
/* 输入框上方工作状态条(审计:长任务静默硬伤——实时显示执行步数/耗时与后台任务数)。
|
|
44
|
+
与输入框同宽对齐(复用 #composer 的居中容器),不再横贯整个聊天窗口 */
|
|
45
|
+
#workStatus{max-width:860px;margin:0 auto 4px;display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border:1px solid var(--border);border-radius:12px;font-size:12.5px;color:var(--dim);flex:none}
|
|
45
46
|
#workStatus .ws-busy{color:var(--accent2);display:flex;align-items:center;gap:8px;flex:1;min-width:0}
|
|
46
47
|
#workStatus .ws-bg{cursor:pointer;color:var(--warn);white-space:nowrap}
|
|
47
48
|
#workStatus .ws-bg:hover{color:var(--text)}
|
|
48
|
-
#workStatus .ws-stop{cursor:pointer;color:var(--err);font-weight:600;border:1px solid var(--err);border-radius:6px;padding:1px 10px;white-space:nowrap}
|
|
49
49
|
/* 消息顶部轨迹行(DeepSeek-Harness 式:每轮可回溯步骤与子代理) */
|
|
50
50
|
.msg-meta{display:flex;gap:8px;align-items:center;font-size:11.5px;color:var(--faint);padding:0 2px 4px}
|
|
51
51
|
.trajBtn{cursor:pointer;background:var(--bg3);border:1px solid var(--border);color:var(--dim);border-radius:20px;padding:2px 10px;font-size:11.5px}
|
|
52
52
|
.trajBtn:hover{color:var(--text);border-color:var(--accent2)}
|
|
53
|
-
/*
|
|
54
|
-
#trajPanel{position:fixed;
|
|
53
|
+
/* 轨迹面板(与任务面板互斥展示):置于聊天窗口左侧(DSH 式侧栏) */
|
|
54
|
+
#trajPanel{position:fixed;left:0;top:52px;bottom:0;width:330px;background:var(--bg2);border-right:1px solid var(--border);z-index:40;display:flex;flex-direction:column;box-shadow:4px 0 24px rgba(0,0,0,.35)}
|
|
55
55
|
.tj-head{padding:12px 14px;font-size:14px;font-weight:600;border-bottom:1px solid var(--border);color:var(--accent2);display:flex;align-items:center;gap:8px}
|
|
56
56
|
.tj-head button{margin-left:auto}
|
|
57
57
|
#tjList{flex:1;overflow-y:auto;padding:8px}
|
|
@@ -775,10 +775,11 @@ async function send(){
|
|
|
775
775
|
await handleEvents(resp.body, ev=>{
|
|
776
776
|
if(ev.taskId) taskId=ev.taskId;
|
|
777
777
|
if(ev.type==='ask') disarm(); else arm(); // 有活动→重置;权限等待→暂停计时
|
|
778
|
-
if(ev.type==='progress'){ const p=ev.seconds||0;
|
|
778
|
+
if(ev.type==='progress'){ const p=ev.seconds||0; showThink(); think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 第 '+msg._steps+' 回合 · 已执行 '+ev.steps+' 步…'; renderWorkStatus(); }
|
|
779
779
|
if(ev.type==='text'){ onActivity(); raw+=ev.delta; update(); }
|
|
780
780
|
else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); reason.dataset.full=''; } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.dataset.full+=ev.delta; const full=reason.dataset.full; reason.textContent=(full.length>9000?full.slice(-9000)+'\n…(思考内容较长,仅显示末尾;已 '+full.length+' 字符)':full); reason.scrollTop=reason.scrollHeight; scroll(); }
|
|
781
|
-
else if(ev.type==='turnStart'){ stepsCount+=1; curSteps+=1; msg._steps=curSteps; msg._traj.push({kind:'turn', t:Date.now()}); showThink(); renderWorkStatus(); scroll(); }
|
|
781
|
+
else if(ev.type==='turnStart'){ stepsCount+=1; curSteps+=1; msg._steps=curSteps; msg._traj.push({kind:'turn', t:Date.now()}); showThink(); think.textContent='💭 第 '+msg._steps+' 回合:模型推理中…'; renderWorkStatus(); scroll(); }
|
|
782
|
+
else if(ev.type==='turnEnd'){ showThink(); think.textContent='⏸ 第 '+msg._steps+' 回合完成(累计 '+(ev.toolSteps||0)+' 工具步)· 进入下一回合…'; renderWorkStatus(); }
|
|
782
783
|
else if(ev.type==='toolStart'){ stepsCount+=1; curSteps+=1; msg._steps=curSteps; msg._traj.push({kind:'tool', seq:ev.seq, name:ev.name, args:ev.args, t:Date.now(), done:false}); onActivity(); update(); renderToolStartEvent(ev); renderWorkStatus(); }
|
|
783
784
|
else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; insertBeforeActiveMsg(pre); scroll(); }
|
|
784
785
|
else if(ev.type==='tool'){ onActivity(); update(); const pending=runningTools.get(ev.seq); if(pending){ pending.remove(); runningTools.delete(ev.seq); } const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.result=ev.result; tj.durationMs=ev.durationMs; tj.card=pending||null; } renderToolEvent(ev); }
|
|
@@ -801,13 +802,12 @@ function renderWorkStatus(){
|
|
|
801
802
|
let html='';
|
|
802
803
|
if(generating){
|
|
803
804
|
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
804
|
-
html='<span class="ws-busy"><span class="spinner"></span>任务执行中:第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 —
|
|
805
|
+
html='<span class="ws-busy"><span class="spinner"></span>任务执行中:第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 — 完成后总结与交付物显示在本轮消息底部(停止请用输入框右侧「■ 停止」按钮)</span>';
|
|
805
806
|
} else if(bgRunning>0){
|
|
806
807
|
html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中 — 点击查看</span>';
|
|
807
808
|
}
|
|
808
809
|
el.style.display=html?'flex':'none';
|
|
809
810
|
el.innerHTML=html;
|
|
810
|
-
const stop=el.querySelector('.ws-stop'); if(stop) stop.onclick=()=>abort();
|
|
811
811
|
const bg=el.querySelector('.ws-bg'); if(bg) bg.onclick=()=>{ const tp=$('#tasksPanel'); tp.style.display = tp.style.display==='none'?'flex':'none'; if(tp.style.display==='flex'){ $('#trajPanel').style.display='none'; updateTasksPanel(); } };
|
|
812
812
|
}
|
|
813
813
|
// 轨迹元行:任务完成后附在本轮消息顶部(步数/子代理数,点击打开轨迹面板)
|
package/src/web/server.js
CHANGED
|
@@ -240,7 +240,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
240
240
|
try {
|
|
241
241
|
send({ type: 'progress', seconds: Math.round((Date.now() - entry.startedAt) / 1000), steps: io?.stats?.().toolCount || 0 });
|
|
242
242
|
} catch {}
|
|
243
|
-
},
|
|
243
|
+
}, 5000);
|
|
244
244
|
const userMessage = String(body.message ?? '').trim();
|
|
245
245
|
entry.message = (userMessage || '[附件]').slice(0, 40);
|
|
246
246
|
const visionSupported = Boolean(modelPreset(modelName)?.supportsVision || cfg.customModels?.[modelName]?.vision);
|
package/src/web/web-io.js
CHANGED
|
@@ -29,16 +29,21 @@ export function createWebIO({ send, askHandler, setAbortHandler }) {
|
|
|
29
29
|
send({ type: 'banner', title, lines });
|
|
30
30
|
},
|
|
31
31
|
// 每轮生成开始/结束:前端据此显示持续的「正在思考…」动态指示
|
|
32
|
+
// 审计(阶段切换静默):turnStart 带累计工具步数;stopSpinner 在回合真正结束时
|
|
33
|
+
// 发 turnEnd——客户端据此显示「第 N 回合完成,进入下一回合…」,阶段边界不再静默
|
|
32
34
|
beginTurn() {},
|
|
33
35
|
endTurn() {},
|
|
34
36
|
startSpinner() {
|
|
35
37
|
if (!io._turnActive) {
|
|
36
38
|
io._turnActive = true;
|
|
37
|
-
send({ type: 'turnStart' });
|
|
39
|
+
send({ type: 'turnStart', toolSteps: io._toolCount });
|
|
38
40
|
}
|
|
39
41
|
},
|
|
40
42
|
stopSpinner() {
|
|
41
|
-
io._turnActive
|
|
43
|
+
if (io._turnActive) {
|
|
44
|
+
io._turnActive = false;
|
|
45
|
+
send({ type: 'turnEnd', toolSteps: io._toolCount });
|
|
46
|
+
}
|
|
42
47
|
},
|
|
43
48
|
writeText(t) {
|
|
44
49
|
send({ type: 'text', delta: String(t) });
|