mingdao-harness 0.1.62 → 0.1.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -266,14 +266,14 @@ async function runWorkerTask(id, question, { permission, model, offpeak }) {
266
266
  session: path.basename(session.file),
267
267
  note,
268
268
  });
269
- if (cfg.notify !== false) notifyTaskDone(question, finalStatus === 'killed' ? 'failed' : finalStatus);
269
+ if (cfg.notify !== false && !process.env.MINGDAO_TASK_QUIET_NOTIFY) notifyTaskDone(question, finalStatus === 'killed' ? 'failed' : finalStatus);
270
270
  try {
271
271
  await maybeAutoSync();
272
272
  } catch {}
273
273
  process.exitCode = res.truncated ? 1 : 0;
274
274
  } catch (err) {
275
275
  finish({ status: 'failed', error: String(err?.message || err) });
276
- if (cfg?.notify !== false) notifyTaskDone(question, 'failed');
276
+ if (cfg?.notify !== false && !process.env.MINGDAO_TASK_QUIET_NOTIFY) notifyTaskDone(question, 'failed');
277
277
  process.exitCode = 2;
278
278
  } finally {
279
279
  if (mcpFacade) mcpFacade.stop();
@@ -377,14 +377,30 @@ async function main() {
377
377
  // 单守护进程调度器(评估 P3-5):一进程监督全部调度任务(协程复用 runSleeper),无任务自动退出
378
378
  if (opts.prompt[0] === 'schedule-daemon') {
379
379
  const home0 = ensureHome();
380
- const { listSchedules, runSleeper, sleeperAlive, daemonPidFile } = await import('./schedule.js');
380
+ const { listSchedules, runSleeper, sleeperAlive, daemonPidFile, writeSchedule } = await import('./schedule.js');
381
381
  const { readTask } = await import('./tasks.js');
382
382
  const handled = new Set();
383
+ const supervising = new Set(); // 本 daemon 正在监督的任务(防崩溃恢复误判正在执行的任务)
383
384
  try {
384
385
  for (;;) {
385
386
  const jobs = listSchedules(home0);
386
387
  if (!jobs.some((j) => j.status === 'pending' || j.status === 'running')) break;
387
388
  for (const j of jobs) {
389
+ // 崩溃恢复(审计):'running' 但无人监督且 worker 已死 → 按任务结果定案或重新排队,
390
+ // 避免任务永久卡在 running(此前 daemon 重启后既不重跑也不收尾)
391
+ if (j.status === 'running' && !supervising.has(j.id) && !sleeperAlive(j.pid)) {
392
+ const t = j.lastTaskId ? readTask(home0, j.lastTaskId) : null;
393
+ if (!t) {
394
+ await writeSchedule(home0, { ...j, status: 'pending' });
395
+ continue;
396
+ }
397
+ if (t.status !== 'running') {
398
+ const result = t.status === 'done' ? 'done' : t.status === 'timedout' ? 'timedout' : 'failed';
399
+ await writeSchedule(home0, { ...j, status: result, lastRunAt: t.startedAt || j.lastRunAt, runs: (j.runs || 0) + 1 });
400
+ continue;
401
+ }
402
+ continue; // worker 仍在跑:等它(外层 2s 轮询)
403
+ }
388
404
  if (j.status !== 'pending' || handled.has(j.id)) continue;
389
405
  if (sleeperAlive(j.pid)) continue; // 旧式 sleeper 仍在:交回给它,避免双跑
390
406
  if (j.lastTaskId) {
@@ -392,9 +408,13 @@ async function main() {
392
408
  if (t && t.status === 'running') continue; // worker 仍在跑(异常窗口),不重拉
393
409
  }
394
410
  handled.add(j.id);
411
+ supervising.add(j.id);
395
412
  runSleeper(home0, j.id)
396
413
  .catch(() => {})
397
- .finally(() => handled.delete(j.id));
414
+ .finally(() => {
415
+ handled.delete(j.id);
416
+ supervising.delete(j.id);
417
+ });
398
418
  }
399
419
  await new Promise((r) => setTimeout(r, 2000));
400
420
  }
package/src/config.js CHANGED
@@ -33,6 +33,26 @@ export function loadConfig() {
33
33
  }
34
34
  }
35
35
 
36
+ /** 桌面版首次运行:无配置时自动创建最小可用配置(引导在 WebUI 内完成,
37
+ * 不再要求先去终端跑 mingdao init)。CLI 的 mingdao init 向导不受影响。 */
38
+ export function ensureMinimalConfig() {
39
+ const existing = loadConfig();
40
+ if (existing) return existing;
41
+ ensureHome();
42
+ const pp = PROVIDERS['deepseek'] || Object.values(PROVIDERS)[0];
43
+ const model = modelPreset('deepseek-v4-flash') ? 'deepseek-v4-flash' : pp.models[0];
44
+ const cfg = {
45
+ provider: 'deepseek',
46
+ model,
47
+ baseUrl: pp.baseUrl,
48
+ permission: 'ask',
49
+ sandbox: 'off',
50
+ contextBudget: 128000,
51
+ };
52
+ saveConfig(cfg);
53
+ return cfg;
54
+ }
55
+
36
56
  export function saveConfig(cfg) {
37
57
  ensureHome();
38
58
  const p = configPath();
@@ -25,7 +25,11 @@ export async function chat({ baseUrl, apiKey, model, messages, tools, temperatur
25
25
  try {
26
26
  res = await fetch(url, {
27
27
  method: 'POST',
28
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
28
+ // 审计(桌面版「第二问挂起」修复):Node 全局 fetch(undici) 默认连接保活,
29
+ // 与部分网关/NAT 组合下复用已断开的 keep-alive 连接会静默挂起——首个请求正常、
30
+ // 后续请求无响应,重启进程恢复。显式 Connection: close 每次新建连接,可靠性优先
31
+ // (模型请求本身是长流式调用,建连开销占比可忽略)。
32
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, Connection: 'close' },
29
33
  body: JSON.stringify(payload),
30
34
  signal,
31
35
  });
package/src/schedule.js CHANGED
@@ -342,10 +342,15 @@ export async function runSleeper(home, id) {
342
342
  if (st === false) return 'pending';
343
343
  if (st === 'failed') return 'skipped';
344
344
  }
345
+ // 连续失败熔断(审计:右下角「失败:避峰任务」通知刷屏根因)——周期任务失败后按原周期
346
+ // 无限重试且每次失败都弹系统通知;这里记录连续失败次数:重试轮次静默(quietNotify),
347
+ // 连续 3 次失败由 every 主循环熔断停止
348
+ const prevFails = Number(readSchedule(home, id)?.consecutiveFailures) || 0;
345
349
  const task = startTask(home, job.question, {
346
350
  permission: job.permission || undefined,
347
351
  model: job.model || undefined,
348
352
  cwd: job.cwd || process.cwd(),
353
+ quietNotify: prevFails >= 1,
349
354
  });
350
355
  // 轮询 worker 状态直至结束(最长 2 小时);超时清理 worker 防孤儿(审计 P2-8)
351
356
  let t = readTask(home, task.id);
@@ -369,14 +374,16 @@ export async function runSleeper(home, id) {
369
374
  text: (t?.text || t?.error || '').slice(0, 200),
370
375
  });
371
376
  if (history.length > 50) history.shift();
377
+ const result = t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
372
378
  writeSchedule(home, {
373
379
  ...cur0,
374
380
  lastRunAt: Date.now(),
375
381
  lastTaskId: task.id,
376
382
  runs: (cur0?.runs || 0) + 1,
377
383
  history,
384
+ consecutiveFailures: result === 'done' ? 0 : prevFails + 1,
378
385
  });
379
- return t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
386
+ return result;
380
387
  };
381
388
 
382
389
  for (;;) {
@@ -389,9 +396,18 @@ export async function runSleeper(home, id) {
389
396
  continue;
390
397
  }
391
398
  writeSchedule(home, { ...cur, status: 'running' });
392
- await runOnce();
399
+ const result = await runOnce();
393
400
  const cur2 = readSchedule(home, id);
394
401
  if (!cur2) return;
402
+ // 连续失败熔断(审计:通知刷屏修复)——3 次失败即停止,避免无限重试 + 无限弹通知
403
+ if (result !== 'done' && (cur2.consecutiveFailures || 0) >= 3) {
404
+ writeSchedule(home, {
405
+ ...cur2,
406
+ status: 'failed',
407
+ note: '连续失败 3 次已停止重试:请检查 API Key / 模型 / 网络(mingdao schedule list 查看历史)',
408
+ });
409
+ return;
410
+ }
395
411
  // 下次 = 完成时间 + 周期(不追赶错过的档期);有每日锚点时对齐锚点,避免逐日漂移
396
412
  const next = cur2.anchor ? nextAnchorAfter(cur2.anchor, cur2.interval) || Date.now() + cur2.interval : Date.now() + cur2.interval;
397
413
  writeSchedule(home, { ...cur2, status: 'pending', nextRunAt: next });
@@ -116,6 +116,9 @@ export async function installFromRegistry(name) {
116
116
 
117
117
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mingdao-reg-'));
118
118
  try {
119
+ // 逐文件下载按镜像回退(审计:国内网络下 raw.githubusercontent 常超时/被断,
120
+ // 此前只试首选主机 → 安装报「This operation was aborted」;gitee/gitcode 国内秒开)
121
+ const hosts = [r.host, ...registryBase().hosts.filter((h) => h !== r.host)];
119
122
  let verified = false; // 是否至少一个文件做了 sha256 校验(索引声明了哈希才校验)
120
123
  for (const f of entry.files) {
121
124
  const rel = String(f.path || '').replace(/\\/g, '/');
@@ -124,12 +127,17 @@ export async function installFromRegistry(name) {
124
127
  }
125
128
  const dest = path.join(tmp, rel);
126
129
  fs.mkdirSync(path.dirname(dest), { recursive: true });
127
- let text;
128
- try {
129
- text = await fetchText(`${r.host}/skills-lib/${encodeURI(name)}/${rel.split('/').map(encodeURIComponent).join('/')}`, 30000, MAX_FILE);
130
- } catch (e) {
131
- return { error: `下载 ${name}/${rel} 失败:${e.message}` };
130
+ let text = null;
131
+ let lastErr = '';
132
+ for (const host of hosts) {
133
+ try {
134
+ text = await fetchText(`${host}/skills-lib/${encodeURI(name)}/${rel.split('/').map(encodeURIComponent).join('/')}`, 30000, MAX_FILE);
135
+ break;
136
+ } catch (e) {
137
+ lastErr = e?.name === 'AbortError' ? '下载超时' : String(e?.message || e);
138
+ }
132
139
  }
140
+ if (text === null) return { error: `下载 ${name}/${rel} 失败:${lastErr}(已尝试全部镜像)` };
133
141
  if (text.length > MAX_FILE) return { error: `${name}/${rel} 超过 512KB 上限` };
134
142
  // 完整性校验(P3-3):索引声明 sha256 时逐文件比对,不符即拒绝安装(供应链防护)
135
143
  if (f.sha256 && typeof f.sha256 === 'string') {
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 } = /** @type {any} */ ({})) {
57
+ export function startTask(home, question, { permission, model, cwd, offpeak, quietNotify } = /** @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,
@@ -78,7 +78,8 @@ export function startTask(home, question, { permission, model, cwd, offpeak } =
78
78
  cwd: cwd || process.cwd(),
79
79
  detached: true,
80
80
  stdio: 'ignore',
81
- env: { ...process.env, MINGDAO_HOME: home },
81
+ // 连续失败的重试轮次静默:只保留首次失败的系统通知,避免右下角刷屏(审计)
82
+ env: { ...process.env, MINGDAO_HOME: home, ...(quietNotify ? { MINGDAO_TASK_QUIET_NOTIFY: '1' } : {}) },
82
83
  });
83
84
  task.pid = child.pid;
84
85
  writeTask(home, task);
package/src/titles.js CHANGED
@@ -18,37 +18,48 @@ function cleanTitle(s) {
18
18
  .slice(0, 20);
19
19
  }
20
20
 
21
- export async function generateTitle(provider, model, firstUserText) {
21
+ export async function generateTitle(provider, model, firstUserText, { timeoutMs = 10000 } = {}) {
22
+ // 硬超时护栏(审计:「第二问无反应」根因):标题生成发生在回合收尾阶段,若网关/网络层
23
+ // 挂起(不抛错),回合永远无法 complete——前端停在生成态、后续发送全部静默。20s 兜底:
24
+ // 超时放弃标题(会话仍以时间戳命名),绝不阻塞下一问。
25
+ const ctrl = new AbortController();
26
+ const timer = setTimeout(() => ctrl.abort(new Error('标题生成超时')), timeoutMs);
22
27
  const user = { role: 'user', content: String(firstUserText).slice(0, 300) };
23
- // 结构化输出(评估 4.2-4):json_object,maxTokens 120→50,解析零失败;网关不支持时回退纯文本
24
28
  try {
25
- const res = await provider.chat({
26
- model,
27
- messages: [
28
- { role: 'system', content: '为下面的对话开头生成一个简短标题(≤12 字,中文,不要引号、句号、markdown 符号)。只输出 JSON:{"title":"标题"}。' },
29
- user,
30
- ],
31
- tools: [],
32
- temperature: 0.3,
33
- maxTokens: 50,
34
- responseFormat: { type: 'json_object' },
35
- });
36
- const j = JSON.parse(String(res.text || '').trim());
37
- const t = cleanTitle(j?.title);
38
- if (t) return t;
39
- } catch {}
40
- try {
41
- const res = await provider.chat({
42
- model,
43
- messages: [{ role: 'system', content: '为下面的对话开头生成一个简短标题(≤12 字,中文,不要引号、句号、markdown 符号,直接输出标题本身)。' }, user],
44
- tools: [],
45
- temperature: 0.3,
46
- maxTokens: 120,
47
- });
48
- const t = cleanTitle(res.text);
49
- return t || null;
50
- } catch {
51
- return null;
29
+ // 结构化输出(评估 4.2-4):json_object,maxTokens 120→50,解析零失败;网关不支持时回退纯文本
30
+ try {
31
+ const res = await provider.chat({
32
+ model,
33
+ messages: [
34
+ { role: 'system', content: '为下面的对话开头生成一个简短标题(≤12 字,中文,不要引号、句号、markdown 符号)。只输出 JSON:{"title":"标题"}。' },
35
+ user,
36
+ ],
37
+ tools: [],
38
+ temperature: 0.3,
39
+ maxTokens: 50,
40
+ responseFormat: { type: 'json_object' },
41
+ signal: ctrl.signal,
42
+ });
43
+ const j = JSON.parse(String(res.text || '').trim());
44
+ const t = cleanTitle(j?.title);
45
+ if (t) return t;
46
+ } catch {}
47
+ try {
48
+ const res = await provider.chat({
49
+ model,
50
+ messages: [{ role: 'system', content: '为下面的对话开头生成一个简短标题(≤12 字,中文,不要引号、句号、markdown 符号,直接输出标题本身)。' }, user],
51
+ tools: [],
52
+ temperature: 0.3,
53
+ maxTokens: 120,
54
+ signal: ctrl.signal,
55
+ });
56
+ const t = cleanTitle(res.text);
57
+ return t || null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ } finally {
62
+ clearTimeout(timer);
52
63
  }
53
64
  }
54
65
 
@@ -50,19 +50,26 @@ button.primary{background:var(--accent);color:#06231a;border:none;font-weight:60
50
50
  button.danger{background:var(--err);color:#fff;border:none}
51
51
  button:disabled{opacity:.45;cursor:not-allowed}
52
52
  main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
53
- #chat{max-width:860px;margin:0 auto;padding:0 18px;display:flex;flex-direction:column;gap:18px}
53
+ #chat{max-width:860px;margin:0 auto;padding:0 18px;display:flex;flex-direction:column;gap:20px}
54
54
  .msg-user{display:flex;justify-content:flex-end}
55
- .msg-user .bubble{background:var(--user);border:1px solid #2c4a66;border-radius:14px 14px 4px 14px;padding:10px 14px;max-width:78%;white-space:pre-wrap;word-break:break-word}
56
- .msg-ai{display:flex;flex-direction:column;gap:10px}
57
- .msg-ai .content{white-space:normal;word-break:break-word}
58
- .msg-ai .content h1,.msg-ai .content h2,.msg-ai .content h3{font-size:1.05em;margin:.6em 0 .3em;color:var(--accent2)}
59
- .msg-ai .content h4{font-size:1em;margin:.5em 0 .2em}
60
- .msg-ai .content p{margin:.35em 0}
61
- .msg-ai .content ul,.msg-ai .content ol{margin:.3em 0;padding-left:1.4em}
62
- .msg-ai .content li{margin:.15em 0}
63
- .msg-ai .content blockquote{margin:.4em 0;padding-left:10px;border-left:3px solid var(--border);color:var(--dim)}
55
+ .msg-user .bubble{background:linear-gradient(135deg,#1f3d57,#173a33);border:1px solid #2c5a4a;border-radius:16px 16px 4px 16px;padding:10px 16px;max-width:78%;white-space:pre-wrap;word-break:break-word;font-size:14.5px;line-height:1.7}
56
+ .msg-ai{display:flex;flex-direction:column;gap:10px;font-size:15px;color:var(--text)}
57
+ .msg-ai .content{white-space:normal;word-break:break-word;line-height:1.75}
58
+ .msg-ai .content h1,.msg-ai .content h2,.msg-ai .content h3,.msg-ai .content h4{color:var(--text);font-weight:700;line-height:1.4}
59
+ .msg-ai .content h1{font-size:20px;margin:18px 0 10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
60
+ .msg-ai .content h2{font-size:18px;margin:16px 0 8px;padding-left:10px;border-left:4px solid var(--accent)}
61
+ .msg-ai .content h3{font-size:16px;margin:13px 0 6px}
62
+ .msg-ai .content h4{font-size:15px;margin:11px 0 5px;color:var(--accent2)}
63
+ .msg-ai .content p{margin:0 0 12px}
64
+ .msg-ai .content p:last-child{margin-bottom:0}
65
+ .msg-ai .content ul,.msg-ai .content ol{margin:0 0 12px;padding-left:24px}
66
+ .msg-ai .content li{margin:4px 0}
67
+ .msg-ai .content li::marker{color:var(--accent2)}
68
+ .msg-ai .content blockquote{margin:0 0 12px;padding:8px 14px;border-left:3px solid var(--accent2);background:var(--bg3);border-radius:0 8px 8px 0;color:var(--dim)}
69
+ .msg-ai .content hr{border:none;border-top:1px solid var(--border);margin:16px 0}
70
+ .msg-ai .content b,.msg-ai .content strong{font-weight:700;color:var(--text)}
64
71
  .msg-ai .content code{background:var(--code);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;color:#7ee0b8}
65
- .msg-ai .content pre{background:var(--code);border:1px solid var(--border);border-radius:10px;padding:12px 14px;overflow-x:auto;margin:.5em 0}
72
+ .msg-ai .content pre{background:var(--code);border:1px solid var(--border);border-radius:10px;padding:12px 14px;overflow-x:auto;margin:.5em 0 12px}
66
73
  .msg-ai .content pre code{background:none;border:none;padding:0;color:#c8d3e0}
67
74
  .msg-ai .content a{color:var(--accent2)}
68
75
  .msg-ai .content .hl-kw{color:#7aa2f7;font-weight:600}.hl-s{color:#9ece6a}.hl-n{color:#e0af68}.hl-c{color:#565f6e;font-style:italic}.hl-f{color:#7dcfff}
@@ -306,6 +313,39 @@ if (AUTH_TOKEN) {
306
313
  };
307
314
  }
308
315
  const $ = (s) => document.querySelector(s);
316
+
317
+ // —— 弹窗三件套(Electron 不实现 window.prompt/confirm/alert:prompt 恒返回 null、confirm 恒 false、
318
+ // alert 静默无反应——桌面版「⚙ 设置里点设Key 没反应」即由此而来)。统一替换为应用内模态框。
319
+ function modalBox(inner){
320
+ const mask=document.createElement('div'); mask.className='modal-mask';
321
+ const m=document.createElement('div'); m.className='modal'; m.innerHTML=inner;
322
+ mask.appendChild(m); document.body.appendChild(mask);
323
+ return {mask, m};
324
+ }
325
+ function uiPrompt(title, def, opts){
326
+ opts=opts||{};
327
+ return new Promise((resolve)=>{
328
+ const {mask, m}=modalBox('<h3>'+esc(String(title??''))+'</h3><input class="uiPromptInput" '+(opts.hidden?'type="password" ':'')+'style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13.5px;margin-top:10px"><div class="row" style="margin-top:12px"><button class="danger" data-a="c">取消</button><button class="primary" data-a="y">确定</button></div>');
329
+ const inp=m.querySelector('input');
330
+ if(def!==undefined&&def!==null) inp.value=String(def);
331
+ const done=(v)=>{ mask.remove(); resolve(v); };
332
+ m.querySelector('[data-a=y]').onclick=()=>done(inp.value);
333
+ m.querySelector('[data-a=c]').onclick=()=>done(null);
334
+ inp.addEventListener('keydown',(e)=>{ if(e.key==='Enter') done(inp.value); if(e.key==='Escape') done(null); });
335
+ setTimeout(()=>inp.focus(),0);
336
+ });
337
+ }
338
+ function uiConfirm(title){
339
+ return new Promise((resolve)=>{
340
+ const {mask, m}=modalBox('<h3>'+esc(String(title??''))+'</h3><div class="row" style="margin-top:12px"><button class="danger" data-a="c">取消</button><button class="primary" data-a="y">确定</button></div>');
341
+ m.querySelector('[data-a=y]').onclick=()=>{ mask.remove(); resolve(true); };
342
+ m.querySelector('[data-a=c]').onclick=()=>{ mask.remove(); resolve(false); };
343
+ });
344
+ }
345
+ function uiAlert(title){
346
+ const {mask, m}=modalBox('<h3>'+esc(String(title??''))+'</h3><div class="row" style="margin-top:12px"><button class="primary" data-a="y">确定</button></div>');
347
+ m.querySelector('[data-a=y]').onclick=()=>mask.remove();
348
+ }
309
349
  // —— 目录选择器(新建工作空间 / 改目录):浏览服务器磁盘目录树 ——
310
350
  let pickerCb = null, pickerDir = '/';
311
351
  function loadDirList(){
@@ -371,26 +411,44 @@ function highlight(code, lang){
371
411
  });
372
412
  }
373
413
  function renderMarkdown(text){
374
- const lines = String(text).split('\n'); let out='', code=null, codeLang='';
375
- // 审计 P1-3:codeLang 经 esc() 转义防 XSS(信息串闭合 class 注入)
376
- const flush=()=>{ if(code!==null){ out+='<pre><code class="lang-'+esc(codeLang)+'">'+highlight(code.join('\n'),codeLang)+'</code></pre>'; code=null; } };
377
- for(const line of lines){
378
- if(line.trim().startsWith('```')){
379
- if(code===null){ code=[]; codeLang=(line.trim().slice(3).trim().split(/\s+/)[0]||''); }
380
- else flush();
414
+ // 块级解析(美化:加粗标题 / 段落 / 真列表 / 引用 / 分割线,参照 DeepSeek-Harness 会话排版)
415
+ const lines = String(text).split('\n');
416
+ let out = '', code = null, codeLang = '';
417
+ const flushCode = () => { if (code !== null) { out += '<pre><code class="lang-' + esc(codeLang) + '">' + highlight(code.join('\n'), codeLang) + '</code></pre>'; code = null; } };
418
+ let para = [];
419
+ let list = null; // {type:'ul'|'ol', items:[]}
420
+ const flushPara = () => { if (para.length) { out += '<p>' + para.join('<br>') + '</p>'; para = []; } };
421
+ const flushList = () => { if (list) { out += '<' + list.type + '>' + list.items.map((i) => '<li>' + i + '</li>').join('') + '</' + list.type + '>'; list = null; } };
422
+ // 行内元素(审计 P1-3:内容与 codeLang 均经 esc 转义防 XSS)
423
+ const inline = (l) => {
424
+ let s = esc(l);
425
+ s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
426
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
427
+ .replace(/\*([^*\s][^*]*)\*/g, '<i>$1</i>')
428
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
429
+ return s;
430
+ };
431
+ for (const line of lines) {
432
+ const t = line.trim();
433
+ if (t.startsWith('```')) {
434
+ if (code === null) { flushList(); flushPara(); code = []; codeLang = (t.slice(3).trim().split(/\s+/)[0] || ''); }
435
+ else flushCode();
381
436
  continue;
382
437
  }
383
- if(code!==null){ code.push(line); continue; }
384
- let l = esc(line);
385
- l = l.replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>').replace(/\*([^*\s][^*]*)\*/g,'<i>$1</i>');
386
- l = l.replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
387
- if(/^#{1,6}\s/.test(line.trim())){ const m=line.trim().match(/^(#+)\s+(.*)/); const h=Math.min(m[1].length,3); l='<h'+h+'>'+esc(m[2])+'</h'+h+'>'; }
388
- else if(/^\s*[-*+]\s/.test(line)) l=' '+l.replace(/^\s*[-*+]\s/,'');
389
- else if(/^\s*\d+[.)]\s/.test(line)) l=l.replace(/^\s*(\d+)[.)]\s/,'<b>$1.</b> ');
390
- else if(/^>\s?/.test(line)) l='<blockquote>'+l.replace(/^>\s?/,'')+'</blockquote>';
391
- out+= l+'<br>';
438
+ if (code !== null) { code.push(line); continue; }
439
+ if (t === '') { flushList(); flushPara(); continue; }
440
+ const h = t.match(/^(#{1,6})\s+(.*)$/);
441
+ if (h) { flushList(); flushPara(); const level = Math.min(h[1].length, 4); out += '<h' + level + '>' + inline(h[2]) + '</h' + level + '>'; continue; }
442
+ const ul = t.match(/^([-*+])\s+(.*)$/);
443
+ if (ul) { flushPara(); if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; } list.items.push(inline(ul[2])); continue; }
444
+ const ol = t.match(/^(\d+)[.)]\s+(.*)$/);
445
+ if (ol) { flushPara(); if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; } list.items.push(inline(ol[2])); continue; }
446
+ if (/^>\s?/.test(t)) { flushList(); flushPara(); out += '<blockquote>' + inline(t.replace(/^>\s?/, '')) + '</blockquote>'; continue; }
447
+ if (/^(-{3,}|\*{3,})$/.test(t)) { flushList(); flushPara(); out += '<hr>'; continue; }
448
+ flushList();
449
+ para.push(inline(t));
392
450
  }
393
- flush();
451
+ flushCode(); flushList(); flushPara();
394
452
  return out;
395
453
  }
396
454
  function scrollBottom(){ chatEl.scrollTop = chatEl.scrollHeight; }
@@ -515,17 +573,17 @@ let attachments=[];
515
573
  $('#attachBtn').onclick=()=>{ $('#fileInput').click(); };
516
574
  $('#fileInput').addEventListener('change', async e=>{
517
575
  for(const f of e.target.files||[]){
518
- if(attachments.length>=4){ alert('最多 4 个附件'); break; }
576
+ if(attachments.length>=4){ uiAlert('最多 4 个附件'); break; }
519
577
  if(f.type.startsWith('image/')){
520
- if(f.size>5*1024*1024){ alert(f.name+' 超过 5MB'); continue; }
578
+ if(f.size>5*1024*1024){ uiAlert(f.name+' 超过 5MB'); continue; }
521
579
  const dataUrl=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); });
522
580
  attachments.push({type:'image',name:f.name,dataUrl});
523
581
  }else if(f.type.startsWith('text/')||/\.(txt|md|json|js|py|log|csv|html|css)$/i.test(f.name)){
524
- if(f.size>200*1024){ alert(f.name+' 超过 200KB'); continue; }
582
+ if(f.size>200*1024){ uiAlert(f.name+' 超过 200KB'); continue; }
525
583
  const content=await new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsText(f); });
526
584
  attachments.push({type:'text',name:f.name,content});
527
585
  }else{
528
- alert(f.name+':暂只支持图片与文本文件');
586
+ uiAlert(f.name+':暂只支持图片与文本文件');
529
587
  }
530
588
  }
531
589
  e.target.value='';
@@ -546,8 +604,17 @@ function renderAttachments(){
546
604
  }
547
605
 
548
606
  async function send(){
549
- if(generating){ abort(); return; } // 生成中:同一按钮承担停止职责
550
- const text=input.value.trim(); if(!text && !attachments.length) return;
607
+ const text=input.value.trim();
608
+ console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
609
+ if(generating){ console.log('[MingDao] send 被生成态拦截 → 走 abort'); abort(); return; } // 生成中:同一按钮承担停止职责
610
+ if(!text && !attachments.length) return;
611
+ generating=true; setBtn(); // 本地在途状态:不再依赖 /api/tasks 轮询
612
+ const turnCtrl=new AbortController();
613
+ // 回合看门狗:120 秒无任何响应强制中断——无论服务端处于什么状态,界面都能自动恢复可发送
614
+ const watchdog=setTimeout(()=>{
615
+ try{ turnCtrl.abort(); }catch{}
616
+ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>{});
617
+ },120000);
551
618
  input.value=''; input.style.height='44px';
552
619
  const payload={message:text,file:currentSession};
553
620
  // 带上文开关:勾选后系统提示注入最近会话日志(默认不注入——新会话全新开始,避免串到历史会话上下文)
@@ -573,7 +640,7 @@ async function send(){
573
640
  const onActivity=()=>{ if(think.parentNode) think.remove(); if(reason&&reason.parentElement){ reason.parentElement.open=false; reason.parentElement.querySelector('summary').classList.remove('live'); } };
574
641
  updateTasksPanel();
575
642
  try{
576
- const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
643
+ const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:turnCtrl.signal});
577
644
  if(!resp.ok){ const j=await resp.json().catch(()=>({})); onActivity(); msg.innerHTML='<div class="errline">'+esc(j.error||('HTTP '+resp.status))+'</div>'; updateTasksPanel(); return; }
578
645
  await handleEvents(resp.body, ev=>{
579
646
  if(ev.taskId) taskId=ev.taskId;
@@ -587,24 +654,27 @@ async function send(){
587
654
  else if(ev.type==='banner'){ renderBanner(ev); }
588
655
  else if(ev.type==='ask'){ update(); askModal(ev).then(a=>fetch('/api/permission',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:ev.id,answer:a,taskId})}).catch(()=>{})); }
589
656
  else if(ev.type==='usage'){ onActivity(); const u=document.createElement('div'); u.className='usage'; u.textContent=''+ev.modelName+' · ↑'+ev.usage.prompt_tokens+' ↓'+ev.usage.completion_tokens+' tokens · '+((ev.durationMs||0)/1000).toFixed(1)+'s'+ev.cost; msg.appendChild(u); scroll(); }
590
- else if(ev.type==='error'){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
591
- else if(ev.type==='done'){ onActivity(); if(ev.budget){ const b=ev.budget; hintEl.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(); }
657
+ 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(); }
658
+ else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; hintEl.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(); }
592
659
  });
593
- }catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=e.message||'网络错误'; msg.appendChild(d); scroll(); }
594
- finally{ clearInterval(hintTimer); hintEl.classList.remove('working'); hintEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); scroll(); updateTasksPanel(); }
660
+ }catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=(e&&e.name==='AbortError')?'响应超时已中断(120 秒无响应),请重试':(e&&e.message)||'网络错误'; msg.appendChild(d); scroll(); }
661
+ finally{ clearInterval(hintTimer); clearTimeout(watchdog); generating=false; setBtn(); console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); hintEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); scroll(); updateTasksPanel(); }
595
662
  }
