mingdao-harness 0.1.63 → 0.1.65

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.63",
3
+ "version": "0.1.65",
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
  }
@@ -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}
@@ -157,7 +164,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
157
164
  <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送,Shift+Enter 换行)"></textarea>
158
165
  <button id="sendBtn" class="primary">发送</button>
159
166
  </div>
160
- <div id="hint">工具执行与权限确认会实时展示;生成中可点「中断」停止。<label style="margin-left:12px;cursor:pointer;user-select:none"><input type="checkbox" id="journalChk" style="vertical-align:-2px;margin:0 4px 0 0">📌 带上文(最近会话日志,默认关=新会话全新开始)</label></div>
167
+ <div id="hint"><span id="hintText">工具执行与权限确认会实时展示;生成中可点「中断」停止。</span><label style="margin-left:12px;cursor:pointer;user-select:none"><input type="checkbox" id="journalChk" style="vertical-align:-2px;margin:0 4px 0 0">📌 带上文(最近会话日志,默认关=新会话全新开始)</label></div>
161
168
  <div id="statusBar" title="累计使用统计(轮次/步数/LLM 与工具时长/首 token/吞吐/缓存命中/输入输出 tokens)"></div>
162
169
  </footer>
163
170
  <div id="modalRoot"></div>
@@ -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,12 +604,20 @@ 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
+ const turnCtrl=new AbortController();
612
+ // 回合看门狗:120 秒无任何响应强制中断——无论服务端处于什么状态,界面都能自动恢复可发送
613
+ const watchdog=setTimeout(()=>{
614
+ try{ turnCtrl.abort(); }catch{}
615
+ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>{});
616
+ },120000);
551
617
  input.value=''; input.style.height='44px';
552
618
  const payload={message:text,file:currentSession};
553
619
  // 带上文开关:勾选后系统提示注入最近会话日志(默认不注入——新会话全新开始,避免串到历史会话上下文)
554
- if($('#journalChk').checked) payload.withJournal=true;
620
+ if($('#journalChk')?.checked) payload.withJournal=true;
555
621
  if(attachments.length) payload.attachments=attachments;
556
622
  const sentAttachments=attachments;
557
623
  attachments=[]; renderAttachments();
@@ -562,10 +628,13 @@ async function send(){
562
628
  const think=document.createElement('div'); think.className='think'; think.textContent='💭 正在思考…'; msg.appendChild(think);
563
629
  const showThink=()=>{ if(!think.parentNode) msg.appendChild(think); };
564
630
  // 工作指示:提示栏实时显示已工作时长与执行步数,让用户时刻感受到智能体在干活
631
+ // 审计(第二问根因):文本只写 #hintText,绝不 textContent 覆盖 #hint——那会把其中的
632
+ // #journalChk 复选框从 DOM 抹掉,第二问读 .checked 抛异常 → 按钮卡红 → 全部发送静默
565
633
  let stepsCount=0; const workT0=Date.now();
566
- const hintEl=$('#hint'); const defaultHint=hintEl.textContent;
634
+ const hintEl=$('#hint'); const hintTextEl=$('#hintText'); const defaultHint=hintTextEl?hintTextEl.textContent:'';
567
635
  hintEl.classList.add('working');
568
- const hintTimer=setInterval(()=>{ hintEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; },1000);
636
+ const hintTimer=setInterval(()=>{ if(hintTextEl) hintTextEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; },1000);
637
+ generating=true; setBtn(); // 本地在途状态(前置 DOM 段完成后才置位,异常不影响按钮)
569
638
  const nearBottom=()=> chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 90;
570
639
  const scroll=()=>{ if(nearBottom()) chatEl.scrollTop = chatEl.scrollHeight; };
571
640
  let pending=false;
@@ -573,7 +642,7 @@ async function send(){
573
642
  const onActivity=()=>{ if(think.parentNode) think.remove(); if(reason&&reason.parentElement){ reason.parentElement.open=false; reason.parentElement.querySelector('summary').classList.remove('live'); } };
574
643
  updateTasksPanel();
575
644
  try{
576
- const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
645
+ const resp=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:turnCtrl.signal});
577
646
  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
647
  await handleEvents(resp.body, ev=>{
579
648
  if(ev.taskId) taskId=ev.taskId;
@@ -587,24 +656,27 @@ async function send(){
587
656
  else if(ev.type==='banner'){ renderBanner(ev); }
588
657
  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
658
  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(); }
659
+ 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(); }
660
+ else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
592
661
  });
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(); }
662
+ }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(); }
663
+ finally{ clearInterval(hintTimer); clearTimeout(watchdog); generating=false; setBtn(); console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); scroll(); updateTasksPanel(); }
595
664
  }
