mingdao-harness 0.2.7 → 0.2.8

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/docs/CONFIG.md CHANGED
@@ -57,9 +57,9 @@ MingDao 先用 executor 模型(路由关闭时为当前模型)把被裁段
57
57
  ## 会话检索索引(P3-2)
58
58
 
59
59
  `mingdao sessions search <关键词>` 与 WebUI 历史会话搜索走增量词表索引
60
- (`~/.mingdao/sessions-index.json`):中文按 bigram+单字、英文按词,多词 AND 匹配;
61
- 只有内容变化(mtime/size)的会话才重新分词,删除的会话自动清出索引。索引文件可随时
62
- 删除(下次搜索自动重建)。
60
+ (`~/.mingdao/sessions-index/` 分片目录,v0.2.8 起按会话名 sha1 前 2 位分 256 片):
61
+ 中文按 bigram+单字、英文按词,多词 AND 匹配;只有内容变化(mtime/size)的会话才重新
62
+ 分词,删除的会话自动清出索引。索引目录可随时删除(下次搜索自动重建)。
63
63
 
64
64
  ## 会话级工作空间(P3-4)
65
65
 
@@ -169,7 +169,7 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
169
169
  { "costGuard": { "dailyLimitYuan": 10, "warnAtYuan": 8, "action": "block" } }
