mingdao-harness 0.1.66 → 0.1.67

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  ## 为什么选 MingDao
8
8
 
9
9
  - ⚡ **真正的零依赖**:纯 Node.js ≥ 18,无任何 npm 运行时依赖、无构建步骤——装完即用,不拖 node_modules
10
- - 💰 **DeepSeek-V4 深度省钱**:384K 上下文预设、**缓存命中计价**(命中价仅为未命中的 1/30)+ 命中率仪表盘、峰谷计价(高峰=北京工作日 9:00–12:00、14:00–18:00,闲时半价自动识别)、自动路由(pro 规划 / flash 执行 + 分类缓存 + 会话粘滞)、精确 tokenizer(官方词表 BPE)、滞回自动压缩、**Batch API 半价批处理**(`mingdao batch`)、**避峰调度**(`--offpeak` 高峰顺延到最近闲时 12:00/18:00)、**费用护栏**(每日上限防超支)
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 生态预设
@@ -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(正式版规格:2026-08-17 V4-Pro 转正商用),预算可随时调高。
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~0.2.0,每项半天~1 天)
48
- 1. **思考强度三档透传**(Kimi P1-B,单点收益最高):`models.js` 加 `reasoningEffort`、Provider 透传 `reasoning_effort`,辅助调用(标题/记忆/分类器)固定 `low`,`/think low|high|max` 切换。预期简单问答输出 token 降 50%+。
49
- 2. **上下文窗口字段修正**(Kimi P1-C):`contextWindow` 1M、新增 `maxOutputCeiling`、pro 默认 `maxOutputTokens` 65536,减少大文件生成的续写重复计费。
50
- 3. **价格表外置 + 自动刷新**(Hermes C1):内置表兜底 + `~/.mingdao/pricing.json` 官方价跟随(TTL 7 天),`/cost` 在数据时点超 60 天时提示"价格表可能过期"。
51
- 4. **轻量语义回收**(Hermes B1):预算 80% 触发 O(1) 回收(最老 tool 消息 → 摘要行、旧 assistant 长文截前 200 字),不破坏前缀,重压缩线下移到 90%。
52
- 5. **路由升级检测 + 第三态**(Hermes C2 / Kimi):粘滞会话内按生成词/截断次数/工具步数允许升 planner;分类器"不确定"→ 保守走 planner。
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 实为最大输出规格 → 已列入近期路线第 2 条。
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.66",
3
+ "version": "0.1.67",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -26,6 +26,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
26
26
  const budget = cfg.contextBudget || preset.budgetTokens || 128000;
27
27
  const maxOutput = cfg.maxOutputTokens || preset.maxOutputTokens || 8192;
28
28
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
29
+ const reasoningEffort = cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
29
30
  const hooks = createHooks(cfg.hooks, workingDir);
30
31
  const todos = [];
31
32
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
@@ -189,6 +190,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
189
190
  tools: [...toolSchemas(), ...mcpSchemas()],
190
191
  temperature,
191
192
  maxTokens: maxOutput,
193
+ reasoningEffort,
192
194
  signal: ac.signal,