596
665
 
597
666
  function abort(){ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(()=>updateTasksPanel()).catch(()=>{}); }
598
667
 
599
668
  // —— 任务面板(多会话并行) ——
669
+ function setBtn(){
670
+ sendBtn.textContent = generating ? '■ 停止' : '发送';
671
+ sendBtn.className = generating ? 'danger' : 'primary';
672
+ }
600
673
  async function updateTasksPanel(){
601
674
  const r=await fetch('/api/tasks').catch(()=>null); if(!r) return;
602
675
  const j=await r.json(); const list=$('#tpList'); list.innerHTML='';
603
676
  $('#tpCount').textContent='('+j.running+' 运行中 / 上限 '+j.maxConcurrent+')';
604
- // 发送按钮复用:生成中变「■ 停止」(点击中止全部运行中任务)
605
- generating = j.running > 0;
606
- sendBtn.textContent = generating ? '■ 停止' : '发送';
607
- sendBtn.className = generating ? 'danger' : 'primary';
677
+ // 审计(第二问无反应修复):generating 改为「本轮在途」本地状态,由 send/finally 维护,
678
+ // 不再由 /api/tasks 轮询推导——任务面板瞬时波动或一次轮询失败曾让按钮永久卡在停止态,
679
+ // 后续发送全部静默。面板仅展示,不参与按钮状态。
608
680
  for(const t of j.tasks){
609
681
  const div=document.createElement('div'); div.className='tp-item';
610
682
  const dot = t.status==='running'?'tp-run':(t.status==='done'?'tp-done':'tp-bad');
@@ -702,11 +774,11 @@ async function refreshWsSel(){
702
774
  $('#wsSel').addEventListener('change', async e=>{
703
775
  const v=e.target.value;
704
776
  if(v==='__add__'){
705
- const name=prompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
777
+ const name=await uiPrompt('新工作空间名称:'); if(!name){ refreshWsSel(); return; }
706
778
  openDirPicker(null, (dir)=>{
707
779
  fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir:dir||undefined})})
708
780
  .then((r)=>r.json()).then((j)=>{
709
- if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else alert(j.error||'创建失败');
781
+ if(j.ok) renderBanner({text:'✓ 已登记工作空间 '+j.name+' → '+j.dir+'(目录已自动创建)'}); else uiAlert(j.error||'创建失败');
710
782
  refreshWsSel(); reloadModels();
711
783
  });
712
784
  });
@@ -723,7 +795,7 @@ $('#wsSel').addEventListener('change', async e=>{
723
795
  if(currentSession) payload.file=currentSession;
724
796
  const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
725
797
  const j=await r.json().catch(()=>({error:'请求失败'}));
726
- if(j.ok) renderBanner({text:'✓ 已切换到工作空间 '+j.name+'('+j.dir+'),后续工具在此目录执行'}); else { alert(j.error||'切换失败'); }
798
+ if(j.ok) renderBanner({text:'✓ 已切换到工作空间 '+j.name+'('+j.dir+'),后续工具在此目录执行'}); else { uiAlert(j.error||'切换失败'); }
727
799
  refreshWsSel(); reloadModels();
728
800
  }
729
801
  });
@@ -752,11 +824,11 @@ $('#schAdd').onclick=async ()=>{
752
824
  if($('#schOffpeak').checked) payload.offpeak=true;
753
825
  if(kind==='at'){ const v=$('#schAt').value; if(!v) return; payload.at=v.replace('T',' '); }
754
826
  else if(kind==='every'){ payload.every=$('#schEvery').value; }
755
- 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; }
827
+ 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; }
756
828
  else { const v=$('#schAfter').value.trim(); if(!v) return; payload.after=[v]; }