170
170
  ```
171
171
 
172
- - `action: "warn"`(默认)超限仅提醒;`"block"` 到达上限暂停执行(明天自动恢复);
172
+ - `action: "warn"`(默认)超限仅提醒;`"block"` 到达上限暂停执行(明天自动恢复);`"downgrade"` 触顶后自动切换到同服务商更便宜模型(如 flash)继续执行并提示(已是 flash 则按 block 处理);
173
173
  - `/cost` 与 WebUI 头部费用徽标实时显示「今日费用 / 上限百分比」。
174
174
 
175
175
  ## 战略省钱:Batch 半价 + 避峰调度
@@ -182,7 +182,7 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
182
182
  ——高峰时段(北京时间工作日 9:00–12:00、14:00–18:00 两段)自动顺延到最近闲时(12:00 / 18:00)执行;**周末与午间 12:00–14:00 按闲时计价**
183
183
  · `pricing.peakWindows`:高峰窗口覆盖([[起,止],...] 北京时间整点);`pricing.timezone`:计价时区
184
184
  · `pricing.source`:官方价格 JSON 地址(`mingdao update --pricing` 拉取,TTL 默认 7 天,`pricing.ttlDays` 可调);`pricing.overrides`:按模型覆盖价格
185
- · `reasoningEffort`:思考强度 low/high/max(默认模型内置 high;REPL `/think` 切换)
185
+ · `reasoningByModel`:思考强度按模型独立覆盖,`{ "deepseek-v4-pro": "low" }`(`off`/`low`/`high`/`max`,默认模型内置 high;`off` 显式关闭思考省推理 token;REPL `/think` 或 WebUI 设置「模型与密钥」→ 思考模式开关 + 推理等级);旧版全局 `reasoningEffort` 仍兼容,优先级低于 `reasoningByModel`
186
186
  · `routing.upgradeSteps` / `routing.upgradeTruncated`:粘滞 flash 会话累计步数/截断超过阈值自动升 planner(默认 10 / 2)
187
187
  (DeepSeek 官方邮件确认),不触发避峰等待。WebUI 调度面板勾选「🌙 避峰执行」。
188
188
 
package/docs/QA-REPORT.md CHANGED
@@ -5,6 +5,8 @@
5
5
  > 版本:0.1.54(历史归档;现行 0.1.x 口径见根 README 与各版本 tag。纯 ESM,零 npm 依赖,Node.js ≥ 18.17)
6
6
  > 质检方式:全量源码走读 + 冒烟/端到端测试 + 针对性缺陷验证脚本
7
7
 
8
+ > **当前测试规模快照(v0.2.8,随版本持续增长)**:smoke **61 组** · e2e-local **16 项** · e2e-web **21 项** · e2e-schedule **8 项** · api-contracts **8 项** · bench **194 条断言**(tokenizer 16 + routing 118 + compaction 46 + cost 14)。下文 2026-08-18 的「14 组断言」为当时历史口径,已过时——以本快照为准。
9
+
8
10
  ---
9
11
 
10
12
  ## 一、总体评价
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,7 +49,7 @@
49
49
  "scripts": {
50
50
  "start": "node src/cli.js",
51
51
  "test": "node test/run-all.mjs",
52
- "typecheck": "tsc -p tsconfig.json",
52
+ "typecheck": "tsc -p tsconfig.full.json",
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",
package/src/agent.js CHANGED
@@ -28,7 +28,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
28
28
  const budget = cfg.contextBudget || preset.budgetTokens || 128000;
29
29
  const maxOutput = cfg.maxOutputTokens || preset.maxOutputTokens || 8192;
30
30
  const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
31
- const reasoningEffort = cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
31
+ const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
32
32
  const hooks = createHooks(cfg.hooks, workingDir);
33
33
  const todos = /** @type {any[]} */ ([]);
34
34
  // 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
@@ -359,6 +359,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
359
359
  }
360
360
 
361
361
  if (res.toolCalls?.length) {
362
+ // v0.2.8 步数上限收尾(对齐 DSH):末轮仍返回工具调用(无视收尾指令)时不再执行工具——
363
+ // 有正文直接收尾,无正文跳出循环进入兜底总结,避免再次跑满步数后静默结束。
364
+ if (steps === stepLimit) {
365
+ io.endTurn();
366
+ if (res.text) {
367
+ messages.push({ role: 'assistant', content: res.text });
368
+ return { text: res.text, reasoning: res.reasoning || '', usage, steps, finish, truncated: false, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
369
+ }
370
+ break;
371
+ }
362
372
  io.endTurn();
363
373
  const assistantMsg = {
364
374
  role: 'assistant',
@@ -553,6 +563,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
553
563
  }
554
564
  }
555
565
  }
566
+ // v0.2.8 步数预留收尾(对齐 DSH):仅剩最后一轮时,工具结果回填后追加收尾指令,
567
+ // 让模型在末轮输出「总结文字 + 交付物清单」,而非跑满步数后静默结束。
568
+ if (steps === stepLimit - 1) {
569
+ messages.push({
570
+ role: 'user',
571
+ content:
572
+ '(系统提示)已达到工具调用步数上限,请停止调用工具,直接输出最终总结:① 已完成的工作;② 交付物清单(文件路径);③ 遗留问题与后续建议。',
573
+ });
574
+ }
556
575
  } else {
557
576
  io.endTurn();
558
577
  // 最终纯文本回复回填消息历史(会话持久化与多轮上下文依赖它);
@@ -626,6 +645,38 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
626
645
  io.endTurn();
627
646
  // 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
628
647
  stripOrphanCalls();
648
+ // v0.2.8 兜底总结(对齐 DSH):跑满步数/末轮无正文且未中断时,补一次 no-tool 小输出请求,
649
+ // 让任务以「总结文字 + 交付物清单」收尾,而非静默结束;失败则回退旧行为(text:null)。
650
+ if (!aborted && messages.length) {
651
+ try {
652
+ // stripOrphanCalls 残留的空 assistant(无 tool_calls 且 content 为空)回传会触发 API 400,先清掉
653
+ const lastMsg = messages[messages.length - 1];
654
+ if (lastMsg.role === 'assistant' && !lastMsg.content && !Array.isArray(lastMsg.tool_calls)) messages.pop();
655
+ const wrapReq = [
656
+ ...trimMessages(messages, budget, count),
657
+ { role: 'user', content: '(系统提示)任务已执行完毕。请用一段话总结刚才完成的工作,列出交付物(文件路径),并说明遗留问题与后续建议。' },
658
+ ];
659
+ currentAc = new AbortController();
660
+ const wrapRes = await provider.chat({
661
+ model: activeModel,
662
+ messages: wrapReq,
663
+ tools: [],
664
+ temperature,
665
+ maxTokens: Math.min(maxOutput, 2048),
666
+ reasoningEffort: modelPreset(activeModel)?.supportsReasoning ? 'low' : undefined,
667
+ signal: currentAc.signal,
668
+ onDelta(/** @type {any} */ d) {
669
+ if (d.text) io.writeText(d.text);
670
+ },
671
+ });
672
+ if (wrapRes.usage) {
673
+ usage.prompt_tokens += wrapRes.usage.prompt_tokens || 0;
674
+ usage.completion_tokens += wrapRes.usage.completion_tokens || 0;
675
+ }
676
+ if (wrapRes.text) messages.push({ role: 'assistant', content: wrapRes.text });
677
+ return { text: wrapRes.text || null, reasoning: wrapRes.reasoning || '', usage, steps, finish, truncated: false, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
678
+ } catch {}
679
+ }
629
680
  return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, durationMs: Date.now() - startedAt, perf: perf() };
630
681
  } finally {
631
682
  currentAc = null;
@@ -318,13 +318,16 @@ export async function runRepl(ctx) {
318
318
  await switchToModel(target);
319
319
  } else if (cmd === '/think') {
320
320
  const vals = ['low', 'high', 'max'];
321
+ const cur = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? '跟随模型默认';
321
322
  if (!arg) {
322
- io.print(`用法:/think low|high|max|off(当前:${cfg.reasoningEffort || '跟随模型默认 high'})`);
323
+ io.print(`用法:/think low|high|max|off(当前:${cur})`);
323
324
  continue;
324
325
  }
325
- if (arg === 'off') cfg.reasoningEffort = 'off'; // 显式禁用(provider 转 thinking:{type:'disabled'})
326
- else if (vals.includes(arg)) cfg.reasoningEffort = arg;
327
- else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
326
+ if (arg === 'off' || vals.includes(arg)) {
327
+ cfg.reasoningByModel = cfg.reasoningByModel || {};
328
+ cfg.reasoningByModel[modelName] = arg; // 按模型独立:只改当前模型(off thinking:{type:'disabled'}
329
+ saveConfig(cfg);
330
+ } else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
328
331
  agent = createAgent({
329
332
  provider, permission, io, modelName, workingDir, cfg,
330
333
  undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
@@ -333,7 +336,7 @@ export async function runRepl(ctx) {
333
336
  tuiState.persisted = msgs.length;
334
337
  },
335
338
  });
336
- io.print(style(`✓ 思考强度:${cfg.reasoningEffort || 'off(模型默认)'}`, C.green));
339
+ io.print(style(`✓ 思考强度(${modelName}):${cfg.reasoningByModel[modelName]}`, C.green));
337
340
  } else if (cmd === '/plan') {
338
341
  planMode = !planMode;
339
342
  io.print(style(`✓ 计划模式:${planMode ? '开' : '关'}${planMode ? '(先出计划,确认后执行)' : ''}`, C.green));
@@ -1,36 +1,71 @@
1
1
  // 会话检索索引(P3-2):词表倒排 + 增量同步,替代每次全量扫描全部会话文件。
2
- // 索引文件 <mingdao-home>/sessions-index.json
3
- // { files: { "<会话名>": { mtime, size, terms: {词: 次数} } } }
2
+ // 索引分片存储(v0.2.8 B1):<mingdao-home>/sessions-index/<2位sha1前缀>.json ——
3
+ // 每片 { files: { "<会话名>": { mtime, size, terms: {词: 次数} } } }
4
+ // 分片收益:>1000 会话时不再单次 JSON.parse/stringify 一个巨型索引文件;
5
+ // 同步只重写「变脏」的分片,读/写放大与内存峰值都大幅下降(256 片,每片 ~4 个会话)。
4
6
  // 同步策略:查询前对每个会话文件做 mtime/size 对比——未变化直接用缓存词表(O(词表)),
5
7
  // 变化/新增才重新分词(增量);已删除文件自动清理;>8MB 超大文件跳过并移除条目。
6
8
  // 分词:英文/数字/路径按词(小写),中文按 bigram + 单字,查询串同口径分词后按 AND 匹配。
7
9
 
8
10
  import fs from 'node:fs';
9
11
  import path from 'node:path';
12
+ import crypto from 'node:crypto';
10
13
  import { mingdaoHome, ensureHome } from './config.js';
11
14
  import { atomicWriteFileSync } from './atomic-write.js';
12
15
 
13
16
  const INDEX_MAX_FILE = 8 * 1024 * 1024;
17
+ const SHARD_HEX = 2; // sha1 前 2 个十六进制位 → 256 片
14
18
 
15
- export function sessionIndexFile() {
16
- return path.join(mingdaoHome(), 'sessions-index.json');
19
+ /** 分片目录(索引分片统一存放于此) */
20
+ export function shardDir() {
21
+ return path.join(mingdaoHome(), 'sessions-index');
17
22
  }
18
23
 
19
- export function loadSessionIndex() {
24
+ /** 会话名 分片名(sha1 前 2 位,稳定且均匀) */
25
+ /** @param {any} name */
26
+ export function shardOf(name) {
27
+ return crypto.createHash('sha1').update(String(name)).digest('hex').slice(0, SHARD_HEX);
28
+ }
29
+
30
+ /** @param {any} shard */
31
+ function shardFile(shard) {
32
+ return path.join(shardDir(), `${shard}.json`);
33
+ }
34
+
35
+ /** @param {any} shard */
36
+ function loadShard(shard) {
20
37
  try {
21
- const j = JSON.parse(fs.readFileSync(sessionIndexFile(), 'utf8'));
38
+ const j = JSON.parse(fs.readFileSync(shardFile(shard), 'utf8'));
22
39
  return j && typeof j === 'object' && j.files && typeof j.files === 'object' ? j : { files: {} };
23
40
  } catch {
24
41
  return { files: {} };
25
42
  }
26
43
  }
27
44
 
28
- /** @param {any} idx */
29
- export function saveSessionIndex(idx) {
45
+ /** @param {any} shard @param {any} idx */
46
+ function saveShard(shard, idx) {
30
47
  try {
31
48
  ensureHome();
32
- const target = sessionIndexFile();
33
- atomicWriteFileSync(target, JSON.stringify(idx) + '\n'); // 质检 H4:索引原子写
49
+ const dir = shardDir();
50
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ atomicWriteFileSync(shardFile(shard), JSON.stringify(idx) + '\n'); // 质检 H4:索引原子写
52
+ } catch {}
53
+ }
54
+
55
+ // 一次性迁移:旧版单文件 sessions-index.json → 分片目录(迁移后删除旧文件)
56
+ function migrateLegacyIndex() {
57
+ const legacy = path.join(mingdaoHome(), 'sessions-index.json');
58
+ if (!fs.existsSync(legacy)) return;
59
+ try {
60
+ const j = JSON.parse(fs.readFileSync(legacy, 'utf8'));
61
+ const files = j && typeof j === 'object' && j.files && typeof j.files === 'object' ? j.files : {};
62
+ for (const [name, entry] of Object.entries(files)) {
63
+ const shard = shardOf(name);
64
+ const idx = loadShard(shard);
65
+ idx.files[name] = entry;
66
+ saveShard(shard, idx);
67
+ }
68
+ fs.unlinkSync(legacy);
34
69
  } catch {}
35
70
  }
36
71
 
@@ -81,45 +116,99 @@ export function extractSessionText(file) {
81
116
  return parts.join('\n');
82
117
  }
83
118
 
84
- // 增量同步索引(对比 mtime/size,只重算变化的文件;清理已删除条目)
85
- /** @param {any} home @param {any} sessions */
119
+ // 增量同步索引(对比 mtime/size,只重算变化的文件;清理已删除条目)。
120
+ // 返回合并后的 { files: {名称: 词表条目} } 供检索匹配(仍只持有所需词表,不再单次解析巨型文件)。
121
+ /** @param {any} home @param {any} sessions
122
+ * @returns {{ files: Record<string, any> }} */
86
123
  export function syncSessionIndex(home, sessions) {
87
- const idx = loadSessionIndex();
124
+ migrateLegacyIndex();
88
125
  const dir = path.join(home, 'sessions');
89
- let changed = false;
126
+ /** @type {{ files: Record<string, any> }} */
127
+ const combined = { files: {} };
128
+ const liveShards = new Set();
129
+
130
+ // 按分片聚合存活会话:每片只 load 一次,命中/变化只写回该片
131
+ const byShard = new Map();
90
132
  for (const s of sessions) {
91
- let st;
92
- try {
93
- st = fs.statSync(s.file);
94
- } catch {
95
- delete idx.files[s.name];
133
+ const k = shardOf(s.name);
134
+ if (!byShard.has(k)) byShard.set(k, []);
135
+ byShard.get(k).push(s);
136
+ }
137
+
138
+ for (const [k, group] of byShard) {
139
+ liveShards.add(k);
140
+ const idx = loadShard(k);
141
+ let changed = false;
142
+ for (const s of group) {
143
+ let st;
144
+ try {
145
+ st = fs.statSync(s.file);
146
+ } catch {
147
+ delete idx.files[s.name];
148
+ changed = true;
149
+ continue;
150
+ }
151
+ const cached = idx.files[s.name];
152
+ if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) continue; // 未变化:直接用缓存
96
153
  changed = true;
97
- continue;
154
+ if (st.size > INDEX_MAX_FILE) {
155
+ delete idx.files[s.name]; // 超大文件不索引
156
+ continue;
157
+ }
158
+ try {
159
+ idx.files[s.name] = {
160
+ mtime: st.mtimeMs,
161
+ size: st.size,
162
+ terms: Object.fromEntries(tokenize(extractSessionText(s.file))),
163
+ };
164
+ } catch {
165
+ delete idx.files[s.name];
166
+ }
98
167
  }
99
- const cached = idx.files[s.name];
100
- if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) continue; // 未变化:直接用缓存
101
- changed = true;
102
- if (st.size > INDEX_MAX_FILE) {
103
- delete idx.files[s.name]; // 超大文件不索引
104
- continue;
168
+ // 清理本片内已删除的会话条目(防索引膨胀)
169
+ for (const name of Object.keys(idx.files)) {
170
+ if (!fs.existsSync(path.join(dir, name))) {
171
+ delete idx.files[name];
172
+ changed = true;
173
+ }
105
174
  }
106
- try {
107
- idx.files[s.name] = {
108
- mtime: st.mtimeMs,
109
- size: st.size,
110
- terms: Object.fromEntries(tokenize(extractSessionText(s.file))),
111
- };
112
- } catch {
113
- delete idx.files[s.name];
175
+ if (changed) saveShard(k, idx);
176
+ // 合并本片存活条目供检索
177
+ for (const s of group) {
178
+ const e = idx.files[s.name];
179
+ if (e) combined.files[s.name] = e;
114
180
  }
115
181
  }
116
- // 清理已删除的会话条目(防索引无限膨胀)
117
- for (const name of Object.keys(idx.files)) {
118
- if (!fs.existsSync(path.join(dir, name))) {
119
- delete idx.files[name];
120
- changed = true;
182
+
183
+ // 清理「无存活会话」分片里的陈旧条目(这些片的会话全被删了):目录扫描逐片检查,
184
+ // 每片都很小;空片直接删除文件,避免残留索引膨胀
185
+ let entries = [];
186
+ try {
187
+ entries = fs.readdirSync(shardDir());
188
+ } catch {
189
+ return combined;
190
+ }
191
+ for (const f of entries) {
192
+ if (!f.endsWith('.json')) continue;
193
+ const k = f.slice(0, -5);
194
+ if (liveShards.has(k)) continue;
195
+ const idx = loadShard(k);
196
+ let changed = false;
197
+ for (const name of Object.keys(idx.files)) {
198
+ if (!fs.existsSync(path.join(dir, name))) {
199
+ delete idx.files[name];
200
+ changed = true;
201
+ }
202
+ }
203
+ if (changed) {
204
+ if (Object.keys(idx.files).length) saveShard(k, idx);
205
+ else {
206
+ try {
207
+ fs.unlinkSync(path.join(shardDir(), f));
208
+ } catch {}
209
+ }
121
210
  }
122
211
  }
123
- if (changed) saveSessionIndex(idx);
124
- return idx;
212
+
213
+ return combined;
125
214
  }
@@ -3,11 +3,12 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { MAX_FILE_READ_BYTES } from '../web/constants.js';
6
7
 
7
8
  const MAX_SCAN_FILES = 20000;
8
9
  const MAX_GLOB_RESULTS = 1000;
9
10
  const MAX_GREP_MATCHES = 250;
10
- const MAX_FILE_BYTES = 5 * 1024 * 1024;
11
+ const MAX_FILE_BYTES = MAX_FILE_READ_BYTES; // 单源化(v0.2.8 B2):read/edit 单文件读取上限
11
12
 
12
13
  /**
13
14
  * @param {any} cwd
package/src/web/app.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { $, esc, highlight, renderMarkdown, scrollBottom, expandableBody, resultText, truncText, fmtDur, fmtTok, fmtT } from './util.js';
2
+ import { MAX_ATTACHMENTS, MAX_IMAGE_BYTES, MAX_TEXT_BYTES } from './constants.js';
3
+
1
4
  // 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
2
5
  // 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
3
6
  const AUTH_TOKEN = (() => {
@@ -18,7 +21,52 @@ if (AUTH_TOKEN) {
18
21
  return rawFetch(input, init);
19
22
  };
20
23
  }
21
- const $ = (s) => document.querySelector(s);
24
+
25
+ // —— 自绘悬浮气泡 tooltip(替换原生 title:出现快、样式与暗色主题一致) ——
26
+ let tipEl = null;
27
+ function ensureTip() {
28
+ if (!tipEl) {
29
+ tipEl = document.createElement('div');
30
+ tipEl.className = 'mdtip';
31
+ document.body.appendChild(tipEl);
32
+ }
33
+ return tipEl;
34
+ }
35
+ function showTip(text, anchor) {
36
+ if (!text || !anchor) return;
37
+ const t = ensureTip();
38
+ t.textContent = text;
39
+ t.style.display = 'block';
40
+ const r = anchor.getBoundingClientRect();
41
+ const w = t.offsetWidth, h = t.offsetHeight;
42
+ let left = r.left + r.width / 2 - w / 2;
43
+ left = Math.max(8, Math.min(left, window.innerWidth - w - 8));
44
+ let top = r.top - h - 8;
45
+ if (top < 8) top = r.bottom + 8;
46
+ t.style.left = left + 'px';
47
+ t.style.top = top + 'px';
48
+ }
49
+ function hideTip() { if (tipEl) tipEl.style.display = 'none'; }
50
+ function attachTip(el, text) {
51
+ if (!el || !text) return;
52
+ el.removeAttribute('title'); // 去掉原生 title,避免双提示
53
+ const get = typeof text === 'function' ? text : () => text;
54
+ el.addEventListener('mouseenter', () => showTip(get(), el));
55
+ el.addEventListener('mouseleave', hideTip);
56
+ el.addEventListener('focus', () => showTip(get(), el));
57
+ el.addEventListener('blur', hideTip);
58
+ el.addEventListener('mousedown', hideTip); // 打开下拉/点击时隐藏,避免遮挡
59
+ }
60
+ window.addEventListener('scroll', hideTip, true);
61
+
62
+ // 输入区下拉 / 附件按钮:把原生 title 换成自绘气泡(文本取自原 title,一次性读取后移除)
63
+ function initTips() {
64
+ const perm = $('#permSel'); if (perm) attachTip(perm, perm.getAttribute('title'));
65
+ const reas = $('#reasoningSel'); if (reas) attachTip(reas, reas.getAttribute('title'));
66
+ const model = $('#modelSel'); if (model) attachTip(model, () => { const o = model.options[model.selectedIndex]; return (o && o.title) ? o.title : '切换模型'; });
67
+ const at = $('#attachBtn'); if (at) attachTip(at, at.getAttribute('title'));
68
+ }
69
+ initTips();
22
70
 
23
71
  // —— 弹窗三件套(Electron 不实现 window.prompt/confirm/alert:prompt 恒返回 null、confirm 恒 false、
24
72
  // alert 静默无反应——桌面版「⚙ 设置里点设Key 没反应」即由此而来)。统一替换为应用内模态框。
@@ -125,61 +173,7 @@ chatEl.addEventListener('click', (e) => {
125
173
  let generating = false; // 生成中:发送按钮复用为停止按钮
126
174
  let currentSession = null, thinking = null;
127
175
 
128
- function esc(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
129
176
 
130
- function highlight(code, lang){
131
- const kws = new Set('const let var function return if else for while import from export class new await async try catch throw null undefined true false def elif raise with as lambda None True False self echo exit if then else fi done local function'.split(' '));
132
- const re = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`|\b\d[\d._]*\b|[A-Za-z_$][\w$]*)/g;
133
- return esc(code).replace(re, (tok)=>{
134
- if(tok.startsWith('//')||tok.startsWith('#')) return '<span class="hl-c">'+tok+'</span>';
135
- if(/^["'`]/.test(tok)) return '<span class="hl-s">'+tok+'</span>';
136
- if(/^\d/.test(tok)) return '<span class="hl-n">'+tok+'</span>';
137
- if(kws.has(tok)) return '<span class="hl-kw">'+tok+'</span>';
138
- return tok;
139
- });
140
- }
141
- function renderMarkdown(text){
142
- // 块级解析(美化:加粗标题 / 段落 / 真列表 / 引用 / 分割线,参照 DeepSeek-Harness 会话排版)
143
- const lines = String(text).split('\n');
144
- let out = '', code = null, codeLang = '';
145
- const flushCode = () => { if (code !== null) { out += '<div class="codeblock"><div class="cb-banner"><span class="cb-lang"><span class="cb-dot"></span>' + (esc(codeLang) || 'code') + '</span><span class="cb-copy" style="cursor:pointer">复制</span></div><pre><code class="lang-' + esc(codeLang) + '">' + highlight(code.join('\n'), codeLang) + '</code></pre></div>'; code = null; } };
146
- let para = [];
147
- let list = null; // {type:'ul'|'ol', items:[]}
148
- const flushPara = () => { if (para.length) { out += '<p>' + para.join('<br>') + '</p>'; para = []; } };
149
- const flushList = () => { if (list) { out += '<' + list.type + '>' + list.items.map((i) => '<li>' + i + '</li>').join('') + '</' + list.type + '>'; list = null; } };
150
- // 行内元素(审计 P1-3:内容与 codeLang 均经 esc 转义防 XSS)
151
- const inline = (l) => {
152
- let s = esc(l);
153
- s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
154
- .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
155
- .replace(/\*([^*\s][^*]*)\*/g, '<i>$1</i>')
156
- .replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
157
- return s;
158
- };
159
- for (const line of lines) {
160
- const t = line.trim();
161
- if (t.startsWith('```')) {
162
- if (code === null) { flushList(); flushPara(); code = []; codeLang = (t.slice(3).trim().split(/\s+/)[0] || ''); }
163
- else flushCode();
164
- continue;
165
- }
166
- if (code !== null) { code.push(line); continue; }
167
- if (t === '') { flushList(); flushPara(); continue; }
168
- const h = t.match(/^(#{1,6})\s+(.*)$/);
169
- if (h) { flushList(); flushPara(); const level = Math.min(h[1].length, 4); out += '<h' + level + '>' + inline(h[2]) + '</h' + level + '>'; continue; }
170
- const ul = t.match(/^([-*+])\s+(.*)$/);
171
- if (ul) { flushPara(); if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; } list.items.push(inline(ul[2])); continue; }
172
- const ol = t.match(/^(\d+)[.)]\s+(.*)$/);
173
- if (ol) { flushPara(); if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; } list.items.push(inline(ol[2])); continue; }
174
- if (/^>\s?/.test(t)) { flushList(); flushPara(); out += '<blockquote>' + inline(t.replace(/^>\s?/, '')) + '</blockquote>'; continue; }
175
- if (/^(-{3,}|\*{3,})$/.test(t)) { flushList(); flushPara(); out += '<hr>'; continue; }
176
- flushList();
177
- para.push(inline(t));
178
- }
179
- flushCode(); flushList(); flushPara();
180
- return out;
181
- }
182
- function scrollBottom(){ const sc=document.querySelector('main'); if(sc) sc.scrollTop = sc.scrollHeight; }
183
177
 