596
663
 
597
664
  function abort(){ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(()=>updateTasksPanel()).catch(()=>{}); }
598
665
 
599
666
  // —— 任务面板(多会话并行) ——
667
+ function setBtn(){
668
+ sendBtn.textContent = generating ? '■ 停止' : '发送';
669
+ sendBtn.className = generating ? 'danger' : 'primary';
670
+ }
600
671
  async function updateTasksPanel(){
601
672
  const r=await fetch('/api/tasks').catch(()=>null); if(!r) return;
602
673
  const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
603
674
  $('#tpCount').textContent='('+j.running+' 运行中 / 上限 '+j.maxConcurrent+')';
604
- // 发送按钮复用:生成中变「■ 停止」(点击中止全部运行中任务)
605
- generating = j.running > 0;
606
- sendBtn.textContent = generating ? '■ 停止' : '发送';
607
- sendBtn.className = generating ? 'danger' : 'primary';
675
+ // 审计(第二问无反应修复):generating 改为「本轮在途」本地状态,由 send/finally 维护,
676
+ // 不再由 /api/tasks 轮询推导——任务面板瞬时波动或一次轮询失败曾让按钮永久卡在停止态,
677
+ // 后续发送全部静默。面板仅展示,不参与按钮状态。
608
678
  for(const t of j.tasks){
609
679
  const div=document.createElement('div'); div.className='tp-item';
610
680
  const dot = t.status==='running'?'tp-run':(t.status==='done'?'tp-done':'tp-bad');
@@ -666,6 +736,10 @@ async function init(){
666
736
  $('#notifyChk').checked=j.notify!==false;
667
737
  const env = ('路由'+(j.routing?'开':'关')+' · 沙箱'+((j.sandbox&&j.sandbox!=='off')?(j.sandboxSupported?j.sandbox:'降级'):'off'));
668
738
  $('#envBadge').textContent=env; $('#envBadge').style.display='';
739
+ // 首次使用引导:无 API Key 时明确提示去设置(桌面版自动初始化后必走这里)
740
+ if (j.keyReady === false) {
741
+ renderBanner({ text: '🔑 欢迎使用 MingDao Harness!还没有配置 API Key:请点击右上角 ⚙ 设置 →「模型与 API Key」选择模型并填入密钥(DeepSeek 平台申请)后即可对话。' });
742
+ }
669
743
  }catch(e){
670
744
  const ms=$('#modelSel'); if(!ms.options.length){ const o=document.createElement('option'); o.textContent='加载失败,刷新重试'; ms.appendChild(o); }
671
745
  }
@@ -698,11 +772,11 @@ async function refreshWsSel(){
698
772
  $('#wsSel').addEventListener('change', async e=>{
699
773
  const v=e.target.value;
700
774
  if(v==='__add__'){
701
- const name=prompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
775
+ const name=await uiPrompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
702
776
  openDirPicker(null, (dir)=>{
703
777
  fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir:dir||undefined})})
704
778
  .then((r)=>r.json()).then((j)=>{
705
- if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else alert(j.error||'创建失败');
779
+ if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else uiAlert(j.error||'创建失败');
706
780
  refreshWsSel(); reloadModels();
707
781
  });
708
782
  });
@@ -719,7 +793,7 @@ $('#wsSel').addEventListener('change', async e=>{
719
793
  if(currentSession) payload.file=currentSession;
720
794
  const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
721
795
  const j=await r.json().catch(()=>({error:'请求失败'}));
722
- if(j.ok) renderBanner({text:'✓ 已切换到工作空间 '+j.name+'('+j.dir+'),后续工具在此目录执行'}); else { alert(j.error||'切换失败'); }
796
+ if(j.ok) renderBanner({text:'✓ 已切换到工作空间 '+j.name+'('+j.dir+'),后续工具在此目录执行'}); else { uiAlert(j.error||'切换失败'); }
723
797
  refreshWsSel(); reloadModels();
724
798
  }
725
799
  });
@@ -748,11 +822,11 @@ $('#schAdd').onclick=async ()=>{
748
822
  if($('#schOffpeak').checked) payload.offpeak=true;
749
823
  if(kind==='at'){ const v=$('#schAt').value; if(!v) return; payload.at=v.replace('T',' '); }
750
824
  else if(kind==='every'){ payload.every=$('#schEvery').value; }
751
- else if(kind==='chain'){ const v=$('#schChain').value.trim(); if(!v) return; const qs=v.split('\n').map(x=>x.trim()).filter(Boolean); if(qs.length<2){ alert('链式需要至少两行任务'); return; } payload.action='chain'; payload.questions=qs; }
825
+ else if(kind==='chain'){ const v=$('#schChain').value.trim(); if(!v) return; const qs=v.split('\n').map(x=>x.trim()).filter(Boolean); if(qs.length<2){ uiAlert('链式需要至少两行任务'); return; } payload.action='chain'; payload.questions=qs; }
752
826
  else { const v=$('#schAfter').value.trim(); if(!v) return; payload.after=[v]; }
753
827
  const r=await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
754
828
  const j=await r.json().catch(()=>({error:'请求失败'}));
755
- if(j.ok){ $('#schQuestion').value=''; refreshSchList(); } else { alert(j.error||'添加失败'); }
829
+ if(j.ok){ $('#schQuestion').value=''; refreshSchList(); } else { uiAlert(j.error||'添加失败'); }
756
830
  };
757
831
  // —— 工作空间 ——
758
832
  async function refreshWorkspaces(){
@@ -762,9 +836,9 @@ async function refreshWorkspaces(){
762
836
  for(const w of j.workspaces){
763
837
  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';
764
838
  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>';
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||'修改失败'); }); };
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||'重命名失败'); };
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(); };
839
+ 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 uiAlert(jj.error||'修改失败'); }); };
840
+ const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=await uiPrompt('新名称:', 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 uiAlert(jj.error||'重命名失败'); };
841
+ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除工作空间 '+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(); };
768
842
  div.appendChild(ed); div.appendChild(rn); div.appendChild(rm); list.appendChild(div);
