mingdao-harness 0.2.3 → 0.2.5

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.
Files changed (65) hide show
  1. package/docs/ROADMAP-NEXT.md +13 -13
  2. package/package.json +1 -1
  3. package/skills/release-checklist/SKILL.md +16 -3
  4. package/src/agent.js +102 -30
  5. package/src/audit.js +2 -2
  6. package/src/batch.js +74 -16
  7. package/src/cachestats.js +57 -11
  8. package/src/cli.js +29 -679
  9. package/src/commands/desktop.js +1 -1
  10. package/src/commands/key.js +1 -1
  11. package/src/commands/repl.js +711 -0
  12. package/src/commands/schedule.js +7 -7
  13. package/src/commands/skill.js +18 -16
  14. package/src/commands/sync.js +5 -5
  15. package/src/commands/update.js +22 -9
  16. package/src/commands/workspace.js +2 -2
  17. package/src/compact.js +6 -6
  18. package/src/config.js +7 -4
  19. package/src/context.js +11 -11
  20. package/src/cost-guard.js +15 -5
  21. package/src/credentials.js +6 -0
  22. package/src/hooks.js +11 -11
  23. package/src/mcp-presets.js +5 -5
  24. package/src/mcp.js +19 -19
  25. package/src/memory.js +16 -16
  26. package/src/model-discovery.js +16 -15
  27. package/src/models.js +4 -4
  28. package/src/notify.js +2 -2
  29. package/src/permissions.js +7 -3
  30. package/src/pricing.js +35 -1
  31. package/src/prompts.js +1 -1
  32. package/src/providers/index.js +7 -7
  33. package/src/providers/openai-compatible.js +8 -8
  34. package/src/routing.js +5 -5
  35. package/src/schedule.js +28 -28
  36. package/src/session-index.js +5 -1
  37. package/src/session.js +11 -2
  38. package/src/skill-lib.js +62 -3
  39. package/src/skill-registry.js +10 -9
  40. package/src/skills.js +10 -10
  41. package/src/sync-server.js +86 -3
  42. package/src/sync.js +53 -20
  43. package/src/tasks/worker.js +126 -0
  44. package/src/tasks.js +13 -13
  45. package/src/titles.js +5 -5
  46. package/src/tokenizer.js +22 -4
  47. package/src/tools/bash.js +12 -12
  48. package/src/tools/fs-tools.js +66 -9
  49. package/src/tools/index.js +77 -36
  50. package/src/ui.js +83 -6
  51. package/src/update.js +8 -8
  52. package/src/web/app.js +43 -0
  53. package/src/web/attachments.js +1 -1
  54. package/src/web/index.html +12 -0
  55. package/src/web/routes/api.js +38 -786
  56. package/src/web/routes/domains/config.js +280 -0
  57. package/src/web/routes/domains/misc.js +128 -0
  58. package/src/web/routes/domains/schedule.js +127 -0
  59. package/src/web/routes/domains/sessions.js +105 -0
  60. package/src/web/routes/domains/skills.js +79 -0
  61. package/src/web/routes/domains/sync.js +101 -0
  62. package/src/web/routes/domains/workspace.js +113 -0
  63. package/src/web/server.js +29 -14
  64. package/src/web/web-io.js +54 -12
  65. package/src/workspace.js +14 -14
