mingdao-harness 0.1.59 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,6 +101,14 @@ git clone https://gitee.com/MingDaoTCM/MingDao-harness.git MingDao-Harness && cd
101
101
  node src/cli.js # 直接运行,无需安装
102
102
  ```
103
103
 
104
+ 开发者护栏(提交前建议执行;CI 会强制跑全套):
105
+
106
+ ```bash
107
+ npm install # 仅装 devDependencies(typescript + @types/node),运行时依旧零依赖
108
+ npm run typecheck # tsc --checkJs 类型护栏(覆盖 agent/cli/commands/provider/cachestats 等核心模块)
109
+ node test/smoke.js && node test/e2e-local.js && node test/e2e-web.js && node test/e2e-schedule.js
110
+ ```
111
+
104
112
  ### 验证安装
105
113
 
106
114
  ```bash
package/docs/QA-REPORT.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 质检日期:2026-08-18
4
4
  > 项目路径:`/home/YouLi/AI/DeepSeek-harness-Space/MingDao-Harness`
5
- > 版本:0.4.0(纯 ESM,零 npm 依赖,Node.js ≥ 18.17)
5
+ > 版本:0.1.54(历史归档;现行 0.1.x 口径见根 README 与各版本 tag。纯 ESM,零 npm 依赖,Node.js ≥ 18.17)
6
6
  > 质检方式:全量源码走读 + 冒烟/端到端测试 + 针对性缺陷验证脚本
7
7
 
8
8
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,7 +52,8 @@
52
52
  "typecheck": "tsc -p tsconfig.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
- "desktop:dist": "npm --prefix desktop run dist:dir"
55
+ "desktop:dist": "node scripts/sync-versions.mjs && npm --prefix desktop run dist:dir",
56
+ "desktop:sync": "node scripts/sync-versions.mjs"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/node": "^26.2.0",
package/src/agent.js CHANGED
@@ -16,6 +16,11 @@ import { checkCostGuard } from './cost-guard.js';
16
16
  const MAX_STEPS = 24;
17
17
  const SUBAGENT_MAX_STEPS = 12;
18
18
 
19
+ /**
20
+ * 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
21
+ * @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
22
+ * cfg?: any, undoStore?: any, maxSteps?: number, mcp?: any, onCompact?: any, sessionRef?: any }} params
23
+ */
19
24
  export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
20
25
  const preset = modelPreset(modelName) || {};
21
26
  const budget = cfg.contextBudget || preset.budgetTokens || 128000;
@@ -174,8 +179,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
174
179
  io.startSpinner('正在思考…');
175
180
 
176
181
  let res;
182
+ // 审计(tsc 扩面发现):llmT0 此前在 try 内声明、catch 内引用——chat 抛错时
183
+ // catch 自身 ReferenceError,掩盖原始错误且计时丢失;提到 try 外声明。
184
+ const llmT0 = Date.now();
177
185
  try {
178
- const llmT0 = Date.now();
179
186
  res = await provider.chat({
180
187
  model: modelName,
181
188
  messages: trimmed,
package/src/batch.js CHANGED
@@ -11,7 +11,6 @@ import path from 'node:path';
11
11
  import { resolveProviderConfig } from './providers/index.js';
12
12
  import { estimateBatchCost, BATCH_DISCOUNT } from './pricing.js';
13
13
  import { recordCacheStats } from './cachestats.js';
14
- import { buildSystemPrompt } from './prompts.js';
15
14
 
16
15
  const DEFAULT_WINDOW = '24h';
17
16
  const DEFAULT_ENDPOINT = '/v1/chat/completions';
@@ -26,15 +25,16 @@ function batchBase(cfg, model) {
26
25
  return pc.name === 'deepseek' ? base.replace(/\/v1\/?$/, '') : base;
27
26
  }
28
27
 
28
+ /** @returns {Promise<any>} */
29
29
  async function api(base, apiKey, methodPath, payload, httpMethod = 'POST') {
30
30
  const res = await fetch(base + methodPath, {
31
31
  method: httpMethod,
32
32
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
33
33
  body: payload === undefined ? undefined : JSON.stringify(payload),
34
34
  });
35
- const j = await res.json().catch(() => ({}));
35
+ const j = /** @type {any} */ (await res.json().catch(() => ({})));
36
36
  if (!res.ok) {
37
- const e = new Error(j?.error?.message || j?.message || `HTTP ${res.status}`);
37
+ const e = /** @type {Error & { status?: number }} */ (new Error(j?.error?.message || j?.message || `HTTP ${res.status}`));
38
38
  e.status = res.status;
39
39
  throw e;
40
40
  }
@@ -50,9 +50,9 @@ async function uploadFile(base, apiKey, jsonl) {
50
50
  headers: { Authorization: `Bearer ${apiKey}` },
51
51
  body: form,
52
52
  });
53
- const j = await res.json().catch(() => ({}));
53
+ const j = /** @type {any} */ (await res.json().catch(() => ({})));
54
54
  if (!res.ok) {
55
- const e = new Error(j?.error?.message || j?.message || `上传失败 HTTP ${res.status}`);
55
+ const e = /** @type {Error & { status?: number }} */ (new Error(j?.error?.message || j?.message || `上传失败 HTTP ${res.status}`));
56
56
  e.status = res.status;
57
57
  throw e;
58
58
  }
@@ -84,7 +84,8 @@ async function downloadResults(base, apiKey, batch) {
84
84
  throw new Error('批处理结果文件不可用(端点不支持或文件已过期)');
85
85
  }
86
86
 
87
- // 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId }
87
+ /** 执行一次批处理。questions: string[]。返回 { ok, outputFile, results, usage, cost, batchId }
88
+ * @param {{ cfg: any, model: any, questions: any, workingDir?: string, maxTokens?: number, temperature?: any, signal?: any, onStatus?: any }} opts */
88
89
  export async function runBatch({ cfg, model, questions, workingDir = process.cwd(), maxTokens = 4096, temperature, signal, onStatus }) {
89
90
  const list = (questions || []).map((q) => String(q).trim()).filter(Boolean);
90
91
  if (!list.length) return { error: '没有可批处理的问题(每行一个问题)' };
@@ -92,7 +93,10 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
92
93
  if (!pc.apiKey) return { error: `模型 ${model} 没有可用 API Key(mingdao key set ${pc.name})` };
93
94
  const base = batchBase(cfg, model);
94
95
  const apiKey = pc.apiKey;
95
- const systemPrompt = buildSystemPrompt({ workingDir });
96
+ // 审计(workbuddy P2-1):批量任务无缓存语义,每个问题的 system 都按 input 全价计费——
97
+ // 不再携带完整系统提示(技能清单/用户记忆/AGENTS.md 对无工具批任务毫无意义,1000 问
98
+ // 可白烧 50-80 万 token);改用一行精简角色提示,剩余能力损失为零。
99
+ const systemPrompt = '你是 MingDao Harness 编程助手。直接针对每个问题给出准确、完整的答案,不要复述问题、不要解释过程。';
96
100
  const bodyTemplate = {
97
101
  model,
98
102
  messages: null, // 逐行填充
@@ -122,11 +126,14 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
122
126
  completion_window: cfg?.batchWindow || DEFAULT_WINDOW,
123
127
  });
124
128
  onStatus?.(`任务已创建:${batch.id}`);
125
- // 轮询(间隔可用 MINGDAO_BATCH_POLL_MS 覆盖,测试用);连续失败 10 次 → 报错,绝不无限重试
126
- const interval = Math.max(500, Number(process.env.MINGDAO_BATCH_POLL_MS) || 5000);
129
+ // 轮询:指数退避(审计 workbuddy P3-3)——基础间隔 5s(MINGDAO_BATCH_POLL_MS 可覆盖,测试用),
130
+ // ×1.5 逐次翻倍、30s 封顶:24h 窗口内轮询请求量从 ~1.7 万次降到 ~3 千次;
131
+ // 连续失败 10 次 → 报错,绝不无限重试。每次轮询报告进度(含已处理 X/Y)。
132
+ const baseInterval = Math.max(500, Number(process.env.MINGDAO_BATCH_POLL_MS) || 5000);
127
133
  const t0 = Date.now();
128
134
  let st = batch.status;
129
135
  let failures = 0;
136
+ let polls = 0;
130
137
  for (;;) {
131
138
  if (signal?.aborted) return { error: '已取消轮询(任务仍在服务端运行)', batchId: batch.id };
132
139
  if (Date.now() - t0 > 24 * 3600 * 1000) return { error: '批处理超过 24h 窗口', batchId: batch.id };
@@ -137,7 +144,7 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
137
144
  } catch (err) {
138
145
  failures += 1;
139
146
  if (failures >= 10) return { error: `轮询失败:${err?.message || err}(任务仍在服务端,ID ${batch.id})`, batchId: batch.id };
140
- await new Promise((r) => setTimeout(r, interval));
147
+ await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** failures, 30000)));
141
148
  continue;
142
149
  }