757
829
  const r=await fetch('/api/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
758
830
  const j=await r.json().catch(()=>({error:'请求失败'}));
759
- if(j.ok){ $('#schQuestion').value=''; refreshSchList(); } else { alert(j.error||'添加失败'); }
831
+ if(j.ok){ $('#schQuestion').value=''; refreshSchList(); } else { uiAlert(j.error||'添加失败'); }
760
832
  };
761
833
  // —— 工作空间 ——
762
834
  async function refreshWorkspaces(){
@@ -766,9 +838,9 @@ async function refreshWorkspaces(){
766
838
  for(const w of j.workspaces){
767
839
  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';
768
840
  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>';
769
- 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||'修改失败'); }); };
770
- 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||'重命名失败'); };
771
- 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(); };
841
+ 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||'修改失败'); }); };
842
+ 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||'重命名失败'); };
843
+ 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(); };
772
844
  div.appendChild(ed); div.appendChild(rn); div.appendChild(rm); list.appendChild(div);
773
845
  }
774
846
  }
@@ -777,7 +849,7 @@ $('#wsAdd').onclick=async ()=>{
777
849
  const dir=$('#wsDir').value.trim();
778
850
  const r=await fetch('/api/workspaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'add',name,dir})});
779
851
  const j=await r.json().catch(()=>({error:'请求失败'}));
780
- if(j.ok){ $('#wsName').value=''; $('#wsDir').value=''; refreshWorkspaces(); refreshWsSel(); reloadModels(); } else alert(j.error||'添加失败');
852
+ if(j.ok){ $('#wsName').value=''; $('#wsDir').value=''; refreshWorkspaces(); refreshWsSel(); reloadModels(); } else uiAlert(j.error||'添加失败');
781
853
  };
782
854
  // —— 长期记忆 ——
783
855
  function memFlash(msg, good){ const m=$('#memMsg'); m.textContent=msg; m.style.color=good?'var(--accent)':'var(--err)'; setTimeout(()=>{m.textContent='';},4000); }
@@ -833,7 +905,7 @@ $('#mcpAdd').onclick=async ()=>{
833
905
  const arg=$('#mcpArg').value.trim();
834
906
  const r=await fetch('/api/mcp-presets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,arg})});
835
907
  const j=await r.json().catch(()=>({error:'请求失败'}));
836
- if(j.ok) alert('✓ 已接入 '+name+'(重启 mingdao web 后生效,/mcp 查看状态)'); else alert(j.error||'接入失败');
908
+ if(j.ok) uiAlert('✓ 已接入 '+name+'(重启 mingdao web 后生效,/mcp 查看状态)'); else uiAlert(j.error||'接入失败');
837
909
  };
838
910
  // —— 技能库 ——
839
911
  let skillSearchTimer=null;
@@ -863,7 +935,7 @@ async function refreshSkillLib(q, force){
863
935
  const body=s.installed?{action:'uninstall',name:s.name}:{action:'install',name:s.name};
864
936
  const rr=await fetch('/api/skills',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
865
937
  const jj=await rr.json().catch(()=>({error:'请求失败'}));
866
- if(jj.ok) refreshSkillLib($('#skillSearch').value.trim()); else alert(jj.error||'操作失败');
938
+ if(jj.ok) refreshSkillLib($('#skillSearch').value.trim()); else uiAlert(jj.error||'操作失败');
867
939
  };
868
940
  div.appendChild(b); list.appendChild(div);
869
941
  }
@@ -895,9 +967,9 @@ async function refreshModelsCfg(){
895
967
  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>';
896
968
  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>';
897
969
  const set=document.createElement('button'); set.textContent='设Key'; set.style.cssText='padding:1px 8px;font-size:11px';
898
- 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||'设置失败'); };
899
- 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); }
900
- 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); }
970
+ 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||'设置失败'); };
971
+ 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); }
972
+ 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); }
901
973
  div.appendChild(set); pk.appendChild(div);
902
974
  }
903
975
  const cl=$('#cmList'); cl.innerHTML='';
