mingdao-harness 0.1.66 → 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/README.md +1 -1
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONFIG.md +4 -0
- package/docs/EVALUATION-SYNTHESIS.md +7 -7
- package/package.json +3 -2
- package/src/agent.js +56 -6
- package/src/cli.js +18 -0
- package/src/commands/update.js +10 -0
- package/src/compact.js +43 -8
- package/src/context.js +28 -11
- package/src/memory.js +1 -1
- package/src/models.js +11 -5
- package/src/pricing.js +53 -8
- package/src/providers/openai-compatible.js +2 -1
- package/src/routing.js +25 -4
- package/src/titles.js +2 -0
- package/src/tools/fs-tools.js +24 -0
- package/src/web/index.html +126 -15
- package/src/web/server.js +12 -2
- package/src/web/web-io.js +7 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
## 为什么选 MingDao
|
|
8
8
|
|
|
9
9
|
- ⚡ **真正的零依赖**:纯 Node.js ≥ 18,无任何 npm 运行时依赖、无构建步骤——装完即用,不拖 node_modules
|
|
10
|
-
- 💰 **DeepSeek-V4 深度省钱**:384K
|
|
10
|
+
- 💰 **DeepSeek-V4 深度省钱**:1M 上下文预设(单次输出上限 384K)、**缓存命中计价**(命中价仅为未命中的 1/30)+ 命中率仪表盘、峰谷计价(高峰=北京工作日 9:00–12:00、14:00–18:00,闲时半价自动识别)、自动路由(pro 规划 / flash 执行 + 分类缓存 + 会话粘滞)、精确 tokenizer(官方词表 BPE)、滞回自动压缩、**Batch API 半价批处理**(`mingdao batch`)、**避峰调度**(`--offpeak` 高峰顺延到最近闲时 12:00/18:00)、**费用护栏**(每日上限防超支)
|
|
11
11
|
- 🖥 **双界面 + IDE 全家桶**:产品级 TUI(流式 Markdown、代码高亮、编辑 diff、Ctrl+C 中断、Tab 补全)与 `mingdao web` 一键 WebUI(PWA 可装桌面、多任务并行、全项设置面板);VS Code 侧边栏与 JetBrains 工具窗深度集成
|
|
12
12
|
- 🧠 **36 个技能开箱即用**:14 个内置常驻 + 22 个可安装技能库(线上 registry 逐文件 sha256 校验防供应链篡改,`mingdao skill install sql` 一键装,可自建企业内 registry)
|
|
13
13
|
- 🔌 **生态即插即用**:MCP 客户端(零依赖实现,`mcpServers` 配置即接入任意 MCP 服务器)+ Hooks 钩子 + 9 个 MCP 生态预设
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -89,7 +89,7 @@ PreToolUse(stdin 收 JSON,stdout 输出 `{decision:"block", reason}` 可阻
|
|
|
89
89
|
|
|
90
90
|
## 5. 面向 DeepSeek-V4 的优化设计
|
|
91
91
|
|
|
92
|
-
1. **预设调优**:v4-pro(推理/规划:温度 0.4、输出上限 32k、预算 200k);v4-flash(日常:温度 0.6、输出 8k、预算 128k);两者 `contextWindow` 均为 384K
|
|
92
|
+
1. **预设调优**:v4-pro(推理/规划:温度 0.4、输出上限 32k、预算 200k);v4-flash(日常:温度 0.6、输出 8k、预算 128k);两者 `contextWindow` 均为 1M(官方规格;384K 是单次最大输出,字段 `maxOutputCeiling`),预算可随时调高。
|
|
93
93
|
2. **推理内容流式展示**:`reasoning_content` 以暗色增量渲染,与正文同流。
|
|
94
94
|
3. **峰谷定价适配**:每轮后展示 prompt/completion tokens,便于用户把批处理放在谷时段(v4 系列 2026-08-17 起峰谷定价,高峰=北京工作日 9:00–12:00、14:00–18:00,闲时价=高峰一半)。官方同时提供 Responses API 与 Anthropic 兼容接口;MingDao 默认走 OpenAI 兼容 chat/completions,如需原生协议可写自定义 Provider 模块。
|
|
95
95
|
4. **模型路由(路线图)**:规划用 v4-pro、执行用 v4-flash 的自动分工;Provider 抽象已支持任意切换。
|
package/docs/CONFIG.md
CHANGED
|
@@ -180,6 +180,10 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
180
180
|
批处理的网关、`batchEndpoint`/`batchWindow` 覆盖协议字段。
|
|
181
181
|
- **避峰执行(高峰输入价 2 倍)**:`mingdao run --offpeak` / `mingdao schedule add --offpeak`
|
|
182
182
|
——高峰时段(北京时间工作日 9:00–12:00、14:00–18:00 两段)自动顺延到最近闲时(12:00 / 18:00)执行;**周末与午间 12:00–14:00 按闲时计价**
|
|
183
|
+
· `pricing.peakWindows`:高峰窗口覆盖([[起,止],...] 北京时间整点);`pricing.timezone`:计价时区
|
|
184
|
+
· `pricing.source`:官方价格 JSON 地址(`mingdao update --pricing` 拉取,TTL 默认 7 天,`pricing.ttlDays` 可调);`pricing.overrides`:按模型覆盖价格
|
|
185
|
+
· `reasoningEffort`:思考强度 low/high/max(默认模型内置 high;REPL `/think` 切换)
|
|
186
|
+
· `routing.upgradeSteps` / `routing.upgradeTruncated`:粘滞 flash 会话累计步数/截断超过阈值自动升 planner(默认 10 / 2)
|
|
183
187
|
(DeepSeek 官方邮件确认),不触发避峰等待。WebUI 调度面板勾选「🌙 避峰执行」。
|
|
184
188
|
|
|
185
189
|
## 云同步
|
|
@@ -44,12 +44,12 @@
|
|
|
44
44
|
|
|
45
45
|
## 三、下一步优化路线(按投入产出排序)
|
|
46
46
|
|
|
47
|
-
### 近期(0.1.67
|
|
48
|
-
1. **思考强度三档透传**(Kimi P1-B
|
|
49
|
-
2. **上下文窗口字段修正**(Kimi P1-C):`contextWindow` 1M、新增 `maxOutputCeiling`、pro
|
|
50
|
-
3. **价格表外置 + 自动刷新**(Hermes C1):内置表兜底 + `~/.mingdao/pricing.json`
|
|
51
|
-
4. **轻量语义回收**(Hermes B1):预算 80%
|
|
52
|
-
5. **路由升级检测 + 第三态**(Hermes C2 / Kimi
|
|
47
|
+
### 近期(0.1.67 已实施 ✅,其余顺延 0.2.0)
|
|
48
|
+
1. ✅ **思考强度三档透传**(Kimi P1-B):`models.js` 加 `reasoningEffort`、Provider 透传 `reasoning_effort`(官方 thinking_mode 参数),辅助调用(标题/记忆/分类器)固定 `low`,REPL `/think low|high|max|off` 切换。
|
|
49
|
+
2. ✅ **上下文窗口字段修正**(Kimi P1-C):`contextWindow` 1M、新增 `maxOutputCeiling:384000`、pro `maxOutputTokens` 32768→65536。
|
|
50
|
+
3. ✅ **价格表外置 + 自动刷新**(Hermes C1):内置表兜底 + `~/.mingdao/pricing.json`(`mingdao update --pricing` 从 `pricing.source` 拉取,TTL 7 天);过期时费用标签提示刷新。
|
|
51
|
+
4. ✅ **轻量语义回收**(Hermes B1):预算 >80% 时最老 tool 消息 → 单行摘要、旧 assistant 长文截前 200 字,无需模型调用;仍超才按尾部保留丢弃。
|
|
52
|
+
5. ✅ **路由升级检测 + 第三态**(Hermes C2 / Kimi):粘滞 flash 会话累计工具步数 ≥10 或截断 ≥2 自动升 planner(`routing.upgradeSteps/upgradeTruncated` 可调);分类器"不确定"→ 保守走 planner。
|
|
53
53
|
|
|
54
54
|
### 中期(0.2.x,2–5 天)
|
|
55
55
|
6. **reasoning 回填裁剪**(MiniMax P0-2):推理链 >1000 字符截尾 500 + 关键决策标记;>4000 完全丢弃留一句概括。
|
|
@@ -82,4 +82,4 @@
|
|
|
82
82
|
|
|
83
83
|
- 峰谷窗口:Kimi 正确(双窗口),MiniMax 漏查 → 已按官方口径修复。
|
|
84
84
|
- "无自动避峰调度"(OfficeACE 二/三):不实,`run --offpeak` / `schedule add --offpeak` / WebUI 均已实现 → 本版修正了顺延目标。
|
|
85
|
-
- 上下文窗口 384K vs 1M:Kimi 指出 384K 实为最大输出规格 →
|
|
85
|
+
- 上下文窗口 384K vs 1M:Kimi 指出 384K 实为最大输出规格 → 0.1.67 已修正(contextWindow=1M、新增 maxOutputCeiling=384K、pro maxOutputTokens 32768→65536)。
|
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;
|
|
@@ -26,6 +27,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
26
27
|
const budget = cfg.contextBudget || preset.budgetTokens || 128000;
|
|
27
28
|
const maxOutput = cfg.maxOutputTokens || preset.maxOutputTokens || 8192;
|
|
28
29
|
const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
|
|
30
|
+
const reasoningEffort = cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
|
|
29
31
|
const hooks = createHooks(cfg.hooks, workingDir);
|
|
30
32
|
const todos = [];
|
|
31
33
|
// 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
|
|
@@ -118,6 +120,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
118
120
|
try {
|
|
119
121
|
while (steps < stepLimit) {
|
|
120
122
|
steps += 1;
|
|
123
|
+
// 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
|
|
124
|
+
const turnToolCache = new Map();
|
|
121
125
|
// 自动压缩(P3-1):预算不足、静默裁剪即将丢弃早期段落时,先用 executor 模型
|
|
122
126
|
// 把被裁段落压成摘要注入,替代「失忆」;失败/不值得时回退普通裁剪。
|
|
123
127
|
if (cfg.autoCompact !== false) {
|
|
@@ -150,6 +154,39 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
150
154
|
}
|
|
151
155
|
const trimmed = trimMessages(messages, budget, count);
|
|
152
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
|
+
|
|
153
190
|
// 费用护栏(A2):每轮开始前按今日实际费用检查;block 时暂停本轮并明确告知
|
|
154
191
|
if (cfg.costGuard) {
|
|
155
192
|
const guard = checkCostGuard();
|
|
@@ -185,10 +222,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
185
222
|
try {
|
|
186
223
|
res = await provider.chat({
|
|
187
224
|
model: modelName,
|
|
188
|
-
messages:
|
|
225
|
+
messages: sanitized,
|
|
189
226
|
tools: [...toolSchemas(), ...mcpSchemas()],
|
|
190
227
|
temperature,
|
|
191
228
|
maxTokens: maxOutput,
|
|
229
|
+
reasoningEffort,
|
|
192
230
|
signal: ac.signal,
|
|
193
231
|
onDelta(d) {
|
|
194
232
|
io.stopSpinner();
|
|
@@ -297,14 +335,25 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
297
335
|
}
|
|
298
336
|
|
|
299
337
|
// 执行单个工具(渲染「执行中」→ dispatch → 捕获异常转错误结果)
|
|
338
|
+
// 审计 Hermes C4:同回合相同参数的只读工具(read/ls/glob/grep/skill)合并执行一次,
|
|
339
|
+
// 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
|
|
300
340
|
async function runTool(prep) {
|
|
301
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
|
+
}
|
|
302
347
|
try {
|
|
348
|
+
let result;
|
|
303
349
|
if (prep.isMcp) {
|
|
304
350
|
if (!mcp) throw new Error('MCP 工具未启用');
|
|
305
|
-
|
|
351
|
+
result = await mcp.call(prep.name, prep.args);
|
|
352
|
+
} else {
|
|
353
|
+
result = await dispatch(prep.name, prep.args, ctx);
|
|
306
354
|
}
|
|
307
|
-
|
|
355
|
+
if (dedupKey) turnToolCache.set(dedupKey, result);
|
|
356
|
+
return result;
|
|
308
357
|
} catch (err) {
|
|
309
358
|
return JSON.stringify({ ok: false, error: String(err?.message || err) });
|
|
310
359
|
}
|
|
@@ -342,7 +391,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
342
391
|
});
|
|
343
392
|
}
|
|
344
393
|
const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
|
|
345
|
-
|
|
394
|
+
const prefix = prep.cached ? '(与同回合相同调用结果一致,已复用)\n' : '';
|
|
395
|
+
messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text) });
|
|
346
396
|
}
|
|
347
397
|
|
|
348
398
|
let i = 0;
|
package/src/cli.js
CHANGED
|
@@ -780,6 +780,24 @@ async function main() {
|
|
|
780
780
|
continue;
|
|
781
781
|
}
|
|
782
782
|
await switchToModel(target);
|
|
783
|
+
} else if (cmd === '/think') {
|
|
784
|
+
const vals = ['low', 'high', 'max'];
|
|
785
|
+
if (!arg) {
|
|
786
|
+
io.print(`用法:/think low|high|max|off(当前:${cfg.reasoningEffort || '跟随模型默认 high'})`);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (arg === 'off') cfg.reasoningEffort = undefined;
|
|
790
|
+
else if (vals.includes(arg)) cfg.reasoningEffort = arg;
|
|
791
|
+
else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
|
|
792
|
+
agent = createAgent({
|
|
793
|
+
provider, permission, io, modelName, workingDir, cfg,
|
|
794
|
+
undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
|
|
795
|
+
onCompact: (msgs) => {
|
|
796
|
+
rewriteSession(session.file, msgs);
|
|
797
|
+
persisted = msgs.length;
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
io.print(style(`✓ 思考强度:${cfg.reasoningEffort || 'off(模型默认)'}`, C.green));
|
|
783
801
|
} else if (cmd === '/plan') {
|
|
784
802
|
planMode = !planMode;
|
|
785
803
|
io.print(style(`✓ 计划模式:${planMode ? '开' : '关'}${planMode ? '(先出计划,确认后执行)' : ''}`, C.green));
|
package/src/commands/update.js
CHANGED
|
@@ -5,6 +5,16 @@ import path from 'node:path';
|
|
|
5
5
|
import { loadConfig } from '../config.js';
|
|
6
6
|
|
|
7
7
|
export async function handleUpdateFamily(cmd, args) {
|
|
8
|
+
// 价格表外置刷新(Hermes C1):mingdao update --pricing —— 从 cfg.pricing.source 拉取官方价格 JSON
|
|
9
|
+
if (cmd === 'update' && args.includes('--pricing')) {
|
|
10
|
+
const cfg = loadConfig();
|
|
11
|
+
if (!cfg) { console.log('[错误] 未初始化配置:请先运行 mingdao init'); process.exitCode = 1; return true; }
|
|
12
|
+
const { refreshPricingFromSource } = await import('../pricing.js');
|
|
13
|
+
const r = await refreshPricingFromSource(cfg);
|
|
14
|
+
console.log(r.lines.join('\n'));
|
|
15
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
8
18
|
// 自更新与回滚(git 安装形态)。
|
|
9
19
|
// 评估 P3-1 子命令劫持防护:参数不合法时回退为普通提问(如 "mingdao update the docs" 是提问而非更新命令)
|
|
10
20
|
if (cmd === 'update' && args.every((a) => a === '--check')) {
|
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/context.js
CHANGED
|
@@ -65,18 +65,35 @@ export function trimMessages(messages, budget, count = approxTokens) {
|
|
|
65
65
|
const hasSystem = messages[0]?.role === 'system';
|
|
66
66
|
const system = hasSystem ? [messages[0]] : [];
|
|
67
67
|
const rest = hasSystem ? messages.slice(1) : messages;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
const totalOf = (arr) => arr.reduce((s, m) => s + messageTokens(m, count), 0);
|
|
69
|
+
// —— 轻量语义回收(Hermes B1):预算 >80% 时先对最老消息做无模型压缩 ——
|
|
70
|
+
// 最老 tool 消息 → 单行摘要;老 assistant 长文 → 前 200 字;逐条向新推进直到回到 80% 线。
|
|
71
|
+
// 相比直接丢弃,被修改的只有前端旧消息——其后未被触碰的近期尾部字节保持不变,
|
|
72
|
+
// 下一轮前缀缓存从首个未修改消息起继续命中(直接丢弃反而让整段前缀失配)。
|
|
73
|
+
if (totalOf(system) + totalOf(rest) > budget * 0.8) {
|
|
74
|
+
const out = [...rest];
|
|
75
|
+
for (let i = 0; i < out.length && i < 500; i++) {
|
|
76
|
+
if (totalOf(system) + totalOf(out) <= budget * 0.8) break;
|
|
77
|
+
const m = out[i];
|
|
78
|
+
const c = typeof m.content === 'string' ? m.content : JSON.stringify(m?.content ?? '');
|
|
79
|
+
if (m.role === 'tool' && c.length > 40) {
|
|
80
|
+
out[i] = { ...m, content: `[工具 ${m.tool_call_id || ''} 结果摘要:${c.slice(0, 40).replace(/\n/g, ' ')}…(已回收,原 ${c.length} 字)]` };
|
|
81
|
+
} else if (m.role === 'assistant' && c.length > 200) {
|
|
82
|
+
out[i] = { ...m, content: c.slice(0, 200) + ` …[已回收,原 ${c.length} 字]` };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 回收后仍超预算 → 从尾部保留(必要时继续丢弃最老,清洗 tool 配对)
|
|
86
|
+
let total = totalOf(system);
|
|
87
|
+
const tail = [];
|
|
88
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
89
|
+
const t = messageTokens(out[i], count);
|
|
90
|
+
if (total + t > budget) break;
|
|
91
|
+
total += t;
|
|
92
|
+
tail.unshift(out[i]);
|
|
93
|
+
}
|
|
94
|
+
return cleanToolPairing(system.concat(tail));
|
|
75
95
|
}
|
|
76
|
-
|
|
77
|
-
// 静默裁剪:不向中间插入说明消息——保持消息前缀字节稳定,
|
|
78
|
-
// 让 DeepSeek 等支持上下文缓存的 API 最大化缓存命中(命中价仅为未命中的约 1/30)。
|
|
79
|
-
return kept;
|
|
96
|
+
return cleanToolPairing(system.concat(rest));
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
export function clampText(text, maxChars = TOOL_RESULT_LIMIT) {
|
package/src/memory.js
CHANGED
|
@@ -178,7 +178,7 @@ export async function extractMemory(provider, model, messages, existingMemory) {
|
|
|
178
178
|
];
|
|
179
179
|
// 结构化输出(评估 4.2-4):json_object,maxTokens 300→200,解析零失败;网关不支持时回退纯文本
|
|
180
180
|
try {
|
|
181
|
-
const res = await provider.chat({ model, messages: msgs, tools: [], temperature: 0.2, maxTokens: 200, responseFormat: { type: 'json_object' } });
|
|
181
|
+
const res = await provider.chat({ model, messages: msgs, tools: [], temperature: 0.2, maxTokens: 200, reasoningEffort: 'low', responseFormat: { type: 'json_object' } });
|
|
182
182
|
const j = JSON.parse(String(res.text || '').trim());
|
|
183
183
|
const items = Array.isArray(j?.items) ? j.items : [];
|
|
184
184
|
const clean = items
|
package/src/models.js
CHANGED
|
@@ -9,7 +9,7 @@ export const PROVIDERS = {
|
|
|
9
9
|
baseUrl: 'https://api.deepseek.com/v1',
|
|
10
10
|
envKey: 'DEEPSEEK_API_KEY',
|
|
11
11
|
models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'],
|
|
12
|
-
note: 'DeepSeek-V4 正式版(2026-08-17 起商用):384K
|
|
12
|
+
note: 'DeepSeek-V4 正式版(2026-08-17 起商用):1M 上下文(单次最大输出 384K),峰谷定价(高峰=北京工作日 9:00–12:00、14:00–18:00,闲时价=高峰一半),支持工具调用 / Responses API / Anthropic 兼容接口。',
|
|
13
13
|
},
|
|
14
14
|
openai: {
|
|
15
15
|
label: 'OpenAI',
|
|
@@ -53,11 +53,15 @@ export const MODELS = {
|
|
|
53
53
|
'deepseek-v4-pro': {
|
|
54
54
|
label: '旗舰推理 · 复杂架构、排障、规划',
|
|
55
55
|
provider: 'deepseek',
|
|
56
|
-
contextWindow:
|
|
56
|
+
contextWindow: 1000000, // 官方上下文 1M(审计 Kimi P1-C:384K 实为单次最大输出,字段语义修正)
|
|
57
|
+
maxOutputCeiling: 384000, // 单次最大输出上限(官方规格)
|
|
57
58
|
budgetTokens: 200000, // 默认注入预算(省钱省时,可调高)
|
|
58
|
-
maxOutputTokens:
|
|
59
|
+
maxOutputTokens: 65536, // 生成类任务少续写、少重复计费(Kimi P1-C)
|
|
59
60
|
temperature: 0.4,
|
|
60
61
|
supportsReasoning: true,
|
|
62
|
+
// 思考强度三档(官方 thinking_mode:low/high/max)——输出按 completion 计费(输入 3 倍价),
|
|
63
|
+
// 简单任务用 low 可砍掉大部分推理 token;辅助调用(标题/记忆/分类器)固定 low
|
|
64
|
+
reasoningEffort: { default: 'high', options: ['low', 'high', 'max'] },
|
|
61
65
|
// 峰谷定价(元/百万 tokens;input=缓存未命中,cacheHit=缓存命中约为未命中的 1/30)
|
|
62
66
|
pricing: {
|
|
63
67
|
offpeak: { input: 4.5, output: 13.5, cacheHit: 0.15 },
|
|
@@ -67,7 +71,8 @@ export const MODELS = {
|
|
|
67
71
|
'deepseek-v4-flash': {
|
|
68
72
|
label: '极速响应 · 日常问答与轻量任务',
|
|
69
73
|
provider: 'deepseek',
|
|
70
|
-
contextWindow:
|
|
74
|
+
contextWindow: 1000000, // 官方上下文 1M
|
|
75
|
+
maxOutputCeiling: 384000,
|
|
71
76
|
budgetTokens: 128000,
|
|
72
77
|
maxOutputTokens: 8192,
|
|
73
78
|
temperature: 0.6,
|
|
@@ -82,7 +87,8 @@ export const MODELS = {
|
|
|
82
87
|
'deepseek-v4-flash-vision-exp': {
|
|
83
88
|
label: '多模态视觉 · 图片描述/截图识别/图表分析(实验版)',
|
|
84
89
|
provider: 'deepseek',
|
|
85
|
-
contextWindow:
|
|
90
|
+
contextWindow: 1000000,
|
|
91
|
+
maxOutputCeiling: 384000,
|
|
86
92
|
budgetTokens: 128000,
|
|
87
93
|
maxOutputTokens: 8192,
|
|
88
94
|
temperature: 0.6,
|
package/src/pricing.js
CHANGED
|
@@ -12,6 +12,47 @@ import { mingdaoHome } from './config.js';
|
|
|
12
12
|
// 内置价格表的数据时点(定价可能调整,配置覆盖可随时更新)
|
|
13
13
|
export const PRICE_DATA_AS_OF = '2026-08';
|
|
14
14
|
|
|
15
|
+
// 价格表外置(Hermes C1):~/.mingdao/pricing.json 由「mingdao update --pricing」从
|
|
16
|
+
// cfg.pricing.source 拉取(TTL 默认 7 天,cfg.pricing.ttlDays 可调);TTL 内覆盖内置表,
|
|
17
|
+
// 过期自动回退内置并置 stale 标记(/cost 与费用标签会提示)。
|
|
18
|
+
function pricingFilePath() { return path.join(mingdaoHome(), 'pricing.json'); }
|
|
19
|
+
let extCache = { mtime: -1, data: null, stale: false };
|
|
20
|
+
function externalPricing() {
|
|
21
|
+
try {
|
|
22
|
+
const f = pricingFilePath();
|
|
23
|
+
const st = fs.statSync(f);
|
|
24
|
+
if (st.mtimeMs !== extCache.mtime) {
|
|
25
|
+
const d = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
26
|
+
const ttlDays = Number(tzCache.ttlDays ?? 7);
|
|
27
|
+
const age = Date.now() - Number(d?.fetchedAt || 0);
|
|
28
|
+
extCache = { mtime: st.mtimeMs, data: d, stale: !Number.isFinite(age) || age > ttlDays * 86400000 };
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
extCache = { mtime: -1, data: null, stale: false };
|
|
32
|
+
}
|
|
33
|
+
return extCache;
|
|
34
|
+
}
|
|
35
|
+
export function pricingDataStale() { return externalPricing().stale; }
|
|
36
|
+
|
|
37
|
+
// 拉取官方价格表(零依赖 fetch):cfg.pricing.source 为 JSON 地址,返回 { models: { <模型名>: { input, output, cacheHit, peak? } } }
|
|
38
|
+
export async function refreshPricingFromSource(cfg) {
|
|
39
|
+
const src = String(cfg?.pricing?.source || '').trim();
|
|
40
|
+
if (!src) return { ok: false, lines: ['未配置 pricing.source(config.json 的 pricing.source 填官方价格 JSON 地址后重试)'] };
|
|
41
|
+
const res = await fetch(src, { signal: AbortSignal.timeout(15000) });
|
|
42
|
+
if (!res.ok) return { ok: false, lines: ['价格源请求失败:HTTP ' + res.status] };
|
|
43
|
+
const d = await res.json();
|
|
44
|
+
const models = d && typeof d === 'object' && !Array.isArray(d) ? /** @type {any} */ (d).models : null;
|
|
45
|
+
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
|
46
|
+
return { ok: false, lines: ['价格源格式不符:应为 {"models": {"模型名": {"input":…,"output":…,"cacheHit":…,"peak":{…}}}}'] };
|
|
47
|
+
}
|
|
48
|
+
const file = pricingFilePath();
|
|
49
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
|
|
51
|
+
extCache = { mtime: -1, data: null, stale: false };
|
|
52
|
+
const names = Object.keys(models).join('、');
|
|
53
|
+
return { ok: true, lines: ['✓ 价格表已刷新(' + names + '),TTL 内费用估算/护栏/避峰自动跟随'] };
|
|
54
|
+
}
|
|
55
|
+
|
|
15
56
|
// 峰谷判断锚定北京时间(DeepSeek 官方定价页:高峰时段 = 北京时间周一至周五
|
|
16
57
|
// 9:00–12:00、14:00–18:00 两段;其余(含午间 12:00–14:00 与周末全天)为闲时,闲时价 = 高峰一半)。
|
|
17
58
|
// 用本机时区判断会让海外用户计费错位。config.pricing.timezone 可覆盖时区、
|
|
@@ -29,6 +70,7 @@ function peakCfg() {
|
|
|
29
70
|
mtime: st.mtimeMs,
|
|
30
71
|
timezone: String(cfg?.pricing?.timezone || 'Asia/Shanghai'),
|
|
31
72
|
peakWindows: Array.isArray(pw) && pw.length ? pw : DEFAULT_PEAK_WINDOWS,
|
|
73
|
+
ttlDays: Number(cfg?.pricing?.ttlDays || 7),
|
|
32
74
|
};
|
|
33
75
|
}
|
|
34
76
|
} catch {}
|
|
@@ -142,16 +184,19 @@ function pricingOverrides() {
|
|
|
142
184
|
}
|
|
143
185
|
function effectivePricing(modelName) {
|
|
144
186
|
const preset = modelPreset(modelName);
|
|
145
|
-
|
|
187
|
+
const ext = externalPricing().data?.models?.[modelName] || null;
|
|
188
|
+
if (!preset?.pricing && !ext) return null;
|
|
189
|
+
const base = ext || preset?.pricing || null;
|
|
190
|
+
if (!base) return null;
|
|
146
191
|
const over = pricingOverrides()[modelName] || {}; // 审计 Q3:mtime 缓存替代每轮读盘
|
|
147
|
-
const merge = (
|
|
148
|
-
input: Number(o.input ??
|
|
149
|
-
output: Number(o.output ??
|
|
150
|
-
cacheHit: Number(o.cacheHit ??
|
|
192
|
+
const merge = (b, o = {}) => ({
|
|
193
|
+
input: Number(o.input ?? b?.input ?? 0),
|
|
194
|
+
output: Number(o.output ?? b?.output ?? 0),
|
|
195
|
+
cacheHit: Number(o.cacheHit ?? b?.cacheHit ?? 0),
|
|
151
196
|
});
|
|
152
197
|
return {
|
|
153
|
-
offpeak: merge(
|
|
154
|
-
peak: merge(
|
|
198
|
+
offpeak: merge(base.offpeak || base, over),
|
|
199
|
+
peak: merge(base.peak || base.offpeak || base, over.peak),
|
|
155
200
|
};
|
|
156
201
|
}
|
|
157
202
|
|
|
@@ -175,5 +220,5 @@ export function estimateCostLabel(modelName, promptTokens, completionTokens, usa
|
|
|
175
220
|
const yuan = estimateCost(modelName, promptTokens, completionTokens, cache).toFixed(5);
|
|
176
221
|
const hitPart =
|
|
177
222
|
cache && cache.hit + cache.miss > 0 ? ` · 缓存命中 ${(cache.rate * 100).toFixed(0)}%` : ' · 未计缓存折扣';
|
|
178
|
-
return ` ≈¥${yuan}(${isPeakHour() ? '高峰' : '闲时'}${hitPart}
|
|
223
|
+
return ` ≈¥${yuan}(${isPeakHour() ? '高峰' : '闲时'}${hitPart})${pricingDataStale() ? ' · ⚠ 价格表过期,运行 mingdao update --pricing 刷新' : ''}`;
|
|
179
224
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @typedef {Error & { status?: number, headers?: Headers }} ApiError
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export async function chat({ baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, includeUsage = true, responseFormat }) {
|
|
9
|
+
export async function chat({ baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
10
10
|
const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
|
11
11
|
const payload = { model, messages };
|
|
12
12
|
if (temperature != null) payload.temperature = temperature;
|
|
@@ -16,6 +16,7 @@ export async function chat({ baseUrl, apiKey, model, messages, tools, temperatur
|
|
|
16
16
|
}
|
|
17
17
|
if (maxTokens) payload.max_tokens = maxTokens;
|
|
18
18
|
if (responseFormat) payload.response_format = responseFormat;
|
|
19
|
+
if (reasoningEffort) payload.reasoning_effort = reasoningEffort; // 思考强度(官方 thinking_mode)
|
|
19
20
|
payload.stream = true;
|
|
20
21
|
// 流式响应默认不带 usage,显式请求以便展示 token/费用统计
|
|
21
22
|
// (部分网关不支持该字段,可在 config.json 设 "includeUsage": false 关闭)
|
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
|
}
|
|
@@ -37,7 +41,7 @@ export function heuristicRoute(text, rc) {
|
|
|
37
41
|
const ROUTE_CACHE_MAX = 100;
|
|
38
42
|
const routeCache = new Map();
|
|
39
43
|
|
|
40
|
-
export async function routeTask({ cfg, provider, currentModel, text, sticky = null }) {
|
|
44
|
+
export async function routeTask({ cfg, provider, currentModel, text, sticky = null, sessionStats = null }) {
|
|
41
45
|
const rc = routingConfig(cfg);
|
|
42
46
|
if (!rc) return { model: currentModel, reason: null };
|
|
43
47
|
// 当前模型已在路由池外(用户手动指定):不干预
|
|
@@ -52,8 +56,19 @@ export async function routeTask({ cfg, provider, currentModel, text, sticky = nu
|
|
|
52
56
|
: { model: quick, reason: quick === rc.planner ? '规划类任务' : '执行类任务' };
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
//
|
|
59
|
+
// 会话粘滞 + 升级检测(Hermes C2):执行类会话不再逐轮分类(生成类关键词已在启发式拦截),
|
|
60
|
+
// 但会话内复杂度信号累积(工具步数/截断次数)时允许升级 planner——「开头简单、中途复杂」不再被 flash 硬扛。
|
|
56
61
|
if (sticky === rc.executor) {
|
|
62
|
+
const stats = sessionStats || {};
|
|
63
|
+
const steps = Number(stats.steps) || 0;
|
|
64
|
+
const truncated = Number(stats.truncated) || 0;
|
|
65
|
+
const UPGRADE_STEPS = Number(cfg?.routing?.upgradeSteps) || 10;
|
|
66
|
+
const UPGRADE_TRUNCATED = Number(cfg?.routing?.upgradeTruncated) || 2;
|
|
67
|
+
if (steps >= UPGRADE_STEPS || truncated >= UPGRADE_TRUNCATED) {
|
|
68
|
+
return rc.planner === currentModel
|
|
69
|
+
? { model: currentModel, reason: null }
|
|
70
|
+
: { model: rc.planner, reason: `会话升级:执行类已累计 ${steps} 步工具 / ${truncated} 次截断 → planner` };
|
|
71
|
+
}
|
|
57
72
|
return rc.executor === currentModel
|
|
58
73
|
? { model: currentModel, reason: null }
|
|
59
74
|
: { model: rc.executor, reason: '会话粘滞:执行类' };
|
|
@@ -90,6 +105,7 @@ export async function routeTask({ cfg, provider, currentModel, text, sticky = nu
|
|
|
90
105
|
tools: [],
|
|
91
106
|
temperature: 0,
|
|
92
107
|
maxTokens: 20, // 结构化输出(评估 4.2-4):80→20
|
|
108
|
+
reasoningEffort: 'low',
|
|
93
109
|
responseFormat: { type: 'json_object' },
|
|
94
110
|
});
|
|
95
111
|
let verdict = null;
|
|
@@ -103,6 +119,11 @@ export async function routeTask({ cfg, provider, currentModel, text, sticky = nu
|
|
|
103
119
|
if (t.includes('plan')) verdict = 'plan';
|
|
104
120
|
else if (t.includes('exec')) verdict = 'execute';
|
|
105
121
|
}
|
|
122
|
+
if (!verdict) {
|
|
123
|
+
// 第三态(Kimi C2):分类器给不出结论 → 保守走 planner,避免复杂任务被 flash 硬扛
|
|
124
|
+
const m = rc.planner;
|
|
125
|
+
return m === currentModel ? { model: currentModel, reason: null } : { model: m, reason: '分类器不确定:保守走 planner' };
|
|
126
|
+
}
|
|
106
127
|
if (verdict) {
|
|
107
128
|
if (routeCache.size >= ROUTE_CACHE_MAX) {
|
|
108
129
|
const first = routeCache.keys().next().value;
|
package/src/titles.js
CHANGED
|
@@ -37,6 +37,7 @@ export async function generateTitle(provider, model, firstUserText, { timeoutMs
|
|
|
37
37
|
tools: [],
|
|
38
38
|
temperature: 0.3,
|
|
39
39
|
maxTokens: 50,
|
|
40
|
+
reasoningEffort: 'low',
|
|
40
41
|
responseFormat: { type: 'json_object' },
|
|
41
42
|
signal: ctrl.signal,
|
|
42
43
|
});
|
|
@@ -51,6 +52,7 @@ export async function generateTitle(provider, model, firstUserText, { timeoutMs
|
|
|
51
52
|
tools: [],
|
|
52
53
|
temperature: 0.3,
|
|
53
54
|
maxTokens: 120,
|
|
55
|
+
reasoningEffort: 'low',
|
|
54
56
|
signal: ctrl.signal,
|
|
55
57
|
});
|
|
56
58
|
const t = cleanTitle(res.text);
|
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,6 +40,30 @@ 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
|
+
与输入框同宽对齐(复用 #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}
|
|
46
|
+
#workStatus .ws-busy{color:var(--accent2);display:flex;align-items:center;gap:8px;flex:1;min-width:0}
|
|
47
|
+
#workStatus .ws-bg{cursor:pointer;color:var(--warn);white-space:nowrap}
|
|
48
|
+
#workStatus .ws-bg:hover{color:var(--text)}
|
|
49
|
+
/* 消息顶部轨迹行(DeepSeek-Harness 式:每轮可回溯步骤与子代理) */
|
|
50
|
+
.msg-meta{display:flex;gap:8px;align-items:center;font-size:11.5px;color:var(--faint);padding:0 2px 4px}
|
|
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
|
+
.trajBtn:hover{color:var(--text);border-color:var(--accent2)}
|
|
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
|
+
.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
|
+
.tj-head button{margin-left:auto}
|
|
57
|
+
#tjList{flex:1;overflow-y:auto;padding:8px}
|
|
58
|
+
.tj-turn{color:var(--accent2);font-size:12px;font-weight:600;margin:8px 2px 4px}
|
|
59
|
+
.tj-item{padding:6px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;font-size:12.5px;background:var(--bg3);cursor:pointer}
|
|
60
|
+
.tj-item:hover{border-color:var(--accent2)}
|
|
61
|
+
.tj-item .tj-head{all:unset;display:flex;gap:6px;align-items:center;font-size:12.5px;color:var(--text);cursor:pointer;padding:0;border:none;margin:0}
|
|
62
|
+
.tj-item .tj-args{color:var(--faint);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
|
63
|
+
.tj-item .tj-body{display:none;color:var(--dim);font-size:11.5px;margin-top:6px;white-space:pre-wrap;word-break:break-word;max-height:220px;overflow-y:auto;border-top:1px dashed var(--border);padding-top:6px}
|
|
64
|
+
.tj-item.open .tj-body{display:block}
|
|
65
|
+
.tj-item.tj-sub{border-color:var(--accent2)}
|
|
66
|
+
.tj-item.tj-sub .tj-head{color:var(--accent2)}
|
|
43
67
|
.tp-head{padding:12px 14px;font-size:14px;font-weight:600;border-bottom:1px solid var(--border);color:var(--accent2)}
|
|
44
68
|
.tp-head span{color:var(--faint);font-weight:400;font-size:12px}
|
|
45
69
|
#tpList{flex:1;overflow-y:auto;padding:8px}
|
|
@@ -82,6 +106,7 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
|
|
|
82
106
|
.msg-ai .content .codeblock{border:1px solid var(--border);border-radius:12px;overflow:hidden;margin:.5em 0 12px}
|
|
83
107
|
.msg-ai .content .cb-banner{display:flex;justify-content:space-between;align-items:center;gap:12px;background:var(--user);border-bottom:1px solid var(--border);padding:8px 14px;font-size:12px;color:var(--dim);font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
|
|
84
108
|
.msg-ai .content .cb-lang{display:flex;align-items:center;gap:8px}
|
|
109
|
+
.msg-sys{max-width:min(640px,92%);margin:6px auto;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.35);color:var(--warn);border-radius:12px;padding:8px 14px;font-size:12.5px;line-height:1.7}
|
|
85
110
|
.msg-ai .content .cb-dot{width:8px;height:8px;border-radius:50%;background:var(--accent)}
|
|
86
111
|
.msg-ai .content pre code{background:none;border:none;padding:0;color:#c8d3e0}
|
|
87
112
|
.msg-ai .content a{color:var(--accent2)}
|
|
@@ -177,12 +202,17 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
177
202
|
<button id="tasksBtn" title="任务面板">任务</button><button id="sessMgrBtn" title="管理会话(重命名/删除)">✎</button>
|
|
178
203
|
<button id="newChat">新对话</button>
|
|
179
204
|
</header>
|
|
205
|
+
<aside id="trajPanel" style="display:none">
|
|
206
|
+
<div class="tj-head">🧭 任务轨迹<span id="tjSummary" style="font-size:11.5px;color:var(--faint);font-weight:400"></span><button id="tjClose">关闭</button></div>
|
|
207
|
+
<div id="tjList"></div>
|
|
208
|
+
</aside>
|
|
180
209
|
<aside id="tasksPanel" style="display:none">
|
|
181
210
|
<div class="tp-head">任务面板 <span id="tpCount"></span><button id="tpClose" style="float:right">✕</button></div>
|
|
182
211
|
<div id="tpList"></div>
|
|
183
212
|
</aside>
|
|
184
213
|
<main><div id="chat"></div></main>
|
|
185
214
|
<footer>
|
|
215
|
+
<div id="workStatus" style="display:none"></div>
|
|
186
216
|
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/gif,image/webp,.txt,.md,.json,.js,.py,.log,.csv,.html,.css" multiple style="display:none">
|
|
187
217
|
<div id="attachList" style="display:none"></div>
|
|
188
218
|
<div id="composer">
|
|
@@ -455,6 +485,7 @@ $('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirM
|
|
|
455
485
|
$('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
|
|
456
486
|
$('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
|
|
457
487
|
const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
|
|
488
|
+
let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0; // 本轮进度/后台任务数(工作状态条与轨迹共用)
|
|
458
489
|
// 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
|
|
459
490
|
(function(){
|
|
460
491
|
const scroller = document.querySelector('main');
|
|
@@ -549,23 +580,22 @@ function expandableBody(previewHtml, fullHtml){
|
|
|
549
580
|
}
|
|
550
581
|
const runningTools = new Map();
|
|
551
582
|
function renderToolStartEvent(ev){
|
|
552
|
-
const msg = newAiMsg();
|
|
553
583
|
const card=document.createElement('div'); card.className='tool';
|
|
554
584
|
const ico = {'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'👥','skill':'🧩','todo':'☑','undo':'↩'}[ev.name]||'🔌';
|
|
555
585
|
const args = ev.name==='bash'? (ev.args&&ev.args.command):(ev.args&&(ev.args.path||ev.args.pattern||ev.args.name||''))||'';
|
|
556
586
|
card.innerHTML='<div class="head"><span class="ico">'+ico+'</span><span class="name">'+esc(ev.name)+'</span><span class="args">'+esc(args)+'</span><span class="st" style="animation:blink 1.2s infinite">执行中…</span></div>';
|
|
557
|
-
|
|
587
|
+
insertBeforeActiveMsg(card);
|
|
558
588
|
runningTools.set(ev.seq, card);
|
|
559
589
|
scrollBottom();
|
|
560
590
|
}
|
|
561
591
|
function renderToolEvent(ev){
|
|
562
|
-
const msg = newAiMsg();
|
|
563
592
|
const card=document.createElement('div'); card.className='tool';
|
|
564
593
|
const ico = {'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'👥','skill':'🧩','todo':'☑','undo':'↩'}[ev.name]||'🔌';
|
|
565
594
|
const args = ev.name==='bash'? (ev.args&&ev.args.command):(ev.args&&(ev.args.path||ev.args.pattern||ev.args.name||''))||'';
|
|
566
595
|
const r = ev.result||{};
|
|
567
596
|
const ok = ev.result===undefined || r.ok!==false;
|
|
568
597
|
card.innerHTML='<div class="head"><span class="ico">'+ico+'</span><span class="name">'+esc(ev.name)+'</span><span class="args">'+esc(args)+'</span><span class="st '+(ok?'ok':'bad')+'">'+(ev.result===undefined?'…':(ok?'✓':'✖'))+(ev.durationMs!=null?' '+ev.durationMs+'ms':'')+'</span></div>';
|
|
598
|
+
insertBeforeActiveMsg(card);
|
|
569
599
|
const bodyWrap=(el)=>{ if(el) card.appendChild(el); };
|
|
570
600
|
if(ev.name==='skill'){
|
|
571
601
|
// 技能内容只供模型使用,不整篇倒给用户:一行摘要
|
|
@@ -625,7 +655,9 @@ function renderTodos(todos,msg){
|
|
|
625
655
|
for(const t of todos){ const li=document.createElement('li'); li.className=t.status==='completed'?'done':t.status==='in_progress'?'doing':''; li.innerHTML=(t.status==='completed'?'✓ ':'○ ')+esc(t.content); ul.appendChild(li); }
|
|
626
656
|
msg.appendChild(ul);
|
|
627
657
|
}
|
|
628
|
-
function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'; d.textContent=ev.text||ev.title||'';
|
|
658
|
+
function renderBanner(ev){ const d=document.createElement('div'); d.className='banner'; d.textContent=ev.text||ev.title||''; insertBeforeActiveMsg(d); scrollBottom(); }
|
|
659
|
+
// 工具卡/横幅/代码块统一插到「本轮 AI 消息」之前:结论与交付物永远位于消息最底部
|
|
660
|
+
function insertBeforeActiveMsg(el){ if(activeAiMsg && activeAiMsg.parentNode){ chatEl.insertBefore(el, activeAiMsg); } else { chatEl.appendChild(el); } }
|
|
629
661
|
|
|
630
662
|
function askModal(ev){
|
|
631
663
|
return new Promise(resolve=>{
|
|
@@ -685,7 +717,15 @@ function renderAttachments(){
|
|
|
685
717
|
async function send(){
|
|
686
718
|
const text=input.value.trim();
|
|
687
719
|
console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
|
|
688
|
-
if(generating){
|
|
720
|
+
if(generating){
|
|
721
|
+
// 审计(长任务静默硬伤):任务执行中追问「是否完成」→ 界面立即如实应答当前进度,
|
|
722
|
+
// 不再静默吞掉输入;停止职责由「■ 停止」按钮承担(输入框内容保留不丢)。
|
|
723
|
+
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
724
|
+
const d=document.createElement('div'); d.className='msg-sys';
|
|
725
|
+
d.innerHTML='⏳ 上一个任务仍在执行(第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒),<b>尚未完成</b>。完成后总结与交付物会自动出现在本轮消息底部;如需立即中断请点「■ 停止」。';
|
|
726
|
+
chatEl.appendChild(d); scroll();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
689
729
|
if(!text && !attachments.length) return;
|
|
690
730
|
const turnCtrl=new AbortController();
|
|
691
731
|
// 回合看门狗(无活动超时,非总时长——审计:此前 120s 定时炸弹误杀长时间健康生成,
|
|
@@ -709,6 +749,7 @@ async function send(){
|
|
|
709
749
|
attachments=[]; renderAttachments();
|
|
710
750
|
addUser(text||(sentAttachments.map(a=>(a.type==='image'?'🖼 ':'📄 ')+a.name).join(' ')));
|
|
711
751
|
const msg=newAiMsg(); const content=aiContent(msg); let raw='';
|
|
752
|
+
activeAiMsg=msg; msg._traj=[]; msg._steps=0; curSteps=0; curWorkT0=Date.now(); renderWorkStatus();
|
|
712
753
|
let reason=null; let taskId=null;
|
|
713
754
|
// 等待输出时的可见状态:脉动「正在思考…」(每轮生成重新显示)
|
|
714
755
|
const think=document.createElement('div'); think.className='think'; think.textContent='💭 正在思考…'; msg.appendChild(think);
|
|
@@ -719,7 +760,7 @@ async function send(){
|
|
|
719
760
|
let stepsCount=0; const workT0=Date.now();
|
|
720
761
|
const hintEl=$('#hint'); const hintTextEl=$('#hintText'); const defaultHint=hintTextEl?hintTextEl.textContent:'';
|
|
721
762
|
hintEl.classList.add('working');
|
|
722
|
-
const hintTimer=setInterval(()=>{ if(hintTextEl) hintTextEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; },1000);
|
|
763
|
+
const hintTimer=setInterval(()=>{ if(hintTextEl) hintTextEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; renderWorkStatus(); },1000);
|
|
723
764
|
generating=true; setBtn(); // 本地在途状态(前置 DOM 段完成后才置位,异常不影响按钮)
|
|
724
765
|
const scroller=document.querySelector('main');
|
|
725
766
|
const nearBottom=()=> scroller? (scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 90) : true;
|
|
@@ -734,26 +775,95 @@ async function send(){
|
|
|
734
775
|
await handleEvents(resp.body, ev=>{
|
|
735
776
|
if(ev.taskId) taskId=ev.taskId;
|
|
736
777
|
if(ev.type==='ask') disarm(); else arm(); // 有活动→重置;权限等待→暂停计时
|
|
737
|
-
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(); }
|
|
738
779
|
if(ev.type==='text'){ onActivity(); raw+=ev.delta; update(); }
|
|
739
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(); }
|
|
740
|
-
else if(ev.type==='turnStart'){ stepsCount+=1; showThink(); scroll(); }
|
|
741
|
-
else if(ev.type==='
|
|
742
|
-
else if(ev.type==='
|
|
743
|
-
else if(ev.type==='
|
|
744
|
-
else if(ev.type==='
|
|
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(); }
|
|
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(); }
|
|
784
|
+
else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; insertBeforeActiveMsg(pre); scroll(); }
|
|
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); }
|
|
786
|
+
else if(ev.type==='toolDenied'){ onActivity(); const tj=msg._traj.find(x=>x.kind==='tool'&&x.seq===ev.seq); if(tj){ tj.done=true; tj.denied=ev.reason||'未授权'; } const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
|
|
745
787
|
else if(ev.type==='banner'){ renderBanner(ev); }
|
|
746
788
|
else if(ev.type==='ask'){ update(); askModal(ev).then(a=>fetch('/api/permission',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:ev.id,answer:a,taskId})}).then(()=>arm()).catch(()=>{})); }
|
|
747
|
-
else if(ev.type==='usage'){ onActivity(); const u=document.createElement('div'); u.className='usage'; u.textContent=''+ev.modelName+' · ↑'+ev.usage.prompt_tokens+' ↓'+ev.usage.completion_tokens+' tokens · '+((ev.durationMs||0)/1000).toFixed(1)+'s'+ev.cost; msg.
|
|
789
|
+
else if(ev.type==='usage'){ onActivity(); const u=document.createElement('div'); u.className='usage'; u.textContent=''+ev.modelName+' · ↑'+ev.usage.prompt_tokens+' ↓'+ev.usage.completion_tokens+' tokens · '+((ev.durationMs||0)/1000).toFixed(1)+'s'+ev.cost; msg.insertBefore(u, content); scroll(); }
|
|
748
790
|
else if(ev.type==='error'){ console.log('[MingDao] error 事件:' + ev.message); onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
|
|
749
791
|
else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
|
|
750
792
|
});
|
|
751
793
|
}catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):(e&&e.message)||'网络错误'; msg.appendChild(d); scroll(); }
|
|
752
|
-
finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); scroll(); updateTasksPanel(); }
|
|
794
|
+
finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); activeAiMsg=null; curWorkT0=0; renderWorkStatus(); scroll(); updateTasksPanel(); }
|
|
753
795
|
}
|
|
754
796
|
|
|
755
797
|
function abort(){ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(()=>updateTasksPanel()).catch(()=>{}); }
|
|
756
798
|
|
|
799
|
+
// —— 输入框上方工作状态条(审计:长任务静默硬伤 → 实时进度可见) ——
|
|
800
|
+
function renderWorkStatus(){
|
|
801
|
+
const el=$('#workStatus'); if(!el) return;
|
|
802
|
+
let html='';
|
|
803
|
+
if(generating){
|
|
804
|
+
const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
|
|
805
|
+
html='<span class="ws-busy"><span class="spinner"></span>任务执行中:第 '+curSteps+' 步 · 已 '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 — 完成后总结与交付物显示在本轮消息底部(停止请用输入框右侧「■ 停止」按钮)</span>';
|
|
806
|
+
} else if(bgRunning>0){
|
|
807
|
+
html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中 — 点击查看</span>';
|
|
808
|
+
}
|
|
809
|
+
el.style.display=html?'flex':'none';
|
|
810
|
+
el.innerHTML=html;
|
|
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
|
+
}
|
|
813
|
+
// 轨迹元行:任务完成后附在本轮消息顶部(步数/子代理数,点击打开轨迹面板)
|
|
814
|
+
function attachTrajMeta(msg){
|
|
815
|
+
if(!msg || !msg._traj || !msg._traj.length) return;
|
|
816
|
+
const tools=msg._traj.filter(x=>x.kind==='tool');
|
|
817
|
+
const subs=tools.filter(x=>x.name==='task');
|
|
818
|
+
const meta=document.createElement('div'); meta.className='msg-meta';
|
|
819
|
+
const b=document.createElement('button'); b.className='trajBtn';
|
|
820
|
+
b.textContent='🧭 轨迹:'+(msg._steps||tools.length)+' 步 · '+subs.length+' 个子代理';
|
|
821
|
+
b.onclick=()=>openTraj(msg);
|
|
822
|
+
meta.appendChild(b);
|
|
823
|
+
msg.insertBefore(meta, msg.firstChild);
|
|
824
|
+
}
|
|
825
|
+
function openTraj(msg){
|
|
826
|
+
const list=$('#tjList'); list.innerHTML='';
|
|
827
|
+
const tools=msg._traj.filter(x=>x.kind==='tool');
|
|
828
|
+
const subs=tools.filter(x=>x.name==='task');
|
|
829
|
+
$('#tjSummary').textContent='('+(msg._steps||tools.length)+' 步 · '+subs.length+' 个子代理)';
|
|
830
|
+
let turnN=0;
|
|
831
|
+
for(const e of msg._traj){
|
|
832
|
+
if(e.kind==='turn'){ turnN+=1;
|
|
833
|
+
const t=document.createElement('div'); t.className='tj-turn'; t.textContent='第 '+turnN+' 回合'; list.appendChild(t);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const isSub=e.name==='task';
|
|
837
|
+
const ico={'read':'📄','write':'✏️','edit':'✎','bash':'⚙','grep':'🔎','glob':'🔎','ls':'📁','task':'🤖','skill':'🧩','todo':'☑','undo':'↩'}[e.name]||'🔌';
|
|
838
|
+
const args = e.name==='bash' ? (e.args&&e.args.command) : (e.args&&(e.args.path||e.args.pattern||e.args.name||e.args.question||''))||'';
|
|
839
|
+
const div=document.createElement('div'); div.className='tj-item'+(isSub?' tj-sub':'');
|
|
840
|
+
const subLabel=isSub?'子代理:':'';
|
|
841
|
+
div.innerHTML='<div class="tj-head"><span>'+ico+'</span><span style="white-space:nowrap">'+esc(subLabel+e.name)+'</span><span class="tj-args">'+esc(String(args).slice(0,80))+'</span><span style="margin-left:auto;color:var(--faint)">'+(e.done?(e.denied?'✖ '+esc(e.denied):'✓'+(e.durationMs!=null?' '+e.durationMs+'ms':'')):'执行中…')+'</span></div>';
|
|
842
|
+
const body=document.createElement('div'); body.className='tj-body';
|
|
843
|
+
if(isSub){
|
|
844
|
+
body.textContent='任务:'+(e.args&&(e.args.question||e.args.prompt))+'\n\n结果:'+truncText(resultText(e.result), 1500);
|
|
845
|
+
}else if(e.result!==undefined){
|
|
846
|
+
body.textContent='参数:'+truncText(JSON.stringify(e.args||{}), 500)+'\n\n结果:'+truncText(resultText(e.result), 1500);
|
|
847
|
+
}else{
|
|
848
|
+
body.textContent='参数:'+truncText(JSON.stringify(e.args||{}), 500);
|
|
849
|
+
}
|
|
850
|
+
div.appendChild(body);
|
|
851
|
+
div.onclick=(ev)=>{ if(ev.target.closest('.tj-body')) return; div.classList.toggle('open'); };
|
|
852
|
+
// 点击标题定位到聊天中的对应工具卡
|
|
853
|
+
list.appendChild(div);
|
|
854
|
+
}
|
|
855
|
+
$('#trajPanel').style.display='flex';
|
|
856
|
+
$('#tasksPanel').style.display='none';
|
|
857
|
+
}
|
|
858
|
+
function resultText(r){
|
|
859
|
+
if(r==null||r===undefined) return '';
|
|
860
|
+
if(typeof r==='string') return r;
|
|
861
|
+
if(r.output) return String(r.output);
|
|
862
|
+
if(r.stdout||r.stderr) return String(r.stdout||'')+String(r.stderr||'');
|
|
863
|
+
return JSON.stringify(r);
|
|
864
|
+
}
|
|
865
|
+
function truncText(t,n){ t=String(t||''); return t.length>n?t.slice(0,n)+'\n…(截断,共 '+t.length+' 字)':t; }
|
|
866
|
+
$('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; };
|
|
757
867
|
// —— 任务面板(多会话并行) ——
|
|
758
868
|
function setBtn(){
|
|
759
869
|
sendBtn.textContent = generating ? '■ 停止' : '发送';
|
|
@@ -762,6 +872,7 @@ function setBtn(){
|
|
|
762
872
|
async function updateTasksPanel(){
|
|
763
873
|
const r=await fetch('/api/tasks').catch(()=>null); if(!r) return;
|
|
764
874
|
const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
|
|
875
|
+
bgRunning=Number(j.running)||0; renderWorkStatus();
|
|
765
876
|
$('#tpCount').textContent='('+j.running+' 运行中 / 上限 '+j.maxConcurrent+')';
|
|
766
877
|
// 审计(第二问无反应修复):generating 改为「本轮在途」本地状态,由 send/finally 维护,
|
|
767
878
|
// 不再由 /api/tasks 轮询推导——任务面板瞬时波动或一次轮询失败曾让按钮永久卡在停止态,
|
|
@@ -1204,7 +1315,7 @@ $('#modelSel').addEventListener('change', e=>{ applyConfig({model:e.target.value
|
|
|
1204
1315
|
$('#permSel').addEventListener('change', e=>{ applyConfig({permission:e.target.value}, e.target); });
|
|
1205
1316
|
async function loadSession(file){ currentSession=file; chatEl.innerHTML=''; const r=await fetch('/api/session?file='+encodeURIComponent(file)); const j=await r.json(); for(const m of j.messages||[]){ if(typeof m.content==='string'&&m.content.startsWith('(系统提示)')) continue; if(m.role==='user') addUser(m.content); else { const el=newAiMsg(); aiContent(el).innerHTML=renderMarkdown(m.content); } } if(j.workspace){ const sel=$('#wsSel'); const has=[...sel.options].some(o=>o.value===j.workspace); if(has){ sel.value=j.workspace; } else { refreshWsSel(); } renderBanner({text:'↩ 已回到该会话的工作空间:'+j.workspace}); } scrollBottom(); }
|
|
1206
1317
|
|
|
1207
|
-
sendBtn.onclick=send;
|
|
1318
|
+
sendBtn.onclick=()=>{ if(generating){ abort(); } else { send(); } };
|
|
1208
1319
|
input.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
|
|
1209
1320
|
input.addEventListener('input',()=>{ input.style.height='44px'; input.style.height=Math.min(input.scrollHeight,200)+'px'; });
|
|
1210
1321
|
$('#newChat').onclick=()=>{ currentSession=null; chatEl.innerHTML=''; refreshSessions(); };
|
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);
|
|
@@ -270,7 +270,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
270
270
|
let routeReason = null;
|
|
271
271
|
if (routingConfig(cfg)) {
|
|
272
272
|
try {
|
|
273
|
-
const route = await routeTask({
|
|
273
|
+
const route = await routeTask({
|
|
274
|
+
cfg,
|
|
275
|
+
provider,
|
|
276
|
+
currentModel: modelName,
|
|
277
|
+
text: built.persistText || userMessage,
|
|
278
|
+
sticky: session.lastRoute || null,
|
|
279
|
+
sessionStats: session.routeStats || null, // 会话级步数/截断统计(路由升级检测)
|
|
280
|
+
});
|
|
274
281
|
session.lastRoute = route.model;
|
|
275
282
|
if (route.model !== modelName) {
|
|
276
283
|
runModel = route.model;
|
|
@@ -375,6 +382,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
375
382
|
try {
|
|
376
383
|
const r = await agent.runTurn(messages);
|
|
377
384
|
srvlog('chat 回合完成 ' + taskId + ' text=' + String(r.text || '').length + ' ' + (Date.now() - entry.startedAt) + 'ms');
|
|
385
|
+
// 会话级路由统计(Hermes C2 升级检测):累计工具步数与截断次数,粘滞 flash 会话复杂度上来后自动升 planner
|
|
386
|
+
const st = session.routeStats || { steps: 0, truncated: 0 };
|
|
387
|
+
session.routeStats = { steps: st.steps + (io.stats().toolCount || 0), truncated: st.truncated + (r.truncated ? 1 : 0) };
|
|
378
388
|
appendMessages(session.file, messages.slice(persistedBefore));
|
|
379
389
|
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
380
390
|
recordUsage(runModel, r.usage, r.perf);
|
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) });
|