184
178
  function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
185
179
  function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
@@ -187,15 +181,6 @@ function addReasoning(msg){ const d=document.createElement('details'); d.classNa
187
181
 
188
182
  function addUser(text){ const el=document.createElement('div'); el.className='msg-user'; el.innerHTML='<div class="bubble">'+esc(text)+'</div>'; chatEl.appendChild(el); scrollBottom(); }
189
183
 
190
- function expandableBody(previewHtml, fullHtml){
191
- const b=document.createElement('div'); b.className='body';
192
- const prev=document.createElement('div'); const full=document.createElement('div');
193
- prev.innerHTML=previewHtml; full.innerHTML=fullHtml; full.style.display='none';
194
- const btn=document.createElement('button'); btn.textContent='展开全文'; btn.style.cssText='padding:1px 8px;font-size:11px;margin-top:4px';
195
- btn.onclick=()=>{ const open=full.style.display!=='none'; full.style.display=open?'none':'block'; btn.textContent=open?'展开全文':'收起'; scrollBottom(); };
196
- b.appendChild(prev); b.appendChild(full); b.appendChild(btn);
197
- return b;
198
- }
199
184
  const runningTools = new Map();
200
185
  function renderToolStartEvent(ev){
201
186
  const card=document.createElement('div'); card.className='tool';
@@ -302,13 +287,13 @@ let attachments=[];
302
287
  $('#attachBtn').onclick=()=>{ $('#fileInput').click(); };
303
288
  $('#fileInput').addEventListener('change', async e=>{
304
289
  for(const f of e.target.files||[]){
305
- if(attachments.length>=4){ uiAlert('最多 4 个附件'); break; }
290
+ if(attachments.length>=MAX_ATTACHMENTS){ uiAlert('最多 '+MAX_ATTACHMENTS+' 个附件'); break; }
306
291
  if(f.type.startsWith('image/')){
307
- if(f.size>5*1024*1024){ uiAlert(f.name+' 超过 5MB'); continue; }
292
+ if(f.size>MAX_IMAGE_BYTES){ uiAlert(f.name+' 超过 5MB'); continue; }
308
293
  const dataUrl=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); });
309
294
  attachments.push({type:'image',name:f.name,dataUrl});
310
295
  }else if(f.type.startsWith('text/')||/\.(txt|md|json|js|py|log|csv|html|css)$/i.test(f.name)){
311
- if(f.size>200*1024){ uiAlert(f.name+' 超过 200KB'); continue; }
296
+ if(f.size>MAX_TEXT_BYTES){ uiAlert(f.name+' 超过 200KB'); continue; }
312
297
  const content=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsText(f); });
313
298
  attachments.push({type:'text',name:f.name,content});
314
299
  }else{
@@ -420,6 +405,7 @@ function renderWorkStatus(){
420
405
  renderLiveBar();
421
406
  const el=$('#workStatus'); if(!el) return;
422
407
  let html='';
408
+ let tip='';
423
409
  if(generating){
424
410
  const secs=curWorkT0?Math.round((Date.now()-curWorkT0)/1000):0;
425
411
  html='<span class="ws-busy"><span class="spinner"></span><span class="ws-phase">'+esc(curPhase)+'</span><span>⏳ 第 '+curSteps+' 步 · '+Math.floor(secs/60)+' 分 '+Math.round(secs%60)+' 秒 · '+curTools+' 工具步'+(curTasks>0?' · '+curTasks+' 个子代理':'')+'</span></span>';
@@ -427,12 +413,12 @@ function renderWorkStatus(){
427
413
  // 后台任务 chip(非顶部,位于输入框上方):计数 + 最新任务 + 悬浮详情 tooltip,点击打开详情面板
428
414
  const running=bgTasks.filter((/** @type {any} */ t)=>t.status==='running');
429
415
  const latest=running.length?running[running.length-1]:null;
430
- const tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
431
- html='<span class="ws-bg" title="'+esc(String(tip)).replace(/"/g,'&quot;')+'">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
416
+ tip=bgTasks.map((/** @type {any} */ t)=>(t.kind==='schedule'?'⏰':'🛠')+' '+String(t.message||t.id||'').slice(0,40)+' · '+t.status).join('\n');
417
+ html='<span class="ws-bg">🛠 '+bgRunning+' 个后台任务运行中'+(latest?' · '+esc(String(latest.message||'').slice(0,22)):'')+' — 点击查看详情</span>';
432
418
  }
433
419
  el.style.display=html?'flex':'none';
434
420
  el.innerHTML=html;
435
- 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'; $('#subPanel').style.display='none'; updateTasksPanel(); } syncPanelLayout(); };
421
+ 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'; $('#subPanel').style.display='none'; updateTasksPanel(); } syncPanelLayout(); }; attachTip(bg, tip); }
436
422
  }
437
423
  // 顶部常驻活动条(静默完美解决层):生成期间始终可见,滚动不影响
438
424
  function renderLiveBar(){
@@ -498,14 +484,6 @@ function syncPanelLayout(){
498
484
  document.body.classList.toggle('sub-open', open('subPanel'));
499
485
  document.body.classList.toggle('tasks-open', open('tasksPanel'));
500
486
  }
501
- function resultText(r){
502
- if(r==null||r===undefined) return '';
503
- if(typeof r==='string') return r;
504
- if(r.output) return String(r.output);
505
- if(r.stdout||r.stderr) return String(r.stdout||'')+String(r.stderr||'');
506
- return JSON.stringify(r);
507
- }
508
- function truncText(t,n){ t=String(t||''); return t.length>n?t.slice(0,n)+'\n…(截断,共 '+t.length+' 字)':t; }
509
487
  $('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
510
488
  $('#trajRailBtn').onclick=()=>{
511
489
  const p=$('#trajPanel');
@@ -602,8 +580,6 @@ async function refreshCostBadge(){
602
580
  }
603
581
  refreshCostBadge(); setInterval(refreshCostBadge, 15000);
604
582
  // 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
605
- function fmtDur(ms){ if(!ms) return '0s'; const sec=Math.round(ms/1000); const m=Math.floor(sec/60); return m>0 ? m+'m'+String(sec%60).padStart(2,'0')+'s' : sec+'s'; }
606
- function fmtTok(n){ if(n>=1e9) return (n/1e9).toFixed(1)+'B'; if(n>=1e6) return (n/1e6).toFixed(0)+'M'; if(n>=1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }
607
583
  async function refreshStatusBar(){
608
584
  const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
609
585
  const j=await r.json().catch(()=>null); if(!j) return;
@@ -620,6 +596,18 @@ $('#tpClose').onclick=()=>{ $('#tasksPanel').style.display='none'; syncPanelLayo
620
596
  async function refreshSessions(q){ const u=q?'/api/sessions?q='+encodeURIComponent(q):'/api/sessions'; const r=await fetch(u).catch(()=>null); if(!r)return; const j=await r.json(); const sel=$('#sessions'); sel.innerHTML='<option value="">历史会话</option>'; for(const s of j.sessions){ const o=document.createElement('option'); o.value=s.file; o.textContent=s.label; sel.appendChild(o); } if(currentSession&&!q) sel.value=currentSession; }
621
597
  let searchTimer=null;
622
598
  $('#sessionSearch').addEventListener('input',e=>{ clearTimeout(searchTimer); searchTimer=setTimeout(()=>refreshSessions(e.target.value.trim()),300); });
599
+ // 思考模式 / 推理等级(v0.2.8):与模型选择并列在输入区,按当前模型独立。
600
+ // 仅 reasoning 模型显示该下拉(关/低/高/最高);切换模型时随 /api/state 的 reasoning 字段刷新。
601
+ function applyReasoningUI(reasoning){
602
+ const sel=$('#reasoningSel');
603
+ if(!sel) return;
604
+ const supported = Boolean(reasoning && reasoning.supported);
605
+ sel.style.display = supported ? '' : 'none';
606
+ if(!supported) return;
607
+ const effort = reasoning && reasoning.effort ? reasoning.effort : 'high';
608
+ sel.value = ['off','low','high','max'].includes(effort) ? effort : 'high';
609
+ }
610
+ $('#reasoningSel').onchange=()=>{ applyConfig({reasoningEffort:$('#reasoningSel').value}); };
623
611
  async function init(){
624
612
  try{
625
613
  const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
@@ -628,7 +616,7 @@ async function init(){
628
616
  for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
629
617
  for(const g of order){
630
618
  const og=document.createElement('optgroup'); og.label=g;
631
- for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
619
+ for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.name; o.title=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
632
620
  ms.appendChild(og);
633
621
  }
634
622
  if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
@@ -639,6 +627,7 @@ async function init(){
639
627
  $('#budgetInput').value=j.contextBudget||128000;
640
628
  $('#autoStartChk').checked=Boolean(j.autostart);
641
629
  $('#notifyChk').checked=j.notify!==false;
630
+ applyReasoningUI(j.reasoning);
642
631
  const env = ('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
643
632
  $('#envBadge').textContent=env; $('#envBadge').style.display='';
644
633
  // 首次使用引导:无 API Key 时明确提示去设置(桌面版自动初始化后必走这里)
@@ -779,7 +768,6 @@ $('#memDedupe').onclick=async ()=>{
779
768
  if(j.ok){ memFlash('✓ 去重完成,移除 '+j.removed+' 行', true); loadMemoryUI(); } else memFlash('✖ '+(j.error||'去重失败'), false);
780
769
  };
781
770
  // —— 缓存命中率仪表盘 ——
782
- function fmtT(n){ n=Number(n||0); return n>=1000?(n/1000).toFixed(1)+'k':String(n); }
783
771
  // 省钱 B3:费用二级分账面板(模型/工具 Top5 + 近 14 天折线,零依赖 SVG)
784
772
  function renderCostBreakdown(bd){
785
773
  const models=$('#costTopModels'); models.innerHTML='';
@@ -904,12 +892,13 @@ async function reloadModels(){
904
892
  for(const m of (j.models||[])){ const g=m.providerLabel||'其他'; if(!groups[g]){groups[g]=[];order.push(g);} groups[g].push(m); }
905
893
  for(const g of order){
906
894
  const og=document.createElement('optgroup'); og.label=g;
907
- for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
895
+ for(const m of groups[g]){ const o=document.createElement('option'); o.value=m.name; o.textContent=m.name; o.title=m.label; if(m.name===j.model) o.selected=true; og.appendChild(o); }
908
896
  ms.appendChild(og);
909
897
  }
910
898
  if(!(j.models||[]).length){ const o=document.createElement('option'); o.value=j.model; o.textContent=j.model; ms.appendChild(o); }
911
899
  const env=('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
912
900
  $('#envBadge').textContent=env; $('#envBadge').style.display='';
901
+ applyReasoningUI(j.reasoning);
913
902
  }catch(e){}
914
903
  }
915
904
  async function refreshModelsCfg(){
@@ -4,12 +4,11 @@
4
4
  // - 文本文件:直接拼接进消息文本(≤200KB),并留文件名标注
5
5
  // - 返回 { content(模型消息内容:字符串或图文数组), persistText(落盘文本), error }
6
6
 
7
- const MAX_IMAGE_DATAURL = 7 * 1024 * 1024; // base64 膨胀 1.33 倍,对应约 5MB 原图
8
- const MAX_TEXT = 200 * 1024;
7
+ import { MAX_ATTACHMENTS, MAX_IMAGE_DATAURL, MAX_TEXT_BYTES } from './constants.js';
9
8
 
10
9
  export function buildUserContent(/** @type {any} */ message, /** @type {any} */ attachments, /** @type {any} */ visionSupported) {
11
10
  const text = String(message ?? '').trim();
12
- const list = Array.isArray(attachments) ? attachments.slice(0, 4) : [];
11
+ const list = Array.isArray(attachments) ? attachments.slice(0, MAX_ATTACHMENTS) : [];
13
12
  const imageParts = [];
14
13
  const persistParts = [];
15
14
  let finalText = text;
@@ -31,7 +30,7 @@ export function buildUserContent(/** @type {any} */ message, /** @type {any} */
31
30
  } else if (a.type === 'text') {
32
31
  const content = String(a.content ?? '');
33
32
  if (!content.trim()) continue;
34
- if (content.length > MAX_TEXT) {
33
+ if (content.length > MAX_TEXT_BYTES) {
35
34
  return { error: `文本文件过大:${a.name || '未命名'}(≤200KB)` };
36
35
  }
37
36
  finalText += `${finalText ? '\n\n' : ''}[文件 ${a.name || '未命名'}]\n${content}`;
@@ -0,0 +1,9 @@
1
+ // 前端 + 服务端共享常量(零依赖纯 ESM,浏览器与 Node 均可 import)。
2
+ // 单源化(v0.2.8 B2):附件上限 / 并发上限等数值只在此定义,
3
+ // 防 app.js(客户端预检)/ attachments.js(服务端校验)/ fs-tools.js(读取上限)漂移。
4
+ export const MAX_CONCURRENT = 8; // 并发任务上限(server 与任务面板共用)
5
+ export const MAX_ATTACHMENTS = 4; // 单次最多附件数
6
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 单张图片原图上限(5MB)
7
+ export const MAX_IMAGE_DATAURL = 7 * 1024 * 1024; // base64 膨胀 1.33 倍后的 dataURL 上限(≈5MB 原图)
8
+ export const MAX_TEXT_BYTES = 200 * 1024; // 文本附件上限(200KB)
9
+ export const MAX_FILE_READ_BYTES = 5 * 1024 * 1024; // read/edit 工具单文件读取上限(独立语义,非附件上限)
@@ -94,6 +94,7 @@ header select{flex:none}
94
94
  #trajPanel{position:fixed;left:42px;top:52px;bottom:0;width:252px;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)}
95
95
  .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}
96
96
  .tj-head button{margin-left:auto}
97
+ #tjClose,#sbClose{font-size:12px;white-space:nowrap;padding:3px 10px}
97
98
  #tjList{flex:1;overflow-y:auto;padding:8px}
98
99
  .tj-turn{color:var(--accent2);font-size:12px;font-weight:600;margin:8px 2px 4px}
99
100
  .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}
@@ -189,10 +190,14 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
189
190
  .errline{color:var(--err);background:rgba(229,83,75,.08);border:1px solid rgba(229,83,75,.3);border-radius:10px;padding:10px 14px;font-size:13.5px}
190
191
  footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);padding:12px 18px}
191
192
  #composer{max-width:860px;margin:0 auto;display:flex;gap:10px;align-items:flex-end}
192
- #input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:12px;color:var(--text);padding:10px 14px;font:14px/1.6 inherit;resize:none;min-height:44px;max-height:200px}
193
+ #composer #permSel{max-width:96px;min-width:64px;flex:none;padding:8px 8px}
194
+ #composer #reasoningSel{max-width:64px;min-width:48px;flex:none;padding:8px 6px;font-size:12px}
195
+ #composer #modelSel{max-width:150px;min-width:88px;flex:none;padding:8px 8px;font-size:12px}
196
+ #input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:12px;color:var(--text);padding:10px 14px;font:14px/1.6 inherit;resize:none;min-height:44px;max-height:200px;min-width:0}
193
197
  #input:focus{outline:none;border-color:var(--accent2)}
194
198
  #hint{font-size:11.5px;color:var(--faint);margin-top:6px;max-width:860px;margin-left:auto;margin-right:auto}
195
199
  #statusBar{font-size:11.5px;color:var(--faint);margin-top:2px;max-width:860px;margin-left:auto;margin-right:auto;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
200
+ @media (max-width:560px){footer{padding:10px 8px}#composer{gap:6px}#composer #permSel{min-width:52px;padding:8px 4px}#composer #reasoningSel{min-width:44px;padding:8px 4px}#composer #modelSel{min-width:72px;padding:8px 4px}#attachBtn{padding:10px 9px}}
196
201
  .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:50}
197
202
  .modal{background:var(--panel);border:1px solid var(--border);border-radius:14px;width:min(520px,92vw);padding:18px;max-height:88vh;overflow-y:auto}
198
203
  /* 设置多级菜单(审计:一拉到底改为左侧分组导航 + 右侧面板) */
@@ -221,17 +226,13 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
221
226
  .spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--accent);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px}
222
227
  #scrollBottomBtn{position:fixed;right:26px;bottom:118px;width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#06121a;font-size:20px;font-weight:800;border:none;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.45);z-index:30;display:none;align-items:center;justify-content:center}
223
228
  @keyframes spin{to{transform:rotate(360deg)}}
229
+ /* 自绘悬浮气泡 tooltip(替换原生 title):暗色悬浮层 + 阴影,跟随锚点居中,越界自动回弹 */
230
+ .mdtip{position:fixed;z-index:1000;max-width:320px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.6;box-shadow:0 8px 24px rgba(0,0,0,.55);pointer-events:none;white-space:pre-line;word-break:break-word;display:none}
224
231
  </style>
225
232
  </head>
226
233
  <body>
227
234
  <header>
228
235
  <div class="logo">MingDao <span>Harness</span></div>
229
- <select id="modelSel" title="切换模型"></select>
230
- <select id="permSel" title="权限模式">
231
- <option value="ask">权限 ask</option>
232
- <option value="auto">权限 auto</option>
233
- <option value="readonly">权限 readonly</option>
234
- </select>
235
236
  <span id="cfgMsg" class="badge" style="display:none"></span>
236
237
  <button id="cfgBtn" title="设置">⚙</button>
237
238
  <select id="wsSel" title="工作空间(切换 / 新建,目录自动创建)"></select>
@@ -266,6 +267,18 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
266
267
  <div id="composer">
267
268
  <button id="attachBtn" title="上传图片或文本文件(图片需视觉模型,如 deepseek-v4-flash-vision-exp)">📎</button>
268
269
  <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送,Shift+Enter 换行)"></textarea>
270
+ <select id="permSel" title="权限模式:询问=写文件/执行命令前先确认(推荐) · 自动=全部直接放行 · 只读=禁止修改与执行">
271
+ <option value="ask">询问</option>
272
+ <option value="auto">自动</option>
273
+ <option value="readonly">只读</option>
274
+ </select>
275
+ <select id="modelSel" title="切换模型"></select>
276
+ <select id="reasoningSel" title="思考模式(推理等级):关=不推理省 token · 低 · 高(默认)· 最高=最强推理" style="display:none">
277
+ <option value="off">关</option>
278
+ <option value="low">低</option>
279
+ <option value="high">高</option>
280
+ <option value="max">最高</option>
281
+ </select>
269
282
  <button id="sendBtn" class="primary">发送</button>
270
283
  </div>
271
284
  <div id="hint"><span id="hintText">工具执行与权限确认会实时展示;生成中可点「中断」停止。</span><label style="margin-left:12px;cursor:pointer;user-select:none"><input type="checkbox" id="journalChk" style="vertical-align:-2px;margin:0 4px 0 0">📌 带上文(最近会话日志,默认关=新会话全新开始)</label></div>
@@ -442,6 +455,6 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
442
455
  </div>
443
456
  </div>
444
457
  </div>
445
- <script src="/app.js"></script>
458
+ <script type="module" src="/app.js"></script>
446
459
  </body>
447
460
  </html>
@@ -31,7 +31,7 @@ export function createApiDispatch(deps) {
31
31
  // 访问控制(P1-3/P1-4):token 校验覆盖数据与操作接口;壳页面与 PWA 静态资源公开
32
32
  // (壳不含任何数据,SPA 需要先加载才能读取 ?token=);Host 白名单覆盖一切请求
33
33
  const isStaticAsset =
34
- p === '/' || p === '/index.html' || p === '/app.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/icon-192.png' || p === '/icon-512.png' || p === '/manifest.webmanifest' || p === '/sw.js';
34
+ p === '/' || p === '/index.html' || p === '/app.js' || p === '/util.js' || p === '/constants.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/icon-192.png' || p === '/icon-512.png' || p === '/manifest.webmanifest' || p === '/sw.js';
35
35
  if (authEnabled && !isStaticAsset && !tokenMatches(requestToken(req, url))) {
36
36
  return json(res, 401, { error: '未授权:缺少或无效的访问令牌(地址需带 ?token=…,或请求头携带 X-MingDao-Token)' });
37
37
  }
@@ -60,15 +60,17 @@ export function createApiDispatch(deps) {
60
60
  }
61
61
 
62
62
  // —— 静态壳资源(公开;不经过域分发) ——
63
- if (method === 'GET' && p === '/app.js') {
64
- // 质检 Q2/S5SPA 外部 JS——从磁盘读取(与 index.html 同目录)
63
+ // 质检 Q2/S5:SPA 外部 JS——从磁盘读取(与 index.html 同目录)。
64
+ // v0.2.8 C2app.js 拆分为 ES Modules(app.js / util.js / constants.js),统一从此处伺服。
65
+ const WEB_JS_FILES = new Set(['app.js', 'util.js', 'constants.js']);
66
+ if (method === 'GET' && WEB_JS_FILES.has(p.slice(1))) {
65
67
  try {
66
- const js = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'app.js'), 'utf8');
68
+ const js = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', p.slice(1)), 'utf8');
67
69
  res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' });
68
70
  res.end(js);
69
71
  } catch {
70
72
  res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
71
- res.end('app.js 加载失败');
73
+ res.end(p.slice(1) + ' 加载失败');
72
74
  }
73
75
  return;
74
76
  }
@@ -124,8 +126,8 @@ export function createApiDispatch(deps) {
124
126
  }
125
127
  if (method === 'GET' && p === '/sw.js') {
126
128
  res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
127
- // 缓存键去掉 query(?token= 不落缓存);mingdao-v4:随 token 认证版本升版本号,强制替换旧 SW 缓存
128
- res.end(`self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!=='mingdao-v4').map(k=>caches.delete(k)))).then(()=>clients.claim())));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).origin===location.origin&&!e.request.url.includes('/api/')){const u=new URL(e.request.url);u.search='';e.respondWith(fetch(e.request).then(r=>{const c=r.clone();caches.open('mingdao-v4').then(cache=>cache.put(u.toString(),c));return r;}).catch(()=>caches.match(u.toString()).then(m=>m||caches.match('/'))));}});`);
129
+ // 缓存键去掉 query(?token= 不落缓存);mingdao-v5:随 token 认证版本升版本号,强制替换旧 SW 缓存
130
+ res.end(`self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!=='mingdao-v5').map(k=>caches.delete(k)))).then(()=>clients.claim())));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).origin===location.origin&&!e.request.url.includes('/api/')){const u=new URL(e.request.url);u.search='';e.respondWith(fetch(e.request).then(r=>{const c=r.clone();caches.open('mingdao-v5').then(cache=>cache.put(u.toString(),c));return r;}).catch(()=>caches.match(u.toString()).then(m=>m||caches.match('/'))));}});`);
129
131
  return;
130
132
  }
131
133
 
@@ -6,7 +6,7 @@ import { loadConfig, saveConfig } from '../../../config.js';
6
6
  import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../../../credentials.js';
7
7
  import { availableModels, fetchProviderModels, providerHasKey } from '../../../model-discovery.js';
8
8
  import { createProvider, resolveProviderConfig } from '../../../providers/index.js';
9
- import { MODELS, PROVIDERS } from '../../../models.js';
9
+ import { MODELS, PROVIDERS, modelPreset } from '../../../models.js';
10
10
  import { detectSandbox } from '../../../tools/bash.js';
11
11
  import { enableAutostart, disableAutostart, autostartStatus } from '../../../autostart.js';
12
12
  import { PRICE_DATA_AS_OF } from '../../../pricing.js';
@@ -29,6 +29,14 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
29
29
  .map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
30
30
  // 模型列表:只列已设置 Key 的服务商,名称以 /models 接口线上名单为准(预设仅回退与补价)
31
31
  const models = await availableModels(cfg, state.modelName);
32
+ // 思考模式 / 推理等级(v0.2.8):当前模型是否支持 reasoning、当前档位与可选档位。
33
+ // 按模型独立:reasoningByModel[当前模型] 覆盖 > 全局 reasoningEffort(旧配置兼容)> 模型预设默认。
34
+ const rp = modelPreset(state.modelName);
35
+ const reasoning = {
36
+ supported: Boolean(rp?.supportsReasoning),
37
+ effort: cfg.reasoningByModel?.[state.modelName] ?? cfg.reasoningEffort ?? rp?.reasoningEffort?.default ?? (rp?.supportsReasoning ? 'high' : 'off'),
38
+ options: rp?.reasoningEffort?.options ?? ['low', 'high', 'max'],
39
+ };
32
40
  json(res, 200, {
33
41
  ok: true,
34
42
  model: state.modelName,
@@ -40,6 +48,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
40
48
  sandbox: cfg.sandbox || 'off',
41
49
  sandboxSupported: detectSandbox() !== 'none',
42
50
  routing: cfg.routing?.enabled ? cfg.routing : null,
51
+ reasoning,
43
52
  contextBudget: cfg.contextBudget || 128000,
44
53
  pricingAsOf: PRICE_DATA_AS_OF,
45
54
  autostart: autostartStatus(),
@@ -103,6 +112,15 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
103
112
  next.contextBudget = n;
104
113
  cfg.contextBudget = n;
105
114
  }
115
+ if (body.reasoningEffort !== undefined) {
116
+ const re = String(body.reasoningEffort);
117
+ if (!['off', 'low', 'high', 'max'].includes(re)) {
118
+ return json(res, 400, { error: '思考强度必须是 off / low / high / max' });
119
+ }
120
+ // 按模型独立:写入当前模型的覆盖档位(不污染其他模型)
121
+ cfg.reasoningByModel = cfg.reasoningByModel || {};
122
+ cfg.reasoningByModel[state.modelName] = re;
123
+ }
106
124
  let autostartChanged = false;
107
125
  if (body.autostart !== undefined) {
108
126
  autostartChanged = true;
@@ -121,7 +139,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
121
139
  cfg.model = next.model;
122
140
  cfg.permission = next.permission;
123
141
  saveConfig(cfg);
124
- json(res, 200, { ok: true, model: state.modelName, permission: cfg.permission, sandbox: cfg.sandbox, routing: cfg.routing?.enabled, contextBudget: cfg.contextBudget, autostart: autostartChanged ? autostartStatus() : undefined, notify: cfg.notify !== false });
142
+ json(res, 200, { ok: true, model: state.modelName, permission: cfg.permission, sandbox: cfg.sandbox, routing: cfg.routing?.enabled, contextBudget: cfg.contextBudget, reasoningEffort: cfg.reasoningByModel?.[state.modelName] ?? cfg.reasoningEffort, autostart: autostartChanged ? autostartStatus() : undefined, notify: cfg.notify !== false });
125
143
  return true;
126
144
  }
127
145
 
package/src/web/server.js CHANGED
@@ -25,6 +25,7 @@ import { createProvider, resolveProviderConfig, helperProvider } from '../provid
25
25
  import { MODELS, modelPreset, PROVIDERS } from '../models.js';
26
26
  import { routeTask, routingConfig } from '../routing.js';
27
27
  import { buildUserContent } from './attachments.js';
28
+ import { MAX_CONCURRENT } from './constants.js';
28
29
  import { createAgent } from '../agent.js';
29
30
  import { createPermission } from '../permissions.js';
30
31
  import { buildSystemPrompt } from '../prompts.js';
@@ -217,7 +218,6 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
217
218
  };
218
219
 
219
220
  // 任务注册表:支持多会话并行——每个任务独立的 SSE 流、权限确认、中断控制
220
- const MAX_CONCURRENT = 8;
221
221
  let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
222
222
  const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
223
223
  let taskSeq = 0;
@@ -519,7 +519,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
519
519
  durationMs: r.durationMs,
520
520
  truncated: r.truncated,
521
521
  aborted: r.aborted,
522
- note: r.note || (r.text ? '' : `(本轮共执行 ${io.stats().toolCount} 步工具操作${io.stats().deliverables.length ? `、交付 ${io.stats().deliverables.length} 个文件` : ''},但模型没有输出总结文字——可追问「总结一下刚才的工作」)`),
522
+ note: r.note || (r.text ? '' : `(任务已执行 ${io.stats().toolCount} 步工具操作${io.stats().deliverables.length ? `、交付 ${io.stats().deliverables.length} 个文件` : ''},自动收尾总结未能生成——可追问「总结一下刚才的工作」)`),
523
523
  stats: io.stats(),
524
524
  session: path.basename(session.file),
525
525
  });
@@ -0,0 +1,201 @@
1
+ // 纯前端工具(零状态,仅依赖 DOM/标准 API):选择器、转义、Markdown 渲染、格式化。
2
+ // 从 app.js 拆分(v0.2.8 C2):这些函数被消息渲染/轨迹/设置等多处复用,独立成模块便于维护与测试。
3
+
4
+ export const $ = (s) => document.querySelector(s);
5
+
6
+ export function esc(s) {
7
+ return String(s ?? '')
8
+ .replace(/&/g, '&amp;')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;')
12
+ .replace(/'/g, '&#39;');
13
+ }
14
+
15
+ export function highlight(code, lang) {
16
+ const kws = new Set(
17
+ 'const let var function return if else for while import from export class new await async try catch throw null undefined true false def elif raise with as lambda None True False self echo exit if then else fi done local function'.split(
18
+ ' '
19
+ )
20
+ );
21
+ const re = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`|\b\d[\d._]*\b|[A-Za-z_$][\w$]*)/g;
22
+ return esc(code).replace(re, (tok) => {
23
+ if (tok.startsWith('//') || tok.startsWith('#')) return '<span class="hl-c">' + tok + '</span>';
24
+ if (/^["'`]/.test(tok)) return '<span class="hl-s">' + tok + '</span>';
25
+ if (/^\d/.test(tok)) return '<span class="hl-n">' + tok + '</span>';
26
+ if (kws.has(tok)) return '<span class="hl-kw">' + tok + '</span>';
27
+ return tok;
28
+ });
29
+ }
30
+
31
+ export function renderMarkdown(text) {
32
+ // 块级解析(美化:加粗标题 / 段落 / 真列表 / 引用 / 分割线,参照 DeepSeek-Harness 会话排版)
33
+ const lines = String(text).split('\n');
34
+ let out = '';
35
+ let code = null;
36
+ let codeLang = '';
37
+ const flushCode = () => {
38
+ if (code !== null) {
39
+ out +=
40
+ '<div class="codeblock"><div class="cb-banner"><span class="cb-lang"><span class="cb-dot"></span>' +
41
+ (esc(codeLang) || 'code') +
42
+ '</span><span class="cb-copy" style="cursor:pointer">复制</span></div><pre><code class="lang-' +
43
+ esc(codeLang) +
44
+ '">' +
45
+ highlight(code.join('\n'), codeLang) +
46
+ '</code></pre></div>';
47
+ code = null;
48
+ }
49
+ };
50
+ let para = [];
51
+ let list = null; // {type:'ul'|'ol', items:[]}
52
+ const flushPara = () => {
53
+ if (para.length) {
54
+ out += '<p>' + para.join('<br>') + '</p>';
55
+ para = [];
56
+ }
57
+ };
58
+ const flushList = () => {
59
+ if (list) {
60
+ out += '<' + list.type + '>' + list.items.map((i) => '<li>' + i + '</li>').join('') + '</' + list.type + '>';
61
+ list = null;
62
+ }
63
+ };
64
+ // 行内元素(审计 P1-3:内容与 codeLang 均经 esc 转义防 XSS)
65
+ const inline = (l) => {
66
+ let s = esc(l);
67
+ s = s
68
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
69
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
70
+ .replace(/\*([^*\s][^*]*)\*/g, '<i>$1</i>')
71
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
72
+ return s;
73
+ };
74
+ for (const line of lines) {
75
+ const t = line.trim();
76
+ if (t.startsWith('```')) {
77
+ if (code === null) {
78
+ flushList();
79
+ flushPara();
80
+ code = [];
81
+ codeLang = t.slice(3).trim().split(/\s+/)[0] || '';
82
+ } else flushCode();
83
+ continue;
84
+ }
85
+ if (code !== null) {
86
+ code.push(line);
87
+ continue;
88
+ }
89
+ if (t === '') {
90
+ flushList();
91
+ flushPara();
92
+ continue;
93
+ }
94
+ const h = t.match(/^(#{1,6})\s+(.*)$/);
95
+ if (h) {
96
+ flushList();
97
+ flushPara();
98
+ const level = Math.min(h[1].length, 4);
99
+ out += '<h' + level + '>' + inline(h[2]) + '</h' + level + '>';
100
+ continue;
101
+ }
102
+ const ul = t.match(/^([-*+])\s+(.*)$/);
103
+ if (ul) {
104
+ flushPara();
105
+ if (!list || list.type !== 'ul') {
106
+ flushList();
107
+ list = { type: 'ul', items: [] };
108
+ }
109
+ list.items.push(inline(ul[2]));
110
+ continue;
111
+ }
112
+ const ol = t.match(/^(\d+)[.)]\s+(.*)$/);
113
+ if (ol) {
114
+ flushPara();
115
+ if (!list || list.type !== 'ol') {
116
+ flushList();
117
+ list = { type: 'ol', items: [] };
118
+ }
119
+ list.items.push(inline(ol[2]));
120
+ continue;
121
+ }
122
+ if (/^>\s?/.test(t)) {
123
+ flushList();
124
+ flushPara();
125
+ out += '<blockquote>' + inline(t.replace(/^>\s?/, '')) + '</blockquote>';
126
+ continue;
127
+ }
128
+ if (/^(-{3,}|\*{3,})$/.test(t)) {
129
+ flushList();
130
+ flushPara();
131
+ out += '<hr>';
132
+ continue;
133
+ }
134
+ flushList();
135
+ para.push(inline(t));
136
+ }
137
+ flushCode();
138
+ flushList();
139
+ flushPara();
140
+ return out;
141
+ }
142
+
143
+ export function scrollBottom() {
144
+ const sc = document.querySelector('main');
145
+ if (sc) sc.scrollTop = sc.scrollHeight;
146
+ }
147
+
148
+ export function expandableBody(previewHtml, fullHtml) {
149
+ const b = document.createElement('div');
150
+ b.className = 'body';
151
+ const prev = document.createElement('div');
152
+ const full = document.createElement('div');
153
+ prev.innerHTML = previewHtml;
154
+ full.innerHTML = fullHtml;
155
+ full.style.display = 'none';
156
+ const btn = document.createElement('button');
157
+ btn.textContent = '展开全文';
158
+ btn.style.cssText = 'padding:1px 8px;font-size:11px;margin-top:4px';
159
+ btn.onclick = () => {
160
+ const open = full.style.display !== 'none';
161
+ full.style.display = open ? 'none' : 'block';
162
+ btn.textContent = open ? '展开全文' : '收起';
163
+ scrollBottom();
164
+ };
165
+ b.appendChild(prev);
166
+ b.appendChild(full);
167
+ b.appendChild(btn);
168
+ return b;
169
+ }
170
+
171
+ export function resultText(r) {
172
+ if (r == null || r === undefined) return '';
173
+ if (typeof r === 'string') return r;
174
+ if (r.output) return String(r.output);
175
+ if (r.stdout || r.stderr) return String(r.stdout || '') + String(r.stderr || '');
176
+ return JSON.stringify(r);
177
+ }
178
+
179
+ export function truncText(t, n) {
180
+ t = String(t || '');
181
+ return t.length > n ? t.slice(0, n) + '\n…(截断,共 ' + t.length + ' 字)' : t;
182
+ }
183
+
184
+ export function fmtDur(ms) {
185
+ if (!ms) return '0s';
186
+ const sec = Math.round(ms / 1000);
187
+ const m = Math.floor(sec / 60);
188
+ return m > 0 ? m + 'm' + String(sec % 60).padStart(2, '0') + 's' : sec + 's';
189
+ }
190
+
191
+ export function fmtTok(n) {
192
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
193
+ if (n >= 1e6) return (n / 1e6).toFixed(0) + 'M';
194
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
195
+ return String(n);
196
+ }
197
+
198
+ export function fmtT(n) {
199
+ n = Number(n || 0);
200
+ return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
201
+ }