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 +1 -1
- package/src/cli.js +24 -4
- package/src/providers/openai-compatible.js +5 -1
- package/src/schedule.js +18 -2
- package/src/skill-registry.js +13 -5
- package/src/tasks.js +3 -2
- package/src/titles.js +40 -29
- package/src/web/index.html +152 -80
- package/src/web/server.js +49 -4
package/package.json
CHANGED
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(() =>
|
|
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
|
-
|
|
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
|
|
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 });
|
package/src/skill-registry.js
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
package/src/web/index.html
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
59
|
-
.msg-ai .content
|
|
60
|
-
.msg-ai .content
|
|
61
|
-
.msg-ai .content
|
|
62
|
-
.msg-ai .content
|
|
63
|
-
.msg-ai .content
|
|
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"
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
out+=
|
|
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
|
-
|
|
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){
|
|
576
|
+
if(attachments.length>=4){ uiAlert('最多 4 个附件'); break; }
|
|
519
577
|
if(f.type.startsWith('image/')){
|
|
520
|
-
if(f.size>5*1024*1024){
|
|
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){
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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')
|
|
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=
|
|
634
|
+
const hintEl=$('#hint'); const hintTextEl=$('#hintText'); const defaultHint=hintTextEl?hintTextEl.textContent:'';
|
|
567
635
|
hintEl.classList.add('working');
|
|
568
|
-
const hintTimer=setInterval(()=>{
|
|
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;
|
|
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');
|
|
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
|
-
|
|
606
|
-
|
|
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=
|
|
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
|
|
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 {
|
|
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){
|
|
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 {
|
|
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
|
|
770
|
-
const rn=document.createElement('button'); rn.textContent='重命名'; rn.style.cssText='padding:1px 8px;font-size:11px'; rn.onclick=async()=>{ const t=
|
|
771
|
-
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!
|
|
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
|
|
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)
|
|
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
|
|
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=
|
|
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();
|
|
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(!
|
|
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=
|
|
910
|
-
const ed=document.createElement('button'); ed.textContent='修改'; ed.style.cssText='padding:1px 8px;font-size:11px'; ed.onclick=()=>{ const u=
|
|
911
|
-
const rm=document.createElement('button'); rm.textContent='删除'; rm.className='danger'; rm.style.cssText='padding:1px 8px;font-size:11px'; rm.onclick=async()=>{ if(!
|
|
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){
|
|
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
|
|
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)
|
|
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){
|
|
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
|
|
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
|
-
|
|
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
|
|
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){
|
|
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
|
|
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){
|
|
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
|
|
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){
|
|
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='';
|
|
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
|
|
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=
|
|
1053
|
-
const dl=document.createElement('button'); dl.textContent='删除'; dl.className='danger'; dl.style.cssText='padding:1px 8px;font-size:11px'; dl.onclick=async()=>{ if(!
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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') {
|