mingdao-harness 0.1.69 → 0.2.0

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.
@@ -0,0 +1,832 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../../config.js';
7
+ import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../../credentials.js';
8
+ import { availableModels, fetchProviderModels, providerHasKey } from '../../model-discovery.js';
9
+ import { createProvider, resolveProviderConfig, helperProvider } from '../../providers/index.js';
10
+ import { MODELS, modelPreset, PROVIDERS } from '../../models.js';
11
+ import { routeTask, routingConfig } from '../../routing.js';
12
+ import { buildUserContent } from '../attachments.js';
13
+ import { createAgent } from '../../agent.js';
14
+ import { createPermission } from '../../permissions.js';
15
+ import { buildSystemPrompt } from '../../prompts.js';
16
+ import { createWebIO } from '../web-io.js';
17
+ import { startMcpServers } from '../../mcp.js';
18
+ import {
19
+ createSession,
20
+ listSessions,
21
+ loadSession,
22
+ appendMessages,
23
+ rewriteSession,
24
+ sessionPreview,
25
+ relativeTime,
26
+ searchSessions,
27
+ } from '../../session.js';
28
+ import { listSkills, tamperedSkillNames } from '../../skills.js';
29
+ import { libraryList, installSkill, uninstallSkill, installedUserSkillNames } from '../../skill-lib.js';
30
+ import { detectSandbox } from '../../tools/bash.js';
31
+ import { generateTitle, renameSessionFile, titleModel, sanitizeTitle } from '../../titles.js';
32
+ import {
33
+ addSchedule,
34
+ listSchedules,
35
+ removeSchedule,
36
+ pauseSchedule,
37
+ resumeSchedule,
38
+ chainSchedules,
39
+ reconcileSchedules,
40
+ } from '../../schedule.js';
41
+ import { enableAutostart, disableAutostart, autostartStatus } from '../../autostart.js';
42
+ import { currentWorkspace, workspaceForDir, listWorkspaces, addWorkspace, removeWorkspace, renameWorkspace, setWorkspaceDir, workspacePath, touchWorkspace, getSessionWorkspace, setSessionWorkspace, removeSessionWorkspace, moveSessionWorkspace } from '../../workspace.js';
43
+ import { loadMemory, writeMemory, dedupeMemory } from '../../memory.js';
44
+ import { recordUsage, listCacheStats, summarizeCacheStats, costBreakdown } from '../../cachestats.js';
45
+ import { PRICE_DATA_AS_OF } from '../../pricing.js';
46
+ import { costGuardStatus } from '../../cost-guard.js';
47
+ import { presetList, buildPreset } from '../../mcp-presets.js';
48
+ import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList, maybeAutoSync, syncChangePassword, syncShareCreate, syncShareList, syncShareAccept, syncShareRevoke, listSyncConflicts, resolveSyncConflict } from '../../sync.js';
49
+
50
+ export function createApiDispatch(deps) {
51
+ const { json, srvlog, readBody, cfg, home, providerCache, getProviderFor, tasks, MAX_CONCURRENT, pruneTasks, handleChat, mcpFacade, provider, validateRemoteUrl, isPrivateHost, authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML } = deps;
52
+ const state = deps.state; // { modelName, workingDir, draftText }
53
+ const refs = deps.refs; // { inflight }
54
+ const dispatch = async (req, res) => {
55
+ const url = new URL(req.url, 'http://localhost');
56
+ const p = url.pathname;
57
+
58
+ // 访问控制(P1-3/P1-4):token 校验覆盖数据与操作接口;壳页面与 PWA 静态资源公开
59
+ // (壳不含任何数据,SPA 需要先加载才能读取 ?token=);Host 白名单覆盖一切请求
60
+ const isStaticAsset =
61
+ p === '/' || p === '/index.html' || p === '/app.js' || p === '/favicon.ico' || p === '/icon.svg' || p === '/icon-192.png' || p === '/icon-512.png' || p === '/manifest.webmanifest' || p === '/sw.js';
62
+ if (authEnabled && !isStaticAsset && !tokenMatches(requestToken(req, url))) {
63
+ return json(res, 401, { error: '未授权:缺少或无效的访问令牌(地址需带 ?token=…,或请求头携带 X-MingDao-Token)' });
64
+ }
65
+ if (!trustedHost(req.headers.host)) {
66
+ return json(res, 403, { error: 'Host 校验失败:请通过绑定地址访问(DNS rebinding 防护)' });
67
+ }
68
+
69
+ // CSRF 防护:跨源请求一律拒绝;POST 仅接受 JSON(拦截表单/纯文本跨站盲提交)
70
+ if (req.method !== 'GET' && req.method !== 'OPTIONS') {
71
+ const origin = req.headers.origin;
72
+ if (origin) {
73
+ try {
74
+ if (new URL(origin).host !== req.headers.host) return json(res, 403, { error: '跨源请求被拒绝' });
75
+ } catch {
76
+ return json(res, 403, { error: '非法 Origin' });
77
+ }
78
+ }
79
+ const ct = String(req.headers['content-type'] || '');
80
+ if (!ct.includes('application/json') && !ct.includes('text/plain')) {
81
+ return json(res, 415, { error: '仅接受 JSON 请求体' });
82
+ }
83
+ if (ct.includes('text/plain')) {
84
+ // text/plain 是经典 CSRF 向量(无需预检):直接拒绝,要求 application/json
85
+ return json(res, 415, { error: '请使用 application/json' });
86
+ }
87
+ }
88
+
89
+ if (req.method === 'GET' && p === '/app.js') {
90
+ // 质检 Q2/S5:SPA 外部 JS——从磁盘读取(与 index.html 同目录)
91
+ try {
92
+ const js = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'app.js'), 'utf8');
93
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' });
94
+ res.end(js);
95
+ } catch {
96
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
97
+ res.end('app.js 加载失败');
98
+ }
99
+ return;
100
+ }
101
+ if (req.method === 'GET' && (p === '/' || p === '/index.html')) {
102
+ try {
103
+ const html = fs.readFileSync(INDEX_HTML);
104
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
105
+ res.end(html);
106
+ } catch {
107
+ json(res, 500, { error: '前端文件缺失' });
108
+ }
109
+ return;
110
+ }
111
+ // 统一品牌图标:浏览器标签页 /favicon.ico 直接返回 192 PNG
112
+ if (req.method === 'GET' && (p === '/favicon.ico' || p === '/icon-192.png' || p === '/icon-512.png')) {
113
+ try {
114
+ const file = path.join(path.dirname(fileURLToPath(import.meta.url)), 'icons', p === '/icon-512.png' ? 'icon-512.png' : 'icon-192.png');
115
+ const buf = fs.readFileSync(file);
116
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' });
117
+ res.end(buf);
118
+ } catch {
119
+ json(res, 404, { error: 'icon missing' });
120
+ }
121
+ return;
122
+ }
123
+ if (req.method === 'GET' && p === '/api/state') {
124
+ const sessions = listSessions(home)
125
+ .slice(0, 30)
126
+ .map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
127
+ // 模型列表:只列已设置 Key 的服务商,名称以 /models 接口线上名单为准(预设仅回退与补价)
128
+ const models = await availableModels(cfg, state.modelName);
129
+ json(res, 200, {
130
+ ok: true,
131
+ model: state.modelName,
132
+ models,
133
+ // 首次使用引导:当前模型无可用 API Key 时前端显示「去 ⚙ 设置填 Key」横幅
134
+ keyReady: Boolean((resolveProviderConfig(cfg, state.modelName) || {}).apiKey),
135
+ permissions: ['ask', 'auto', 'readonly'],
136
+ permission: cfg.permission ?? 'ask',
137
+ sandbox: cfg.sandbox || 'off',
138
+ sandboxSupported: detectSandbox() !== 'none',
139
+ routing: cfg.routing?.enabled ? cfg.routing : null,
140
+ contextBudget: cfg.contextBudget || 128000,
141
+ pricingAsOf: PRICE_DATA_AS_OF,
142
+ autostart: autostartStatus(),
143
+ notify: cfg.notify !== false,
144
+ workspace: currentWorkspace(state.workingDir)?.name || null,
145
+ home,
146
+ workingDir: state.workingDir,
147
+ mcp: mcpFacade.status(),
148
+ sessions,
149
+ });
150
+ return;
151
+ }
152
+ if (req.method === 'POST' && p === '/api/config') {
153
+ const body = await readBody(req);
154
+ // 内存 cfg 可能比磁盘旧(如 CLI 改过同步地址):先从磁盘刷新 sync,避免保存时回滚
155
+ cfg.sync = loadConfig()?.sync || cfg.sync;
156
+ const next = { model: state.modelName, permission: cfg.permission ?? 'ask' };
157
+ if (body.model !== undefined) {
158
+ const target = String(body.model).trim();
159
+ if (!target) return json(res, 400, { error: '模型名不能为空' });
160
+ const tpc = resolveProviderConfig(cfg, target);
161
+ if (!tpc.apiKey) {
162
+ const hint = tpc.name.startsWith('custom:')
163
+ ? '请在 设置 → 模型与 API Key 中填写该模型的 Key'
164
+ : `请先运行 mingdao key set ${tpc.name}`;
165
+ return json(res, 400, { error: `模型 ${target} 没有可用 API Key(服务商 ${tpc.name}),${hint}` });
166
+ }
167
+ await getProviderFor(target); // 预热(失败会抛错)
168
+ next.model = target;
169
+ }
170
+ if (body.permission !== undefined) {
171
+ const perm = String(body.permission);
172
+ if (!['ask', 'auto', 'readonly'].includes(perm)) {
173
+ return json(res, 400, { error: '权限模式必须是 ask / auto / readonly' });
174
+ }
175
+ next.permission = perm;
176
+ }
177
+ if (body.sandbox !== undefined) {
178
+ const sbx = String(body.sandbox);
179
+ if (!['off', 'readonly', 'safe'].includes(sbx)) {
180
+ return json(res, 400, { error: '沙箱模式必须是 off / readonly / safe' });
181
+ }
182
+ next.sandbox = sbx;
183
+ cfg.sandbox = sbx;
184
+ }
185
+ if (body.routing !== undefined) {
186
+ const on = body.routing === true || body.routing === 'on';
187
+ cfg.routing = {
188
+ enabled: on,
189
+ planner: cfg.routing?.planner || 'deepseek-v4-pro',
190
+ executor: cfg.routing?.executor || 'deepseek-v4-flash',
191
+ };
192
+ next.routing = on;
193
+ }
194
+ if (body.contextBudget !== undefined) {
195
+ const n = Number(body.contextBudget);
196
+ if (!Number.isInteger(n) || n < 1000) {
197
+ return json(res, 400, { error: '上下文预算必须是 ≥1000 的整数' });
198
+ }
199
+ next.contextBudget = n;
200
+ cfg.contextBudget = n;
201
+ }
202
+ let autostartChanged = false;
203
+ if (body.autostart !== undefined) {
204
+ autostartChanged = true;
205
+ const okAuto = body.autostart === true || body.autostart === 'on' ? enableAutostart() : disableAutostart();
206
+ if (!okAuto) return json(res, 500, { error: '开机自启设置失败' });
207
+ }
208
+ if (body.notify !== undefined) {
209
+ next.notify = body.notify === true || body.notify === 'on';
210
+ cfg.notify = next.notify;
211
+ }
212
+ if (body.syncAuto !== undefined) {
213
+ cfg.sync = cfg.sync || {};
214
+ cfg.sync.auto = body.syncAuto === true || body.syncAuto === 'on';
215
+ }
216
+ state.modelName = next.model;
217
+ cfg.model = next.model;
218
+ cfg.permission = next.permission;
219
+ saveConfig(cfg);
220
+ json(res, 200, { ok: true, model: state.modelName, permission: cfg.permission, sandbox: cfg.sandbox, routing: cfg.routing?.enabled, contextBudget: cfg.contextBudget, autostart: autostartChanged ? autostartStatus() : undefined, notify: cfg.notify !== false });
221
+ return;
222
+ }
223
+ if (req.method === 'GET' && p === '/api/models-config') {
224
+ const providers = Object.keys(PROVIDERS).map((name) => {
225
+ const pp = PROVIDERS[name];
226
+ const stored = getStoredKey(name);
227
+ const env = pp.envKey && process.env[pp.envKey] ? true : false;
228
+ return {
229
+ name,
230
+ label: pp.label,
231
+ baseUrl: pp.baseUrl,
232
+ envKey: pp.envKey || null,
233
+ keyState: stored ? 'stored' : env ? 'env' : 'none',
234
+ keyMasked: stored ? maskKey(stored) : null,
235
+ };
236
+ });
237
+ const customModels = Object.entries(cfg.customModels || {}).map(([name, cm]) => {
238
+ const stored = getStoredKey(`custom:${name}`);
239
+ return {
240
+ name,
241
+ label: cm.label || '',
242
+ baseUrl: cm.baseUrl || '',
243
+ envKey: cm.envKey || null,
244
+ vision: Boolean(cm.vision),
245
+ keyState: stored ? 'stored' : 'none',
246
+ keyMasked: stored ? maskKey(stored) : null,
247
+ };
248
+ });
249
+ json(res, 200, {
250
+ ok: true,
251
+ providers,
252
+ customModels,
253
+ model: state.modelName,
254
+ provider: resolveProviderConfig(cfg, state.modelName).name,
255
+ baseUrlOverride: cfg.baseUrl || '',
256
+ });
257
+ return;
258
+ }
259
+ if (req.method === 'POST' && p === '/api/models-config') {
260
+ const body = await readBody(req);
261
+ const action = body.action;
262
+ // —— 服务商 Key 管理 ——
263
+ if (action === 'setProviderKey') {
264
+ const provider = String(body.provider || '').trim();
265
+ if (!PROVIDERS[provider]) return json(res, 400, { error: `未知服务商 ${provider}` });
266
+ const key = String(body.key || '').trim();
267
+ if (!key) return json(res, 400, { error: 'Key 不能为空(删除请用 removeProviderKey)' });
268
+ setStoredKey(provider, key);
269
+ providerCache.clear(); // 质检:Key 立即生效(缓存持有旧无 Key 实例 → 此前需重启)
270
+ // 设置 Key 后立即拉取线上真实模型名单(失败不影响 Key 保存,回退预设)
271
+ const fr = provider === 'custom' ? { error: '自定义服务商无模型列表' } : await fetchProviderModels(cfg, provider, { force: true });
272
+ return json(res, 200, {
273
+ ok: true,
274
+ provider,
275
+ keyMasked: maskKey(key),
276
+ models: fr.models || [],
277
+ modelsNote: fr.error ? `模型列表暂用预设(拉取失败:${fr.error}),稍后可用「刷新模型」重试` : null,
278
+ });
279
+ }
280
+ if (action === 'refreshModels') {
281
+ const provider = String(body.provider || '').trim();
282
+ if (!PROVIDERS[provider] || provider === 'custom') return json(res, 400, { error: '未知服务商' });
283
+ if (!providerHasKey(provider)) return json(res, 400, { error: '该服务商未设置 API Key' });
284
+ const fr = await fetchProviderModels(cfg, provider, { force: true });
285
+ if (fr.error) return json(res, 400, { error: `拉取失败:${fr.error}` });
286
+ return json(res, 200, { ok: true, provider, models: fr.models, fromCache: fr.fromCache });
287
+ }
288
+ if (action === 'removeProviderKey') {
289
+ const provider = String(body.provider || '').trim();
290
+ if (!PROVIDERS[provider]) return json(res, 400, { error: `未知服务商 ${provider}` });
291
+ removeStoredKey(provider);
292
+ providerCache.clear(); // 质检:Key 删除立即生效
293
+ return json(res, 200, { ok: true, provider });
294
+ }
295
+ // —— 自定义模型增删改 ——
296
+ if (action === 'addCustom' || action === 'updateCustom') {
297
+ const name = String(body.name || '').trim();
298
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name)) {
299
+ return json(res, 400, { error: '模型名非法:字母/数字开头,可含 . _ -,1–64 位' });
300
+ }
301
+ if (MODELS[name]) return json(res, 400, { error: `${name} 与内置模型同名,请换一个名字` });
302
+ const label = String(body.label || '').trim();
303
+ const baseUrl = String(body.baseUrl || '').trim();
304
+ if (!baseUrl) return json(res, 400, { error: 'API 地址(baseUrl)不能为空' });
305
+ const vurl = validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
306
+ if (vurl.error) return json(res, 400, { error: vurl.error });
307
+ if (action === 'addCustom' && (cfg.customModels || {})[name]) {
308
+ return json(res, 400, { error: `自定义模型 ${name} 已存在(可修改)` });
309
+ }
310
+ cfg.customModels = cfg.customModels || {};
311
+ cfg.customModels[name] = {
312
+ label,
313
+ baseUrl,
314
+ envKey: String(body.envKey || '').trim() || undefined,
315
+ vision: body.vision === true || body.vision === 'on' ? true : undefined,
316
+ };
317
+ if (String(body.key || '').trim()) setStoredKey(`custom:${name}`, String(body.key).trim());
318
+ saveConfig(cfg);
319
+ return json(res, 200, { ok: true, name });
320
+ }
321
+ if (action === 'removeCustom') {
322
+ const name = String(body.name || '').trim();
323
+ if (!(cfg.customModels || {})[name]) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
324
+ delete cfg.customModels[name];
325
+ removeStoredKey(`custom:${name}`);
326
+ if (state.modelName === name) {
327
+ state.modelName = 'deepseek-v4-flash';
328
+ cfg.model = state.modelName;
329
+ }
330
+ saveConfig(cfg);
331
+ return json(res, 200, { ok: true, name, model: state.modelName });
332
+ }
333
+ if (action === 'setCustomKey') {
334
+ const name = String(body.name || '').trim();
335
+ if (!(cfg.customModels || {})[name]) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
336
+ const key = String(body.key || '').trim();
337
+ if (!key) return json(res, 400, { error: 'Key 不能为空' });
338
+ setStoredKey(`custom:${name}`, key);
339
+ providerCache.clear(); // 质检:Key 立即生效
340
+ return json(res, 200, { ok: true, name, keyMasked: maskKey(key) });
341
+ }
342
+ // —— 当前服务商 API 地址覆盖 ——
343
+ if (action === 'setBaseUrl') {
344
+ const baseUrl = String(body.baseUrl || '').trim();
345
+ if (baseUrl) {
346
+ const vurl = validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
347
+ if (vurl.error) return json(res, 400, { error: vurl.error });
348
+ }
349
+ cfg.baseUrl = baseUrl || undefined;
350
+ if (cfg.baseUrl === undefined) delete cfg.baseUrl;
351
+ saveConfig(cfg);
352
+ return json(res, 200, { ok: true, baseUrl: cfg.baseUrl || '' });
353
+ }
354
+ return json(res, 400, { error: '未知操作:setProviderKey|removeProviderKey|addCustom|updateCustom|removeCustom|setCustomKey|setBaseUrl' });
355
+ }
356
+ if (req.method === 'GET' && p === '/api/sessions') {
357
+ const q = url.searchParams.get('q');
358
+ const found = q
359
+ ? searchSessions(home, q, { limit: 30 }).map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${s.snippet}` }))
360
+ : listSessions(home).slice(0, 30).map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
361
+ json(res, 200, { ok: true, sessions: found });
362
+ return;
363
+ }
364
+ if (req.method === 'GET' && p === '/api/session') {
365
+ const file = url.searchParams.get('file');
366
+ if (!file) return json(res, 400, { error: '缺少 file 参数' });
367
+ try {
368
+ const loaded = loadSession(path.join(home, 'sessions', path.basename(file)));
369
+ // 载入会话时聚焦其工作空间(P3-4):全局默认切到该会话的目录,前端下拉同步显示
370
+ const wsDir = getSessionWorkspace(path.basename(file));
371
+ const ws = workspaceForDir(wsDir);
372
+ if (wsDir && path.resolve(wsDir) !== path.resolve(state.workingDir)) {
373
+ state.workingDir = wsDir; // 审计 P2-5:仅在目录不同时才切换,减少全局状态抖动
374
+ if (ws) touchWorkspace(ws.name);
375
+ }
376
+ json(res, 200, {
377
+ ok: true,
378
+ messages: loaded.messages.filter((m) => m.role === 'user' || m.role === 'assistant').map((m) => ({ role: m.role, content: m.content ?? '' })),
379
+ workspace: ws?.name || null,
380
+ });
381
+ } catch {
382
+ json(res, 404, { error: '会话不存在' });
383
+ }
384
+ return;
385
+ }
386
+ if (req.method === 'POST' && p === '/api/session') {
387
+ const body = await readBody(req);
388
+ const file = path.basename(String(body.file || ''));
389
+ const full = path.join(home, 'sessions', file);
390
+ if (!file || !fs.existsSync(full)) return json(res, 404, { error: '会话不存在' });
391
+ if (body.action === 'rename') {
392
+ const title = sanitizeTitle(String(body.title || '会话'));
393
+ const renamed = renameSessionFile(fs, path, home, { file: full }, title);
394
+ if (!renamed) return json(res, 500, { error: '重命名失败(可能存在同名会话)' });
395
+ moveSessionWorkspace(file, path.basename(renamed));
396
+ return json(res, 200, { ok: true, file: path.basename(renamed) });
397
+ }
398
+ if (body.action === 'delete') {
399
+ try {
400
+ fs.unlinkSync(full);
401
+ removeSessionWorkspace(file);
402
+ return json(res, 200, { ok: true });
403
+ } catch (err) {
404
+ return json(res, 500, { error: String(err?.message || err) });
405
+ }
406
+ }
407
+ return json(res, 400, { error: '未知操作:rename|delete' });
408
+ }
409
+ if (req.method === 'GET' && p === '/api/skills') {
410
+ json(res, 200, { ok: true, skills: listSkills(state.workingDir), tampered: tamperedSkillNames(state.workingDir) });
411
+ return;
412
+ }
413
+ if (req.method === 'GET' && p === '/api/tasks') {
414
+ const running = [...tasks.values()].filter((t) => t.status === 'running').length;
415
+ const list = [...tasks.entries()].map(([id, t]) => ({
416
+ id,
417
+ status: t.status,
418
+ message: t.message,
419
+ startedAt: t.startedAt,
420
+ durationMs: t.status === 'running' ? Date.now() - t.startedAt : t.durationMs,
421
+ session: t.session ? path.basename(t.session.file) : null,
422
+ }));
423
+ json(res, 200, { ok: true, running, maxConcurrent: MAX_CONCURRENT, tasks: list });
424
+ return;
425
+ }
426
+ if (req.method === 'POST' && p === '/api/chat') {
427
+ // 质检 S2:inflight 计数绑定请求生命周期(进入 ++ / 结束 --)。
428
+ // 此前检查发生在 readBody 之前且占位即删,多请求可同时卡在 readBody 后批量登记 → 瞬时超并发
429
+ refs.inflight += 1;
430
+ if (refs.inflight > MAX_CONCURRENT) {
431
+ refs.inflight -= 1;
432
+ return json(res, 429, { error: `并发任务已达上限(${MAX_CONCURRENT}),请等待任务完成或中断` });
433
+ }
434
+ res.writeHead(200, {
435
+ 'Content-Type': 'text/event-stream; charset=utf-8',
436
+ 'Cache-Control': 'no-cache',
437
+ Connection: 'keep-alive',
438
+ 'X-Accel-Buffering': 'no',
439
+ });
440
+ let body;
441
+ try {
442
+ body = await readBody(req);
443
+ } catch (e) {
444
+ refs.inflight -= 1;
445
+ res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
446
+ res.end();
447
+ return;
448
+ }
449
+ try {
450
+ await handleChat(res, body);
451
+ } catch (err) {
452
+ // 兜底(审计:「点发送无反应」根因防护):handleChat 内任意未捕获异常若直接外抛,
453
+ // SSE 已开流却无事件 → 前端永远停在「正在思考…」。此处统一转为 error 事件回给界面。
454
+ try {
455
+ res.write(`data: ${JSON.stringify({ type: 'error', message: '对话失败:' + String(err?.message || err) })}\n\n`);
456
+ } catch {}
457
+ try {
458
+ res.end();
459
+ } catch {}
460
+ pruneTasks();
461
+ }
462
+ refs.inflight -= 1; // 与 handleChat 的 finally 无关:handleChat 内部路径全部会返回/结束
463
+ return;
464
+ }
465
+ if (req.method === 'POST' && p === '/api/permission') {
466
+ const body = await readBody(req);
467
+ const entry = tasks.get(body.taskId);
468
+ if (!entry || !entry.pendingAsk) return json(res, 409, { error: '没有挂起的权限确认' });
469
+ const pa = entry.pendingAsk;
470
+ entry.pendingAsk = null;
471
+ const answer = String(body.answer ?? '');
472
+ if (pa.options && Array.isArray(pa.options)) {
473
+ const opt = pa.options.find((o) => String(o.value) === answer);
474
+ pa.resolve(opt ? opt.value : answer);
475
+ } else {
476
+ pa.resolve(answer);
477
+ }
478
+ json(res, 200, { ok: true });
479
+ return;
480
+ }
481
+ if (req.method === 'POST' && p === '/api/abort') {
482
+ const body = await readBody(req);
483
+ if (body.taskId) {
484
+ const entry = tasks.get(body.taskId);
485
+ if (entry?.abortHandler) {
486
+ try {
487
+ entry.abortHandler();
488
+ } catch {}
489
+ }
490
+ } else {
491
+ // 未指定任务:中断全部运行中任务
492
+ for (const t of tasks.values()) {
493
+ if (t.status === 'running' && t.abortHandler) {
494
+ try {
495
+ t.abortHandler();
496
+ } catch {}
497
+ }
498
+ }
499
+ }
500
+ json(res, 200, { ok: true });
501
+ return;
502
+ }
503
+ if (req.method === 'GET' && p === '/api/draft') {
504
+ const text = state.draftText;
505
+ state.draftText = ''; // 读取即清除
506
+ json(res, 200, { ok: true, text });
507
+ return;
508
+ }
509
+ if (req.method === 'POST' && p === '/api/draft') {
510
+ const body = await readBody(req);
511
+ state.draftText = String(body.text ?? '').slice(0, 200000);
512
+ json(res, 200, { ok: true });
513
+ return;
514
+ }
515
+ if (req.method === 'GET' && p === '/api/schedule') {
516
+ reconcileSchedules(home);
517
+ const jobs = listSchedules(home).map((j) => ({
518
+ id: j.id,
519
+ status: j.status,
520
+ kind: j.kind,
521
+ question: j.question,
522
+ nextRunAt: j.nextRunAt,
523
+ lastRunAt: j.lastRunAt,
524
+ lastTaskId: j.lastTaskId,
525
+ runs: j.runs,
526
+ interval: j.interval,
527
+ anchor: j.anchor,
528
+ after: j.after,
529
+ history: j.history || [],
530
+ note: j.note,
531
+ }));
532
+ json(res, 200, { ok: true, jobs });
533
+ return;
534
+ }
535
+ if (req.method === 'POST' && p === '/api/schedule') {
536
+ const body = await readBody(req);
537
+ const action = String(body.action || '');
538
+ try {
539
+ if (action === 'add') {
540
+ if (!body.question || !String(body.question).trim()) return json(res, 400, { error: '任务内容不能为空' });
541
+ const r = addSchedule(home, String(body.question).trim(), {
542
+ at: body.at || null,
543
+ every: body.every || null,
544
+ anchor: body.anchor || null,
545
+ after: Array.isArray(body.after) ? body.after.map(String) : body.after ? String(body.after).split(',').map((x) => x.trim()).filter(Boolean) : undefined,
546
+ permission: body.permission || null,
547
+ model: body.model || null,
548
+ cwd: state.workingDir,
549
+ offpeak: body.offpeak === true,
550
+ });
551
+ if (r.error) return json(res, 400, { error: r.error });
552
+ return json(res, 200, { ok: true, id: r.id });
553
+ }
554
+ if (action === 'chain') {
555
+ const qs = Array.isArray(body.questions) ? body.questions.map(String).filter((q) => q.trim()) : [];
556
+ if (qs.length < 2) return json(res, 400, { error: '链式需要至少两个任务' });
557
+ const r = chainSchedules(home, qs, { permission: body.permission || null, model: body.model || null });
558
+ if (r.error) return json(res, 400, { error: r.error });
559
+ return json(res, 200, { ok: true, ids: r.ids });
560
+ }
561
+ const id = String(body.id || '');
562
+ if (!id) return json(res, 400, { error: '缺少 id' });
563
+ if (action === 'remove') return json(res, 200, { ok: removeSchedule(home, id) });
564
+ if (action === 'pause') return json(res, 200, { ok: pauseSchedule(home, id) });
565
+ if (action === 'resume') return json(res, 200, { ok: resumeSchedule(home, id) });
566
+ return json(res, 400, { error: '未知操作:add|chain|remove|pause|resume' });
567
+ } catch (err) {
568
+ return json(res, 500, { error: String(err?.message || err) });
569
+ }
570
+ }
571
+ if (req.method === 'GET' && p === '/api/workspaces') {
572
+ json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(state.workingDir)?.name || null, cwd: state.workingDir });
573
+ return;
574
+ }
575
+ if (req.method === 'POST' && p === '/api/workspaces') {
576
+ const body = await readBody(req);
577
+ const name = String(body.name || '').trim();
578
+ if (body.action === 'add') {
579
+ if (!name) return json(res, 400, { error: '名称不能为空' });
580
+ // 目录为空/不存在时自动新建(默认开,create:false 关闭)
581
+ const target = path.resolve(body.dir || state.workingDir);
582
+ if (body.create !== false) {
583
+ try {
584
+ fs.mkdirSync(target, { recursive: true });
585
+ } catch (e) {
586
+ return json(res, 400, { error: `无法创建目录:${target}(${e.message})` });
587
+ }
588
+ }
589
+ const r = addWorkspace(name, target);
590
+ if (r.error) return json(res, 400, { error: r.error });
591
+ return json(res, 200, { ok: true, name: r.name, dir: r.dir, created: body.create !== false });
592
+ }
593
+ if (body.action === 'rename') {
594
+ const r = renameWorkspace(name, body.newName);
595
+ if (r.error) return json(res, 400, { error: r.error });
596
+ return json(res, 200, { ok: true, name: r.name });
597
+ }
598
+ if (body.action === 'set') {
599
+ // 切换全局工作空间(新会话默认目录;可带 dir 修改目录);目录缺失自动重建。
600
+ // 携带 file 时同时把当前会话的工作空间切过去(P3-4:会话跟随显式切换)。
601
+ if (body.dir) {
602
+ const r = setWorkspaceDir(name, body.dir);
603
+ if (r.error) return json(res, 400, { error: r.error });
604
+ }
605
+ const dir = workspacePath(name);
606
+ if (!dir) return json(res, 400, { error: `工作空间 ${name} 不存在` });
607
+ try {
608
+ fs.mkdirSync(dir, { recursive: true });
609
+ } catch (e) {
610
+ return json(res, 400, { error: `无法创建目录:${dir}(${e.message})` });
611
+ }
612
+ touchWorkspace(name);
613
+ state.workingDir = dir;
614
+ // 不再 process.chdir:运行中任务的 cwd 在创建时已固定,全局切换只影响新会话
615
+ if (body.file) setSessionWorkspace(String(body.file), dir, name);
616
+ return json(res, 200, { ok: true, name, dir, current: name });
617
+ }
618
+ if (body.action === 'remove') {
619
+ if (!name) return json(res, 400, { error: '缺少名称' });
620
+ return json(res, 200, { ok: removeWorkspace(name) });
621
+ }
622
+ return json(res, 400, { error: '未知操作:add|rename|set|remove' });
623
+ }
624
+ // 目录浏览器(「新建工作空间」选择电脑磁盘目录用):本机运行时即用户电脑的目录树;
625
+ // 只列子目录(不含隐藏目录),供前端逐级导航选择
626
+ if (req.method === 'GET' && p === '/api/fs-browse') {
627
+ let dir = String(url.searchParams.get('dir') || '').trim();
628
+ if (!path.isAbsolute(dir)) return json(res, 400, { error: '需要绝对路径' });
629
+ try {
630
+ const st = fs.statSync(dir);
631
+ if (!st.isDirectory()) return json(res, 400, { error: '不是目录' });
632
+ const entries = fs
633
+ .readdirSync(dir, { withFileTypes: true })
634
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
635
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name) }))
636
+ .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
637
+ .slice(0, 300);
638
+ const parent = path.dirname(dir);
639
+ json(res, 200, { ok: true, path: dir, parent: parent === dir ? null : parent, entries });
640
+ } catch (err) {
641
+ json(res, 400, { error: String(err?.message || err) });
642
+ }
643
+ return;
644
+ }
645
+ if (req.method === 'GET' && p === '/api/memory') {
646
+ json(res, 200, { ok: true, content: loadMemory() });
647
+ return;
648
+ }
649
+ if (req.method === 'POST' && p === '/api/memory') {
650
+ const body = await readBody(req);
651
+ if (body.action === 'dedupe') {
652
+ const removed = dedupeMemory();
653
+ return json(res, 200, { ok: true, removed });
654
+ }
655
+ if (body.content !== undefined) {
656
+ writeMemory(body.content);
657
+ return json(res, 200, { ok: true });
658
+ }
659
+ return json(res, 400, { error: '缺少 content 或 action=dedupe' });
660
+ }
661
+ if (req.method === 'GET' && p === '/api/cache-stats') {
662
+ const entries = listCacheStats();
663
+ json(res, 200, {
664
+ ok: true,
665
+ summary: summarizeCacheStats(entries),
666
+ breakdown: costBreakdown(),
667
+ guard: costGuardStatus(),
668
+ recent: entries.slice(-10).reverse(),
669
+ });
670
+ return;
671
+ }
672
+ if (req.method === 'GET' && p === '/api/mcp-presets') {
673
+ json(res, 200, { ok: true, presets: presetList() });
674
+ return;
675
+ }
676
+ if (req.method === 'POST' && p === '/api/mcp-presets') {
677
+ const body = await readBody(req);
678
+ const name = String(body.name || '').trim();
679
+ const r = buildPreset(name, body.arg, state.workingDir);
680
+ if (r.error) return json(res, 400, { error: r.error });
681
+ cfg.mcpServers = cfg.mcpServers || {};
682
+ cfg.mcpServers[name] = r.config;
683
+ saveConfig(cfg);
684
+ json(res, 200, { ok: true, name, note: '重启 mingdao web 后生效(/mcp 查看状态)' });
685
+ return;
686
+ }
687
+ if (req.method === 'GET' && p === '/api/skill-library') {
688
+ const u = new URL(req.url, 'http://x');
689
+ const q = u.searchParams.get('q') || '';
690
+ const force = u.searchParams.get('refresh') === '1';
691
+ const local = q
692
+ ? libraryList().filter((s) => s.name.includes(q) || (s.description || '').includes(q))
693
+ : libraryList();
694
+ const { searchRegistry } = await import('../../skill-registry.js');
695
+ const remote = await searchRegistry(q || '', { force, allowNetwork: force });
696
+ const localNames = new Set(local.map((s) => s.name));
697
+ const registryEntries = remote.skills
698
+ ? remote.skills.filter((s) => !localNames.has(s.name)).map((s) => ({ ...s, dir: null }))
699
+ : [];
700
+ json(res, 200, {
701
+ ok: true,
702
+ library: local.map((s) => ({ name: s.name, description: s.description, source: 'builtin-lib', installed: s.installed })).concat(
703
+ registryEntries.map((s) => ({ name: s.name, description: s.description, source: 'registry', installed: s.installed }))
704
+ ),
705
+ installed: [...installedUserSkillNames()],
706
+ registry: remote.error ? { error: remote.error } : { host: remote.host, updatedAt: remote.updatedAt, stale: remote.stale || false },
707
+ });
708
+ return;
709
+ }
710
+ if (req.method === 'POST' && p === '/api/skills') {
711
+ const body = await readBody(req);
712
+ if (body.action === 'install') {
713
+ const r = await installSkill(body.arg || body.name || '');
714
+ if (r.error) return json(res, 400, { error: r.error });
715
+ return json(res, 200, { ok: true, name: r.name, names: r.names });
716
+ }
717
+ if (body.action === 'uninstall') {
718
+ const r = uninstallSkill(body.name);
719
+ if (r.error) return json(res, 400, { error: r.error });
720
+ return json(res, 200, { ok: true, name: r.name });
721
+ }
722
+ return json(res, 400, { error: '未知操作:install|uninstall' });
723
+ }
724
+ if (req.method === 'GET' && p === '/api/sync') {
725
+ const st = syncStatus();
726
+ let remote = null;
727
+ if (st.loggedIn) {
728
+ const r = await syncRemoteList();
729
+ remote = r.ok ? { sessions: r.sessions.slice(0, 50) } : { error: r.error };
730
+ }
731
+ json(res, 200, { ok: true, ...st, remote });
732
+ return;
733
+ }
734
+ if (req.method === 'POST' && p === '/api/sync') {
735
+ const body = await readBody(req);
736
+ if (body.action === 'login') {
737
+ const vurl = validateRemoteUrl(body.url); // 质检 S1:SSRF 防护(同步端点同样只允许公网)
738
+ if (vurl.error) return json(res, 400, { error: vurl.error });
739
+ const r = await syncLogin({ url: body.url, username: body.username, password: body.password, deviceName: body.deviceName });
740
+ if (r.error) return json(res, 400, { error: r.error });
741
+ return json(res, 200, { ok: true, ...r });
742
+ }
743
+ if (body.action === 'logout') {
744
+ syncLogout();
745
+ return json(res, 200, { ok: true });
746
+ }
747
+ if (body.action === 'push') {
748
+ const r = await syncPush(body.name);
749
+ if (r.error) return json(res, 400, { error: r.error });
750
+ return json(res, 200, { ok: true, pushed: r.pushed.length, conflicts: r.conflicts });
751
+ }
752
+ if (body.action === 'pull') {
753
+ const r = await syncPull(body.name);
754
+ if (r.error) return json(res, 400, { error: r.error });
755
+ return json(res, 200, { ok: true, pulled: r.pulled.length, conflicts: r.conflicts });
756
+ }
757
+ if (body.action === 'passwd') {
758
+ const r = await syncChangePassword({ oldPassword: body.oldPassword, newPassword: body.newPassword });
759
+ if (r.error) return json(res, 400, { error: r.error });
760
+ return json(res, 200, { ok: true });
761
+ }
762
+ if (body.action === 'share') {
763
+ const r = await syncShareCreate(body.name);
764
+ if (r.error) return json(res, 400, { error: r.error });
765
+ return json(res, 200, { ok: true, shareId: r.shareId, name: r.name });
766
+ }
767
+ if (body.action === 'shares') {
768
+ const r = await syncShareList();
769
+ if (r.error) return json(res, 400, { error: r.error });
770
+ return json(res, 200, { ok: true, mine: r.mine, accepted: r.accepted });
771
+ }
772
+ if (body.action === 'accept') {
773
+ const r = await syncShareAccept(body.shareId);
774
+ if (r.error) return json(res, 400, { error: r.error });
775
+ return json(res, 200, { ok: true, ...r });
776
+ }
777
+ if (body.action === 'unshare') {
778
+ const r = await syncShareRevoke(body.shareId);
779
+ if (r.error) return json(res, 400, { error: r.error });
780
+ return json(res, 200, { ok: true });
781
+ }
782
+ if (body.action === 'resolveConflict') {
783
+ const r = resolveSyncConflict(body.base, body.choice);
784
+ if (r.error) return json(res, 400, { error: r.error });
785
+ return json(res, 200, { ok: true, ...r });
786
+ }
787
+ return json(res, 400, { error: '未知操作:login|logout|push|pull|passwd|share|shares|accept|unshare|resolveConflict' });
788
+ }
789
+ if (req.method === 'GET' && p === '/api/sync-conflicts') {
790
+ json(res, 200, { ok: true, conflicts: listSyncConflicts() });
791
+ return;
792
+ }
793
+ // PWA 资源
794
+ if (req.method === 'GET' && p === '/manifest.webmanifest') {
795
+ res.writeHead(200, { 'Content-Type': 'application/manifest+json; charset=utf-8' });
796
+ res.end(
797
+ JSON.stringify({
798
+ name: 'MingDao Harness',
799
+ short_name: 'MingDao',
800
+ description: 'MingDao-Harness 智能体框架 WebUI',
801
+ start_url: '/',
802
+ display: 'standalone',
803
+ background_color: '#0f1115',
804
+ theme_color: '#0f1115',
805
+ icons: [
806
+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
807
+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
808
+ ],
809
+ })
810
+ );
811
+ return;
812
+ }
813
+ if (req.method === 'GET' && p === '/icon.svg') {
814
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' });
815
+ res.end(
816
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0f1115"/><path d="M18 20h6v24h-6zM29 20h6v24h-6zM40 20h6v24h-6z" fill="#3ddc97"/><circle cx="43" cy="18" r="5" fill="#22b8cf"/></svg>`
817
+ );
818
+ return;
819
+ }
820
+ if (req.method === 'GET' && p === '/sw.js') {
821
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
822
+ // 缓存键去掉 query(?token= 不落缓存);mingdao-v4:随 token 认证版本升版本号,强制替换旧 SW 缓存
823
+ res.end(`self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',e=>e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!=='mingdao-v4').map(k=>caches.delete(k)))).then(()=>clients.claim())));self.addEventListener('fetch',e=>{if(e.request.method==='GET'&&new URL(e.request.url).origin===location.origin&&!e.request.url.includes('/api/')){const u=new URL(e.request.url);u.search='';e.respondWith(fetch(e.request).then(r=>{const c=r.clone();caches.open('mingdao-v4').then(cache=>cache.put(u.toString(),c));return r;}).catch(()=>caches.match(u.toString()).then(m=>m||caches.match('/'))));}});`);
824
+ return;
825
+ }
826
+ json(res, 404, { error: 'Not found' });
827
+ };
828
+
829
+ // 全局异常兜底:readBody 超限(413)等错误不再让连接挂起
830
+
831
+ return dispatch;
832
+ }