@@ -906,9 +978,9 @@ async function refreshModelsCfg(){
906
978
  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';
907
979
  const kstate=c.keyState==='stored'?'<span style="color:var(--accent)">'+esc(c.keyMasked||'')+'</span>':'<span style="color:var(--err)">无Key</span>';
908
980
  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>';
909
- 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||'设置失败'); }); };
910
- 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||'修改失败'); }); };
911
- 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||'删除失败'); };
981
+ 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||'设置失败'); }); };
982
+ 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||'修改失败'); }); };
983
+ 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||'删除失败'); };
912
984
  div.appendChild(sk); div.appendChild(ed); div.appendChild(rm); cl.appendChild(div);
913
985
  }
914
986
  $('#baseUrlOverride').value=j.baseUrlOverride||'';
@@ -916,15 +988,15 @@ async function refreshModelsCfg(){
916
988
  $('#cmAdd').onclick=async ()=>{
917
989
  const name=$('#cmName').value.trim();
918
990
  const url=$('#cmUrl').value.trim();
919
- if(!name||!url){ alert('模型名与 API 地址必填'); return; }
991
+ if(!name||!url){ uiAlert('模型名与 API 地址必填'); return; }
920
992
  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})});
921
993
  const j=await r.json().catch(()=>({error:'请求失败'}));
922
- if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else alert(j.error||'添加失败');
994
+ if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else uiAlert(j.error||'添加失败');
923
995
  };
924
996
  $('#baseUrlSave').onclick=async ()=>{
925
997
  const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setBaseUrl',baseUrl:$('#baseUrlOverride').value.trim()})});
926
998
  const j=await r.json().catch(()=>({error:'请求失败'}));
927
- if(j.ok) alert('✓ API 地址已保存(立即生效)'); else alert(j.error||'保存失败');
999
+ if(j.ok) uiAlert('✓ API 地址已保存(立即生效)'); else uiAlert(j.error||'保存失败');
928
1000
  };
929
1001
  // —— 云同步 ——
930
1002
  async function refreshSyncUI(){
@@ -943,15 +1015,15 @@ async function refreshSyncUI(){
943
1015
  }
944
1016
  $('#syncLogin').onclick=async ()=>{
945
1017
  const url=$('#syncUrl').value.trim(); const username=$('#syncUser').value.trim(); const password=$('#syncPass').value;
946
- if(!url||!username||!password){ alert('服务器地址、用户名、密码均必填'); return; }
1018
+ if(!url||!username||!password){ uiAlert('服务器地址、用户名、密码均必填'); return; }
947
1019
  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()})});
948
1020
  const j=await r.json().catch(()=>({error:'请求失败'}));
949
- if(j.ok){ $('#syncPass').value=''; refreshSyncUI(); } else alert(j.error||'登录失败');
1021
+ if(j.ok){ $('#syncPass').value=''; refreshSyncUI(); } else uiAlert(j.error||'登录失败');
950
1022
  };
951
1023
  $('#syncPush').onclick=async ()=>{
952
1024
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'push'})});
953
1025
  const j=await r.json().catch(()=>({error:'请求失败'}));
954
- alert(j.ok?('✓ 已推送 '+j.pushed+' 个会话'+(j.conflicts?','+j.conflicts+' 个远端版本已备份 .server-*':'')):j.error);
1026
+ uiAlert(j.ok?('✓ 已推送 '+j.pushed+' 个会话'+(j.conflicts?','+j.conflicts+' 个远端版本已备份 .server-*':'')):j.error);
955
1027
  refreshSyncUI(); refreshSyncConflicts();
956
1028
  };
957
1029
  $('#syncLogout').onclick=async ()=>{ await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'logout'})}); refreshSyncUI(); };
@@ -966,7 +1038,7 @@ async function refreshSyncShares(){
966
1038
  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';
967
1039
  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>';
968
1040
  const b=document.createElement('button'); b.textContent='撤销'; b.className='danger'; b.style.cssText='padding:1px 8px;font-size:11px';
969
- 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||'撤销失败'); };
1041
+ 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||'撤销失败'); };
970
1042
  div.appendChild(b); ml.appendChild(div);
