mingdao-harness 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/presets/local-audit.json +2 -2
- package/src/agent.js +15 -3
- package/src/cachestats.js +11 -6
- package/src/cli.js +19 -13
- package/src/commands/repl.js +5 -1
- package/src/commands/skill.js +2 -1
- package/src/hooks.js +14 -2
- package/src/model-caps.js +6 -8
- package/src/presets.js +23 -25
- package/src/pricing.js +10 -7
- package/src/providers/openai-compatible.js +3 -1
- package/src/redact.js +5 -1
- package/src/skill-lib.js +59 -11
- package/src/sync-server.js +3 -3
- package/src/tools/bash.js +5 -2
- package/src/tools/fetch.js +3 -1
- package/src/web/app.js +27 -2
- package/src/web/index.html +1 -1
- package/src/web/server.js +5 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# MingDao Harness
|
|
2
2
|
|
|
3
3
|
> 开源智能体框架(Agent Harness):**零运行时依赖、开箱即用**,针对 DeepSeek-V4 首发深度优化,开放主流模型接入。一条命令安装,终端与浏览器双界面,命令:`mingdao`(简写 `mdh`)。
|
|
4
|
+
>
|
|
5
|
+
> 项目简称 **MDH**(MingDao Harness),自 v0.4.3 起文档/日志/发布说明统一使用。
|
|
4
6
|
|
|
5
7
|
轻量的「模型循环 + 工具 + 权限」内核,能力以 ESM 库导出、接口全部开放。架构见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
|
|
6
8
|
|
package/package.json
CHANGED
package/presets/local-audit.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-audit",
|
|
3
3
|
"label": "本地模型审计",
|
|
4
|
-
"description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 +
|
|
4
|
+
"description": "面向本地/资源受限模型(如 131k 窗口 q8 量化)的只读审计预设:只读工具集 + 只读权限 + 保守参数,配合 v0.3.2 本地自适应长任务不中断",
|
|
5
5
|
"systemPrompt": "你是一名代码审计员。任务是对给定代码库做只读审计:先 ls/glob 摸清结构,再 read/grep 逐文件审查,可用 git 查看历史与 diff、fetch 抓取相关文档。输出审计报告:发现的缺陷按严重度分级,每条给出文件:行号证据;不做任何修改。若上下文紧张,先审查最关键的部分并明示未覆盖区域。",
|
|
6
6
|
"tools": ["read", "ls", "glob", "grep", "skill", "git", "fetch", "todo"],
|
|
7
|
-
"permission": "
|
|
7
|
+
"permission": "readonly",
|
|
8
8
|
"maxRounds": 4,
|
|
9
9
|
"maxOutputTokens": 4096
|
|
10
10
|
}
|
package/src/agent.js
CHANGED
|
@@ -45,7 +45,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
45
45
|
const toolResultCap = Math.min(20000, Math.max(2000, Math.floor(caps.contextWindow / 16)));
|
|
46
46
|
const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
|
|
47
47
|
const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
|
|
48
|
-
const hooks = createHooks(cfg.hooks, workingDir);
|
|
48
|
+
const hooks = createHooks(cfg.hooks, workingDir, cfg);
|
|
49
49
|
const todos = /** @type {any[]} */ ([]);
|
|
50
50
|
// 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
|
|
51
51
|
const undo = undoStore || { backups: new Map() };
|
|
@@ -232,7 +232,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
232
232
|
count,
|
|
233
233
|
provider,
|
|
234
234
|
executorModel: subagentModel(cfg, modelName),
|
|
235
|
-
|
|
235
|
+
// 可配置触发线:默认远程 80%;本地模型默认 60% 提前压缩——本地推理内存预算有限,
|
|
236
|
+
// 等到 80% 再压时上下文已累积过多、prefill 与内存双高(v0.4.2 本地模型 507 修复)
|
|
237
|
+
triggerRatio: Number(cfg.compactTrigger) > 0 ? cfg.compactTrigger : (caps.isLocal ? 0.6 : undefined),
|
|
236
238
|
force: windowPressure, // v0.3.2:逼近窗口时强制压缩(忽略最小阈值门槛)
|
|
237
239
|
});
|
|
238
240
|
if (compacted) {
|
|
@@ -456,6 +458,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
456
458
|
// 其余模式/工具保持串行,避免多个权限对话框交错。事件顺序(start/render/post/回填)不变。
|
|
457
459
|
const READONLY_BATCH = new Set(['read', 'ls', 'glob', 'grep']);
|
|
458
460
|
const canBatch = permission.mode === 'auto';
|
|
461
|
+
// v0.4.2(本地模型 507 memory_refusal 修复):子代理目标为本地模型时,只读子代理不并行——
|
|
462
|
+
// 多路大 prefill 同时冲进本地推理服务会击穿其单进程内存预算(507 memory_refusal),
|
|
463
|
+
// 串行化只读子代理避免并发峰值(只读 read/ls/glob/grep 仍并行,它们不额外触发大 prefill)。
|
|
464
|
+
const subagentIsLocal = resolveModelCaps(cfg, subagentModel(cfg, modelName)).isLocal;
|
|
459
465
|
|
|
460
466
|
// 预检:解析参数 → PreToolUse 钩子 → 权限检查;拒绝/失败只回填不执行(返回 null)
|
|
461
467
|
// task 工具标记 readOnly 时也可并行(评估 A4:只读子代理 Promise.all)
|
|
@@ -608,7 +614,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
608
614
|
const prep = /** @type {any} */ (await prepTool(tc));
|
|
609
615
|
const name = tc.function?.name || '';
|
|
610
616
|
let batchable = canBatch && Boolean(prep) && !prep.isMcp && READONLY_BATCH.has(name);
|
|
611
|
-
if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true) batchable = true;
|
|
617
|
+
if (!batchable && canBatch && Boolean(prep) && name === 'task' && prep.args?.readOnly === true && !subagentIsLocal) batchable = true;
|
|
612
618
|
batch.push({ prep, batchable });
|
|
613
619
|
i += 1;
|
|
614
620
|
if (!batchable) break;
|
|
@@ -783,6 +789,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
783
789
|
}
|
|
784
790
|
// 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
|
|
785
791
|
return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
792
|
+
} catch (/** @type {any} */ err) {
|
|
793
|
+
// 审计 P2-6(v0.4.2):工具管线异常(hooks.pre / permission.check / prepTool 等)沿大 try 上抛时,
|
|
794
|
+
// assistant tool_calls 已 push 进 messages 却无对应 tool 回填——会话恢复后 API 因孤儿 tool_call_id 400。
|
|
795
|
+
// 清理孤儿调用后再抛(stripOrphanCalls 此前只在中断/收尾路径调用)。
|
|
796
|
+
stripOrphanCalls();
|
|
797
|
+
throw err;
|
|
786
798
|
} finally {
|
|
787
799
|
currentAc = null;
|
|
788
800
|
offSigint();
|
package/src/cachestats.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
7
7
|
import { estimateCost, cacheSplit, beijingDayStart, beijingParts } from './pricing.js';
|
|
8
8
|
import { modelPreset } from './models.js';
|
|
9
|
+
import { withFileLockSync, atomicWriteFileSync } from './atomic-write.js';
|
|
9
10
|
|
|
10
11
|
export function cacheStatsFile() {
|
|
11
12
|
return path.join(mingdaoHome(), 'cache-stats.jsonl');
|
|
@@ -41,12 +42,16 @@ export function recordCacheStats(/** @type {any} */ entry) {
|
|
|
41
42
|
} catch {}
|
|
42
43
|
if (cacheStatsCount > MAX_LINES && cacheStatsCount % 200 === 0) {
|
|
43
44
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fs.
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
// 审计 P2-3(v0.4.2):轮转 read-modify-write 加跨进程锁——web/CLI/worker 多进程并发轮转时,
|
|
46
|
+
// 读与写之间他人追加的行会被覆写丢失;锁内重读再瘦身,写用原子替换。
|
|
47
|
+
withFileLockSync(cacheStatsFile() + '.lock', () => {
|
|
48
|
+
const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
|
|
49
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
50
|
+
if (lines.length > MAX_LINES) {
|
|
51
|
+
atomicWriteFileSync(cacheStatsFile(), lines.slice(-KEEP_LINES).join('\n') + '\n');
|
|
52
|
+
cacheStatsCount = KEEP_LINES;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
50
55
|
} catch {}
|
|
51
56
|
}
|
|
52
57
|
}
|
package/src/cli.js
CHANGED
|
@@ -172,6 +172,9 @@ async function main() {
|
|
|
172
172
|
}
|
|
173
173
|
// 最近会话日志默认不注入(新会话全新开始);--journal 显式带上
|
|
174
174
|
const withJournal = Boolean(opts.journal);
|
|
175
|
+
// 审计 P2-5(v0.4.2):jsonMode 提前到 opts 解析后判定——此前在单次提问分支内才判定,
|
|
176
|
+
// 预设/tools/MCP 等提示在判定前已打到 stdout,污染 --format json 的单行 JSON 输出。
|
|
177
|
+
const jsonMode = opts.format === 'json';
|
|
175
178
|
|
|
176
179
|
// —— 命令分发(已拆至 src/commands/,评估 P0-1 拆 cli.js)——
|
|
177
180
|
// 各 handler 返回 true = 已处理;false = 按普通提问继续(保留词劫持防护)。
|
|
@@ -366,20 +369,20 @@ async function main() {
|
|
|
366
369
|
activePreset = loadPreset(workingDir, opts.preset);
|
|
367
370
|
if (!activePreset) {
|
|
368
371
|
const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
|
|
369
|
-
io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
|
|
372
|
+
if (!jsonMode) io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
|
|
370
373
|
} else {
|
|
371
374
|
presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
|
|
372
375
|
// P0(v0.4.1):预设 permission 提权防护——预设不得把 ask/readonly 静默改成 auto
|
|
373
376
|
const permOv = presetPermissionOverride(activePreset, cfg.permission ?? 'ask');
|
|
374
377
|
if (permOv.escalated) {
|
|
375
378
|
delete presetOverlay.permission;
|
|
376
|
-
io.print(style(`⚠ 预设 "${activePreset.name}" 声明 permission=${activePreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
|
|
379
|
+
if (!jsonMode) io.print(style(`⚠ 预设 "${activePreset.name}" 声明 permission=${activePreset.permission} 属提权(当前 ${cfg.permission ?? 'ask'}),已忽略并保持 ${permOv.permission}。`, C.yellow));
|
|
377
380
|
} else if (activePreset.permission !== undefined) {
|
|
378
381
|
presetOverlay.permission = permOv.permission;
|
|
379
382
|
}
|
|
380
383
|
if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
|
|
381
384
|
presetBlock = presetSystemBlock(activePreset);
|
|
382
|
-
io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
|
|
385
|
+
if (!jsonMode) io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
|
|
383
386
|
}
|
|
384
387
|
}
|
|
385
388
|
// agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
|
|
@@ -406,7 +409,7 @@ async function main() {
|
|
|
406
409
|
{
|
|
407
410
|
const { mountConfigTools } = await import('./tools/index.js');
|
|
408
411
|
const mounted = mountConfigTools(cfg);
|
|
409
|
-
if (mounted.length) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
|
|
412
|
+
if (mounted.length && !jsonMode) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
|
|
410
413
|
}
|
|
411
414
|
// 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
|
|
412
415
|
const sessionUndoStore = { backups: new Map() };
|
|
@@ -432,10 +435,10 @@ async function main() {
|
|
|
432
435
|
if (!mcpManager) mcpStartP.then((/** @type {any} */ m) => { if (m) m.stop(); });
|
|
433
436
|
if (mcpManager) {
|
|
434
437
|
const ready = mcpManager.status().filter((/** @type {any} */ s) => s.ok).length;
|
|
435
|
-
if (io && !opts.prompt.length) {
|
|
438
|
+
if (io && !opts.prompt.length && !jsonMode) {
|
|
436
439
|
io.print(style(`✓ MCP 就绪:${ready}/${mcpManager.status().length} 个服务器,共 ${mcpManager.toolSchemas().length} 个工具`, C.dim));
|
|
437
440
|
}
|
|
438
|
-
} else if (io && !opts.prompt.length) {
|
|
441
|
+
} else if (io && !opts.prompt.length && !jsonMode) {
|
|
439
442
|
io.print(style('⚠ MCP 连接超时(6s):本会话不注入 MCP 工具(重启 mingdao 可重试)', C.dim));
|
|
440
443
|
}
|
|
441
444
|
}
|
|
@@ -463,7 +466,6 @@ async function main() {
|
|
|
463
466
|
|
|
464
467
|
// —— 单次提问模式 ——
|
|
465
468
|
if (opts.prompt.length > 0) {
|
|
466
|
-
const jsonMode = opts.format === 'json';
|
|
467
469
|
const question = opts.prompt.join(' ');
|
|
468
470
|
// 自动路由:规划类任务切 planner,执行类走 executor(JSON 模式静默)
|
|
469
471
|
const route = await routeTask({ cfg, provider, currentModel: modelName, text: question });
|
|
@@ -504,12 +506,16 @@ async function main() {
|
|
|
504
506
|
const res = await turnAgent.runTurn(messages);
|
|
505
507
|
appendMessages(session.file, messages.slice(oneShotPersisted));
|
|
506
508
|
if (!jsonMode && cfg.autoTitle !== false && res.text) {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
509
|
+
// 审计 P2-4(v0.4.2):autoTitle 独立 try/catch——此前 titleModel 目标服务商无 Key 时
|
|
510
|
+
// helperProvider 抛错落入外层 catch,成功回答已输出却被误报失败(exitCode=2)。
|
|
511
|
+
try {
|
|
512
|
+
const tModel = titleModel(cfg, modelName);
|
|
513
|
+
const title = await generateTitle(await helperProvider(cfg, tModel, provider), tModel, question);
|
|
514
|
+
if (title) {
|
|
515
|
+
const renamed = renameSessionFile(fs, path, home, session, title);
|
|
516
|
+
if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
|
|
517
|
+
}
|
|
518
|
+
} catch {}
|
|
513
519
|
}
|
|
514
520
|
// v0.3.0 P0-2:单次提问跑满步数/中断落检查点(--continue 可续跑),正常完成清除
|
|
515
521
|
if (res.capHit || res.aborted) {
|
package/src/commands/repl.js
CHANGED
|
@@ -173,8 +173,12 @@ export async function runRepl(ctx) {
|
|
|
173
173
|
if (cfg.web?.autoStart && !process.env.MINGDAO_NO_WEB_AUTOSTART) {
|
|
174
174
|
try {
|
|
175
175
|
const { spawn } = await import('node:child_process');
|
|
176
|
+
const path = await import('node:path');
|
|
176
177
|
const { fileURLToPath } = await import('node:url');
|
|
177
|
-
|
|
178
|
+
// 审计 P1-1(v0.4.2):此前 spawn 的是 repl.js 自身(纯模块无 main 入口),web 参数被静默
|
|
179
|
+
// 丢弃、进程即退——自启 100% 失效还误报「后台启动中」。改为 spawn cli.js web(命令分发入口)。
|
|
180
|
+
const cliEntry = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
|
|
181
|
+
const child = spawn(process.execPath, [cliEntry, 'web'], {
|
|
178
182
|
detached: true,
|
|
179
183
|
stdio: 'ignore',
|
|
180
184
|
env: process.env,
|
package/src/commands/skill.js
CHANGED
|
@@ -39,7 +39,8 @@ export async function handleSkill(/** @type {any} */ cmd, /** @type {any} */ arg
|
|
|
39
39
|
return true;
|
|
40
40
|
}
|
|
41
41
|
if (sub === 'install') {
|
|
42
|
-
|
|
42
|
+
// CLI 显式输入 URL 属用户自担意图:放行内网地址(与 curl 等价);WebUI 路径默认拦截(SSRF 防护)
|
|
43
|
+
const r = await installSkill(arg, { allowPrivateUrl: true });
|
|
43
44
|
if (r.error) {
|
|
44
45
|
console.log('[错误] ' + (/** @type {any} */ (r)).error);
|
|
45
46
|
process.exitCode = 1;
|
package/src/hooks.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// - matcher 支持精确工具名、逗号分隔多个名、'*' 通配。
|
|
10
10
|
|
|
11
11
|
import { spawn } from 'node:child_process';
|
|
12
|
+
import { isSensitiveEnv } from './tools/bash.js';
|
|
12
13
|
|
|
13
14
|
function normalize(/** @type {any} */ list) {
|
|
14
15
|
if (!Array.isArray(list)) return [];
|
|
@@ -25,16 +26,27 @@ function match(/** @type {any} */ hook, /** @type {any} */ toolName) {
|
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir) {
|
|
29
|
+
export function createHooks(hooksCfg = {}, /** @type {any} */ workingDir, /** @type {any} */ cfg = {}) {
|
|
29
30
|
const pre = normalize((/** @type {any} */ (hooksCfg))?.PreToolUse);
|
|
30
31
|
const post = normalize((/** @type {any} */ (hooksCfg))?.PostToolUse);
|
|
32
|
+
// 审计 P3-6(v0.4.2):hook 子进程不再全量透传 process.env(含 API Key)——与 bash 工具同口径
|
|
33
|
+
// 默认过滤敏感变量;config.bashEnvKeep 按名放行、bashEnvFilter=false 整体关闭。
|
|
34
|
+
const keepEnv = new Set((cfg?.bashEnvKeep || []).map(String));
|
|
35
|
+
let childEnv = process.env;
|
|
36
|
+
if (cfg?.bashEnvFilter !== false) {
|
|
37
|
+
const filtered = /** @type {any} */ ({});
|
|
38
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
39
|
+
if (!isSensitiveEnv(k) || keepEnv.has(k)) filtered[k] = v;
|
|
40
|
+
}
|
|
41
|
+
childEnv = filtered;
|
|
42
|
+
}
|
|
31
43
|
|
|
32
44
|
function run(/** @type {any} */ hook, /** @type {any} */ payload) {
|
|
33
45
|
return new Promise((resolve) => {
|
|
34
46
|
const child = spawn(hook.cmd, {
|
|
35
47
|
shell: true,
|
|
36
48
|
cwd: workingDir,
|
|
37
|
-
env:
|
|
49
|
+
env: childEnv,
|
|
38
50
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
51
|
});
|
|
40
52
|
let out = '';
|
package/src/model-caps.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// 供预算推导、超时、工具截断统一引用——避免各层各自猜一份 128000 默认,
|
|
4
4
|
// 本地小模型(窗口小/内存少)自动收紧预算与超时,不撑爆、不误杀。
|
|
5
5
|
import { modelPreset } from './models.js';
|
|
6
|
+
// 审计 P3-3(v0.4.2):本地判定复用 fetch.js 的 isPrivateHost(IPv4 私网/回环/CGNAT/多播 + IPv6
|
|
7
|
+
// fc00::/7、fe80::/10、::、::1、IPv4-mapped)——此前只查 IPv4 与 ::1,IPv6 本地模型被误判远程
|
|
8
|
+
// (超时档位错),且与 fetch 工具/SSRF 判定各维护一份、口径漂移。
|
|
9
|
+
import { isPrivateHost } from './tools/fetch.js';
|
|
6
10
|
|
|
7
11
|
// 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
|
|
8
12
|
export const UNKNOWN_LOCAL_WINDOW = 32768;
|
|
@@ -20,14 +24,8 @@ export const EDGE_RATIO = 0.85;
|
|
|
20
24
|
export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
|
|
21
25
|
try {
|
|
22
26
|
const u = new URL(String(baseUrl || ''));
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (h === 'localhost' || h === '::1') return true;
|
|
26
|
-
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
27
|
-
if (!m) return false;
|
|
28
|
-
const a = Number(m[1]);
|
|
29
|
-
const b = Number(m[2]);
|
|
30
|
-
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
27
|
+
if (!u.hostname) return false;
|
|
28
|
+
return isPrivateHost(u.hostname);
|
|
31
29
|
} catch {
|
|
32
30
|
return false;
|
|
33
31
|
}
|
package/src/presets.js
CHANGED
|
@@ -78,8 +78,9 @@ export function validatePreset(/** @type {any} */ obj) {
|
|
|
78
78
|
*/
|
|
79
79
|
export function listPresets(/** @type {any} */ workingDir) {
|
|
80
80
|
ensureHome();
|
|
81
|
+
// 审计 P3-2(v0.4.2):遮蔽 key 用预设的 name 字段而非文件名——此前两个不同文件名声明同名
|
|
82
|
+
// 预设会都被列出(违背「同名遮蔽」契约),且非法 JSON 在遮蔽判定时不可见。
|
|
81
83
|
const seen = new Map();
|
|
82
|
-
const order = /** @type {string[]} */ ([]);
|
|
83
84
|
for (const { dir, source } of presetLocations(workingDir)) {
|
|
84
85
|
let files = [];
|
|
85
86
|
try {
|
|
@@ -88,34 +89,31 @@ export function listPresets(/** @type {any} */ workingDir) {
|
|
|
88
89
|
continue;
|
|
89
90
|
}
|
|
90
91
|
for (const f of files) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const { dir, source } = seen.get(f);
|
|
99
|
-
try {
|
|
100
|
-
const obj = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
92
|
+
const file = path.join(dir, f);
|
|
93
|
+
let obj;
|
|
94
|
+
try {
|
|
95
|
+
obj = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
96
|
+
} catch {
|
|
97
|
+
continue; // JSON 解析失败:跳过
|
|
98
|
+
}
|
|
101
99
|
const v = validatePreset(obj);
|
|
102
100
|
if (!v.ok) continue; // 非法预设跳过并静默(不阻塞会话);diagnose 可查
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
description: String(obj.description || ''),
|
|
107
|
-
source,
|
|
108
|
-
file: path.join(dir, f),
|
|
109
|
-
...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
|
|
110
|
-
...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
|
|
111
|
-
...(obj.permission ? { permission: String(obj.permission) } : {}),
|
|
112
|
-
...(obj.model ? { model: String(obj.model) } : {}),
|
|
113
|
-
});
|
|
114
|
-
} catch {
|
|
115
|
-
// JSON 解析失败:跳过
|
|
101
|
+
const key = String(obj.name);
|
|
102
|
+
if (seen.has(key)) continue; // 同名遮蔽:项目 → 用户 → 内置(先发现者胜)
|
|
103
|
+
seen.set(key, { obj, source, file });
|
|
116
104
|
}
|
|
117
105
|
}
|
|
118
|
-
return
|
|
106
|
+
return [...seen.values()].map(({ obj, source, file }) => ({
|
|
107
|
+
name: String(obj.name),
|
|
108
|
+
label: String(obj.label || obj.name),
|
|
109
|
+
description: String(obj.description || ''),
|
|
110
|
+
source,
|
|
111
|
+
file,
|
|
112
|
+
...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
|
|
113
|
+
...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
|
|
114
|
+
...(obj.permission ? { permission: String(obj.permission) } : {}),
|
|
115
|
+
...(obj.model ? { model: String(obj.model) } : {}),
|
|
116
|
+
}));
|
|
119
117
|
}
|
|
120
118
|
|
|
121
119
|
/**
|
package/src/pricing.js
CHANGED
|
@@ -16,20 +16,23 @@ export const PRICE_DATA_AS_OF = '2026-08';
|
|
|
16
16
|
// cfg.pricing.source 拉取(TTL 默认 7 天,cfg.pricing.ttlDays 可调);TTL 内覆盖内置表,
|
|
17
17
|
// 过期自动回退内置并置 stale 标记(/cost 与费用标签会提示)。
|
|
18
18
|
function pricingFilePath() { return path.join(mingdaoHome(), 'pricing.json'); }
|
|
19
|
-
/** @type {{ mtime: number, data: any, stale: boolean }} */
|
|
20
|
-
let extCache = { mtime: -1, data: null, stale: false };
|
|
19
|
+
/** @type {{ mtime: number, ttlDays: number, data: any, stale: boolean }} */
|
|
20
|
+
let extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
|
|
21
21
|
function externalPricing() {
|
|
22
22
|
try {
|
|
23
|
+
// 审计 P2-2(v0.4.2):此前直接读 tzCache.ttlDays——首次调用若早于 peakCfg()(isPeakHour),
|
|
24
|
+
// tzCache 还是初值(无 ttlDays),用户配的 pricing.ttlDays 被忽略按 7 天判过期且缓存整个进程寿命。
|
|
25
|
+
// 改走 peakCfg()(mtime 缓存,代价极低);ttlDays 变化也触发重算(不依赖 pricing.json mtime 变化)。
|
|
26
|
+
const ttlDays = Number(peakCfg().ttlDays || 7);
|
|
23
27
|
const f = pricingFilePath();
|
|
24
28
|
const st = fs.statSync(f);
|
|
25
|
-
if (st.mtimeMs !== extCache.mtime) {
|
|
29
|
+
if (st.mtimeMs !== extCache.mtime || ttlDays !== extCache.ttlDays) {
|
|
26
30
|
const d = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
27
|
-
const ttlDays = Number(tzCache.ttlDays ?? 7);
|
|
28
31
|
const age = Date.now() - Number(d?.fetchedAt || 0);
|
|
29
|
-
extCache = { mtime: st.mtimeMs, data: d, stale: !Number.isFinite(age) || age > ttlDays * 86400000 };
|
|
32
|
+
extCache = { mtime: st.mtimeMs, ttlDays, data: d, stale: !Number.isFinite(age) || age > ttlDays * 86400000 };
|
|
30
33
|
}
|
|
31
34
|
} catch {
|
|
32
|
-
extCache = { mtime: -1, data: null, stale: false };
|
|
35
|
+
extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
|
|
33
36
|
}
|
|
34
37
|
return extCache;
|
|
35
38
|
}
|
|
@@ -61,7 +64,7 @@ export async function refreshPricingFromSource(cfg) {
|
|
|
61
64
|
const file = pricingFilePath();
|
|
62
65
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
66
|
fs.writeFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
|
|
64
|
-
extCache = { mtime: -1, data: null, stale: false };
|
|
67
|
+
extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
|
|
65
68
|
const names = Object.keys(models).join('、');
|
|
66
69
|
return { ok: true, lines: ['✓ 价格表已刷新(' + names + '),TTL 内费用估算/护栏/避峰自动跟随'] };
|
|
67
70
|
}
|
|
@@ -49,8 +49,10 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
49
49
|
const j = JSON.parse(raw);
|
|
50
50
|
if (j?.error?.message) detail = j.error.message;
|
|
51
51
|
} catch {}
|
|
52
|
+
// 本地模型内存不足(507 memory_refusal):给可操作降级提示而非裸状态码(v0.4.2 本地模型 507 修复)
|
|
53
|
+
const hint = res.status === 507 ? '(本地模型内存不足:请压缩上下文、减少并发子任务,或重启模型服务释放内存)' : '';
|
|
52
54
|
/** @type {ApiError} */
|
|
53
|
-
const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}`);
|
|
55
|
+
const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}${hint}`);
|
|
54
56
|
e.status = res.status;
|
|
55
57
|
e.headers = res.headers; // 重试退避读取 Retry-After 用
|
|
56
58
|
throw e;
|
package/src/redact.js
CHANGED
|
@@ -21,6 +21,10 @@ export function redactSensitive(/** @type {any} */ text) {
|
|
|
21
21
|
s = s.replace(/\b(?:10|127)(?:\.\d{1,3}){3}\b|\b192\.168(?:\.\d{1,3}){2}\b|\b169\.254(?:\.\d{1,3}){2}\b|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b|\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\b/g, '[私网IP]');
|
|
22
22
|
s = s.replace(/(?:fe80:[\da-f:]+|::1|::)/gi, '[链路本地/回环IPv6]');
|
|
23
23
|
const home = os.homedir();
|
|
24
|
-
|
|
24
|
+
// 审计 P3-1(v0.4.2):split().join() 无边界——/home/user2/xxx 会被误脱敏为 ~2/xxx。
|
|
25
|
+
// 正则要求家目录后跟路径分隔符或字符串结尾(如 /home/user2 的 2 紧跟目录名不匹配)。
|
|
26
|
+
if (home && home.length > 1) {
|
|
27
|
+
s = s.replace(new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=/|$)', 'g'), '~');
|
|
28
|
+
}
|
|
25
29
|
return s;
|
|
26
30
|
}
|
package/src/skill-lib.js
CHANGED
|
@@ -13,9 +13,11 @@ import fs from 'node:fs';
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
|
-
import {
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { lookup } from 'node:dns/promises';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
20
|
+
import { isPrivateHost } from './tools/fetch.js';
|
|
19
21
|
|
|
20
22
|
const LIB_DIR = fileURLToPath(new URL('../skills-lib', import.meta.url));
|
|
21
23
|
|
|
@@ -250,7 +252,7 @@ export function installFromDir(dir) {
|
|
|
250
252
|
/**
|
|
251
253
|
* @param {any} url
|
|
252
254
|
*/
|
|
253
|
-
export async function installFromUrl(url) {
|
|
255
|
+
export async function installFromUrl(url, { allowPrivate = false } = {}) {
|
|
254
256
|
let u;
|
|
255
257
|
try {
|
|
256
258
|
u = new URL(url);
|
|
@@ -264,7 +266,41 @@ export async function installFromUrl(url) {
|
|
|
264
266
|
const timer = setTimeout(() => ctrl.abort(), 30000);
|
|
265
267
|
let text;
|
|
266
268
|
try {
|
|
267
|
-
|
|
269
|
+
// 审计 P2-1(v0.4.2):SSRF 防护——与 fetch 工具/validateRemoteUrl 同口径:
|
|
270
|
+
// 初始与每一跳重定向都做私网/回环字面量判定 + DNS 复检(防域名重绑定),跳数上限 5。
|
|
271
|
+
// allowPrivate(CLI 显式输入 URL 时开启):本地用户自担意图,内网地址可安装;WebUI 默认拦截。
|
|
272
|
+
let cur = u;
|
|
273
|
+
let res = /** @type {any} */ (null);
|
|
274
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
275
|
+
const ch = String(cur.hostname || '').toLowerCase();
|
|
276
|
+
let blocked = !allowPrivate && isPrivateHost(ch);
|
|
277
|
+
if (!blocked && ch && ch !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ch)) {
|
|
278
|
+
try {
|
|
279
|
+
const addrs = await lookup(ch, { all: true, verbatim: true });
|
|
280
|
+
blocked = !allowPrivate && addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
|
|
281
|
+
} catch {
|
|
282
|
+
// DNS 解析失败:放行,连接阶段会报错
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (blocked) return { error: `拒绝访问内网/本机地址(${ch})——SSRF 防护。` };
|
|
286
|
+
res = await fetch(cur, { signal: ctrl.signal, redirect: 'manual' });
|
|
287
|
+
if (res.status >= 300 && res.status < 400) {
|
|
288
|
+
if (hop >= 5) return { error: '重定向次数超过上限(5 跳)。' };
|
|
289
|
+
const loc = res.headers.get('location');
|
|
290
|
+
if (!loc) break;
|
|
291
|
+
try {
|
|
292
|
+
cur = new URL(loc, cur);
|
|
293
|
+
} catch {
|
|
294
|
+
return { error: `非法重定向地址:${loc}` };
|
|
295
|
+
}
|
|
296
|
+
if (cur.protocol !== 'http:' && cur.protocol !== 'https:') {
|
|
297
|
+
return { error: '重定向到非 http(s) 地址,已拒绝。' };
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
if (!res) return { error: '下载失败:无响应' };
|
|
268
304
|
if (!res.ok) return { error: `下载失败:HTTP ${res.status}` };
|
|
269
305
|
text = await res.text();
|
|
270
306
|
if (text.length > 512 * 1024) return { error: 'SKILL.md 超过 512KB 上限' };
|
|
@@ -290,23 +326,34 @@ export async function installFromUrl(url) {
|
|
|
290
326
|
return r;
|
|
291
327
|
}
|
|
292
328
|
|
|
329
|
+
/** 异步 spawn(审计 P1-3):child_process.spawn + Promise,返回 { error?, code, signal }。 */
|
|
330
|
+
function runSpawn(/** @type {string} */ cmd, /** @type {string[]} */ args, /** @type {{ timeoutMs?: number }} */ { timeoutMs } = {}) {
|
|
331
|
+
return new Promise((resolve) => {
|
|
332
|
+
const child = spawn(cmd, args, { stdio: 'ignore', timeout: timeoutMs });
|
|
333
|
+
child.on('error', (/** @type {any} */ err) => resolve({ error: err }));
|
|
334
|
+
child.on('close', (code, signal) => resolve({ code, signal }));
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
293
338
|
/**
|
|
294
339
|
* @param {any} gitUrl
|
|
295
340
|
*/
|
|
296
|
-
export function installFromGit(gitUrl) {
|
|
341
|
+
export async function installFromGit(gitUrl) {
|
|
297
342
|
if (typeof gitUrl !== 'string' || gitUrl.trim().startsWith('-')) {
|
|
298
343
|
return { error: 'git 地址不能以 - 开头(防选项注入)' };
|
|
299
344
|
}
|
|
300
|
-
|
|
301
|
-
|
|
345
|
+
// 审计 P1-3(v0.4.2):spawnSync 最长阻塞 120s 冻结整个 Node 事件循环(WebUI 全部并发会话/
|
|
346
|
+
// 权限确认/SSE 流无响应)。改异步 spawn,与 v0.4.1 mountConfigTools 修复同口径。
|
|
347
|
+
const check = await runSpawn('git', ['--version']);
|
|
348
|
+
if (check.error || check.code !== 0) {
|
|
302
349
|
return { error: '未找到 git(git 仓库安装需要系统 git,可用 URL 安装单文件技能)' };
|
|
303
350
|
}
|
|
304
351
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mingdao-skill-git-'));
|
|
305
352
|
// -- 分隔符:gitUrl 即使形似选项也只按路径处理
|
|
306
|
-
const r =
|
|
307
|
-
if (r.error || r.
|
|
353
|
+
const r = await runSpawn('git', ['clone', '--depth', '1', '--', gitUrl, tmp], { timeoutMs: 120000 });
|
|
354
|
+
if (r.error || r.code !== 0) {
|
|
308
355
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
309
|
-
return { error: `git clone 失败:${r.error?.message || `退出码 ${r.
|
|
356
|
+
return { error: `git clone 失败:${r.error?.message || (r.signal ? `超时/被终止(${r.signal})` : `退出码 ${r.code}`)}` };
|
|
310
357
|
}
|
|
311
358
|
const found = [];
|
|
312
359
|
const stack = [tmp];
|
|
@@ -388,15 +435,16 @@ export async function reinstallSkill(name) {
|
|
|
388
435
|
// 统一入口:自动识别 库名 | 本地目录 | SKILL.md URL | git 仓库
|
|
389
436
|
/**
|
|
390
437
|
* @param {any} arg
|
|
438
|
+
* @param {{ allowPrivateUrl?: boolean }} [opts] allowPrivateUrl=true 时 URL 安装放行内网地址(CLI 显式输入场景)
|
|
391
439
|
*/
|
|
392
|
-
export async function installSkill(arg) {
|
|
440
|
+
export async function installSkill(arg, opts = {}) {
|
|
393
441
|
const a = String(arg || '').trim();
|
|
394
442
|
if (!a) return { error: '缺少参数:mingdao skill install <库名|目录|SKILL.md URL|git 仓库地址>' };
|
|
395
443
|
const libHit = libraryList().find((s) => s.name === a);
|
|
396
444
|
if (libHit) return installFromLibrary(a);
|
|
397
445
|
if (fs.existsSync(path.resolve(a))) return installFromDir(a);
|
|
398
446
|
if (/^https?:\/\//i.test(a)) {
|
|
399
|
-
if (/\.md(#.*)?$/i.test(a.split('?')[0])) return installFromUrl(a);
|
|
447
|
+
if (/\.md(#.*)?$/i.test(a.split('?')[0])) return installFromUrl(a, { allowPrivate: opts.allowPrivateUrl === true });
|
|
400
448
|
return installFromGit(a);
|
|
401
449
|
}
|
|
402
450
|
if (/^git@/.test(a)) return installFromGit(a);
|
package/src/sync-server.js
CHANGED
|
@@ -347,10 +347,10 @@ function doPush(username, body) {
|
|
|
347
347
|
const dir = sessionsDir(username);
|
|
348
348
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
349
349
|
const file = path.join(dir, name);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
fs.renameSync(tmp, file);
|
|
350
|
+
// 审计 P1-2(v0.4.2):内容落盘必须在锁内且原子——此前固定 .tmp 名 + 锁外写,
|
|
351
|
+
// 并发同名 push 互相覆盖 tmp、rename 竞态,内容与 meta size 可能错位。
|
|
353
352
|
return withWriteLock(() => {
|
|
353
|
+
atomicWriteFileSync(file, content, { mode: 0o600 });
|
|
354
354
|
const meta = readJson(metaFile(username), {});
|
|
355
355
|
meta[name] = { mtime: Date.now(), size: Buffer.byteLength(content) };
|
|
356
356
|
writeJson(metaFile(username), meta);
|
package/src/tools/bash.js
CHANGED
|
@@ -15,7 +15,8 @@ const MAX_TIMEOUT_SECONDS = 600;
|
|
|
15
15
|
// 整体关闭(回到完全透传)。
|
|
16
16
|
const SENSITIVE_ENV_PAIR = /(api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)/i;
|
|
17
17
|
const SENSITIVE_ENV_SEGMENT = /(^|_)(token|secret|password|passwd|credential|authorization|auth)(_|$)/i;
|
|
18
|
-
|
|
18
|
+
// 审计 P3-6(v0.4.2):导出供 hooks.js 复用——hook 子进程 env 与 bash 工具同口径过滤敏感变量。
|
|
19
|
+
export const isSensitiveEnv = (/** @type {any} */ k) => SENSITIVE_ENV_PAIR.test(k) || SENSITIVE_ENV_SEGMENT.test(k);
|
|
19
20
|
|
|
20
21
|
function buildChildEnv(/** @type {any} */ ctx, /** @type {any} */ filterSensitive) {
|
|
21
22
|
if (!filterSensitive) return process.env;
|
|
@@ -87,7 +88,9 @@ export function runBash(/** @type {any} */ args, /** @type {any} */ ctx) {
|
|
|
87
88
|
if (!command.trim()) return { ok: false, error: 'command 参数为空。' };
|
|
88
89
|
const timeoutSec = Math.min(Number(args.timeout) || 120, MAX_TIMEOUT_SECONDS);
|
|
89
90
|
// 配置优先:模型不能通过传 sandbox:'off' 自行降级(配置里选了 safe/readonly 就必须沙箱)
|
|
90
|
-
|
|
91
|
+
// 审计 P3-9(v0.4.2):cfg.sandbox=''(空串)时 ?? 不触发,mode 为空串落入 readonly 沙箱分支——
|
|
92
|
+
// || 'off' 归一化空串回默认 off。
|
|
93
|
+
const mode = String(ctx?.cfg?.sandbox ?? args.sandbox ?? 'off') || 'off';
|
|
91
94
|
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
|
|
92
95
|
const shellArgs = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
|
|
93
96
|
|
package/src/tools/fetch.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// SSRF 防护:字面量私网/回环拒绝 + DNS 解析复检(防域名重绑定),口径与 server.js validateRemoteUrl 一致。
|
|
3
3
|
import { lookup } from 'node:dns/promises';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// 审计 4.1(v0.4.2):isPrivateHost 导出复用——skill-lib.installFromUrl 等外联入口共用同一判定,
|
|
6
|
+
// 避免「漏一处」的 SSRF 防护缺口(此前 fetch 工具与 server.validateRemoteUrl 各自维护一份)。
|
|
7
|
+
export function isPrivateHost(/** @type {string} */ hostname) {
|
|
6
8
|
let h = String(hostname || '').toLowerCase();
|
|
7
9
|
if (!h) return true;
|
|
8
10
|
h = h.replace(/^\[|\]$/g, '');
|
package/src/web/app.js
CHANGED
|
@@ -64,6 +64,7 @@ function initTips() {
|
|
|
64
64
|
const perm = $('#permSel'); if (perm) attachTip(perm, perm.getAttribute('title'));
|
|
65
65
|
const reas = $('#reasoningSel'); if (reas) attachTip(reas, reas.getAttribute('title'));
|
|
66
66
|
const model = $('#modelSel'); if (model) attachTip(model, () => { const o = model.options[model.selectedIndex]; return (o && o.title) ? o.title : '切换模型'; });
|
|
67
|
+
const preset = $('#presetSel'); if (preset) attachTip(preset, () => { const o = preset.options[preset.selectedIndex]; return (o && o.title) ? o.title : '智能体预设:一键切换工具白名单/权限/参数组合(选「无预设」恢复默认)'; });
|
|
67
68
|
const at = $('#attachBtn'); if (at) attachTip(at, at.getAttribute('title'));
|
|
68
69
|
}
|
|
69
70
|
initTips();
|
|
@@ -148,6 +149,7 @@ $('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirM
|
|
|
148
149
|
$('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
|
|
149
150
|
$('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
|
|
150
151
|
const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
|
|
152
|
+
let presetData = []; // v0.4.0 Agent Preset:预设列表缓存(含 description/tools/permission,供下拉提示与选中反馈)
|
|
151
153
|
let activeAiMsg=null, bgRunning=0, curSteps=0, curWorkT0=0, curTools=0, curTasks=0; // 本轮进度(活动条/状态条/轨迹共用)
|
|
152
154
|
let bgTasks=[]; // 后台任务列表快照(chip tooltip 详情用,updateTasksPanel 每 2s 刷新)
|
|
153
155
|
let curPhase='模型推理中'; // 阶段语义(服务端 progress 事件下发)
|
|
@@ -396,7 +398,12 @@ async function send(){
|
|
|
396
398
|
else if(ev.type==='error'){ console.log('[MingDao] error 事件:' + ev.message); onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
|
|
397
399
|
else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
|
|
398
400
|
});
|
|
399
|
-
}catch(e){ onActivity();
|
|
401
|
+
}catch(e){ onActivity(); // 诊断(v0.4.2 network error 排查):静默中断此前无任何日志,无法区分
|
|
402
|
+
// 「手点停止 / 看门狗 / fetch 流静默断裂」。补记完整错误形态 + 回合状态,下次失败可定位。
|
|
403
|
+
console.error('[MingDao] chat 流异常', { name: e && e.name, message: e && e.message, err: String(e), taskId, rawLen: raw.length, steps: stepsCount, elapsedS: Math.round((Date.now() - workT0) / 1000) });
|
|
404
|
+
const d=document.createElement('div'); d.className='errline';
|
|
405
|
+
d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):((e&&e.message)||('连接中断,本轮未完成。已执行的工作已保存检查点——直接发送「继续」即可从断点续跑(' + Math.round((Date.now() - workT0) / 1000) + 's · ' + stepsCount + ' 步)。'));
|
|
406
|
+
msg.appendChild(d); scroll(); }
|
|
400
407
|
finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); curTaskId=null; curPhase='模型推理中'; console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); attachTrajMeta(msg); activeAiMsg=null; curWorkT0=0; renderWorkStatus(); scroll(); updateTasksPanel(); }
|
|
401
408
|
}
|
|
402
409
|
|
|
@@ -615,6 +622,23 @@ function applyReasoningUI(reasoning){
|
|
|
615
622
|
sel.value = ['off','low','high','max'].includes(effort) ? effort : 'high';
|
|
616
623
|
}
|
|
617
624
|
$('#reasoningSel').onchange=()=>{ applyConfig({reasoningEffort:$('#reasoningSel').value}); };
|
|
625
|
+
// v0.4.0 Agent Preset:选中预设时给出「这是什么 / 覆盖了什么」的即时反馈(否则用户不知道各选项作用)
|
|
626
|
+
$('#presetSel').onchange=()=>{ presetPicked(); };
|
|
627
|
+
function presetPicked(){
|
|
628
|
+
const val=$('#presetSel')?.value||'';
|
|
629
|
+
const p=presetData.find((x)=>x.name===val);
|
|
630
|
+
if(!p){ renderBanner({ text: '已取消预设,恢复默认配置(不注入预设定制段/白名单/权限覆盖)。' }); return; } // 选回「无预设」= 退出预设模式
|
|
631
|
+
const over=[];
|
|
632
|
+
if(Array.isArray(p.tools)) over.push('工具白名单 '+p.tools.length+' 个');
|
|
633
|
+
if(p.permission) over.push('权限 '+p.permission);
|
|
634
|
+
if(p.model) over.push('模型 '+p.model);
|
|
635
|
+
if(p.maxRounds) over.push('maxRounds '+p.maxRounds);
|
|
636
|
+
if(p.maxOutputTokens) over.push('maxOutput '+p.maxOutputTokens);
|
|
637
|
+
if(p.temperature!==undefined) over.push('温度 '+p.temperature);
|
|
638
|
+
if(p.contextBudget) over.push('预算 '+p.contextBudget);
|
|
639
|
+
const summary=over.length?'(覆盖:'+over.join(' · ')+')':'';
|
|
640
|
+
renderBanner({ text: '🧩 已选预设「'+(p.label||p.name)+'」:'+(p.description||'(无描述)')+summary+'。随本次发送生效,其余设置保持不变。' });
|
|
641
|
+
}
|
|
618
642
|
async function init(){
|
|
619
643
|
try{
|
|
620
644
|
const r=await fetch('/api/state',{cache:'no-store'}); const j=await r.json();
|
|
@@ -653,7 +677,8 @@ async function init(){
|
|
|
653
677
|
try{
|
|
654
678
|
// v0.4.0 Agent Preset:加载预设列表进下拉(项目 → 用户 → 内置)
|
|
655
679
|
const pr=await fetch('/api/presets',{cache:'no-store'}).catch(()=>null); const pj=pr?await pr.json():{presets:[]};
|
|
656
|
-
|
|
680
|
+
presetData = Array.isArray(pj.presets) ? pj.presets : [];
|
|
681
|
+
const psel=$('#presetSel'); if(psel&&presetData.length){ for(const p of presetData){ const o=document.createElement('option'); o.value=p.name; o.textContent=p.label+'('+(p.source==='project'?'项目':p.source==='user'?'用户':'内置')+')'; o.title=p.description||p.name; psel.appendChild(o); } }
|
|
657
682
|
}catch(e){}
|
|
658
683
|
try{
|
|
659
684
|
const dr=await fetch('/api/draft?file='+encodeURIComponent(currentSession||''),{cache:'no-store'}); const dj=await dr.json();
|
package/src/web/index.html
CHANGED
|
@@ -332,7 +332,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
332
332
|
<option value="readonly">只读</option>
|
|
333
333
|
</select>
|
|
334
334
|
<select id="modelSel" title="切换模型"></select>
|
|
335
|
-
<select id="presetSel" title="
|
|
335
|
+
<select id="presetSel" title="智能体预设:一键切换工具白名单/权限/参数组合(选「无预设」恢复默认)"><option value="">无预设</option></select>
|
|
336
336
|
<select id="reasoningSel" title="思考模式(推理等级):关=不推理省 token · 低 · 高(默认)· 最高=最强推理" style="display:none">
|
|
337
337
|
<option value="off">关</option>
|
|
338
338
|
<option value="low">低</option>
|
package/src/web/server.js
CHANGED
|
@@ -516,6 +516,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
516
516
|
});
|
|
517
517
|
|
|
518
518
|
res.on('close', () => {
|
|
519
|
+
// 诊断(v0.4.2 network error 排查):区分「服务端正常收尾」与「客户端中途断开」——
|
|
520
|
+
// writableEnded=false 且任务仍在跑 = 浏览器/渲染层静默断连(此前无任何日志,根因不可见)。
|
|
521
|
+
if (!res.writableEnded && entry.status === 'running') {
|
|
522
|
+
srvlog('chat 客户端断连 ' + taskId + ' 已跑=' + Math.round((Date.now() - entry.startedAt) / 1000) + 's status=' + entry.status + ' writableEnded=' + res.writableEnded);
|
|
523
|
+
}
|
|
519
524
|
// 浏览器断开:中止正在跑的生成(否则白白烧 token),挂起的权限确认按拒绝处理
|
|
520
525
|
if (entry.pendingAsk) {
|
|
521
526
|
entry.pendingAsk.resolve('');
|