mingdao-harness 0.1.54
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/LICENSE +21 -0
- package/README.md +246 -0
- package/assets/tokenizer-data.json.gz +0 -0
- package/docs/ARCHITECTURE.md +108 -0
- package/docs/CONFIG.md +257 -0
- package/docs/DESKTOP-EVALUATION.md +48 -0
- package/docs/PROVIDERS.md +98 -0
- package/docs/QA-REPORT.md +333 -0
- package/install.bat +8 -0
- package/install.ps1 +57 -0
- package/install.sh +154 -0
- package/package.json +61 -0
- package/skills/api-design/SKILL.md +21 -0
- package/skills/code-review/SKILL.md +31 -0
- package/skills/debugging/SKILL.md +21 -0
- package/skills/docker/SKILL.md +27 -0
- package/skills/docx/SKILL.md +28 -0
- package/skills/frontend-design/SKILL.md +31 -0
- package/skills/git-commit/SKILL.md +32 -0
- package/skills/pdf/SKILL.md +30 -0
- package/skills/pptx/SKILL.md +24 -0
- package/skills/refactoring/SKILL.md +24 -0
- package/skills/release-checklist/SKILL.md +20 -0
- package/skills/testing/SKILL.md +30 -0
- package/skills/webapp-testing/SKILL.md +27 -0
- package/skills/xlsx/SKILL.md +28 -0
- package/src/agent.js +472 -0
- package/src/audit.js +68 -0
- package/src/autostart.js +74 -0
- package/src/batch.js +182 -0
- package/src/cachestats.js +215 -0
- package/src/cli.js +1099 -0
- package/src/commands/key.js +75 -0
- package/src/commands/schedule.js +176 -0
- package/src/commands/skill.js +168 -0
- package/src/commands/sync.js +222 -0
- package/src/commands/update.js +157 -0
- package/src/commands/workspace.js +109 -0
- package/src/compact.js +112 -0
- package/src/config.js +134 -0
- package/src/context.js +86 -0
- package/src/cost-guard.js +58 -0
- package/src/credentials.js +69 -0
- package/src/hooks.js +123 -0
- package/src/index.js +42 -0
- package/src/mcp-presets.js +78 -0
- package/src/mcp.js +284 -0
- package/src/memory.js +242 -0
- package/src/model-discovery.js +153 -0
- package/src/models.js +186 -0
- package/src/notify.js +38 -0
- package/src/permissions.js +83 -0
- package/src/pricing.js +156 -0
- package/src/prompts.js +58 -0
- package/src/providers/index.js +135 -0
- package/src/providers/openai-compatible.js +171 -0
- package/src/routing.js +126 -0
- package/src/schedule.js +434 -0
- package/src/session-index.js +122 -0
- package/src/session.js +131 -0
- package/src/skill-lib.js +350 -0
- package/src/skill-registry.js +165 -0
- package/src/skills.js +135 -0
- package/src/sync-server.js +564 -0
- package/src/sync.js +479 -0
- package/src/tasks.js +126 -0
- package/src/titles.js +75 -0
- package/src/tokenizer.js +287 -0
- package/src/tools/bash.js +165 -0
- package/src/tools/fs-tools.js +346 -0
- package/src/tools/index.js +287 -0
- package/src/ui.js +643 -0
- package/src/update.js +222 -0
- package/src/web/attachments.js +49 -0
- package/src/web/index.html +993 -0
- package/src/web/server.js +1173 -0
- package/src/web/web-io.js +107 -0
- package/src/workspace.js +157 -0
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
// MingDao WebUI 服务器:零依赖 node:http + SSE。
|
|
2
|
+
// 复用 Agent 核心:createAgent + createPermission + createWebIO(io 适配层)。
|
|
3
|
+
// 路由:
|
|
4
|
+
// GET / 前端单页(src/web/index.html)
|
|
5
|
+
// GET /api/state 模型/权限/会话列表
|
|
6
|
+
// GET /api/sessions 会话列表(含预览与相对时间)
|
|
7
|
+
// GET /api/session?file= 载入会话消息
|
|
8
|
+
// GET /api/skills 技能列表
|
|
9
|
+
// POST /api/chat {message, file?} → 运行一轮,SSE 事件流
|
|
10
|
+
// POST /api/permission {id, answer} → 应答权限确认
|
|
11
|
+
// POST /api/abort 中断当前生成
|
|
12
|
+
// SSE 事件:banner/text/reasoning/code/tool/toolDenied/todo/usage/ask/error/done
|
|
13
|
+
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { ensureHome, loadConfig, saveConfig, mingdaoHome } from '../config.js';
|
|
20
|
+
import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../credentials.js';
|
|
21
|
+
import { availableModels, fetchProviderModels, providerHasKey } from '../model-discovery.js';
|
|
22
|
+
import { createProvider, resolveProviderConfig, helperProvider } from '../providers/index.js';
|
|
23
|
+
import { MODELS, modelPreset, PROVIDERS } from '../models.js';
|
|
24
|
+
import { routeTask, routingConfig } from '../routing.js';
|
|
25
|
+
import { buildUserContent } from './attachments.js';
|
|
26
|
+
import { createAgent } from '../agent.js';
|
|
27
|
+
import { createPermission } from '../permissions.js';
|
|
28
|
+
import { buildSystemPrompt } from '../prompts.js';
|
|
29
|
+
import { createWebIO } from './web-io.js';
|
|
30
|
+
import { startMcpServers } from '../mcp.js';
|
|
31
|
+
import {
|
|
32
|
+
createSession,
|
|
33
|
+
listSessions,
|
|
34
|
+
loadSession,
|
|
35
|
+
appendMessages,
|
|
36
|
+
rewriteSession,
|
|
37
|
+
sessionPreview,
|
|
38
|
+
relativeTime,
|
|
39
|
+
searchSessions,
|
|
40
|
+
} from '../session.js';
|
|
41
|
+
import { listSkills, tamperedSkillNames } from '../skills.js';
|
|
42
|
+
import { libraryList, installSkill, uninstallSkill, installedUserSkillNames } from '../skill-lib.js';
|
|
43
|
+
import { detectSandbox } from '../tools/bash.js';
|
|
44
|
+
import { generateTitle, renameSessionFile, titleModel, sanitizeTitle } from '../titles.js';
|
|
45
|
+
import {
|
|
46
|
+
addSchedule,
|
|
47
|
+
listSchedules,
|
|
48
|
+
removeSchedule,
|
|
49
|
+
pauseSchedule,
|
|
50
|
+
resumeSchedule,
|
|
51
|
+
chainSchedules,
|
|
52
|
+
reconcileSchedules,
|
|
53
|
+
} from '../schedule.js';
|
|
54
|
+
import { enableAutostart, disableAutostart, autostartStatus } from '../autostart.js';
|
|
55
|
+
import { currentWorkspace, workspaceForDir, listWorkspaces, addWorkspace, removeWorkspace, renameWorkspace, setWorkspaceDir, workspacePath, touchWorkspace, getSessionWorkspace, setSessionWorkspace, removeSessionWorkspace, moveSessionWorkspace } from '../workspace.js';
|
|
56
|
+
import { loadMemory, writeMemory, dedupeMemory } from '../memory.js';
|
|
57
|
+
import { recordUsage, listCacheStats, summarizeCacheStats, costBreakdown } from '../cachestats.js';
|
|
58
|
+
import { PRICE_DATA_AS_OF } from '../pricing.js';
|
|
59
|
+
import { costGuardStatus } from '../cost-guard.js';
|
|
60
|
+
import { presetList, buildPreset } from '../mcp-presets.js';
|
|
61
|
+
import { syncStatus, syncLogin, syncLogout, syncPush, syncPull, syncRemoteList, maybeAutoSync, syncChangePassword, syncShareCreate, syncShareList, syncShareAccept, syncShareRevoke, listSyncConflicts, resolveSyncConflict } from '../sync.js';
|
|
62
|
+
|
|
63
|
+
const INDEX_HTML = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html');
|
|
64
|
+
|
|
65
|
+
function json(res, code, obj) {
|
|
66
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
67
|
+
res.end(JSON.stringify(obj));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readBody(req) {
|
|
71
|
+
const MAX_BODY = 40 * 1024 * 1024; // 附件 base64 上限(4×5MB 图片 + 文本)留余量
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let size = 0;
|
|
75
|
+
// 审计 P2-6:慢速连接防护——60s 未传完请求体即断开,防占满 socket
|
|
76
|
+
const slowTimer = setTimeout(() => {
|
|
77
|
+
const err = new Error('请求体上传超时(60s)');
|
|
78
|
+
err.status = 408;
|
|
79
|
+
req.destroy();
|
|
80
|
+
reject(err);
|
|
81
|
+
}, 60000);
|
|
82
|
+
req.on('end', () => clearTimeout(slowTimer));
|
|
83
|
+
req.on('close', () => clearTimeout(slowTimer));
|
|
84
|
+
req.on('data', (d) => {
|
|
85
|
+
size += d.length;
|
|
86
|
+
if (size > MAX_BODY) {
|
|
87
|
+
const err = new Error('请求体过大(>40MB)');
|
|
88
|
+
err.status = 413;
|
|
89
|
+
req.destroy();
|
|
90
|
+
reject(err);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
chunks.push(d);
|
|
94
|
+
});
|
|
95
|
+
req.on('end', () => {
|
|
96
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
97
|
+
try {
|
|
98
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
99
|
+
} catch {
|
|
100
|
+
resolve({});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
req.on('error', reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken } = {}) {
|
|
108
|
+
const home = ensureHome();
|
|
109
|
+
const cfg = loadConfig();
|
|
110
|
+
if (!cfg) {
|
|
111
|
+
console.error('[MingDao] 未初始化配置:请先运行 mingdao init');
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// 访问令牌(P1-3):非回环绑定时强制——配置的 token / 启动参数 / 环境变量优先,
|
|
116
|
+
// 都没有则本次随机生成并打印带 token 的访问地址。回环绑定且未配置时保持无认证(本机信任)。
|
|
117
|
+
const isLoopbackHost = (h) => h === '127.0.0.1' || h === 'localhost' || h === '::1';
|
|
118
|
+
let webToken = String(authToken || cfg?.web?.token || '').trim() || null;
|
|
119
|
+
let tokenGenerated = false;
|
|
120
|
+
if (!isLoopbackHost(host) && !webToken) {
|
|
121
|
+
webToken = crypto.randomBytes(16).toString('hex');
|
|
122
|
+
tokenGenerated = true;
|
|
123
|
+
}
|
|
124
|
+
const authEnabled = Boolean(webToken);
|
|
125
|
+
const tokenMatches = (got) => {
|
|
126
|
+
if (!webToken) return true;
|
|
127
|
+
if (!got) return false;
|
|
128
|
+
const a = Buffer.from(String(got));
|
|
129
|
+
const b = Buffer.from(webToken);
|
|
130
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
131
|
+
};
|
|
132
|
+
const requestToken = (req, url) => {
|
|
133
|
+
const q = url.searchParams.get('token');
|
|
134
|
+
if (q) return q;
|
|
135
|
+
const h = String(req.headers['x-mingdao-token'] || '');
|
|
136
|
+
if (h) return h;
|
|
137
|
+
const m = String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i);
|
|
138
|
+
return m ? m[1] : null;
|
|
139
|
+
};
|
|
140
|
+
// Host 白名单(P1-4,防 DNS rebinding):只接受回环名 + 绑定地址;
|
|
141
|
+
// 绑定 0.0.0.0 且启用 token 时放行任意 Host(此时 token 才是访问边界,攻击者页面拿不到)。
|
|
142
|
+
let boundPort = port;
|
|
143
|
+
const trustedHost = (reqHost) => {
|
|
144
|
+
const h = String(reqHost || '').toLowerCase();
|
|
145
|
+
const names = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
146
|
+
if (host !== '0.0.0.0') names.add(host.toLowerCase());
|
|
147
|
+
const candidates = new Set();
|
|
148
|
+
for (const n of names) {
|
|
149
|
+
candidates.add(`${n.includes(':') && !n.startsWith('[') ? `[${n}]` : n}:${boundPort}`);
|
|
150
|
+
if (boundPort === 80) candidates.add(n);
|
|
151
|
+
}
|
|
152
|
+
if (candidates.has(h)) return true;
|
|
153
|
+
return host === '0.0.0.0' && authEnabled;
|
|
154
|
+
};
|
|
155
|
+
let modelName = cfg.model || 'deepseek-v4-flash';
|
|
156
|
+
const pc = resolveProviderConfig(cfg, modelName);
|
|
157
|
+
if (!pc.apiKey) {
|
|
158
|
+
console.error(`[MingDao] 未找到 API Key:请运行 mingdao key set ${pc.name} 或 mingdao init`);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let workingDir = process.cwd(); // 工作空间切换时随之更新(后续会话/工具都跟随新目录)
|
|
164
|
+
// Provider 按模型懒加载缓存:界面切换模型后即时生效
|
|
165
|
+
const providerCache = new Map();
|
|
166
|
+
async function getProviderFor(m) {
|
|
167
|
+
if (!providerCache.has(m)) {
|
|
168
|
+
providerCache.set(m, await createProvider(cfg, m));
|
|
169
|
+
}
|
|
170
|
+
return providerCache.get(m);
|
|
171
|
+
}
|
|
172
|
+
const provider = await getProviderFor(modelName);
|
|
173
|
+
const undoStore = { backups: new Map() };
|
|
174
|
+
|
|
175
|
+
// MCP:后台启动,就绪后工具并入后续轮次
|
|
176
|
+
let mcpManager = null;
|
|
177
|
+
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) {
|
|
178
|
+
startMcpServers(cfg.mcpServers, workingDir)
|
|
179
|
+
.then((m) => {
|
|
180
|
+
mcpManager = m;
|
|
181
|
+
})
|
|
182
|
+
.catch(() => {});
|
|
183
|
+
}
|
|
184
|
+
const mcpFacade = {
|
|
185
|
+
toolSchemas: () => (mcpManager ? mcpManager.toolSchemas() : []),
|
|
186
|
+
call: (n, a) => (mcpManager ? mcpManager.call(n, a) : Promise.reject(new Error('MCP 未就绪'))),
|
|
187
|
+
isReadonly: (n) => (mcpManager ? mcpManager.isReadonly(n) : false),
|
|
188
|
+
status: () => (mcpManager ? mcpManager.status() : []),
|
|
189
|
+
stop: () => {
|
|
190
|
+
if (mcpManager) mcpManager.stop();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// 任务注册表:支持多会话并行——每个任务独立的 SSE 流、权限确认、中断控制
|
|
195
|
+
const MAX_CONCURRENT = 8;
|
|
196
|
+
const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
|
|
197
|
+
let taskSeq = 0;
|
|
198
|
+
let draftText = ''; // 外部注入的草稿(VS Code 插件选中代码发送)
|
|
199
|
+
|
|
200
|
+
function pruneTasks() {
|
|
201
|
+
if (tasks.size <= 100) return;
|
|
202
|
+
for (const [id, t] of tasks) {
|
|
203
|
+
if (t.status !== 'running') tasks.delete(id);
|
|
204
|
+
if (tasks.size <= 60) break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function handleChat(res, body) {
|
|
209
|
+
const taskId = `t${++taskSeq}`; // 服务端生成:客户端自选 taskId 可能覆盖他人任务
|
|
210
|
+
const entry = { res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 };
|
|
211
|
+
tasks.set(taskId, entry);
|
|
212
|
+
const send = (obj) => {
|
|
213
|
+
try {
|
|
214
|
+
res.write(`data: ${JSON.stringify({ ...obj, taskId })}\n\n`);
|
|
215
|
+
} catch {}
|
|
216
|
+
};
|
|
217
|
+
entry.send = send;
|
|
218
|
+
const userMessage = String(body.message ?? '').trim();
|
|
219
|
+
entry.message = (userMessage || '[附件]').slice(0, 40);
|
|
220
|
+
const visionSupported = Boolean(modelPreset(modelName)?.supportsVision || cfg.customModels?.[modelName]?.vision);
|
|
221
|
+
const built = buildUserContent(userMessage, body.attachments, visionSupported);
|
|
222
|
+
if (built.error) {
|
|
223
|
+
entry.status = 'failed';
|
|
224
|
+
send({ type: 'error', message: built.error });
|
|
225
|
+
res.end();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let session = null;
|
|
230
|
+
const isNew = !body.file;
|
|
231
|
+
if (body.file) {
|
|
232
|
+
try {
|
|
233
|
+
const loaded = loadSession(path.join(home, 'sessions', path.basename(body.file)));
|
|
234
|
+
if (loaded.messages.length) session = loaded;
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
if (!session) session = createSession(home);
|
|
238
|
+
entry.session = session;
|
|
239
|
+
|
|
240
|
+
// 自动路由(与 CLI 一致):规划/生成类任务 → planner(大输出),执行类 → executor;
|
|
241
|
+
// 会话粘滞 + 分类缓存(评估 P2-1),路由结果挂在会话对象上(进程内,不落盘)
|
|
242
|
+
let runModel = modelName;
|
|
243
|
+
let routeReason = null;
|
|
244
|
+
if (routingConfig(cfg)) {
|
|
245
|
+
try {
|
|
246
|
+
const route = await routeTask({ cfg, provider, currentModel: modelName, text: built.persistText || userMessage, sticky: session.lastRoute || null });
|
|
247
|
+
session.lastRoute = route.model;
|
|
248
|
+
if (route.model !== modelName) {
|
|
249
|
+
runModel = route.model;
|
|
250
|
+
routeReason = route.reason;
|
|
251
|
+
}
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
if (routeReason) send({ type: 'banner', text: `⤷ 自动路由 → ${runModel}(${routeReason})` });
|
|
255
|
+
|
|
256
|
+
// 会话级工作空间(P3-4):已记录工作空间的会话固定用自己目录——运行中的任务不受全局切换影响;
|
|
257
|
+
// 新会话/首次继续的旧会话记录当前全局工作空间。全局 workingDir 只决定新会话的默认目录。
|
|
258
|
+
const sessionName = path.basename(session.file);
|
|
259
|
+
const sessionWsDir = getSessionWorkspace(sessionName);
|
|
260
|
+
const taskDir = sessionWsDir || workingDir;
|
|
261
|
+
if (!sessionWsDir) {
|
|
262
|
+
setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
|
|
263
|
+
}
|
|
264
|
+
const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true });
|
|
265
|
+
let messages =
|
|
266
|
+
session.messages?.length && session.messages[0]?.role === 'system'
|
|
267
|
+
? session.messages
|
|
268
|
+
: [{ role: 'system', content: systemPrompt }, ...(session.messages || [])];
|
|
269
|
+
messages[0] = { role: 'system', content: systemPrompt }; // 总是刷新 system(记忆/技能/AGENTS.md 最新)
|
|
270
|
+
messages.push({ role: 'user', content: built.content });
|
|
271
|
+
// 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)
|
|
272
|
+
appendMessages(session.file, [{ role: 'user', content: built.persistText }]);
|
|
273
|
+
let persistedBefore = messages.length;
|
|
274
|
+
|
|
275
|
+
// 权限/选择类交互:发 ask 事件(带 taskId),等待 POST /api/permission 应答
|
|
276
|
+
const askHandler = ({ question, hidden, options, label, confirm }) =>
|
|
277
|
+
new Promise((resolve) => {
|
|
278
|
+
const id = Math.random().toString(36).slice(2);
|
|
279
|
+
entry.pendingAsk = { id, resolve };
|
|
280
|
+
send({
|
|
281
|
+
type: 'ask',
|
|
282
|
+
id,
|
|
283
|
+
question: question ?? label ?? '',
|
|
284
|
+
options: options ? options.map((o) => ({ value: o.value, label: o.label })) : null,
|
|
285
|
+
confirm: Boolean(confirm ?? !options),
|
|
286
|
+
hidden: Boolean(hidden),
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const io = createWebIO({
|
|
291
|
+
send,
|
|
292
|
+
askHandler,
|
|
293
|
+
setAbortHandler: (fn) => {
|
|
294
|
+
entry.abortHandler = fn;
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
const permission = createPermission(cfg.permission ?? 'ask', io);
|
|
298
|
+
let providerNow;
|
|
299
|
+
try {
|
|
300
|
+
providerNow = await getProviderFor(runModel); // 审计 P1-1:失败时清理任务占位,避免僵尸 running 耗尽并发
|
|
301
|
+
} catch (err) {
|
|
302
|
+
entry.status = 'failed';
|
|
303
|
+
entry.durationMs = Date.now() - entry.startedAt;
|
|
304
|
+
send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
|
|
305
|
+
res.end();
|
|
306
|
+
pruneTasks();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const agent = createAgent({
|
|
310
|
+
provider: providerNow,
|
|
311
|
+
permission,
|
|
312
|
+
io,
|
|
313
|
+
modelName: runModel,
|
|
314
|
+
workingDir: taskDir,
|
|
315
|
+
cfg,
|
|
316
|
+
undoStore,
|
|
317
|
+
mcp: mcpFacade,
|
|
318
|
+
// 自动压缩后重写会话文件(否则每次加载历史都会重新触发压缩),并同步落盘游标
|
|
319
|
+
onCompact: (msgs) => {
|
|
320
|
+
rewriteSession(session.file, msgs);
|
|
321
|
+
persistedBefore = msgs.length;
|
|
322
|
+
},
|
|
323
|
+
// 审计记录归入当前会话(P3-5)
|
|
324
|
+
sessionRef: { name: path.basename(session.file) },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
res.on('close', () => {
|
|
328
|
+
// 浏览器断开:中止正在跑的生成(否则白白烧 token),挂起的权限确认按拒绝处理
|
|
329
|
+
if (entry.pendingAsk) {
|
|
330
|
+
entry.pendingAsk.resolve('');
|
|
331
|
+
entry.pendingAsk = null;
|
|
332
|
+
}
|
|
333
|
+
if (entry.status === 'running') {
|
|
334
|
+
try {
|
|
335
|
+
entry.abortHandler?.();
|
|
336
|
+
} catch {}
|
|
337
|
+
entry.status = 'failed';
|
|
338
|
+
entry.durationMs = Date.now() - entry.startedAt;
|
|
339
|
+
pruneTasks();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const r = await agent.runTurn(messages);
|
|
345
|
+
appendMessages(session.file, messages.slice(persistedBefore));
|
|
346
|
+
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
347
|
+
recordUsage(runModel, r.usage, r.perf);
|
|
348
|
+
maybeAutoSync().catch(() => {});
|
|
349
|
+
// 新会话自动标题(可配置关闭)
|
|
350
|
+
if (isNew && cfg.autoTitle !== false && r.text) {
|
|
351
|
+
try {
|
|
352
|
+
const tModel = titleModel(cfg, runModel);
|
|
353
|
+
const title = await generateTitle(await helperProvider(cfg, tModel, providerNow), tModel, built.persistText);
|
|
354
|
+
if (title) {
|
|
355
|
+
const oldName = path.basename(session.file);
|
|
356
|
+
const renamed = renameSessionFile(fs, path, home, session, title);
|
|
357
|
+
if (renamed) moveSessionWorkspace(oldName, path.basename(renamed)); // 会话改名 → 工作空间映射跟随
|
|
358
|
+
}
|
|
359
|
+
} catch {}
|
|
360
|
+
}
|
|
361
|
+
entry.status = r.aborted ? 'aborted' : 'done';
|
|
362
|
+
entry.durationMs = Date.now() - entry.startedAt;
|
|
363
|
+
// 预算可视化(评估 A5):会话当前 token 占用 / 预算
|
|
364
|
+
let budgetInfo = null;
|
|
365
|
+
try {
|
|
366
|
+
const { makeTokenCounter } = await import('../tokenizer.js');
|
|
367
|
+
const { messageTokens } = await import('../context.js');
|
|
368
|
+
const counter = makeTokenCounter(runModel);
|
|
369
|
+
const used = messages.reduce((sum, m) => sum + messageTokens(m, counter), 0);
|
|
370
|
+
budgetInfo = { used, total: cfg.contextBudget || modelPreset(runModel)?.budgetTokens || 128000 };
|
|
371
|
+
} catch {}
|
|
372
|
+
send({
|
|
373
|
+
type: 'done',
|
|
374
|
+
budget: budgetInfo,
|
|
375
|
+
ok: true,
|
|
376
|
+
text: r.text,
|
|
377
|
+
usage: r.usage,
|
|
378
|
+
durationMs: r.durationMs,
|
|
379
|
+
truncated: r.truncated,
|
|
380
|
+
aborted: r.aborted,
|
|
381
|
+
note: r.note || (r.text ? '' : '(模型本轮没有输出正文)'),
|
|
382
|
+
stats: io.stats(),
|
|
383
|
+
session: path.basename(session.file),
|
|
384
|
+
});
|
|
385
|
+
} catch (err) {
|
|
386
|
+
entry.status = 'failed';
|
|
387
|
+
entry.durationMs = Date.now() - entry.startedAt;
|
|
388
|
+
send({ type: 'error', message: String(err?.message || err) });
|
|
389
|
+
} finally {
|
|
390
|
+
entry.abortHandler = null;
|
|
391
|
+
entry.pendingAsk = null;
|
|
392
|
+
entry.send = null;
|
|
393
|
+
res.end();
|
|
394
|
+
pruneTasks();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const dispatch = async (req, res) => {
|
|
399
|
+
const url = new URL(req.url, 'http://localhost');
|
|
400
|
+
const p = url.pathname;
|
|
401
|
+
|
|
402
|
+
// 访问控制(P1-3/P1-4):token 校验覆盖数据与操作接口;壳页面与 PWA 静态资源公开
|
|
403
|
+
// (壳不含任何数据,SPA 需要先加载才能读取 ?token=);Host 白名单覆盖一切请求
|
|
404
|
+
const isStaticAsset =
|
|
405
|
+
p === '/' || p === '/index.html' || p === '/favicon.ico' || p === '/icon.svg' || p === '/manifest.webmanifest' || p === '/sw.js';
|
|
406
|
+
if (authEnabled && !isStaticAsset && !tokenMatches(requestToken(req, url))) {
|
|
407
|
+
return json(res, 401, { error: '未授权:缺少或无效的访问令牌(地址需带 ?token=…,或请求头携带 X-MingDao-Token)' });
|
|
408
|
+
}
|
|
409
|
+
if (!trustedHost(req.headers.host)) {
|
|
410
|
+
return json(res, 403, { error: 'Host 校验失败:请通过绑定地址访问(DNS rebinding 防护)' });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// CSRF 防护:跨源请求一律拒绝;POST 仅接受 JSON(拦截表单/纯文本跨站盲提交)
|
|
414
|
+
if (req.method !== 'GET' && req.method !== 'OPTIONS') {
|
|
415
|
+
const origin = req.headers.origin;
|
|
416
|
+
if (origin) {
|
|
417
|
+
try {
|
|
418
|
+
if (new URL(origin).host !== req.headers.host) return json(res, 403, { error: '跨源请求被拒绝' });
|
|
419
|
+
} catch {
|
|
420
|
+
return json(res, 403, { error: '非法 Origin' });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const ct = String(req.headers['content-type'] || '');
|
|
424
|
+
if (!ct.includes('application/json') && !ct.includes('text/plain')) {
|
|
425
|
+
return json(res, 415, { error: '仅接受 JSON 请求体' });
|
|
426
|
+
}
|
|
427
|
+
if (ct.includes('text/plain')) {
|
|
428
|
+
// text/plain 是经典 CSRF 向量(无需预检):直接拒绝,要求 application/json
|
|
429
|
+
return json(res, 415, { error: '请使用 application/json' });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (req.method === 'GET' && (p === '/' || p === '/index.html')) {
|
|
434
|
+
try {
|
|
435
|
+
const html = fs.readFileSync(INDEX_HTML);
|
|
436
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
437
|
+
res.end(html);
|
|
438
|
+
} catch {
|
|
439
|
+
json(res, 500, { error: '前端文件缺失' });
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (req.method === 'GET' && p === '/favicon.ico') {
|
|
444
|
+
res.writeHead(204);
|
|
445
|
+
res.end();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (req.method === 'GET' && p === '/api/state') {
|
|
449
|
+
const sessions = listSessions(home)
|
|
450
|
+
.slice(0, 30)
|
|
451
|
+
.map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
|
|
452
|
+
// 模型列表:只列已设置 Key 的服务商,名称以 /models 接口线上名单为准(预设仅回退与补价)
|
|
453
|
+
const models = await availableModels(cfg, modelName);
|
|
454
|
+
json(res, 200, {
|
|
455
|
+
ok: true,
|
|
456
|
+
model: modelName,
|
|
457
|
+
models,
|
|
458
|
+
permissions: ['ask', 'auto', 'readonly'],
|
|
459
|
+
permission: cfg.permission ?? 'ask',
|
|
460
|
+
sandbox: cfg.sandbox || 'off',
|
|
461
|
+
sandboxSupported: detectSandbox() !== 'none',
|
|
462
|
+
routing: cfg.routing?.enabled ? cfg.routing : null,
|
|
463
|
+
contextBudget: cfg.contextBudget || 128000,
|
|
464
|
+
pricingAsOf: PRICE_DATA_AS_OF,
|
|
465
|
+
autostart: autostartStatus(),
|
|
466
|
+
notify: cfg.notify !== false,
|
|
467
|
+
workspace: currentWorkspace(workingDir)?.name || null,
|
|
468
|
+
home,
|
|
469
|
+
workingDir,
|
|
470
|
+
mcp: mcpFacade.status(),
|
|
471
|
+
sessions,
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (req.method === 'POST' && p === '/api/config') {
|
|
476
|
+
const body = await readBody(req);
|
|
477
|
+
// 内存 cfg 可能比磁盘旧(如 CLI 改过同步地址):先从磁盘刷新 sync,避免保存时回滚
|
|
478
|
+
cfg.sync = loadConfig()?.sync || cfg.sync;
|
|
479
|
+
const next = { model: modelName, permission: cfg.permission ?? 'ask' };
|
|
480
|
+
if (body.model !== undefined) {
|
|
481
|
+
const target = String(body.model).trim();
|
|
482
|
+
if (!target) return json(res, 400, { error: '模型名不能为空' });
|
|
483
|
+
const tpc = resolveProviderConfig(cfg, target);
|
|
484
|
+
if (!tpc.apiKey) {
|
|
485
|
+
const hint = tpc.name.startsWith('custom:')
|
|
486
|
+
? '请在 设置 → 模型与 API Key 中填写该模型的 Key'
|
|
487
|
+
: `请先运行 mingdao key set ${tpc.name}`;
|
|
488
|
+
return json(res, 400, { error: `模型 ${target} 没有可用 API Key(服务商 ${tpc.name}),${hint}` });
|
|
489
|
+
}
|
|
490
|
+
await getProviderFor(target); // 预热(失败会抛错)
|
|
491
|
+
next.model = target;
|
|
492
|
+
}
|
|
493
|
+
if (body.permission !== undefined) {
|
|
494
|
+
const perm = String(body.permission);
|
|
495
|
+
if (!['ask', 'auto', 'readonly'].includes(perm)) {
|
|
496
|
+
return json(res, 400, { error: '权限模式必须是 ask / auto / readonly' });
|
|
497
|
+
}
|
|
498
|
+
next.permission = perm;
|
|
499
|
+
}
|
|
500
|
+
if (body.sandbox !== undefined) {
|
|
501
|
+
const sbx = String(body.sandbox);
|
|
502
|
+
if (!['off', 'readonly', 'safe'].includes(sbx)) {
|
|
503
|
+
return json(res, 400, { error: '沙箱模式必须是 off / readonly / safe' });
|
|
504
|
+
}
|
|
505
|
+
next.sandbox = sbx;
|
|
506
|
+
cfg.sandbox = sbx;
|
|
507
|
+
}
|
|
508
|
+
if (body.routing !== undefined) {
|
|
509
|
+
const on = body.routing === true || body.routing === 'on';
|
|
510
|
+
cfg.routing = {
|
|
511
|
+
enabled: on,
|
|
512
|
+
planner: cfg.routing?.planner || 'deepseek-v4-pro',
|
|
513
|
+
executor: cfg.routing?.executor || 'deepseek-v4-flash',
|
|
514
|
+
};
|
|
515
|
+
next.routing = on;
|
|
516
|
+
}
|
|
517
|
+
if (body.contextBudget !== undefined) {
|
|
518
|
+
const n = Number(body.contextBudget);
|
|
519
|
+
if (!Number.isInteger(n) || n < 1000) {
|
|
520
|
+
return json(res, 400, { error: '上下文预算必须是 ≥1000 的整数' });
|
|
521
|
+
}
|
|
522
|
+
next.contextBudget = n;
|
|
523
|
+
cfg.contextBudget = n;
|
|
524
|
+
}
|
|
525
|
+
let autostartChanged = false;
|
|
526
|
+
if (body.autostart !== undefined) {
|
|
527
|
+
autostartChanged = true;
|
|
528
|
+
const okAuto = body.autostart === true || body.autostart === 'on' ? enableAutostart() : disableAutostart();
|
|
529
|
+
if (!okAuto) return json(res, 500, { error: '开机自启设置失败' });
|
|
530
|
+
}
|
|
531
|
+
if (body.notify !== undefined) {
|
|
532
|
+
next.notify = body.notify === true || body.notify === 'on';
|
|
533
|
+
cfg.notify = next.notify;
|
|
534
|
+
}
|
|
535
|
+
if (body.syncAuto !== undefined) {
|
|
536
|
+
cfg.sync = cfg.sync || {};
|
|
537
|
+
cfg.sync.auto = body.syncAuto === true || body.syncAuto === 'on';
|
|
538
|
+
}
|
|
539
|
+
modelName = next.model;
|
|
540
|
+
cfg.model = next.model;
|
|
541
|
+
cfg.permission = next.permission;
|
|
542
|
+
saveConfig(cfg);
|
|
543
|
+
json(res, 200, { ok: true, model: modelName, permission: cfg.permission, sandbox: cfg.sandbox, routing: cfg.routing?.enabled, contextBudget: cfg.contextBudget, autostart: autostartChanged ? autostartStatus() : undefined, notify: cfg.notify !== false });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (req.method === 'GET' && p === '/api/models-config') {
|
|
547
|
+
const providers = Object.keys(PROVIDERS).map((name) => {
|
|
548
|
+
const pp = PROVIDERS[name];
|
|
549
|
+
const stored = getStoredKey(name);
|
|
550
|
+
const env = pp.envKey && process.env[pp.envKey] ? true : false;
|
|
551
|
+
return {
|
|
552
|
+
name,
|
|
553
|
+
label: pp.label,
|
|
554
|
+
baseUrl: pp.baseUrl,
|
|
555
|
+
envKey: pp.envKey || null,
|
|
556
|
+
keyState: stored ? 'stored' : env ? 'env' : 'none',
|
|
557
|
+
keyMasked: stored ? maskKey(stored) : null,
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
const customModels = Object.entries(cfg.customModels || {}).map(([name, cm]) => {
|
|
561
|
+
const stored = getStoredKey(`custom:${name}`);
|
|
562
|
+
return {
|
|
563
|
+
name,
|
|
564
|
+
label: cm.label || '',
|
|
565
|
+
baseUrl: cm.baseUrl || '',
|
|
566
|
+
envKey: cm.envKey || null,
|
|
567
|
+
vision: Boolean(cm.vision),
|
|
568
|
+
keyState: stored ? 'stored' : 'none',
|
|
569
|
+
keyMasked: stored ? maskKey(stored) : null,
|
|
570
|
+
};
|
|
571
|
+
});
|
|
572
|
+
json(res, 200, {
|
|
573
|
+
ok: true,
|
|
574
|
+
providers,
|
|
575
|
+
customModels,
|
|
576
|
+
model: modelName,
|
|
577
|
+
provider: resolveProviderConfig(cfg, modelName).name,
|
|
578
|
+
baseUrlOverride: cfg.baseUrl || '',
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (req.method === 'POST' && p === '/api/models-config') {
|
|
583
|
+
const body = await readBody(req);
|
|
584
|
+
const action = body.action;
|
|
585
|
+
// —— 服务商 Key 管理 ——
|
|
586
|
+
if (action === 'setProviderKey') {
|
|
587
|
+
const provider = String(body.provider || '').trim();
|
|
588
|
+
if (!PROVIDERS[provider]) return json(res, 400, { error: `未知服务商 ${provider}` });
|
|
589
|
+
const key = String(body.key || '').trim();
|
|
590
|
+
if (!key) return json(res, 400, { error: 'Key 不能为空(删除请用 removeProviderKey)' });
|
|
591
|
+
setStoredKey(provider, key);
|
|
592
|
+
// 设置 Key 后立即拉取线上真实模型名单(失败不影响 Key 保存,回退预设)
|
|
593
|
+
const fr = provider === 'custom' ? { error: '自定义服务商无模型列表' } : await fetchProviderModels(cfg, provider, { force: true });
|
|
594
|
+
return json(res, 200, {
|
|
595
|
+
ok: true,
|
|
596
|
+
provider,
|
|
597
|
+
keyMasked: maskKey(key),
|
|
598
|
+
models: fr.models || [],
|
|
599
|
+
modelsNote: fr.error ? `模型列表暂用预设(拉取失败:${fr.error}),稍后可用「刷新模型」重试` : null,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
if (action === 'refreshModels') {
|
|
603
|
+
const provider = String(body.provider || '').trim();
|
|
604
|
+
if (!PROVIDERS[provider] || provider === 'custom') return json(res, 400, { error: '未知服务商' });
|
|
605
|
+
if (!providerHasKey(provider)) return json(res, 400, { error: '该服务商未设置 API Key' });
|
|
606
|
+
const fr = await fetchProviderModels(cfg, provider, { force: true });
|
|
607
|
+
if (fr.error) return json(res, 400, { error: `拉取失败:${fr.error}` });
|
|
608
|
+
return json(res, 200, { ok: true, provider, models: fr.models, fromCache: fr.fromCache });
|
|
609
|
+
}
|
|
610
|
+
if (action === 'removeProviderKey') {
|
|
611
|
+
const provider = String(body.provider || '').trim();
|
|
612
|
+
if (!PROVIDERS[provider]) return json(res, 400, { error: `未知服务商 ${provider}` });
|
|
613
|
+
removeStoredKey(provider);
|
|
614
|
+
return json(res, 200, { ok: true, provider });
|
|
615
|
+
}
|
|
616
|
+
// —— 自定义模型增删改 ——
|
|
617
|
+
if (action === 'addCustom' || action === 'updateCustom') {
|
|
618
|
+
const name = String(body.name || '').trim();
|
|
619
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name)) {
|
|
620
|
+
return json(res, 400, { error: '模型名非法:字母/数字开头,可含 . _ -,1–64 位' });
|
|
621
|
+
}
|
|
622
|
+
if (MODELS[name]) return json(res, 400, { error: `${name} 与内置模型同名,请换一个名字` });
|
|
623
|
+
const label = String(body.label || '').trim();
|
|
624
|
+
const baseUrl = String(body.baseUrl || '').trim();
|
|
625
|
+
if (!baseUrl) return json(res, 400, { error: 'API 地址(baseUrl)不能为空' });
|
|
626
|
+
try {
|
|
627
|
+
const u = new URL(baseUrl);
|
|
628
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error();
|
|
629
|
+
} catch {
|
|
630
|
+
return json(res, 400, { error: 'API 地址必须是合法的 http(s) URL' });
|
|
631
|
+
}
|
|
632
|
+
if (action === 'addCustom' && (cfg.customModels || {})[name]) {
|
|
633
|
+
return json(res, 400, { error: `自定义模型 ${name} 已存在(可修改)` });
|
|
634
|
+
}
|
|
635
|
+
cfg.customModels = cfg.customModels || {};
|
|
636
|
+
cfg.customModels[name] = {
|
|
637
|
+
label,
|
|
638
|
+
baseUrl,
|
|
639
|
+
envKey: String(body.envKey || '').trim() || undefined,
|
|
640
|
+
vision: body.vision === true || body.vision === 'on' ? true : undefined,
|
|
641
|
+
};
|
|
642
|
+
if (String(body.key || '').trim()) setStoredKey(`custom:${name}`, String(body.key).trim());
|
|
643
|
+
saveConfig(cfg);
|
|
644
|
+
return json(res, 200, { ok: true, name });
|
|
645
|
+
}
|
|
646
|
+
if (action === 'removeCustom') {
|
|
647
|
+
const name = String(body.name || '').trim();
|
|
648
|
+
if (!(cfg.customModels || {})[name]) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
|
|
649
|
+
delete cfg.customModels[name];
|
|
650
|
+
removeStoredKey(`custom:${name}`);
|
|
651
|
+
if (modelName === name) {
|
|
652
|
+
modelName = 'deepseek-v4-flash';
|
|
653
|
+
cfg.model = modelName;
|
|
654
|
+
}
|
|
655
|
+
saveConfig(cfg);
|
|
656
|
+
return json(res, 200, { ok: true, name, model: modelName });
|
|
657
|
+
}
|
|
658
|
+
if (action === 'setCustomKey') {
|
|
659
|
+
const name = String(body.name || '').trim();
|
|
660
|
+
if (!(cfg.customModels || {})[name]) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
|
|
661
|
+
const key = String(body.key || '').trim();
|
|
662
|
+
if (!key) return json(res, 400, { error: 'Key 不能为空' });
|
|
663
|
+
setStoredKey(`custom:${name}`, key);
|
|
664
|
+
return json(res, 200, { ok: true, name, keyMasked: maskKey(key) });
|
|
665
|
+
}
|
|
666
|
+
// —— 当前服务商 API 地址覆盖 ——
|
|
667
|
+
if (action === 'setBaseUrl') {
|
|
668
|
+
const baseUrl = String(body.baseUrl || '').trim();
|
|
669
|
+
if (baseUrl) {
|
|
670
|
+
try {
|
|
671
|
+
const u = new URL(baseUrl);
|
|
672
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error();
|
|
673
|
+
} catch {
|
|
674
|
+
return json(res, 400, { error: 'API 地址必须是合法的 http(s) URL' });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
cfg.baseUrl = baseUrl || undefined;
|
|
678
|
+
if (cfg.baseUrl === undefined) delete cfg.baseUrl;
|
|
679
|
+
saveConfig(cfg);
|
|
680
|
+
return json(res, 200, { ok: true, baseUrl: cfg.baseUrl || '' });
|
|
681
|
+
}
|
|
682
|
+
return json(res, 400, { error: '未知操作:setProviderKey|removeProviderKey|addCustom|updateCustom|removeCustom|setCustomKey|setBaseUrl' });
|
|
683
|
+
}
|
|
684
|
+
if (req.method === 'GET' && p === '/api/sessions') {
|
|
685
|
+
const q = url.searchParams.get('q');
|
|
686
|
+
const found = q
|
|
687
|
+
? searchSessions(home, q, { limit: 30 }).map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${s.snippet}` }))
|
|
688
|
+
: listSessions(home).slice(0, 30).map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
|
|
689
|
+
json(res, 200, { ok: true, sessions: found });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (req.method === 'GET' && p === '/api/session') {
|
|
693
|
+
const file = url.searchParams.get('file');
|
|
694
|
+
if (!file) return json(res, 400, { error: '缺少 file 参数' });
|
|
695
|
+
try {
|
|
696
|
+
const loaded = loadSession(path.join(home, 'sessions', path.basename(file)));
|
|
697
|
+
// 载入会话时聚焦其工作空间(P3-4):全局默认切到该会话的目录,前端下拉同步显示
|
|
698
|
+
const wsDir = getSessionWorkspace(path.basename(file));
|
|
699
|
+
const ws = workspaceForDir(wsDir);
|
|
700
|
+
if (wsDir && path.resolve(wsDir) !== path.resolve(workingDir)) {
|
|
701
|
+
workingDir = wsDir; // 审计 P2-5:仅在目录不同时才切换,减少全局状态抖动
|
|
702
|
+
if (ws) touchWorkspace(ws.name);
|
|
703
|
+
}
|
|
704
|
+
json(res, 200, {
|
|
705
|
+
ok: true,
|
|
706
|
+
messages: loaded.messages.filter((m) => m.role === 'user' || m.role === 'assistant').map((m) => ({ role: m.role, content: m.content ?? '' })),
|
|
707
|
+
workspace: ws?.name || null,
|
|
708
|
+
});
|
|
709
|
+
} catch {
|
|
710
|
+
json(res, 404, { error: '会话不存在' });
|
|
711
|
+
}
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (req.method === 'POST' && p === '/api/session') {
|
|
715
|
+
const body = await readBody(req);
|
|
716
|
+
const file = path.basename(String(body.file || ''));
|
|
717
|
+
const full = path.join(home, 'sessions', file);
|
|
718
|
+
if (!file || !fs.existsSync(full)) return json(res, 404, { error: '会话不存在' });
|
|
719
|
+
if (body.action === 'rename') {
|
|
720
|
+
const title = sanitizeTitle(String(body.title || '会话'));
|
|
721
|
+
const renamed = renameSessionFile(fs, path, home, { file: full }, title);
|
|
722
|
+
if (!renamed) return json(res, 500, { error: '重命名失败(可能存在同名会话)' });
|
|
723
|
+
moveSessionWorkspace(file, path.basename(renamed));
|
|
724
|
+
return json(res, 200, { ok: true, file: path.basename(renamed) });
|
|
725
|
+
}
|
|
726
|
+
if (body.action === 'delete') {
|
|
727
|
+
try {
|
|
728
|
+
fs.unlinkSync(full);
|
|
729
|
+
removeSessionWorkspace(file);
|
|
730
|
+
return json(res, 200, { ok: true });
|
|
731
|
+
} catch (err) {
|
|
732
|
+
return json(res, 500, { error: String(err?.message || err) });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return json(res, 400, { error: '未知操作:rename|delete' });
|
|
736
|
+
}
|
|
737
|
+
if (req.method === 'GET' && p === '/api/skills') {
|
|
738
|
+
json(res, 200, { ok: true, skills: listSkills(workingDir), tampered: tamperedSkillNames(workingDir) });
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (req.method === 'GET' && p === '/api/tasks') {
|
|
742
|
+
const running = [...tasks.values()].filter((t) => t.status === 'running').length;
|
|
743
|
+
const list = [...tasks.entries()].map(([id, t]) => ({
|
|
744
|
+
id,
|
|
745
|
+
status: t.status,
|
|
746
|
+
message: t.message,
|
|
747
|
+
startedAt: t.startedAt,
|
|
748
|
+
durationMs: t.status === 'running' ? Date.now() - t.startedAt : t.durationMs,
|
|
749
|
+
session: t.session ? path.basename(t.session.file) : null,
|
|
750
|
+
}));
|
|
751
|
+
json(res, 200, { ok: true, running, maxConcurrent: MAX_CONCURRENT, tasks: list });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (req.method === 'POST' && p === '/api/chat') {
|
|
755
|
+
// 审计 P2-4:先占位再计数(单线程下 set 后统计即真实并发),消除检查-注册间隙
|
|
756
|
+
const tmpId = 'resv' + (++taskSeq) + Math.random().toString(36).slice(2, 5);
|
|
757
|
+
tasks.set(tmpId, { status: 'running' });
|
|
758
|
+
const running = [...tasks.values()].filter((t) => t.status === 'running').length;
|
|
759
|
+
tasks.delete(tmpId);
|
|
760
|
+
if (running > MAX_CONCURRENT) {
|
|
761
|
+
return json(res, 429, { error: `并发任务已达上限(${MAX_CONCURRENT}),请等待任务完成或中断` });
|
|
762
|
+
}
|
|
763
|
+
res.writeHead(200, {
|
|
764
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
765
|
+
'Cache-Control': 'no-cache',
|
|
766
|
+
Connection: 'keep-alive',
|
|
767
|
+
'X-Accel-Buffering': 'no',
|
|
768
|
+
});
|
|
769
|
+
let body;
|
|
770
|
+
try {
|
|
771
|
+
body = await readBody(req);
|
|
772
|
+
} catch (e) {
|
|
773
|
+
res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
|
|
774
|
+
res.end();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
await handleChat(res, body);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (req.method === 'POST' && p === '/api/permission') {
|
|
781
|
+
const body = await readBody(req);
|
|
782
|
+
const entry = tasks.get(body.taskId);
|
|
783
|
+
if (!entry || !entry.pendingAsk) return json(res, 409, { error: '没有挂起的权限确认' });
|
|
784
|
+
const pa = entry.pendingAsk;
|
|
785
|
+
entry.pendingAsk = null;
|
|
786
|
+
const answer = String(body.answer ?? '');
|
|
787
|
+
if (pa.options && Array.isArray(pa.options)) {
|
|
788
|
+
const opt = pa.options.find((o) => String(o.value) === answer);
|
|
789
|
+
pa.resolve(opt ? opt.value : answer);
|
|
790
|
+
} else {
|
|
791
|
+
pa.resolve(answer);
|
|
792
|
+
}
|
|
793
|
+
json(res, 200, { ok: true });
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (req.method === 'POST' && p === '/api/abort') {
|
|
797
|
+
const body = await readBody(req);
|
|
798
|
+
if (body.taskId) {
|
|
799
|
+
const entry = tasks.get(body.taskId);
|
|
800
|
+
if (entry?.abortHandler) {
|
|
801
|
+
try {
|
|
802
|
+
entry.abortHandler();
|
|
803
|
+
} catch {}
|
|
804
|
+
}
|
|
805
|
+
} else {
|
|
806
|
+
// 未指定任务:中断全部运行中任务
|
|
807
|
+
for (const t of tasks.values()) {
|
|
808
|
+
if (t.status === 'running' && t.abortHandler) {
|
|
809
|
+
try {
|
|
810
|
+
t.abortHandler();
|
|
811
|
+
} catch {}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
json(res, 200, { ok: true });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (req.method === 'GET' && p === '/api/draft') {
|
|
819
|
+
const text = draftText;
|
|
820
|
+
draftText = ''; // 读取即清除
|
|
821
|
+
json(res, 200, { ok: true, text });
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (req.method === 'POST' && p === '/api/draft') {
|
|
825
|
+
const body = await readBody(req);
|
|
826
|
+
draftText = String(body.text ?? '').slice(0, 200000);
|
|
827
|
+
json(res, 200, { ok: true });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (req.method === 'GET' && p === '/api/schedule') {
|
|
831
|
+
reconcileSchedules(home);
|
|
832
|
+
const jobs = listSchedules(home).map((j) => ({
|
|
833
|
+
id: j.id,
|
|
834
|
+
status: j.status,
|
|
835
|
+
kind: j.kind,
|
|
836
|
+
question: j.question,
|
|
837
|
+
nextRunAt: j.nextRunAt,
|
|
838
|
+
lastRunAt: j.lastRunAt,
|
|
839
|
+
lastTaskId: j.lastTaskId,
|
|
840
|
+
runs: j.runs,
|
|
841
|
+
interval: j.interval,
|
|
842
|
+
anchor: j.anchor,
|
|
843
|
+
after: j.after,
|
|
844
|
+
history: j.history || [],
|
|
845
|
+
note: j.note,
|
|
846
|
+
}));
|
|
847
|
+
json(res, 200, { ok: true, jobs });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (req.method === 'POST' && p === '/api/schedule') {
|
|
851
|
+
const body = await readBody(req);
|
|
852
|
+
const action = String(body.action || '');
|
|
853
|
+
try {
|
|
854
|
+
if (action === 'add') {
|
|
855
|
+
if (!body.question || !String(body.question).trim()) return json(res, 400, { error: '任务内容不能为空' });
|
|
856
|
+
const r = addSchedule(home, String(body.question).trim(), {
|
|
857
|
+
at: body.at || null,
|
|
858
|
+
every: body.every || null,
|
|
859
|
+
anchor: body.anchor || null,
|
|
860
|
+
after: Array.isArray(body.after) ? body.after.map(String) : body.after ? String(body.after).split(',').map((x) => x.trim()).filter(Boolean) : undefined,
|
|
861
|
+
permission: body.permission || null,
|
|
862
|
+
model: body.model || null,
|
|
863
|
+
cwd: workingDir,
|
|
864
|
+
offpeak: body.offpeak === true,
|
|
865
|
+
});
|
|
866
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
867
|
+
return json(res, 200, { ok: true, id: r.id });
|
|
868
|
+
}
|
|
869
|
+
if (action === 'chain') {
|
|
870
|
+
const qs = Array.isArray(body.questions) ? body.questions.map(String).filter((q) => q.trim()) : [];
|
|
871
|
+
if (qs.length < 2) return json(res, 400, { error: '链式需要至少两个任务' });
|
|
872
|
+
const r = chainSchedules(home, qs, { permission: body.permission || null, model: body.model || null });
|
|
873
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
874
|
+
return json(res, 200, { ok: true, ids: r.ids });
|
|
875
|
+
}
|
|
876
|
+
const id = String(body.id || '');
|
|
877
|
+
if (!id) return json(res, 400, { error: '缺少 id' });
|
|
878
|
+
if (action === 'remove') return json(res, 200, { ok: removeSchedule(home, id) });
|
|
879
|
+
if (action === 'pause') return json(res, 200, { ok: pauseSchedule(home, id) });
|
|
880
|
+
if (action === 'resume') return json(res, 200, { ok: resumeSchedule(home, id) });
|
|
881
|
+
return json(res, 400, { error: '未知操作:add|chain|remove|pause|resume' });
|
|
882
|
+
} catch (err) {
|
|
883
|
+
return json(res, 500, { error: String(err?.message || err) });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (req.method === 'GET' && p === '/api/workspaces') {
|
|
887
|
+
json(res, 200, { ok: true, workspaces: listWorkspaces(), current: currentWorkspace(workingDir)?.name || null });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (req.method === 'POST' && p === '/api/workspaces') {
|
|
891
|
+
const body = await readBody(req);
|
|
892
|
+
const name = String(body.name || '').trim();
|
|
893
|
+
if (body.action === 'add') {
|
|
894
|
+
if (!name) return json(res, 400, { error: '名称不能为空' });
|
|
895
|
+
// 目录为空/不存在时自动新建(默认开,create:false 关闭)
|
|
896
|
+
const target = path.resolve(body.dir || workingDir);
|
|
897
|
+
if (body.create !== false) {
|
|
898
|
+
try {
|
|
899
|
+
fs.mkdirSync(target, { recursive: true });
|
|
900
|
+
} catch (e) {
|
|
901
|
+
return json(res, 400, { error: `无法创建目录:${target}(${e.message})` });
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
const r = addWorkspace(name, target);
|
|
905
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
906
|
+
return json(res, 200, { ok: true, name: r.name, dir: r.dir, created: body.create !== false });
|
|
907
|
+
}
|
|
908
|
+
if (body.action === 'rename') {
|
|
909
|
+
const r = renameWorkspace(name, body.newName);
|
|
910
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
911
|
+
return json(res, 200, { ok: true, name: r.name });
|
|
912
|
+
}
|
|
913
|
+
if (body.action === 'set') {
|
|
914
|
+
// 切换全局工作空间(新会话默认目录;可带 dir 修改目录);目录缺失自动重建。
|
|
915
|
+
// 携带 file 时同时把当前会话的工作空间切过去(P3-4:会话跟随显式切换)。
|
|
916
|
+
if (body.dir) {
|
|
917
|
+
const r = setWorkspaceDir(name, body.dir);
|
|
918
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
919
|
+
}
|
|
920
|
+
const dir = workspacePath(name);
|
|
921
|
+
if (!dir) return json(res, 400, { error: `工作空间 ${name} 不存在` });
|
|
922
|
+
try {
|
|
923
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
924
|
+
} catch (e) {
|
|
925
|
+
return json(res, 400, { error: `无法创建目录:${dir}(${e.message})` });
|
|
926
|
+
}
|
|
927
|
+
touchWorkspace(name);
|
|
928
|
+
workingDir = dir;
|
|
929
|
+
// 不再 process.chdir:运行中任务的 cwd 在创建时已固定,全局切换只影响新会话
|
|
930
|
+
if (body.file) setSessionWorkspace(String(body.file), dir, name);
|
|
931
|
+
return json(res, 200, { ok: true, name, dir, current: name });
|
|
932
|
+
}
|
|
933
|
+
if (body.action === 'remove') {
|
|
934
|
+
if (!name) return json(res, 400, { error: '缺少名称' });
|
|
935
|
+
return json(res, 200, { ok: removeWorkspace(name) });
|
|
936
|
+
}
|
|
937
|
+
return json(res, 400, { error: '未知操作:add|rename|set|remove' });
|
|
938
|
+
}
|
|
939
|
+
if (req.method === 'GET' && p === '/api/memory') {
|
|
940
|
+
json(res, 200, { ok: true, content: loadMemory() });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (req.method === 'POST' && p === '/api/memory') {
|
|
944
|
+
const body = await readBody(req);
|
|
945
|
+
if (body.action === 'dedupe') {
|
|
946
|
+
const removed = dedupeMemory();
|
|
947
|
+
return json(res, 200, { ok: true, removed });
|
|
948
|
+
}
|
|
949
|
+
if (body.content !== undefined) {
|
|
950
|
+
writeMemory(body.content);
|
|
951
|
+
return json(res, 200, { ok: true });
|
|
952
|
+
}
|
|
953
|
+
return json(res, 400, { error: '缺少 content 或 action=dedupe' });
|
|
954
|
+
}
|
|
955
|
+
if (req.method === 'GET' && p === '/api/cache-stats') {
|
|
956
|
+
const entries = listCacheStats();
|
|
957
|
+
json(res, 200, {
|
|
958
|
+
ok: true,
|
|
959
|
+
summary: summarizeCacheStats(entries),
|
|
960
|
+
breakdown: costBreakdown(),
|
|
961
|
+
guard: costGuardStatus(),
|
|
962
|
+
recent: entries.slice(-10).reverse(),
|
|
963
|
+
});
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (req.method === 'GET' && p === '/api/mcp-presets') {
|
|
967
|
+
json(res, 200, { ok: true, presets: presetList() });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (req.method === 'POST' && p === '/api/mcp-presets') {
|
|
971
|
+
const body = await readBody(req);
|
|
972
|
+
const name = String(body.name || '').trim();
|
|
973
|
+
const r = buildPreset(name, body.arg, workingDir);
|
|
974
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
975
|
+
cfg.mcpServers = cfg.mcpServers || {};
|
|
976
|
+
cfg.mcpServers[name] = r.config;
|
|
977
|
+
saveConfig(cfg);
|
|
978
|
+
json(res, 200, { ok: true, name, note: '重启 mingdao web 后生效(/mcp 查看状态)' });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (req.method === 'GET' && p === '/api/skill-library') {
|
|
982
|
+
const u = new URL(req.url, 'http://x');
|
|
983
|
+
const q = u.searchParams.get('q') || '';
|
|
984
|
+
const force = u.searchParams.get('refresh') === '1';
|
|
985
|
+
const local = q
|
|
986
|
+
? libraryList().filter((s) => s.name.includes(q) || (s.description || '').includes(q))
|
|
987
|
+
: libraryList();
|
|
988
|
+
const { searchRegistry } = await import('../skill-registry.js');
|
|
989
|
+
const remote = await searchRegistry(q || '', { force, allowNetwork: force });
|
|
990
|
+
const localNames = new Set(local.map((s) => s.name));
|
|
991
|
+
const registryEntries = remote.skills
|
|
992
|
+
? remote.skills.filter((s) => !localNames.has(s.name)).map((s) => ({ ...s, dir: null }))
|
|
993
|
+
: [];
|
|
994
|
+
json(res, 200, {
|
|
995
|
+
ok: true,
|
|
996
|
+
library: local.map((s) => ({ name: s.name, description: s.description, source: 'builtin-lib', installed: s.installed })).concat(
|
|
997
|
+
registryEntries.map((s) => ({ name: s.name, description: s.description, source: 'registry', installed: s.installed }))
|
|
998
|
+
),
|
|
999
|
+
installed: [...installedUserSkillNames()],
|
|
1000
|
+
registry: remote.error ? { error: remote.error } : { host: remote.host, updatedAt: remote.updatedAt, stale: remote.stale || false },
|
|
1001
|
+
});
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (req.method === 'POST' && p === '/api/skills') {
|
|
1005
|
+
const body = await readBody(req);
|
|
1006
|
+
if (body.action === 'install') {
|
|
1007
|
+
const r = await installSkill(body.arg || body.name || '');
|
|
1008
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1009
|
+
return json(res, 200, { ok: true, name: r.name, names: r.names });
|
|
1010
|
+
}
|
|
1011
|
+
if (body.action === 'uninstall') {
|
|
1012
|
+
const r = uninstallSkill(body.name);
|
|
1013
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1014
|
+
return json(res, 200, { ok: true, name: r.name });
|
|
1015
|
+
}
|
|
1016
|
+
return json(res, 400, { error: '未知操作:install|uninstall' });
|
|
1017
|
+
}
|
|
1018
|
+
if (req.method === 'GET' && p === '/api/sync') {
|
|
1019
|
+
const st = syncStatus();
|
|
1020
|
+
let remote = null;
|
|
1021
|
+
if (st.loggedIn) {
|
|
1022
|
+
const r = await syncRemoteList();
|
|
1023
|
+
remote = r.ok ? { sessions: r.sessions.slice(0, 50) } : { error: r.error };
|
|
1024
|
+
}
|
|
1025
|
+
json(res, 200, { ok: true, ...st, remote });
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (req.method === 'POST' && p === '/api/sync') {
|
|
1029
|
+
const body = await readBody(req);
|
|
1030
|
+
if (body.action === 'login') {
|
|
1031
|
+
const r = await syncLogin({ url: body.url, username: body.username, password: body.password, deviceName: body.deviceName });
|
|
1032
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1033
|
+
return json(res, 200, { ok: true, ...r });
|
|
1034
|
+
}
|
|
1035
|
+
if (body.action === 'logout') {
|
|
1036
|
+
syncLogout();
|
|
1037
|
+
return json(res, 200, { ok: true });
|
|
1038
|
+
}
|
|
1039
|
+
if (body.action === 'push') {
|
|
1040
|
+
const r = await syncPush(body.name);
|
|
1041
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1042
|
+
return json(res, 200, { ok: true, pushed: r.pushed.length, conflicts: r.conflicts });
|
|
1043
|
+
}
|
|
1044
|
+
if (body.action === 'pull') {
|
|
1045
|
+
const r = await syncPull(body.name);
|
|
1046
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1047
|
+
return json(res, 200, { ok: true, pulled: r.pulled.length, conflicts: r.conflicts });
|
|
1048
|
+
}
|
|
1049
|
+
if (body.action === 'passwd') {
|
|
1050
|
+
const r = await syncChangePassword({ oldPassword: body.oldPassword, newPassword: body.newPassword });
|
|
1051
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1052
|
+
return json(res, 200, { ok: true });
|
|
1053
|
+
}
|
|
1054
|
+
if (body.action === 'share') {
|
|
1055
|
+
const r = await syncShareCreate(body.name);
|
|
1056
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1057
|
+
return json(res, 200, { ok: true, shareId: r.shareId, name: r.name });
|
|
1058
|
+
}
|
|
1059
|
+
if (body.action === 'shares') {
|
|
1060
|
+
const r = await syncShareList();
|
|
1061
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1062
|
+
return json(res, 200, { ok: true, mine: r.mine, accepted: r.accepted });
|
|
1063
|
+
}
|
|
1064
|
+
if (body.action === 'accept') {
|
|
1065
|
+
const r = await syncShareAccept(body.shareId);
|
|
1066
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1067
|
+
return json(res, 200, { ok: true, ...r });
|
|
1068
|
+
}
|
|
1069
|
+
if (body.action === 'unshare') {
|
|
1070
|
+
const r = await syncShareRevoke(body.shareId);
|
|
1071
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1072
|
+
return json(res, 200, { ok: true });
|
|
1073
|
+
}
|
|
1074
|
+
if (body.action === 'resolveConflict') {
|
|
1075
|
+
const r = resolveSyncConflict(body.base, body.choice);
|
|
1076
|
+
if (r.error) return json(res, 400, { error: r.error });
|
|
1077
|
+
return json(res, 200, { ok: true, ...r });
|
|
1078
|
+
}
|
|
1079
|
+
return json(res, 400, { error: '未知操作:login|logout|push|pull|passwd|share|shares|accept|unshare|resolveConflict' });
|
|
1080
|
+
}
|
|
1081
|
+
if (req.method === 'GET' && p === '/api/sync-conflicts') {
|
|
1082
|
+
json(res, 200, { ok: true, conflicts: listSyncConflicts() });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
// PWA 资源
|
|
1086
|
+
if (req.method === 'GET' && p === '/manifest.webmanifest') {
|
|
1087
|
+
res.writeHead(200, { 'Content-Type': 'application/manifest+json; charset=utf-8' });
|
|
1088
|
+
res.end(
|
|
1089
|
+
JSON.stringify({
|
|
1090
|
+
name: 'MingDao 明道',
|
|
1091
|
+
short_name: 'MingDao',
|
|
1092
|
+
description: 'MingDao-Harness 智能体框架 WebUI',
|
|
1093
|
+
start_url: '/',
|
|
1094
|
+
display: 'standalone',
|
|
1095
|
+
background_color: '#0f1115',
|
|
1096
|
+
theme_color: '#0f1115',
|
|
1097
|
+
icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
|
|
1098
|
+
})
|
|
1099
|
+
);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (req.method === 'GET' && p === '/icon.svg') {
|
|
1103
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8' });
|
|
1104
|
+
res.end(
|
|
1105
|
+
`<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>`
|
|
1106
|
+
);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (req.method === 'GET' && p === '/sw.js') {
|
|
1110
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
|
1111
|
+
// 缓存键去掉 query(?token= 不落缓存);mingdao-v4:随 token 认证版本升版本号,强制替换旧 SW 缓存
|
|
1112
|
+
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('/'))));}});`);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
json(res, 404, { error: 'Not found' });
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
// 全局异常兜底:readBody 超限(413)等错误不再让连接挂起
|
|
1119
|
+
const server = http.createServer((req, res) => {
|
|
1120
|
+
dispatch(req, res).catch((e) => {
|
|
1121
|
+
if (!res.headersSent) {
|
|
1122
|
+
json(res, e?.status || 500, { error: e?.message || '服务器内部错误' });
|
|
1123
|
+
} else {
|
|
1124
|
+
try {
|
|
1125
|
+
res.end();
|
|
1126
|
+
} catch {}
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
server.on('error', (err) => {
|
|
1132
|
+
if (err.code === 'EADDRINUSE') {
|
|
1133
|
+
console.error(`[MingDao] 端口 ${port} 已被占用,请换一个端口:mingdao web <端口号>`);
|
|
1134
|
+
} else {
|
|
1135
|
+
console.error('[MingDao] 服务器错误:' + err.message);
|
|
1136
|
+
}
|
|
1137
|
+
process.exitCode = 1;
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
server.listen(port, host, () => {
|
|
1141
|
+
const actual = server.address().port;
|
|
1142
|
+
boundPort = actual;
|
|
1143
|
+
const displayHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
1144
|
+
console.log('');
|
|
1145
|
+
console.log(` MingDao WebUI 已启动`);
|
|
1146
|
+
console.log(` 地址: http://${displayHost}:${actual}`);
|
|
1147
|
+
if (authEnabled) {
|
|
1148
|
+
console.log(` 访问令牌: http://${displayHost}:${actual}/?token=${webToken}`);
|
|
1149
|
+
if (tokenGenerated) {
|
|
1150
|
+
console.log(' ⚠ 非回环绑定且未配置令牌:本次已随机生成(重启后更换)。');
|
|
1151
|
+
console.log(' 固定令牌:mingdao web --auth-token <令牌>,或 config.json 的 web.token / 环境变量 MINGDAO_WEB_TOKEN。');
|
|
1152
|
+
} else if (isLoopbackHost(host)) {
|
|
1153
|
+
console.log(' ℹ 已启用访问令牌:地址需带 ?token=… 才能访问。');
|
|
1154
|
+
}
|
|
1155
|
+
} else if (!isLoopbackHost(host)) {
|
|
1156
|
+
console.warn(' ⚠ 警告:当前监听 ' + host + '(非本机回环)且未启用令牌,任何能到达端口的人都可访问。建议:mingdao web --auth-token <令牌>。');
|
|
1157
|
+
}
|
|
1158
|
+
console.log(` 模型: ${modelName} · 权限: ${cfg.permission ?? 'ask'} · 工作目录: ${workingDir}`);
|
|
1159
|
+
if (cfg.mcpServers && Object.keys(cfg.mcpServers).length) console.log(' MCP: 后台连接中,/api/state 可查看状态');
|
|
1160
|
+
console.log(` 退出: Ctrl+C`);
|
|
1161
|
+
console.log('');
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
const shutdown = () => {
|
|
1165
|
+
mcpFacade.stop();
|
|
1166
|
+
server.close(() => process.exit(0));
|
|
1167
|
+
setTimeout(() => process.exit(0), 1000).unref();
|
|
1168
|
+
};
|
|
1169
|
+
process.on('SIGINT', shutdown);
|
|
1170
|
+
process.on('SIGTERM', shutdown);
|
|
1171
|
+
|
|
1172
|
+
return server;
|
|
1173
|
+
}
|