769
843
  }
770
844
  }
@@ -773,7 +847,7 @@ $('#wsAdd').onclick=async ()=>{
773
847
  const dir=$('#wsDir').value.trim();
774
848
  const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir})});
775
849
  const j=await r.json().catch(()=>({error:'请求失败'}));
776
- if(j.ok){ $('#wsName').value=''; $('#wsDir').value=''; refreshWorkspaces(); refreshWsSel(); reloadModels(); } else alert(j.error||'添加失败');
850
+ if(j.ok){ $('#wsName').value=''; $('#wsDir').value=''; refreshWorkspaces(); refreshWsSel(); reloadModels(); } else uiAlert(j.error||'添加失败');
777
851
  };
778
852
  // —— 长期记忆 ——
779
853
  function memFlash(msg, good){ const m=$('#memMsg'); m.textContent=msg; m.style.color=good?'var(--accent)':'var(--err)'; setTimeout(()=>{m.textContent='';},4000); }
@@ -829,7 +903,7 @@ $('#mcpAdd').onclick=async ()=>{
829
903
  const arg=$('#mcpArg').value.trim();
830
904
  const r=await fetch('/api/mcp-presets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,arg})});
831
905
  const j=await r.json().catch(()=>({error:'请求失败'}));
832
- if(j.ok) alert('✓ 已接入 '+name+'(重启 mingdao web 后生效,/mcp 查看状态)'); else alert(j.error||'接入失败');
906
+ if(j.ok) uiAlert('✓ 已接入 '+name+'(重启 mingdao web 后生效,/mcp 查看状态)'); else uiAlert(j.error||'接入失败');
833
907
  };
834
908
  // —— 技能库 ——
835
909
  let skillSearchTimer=null;
@@ -859,7 +933,7 @@ async function refreshSkillLib(q, force){
859
933
  const body=s.installed?{action:'uninstall',name:s.name}:{action:'install',name:s.name};
860
934
  const rr=await fetch('/api/skills',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
861
935
  const jj=await rr.json().catch(()=>({error:'请求失败'}));
862
- if(jj.ok) refreshSkillLib($('#skillSearch').value.trim()); else alert(jj.error||'操作失败');
936
+ if(jj.ok) refreshSkillLib($('#skillSearch').value.trim()); else uiAlert(jj.error||'操作失败');
863
937
  };
864
938
  div.appendChild(b); list.appendChild(div);
865
939
  }
@@ -891,9 +965,9 @@ async function refreshModelsCfg(){
891
965
  const state=p.keyState==='stored'?'<span style="color:var(--accent)">已存 '+esc(p.keyMasked||'')+'</span>':p.keyState==='env'?'<span style="color:var(--dim)">环境变量 '+esc(p.envKey||'')+'</span>':'<span style="color:var(--err)">未设置</span>';
892
966
  div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>'+esc(p.name)+'</b> '+esc(p.label)+'</span><span style="white-space:nowrap">'+state+'</span>';
893
967
  const set=document.createElement('button'); set.textContent='设Key'; set.style.cssText='padding:1px 8px;font-size:11px';
894
- set.onclick=async()=>{ const k=prompt('API Key('+p.name+'):'); if(k===null) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setProviderKey',provider:p.name,key:k})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); if(jj.modelsNote) alert(jj.modelsNote); } else alert(jj.error||'设置失败'); };
895
- if(p.keyState==='stored'&&p.name!=='custom'){ const rf=document.createElement('button'); rf.textContent='刷新模型'; rf.style.cssText='padding:1px 8px;font-size:11px'; rf.onclick=async()=>{ const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'refreshModels',provider:p.name})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ reloadModels(); alert('✓ 已拉取 '+jj.models.length+' 个线上模型'+(jj.fromCache?'(缓存)':'')); } else alert(jj.error||'刷新失败'); }; div.appendChild(rf); }
896
- if(p.keyState==='stored'){ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!confirm('删除 '+p.name+' 的 Key?')) return; await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeProviderKey',provider:p.name})}); refreshModelsCfg(); reloadModels(); }; div.appendChild(rm); }
968
+ set.onclick=async()=>{ const k=await uiPrompt('API Key('+p.name+'):', null, {hidden:true}); if(k===null) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setProviderKey',provider:p.name,key:k})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); if(jj.modelsNote) uiAlert(jj.modelsNote); } else uiAlert(jj.error||'设置失败'); };
969
+ if(p.keyState==='stored'&&p.name!=='custom'){ const rf=document.createElement('button'); rf.textContent='刷新模型'; rf.style.cssText='padding:1px 8px;font-size:11px'; rf.onclick=async()=>{ const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'refreshModels',provider:p.name})}); const jj=await rr.json().catch(()=>({error:'请求失败'})); if(jj.ok){ reloadModels(); uiAlert('✓ 已拉取 '+jj.models.length+' 个线上模型'+(jj.fromCache?'(缓存)':'')); } else uiAlert(jj.error||'刷新失败'); }; div.appendChild(rf); }
970
+ if(p.keyState==='stored'){ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除 '+p.name+' 的 Key?')) return; await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeProviderKey',provider:p.name})}); refreshModelsCfg(); reloadModels(); }; div.appendChild(rm); }
897
971
  div.appendChild(set); pk.appendChild(div);