193
195
  onDelta(d) {
194
196
  io.stopSpinner();
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));
@@ -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/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
- let total = system.reduce((s, m) => s + messageTokens(m, count), 0);
69
- const tail = [];
70
- for (let i = rest.length - 1; i >= 0; i--) {
71
- const t = messageTokens(rest[i], count);
72
- if (total + t > budget) break;
73
- total += t;
74
- tail.unshift(rest[i]);
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
- const kept = cleanToolPairing(system.concat(tail));
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 上下文,峰谷定价(高峰=北京工作日 9:00–12:00、14:00–18:00,闲时价=高峰一半),支持工具调用 / Responses API / Anthropic 兼容接口。',
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: 384000, // 正式版规格:384K 上下文(2026-08-17 起商用)
56
+ contextWindow: 1000000, // 官方上下文 1M(审计 Kimi P1-C:384K 实为单次最大输出,字段语义修正)
57
+ maxOutputCeiling: 384000, // 单次最大输出上限(官方规格)
57
58
  budgetTokens: 200000, // 默认注入预算(省钱省时,可调高)
58
- maxOutputTokens: 32768,
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: 384000, // 正式版规格:384K 上下文
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: 384000,
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
- if (!preset?.pricing) return null;
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 = (base, o = {}) => ({
148
- input: Number(o.input ?? base?.input ?? 0),
149
- output: Number(o.output ?? base?.output ?? 0),
150
- cacheHit: Number(o.cacheHit ?? base?.cacheHit ?? 0),
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(preset.pricing.offpeak, over),
154
- peak: merge(preset.pricing.peak || preset.pricing.offpeak, over.peak),
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
@@ -37,7 +37,7 @@ export function heuristicRoute(text, rc) {
37
37
  const ROUTE_CACHE_MAX = 100;
38
38
  const routeCache = new Map();
39
39
 
40
- export async function routeTask({ cfg, provider, currentModel, text, sticky = null }) {
40
+ export async function routeTask({ cfg, provider, currentModel, text, sticky = null, sessionStats = null }) {
41
41
  const rc = routingConfig(cfg);
42
42
  if (!rc) return { model: currentModel, reason: null };
43
43
  // 当前模型已在路由池外(用户手动指定):不干预
@@ -52,8 +52,19 @@ export async function routeTask({ cfg, provider, currentModel, text, sticky = nu
52
52
  : { model: quick, reason: quick === rc.planner ? '规划类任务' : '执行类任务' };
53
53
  }
54
54
 
55
- // 会话粘滞:执行类会话不再逐轮分类(生成类关键词已在启发式拦截)
55
+ // 会话粘滞 + 升级检测(Hermes C2):执行类会话不再逐轮分类(生成类关键词已在启发式拦截),
56
+ // 但会话内复杂度信号累积(工具步数/截断次数)时允许升级 planner——「开头简单、中途复杂」不再被 flash 硬扛。
56
57
  if (sticky === rc.executor) {
58
+ const stats = sessionStats || {};
59
+ const steps = Number(stats.steps) || 0;
60
+ const truncated = Number(stats.truncated) || 0;
61
+ const UPGRADE_STEPS = Number(cfg?.routing?.upgradeSteps) || 10;
62
+ const UPGRADE_TRUNCATED = Number(cfg?.routing?.upgradeTruncated) || 2;
63
+ if (steps >= UPGRADE_STEPS || truncated >= UPGRADE_TRUNCATED) {
64
+ return rc.planner === currentModel
65
+ ? { model: currentModel, reason: null }
66
+ : { model: rc.planner, reason: `会话升级:执行类已累计 ${steps} 步工具 / ${truncated} 次截断 → planner` };
67
+ }
57
68
  return rc.executor === currentModel
58
69
  ? { model: currentModel, reason: null }
59
70
  : { model: rc.executor, reason: '会话粘滞:执行类' };
@@ -90,6 +101,7 @@ export async function routeTask({ cfg, provider, currentModel, text, sticky = nu
90
101
  tools: [],
91
102
  temperature: 0,
92
103
  maxTokens: 20, // 结构化输出(评估 4.2-4):80→20
104
+ reasoningEffort: 'low',
93
105
  responseFormat: { type: 'json_object' },
94
106
  });
95
107
  let verdict = null;
@@ -103,6 +115,11 @@ export async function routeTask({ cfg, provider, currentModel, text, sticky = nu
103
115
  if (t.includes('plan')) verdict = 'plan';
104
116
  else if (t.includes('exec')) verdict = 'execute';
105
117
  }
118
+ if (!verdict) {
119
+ // 第三态(Kimi C2):分类器给不出结论 → 保守走 planner,避免复杂任务被 flash 硬扛
120
+ const m = rc.planner;
121
+ return m === currentModel ? { model: currentModel, reason: null } : { model: m, reason: '分类器不确定:保守走 planner' };
122
+ }
106
123
  if (verdict) {
107
124
  if (routeCache.size >= ROUTE_CACHE_MAX) {
108
125
  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);
@@ -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
+ #workStatus{display:flex;align-items:center;gap:10px;padding:6px 14px;background:var(--panel);border-top:1px solid var(--border);font-size:12.5px;color:var(--dim);flex:none}
45
+ #workStatus .ws-busy{color:var(--accent2);display:flex;align-items:center;gap:8px;flex:1;min-width:0}
46
+ #workStatus .ws-bg{cursor:pointer;color:var(--warn);white-space:nowrap}
47
+ #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
+ /* 消息顶部轨迹行(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
+ /* 轨迹面板(与任务面板互斥展示) */
54
+ #trajPanel{position:fixed;right:0;top:52px;bottom:0;width:330px;background:var(--bg2);border-left:1px solid var(--border);z-index:40;display:flex;flex-direction:column}
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
- msg.appendChild(card);
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||''; chatEl.appendChild(d); scrollBottom(); }
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){ console.log('[MingDao] send 被生成态拦截 → 走 abort'); abort(); return; } // 生成中:同一按钮承担停止职责
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; if(think.parentNode) think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 执行 '+ev.steps+' 步…'; }
778
+ if(ev.type==='progress'){ const p=ev.seconds||0; if(think.parentNode) think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 执行 '+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==='toolStart'){ stepsCount+=1; onActivity(); update(); renderToolStartEvent(ev); }
742
- else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; content.appendChild(pre); scroll(); }
743
- else if(ev.type==='tool'){ onActivity(); update(); const pending=runningTools.get(ev.seq); if(pending){ pending.remove(); runningTools.delete(ev.seq); } renderToolEvent(ev); }
744
- else if(ev.type==='toolDenied'){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); 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(); }
782
+ 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
+ else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; insertBeforeActiveMsg(pre); scroll(); }
784
+ 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); }
785
+ 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
786
  else if(ev.type==='banner'){ renderBanner(ev); }
746
787
  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.appendChild(u); scroll(); }
788
+ 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
789
  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
790
  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
791
  });
751
792
  }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(); }
793
+ 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
794
  }
754
795
 
755
796
  function abort(){ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(()=>updateTasksPanel()).catch(()=>{}); }
756
797
 
798
+ // —— 输入框上方工作状态条(审计:长任务静默硬伤 → 实时进度可见) ——
799
+ function renderWorkStatus(){
800
+ const el=$('#workStatus'); if(!el) return;
801
+ let html='';
802
+ if(generating){
803
+ 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)+' 秒 — 完成后总结与交付物显示在本轮消息底部</span><span class="ws-stop">■ 停止</span>';
805
+ } else if(bgRunning>0){
806
+ html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中 — 点击查看</span>';
807
+ }
808
+ el.style.display=html?'flex':'none';
809
+ el.innerHTML=html;
810
+ const stop=el.querySelector('.ws-stop'); if(stop) stop.onclick=()=>abort();
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
@@ -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({ cfg, provider, currentModel: modelName, text: built.persistText || userMessage, sticky: session.lastRoute || null });
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);