971
1043
  }
972
1044
  } else { ml.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">暂无分享(分享后此处显示分享码)</div>'; }
@@ -980,23 +1052,23 @@ async function refreshSyncShares(){
980
1052
  } else { al.innerHTML='<div style="color:var(--faint);font-size:12px;padding:4px 2px">未接受任何分享</div>'; }
981
1053
  }
982
1054
  $('#shareCreate').onclick=async ()=>{
983
- const name=$('#shareName').value.trim(); if(!name){ alert('输入会话文件名'); return; }
1055
+ const name=$('#shareName').value.trim(); if(!name){ uiAlert('输入会话文件名'); return; }
984
1056
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'share',name})});
985
1057
  const j=await r.json().catch(()=>({error:'请求失败'}));
986
- if(j.ok){ $('#shareName').value=''; refreshSyncShares(); } else alert(j.error||'创建失败');
1058
+ if(j.ok){ $('#shareName').value=''; refreshSyncShares(); } else uiAlert(j.error||'创建失败');
987
1059
  };
988
1060
  $('#shareAccept').onclick=async ()=>{
989
- const code=$('#acceptCode').value.trim(); if(!code){ alert('输入分享码'); return; }
1061
+ const code=$('#acceptCode').value.trim(); if(!code){ uiAlert('输入分享码'); return; }
990
1062
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'accept',shareId:code})});
991
1063
  const j=await r.json().catch(()=>({error:'请求失败'}));
992
- if(j.ok){ $('#acceptCode').value=''; refreshSyncShares(); refreshSessions(); } else alert(j.error||'接受失败');
1064
+ if(j.ok){ $('#acceptCode').value=''; refreshSyncShares(); refreshSessions(); } else uiAlert(j.error||'接受失败');
993
1065
  };
994
1066
  $('#passwdBtn').onclick=async ()=>{
995
1067
  const oldPass=$('#oldPass').value, newPass=$('#newPass').value;
996
- if(!oldPass||!newPass){ alert('旧密码与新密码均必填'); return; }
1068
+ if(!oldPass||!newPass){ uiAlert('旧密码与新密码均必填'); return; }
997
1069
  const r=await fetch('/api/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'passwd',oldPassword:oldPass,newPassword:newPass})});
998
1070
  const j=await r.json().catch(()=>({error:'请求失败'}));
999
- if(j.ok){ $('#oldPass').value=''; $('#newPass').value=''; alert('✓ 密码已修改'); } else alert(j.error||'修改失败');
1071
+ if(j.ok){ $('#oldPass').value=''; $('#newPass').value=''; uiAlert('✓ 密码已修改'); } else uiAlert(j.error||'修改失败');
1000
1072
  };
1001
1073
  async function refreshSyncConflicts(){
1002
1074
  const r=await fetch('/api/sync-conflicts',{cache:'no-store'}).catch(()=>null); if(!r) return;
@@ -1005,7 +1077,7 @@ async function refreshSyncConflicts(){
1005
1077
  for(const c of j.conflicts){
1006
1078
  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';
1007
1079
  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>';
1008
- 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; };
1080
+ 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; };
1009
1081
  div.appendChild(mk('保留本地','local')); div.appendChild(mk('采用远端','remote','danger')); div.appendChild(mk('都保留','both'));
1010
1082
  list.appendChild(div);
1011
1083
  }
@@ -1049,8 +1121,8 @@ async function openSessModal(){
1049
1121
  for(const s of j.sessions){
1050
1122
  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';
1051
1123
  div.innerHTML='<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(s.label)+'</span>';
1052
- 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||'重命名失败'); };
1053
- 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||'删除失败'); };
1124
+ 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||'重命名失败'); };
1125
+ 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||'删除失败'); };
1054
1126
  div.appendChild(rn); div.appendChild(dl); list.appendChild(div);
1055
1127
  }
1056
1128
  }
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;
@@ -784,7 +817,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
784
817
  res.end();
785
818
  return;
786
819
  }
787
- 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
+ }
788
833
  return;
789
834
  }
790
835
  if (req.method === 'POST' && p === '/api/permission') {