898
972
  }
899
973
  const cl=$('#cmList'); cl.innerHTML='';
@@ -902,9 +976,9 @@ async function refreshModelsCfg(){
902
976
  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';
903
977
  const kstate=c.keyState==='stored'?'<span style="color:var(--accent)">'+esc(c.keyMasked||'')+'</span>':'<span style="color:var(--err)">无Key</span>';
904
978
  div.innerHTML='<span style="flex:1;min-width:0"><b>'+esc(c.name)+'</b> <span style="color:var(--faint)">'+esc(c.label)+' · '+esc(c.baseUrl)+'</span></span><span style="white-space:nowrap">'+kstate+'</span>';
905
- const sk=document.createElement('button'); sk.textContent='设Key'; sk.style.cssText='padding:1px 8px;font-size:11px'; sk.onclick=()=>{ const k=prompt('API Key('+c.name+'):'); if(k===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setCustomKey',name:c.name,key:k})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok) refreshModelsCfg(); else alert(jj.error||'设置失败'); }); };
906
- const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=()=>{ const u=prompt('API 地址:', c.baseUrl); if(u===null) return; const l=prompt('标签:', c.label); if(l===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else alert(jj.error||'修改失败'); }); };
907
- const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!confirm('删除自定义模型 '+c.name+'?')) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); } else alert(jj.error||'删除失败'); };
979
+ const sk=document.createElement('button'); sk.textContent='设Key'; sk.style.cssText='padding:1px 8px;font-size:11px'; sk.onclick=async()=>{ const k=await uiPrompt('API Key('+c.name+'):', null, {hidden:true}); if(k===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setCustomKey',name:c.name,key:k})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok) refreshModelsCfg(); else uiAlert(jj.error||'设置失败'); }); };
980
+ const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=async()=>{ const u=await uiPrompt('API 地址:', c.baseUrl); if(u===null) return; const l=await uiPrompt('标签:', c.label); if(l===null) return; fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'updateCustom',name:c.name,baseUrl:u,label:l})}).then(rr=>rr.json()).then(jj=>{ if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'修改失败'); }); };
981
+ const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!await uiConfirm('删除自定义模型 '+c.name+'?')) return; const rr=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'removeCustom',name:c.name})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshModelsCfg(); reloadModels(); } else uiAlert(jj.error||'删除失败'); };
908
982
  div.appendChild(sk); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