143
150
  st = j.status;
@@ -149,11 +156,11 @@ export async function runBatch({ cfg, model, questions, workingDir = process.cwd
149
156
  const detail = j?.errors?.data?.[0]?.message || j?.errors?.message || '';
150
157
  return { error: `批处理失败:${st}${detail ? '(' + detail + ')' : ''}`, batchId: batch.id };
151
158
  }
152
- if (st !== 'in_progress' && st !== 'validating' && st !== 'finalizing') {
153
- // 未知状态:继续等待但报告
154
- onStatus?.(`状态:${st}`);
155
- }
156
- await new Promise((r) => setTimeout(r, interval));
159
+ polls += 1;
160
+ const rc = j?.request_counts || {};
161
+ const done = rc.completed != null && rc.total != null ? `${rc.completed}/${rc.total}` : '';
162
+ onStatus?.(`状态:${st}${done ? `(已处理 ${done})` : ''}`);
163
+ await new Promise((r) => setTimeout(r, Math.min(baseInterval * 1.5 ** polls, 30000)));
157
164
  }
158
165
  onStatus?.('下载结果…');
159
166
  const results = await downloadResults(base, apiKey, batch);
package/src/cachestats.js CHANGED
@@ -49,17 +49,27 @@ export function recordCacheStats(entry) {
49
49
  }
50
50
  }
51
51
 
