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