909
983
  }
910
984
  $('#baseUrlOverride').value=j.baseUrlOverride||'';
@@ -912,15 +986,15 @@ async function refreshModelsCfg(){
912
986
  $('#cmAdd').onclick=async ()=>{
913
987
  const name=$('#cmName').value.trim();
914
988
  const url=$('#cmUrl').value.trim();
915
- if(!name||!url){ alert('模型名与 API 地址必填'); return; }
989
+ if(!name||!url){ uiAlert('模型名与 API 地址必填'); return; }
916
990
  const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'addCustom',name,label:$('#cmLabel').value.trim(),baseUrl:url,key:$('#cmKey').value})});
917
991
  const j=await r.json().catch(()=>({error:'请求失败'}));
918
- if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else alert(j.error||'添加失败');
992
+ if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else uiAlert(j.error||'添加失败');
919
993
  };
920
994
  $('#baseUrlSave').onclick=async ()=>{
921
995
  const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setBaseUrl',baseUrl:$('#baseUrlOverride').value.trim()})});
922
996
  const j=await r.json().catch(()=>({error:'请求失败'}));
923
- if(j.ok) alert('✓ API 地址已保存(立即生效)'); else alert(j.error||'保存失败');
997
+ if(j.ok) uiAlert('✓ API 地址已保存(立即生效)'); else uiAlert(j.error||'保存失败');
924
998
  };