52
+ // listCacheStats 读取缓存(审计:costGuard 每步 / WebUI 每 15s / /cost 命令都调用——
53
+ // 四报告共识 P2-2/§3.3-A:全文件读+逐行 parse 会随 JSONL 增长线性放大 IO。
54
+ // 按 mtimeMs+size 双键缓存;写入追加会改两者,轮转重写同样改,缓存自动失效)
55
+ let _statsCache = null;
56
+
52
57
  export function listCacheStats(limit = 2000) {
53
58
  try {
54
- const raw = fs.readFileSync(cacheStatsFile(), 'utf8');
55
- const out = [];
56
- for (const l of raw.split('\n')) {
57
- if (!l.trim()) continue;
58
- try {
59
- out.push(JSON.parse(l));
60
- } catch {}
59
+ const file = cacheStatsFile();
60
+ const st = fs.statSync(file);
61
+ if (!_statsCache || _statsCache.mtimeMs !== st.mtimeMs || _statsCache.size !== st.size || _statsCache.limit < limit) {
62
+ const raw = fs.readFileSync(file, 'utf8');
63
+ const out = [];
64
+ for (const l of raw.split('\n')) {
65
+ if (!l.trim()) continue;
66
+ try {
67
+ out.push(JSON.parse(l));
68
+ } catch {}
69
+ }
70
+ _statsCache = { mtimeMs: st.mtimeMs, size: st.size, limit, lines: out };
61
71
  }
62
- return out.slice(-limit);
72
+ return _statsCache.lines.slice(-limit);
63
73
  } catch {
64
74
  return [];
65
75
  }
package/src/cli.js CHANGED
@@ -23,7 +23,7 @@ import { enableAutostart, disableAutostart, autostartStatus, autostartPath } fro
23
23
  import { notifyTaskDone } from './notify.js';
24
24
  import { addWorkspace, removeWorkspace, workspacePath, touchWorkspace, listWorkspaces, currentWorkspace } from './workspace.js';
25
25
  import { finalizeSession, extractMemory, loadMemory, appendMemory, recentJournal, dedupeMemory, removeMemoryLines } from './memory.js';
26
- import { recordUsage, listCacheStats, summarizeCacheStats, formatCacheSummary } from './cachestats.js';
26
+ import { recordUsage, listCacheStats, summarizeCacheStats, formatCacheSummary, costBreakdown } from './cachestats.js';
27
27
  import { presetList, buildPreset } from './mcp-presets.js';
28
28
  import {
29
29
  addSchedule,
@@ -436,7 +436,8 @@ async function main() {
436
436
  if (opts.init) return;
437
437
  }
438
438
 
439
- let modelName = opts.model || cfg.model || 'deepseek-v4-flash';
439
+ // 模型回退链(向导允许跳过模型选择 cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
440
+ let modelName = opts.model || cfg.model || PROVIDERS[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
440
441
  const io = createIO();
441
442
 
442
443
  const pc0 = resolveProviderConfig(cfg, modelName);
@@ -508,10 +509,12 @@ async function main() {
508
509
  const route = await routeTask({ cfg, provider, currentModel: modelName, text: question });
509
510
  if (route.model !== modelName) {
510
511
  if (!jsonMode) io.print(style(`⤷ 自动路由 → ${route.model}(${route.reason})`, C.dim));
512
+ // 审计 P1-2(第五轮复审实证):先按新模型重建 provider、再改模型名——
513
+ // 此前顺序颠倒(先赋值再判断),条件恒假,跨服务商路由池会把 executor 模型名
514
+ // 发到 planner 的 baseUrl/key 上(401/404)。默认同服务商配置不受影响。
515
+ provider = await createProvider(cfg, route.model);
511
516
  modelName = route.model;
512
517
  }
513
- // 审计 P1-2:自动路由改换模型后必须重建对应 provider(此前用旧模型的 baseUrl/key 发请求)
514
- if (route.model !== modelName) provider = await createProvider(cfg, modelName);
515
518
  // JSON 模式:关闭流式输出,结果以单行 JSON 输出(脚本/管道友好)
516
519
  const turnIo = jsonMode ? createIO({ quiet: true }) : io;
517
520
  const session = createSession(home);
@@ -608,6 +611,22 @@ async function main() {
608
611
  ].filter(Boolean));
609
612
  io.print(style('输入问题开始对话 · /help 查看命令 · Tab 补全 · Ctrl+C 中断生成\n', C.dim));
610
613
 
614
+ // WebUI 自动启动(首次运行 mingdao web 时可选开启,存 config.web.autoStart):
615
+ // 独立后台进程拉起,退出 TUI 后 WebUI 继续可用;关闭:mingdao web --no-autostart
616
+ if (cfg.web?.autoStart && !process.env.MINGDAO_NO_WEB_AUTOSTART) {
617
+ try {
618
+ const { spawn } = await import('node:child_process');
619
+ const { fileURLToPath } = await import('node:url');
620
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'web'], {
621
+ detached: true,
622
+ stdio: 'ignore',
623
+ env: process.env,
624
+ });
625
+ child.unref();
626
+ io.print(style(`🌐 WebUI 后台启动中:http://127.0.0.1:${cfg.web?.port || 3820}(关闭自动启动:mingdao web --no-autostart)`, C.dim));
627
+ } catch {}
628
+ }
629
+
611
630
  let session = null;
612
631
  if (opts.resume) {
613
632
  const list = listSessions(home).slice(0, 10);
@@ -712,6 +731,9 @@ async function main() {
712
731
  if (input.startsWith('/')) {
713
732
  const [cmd, ...rest] = input.split(/\s+/);
714
733
  const arg = rest.join(' ');
734
+ // 审计(第五轮 P1-1 教训):斜杠命令统一 try/catch——单条命令异常只提示不退出,
735
+ // 绝不再因一条命令的错误杀死整个 REPL 会话(历史 P1-1 曾导致会话上下文全丢)
736
+ try {
715
737
  if (cmd === '/exit' || cmd === '/quit') break;
716
738
  else if (cmd === '/help') printHelpLines(io.print);
717
739
  else if (cmd === '/clear') {
@@ -993,6 +1015,10 @@ async function main() {
993
1015
  io.print(style('未知命令,输入 /help 查看可用命令。', C.yellow));
994
1016
  }
995
1017
  continue;
1018
+ } catch (err) {
1019
+ io.print(style('[错误] 命令执行失败:' + (err?.message || err), C.red));
1020
+ continue;
1021
+ }
996
1022
  }
997
1023
 
998
1024
  // 自动路由:规划类任务切 planner,执行类走 executor(会话粘滞 + 分类缓存见 routing.js)
@@ -2,10 +2,21 @@
2
2
  import { listSkills, tamperedSkillNames } from '../skills.js';
3
3
  import { libraryList, searchLibrary, installSkill, uninstallSkill, reinstallSkill, trustSkill } from '../skill-lib.js';
4
4
  import { searchRegistry } from '../skill-registry.js';
5
- import { loadConfig } from '../config.js';
6
- import { ensureHome } from '../config.js';
5
+ import { loadConfig, saveConfig, ensureHome } from '../config.js';
7
6
  import { searchSessions, relativeTime } from '../session.js';
8
7
  import { runWebServer } from '../web/server.js';
8
+ import readline from 'node:readline';
9
+
10
+ // 首次运行询问「是否自动后台启动 WebUI」(交互终端才问;管道/脚本下默认否)
11
+ function askAutoStart() {
12
+ return new Promise((resolve) => {
13
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
14
+ rl.question('是否在每次运行 mingdao 时自动后台启动 WebUI(免敲命令)?[y/N] ', (a) => {
15
+ rl.close();
16
+ resolve(/^y(es)?$/i.test(String(a).trim()));
17
+ });
18
+ });
19
+ }
9
20
 
10
21
  export async function handleSkill(cmd, args) {
11
22
  const sub = args[0] || 'list';
@@ -102,10 +113,13 @@ export async function handleSkill(cmd, args) {
102
113
  return true;
103
114
  }
104
115
 
105
- // WebUI:mingdao web [端口] [--auth-token <令牌>](评估 P3-1:参数结构不合法的按提问处理)
116
+ // WebUI:mingdao web [端口] [--auth-token <令牌>] [--autostart|--no-autostart](评估 P3-1:参数结构不合法的按提问处理)
117
+ // --autostart/--no-autostart:写 config.web.autoStart——开启后每次运行 mingdao 会自动后台拉起 WebUI;
118
+ // 首次交互运行且未设置时,询问一次「下次是否自动启动」。
106
119
  export async function handleWeb(cmd, args) {
107
120
  let portIndex = -1;
108
121
  let tokenSeen = false;
122
+ let autoChoice; // undefined=未指定;true/false=显式指定
109
123
  for (let i = 0; i < args.length; i++) {
110
124
  const a = args[i];
111
125
  if (a === '--auth-token') {
@@ -119,6 +133,11 @@ export async function handleWeb(cmd, args) {
119
133
  tokenSeen = true;
120
134
  continue;
121
135
  }
136
+ if (a === '--autostart' || a === '--no-autostart') {
137
+ if (autoChoice !== undefined) return false;
138
+ autoChoice = a === '--autostart';
139
+ continue;
140
+ }
122
141
  if (/^\d+$/.test(a) && portIndex === -1) {
123
142
  portIndex = i; // 审计 P2-11:记录端口实际位置,而非默认读首参
124
143
  continue;
@@ -126,6 +145,21 @@ export async function handleWeb(cmd, args) {
126
145
  return false;
127
146
  }
128
147
  const cfg0 = loadConfig();
148
+ // 自动启动开关(先落盘再起服务,服务失败也保留用户选择)
149
+ if (autoChoice !== undefined) {
150
+ const c = cfg0 ? { ...cfg0, web: { ...(cfg0.web || {}), autoStart: autoChoice } } : { web: { autoStart: autoChoice } };
151
+ saveConfig(c);
152
+ console.log(
153
+ autoChoice
154
+ ? '✓ 已开启 WebUI 自动启动:以后直接运行 mingdao 即可后台拉起(关闭:mingdao web --no-autostart)'
155
+ : '✓ 已关闭 WebUI 自动启动'
156
+ );
157
+ } else if (cfg0?.web?.autoStart === undefined && process.stdin.isTTY) {
158
+ const ans = await askAutoStart();
159
+ const c = { ...cfg0, web: { ...(cfg0.web || {}), autoStart: ans } };
160
+ saveConfig(c);
161
+ if (ans) console.log('✓ 已开启:以后直接运行 mingdao 即可自动后台启动 WebUI(关闭:mingdao web --no-autostart)');
162
+ }
129
163
  const portArg = portIndex !== -1 ? Number(args[portIndex]) : NaN;
130
164
  const port = Number.isFinite(portArg) && portArg > 0 ? portArg : cfg0?.web?.port || 3820;
131
165
  const host = cfg0?.web?.host || '127.0.0.1';
@@ -18,7 +18,8 @@ import {
18
18
 
19
19
  async function askHidden(question) {
20
20
  return new Promise((resolve) => {
21
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
21
+ // _writeToOutput readline 内部接口:静音回显(密码输入),类型护栏下显式 any
22
+ const rl = /** @type {any} */ (readline.createInterface({ input: process.stdin, output: process.stdout }));
22
23
  const orig = rl._writeToOutput;
23
24
  rl._writeToOutput = () => {};
24
25
  rl.question(question, (a) => {
package/src/compact.js CHANGED
@@ -19,12 +19,12 @@ const INPUT_MAX_CHARS = 30000; // 摘要输入上限(防超长工具输出撑
19
19
  const TOOL_OUTPUT_CAP = 300; // 摘要输入中每条工具结果截断长度
20
20
 
21
21
  const SUMMARY_SYSTEM =
22
- '你是 MingDao 的会话压缩器。把对话记录压成紧凑中文摘要(≤500 字,要点列表):' +
22
+ '你是 MingDao Harness 的会话压缩器。把对话记录压成紧凑中文摘要(≤500 字,要点列表):' +
23
23
  '保留用户目标与关键要求、已完成的步骤与结论、修改/创建的文件、未完成事项、重要约定与决策;' +
24
- '省略已完成的中间过程与细节。只输出摘要本身,不要任何解释。';
24
+ '省略已完成的中间过程与细节。只输出 JSON:{"summary": "摘要内容"}。';
25
25
 
26
26
  export async function summarizeConversation(provider, model, convoText) {
27
- const res = await provider.chat({
27
+ const base = {
28
28
  model,
29
29
  messages: [
30
30
  { role: 'system', content: SUMMARY_SYSTEM },
@@ -32,11 +32,26 @@ export async function summarizeConversation(provider, model, convoText) {
32
32
  ],
33
33
  tools: [],
34
34
  temperature: 0.2,
35
- maxTokens: 2048, // 审计 Q2:与 SUMMARY_MAX_CHARS(1600 字) 匹配,避免模型侧先截断
36
- });
37
- const text = String(res?.text || '').trim();
38
- if (!text) return { text: null, usage: res?.usage || null };
39
- return { text: text.slice(0, SUMMARY_MAX_CHARS), usage: res?.usage || null };
35
+ };
36
+ // 结构化输出(审计 MiniMax §3.3-D):压缩是 30K 输入 × pro 价的大开销,与标题/记忆/路由
37
+ // 一致改 json_object + maxTokens 2048→1024(1600 字摘要足够);网关不支持时回退纯文本。
38
+ let text = '';
39
+ let usage = null;
40
+ try {
41
+ const res = await provider.chat({ ...base, maxTokens: 1024, responseFormat: { type: 'json_object' } });
42
+ const j = JSON.parse(String(res?.text || '').trim());
43
+ text = String(j?.summary || '').trim();
44
+ usage = res?.usage || null;
45
+ } catch {}
46
+ if (!text) {
47
+ try {
48
+ const res = await provider.chat({ ...base, maxTokens: 1024 });
49
+ text = String(res?.text || '').trim();
50
+ usage = res?.usage || null;
51
+ } catch {}
52
+ }
53
+ if (!text) return { text: null, usage };
54
+ return { text: text.slice(0, SUMMARY_MAX_CHARS), usage };
40
55
  }
41
56
 
42
57
  export async function compactConversation({ messages, budget, count, provider, executorModel, triggerRatio }) {
package/src/config.js CHANGED
@@ -23,6 +23,8 @@ export function configPath() {
23
23
  return path.join(mingdaoHome(), 'config.json');
24
24
  }
25
25
 
26
+ /** 读取配置对象(不存在/损坏返回 null);返回值为用户可编辑的任意 JSON 配置,类型不定
27
+ * @returns {any} */
26
28
  export function loadConfig() {
27
29
  try {
28
30
  return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
@@ -45,7 +47,7 @@ export function effectiveApiKey(cfg, providerName) {
45
47
  }
46
48
 
47
49
  export async function runWizard(io) {
48
- io.box('MingDao 初始化向导', ['三步完成:选服务商/模型 → 填 API Key 选权限模式']);
50
+ io.box('MingDao Harness 初始化向导', [' 选服务商 填 API Key(自动验证)→ 选模型(可跳过)']);
49
51
  io.print('');
50
52
 
51
53
  const providerKeys = Object.keys(PROVIDERS);
@@ -55,33 +57,63 @@ export async function runWizard(io) {
55
57
  );
56
58
  const pp = PROVIDERS[provider];
57
59
 
58
- let model;
60
+ let baseUrl = '';
59
61
  if (provider === 'custom') {
60
- model = await io.ask('② 模型名(例如 deepseek-v4-flash):');
61
- } else {
62
- const options = pp.models.map((m) => {
63
- const preset = modelPreset(m);
64
- return { value: m, label: preset ? `${m} — ${preset.label}` : m };
65
- });
66
- options.push({ value: '__custom__', label: '自定义模型名(手动输入)' });
67
- const choice = await io.choose('② 选择模型:', options);
68
- model = choice === '__custom__' ? await io.ask('模型名:') : choice;
62
+ baseUrl = await io.ask(`API 地址(回车默认 ${pp.baseUrl}):`);
69
63
  }
70
-
71
- const envDetected = pp.envKey && process.env[pp.envKey] ? `(检测到环境变量 ${pp.envKey},回车直接使用)` : '';
72
- const apiKey = await io.ask(`③ API Key${envDetected}:`, { hidden: true });
73
-
74
- if (apiKey) {
75
- setStoredKey(provider, apiKey);
76
- io.print(`✓ API Key 已保存到独立凭证库(权限 600),不会写入 config.json。`);
77
- } else {
78
- io.print(`✓ 未输入 API Key:将使用环境变量 ${pp.envKey || 'MINGDAO_API_KEY'}。`);
64
+ const provisional = { provider, baseUrl: baseUrl || pp.baseUrl };
65
+ const hasEnvKey = () => Boolean((pp.envKey && process.env[pp.envKey]) || process.env.MINGDAO_API_KEY);
66
+
67
+ // ② 选定服务商后立即输入该服务商的 API Key,随后调用 /models 验证有效性
68
+ let apiKey = '';
69
+ let models = null; // 验证通过的线上模型名单
70
+ let verifyError = null;
71
+ for (let tries = 0; tries < 3; tries++) {
72
+ const envDetected = hasEnvKey() ? '(检测到环境变量,回车直接使用)' : '';
73
+ const input = await io.ask(`② ${provider} 的 API Key${envDetected}:`, { hidden: true });
74
+ apiKey = String(input || '').trim();
75
+ if (apiKey) setStoredKey(provider, apiKey);
76
+ if (!apiKey && !hasEnvKey()) {
77
+ const skipped = await io.confirm(' 未输入 API Key,跳过密钥设置?');
78
+ if (!skipped) continue;
79
+ break;
80
+ }
81
+ io.print(' 正在验证 API Key 有效性(调用 /models)…');
82
+ const { fetchProviderModels } = await import('./model-discovery.js');
83
+ const r = await fetchProviderModels(provisional, provider, { force: true });
84
+ if (r.models?.length) {
85
+ models = r.models;
86
+ break;
87
+ }
88
+ verifyError = r.error;
89
+ const retry = await io.confirm(`⚠ 验证失败:${r.error}(可能是密钥错误,或该网关不支持 /models 接口)。重新输入密钥?`);
90
+ if (retry) continue;
91
+ break; // 用户选择继续(稍后自行验证)
79
92
  }
93
+ if (apiKey) io.print('✓ API Key 已保存到独立凭证库(权限 600),不会写入 config.json。');
94
+ else io.print('✓ 未输入 API Key:将使用环境变量(稍后可用 mingdao key set 补齐)。');
95
+ if (models) io.print(`✓ API Key 验证通过:该服务商线上可用模型 ${models.length} 个。`);
96
+ else if (verifyError) io.print('(已跳过验证,稍后可在 WebUI 设置面板「刷新模型」处再次校验)');
80
97
 
81
- let baseUrl = '';
98
+ // 选择模型(允许暂时跳过——不写 config.model,进入后默认用该服务商首个模型,/model 可随时换)
99
+ let model = null;
82
100
  if (provider === 'custom') {
83
- baseUrl = await io.ask(`API 地址(回车默认 ${pp.baseUrl}):`);
101
+ const m = await io.ask('③ 模型名(可留空跳过,稍后 /model 再选):');
102
+ model = m.trim() || null;
103
+ } else {
104
+ const options = [{ value: '__skip__', label: '暂时跳过(稍后 /model 再选)' }];
105
+ for (const m of models || pp.models) {
106
+ const preset = modelPreset(m);
107
+ options.push({ value: m, label: preset ? `${m} — ${preset.label}` : m });
108
+ }
109
+ options.push({ value: '__custom__', label: '自定义模型名(手动输入)' });
110
+ const choice = await io.choose('③ 选择模型:', options);
111
+ if (choice === '__skip__') model = null;
112
+ else if (choice === '__custom__') model = (await io.ask('模型名:')).trim() || null;
113
+ else model = choice;
84
114
  }
115
+ if (model) io.print(`✓ 已选择模型:${model}`);
116
+ else io.print('✓ 已跳过模型选择:进入后自动使用该服务商默认模型,输入 /model 可随时切换。');
85
117
 
86
118
  const perm = await io.choose('权限模式(写文件 / 执行命令时):', [
87
119
  { value: 'ask', label: 'ask — 每次询问(推荐,最安全)' },
@@ -104,7 +136,7 @@ export async function runWizard(io) {
104
136
  if (routeChoice === 'on') routing = { enabled: true, planner: 'deepseek-v4-pro', executor: 'deepseek-v4-flash' };
105
137
  }
106
138
 
107
- const preset = modelPreset(model);
139
+ const preset = model ? modelPreset(model) : null;
108
140
  const defaultBudget = preset?.budgetTokens ?? 128000;
109
141
  const budgetInput = await io.ask(`上下文预算 tokens(回车默认 ${defaultBudget}):`);
110
142
  const contextBudget = Number(budgetInput) > 0 ? Number(budgetInput) : defaultBudget;
@@ -112,18 +144,18 @@ export async function runWizard(io) {
112
144
  // 注意:config.json 不含任何密钥(可安全分享/提交),密钥只存 credentials.json。
113
145
  const cfg = {
114
146
  provider,
115
- model,
116
147
  baseUrl: baseUrl || pp.baseUrl,
117
148
  permission: perm,
118
149
  sandbox,
119
150
  contextBudget,
120
151
  };
152
+ if (model) cfg.model = model;
121
153
  if (routing) cfg.routing = routing;
122
154
  saveConfig(cfg);
123
155
  io.print('');
124
156
  io.box('配置完成 ✓', [
125
157
  `服务商 ${provider}(${pp.label})`,
126
- `模型 ${model}`,
158
+ model ? `模型 ${model}` : '模型 (暂未选择,进入后 /model 随时切换)',
127
159
  `权限 ${perm} · 沙箱 ${sandbox}`,
128
160
  `路由 ${routing ? '自动(pro⇄flash)' : '关闭'}`,
129
161
  `密钥 ${apiKey ? '凭证库 ' + maskKey(apiKey) : '环境变量 ' + (pp.envKey || 'MINGDAO_API_KEY')}`,
package/src/mcp.js CHANGED
@@ -151,7 +151,7 @@ export class McpClient {
151
151
  this.notify('notifications/initialized');
152
152
  this.ready = true;
153
153
  clearTimeout(timer);
154
- resolve();
154
+ resolve(undefined);
155
155
  })
156
156
  .catch((err) => {
157
157
  clearTimeout(timer);
package/src/memory.js CHANGED
@@ -171,7 +171,7 @@ export async function extractMemory(provider, model, messages, existingMemory) {
171
171
  {
172
172
  role: 'system',
173
173
  content:
174
- '你是 MingDao 的记忆提取器。从对话中提取值得长期记住的用户偏好与事实(工具链、代码风格、项目背景、个人约定、常用指令等)。每条一行,以 - 开头,≤30 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出);没有新增时 items 为空数组。\n已有记忆:\n' +
174
+ '你是 MingDao Harness 的记忆提取器。从对话中提取值得长期记住的用户偏好与事实(工具链、代码风格、项目背景、个人约定、常用指令等)。每条 30 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出)。只输出 JSON:{"items": ["条目1", "条目2"]};没有新增时输出 {"items": []}。\n已有记忆:\n' +
175
175
  (existingMemory || '(空)'),
176
176
  },
177
177
  { role: 'user', content: convo.slice(0, 8000) },
@@ -84,7 +84,7 @@ export async function fetchProviderModels(cfg, providerName, { force = false } =
84
84
  redirect: 'follow',
85
85
  });
86
86
  if (!res.ok) return { error: `HTTP ${res.status}` };
87
- const j = await res.json().catch(() => null);
87
+ const j = /** @type {any} */ (await res.json().catch(() => null));
88
88
  const list = (j?.data || [])
89
89
  .map((m) => String(m?.id || '').trim())
90
90
  .filter((id) => id && isChatModel(id))
package/src/prompts.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { skillsRegistryBlock } from './skills.js';
7
- import { mingdaoHome } from './config.js';
7
+ import { mingdaoHome, loadConfig } from './config.js';
8
8
  import { recentJournalBlock } from './memory.js';
9
9
 
10
10
  const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI 编程助手。你在用户的电脑上工作:通过工具读写文件、搜索代码、执行命令,帮助用户完成编程、调试与自动化任务。
@@ -29,6 +29,7 @@ function loadFile(p, cap) {
29
29
  }
30
30
  }
31
31
 
32
+ /** @param {{ workingDir: any, withJournal?: boolean, [key: string]: any }} opts */
32
33
  export function buildSystemPrompt({ workingDir, withJournal = false }) {
33
34
  // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
34
35
  // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
@@ -50,9 +51,15 @@ export function buildSystemPrompt({ workingDir, withJournal = false }) {
50
51
  // 技能清单(渐进披露:仅名称+描述,按需加载全文)
51
52
  prompt += skillsRegistryBlock(workingDir);
52
53
 
53
- // 项目约定(./AGENTS.md
54
- const agentsMd = loadFile(path.join(workingDir, 'AGENTS.md'), 20000);
55
- if (agentsMd) prompt += `\n\n<agents_md>\n${agentsMd}\n</agents_md>`;
54
+ // 项目约定(./AGENTS.md)——体积可配置(审计 MiniMax §3.3-B,v0.1.48 P0-D):
55
+ // 典型项目 6-12K AGENTS.md 全量进 system 每轮按缓存价计费;默认截 4K,超长部分
56
+ // 模型可 read 工具按需读全文。config.maxAgentsMdChars 可调(0 表示不注入)。
57
+ const cfg = loadConfig();
58
+ const agentsMdCap = cfg && Number.isFinite(Number(cfg.maxAgentsMdChars)) ? Math.max(0, Number(cfg.maxAgentsMdChars)) : 4000;
59
+ if (agentsMdCap > 0) {
60
+ const agentsMd = loadFile(path.join(workingDir, 'AGENTS.md'), agentsMdCap);
61
+ if (agentsMd) prompt += `\n\n<agents_md>\n${agentsMd}\n</agents_md>`;
62
+ }
56
63
 
57
64
  return prompt;
58
65
  }
package/src/schedule.js CHANGED
@@ -93,8 +93,8 @@ export function writeSchedule(home, job) {
93
93
  return job;
94
94
  }
95
95
 
96
- // 新建调度任务;after: 依赖的任务 ID(全部成功后才启动,任一失败则跳过)
97
- export function addSchedule(home, question, { at, every, after, permission, model, cwd, anchor, offpeak }) {
96
+ /** 新建调度任务;after: 依赖的任务 ID(全部成功后才启动,任一失败则跳过) */
97
+ export function addSchedule(home, question, /** @type {any} */ { at, every, after, permission, model, cwd, anchor, offpeak }) {
98
98
  const id = 'sc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
99
99
  const interval = every != null ? parseInterval(every) : null;
100
100
  const afterList = Array.isArray(after) ? after.filter(Boolean).map(String) : after ? String(after).split(',').map((x) => x.trim()).filter(Boolean) : [];
@@ -276,7 +276,11 @@ export function spawnDaemon(home) {
276
276
  env: { ...process.env, MINGDAO_HOME: home },
277
277
  });
278
278
  try {
279
- fs.writeFileSync(daemonPidFile(home), `${child.pid} ${nonce}`);
279
+ // 原子写(审计 workbuddy P3-4):tmp+rename 与 writeSchedule 同款——崩溃不留半截 pid 文件
280
+ const target = daemonPidFile(home);
281
+ const tmp = target + '.tmp';
282
+ fs.writeFileSync(tmp, `${child.pid} ${nonce}`);
283
+ fs.renameSync(tmp, target);
280
284
  } catch {}
281
285
  child.unref();
282
286
  return true;
@@ -193,7 +193,7 @@ async function doRegister(body) {
193
193
  // 审计 P2-10:注册用进程内互斥,避免并发同名注册双双成功(后写覆盖)
194
194
  if (!registerLock) {
195
195
  registerLock = new Promise((resolve) => {
196
- queueMicrotask(resolve);
196
+ queueMicrotask(() => resolve(undefined));
197
197
  });
198
198
  }
199
199
  const prev = registerLock;
@@ -530,6 +530,7 @@ async function handle(req, res) {
530
530
  }
531
531
 
532
532
  // ---------- 启动 ----------
533
+ /** @param {{ port?: any, host?: any, dataDir?: any, cert?: any, key?: any }} [opts] */
533
534
  export function runSyncServer({ port, host, dataDir, cert, key } = {}) {
534
535
  const dir = dataDir || DEFAULT_DATA_DIR;
535
536
  ACTIVE_DIR = dir;
package/src/sync.js CHANGED
@@ -63,7 +63,7 @@ async function apiCall(baseUrl, method, payload, token, timeoutMs = TIMEOUT_MS,
63
63
  if (insecure) {
64
64
  const { status, json: j } = await rawRequest(target, { headers, body, timeoutMs, insecure: true });
65
65
  if (status !== 200) {
66
- const err = new Error(j.error || `HTTP ${status}`);
66
+ const err = /** @type {Error & { status?: number, body?: any }} */ (new Error(j.error || `HTTP ${status}`));
67
67
  err.status = status;
68
68
  err.body = j;
69
69
  throw err;
@@ -79,9 +79,9 @@ async function apiCall(baseUrl, method, payload, token, timeoutMs = TIMEOUT_MS,
79
79
  body,
80
80
  signal: ctrl.signal,
81
81
  });
82
- const j = await res.json().catch(() => ({}));
82
+ const j = /** @type {any} */ (await res.json().catch(() => ({})));
83
83
  if (!res.ok) {
84
- const err = new Error(j.error || `HTTP ${res.status}`);
84
+ const err = /** @type {Error & { status?: number, body?: any }} */ (new Error(j.error || `HTTP ${res.status}`));
85
85
  err.status = res.status;
86
86
  err.body = j;
87
87
  throw err;
package/src/tasks.js CHANGED
@@ -54,7 +54,7 @@ export function isValidTaskId(id) {
54
54
  return typeof id === 'string' && /^[a-z0-9]+$/.test(id) && id.length >= 4 && id.length <= 40;
55
55
  }
56
56
 
57
- export function startTask(home, question, { permission, model, cwd, offpeak } = {}) {
57
+ export function startTask(home, question, { permission, model, cwd, offpeak } = /** @type {any} */ ({})) {
58
58
  const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + process.pid.toString(36);
59
59
  const task = {
60
60
  id,
package/src/tools/bash.js CHANGED
@@ -43,8 +43,35 @@ export function detectSandbox() {
43
43
  return sandboxSupport;
44
44
  }
45
45
 
46
+ // 输出折叠(审计 MiniMax §3.3-E / v0.1.48 P1-G):模型回填的 bash 输出先折叠再截断——
47
+ // 1) 剥离 ANSI 转义序列(CSI/OSC);2) 连续重复行(>3 行相同)折叠为「首行 + 重复标记」。
48
+ // npm install 类输出通常 30-50KB → 折叠后 5-10KB,单次工具回填省 60-70% prompt token。
49
+ function stripAnsi(s) {
50
+ return s
51
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
52
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
53
+ }
54
+ function foldRepeats(s) {
55
+ const lines = s.split('\n');
56
+ const out = [];
57
+ let i = 0;
58
+ while (i < lines.length) {
59
+ let j = i;
60
+ while (j + 1 < lines.length && lines[j + 1] === lines[i]) j += 1;
61
+ const run = j - i + 1;
62
+ if (run > 3) {
63
+ out.push(lines[i], `…(以上重复 ${run} 行,已折叠)`);
64
+ i = j + 1;
65
+ } else {
66
+ out.push(lines[i]);
67
+ i += 1;
68
+ }
69
+ }
70
+ return out.join('\n');
71
+ }
46
72
  function tail(s, n) {
47
- return s.length > n ? `…[输出过长,已截断头部]\n${s.slice(-n)}` : s;
73
+ const folded = foldRepeats(stripAnsi(s));
74
+ return folded.length > n ? `…[输出过长,已截断头部]\n${folded.slice(-n)}` : folded;
48
75
  }
49
76
 
50
77
  export function runBash(args, ctx) {
package/src/update.js CHANGED
@@ -111,7 +111,7 @@ const NPM_HINT =
111
111
  '源码包安装请重新运行安装脚本(install.sh);或从任意平台仓库克隆后 `npm link`:' +
112
112
  'https://gitee.com/MingDaoTCM/MingDao-harness · https://gitcode.com/MingDaoTCM/MingDao-Harness · https://github.com/MingDaoTCM/MingDao-Harness';
113
113
 
114
- export async function updateCheck({ repo } = {}) {
114
+ export async function updateCheck({ repo } = /** @type {any} */ ({})) {
115
115
  const lines = [];
116
116
  const root = resolveRepo(repo);
117
117
  if (!root) return { ok: false, lines: [NPM_HINT] };
@@ -130,7 +130,7 @@ export async function updateCheck({ repo } = {}) {
130
130
  };
131
131
  }
132
132
 
133
- export async function mingdaoUpdate({ repo } = {}) {
133
+ export async function mingdaoUpdate({ repo } = /** @type {any} */ ({})) {
134
134
  const lines = [];
135
135
  const root = resolveRepo(repo);
136
136
  if (!root) return { ok: false, lines: [NPM_HINT] };
@@ -195,7 +195,7 @@ export async function mingdaoUpdate({ repo } = {}) {
195
195
  };
196
196
  }
197
197
 
198
- export function mingdaoRollback({ repo } = {}) {
198
+ export function mingdaoRollback({ repo } = /** @type {any} */ ({})) {
199
199
  const root = resolveRepo(repo);
200
200
  if (!root) return { ok: false, lines: [NPM_HINT] };
201
201
  const st = readState();
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'">
6
7
  <title>MingDao Harness</title>
7
8
  <link rel="manifest" href="/manifest.webmanifest">
8
9
  <link rel="icon" href="/icon-192.png" type="image/png">
@@ -118,6 +119,8 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
118
119
  .cfg-row select,.cfg-row input[type=number]{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
119
120
  .cfg-hint{font-size:12px;color:var(--faint);margin-top:4px}
120
121
  .modal .row{display:flex;gap:10px;justify-content:flex-end}
122
+ .dir-entry{padding:6px 10px;border-radius:6px;cursor:pointer;font-size:13.5px;word-break:break-all}
123
+ .dir-entry:hover{background:var(--bg3)}
121
124
  .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}
122
125
  @keyframes spin{to{transform:rotate(360deg)}}
123
126
  </style>
@@ -187,7 +190,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
187
190
  <h4 style="margin:4px 0;color:var(--accent2)">云同步(跨设备会话同步,服务器端运行 mingdao sync-server)</h4>
188
191
  <div id="syncStatus" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
189
192
  <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
190
- <input id="syncUrl" placeholder="服务器地址 https://…" style="flex:1.4;min-width:150px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
193
+ <input id="syncUrl" placeholder="默认 https://session.mingdao.ai/" style="flex:1.4;min-width:150px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
191
194
  <input id="syncUser" placeholder="用户名" style="flex:1;min-width:90px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
192
195
  </div>
193
196
  <div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
@@ -268,6 +271,19 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
268
271
  <div class="row"><button id="cfgCancel">取消</button><button class="primary" id="cfgSave">保存</button></div>
269
272
  </div>
270
273
  </div>
274
+ <!-- 目录选择器(新建工作空间 / 改目录:浏览服务器磁盘目录树) -->
275
+ <div id="dirModal" class="modal-mask" style="display:none">
276
+ <div class="modal" style="width:min(520px,92vw);display:flex;flex-direction:column;max-height:78vh">
277
+ <h4 style="margin:0 0 6px;color:var(--cyan)">选择目录(点目录进入,点「使用此目录」确认)</h4>
278
+ <div id="dirPath" style="font-size:12px;color:var(--dim);word-break:break-all;margin-bottom:8px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 10px"></div>
279
+ <div id="dirList" style="flex:1;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:6px;min-height:220px"></div>
280
+ <div class="row" style="margin-top:12px">
281
+ <button id="dirPickNone" class="ghost">不指定(当前目录)</button>
282
+ <button id="dirPickCancel" class="ghost">取消</button>
283
+ <button id="dirPickOk" class="primary">使用此目录</button>
284
+ </div>
285
+ </div>
286
+ </div>
271
287
  <script>
272
288
  // 访问令牌(P1-3):地址带 ?token= 时记入 sessionStorage 并从地址栏移除(防截图/历史外泄),
273
289
  // 之后所有同源请求统一附加 X-MingDao-Token 头;无令牌时行为与旧版一致。
@@ -290,6 +306,53 @@ if (AUTH_TOKEN) {
290
306
  };
291
307
  }
292
308
  const $ = (s) => document.querySelector(s);
309
+ // —— 目录选择器(新建工作空间 / 改目录):浏览服务器磁盘目录树 ——
310
+ let pickerCb = null, pickerDir = '/';
311
+ function loadDirList(){
312
+ $('#dirPath').textContent = pickerDir;
313
+ const box = $('#dirList');
314
+ fetch('/api/fs-browse?dir=' + encodeURIComponent(pickerDir), { cache: 'no-store' })
315
+ .then((r) => r.json())
316
+ .then((j) => {
317
+ box.innerHTML = '';
318
+ if (!j.ok) {
319
+ const e = document.createElement('div'); e.className = 'dir-entry'; e.style.color = 'var(--err)';
320
+ e.textContent = j.error || '无法读取该目录'; box.appendChild(e); return;
321
+ }
322
+ if (j.parent != null) {
323
+ const up = document.createElement('div'); up.className = 'dir-entry'; up.textContent = '⬆ …(上级目录)';
324
+ up.onclick = () => { pickerDir = j.parent; loadDirList(); };
325
+ box.appendChild(up);
326
+ }
327
+ if (!(j.entries || []).length) {
328
+ const e = document.createElement('div'); e.className = 'dir-entry'; e.style.color = 'var(--faint)';
329
+ e.textContent = '(无子目录)'; box.appendChild(e);
330
+ }
331
+ for (const en of j.entries || []) {
332
+ const d = document.createElement('div'); d.className = 'dir-entry'; d.textContent = '📁 ' + en.name;
333
+ d.onclick = () => { pickerDir = en.path; loadDirList(); };
334
+ box.appendChild(d);
335
+ }
336
+ })
337
+ .catch(() => {
338
+ box.innerHTML = '<div class="dir-entry" style="color:var(--err)">请求失败(请重试)</div>';
339
+ });
340
+ }
341
+ function openDirPicker(startDir, cb){
342
+ pickerCb = cb;
343
+ if (startDir) { pickerDir = startDir; loadDirList(); }
344
+ else {
345
+ // 无指定起点:从服务器当前工作目录开始浏览
346
+ fetch('/api/workspaces', { cache: 'no-store' }).then((r) => r.json()).then((j) => {
347
+ pickerDir = (j && j.cwd) || '/';
348
+ loadDirList();
349
+ }).catch(() => { pickerDir = '/'; loadDirList(); });
350
+ }
351
+ $('#dirModal').style.display = 'flex';
352
+ }
353
+ $('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(pickerDir); };
354
+ $('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
355
+ $('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
293
356
  const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
294
357
  let generating = false; // 生成中:发送按钮复用为停止按钮
295
358
  let currentSession = null, thinking = null;
@@ -636,11 +699,13 @@ $('#wsSel').addEventListener('change', async e=>{
636
699
  const v=e.target.value;
637
700
  if(v==='__add__'){
638
701
  const name=prompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
639
- const dir=prompt('目录路径(留空 = 当前目录,目录不存在会自动创建):');
640
- const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir:dir||undefined})});
641
- const j=await r.json().catch(()=>({error:'请求失败'}));
642
- if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else alert(j.error||'创建失败');
643
- refreshWsSel(); reloadModels();
702
+ openDirPicker(null, (dir)=>{
703
+ fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir:dir||undefined})})
704
+ .then((r)=>r.json()).then((j)=>{
705
+ if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else alert(j.error||'创建失败');
706
+ refreshWsSel(); reloadModels();
707
+ });
708
+ });
644
709
  return;
645
710
  }
646
711
  if(v==='__manage__'){
@@ -697,7 +762,7 @@ async function refreshWorkspaces(){
697
762
  for(const w of j.workspaces){
698
763
  const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:12px';
699
764
  div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(w.name)+(w.name===j.current?' <span style="color:var(--accent)">●当前</span>':'')+'</span><span style="flex:1.4;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left">'+esc(w.dir||'')+'</span>';
700
- const ed=document.createElement('button'); ed.textContent='改目录'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const d=prompt('新目录:', w.dir); if(!d) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'set',name:w.name,dir:d})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); reloadModels(); } else alert(jj.error||'修改失败'); };
765
+ const ed=document.createElement('button'); ed.textContent='改目录'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=()=>{ openDirPicker(w.dir||null, async (d)=>{ if(d==null) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'set',name:w.name,dir:d})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); reloadModels(); } else alert(jj.error||'修改失败'); }); };
701
766
  const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=prompt('新名称:', w.name); if(!t) return; const rr=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',name:w.name,newName:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshWorkspaces(); refreshWsSel(); } else alert(jj.error||'重命名失败'); };
702
767
  const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!confirm('删除工作空间 '+w.name+'?')) return; await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'remove',name:w.name})}); refreshWorkspaces(); refreshWsSel(); reloadModels(); };
703
768
  div.appendChild(ed); div.appendChild(rn); div.appendChild(rm); list.appendChild(div);
@@ -861,7 +926,7 @@ $('#baseUrlSave').onclick=async ()=>{
861
926
  async function refreshSyncUI(){
862
927
  const r=await fetch('/api/sync',{cache:'no-store'}).catch(()=>null); if(!r) return;
863
928
  const j=await r.json();
864
- $('#syncUrl').value=j.url||''; $('#syncUser').value=j.username||''; $('#syncDevice').value=j.deviceName||''; $('#syncAutoChk').checked=Boolean(j.auto);
929
+ $('#syncUrl').value=j.url||'https://session.mingdao.ai/'; $('#syncUser').value=j.username||''; $('#syncDevice').value=j.deviceName||''; $('#syncAutoChk').checked=Boolean(j.auto);
865
930
  const st=$('#syncStatus');
866
931
  if(!j.configured) st.innerHTML='<span style="color:var(--faint)">未配置:填服务器地址并登录(服务器端运行 mingdao sync-server)</span>';
867
932
  else if(!j.loggedIn) st.innerHTML='<span style="color:var(--warn)">已配置 '+esc(j.url)+' · 未登录(输入密码登录)</span>';
package/src/web/server.js CHANGED
@@ -74,7 +74,7 @@ function readBody(req) {
74
74
  let size = 0;
75
75
  // 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
76
76
  const slowTimer = setTimeout(() => {
77
- const err = new Error('请求体上传超时(60s)');
77
+ const err = /** @type {Error & { status?: number }} */ (new Error('请求体上传超时(60s)'));
78
78
  err.status = 408;
79
79
  req.destroy();
80
80
  reject(err);
@@ -84,7 +84,7 @@ function readBody(req) {
84
84
  req.on('data', (d) => {
85
85
  size += d.length;
86
86
  if (size > MAX_BODY) {
87
- const err = new Error('请求体过大(>40MB)');
87
+ const err = /** @type {Error & { status?: number }} */ (new Error('请求体过大(>40MB)'));
88
88
  err.status = 413;
89
89
  req.destroy();
90
90
  reject(err);
@@ -104,6 +104,7 @@ function readBody(req) {
104
104
  });
105
105
  }
106
106
 
107
+ /** @param {{ host?: string, port?: number, authToken?: string|null, [key: string]: any }} [opts] */
107
108
  export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken } = {}) {
108
109
  const home = ensureHome();
109
110
  const cfg = loadConfig();
@@ -891,7 +892,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
891
892
  }
892
893
  }
893
894
  if (req.method === 'GET' && p === '/api/workspaces') {
894
- json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(workingDir)?.name || null });
895
+ json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(workingDir)?.name || null, cwd: workingDir });
895
896
  return;
896
897
  }
897
898
  if (req.method === 'POST' && p === '/api/workspaces') {
@@ -943,6 +944,27 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
943
944
  }
944
945
  return json(res, 400, { error: '未知操作:add|rename|set|remove' });
945
946
  }
947
+ // 目录浏览器(「新建工作空间」选择电脑磁盘目录用):本机运行时即用户电脑的目录树;
948
+ // 只列子目录(不含隐藏目录),供前端逐级导航选择
949
+ if (req.method === 'GET' && p === '/api/fs-browse') {
950
+ let dir = String(url.searchParams.get('dir') || '').trim();
951
+ if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
952
+ try {
953
+ const st = fs.statSync(dir);
954
+ if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
955
+ const entries = fs
956
+ .readdirSync(dir, { withFileTypes: true })
957
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
958
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
959
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
960
+ .slice(0, 300);
961
+ const parent = path.dirname(dir);
962
+ json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
963
+ } catch (err) {
964
+ json(res, 400, { error: String(err?.message || err) });
965
+ }
966
+ return;
967
+ }
946
968
  if (req.method === 'GET' && p === '/api/memory') {
947
969
  json(res, 200, { ok: true, content: loadMemory() });
948
970
  return;
@@ -1138,7 +1160,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
1138
1160
  });
1139
1161
  });
1140
1162
 
1141
- server.on('error', (err) => {
1163
+ server.on('error', (/** @type {Error & { code?: string }} */ err) => {
1142
1164
  if (err.code === 'EADDRINUSE') {
1143
1165
  console.error(`[MingDao] 端口 ${port} 已被占用,请换一个端口:mingdao web <端口号>`);
1144
1166
  } else {
@@ -1148,7 +1170,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
1148
1170
  });
1149
1171
 
1150
1172
  server.listen(port, host, () => {
1151
- const actual = server.address().port;
1173
+ const actual = /** @type {import('node:net').AddressInfo} */ (server.address()).port;
1152
1174
  boundPort = actual;
1153
1175
  const displayHost = host === '0.0.0.0' ? '127.0.0.1' : host;
1154
1176
  console.log('');