package/src/ui.js CHANGED
@@ -26,6 +26,10 @@ export const C = {
26
26
 
27
27
  const stdoutTTY = () => Boolean(process.stdout.isTTY);
28
28
 
29
+ /**
30
+ * @param {any} text
31
+ * @param {any} code
32
+ */
29
33
  export function style(text, code) {
30
34
  if (!stdoutTTY()) {
31
35
  // 非 TTY(管道/重定向):剥离所有内嵌 ANSI 码,保证输出干净可解析
@@ -35,10 +39,11 @@ export function style(text, code) {
35
39
  }
36
40
 
37
41
  // ---------- 终端显示宽度(CJK 占 2 列) ----------
42
+ /** @param {any} s */
38
43
  function displayWidth(s) {
39
44
  let w = 0;
40
45
  for (const ch of String(s)) {
41
- const c = ch.codePointAt(0);
46
+ const c = /** @type {any} */ (ch.codePointAt(0));
42
47
  const wide =
43
48
  c >= 0x1100 &&
44
49
  (c <= 0x115f ||
@@ -57,10 +62,18 @@ function displayWidth(s) {
57
62
  return w;
58
63
  }
59
64
 
65
+ /**
66
+ * @param {any} s
67
+ * @param {any} w
68
+ */
60
69
  function padTo(s, w) {
61
70
  return String(s) + ' '.repeat(Math.max(0, w - displayWidth(s)));
62
71
  }
63
72
 
73
+ /**
74
+ * @param {any} s
75
+ * @param {any} prefix
76
+ */
64
77
  function indent(s, prefix) {
65
78
  return String(s)
66
79
  .split('\n')
@@ -70,6 +83,7 @@ function indent(s, prefix) {
70
83
 
71
84
  // ---------- 轻量代码高亮 ----------
72
85
  const HASH_LANGS = new Set(['py', 'python', 'sh', 'bash', 'yaml', 'yml', 'toml', 'markdown', 'md']);
86
+ /** @type {Record<string, string[]>} */
73
87
  const LANG_KEYWORDS = {
74
88
  js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'import', 'from', 'export', 'default', 'class', 'extends', 'new', 'await', 'async', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'null', 'undefined', 'true', 'false', 'this', 'super', 'switch', 'case', 'break', 'continue', 'do', 'of', 'in', 'static', 'get', 'set', 'delete', 'void', 'yield'],
75
89
  ts: ['interface', 'type', 'enum', 'implements', 'readonly', 'private', 'public', 'protected', 'namespace', 'declare', 'as', 'any', 'unknown', 'never', 'string', 'number', 'boolean'],
@@ -83,6 +97,10 @@ const LANG_KEYWORDS = {
83
97
  yaml: [],
84
98
  };
85
99
 
100
+ /**
101
+ * @param {any} code
102
+ * @param {any} lang
103
+ */
86
104
  export function highlightCode(code, lang = '') {
87
105
  if (!stdoutTTY()) return code;
88
106
  const kws = new Set([...(LANG_KEYWORDS[lang] || []), ...(LANG_KEYWORDS[lang.replace(/-.*/, '')] || [])]);
@@ -117,6 +135,7 @@ export function highlightCode(code, lang = '') {
117
135
  }
118
136
 
119
137
  // ---------- Markdown 行内样式 ----------
138
+ /** @param {any} s */
120
139
  function inline(s) {
121
140
  const parts = [];
122
141
  const re = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*)/g;
@@ -134,6 +153,7 @@ function inline(s) {
134
153
  return parts.join('');
135
154
  }
136
155
 
156
+ /** @param {any} line */
137
157
  function renderInline(line) {
138
158
  const trimmed = line.trim();
139
159
  if (/^#{1,6}\s/.test(trimmed)) return style(inline(line), C.bold + C.cyan);
@@ -145,13 +165,16 @@ function renderInline(line) {
145
165
  }
146
166
 
147
167
  // ---------- 流式渲染器 ----------
168
+ /** @param {any} io */
148
169
  function createStreamRenderer(io) {
149
170
  let buf = '';
150
171
  let inCode = false;
151
172
  let lang = '';
173
+ /** @type {any[]} */
152
174
  let codeLines = [];
153
175
  let streamed = false;
154
176
 
177
+ /** @param {any} line */
155
178
  function renderLine(line) {
156
179
  const trimmed = line.trim();
157
180
  if (trimmed.startsWith('```')) {
@@ -174,6 +197,7 @@ function createStreamRenderer(io) {
174
197
  }
175
198
 
176
199
  return {
200
+ /** @param {any} text */
177
201
  push(text) {
178
202
  streamed = true;
179
203
  buf += String(text);
@@ -202,6 +226,10 @@ function createStreamRenderer(io) {
202
226
  }
203
227
 
204
228
  // ---------- 行级 diff(编辑预览用,仅小区域) ----------
229
+ /**
230
+ * @param {any} a
231
+ * @param {any} b
232
+ */
205
233
  function diffLines(a, b) {
206
234
  const n = a.length;
207
235
  const m = b.length;
@@ -253,6 +281,7 @@ const COMMANDS = [
253
281
  '/quit',
254
282
  ];
255
283
 
284
+ /** @param {any} line */
256
285
  function completeLine(line) {
257
286
  if (line.startsWith('/model ') && line.length >= 7) {
258
287
  const pre = line.slice(7);
@@ -261,6 +290,10 @@ function completeLine(line) {
261
290
  return [COMMANDS.filter((c) => c.startsWith(line)), line];
262
291
  }
263
292
 
293
+ /**
294
+ * @param {any} name
295
+ * @param {any} args
296
+ */
264
297
  function summarizeArgs(name, args) {
265
298
  try {
266
299
  if (name === 'bash') return ` ${args.command ?? ''}`;
@@ -274,12 +307,19 @@ function summarizeArgs(name, args) {
274
307
 
275
308
  // ---------- io 工厂 ----------
276
309
  export function createIO({ quiet = false } = {}) {
310
+ /** @type {any} */
277
311
  let rl = null;
312
+ /** @type {any} */
278
313
  let spinnerTimer = null;
314
+ /** @type {any} */
279
315
  let renderer = null;
316
+ /** @type {any[]} */
280
317
  let askQueue = []; // 非 TTY:挂起的输入请求
318
+ /** @type {any[]} */
281
319
  let lineQueue = []; // 非 TTY:已缓冲但未被消费的行
320
+ /** @type {any} */
282
321
  let sigintHandler = null; // TTY 下 rl 'SIGINT' 事件转发目标
322
+ /** @type {any} */
283
323
  let ttyPending = null;
284
324
  let closed = false;
285
325
 
@@ -291,6 +331,7 @@ export function createIO({ quiet = false } = {}) {
291
331
  isTTY: Boolean(process.stdin.isTTY),
292
332
  showReasoning: true,
293
333
 
334
+ /** @param {any} v */
294
335
  setShowReasoning(v) {
295
336
  io.showReasoning = !!v;
296
337
  },
@@ -304,7 +345,7 @@ export function createIO({ quiet = false } = {}) {
304
345
  output: process.stdout,
305
346
  terminal,
306
347
  historySize: 200,
307
- completer: (line) => completeLine(line),
348
+ completer: (/** @type {any} */ line) => completeLine(line),
308
349
  });
309
350
  if (terminal) {
310
351
  // 关键:TTY 下 Ctrl+C 由 readline 捕获(raw 模式),需要在这里转发给中断处理器;
@@ -321,7 +362,7 @@ export function createIO({ quiet = false } = {}) {
321
362
  }
322
363
  if (!terminal) {
323
364
  // 非 TTY(管道输入):持久 line 监听 + 队列,避免 rl.question 丢弃首行之后的输入
324
- rl.on('line', (line) => {
365
+ rl.on('line', (/** @type {any} */ line) => {
325
366
  const trimmed = line.trim();
326
367
  const pending = askQueue.shift();
327
368
  if (pending) pending.resolve(trimmed);
@@ -344,6 +385,7 @@ export function createIO({ quiet = false } = {}) {
344
385
  return rl;
345
386
  },
346
387
 
388
+ /** @param {any} lines */
347
389
  setHistory(lines) {
348
390
  try {
349
391
  const r = io.ensureRl();
@@ -357,6 +399,10 @@ export function createIO({ quiet = false } = {}) {
357
399
  if (!quiet) console.log(text);
358
400
  },
359
401
 
402
+ /**
403
+ * @param {any} title
404
+ * @param {any} lines
405
+ */
360
406
  box(title, lines) {
361
407
  if (quiet) return;
362
408
  if (!stdoutTTY()) {
@@ -389,6 +435,7 @@ export function createIO({ quiet = false } = {}) {
389
435
  io.stopSpinner();
390
436
  },
391
437
 
438
+ /** @param {any} label */
392
439
  startSpinner(label) {
393
440
  if (quiet || !stdoutTTY()) return;
394
441
  io.stopSpinner();
@@ -408,6 +455,7 @@ export function createIO({ quiet = false } = {}) {
408
455
  }
409
456
  },
410
457
 
458
+ /** @param {any} text */
411
459
  writeText(text) {
412
460
  if (quiet) return;
413
461
  if (io._reasoningStarted && !io._answerStarted && stdoutTTY()) {
@@ -417,6 +465,7 @@ export function createIO({ quiet = false } = {}) {
417
465
  renderer?.push(String(text));
418
466
  },
419
467
 
468
+ /** @param {any} text */
420
469
  writeReasoning(text) {
421
470
  if (quiet || !io.showReasoning) return;
422
471
  if (!io._reasoningStarted) {
@@ -426,6 +475,7 @@ export function createIO({ quiet = false } = {}) {
426
475
  process.stdout.write(style(String(text), C.dim));
427
476
  },
428
477
 
478
+ /** @param {any} todos */
429
479
  renderTodo(todos) {
430
480
  if (quiet || !todos?.length) return;
431
481
  io.print(style(' ☑ 任务清单', C.bold));
@@ -436,6 +486,10 @@ export function createIO({ quiet = false } = {}) {
436
486
  }
437
487
  },
438
488
 
489
+ /**
490
+ * @param {any} code
491
+ * @param {any} lang
492
+ */
439
493
  printCodeBlock(code, lang) {
440
494
  if (quiet) return;
441
495
  const label = lang || 'code';
@@ -446,6 +500,12 @@ export function createIO({ quiet = false } = {}) {
446
500
  },
447
501
 
448
502
  // —— 工具执行可视化 ——
503
+ /**
504
+ * @param {any} name
505
+ * @param {any} args
506
+ * @param {any} result
507
+ * @param {any} durationMs
508
+ */
449
509
  renderTool(name, args, result, durationMs) {
450
510
  if (quiet) return;
451
511
  const r = result && typeof result === 'object' ? result : { ok: false, error: String(result ?? '') };
@@ -525,6 +585,11 @@ export function createIO({ quiet = false } = {}) {
525
585
  io.print(style(` ✓ 输出 ${out.length} 字符${ms}`, C.green));
526
586
  },
527
587
 
588
+ /**
589
+ * @param {any} name
590
+ * @param {any} args
591
+ * @param {any} reason
592
+ */
528
593
  renderToolDenied(name, args, reason) {
529
594
  if (quiet) return;
530
595
  const label = reason === '未授权' || !reason ? '未授权,已跳过' : reason;
@@ -532,11 +597,12 @@ export function createIO({ quiet = false } = {}) {
532
597
  },
533
598
 
534
599
  // —— 用量与费用状态行 ——
600
+ /** @param {{ modelName: any, usage: any, durationMs: any }} param0 */
535
601
  printUsageLine({ modelName, usage, durationMs }) {
536
602
  if (quiet) return;
537
603
  const p = usage?.prompt_tokens ?? 0;
538
604
  const c = usage?.completion_tokens ?? 0;
539
- const fmt = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
605
+ const fmt = (/** @type {any} */ n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n));
540
606
  const secs = ((durationMs ?? 0) / 1000).toFixed(1) + 's';
541
607
  const costPart = estimateCostLabel(modelName, p, c, usage);
542
608
  io.print(
@@ -550,6 +616,7 @@ export function createIO({ quiet = false } = {}) {
550
616
  // —— SIGINT(Ctrl+C 中断生成,不退出程序) ——
551
617
  // TTY 下 readline raw 模式会把 Ctrl+C 转成 rl 的 'SIGINT' 事件(ensureRl 转发到 sigintHandler),
552
618
  // 非 TTY/其他来源走 process 级 SIGINT;两层都注册,保证任何路径都能中断
619
+ /** @param {any} fn */
553
620
  onSigint(fn) {
554
621
  const h = () => {
555
622
  try {
@@ -564,6 +631,10 @@ export function createIO({ quiet = false } = {}) {
564
631
  };
565
632
  },
566
633
 
634
+ /**
635
+ * @param {any} question
636
+ * @param {any} opts
637
+ */
567
638
  ask(question, opts = {}) {
568
639
  if (!process.stdin.isTTY) {
569
640
  // 非 TTY:优先消费已缓冲的行(接口关闭后仍可能有剩余行),不打印提示符
@@ -592,7 +663,7 @@ export function createIO({ quiet = false } = {}) {
592
663
  }
593
664
  const entry = { resolve, restore };
594
665
  ttyPending = entry;
595
- r.question(question, (answer) => {
666
+ r.question(question, (/** @type {any} */ answer) => {
596
667
  restore();
597
668
  if (ttyPending === entry) ttyPending = null;
598
669
  resolve(answer.trim());
@@ -601,6 +672,7 @@ export function createIO({ quiet = false } = {}) {
601
672
  },
602
673
 
603
674
  // 行尾反斜杠续行,支持多行输入
675
+ /** @param {any} prompt */
604
676
  async askMultiline(prompt, continuation = '…> ') {
605
677
  const lines = [];
606
678
  for (;;) {
@@ -615,14 +687,19 @@ export function createIO({ quiet = false } = {}) {
615
687
  }
616
688
  },
617
689
 
690
+ /** @param {any} question */
618
691
  async confirm(question) {
619
692
  const a = await io.ask(question + ' ');
620
693
  return /^y(es)?$/i.test(a);
621
694
  },
622
695
 
696
+ /**
697
+ * @param {any} label
698
+ * @param {any} options
699
+ */
623
700
  async choose(label, options) {
624
701
  io.print(style(label, C.bold));
625
- options.forEach((o, i) => io.print(` ${style(String(i + 1), C.cyan)}. ${o.label}`));
702
+ options.forEach((/** @type {any} */ o, /** @type {any} */ i) => io.print(` ${style(String(i + 1), C.cyan)}. ${o.label}`));
626
703
  for (;;) {
627
704
  const ans = await io.ask('请输入序号:');
628
705
  const n = Number(ans);
package/src/update.js CHANGED
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { mingdaoHome, ensureHome } from './config.js';
13
13
  import { atomicWriteFileSync } from './atomic-write.js';
14
14
 
15
- function git(args, cwd) {
15
+ function git(/** @type {any} */ args, /** @type {any} */ cwd) {
16
16
  const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120000 });
17
17
  return {
18
18
  ok: r.status === 0 && !r.error,
@@ -36,11 +36,11 @@ export function findRepoRoot() {
36
36
  return null;
37
37
  }
38
38
 
39
- function resolveRepo(override) {
39
+ function resolveRepo(/** @type {any} */ override) {
40
40
  return override || findRepoRoot();
41
41
  }
42
42
 
43
- function versionAt(repo, ref = 'HEAD') {
43
+ function versionAt(/** @type {any} */ repo, ref = 'HEAD') {
44
44
  const r = git(['show', `${ref}:package.json`], repo);
45
45
  if (!r.ok) return null;
46
46
  try {
@@ -50,7 +50,7 @@ function versionAt(repo, ref = 'HEAD') {
50
50
  }
51
51
  }
52
52
 
53
- function compareVersions(a, b) {
53
+ function compareVersions(/** @type {any} */ a, /** @type {any} */ b) {
54
54
  const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
55
55
  const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
56
56
  for (let i = 0; i < 3; i++) {
@@ -71,7 +71,7 @@ function readState() {
71
71
  return null;
72
72
  }
73
73
  }
74
- function writeState(s) {
74
+ function writeState(/** @type {any} */ s) {
75
75
  ensureHome();
76
76
  atomicWriteFileSync(stateFile(), JSON.stringify(s, null, 2) + '\n', { mode: 0o600 }); // 质检 H4:更新状态原子写
77
77
  }
@@ -79,20 +79,20 @@ function writeState(s) {
79
79
  // 直接向远端指定分支要版本(评估 D6):fetch 到 FETCH_HEAD 再读 package.json,
80
80
  // 不依赖本地 refs 落盘——部分 Windows git 环境存在「fetch 成功但 refs 不更新」的异常,
81
81
  // 按本地 refs 判断会误报「已是最新」。ls-remote 思路的等价实现。
82
- function remoteVersion(repo, remote, branch) {
82
+ function remoteVersion(/** @type {any} */ repo, /** @type {any} */ remote, /** @type {any} */ branch) {
83
83
  const r = git(['fetch', '--quiet', remote, branch], repo);
84
84
  if (!r.ok) return null;
85
85
  return versionAt(repo, 'FETCH_HEAD');
86
86
  }
87
87
 
88
- function listRemotes(repo) {
88
+ function listRemotes(/** @type {any} */ repo) {
89
89
  const r = git(['remote'], repo);
90
90
  if (!r.ok) return [];
91
91
  return r.out.split('\n').filter(Boolean);
92
92
  }
93
93
 
94
94
  // 各远端(main/master 任一存在)中版本号最大的(多镜像部署:github/gitee/gitcode 谁新跟谁)
95
- function newestRemote(repo) {
95
+ function newestRemote(/** @type {any} */ repo) {
96
96
  let latest = '';
97
97
  let ref = '';
98
98
  for (const remote of listRemotes(repo)) {
package/src/web/app.js CHANGED
@@ -763,12 +763,55 @@ $('#memDedupe').onclick=async ()=>{
763
763
  };
764
764
  // —— 缓存命中率仪表盘 ——
765
765
  function fmtT(n){ n=Number(n||0); return n>=1000?(n/1000).toFixed(1)+'k':String(n); }
766
+ // 省钱 B3:费用二级分账面板(模型/工具 Top5 + 近 14 天折线,零依赖 SVG)
767
+ function renderCostBreakdown(bd){
768
+ const models=$('#costTopModels'); models.innerHTML='';
769
+ for(const m of ((bd.byModel||[]).slice(0,5))){
770
+ const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
771
+ const max=bd.byModel&&bd.byModel[0]?bd.byModel[0].cost:1;
772
+ bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((m.cost||0)/Math.max(max,1e-9)*100))+'%;background:var(--accent2)"></div>';
773
+ const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
774
+ row.innerHTML='<span>'+esc(m.model||'—')+'</span><span>¥'+(m.cost||0).toFixed(4)+' · '+m.turns+' 轮</span>';
775
+ const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); models.appendChild(w);
776
+ }
777
+ if(!(bd.byModel||[]).length) models.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
778
+ const tools=$('#costTopTools'); tools.innerHTML='';
779
+ for(const t of ((bd.byTool||[]).slice(0,5))){
780
+ const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
781
+ const max=bd.byTool&&bd.byTool[0]?bd.byTool[0].ms:1;
782
+ bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((t.ms||0)/Math.max(max,1e-9)*100))+'%;background:var(--cyan)"></div>';
783
+ const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
784
+ row.innerHTML='<span>'+esc(t.tool||'—')+'</span><span>'+t.calls+' 次 · '+fmtDur(t.ms)+'</span>';
785
+ const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); tools.appendChild(w);
786
+ }
787
+ if(!(bd.byTool||[]).length) tools.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
788
+ const line=$('#costLine');
789
+ const days=(bd.byDay||[]).slice(-14);
790
+ if(!days.length){ line.innerHTML=''; return; }
791
+ const W=320, H=56, P=4;
792
+ const maxC=Math.max(...days.map(d=>d.cost), 1e-9);
793
+ const pts=days.map((d,i)=>{
794
+ const x=P+i*(W-2*P)/Math.max(days.length-1,1);
795
+ const y=H-4-(d.cost/maxC)*(H-10);
796
+ return [x.toFixed(1), y.toFixed(1)];
797
+ });
798
+ const poly=pts.map(p=>p.join(',')).join(' ');
799
+ const last=pts[pts.length-1];
800
+ line.innerHTML='<div style="font-size:11.5px;color:var(--faint);margin-bottom:2px">近 14 天费用折线(¥,最高 ¥'+maxC.toFixed(4)+')</div>'
801
+ +'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;background:var(--bg3);border-radius:8px">'
802
+ +'<polyline points="'+poly+'" fill="none" style="stroke:var(--accent2);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round"/>'
803
+ +'<circle cx="'+last[0]+'" cy="'+last[1]+'" r="2.6" style="fill:var(--accent)"/>'
804
+ +'<text x="4" y="10" font-size="8" style="fill:var(--faint)">'+days[0].day+'</text>'
805
+ +'<text x="'+(W-52)+'" y="10" font-size="8" style="fill:var(--faint)">'+days[days.length-1].day+'</text>'
806
+ +'</svg>';
807
+ }
766
808
  async function refreshCache(){
767
809
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
768
810
  const j=await r.json(); if(!j.ok) return;
769
811
  const s=j.summary||{};
770
812
  const rate=s.rate!=null?(s.rate*100).toFixed(0)+'%':'暂无缓存数据';
771
813
  $('#cacheSummary').innerHTML='轮次 '+s.turns+' · ↑'+fmtT(s.prompt)+' ↓'+fmtT(s.completion)+' tokens · 命中率 <b style="color:var(--accent)">'+rate+'</b> · 累计 ≈¥'+Number(s.cost||0).toFixed(5)+'(比全未命中省 ≈¥'+Number(s.saved||0).toFixed(5)+')';
814
+ renderCostBreakdown(j.breakdown||{});
772
815
  const list=$('#cacheRecent'); list.innerHTML='';
773
816
  if(!(j.recent||[]).length){ list.innerHTML='<div style="color:var(--faint);font-size:12px;padding:6px 2px">暂无记录(每次对话后自动统计)</div>'; return; }
774
817
  for(const e of j.recent){
@@ -7,7 +7,7 @@
7
7
  const MAX_IMAGE_DATAURL = 7 * 1024 * 1024; // base64 膨胀 1.33 倍,对应约 5MB 原图
8
8
  const MAX_TEXT = 200 * 1024;
9
9
 
10
- export function buildUserContent(message, attachments, visionSupported) {
10
+ export function buildUserContent(/** @type {any} */ message, /** @type {any} */ attachments, /** @type {any} */ visionSupported) {
11
11
  const text = String(message ?? '').trim();
12
12
  const list = Array.isArray(attachments) ? attachments.slice(0, 4) : [];
13
13
  const imageParts = [];
@@ -372,6 +372,18 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
372
372
  <h4 style="margin:4px 0;color:var(--accent2)">缓存命中率仪表盘(最近 10 次)</h4>
373
373
  <div id="cacheSummary" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
374
374
  <div id="cacheRecent" style="max-height:130px;overflow-y:auto"></div>
375
+ <h4 style="margin:10px 0 4px;color:var(--accent2)">费用二级分账(模型 / 工具 Top5 · 近 14 天)</h4>
376
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
377
+ <div style="flex:1;min-width:170px">
378
+ <div style="font-size:11.5px;color:var(--faint)">模型 Top5(费用)</div>
379
+ <div id="costTopModels" style="font-size:12px;color:var(--dim)"></div>
380
+ </div>
381
+ <div style="flex:1;min-width:170px">
382
+ <div style="font-size:11.5px;color:var(--faint)">工具 Top5(耗时)</div>
383
+ <div id="costTopTools" style="font-size:12px;color:var(--dim)"></div>
384
+ </div>
385
+ </div>
386
+ <div id="costLine" style="margin-top:8px"></div>
375
387
  </section>
376
388
  <section class="cfg-panel" data-panel="mcp">
377
389
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">