925
999
  // —— 云同步 ——
926
1000
  async function refreshSyncUI(){
@@ -939,15 +1013,15 @@ async function refreshSyncUI(){
939
1013
  }
940
1014
  $('#syncLogin').onclick=async ()=>{
941
1015
  const url=$('#syncUrl').value.trim(); const username=$('#syncUser').value.trim(); const password=$('#syncPass').value;
942
- if(!url||!username||!password){ alert('服务器地址、用户名、密码均必填'); return; }
1016
+ if(!url||!username||!password){ uiAlert('服务器地址、用户名、密码均必填'); return; }
943
1017
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'login',url,username,password,deviceName:$('#syncDevice').value.trim()})});
944
1018
  const j=await r.json().catch(()=>({error:'请求失败'}));
945
- if(j.ok){ $('#syncPass').value=''; refreshSyncUI(); } else alert(j.error||'登录失败');
1019
+ if(j.ok){ $('#syncPass').value=''; refreshSyncUI(); } else uiAlert(j.error||'登录失败');
946
1020
  };
947
1021
  $('#syncPush').onclick=async ()=>{
948
1022
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'push'})});
949
1023
  const j=await r.json().catch(()=>({error:'请求失败'}));
950
- alert(j.ok?('✓ 已推送 '+j.pushed+' 个会话'+(j.conflicts?','+j.conflicts+' 个远端版本已备份 .server-*':'')):j.error);
1024
+ uiAlert(j.ok?('✓ 已推送 '+j.pushed+' 个会话'+(j.conflicts?','+j.conflicts+' 个远端版本已备份 .server-*':'')):j.error);
951
1025
  refreshSyncUI(); refreshSyncConflicts();
