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 +5 -5
- package/docs/QA-REPORT.md +2 -0
- package/package.json +2 -2
- package/src/agent.js +52 -1
- package/src/commands/repl.js +8 -5
- package/src/session-index.js +130 -41
- package/src/tools/fs-tools.js +2 -1
- package/src/web/app.js +72 -83
- package/src/web/attachments.js +3 -4
- package/src/web/constants.js +9 -0
- package/src/web/index.html +21 -8
- package/src/web/routes/api.js +9 -7
- package/src/web/routes/domains/config.js +20 -2
- package/src/web/server.js +2 -2
- package/src/web/util.js +201 -0
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.
|
|
61
|
-
|
|
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
|
-
· `
|
|
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.
|
|
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;
|
package/src/commands/repl.js
CHANGED
|
@@ -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(当前:${
|
|
323
|
+
io.print(`用法:/think low|high|max|off(当前:${cur})`);
|
|
323
324
|
continue;
|
|
324
325
|
}
|
|
325
|
-
if (arg === 'off'
|
|
326
|
-
|
|
327
|
-
|
|
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(`✓
|
|
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));
|
package/src/session-index.js
CHANGED
|
@@ -1,36 +1,71 @@
|
|
|
1
1
|
// 会话检索索引(P3-2):词表倒排 + 增量同步,替代每次全量扫描全部会话文件。
|
|
2
|
-
//
|
|
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
|
-
|
|
16
|
-
|
|
19
|
+
/** 分片目录(索引分片统一存放于此) */
|
|
20
|
+
export function shardDir() {
|
|
21
|
+
return path.join(mingdaoHome(), 'sessions-index');
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
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(
|
|
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
|
-
|
|
45
|
+
/** @param {any} shard @param {any} idx */
|
|
46
|
+
function saveShard(shard, idx) {
|
|
30
47
|
try {
|
|
31
48
|
ensureHome();
|
|
32
|
-
const
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
migrateLegacyIndex();
|
|
88
125
|
const dir = path.join(home, 'sessions');
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
return
|
|
212
|
+
|
|
213
|
+
return combined;
|
|
125
214
|
}
|
package/src/tools/fs-tools.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
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>=
|
|
290
|
+
if(attachments.length>=MAX_ATTACHMENTS){ uiAlert('最多 '+MAX_ATTACHMENTS+' 个附件'); break; }
|
|
306
291
|
if(f.type.startsWith('image/')){
|
|
307
|
-
if(f.size>
|
|
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>
|
|
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
|
-
|
|
431
|
-
html='<span class="ws-bg"
|
|
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(){
|
package/src/web/attachments.js
CHANGED
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
// - 文本文件:直接拼接进消息文本(≤200KB),并留文件名标注
|
|
5
5
|
// - 返回 { content(模型消息内容:字符串或图文数组), persistText(落盘文本), error }
|
|
6
6
|
|
|
7
|
-
|
|
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,
|
|
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 >
|
|
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 工具单文件读取上限(独立语义,非附件上限)
|
package/src/web/index.html
CHANGED
|
@@ -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
|
-
#
|
|
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>
|
package/src/web/routes/api.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
63
|
+
// 质检 Q2/S5:SPA 外部 JS——从磁盘读取(与 index.html 同目录)。
|
|
64
|
+
// v0.2.8 C2:app.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)), '..',
|
|
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(
|
|
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-
|
|
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-
|
|
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 ? '' :
|
|
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
|
});
|
package/src/web/util.js
ADDED
|
@@ -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, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|