952
1026
  };
953
1027
  $('#syncLogout').onclick=async ()=>{ await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'logout'})}); refreshSyncUI(); };
@@ -962,7 +1036,7 @@ async function refreshSyncShares(){
962
1036
  const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px';
963
1037
  div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><b>'+esc(s.shareId)+'</b> <span style="color:var(--faint)">'+esc(s.name)+' · 被接受 '+s.pulls+' 次</span></span>';
964
1038
  const b=document.createElement('button'); b.textContent='撤销'; b.className='danger'; b.style.cssText='padding:1px 8px;font-size:11px';
965
- b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'unshare',shareId:s.shareId})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncShares(); else alert(jj.error||'撤销失败'); };
1039
+ b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'unshare',shareId:s.shareId})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncShares(); else uiAlert(jj.error||'撤销失败'); };
966
1040
  div.appendChild(b); ml.appendChild(div);
967
1041
  }
968
1042
  } else { ml.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">暂无分享(分享后此处显示分享码)</div>'; }
@@ -976,23 +1050,23 @@ async function refreshSyncShares(){
976
1050
  } else { al.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">未接受任何分享</div>'; }
977
1051
  }
978
1052
  $('#shareCreate').onclick=async ()=>{
979
- const name=$('#shareName').value.trim(); if(!name){ alert('输入会话文件名'); return; }
1053
+ const name=$('#shareName').value.trim(); if(!name){ uiAlert('输入会话文件名'); return; }
980
1054
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'share',name})});
981
1055
  const j=await r.json().catch(()=>({error:'请求失败'}));
982
- if(j.ok){ $('#shareName').value=''; refreshSyncShares(); } else alert(j.error||'创建失败');
1056
+ if(j.ok){ $('#shareName').value=''; refreshSyncShares(); } else uiAlert(j.error||'创建失败');
983
1057
  };
984
1058
  $('#shareAccept').onclick=async ()=>{
985
- const code=$('#acceptCode').value.trim(); if(!code){ alert('输入分享码'); return; }
1059
+ const code=$('#acceptCode').value.trim(); if(!code){ uiAlert('输入分享码'); return; }
986
1060
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'accept',shareId:code})});
987
1061
  const j=await r.json().catch(()=>({error:'请求失败'}));
988
- if(j.ok){ $('#acceptCode').value=''; refreshSyncShares(); refreshSessions(); } else alert(j.error||'接受失败');
1062
+ if(j.ok){ $('#acceptCode').value=''; refreshSyncShares(); refreshSessions(); } else uiAlert(j.error||'接受失败');
989
1063
  };
990
1064
  $('#passwdBtn').onclick=async ()=>{
991
1065
  const oldPass=$('#oldPass').value, newPass=$('#newPass').value;
992
- if(!oldPass||!newPass){ alert('旧密码与新密码均必填'); return; }
1066
+ if(!oldPass||!newPass){ uiAlert('旧密码与新密码均必填'); return; }
993
1067
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'passwd',oldPassword:oldPass,newPassword:newPass})});
994
1068
  const j=await r.json().catch(()=>({error:'请求失败'}));
995
- if(j.ok){ $('#oldPass').value=''; $('#newPass').value=''; alert('✓ 密码已修改'); } else alert(j.error||'修改失败');
1069
+ if(j.ok){ $('#oldPass').value=''; $('#newPass').value=''; uiAlert('✓ 密码已修改'); } else uiAlert(j.error||'修改失败');
996
1070
  };
997
1071
  async function refreshSyncConflicts(){
998
1072
  const r=await fetch('/api/sync-conflicts',{cache:'no-store'}).catch(()=>null); if(!r) return;
@@ -1001,7 +1075,7 @@ async function refreshSyncConflicts(){
1001
1075
  for(const c of j.conflicts){
1002
1076
  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';
1003
1077
  div.innerHTML='<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(c.base)+' <span style="color:var(--faint)">'+(c.localExists?'本地有':'本地无')+' · 备份 '+c.entries.length+'</span></span>';
1004
- const mk=(label,choice,cls)=>{ const b=document.createElement('button'); b.textContent=label; if(cls) b.className=cls; b.style.cssText='padding:1px 8px;font-size:11px'; b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'resolveConflict',base:c.base,choice})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncConflicts(); else alert(jj.error||'解决失败'); }; return b; };
1078
+ const mk=(label,choice,cls)=>{ const b=document.createElement('button'); b.textContent=label; if(cls) b.className=cls; b.style.cssText='padding:1px 8px;font-size:11px'; b.onclick=async()=>{ const rr=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'resolveConflict',base:c.base,choice})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok) refreshSyncConflicts(); else uiAlert(jj.error||'解决失败'); }; return b; };
1005
1079
  div.appendChild(mk('保留本地','local')); div.appendChild(mk('采用远端','remote','danger')); div.appendChild(mk('都保留','both'));
1006
1080
  list.appendChild(div);
1007
1081
  }
@@ -1045,8 +1119,8 @@ async function openSessModal(){
1045
1119
  for(const s of j.sessions){
1046
1120
  const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12.5px';
1047
1121
  div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
1048
- const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=prompt('新标题:', s.file.replace(/\.jsonl$/,'')); if(!t) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',file:s.file,title:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshSessions(); openSessModal(); } else alert(jj.error||'重命名失败'); };
1049
- const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{ if(!confirm('删除会话 '+s.file+'?此操作不可恢复。')) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'delete',file:s.file})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ if(currentSession===s.file) currentSession=null; refreshSessions(); openSessModal(); } else alert(jj.error||'删除失败'); };
1122
+ const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=await uiPrompt('新标题:', s.file.replace(/\.jsonl$/,'')); if(!t) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'rename',file:s.file,title:t})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ refreshSessions(); openSessModal(); } else uiAlert(jj.error||'重命名失败'); };
1123
+ const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{ if(!await uiConfirm('删除会话 '+s.file+'?此操作不可恢复。')) return; const rr=await fetch('/api/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'delete',file:s.file})}); const jj=await rr.json().catch(()=>({error:'失败'})); if(jj.ok){ if(currentSession===s.file) currentSession=null; refreshSessions(); openSessModal(); } else uiAlert(jj.error||'删除失败'); };
1050
1124
  div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
1051
1125
  }
1052
1126
  }
package/src/web/server.js CHANGED
@@ -156,9 +156,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
156
156
  let modelName = cfg.model || 'deepseek-v4-flash';
157
157
  const pc = resolveProviderConfig(cfg, modelName);
158
158
  if (!pc.apiKey) {
159
- console.error(`[MingDao] 未找到 API Key:请运行 mingdao key set ${pc.name} 或 mingdao init`);
160
- process.exitCode = 1;
161
- return;
159
+ // 首次运行/未配置密钥:界面照常启动(黑屏根因修复——此前这里直接 return,桌面版窗口
160
+ // 加载不到任何服务 → 整窗黑屏且无提示)。⚙ 设置里填入 Key 后即可对话。
161
+ console.error(`[MingDao] ⚠ 未配置 ${modelName} 的 API Key:界面可正常使用,对话前请在 ⚙ 设置 →「模型与 API Key」填入密钥。`);
162
162
  }
163
163
 
164
164
  let workingDir = process.cwd(); // 工作空间切换时随之更新(后续会话/工具都跟随新目录)
@@ -198,6 +198,22 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
198
198
  let taskSeq = 0;
199
199
  let draftText = ''; // 外部注入的草稿(VS Code 插件选中代码发送)
200
200
 
201
+ // —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
202
+ // 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
203
+ function srvlog(msg) {
204
+ try {
205
+ const dir = path.join(mingdaoHome(), 'logs');
206
+ fs.mkdirSync(dir, { recursive: true });
207
+ const f = path.join(dir, 'web-server.log');
208
+ let prev = '';
209
+ try {
210
+ prev = fs.readFileSync(f, 'utf8');
211
+ } catch {}
212
+ if (prev.length > 2 * 1024 * 1024) prev = prev.slice(-1024 * 1024);
213
+ fs.writeFileSync(f, prev + new Date().toISOString() + ' ' + msg + '\n');
214
+ } catch {}
215
+ }
216
+
201
217
  function pruneTasks() {
202
218
  if (tasks.size <= 100) return;
203
219
  for (const [id, t] of tasks) {
@@ -209,6 +225,12 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
209
225
  async function handleChat(res, body) {
210
226
  const taskId = `t${++taskSeq}`; // 服务端生成:客户端自选 taskId 可能覆盖他人任务
211
227
  const entry = { res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 };
228
+ srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
229
+ // 硬截止:任何异常路径 6 分钟内强制结束 SSE 流——前端回合必然收尾,按钮必然恢复
230
+ const hardDeadline = setTimeout(() => {
231
+ srvlog('chat 硬截止触发 ' + taskId + '(异常路径强制 res.end)');
232
+ try { res.end(); } catch {}
233
+ }, 6 * 60000);
212
234
  tasks.set(taskId, entry);
213
235
  const send = (obj) => {
214
236
  try {
@@ -298,6 +320,10 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
298
320
  const permission = createPermission(cfg.permission ?? 'ask', io);
299
321
  let providerNow;
300
322
  try {
323
+ // 首次使用引导:未配置密钥时给明确指引,而不是晦涩的 401 原始报错
324
+ if (!(resolveProviderConfig(cfg, runModel) || {}).apiKey) {
325
+ throw new Error(`模型 ${runModel} 尚未配置 API Key:请点击右上角 ⚙ 设置 →「模型与 API Key」填入密钥。`);
326
+ }
301
327
  providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
302
328
  } catch (err) {
303
329
  entry.status = 'failed';
@@ -343,15 +369,18 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
343
369
 
344
370
  try {
345
371
  const r = await agent.runTurn(messages);
372
+ srvlog('chat 回合完成 ' + taskId + ' text=' + String(r.text || '').length + ' ' + (Date.now() - entry.startedAt) + 'ms');
346
373
  appendMessages(session.file, messages.slice(persistedBefore));
347
374
  io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
348
375
  recordUsage(runModel, r.usage, r.perf);
349
376
  maybeAutoSync().catch(() => {});
350
377
  // 新会话自动标题(可配置关闭)
378
+ srvlog('chat 标题生成前 ' + taskId);
351
379
  if (isNew && cfg.autoTitle !== false && r.text) {
352
380
  try {
353
381
  const tModel = titleModel(cfg, runModel);
354
382
  const title = await generateTitle(await helperProvider(cfg, tModel, providerNow), tModel, built.persistText);
383
+ srvlog('chat 标题完成 ' + taskId + ' ' + (title || '(无标题)'));
355
384
  if (title) {
356
385
  const oldName = path.basename(session.file);
357
386
  const renamed = renameSessionFile(fs, path, home, session, title);
@@ -361,6 +390,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
361
390
  }
362
391
  entry.status = r.aborted ? 'aborted' : 'done';
363
392
  entry.durationMs = Date.now() - entry.startedAt;
393
+ srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
364
394
  // 预算可视化(评估 A5):会话当前 token 占用 / 预算
365
395
  let budgetInfo = null;
366
396
  try {
@@ -386,8 +416,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
386
416
  } catch (err) {
387
417
  entry.status = 'failed';
388
418
  entry.durationMs = Date.now() - entry.startedAt;
419
+ srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));
389
420
  send({ type: 'error', message: String(err?.message || err) });
390
421
  } finally {
422
+ clearTimeout(hardDeadline);
423
+ srvlog('chat 收尾 ' + taskId + ' res.end 前(客户端将收到流结束)');
391
424
  entry.abortHandler = null;
392
425
  entry.pendingAsk = null;
393
426
  entry.send = null;
@@ -463,6 +496,8 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
463
496
  ok: true,
464
497
  model: modelName,
465
498
  models,
499
+ // 首次使用引导:当前模型无可用 API Key 时前端显示「去 ⚙ 设置填 Key」横幅
500
+ keyReady: Boolean((resolveProviderConfig(cfg, modelName) || {}).apiKey),
466
501
  permissions: ['ask', 'auto', 'readonly'],
467
502
  permission: cfg.permission ?? 'ask',
468
503
  sandbox: cfg.sandbox || 'off',
@@ -782,7 +817,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
782
817
  res.end();
783
818
  return;
784
819
  }
785
- await handleChat(res, body);
820
+ try {
821
+ await handleChat(res, body);
822
+ } catch (err) {
823
+ // 兜底(审计:「点发送无反应」根因防护):handleChat 内任意未捕获异常若直接外抛,
824
+ // SSE 已开流却无事件 → 前端永远停在「正在思考…」。此处统一转为 error 事件回给界面。
825
+ try {
826
+ res.write(`data: ${JSON.stringify({ type: 'error', message: '对话失败:' + String(err?.message || err) })}\n\n`);
827
+ } catch {}
828
+ try {
829
+ res.end();
830
+ } catch {}
831
+ pruneTasks();
832
+ }
786
833
  return;
787
834
  }
788
835
  if (req.method === 'POST' && p === '/api/permission') {