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.
Files changed (66) hide show
  1. package/docs/EVALUATION-ROUND2.md +58 -0
  2. package/docs/ROADMAP-NEXT.md +5 -5
  3. package/package.json +2 -2
  4. package/src/agent.js +74 -36
  5. package/src/atomic-write.js +8 -2
  6. package/src/audit.js +2 -2
  7. package/src/batch.js +7 -7
  8. package/src/cachestats.js +9 -9
  9. package/src/cli.js +42 -688
  10. package/src/commands/desktop.js +1 -1
  11. package/src/commands/key.js +1 -1
  12. package/src/commands/repl.js +711 -0
  13. package/src/commands/schedule.js +7 -7
  14. package/src/commands/skill.js +18 -16
  15. package/src/commands/sync.js +5 -5
  16. package/src/commands/update.js +7 -6
  17. package/src/commands/workspace.js +2 -2
  18. package/src/compact.js +6 -6
  19. package/src/config.js +7 -4
  20. package/src/context.js +37 -21
  21. package/src/cost-guard.js +14 -6
  22. package/src/credentials.js +6 -0
  23. package/src/hooks.js +11 -11
  24. package/src/mcp-presets.js +5 -5
  25. package/src/mcp.js +19 -19
  26. package/src/memory.js +16 -16
  27. package/src/model-discovery.js +16 -15
  28. package/src/models.js +4 -4
  29. package/src/notify.js +2 -2
  30. package/src/permissions.js +7 -3
  31. package/src/pricing.js +35 -1
  32. package/src/prompts.js +1 -1
  33. package/src/providers/index.js +7 -7
  34. package/src/providers/openai-compatible.js +13 -11
  35. package/src/routing.js +8 -8
  36. package/src/schedule.js +30 -30
  37. package/src/session-index.js +5 -1
  38. package/src/session.js +11 -2
  39. package/src/skill-lib.js +62 -3
  40. package/src/skill-registry.js +10 -9
  41. package/src/skills.js +14 -12
  42. package/src/sync-server.js +86 -3
  43. package/src/sync.js +53 -20
  44. package/src/tasks/worker.js +126 -0
  45. package/src/tasks.js +14 -14
  46. package/src/titles.js +5 -5
  47. package/src/tokenizer.js +23 -5
  48. package/src/tools/bash.js +12 -12
  49. package/src/tools/fs-tools.js +66 -9
  50. package/src/tools/index.js +7 -7
  51. package/src/ui.js +83 -6
  52. package/src/update.js +8 -8
  53. package/src/web/app.js +16 -5
  54. package/src/web/attachments.js +1 -1
  55. package/src/web/index.html +21 -1
  56. package/src/web/routes/api.js +38 -786
  57. package/src/web/routes/domains/config.js +280 -0
  58. package/src/web/routes/domains/misc.js +129 -0
  59. package/src/web/routes/domains/schedule.js +127 -0
  60. package/src/web/routes/domains/sessions.js +105 -0
  61. package/src/web/routes/domains/skills.js +79 -0
  62. package/src/web/routes/domains/sync.js +101 -0
  63. package/src/web/routes/domains/workspace.js +119 -0
  64. package/src/web/server.js +73 -31
  65. package/src/web/web-io.js +54 -12
  66. package/src/workspace.js +14 -14
@@ -0,0 +1,280 @@
1
+ // 配置域(Phase C C1):/api/state /api/config /api/models-config
2
+ // 服务商 Key 管理、自定义模型增删改、API 地址覆盖、系统状态快照。
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { loadConfig, saveConfig } from '../../../config.js';
6
+ import { setStoredKey, removeStoredKey, getStoredKey, maskKey } from '../../../credentials.js';
7
+ import { availableModels, fetchProviderModels, providerHasKey } from '../../../model-discovery.js';
8
+ import { createProvider, resolveProviderConfig } from '../../../providers/index.js';
9
+ import { MODELS, PROVIDERS } from '../../../models.js';
10
+ import { detectSandbox } from '../../../tools/bash.js';
11
+ import { enableAutostart, disableAutostart, autostartStatus } from '../../../autostart.js';
12
+ import { PRICE_DATA_AS_OF } from '../../../pricing.js';
13
+ import { listSessions, relativeTime, sessionPreview } from '../../../session.js';
14
+ import { currentWorkspace } from '../../../workspace.js';
15
+
16
+ /**
17
+ * 配置域路由。命中返回 true,未命中返回 false。
18
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
19
+ * @param {any} deps
20
+ * @param {{json:any,readBody:any,MAX_API_BODY:any,validateRemoteUrl:any}} shared
21
+ */
22
+ export async function handle({ req, res, method, p, url }, deps, shared) {
23
+ const { json, readBody, MAX_API_BODY } = shared;
24
+ const { cfg, home, state, providerCache, getProviderFor, mcpFacade } = deps;
25
+
26
+ if (method === 'GET' && p === '/api/state') {
27
+ const sessions = listSessions(home)
28
+ .slice(0, 30)
29
+ .map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
30
+ // 模型列表:只列已设置 Key 的服务商,名称以 /models 接口线上名单为准(预设仅回退与补价)
31
+ const models = await availableModels(cfg, state.modelName);
32
+ json(res, 200, {
33
+ ok: true,
34
+ model: state.modelName,
35
+ models,
36
+ // 首次使用引导:当前模型无可用 API Key 时前端显示「去 ⚙ 设置填 Key」横幅
37
+ keyReady: Boolean((resolveProviderConfig(cfg, state.modelName) || {}).apiKey),
38
+ permissions: ['ask', 'auto', 'readonly'],
39
+ permission: cfg.permission ?? 'ask',
40
+ sandbox: cfg.sandbox || 'off',
41
+ sandboxSupported: detectSandbox() !== 'none',
42
+ routing: cfg.routing?.enabled ? cfg.routing : null,
43
+ contextBudget: cfg.contextBudget || 128000,
44
+ pricingAsOf: PRICE_DATA_AS_OF,
45
+ autostart: autostartStatus(),
46
+ notify: cfg.notify !== false,
47
+ workspace: currentWorkspace(state.workingDir)?.name || null,
48
+ home,
49
+ workingDir: state.workingDir,
50
+ mcp: mcpFacade.status(),
51
+ sessions,
52
+ });
53
+ return true;
54
+ }
55
+
56
+ if (method === 'POST' && p === '/api/config') {
57
+ const body = await readBody(req, MAX_API_BODY);
58
+ // 内存 cfg 可能比磁盘旧(如 CLI 改过同步地址):先从磁盘刷新 sync,避免保存时回滚
59
+ cfg.sync = loadConfig()?.sync || cfg.sync;
60
+ const next = /** @type {any} */ ({ model: state.modelName, permission: cfg.permission ?? 'ask' });
61
+ if (body.model !== undefined) {
62
+ const target = String(body.model).trim();
63
+ if (!target) return json(res, 400, { error: '模型名不能为空' });
64
+ const tpc = resolveProviderConfig(cfg, target);
65
+ if (!tpc.apiKey) {
66
+ const hint = tpc.name.startsWith('custom:')
67
+ ? '请在 设置 → 模型与 API Key 中填写该模型的 Key'
68
+ : `请先运行 mingdao key set ${tpc.name}`;
69
+ return json(res, 400, { error: `模型 ${target} 没有可用 API Key(服务商 ${tpc.name}),${hint}` });
70
+ }
71
+ await getProviderFor(target); // 预热(失败会抛错)
72
+ next.model = target;
73
+ }
74
+ if (body.permission !== undefined) {
75
+ const perm = String(body.permission);
76
+ if (!['ask', 'auto', 'readonly'].includes(perm)) {
77
+ return json(res, 400, { error: '权限模式必须是 ask / auto / readonly' });
78
+ }
79
+ next.permission = perm;
80
+ }
81
+ if (body.sandbox !== undefined) {
82
+ const sbx = String(body.sandbox);
83
+ if (!['off', 'readonly', 'safe'].includes(sbx)) {
84
+ return json(res, 400, { error: '沙箱模式必须是 off / readonly / safe' });
85
+ }
86
+ next.sandbox = sbx;
87
+ cfg.sandbox = sbx;
88
+ }
89
+ if (body.routing !== undefined) {
90
+ const on = body.routing === true || body.routing === 'on';
91
+ cfg.routing = {
92
+ enabled: on,
93
+ planner: cfg.routing?.planner || 'deepseek-v4-pro',
94
+ executor: cfg.routing?.executor || 'deepseek-v4-flash',
95
+ };
96
+ next.routing = on;
97
+ }
98
+ if (body.contextBudget !== undefined) {
99
+ const n = Number(body.contextBudget);
100
+ if (!Number.isInteger(n) || n < 1000) {
101
+ return json(res, 400, { error: '上下文预算必须是 ≥1000 的整数' });
102
+ }
103
+ next.contextBudget = n;
104
+ cfg.contextBudget = n;
105
+ }
106
+ let autostartChanged = false;
107
+ if (body.autostart !== undefined) {
108
+ autostartChanged = true;
109
+ const okAuto = body.autostart === true || body.autostart === 'on' ? enableAutostart() : disableAutostart();
110
+ if (!okAuto) return json(res, 500, { error: '开机自启设置失败' });
111
+ }
112
+ if (body.notify !== undefined) {
113
+ next.notify = body.notify === true || body.notify === 'on';
114
+ cfg.notify = next.notify;
115
+ }
116
+ if (body.syncAuto !== undefined) {
117
+ cfg.sync = cfg.sync || {};
118
+ cfg.sync.auto = body.syncAuto === true || body.syncAuto === 'on';
119
+ }
120
+ state.modelName = next.model;
121
+ cfg.model = next.model;
122
+ cfg.permission = next.permission;
123
+ saveConfig(cfg);
124
+ 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 });
125
+ return true;
126
+ }
127
+
128
+ if (method === 'GET' && p === '/api/models-config') {
129
+ const providers = Object.keys(PROVIDERS).map((name) => {
130
+ const pp = /** @type {Record<string, any>} */ (PROVIDERS)[name];
131
+ const stored = getStoredKey(name);
132
+ const env = pp.envKey && process.env[pp.envKey] ? true : false;
133
+ return {
134
+ name,
135
+ label: pp.label,
136
+ baseUrl: pp.baseUrl,
137
+ envKey: pp.envKey || null,
138
+ keyState: stored ? 'stored' : env ? 'env' : 'none',
139
+ keyMasked: stored ? maskKey(stored) : null,
140
+ };
141
+ });
142
+ const customModels = Object.entries(cfg.customModels || {}).map(([name, cm]) => {
143
+ const stored = getStoredKey(`custom:${name}`);
144
+ return {
145
+ name,
146
+ label: cm.label || '',
147
+ baseUrl: cm.baseUrl || '',
148
+ envKey: cm.envKey || null,
149
+ vision: Boolean(cm.vision),
150
+ keyState: stored ? 'stored' : 'none',
151
+ keyMasked: stored ? maskKey(stored) : null,
152
+ };
153
+ });
154
+ json(res, 200, {
155
+ ok: true,
156
+ providers,
157
+ customModels,
158
+ model: state.modelName,
159
+ provider: resolveProviderConfig(cfg, state.modelName).name,
160
+ baseUrlOverride: cfg.baseUrl || '',
161
+ });
162
+ return true;
163
+ }
164
+
165
+ if (method === 'POST' && p === '/api/models-config') {
166
+ const body = await readBody(req, MAX_API_BODY);
167
+ const action = body.action;
168
+ // —— 服务商 Key 管理 ——
169
+ if (action === 'setProviderKey') {
170
+ const provider = String(body.provider || '').trim();
171
+ if (!/** @type {Record<string, any>} */ (PROVIDERS)[provider]) return json(res, 400, { error: `未知服务商 ${provider}` });
172
+ const key = String(body.key || '').trim();
173
+ if (!key) return json(res, 400, { error: 'Key 不能为空(删除请用 removeProviderKey)' });
174
+ setStoredKey(provider, key);
175
+ providerCache.clear(); // 质检:Key 立即生效(缓存持有旧无 Key 实例 → 此前需重启)
176
+ // 设置 Key 后立即拉取线上真实模型名单(失败不影响 Key 保存,回退预设)
177
+ const fr = provider === 'custom' ? { error: '自定义服务商无模型列表' } : await fetchProviderModels(cfg, provider, { force: true });
178
+ return json(res, 200, {
179
+ ok: true,
180
+ provider,
181
+ keyMasked: maskKey(key),
182
+ models: fr.models || [],
183
+ modelsNote: fr.error ? `模型列表暂用预设(拉取失败:${fr.error}),稍后可用「刷新模型」重试` : null,
184
+ });
185
+ }
186
+ if (action === 'refreshModels') {
187
+ const provider = String(body.provider || '').trim();
188
+ if (!/** @type {Record<string, any>} */ (PROVIDERS)[provider] || provider === 'custom') return json(res, 400, { error: '未知服务商' });
189
+ if (!providerHasKey(provider)) return json(res, 400, { error: '该服务商未设置 API Key' });
190
+ const fr = await fetchProviderModels(cfg, provider, { force: true });
191
+ if (fr.error) return json(res, 400, { error: `拉取失败:${fr.error}` });
192
+ return json(res, 200, { ok: true, provider, models: fr.models, fromCache: fr.fromCache });
193
+ }
194
+ if (action === 'removeProviderKey') {
195
+ const provider = String(body.provider || '').trim();
196
+ if (!/** @type {Record<string, any>} */ (PROVIDERS)[provider]) return json(res, 400, { error: `未知服务商 ${provider}` });
197
+ removeStoredKey(provider);
198
+ providerCache.clear(); // 质检:Key 删除立即生效
199
+ return json(res, 200, { ok: true, provider });
200
+ }
201
+ // —— 自定义模型增删改 ——
202
+ if (action === 'addCustom' || action === 'updateCustom') {
203
+ const name = String(body.name || '').trim();
204
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name)) {
205
+ return json(res, 400, { error: '模型名非法:字母/数字开头,可含 . _ -,1–64 位' });
206
+ }
207
+ if (/** @type {Record<string, any>} */ (MODELS)[name]) return json(res, 400, { error: `${name} 与内置模型同名,请换一个名字` });
208
+ const label = String(body.label || '').trim();
209
+ const baseUrl = String(body.baseUrl || '').trim();
210
+ if (!baseUrl) return json(res, 400, { error: 'API 地址(baseUrl)不能为空' });
211
+ const vurl = await shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
212
+ if (vurl.error) return json(res, 400, { error: vurl.error });
213
+ if (action === 'addCustom' && (cfg.customModels || {})[name]) {
214
+ return json(res, 400, { error: `自定义模型 ${name} 已存在(可修改)` });
215
+ }
216
+ cfg.customModels = cfg.customModels || {};
217
+ cfg.customModels[name] = {
218
+ label,
219
+ baseUrl,
220
+ envKey: String(body.envKey || '').trim() || undefined,
221
+ vision: body.vision === true || body.vision === 'on' ? true : undefined,
222
+ };
223
+ if (String(body.key || '').trim()) setStoredKey(`custom:${name}`, String(body.key).trim());
224
+ saveConfig(cfg);
225
+ return json(res, 200, { ok: true, name });
226
+ }
227
+ if (action === 'removeCustom') {
228
+ const name = String(body.name || '').trim();
229
+ if (!(cfg.customModels || {})[name]) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
230
+ delete cfg.customModels[name];
231
+ removeStoredKey(`custom:${name}`);
232
+ if (state.modelName === name) {
233
+ state.modelName = 'deepseek-v4-flash';
234
+ cfg.model = state.modelName;
235
+ }
236
+ saveConfig(cfg);
237
+ return json(res, 200, { ok: true, name, model: state.modelName });
238
+ }
239
+ // 质检(自定义模型连通性):发起一次最小对话验证 baseUrl+Key 可用
240
+ if (action === 'testCustom') {
241
+ const name = String(body.name || '').trim();
242
+ const cm = (cfg.customModels || {})[name];
243
+ if (!cm) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
244
+ const pc = resolveProviderConfig(cfg, name);
245
+ if (!pc.apiKey) return json(res, 400, { error: '该模型未设置 API Key,请先「设Key」再测试' });
246
+ const t0 = Date.now();
247
+ try {
248
+ const tp = await createProvider(cfg, name);
249
+ const r = await tp.chat({ model: name, messages: [{ role: 'user', content: 'ping' }], tools: [], temperature: 0, maxTokens: 8 });
250
+ return json(res, 200, { ok: true, name, latencyMs: Date.now() - t0, reply: String(r.text || r.reasoning || '').slice(0, 60) || '(空回复,但连接成功)' });
251
+ } catch (/** @type {any} */ e) {
252
+ return json(res, 200, { ok: false, name, latencyMs: Date.now() - t0, error: String(e?.message || e) });
253
+ }
254
+ }
255
+ if (action === 'setCustomKey') {
256
+ const name = String(body.name || '').trim();
257
+ if (!(cfg.customModels || {})[name]) return json(res, 400, { error: `自定义模型 ${name} 不存在` });
258
+ const key = String(body.key || '').trim();
259
+ if (!key) return json(res, 400, { error: 'Key 不能为空' });
260
+ setStoredKey(`custom:${name}`, key);
261
+ providerCache.clear(); // 质检:Key 立即生效
262
+ return json(res, 200, { ok: true, name, keyMasked: maskKey(key) });
263
+ }
264
+ // —— 当前服务商 API 地址覆盖 ——
265
+ if (action === 'setBaseUrl') {
266
+ const baseUrl = String(body.baseUrl || '').trim();
267
+ if (baseUrl) {
268
+ const vurl = await shared.validateRemoteUrl(baseUrl); // 质检 S1:SSRF 防护
269
+ if (vurl.error) return json(res, 400, { error: vurl.error });
270
+ }
271
+ cfg.baseUrl = baseUrl || undefined;
272
+ if (cfg.baseUrl === undefined) delete cfg.baseUrl;
273
+ saveConfig(cfg);
274
+ return json(res, 200, { ok: true, baseUrl: cfg.baseUrl || '' });
275
+ }
276
+ return json(res, 400, { error: '未知操作:setProviderKey|removeProviderKey|addCustom|updateCustom|removeCustom|setCustomKey|setBaseUrl' });
277
+ }
278
+
279
+ return false;
280
+ }
@@ -0,0 +1,129 @@
1
+ // 杂项域(Phase C C1):/api/chat /api/permission /api/abort /api/memory /api/cache-stats
2
+ // 对话 SSE 流(并发上限 + 生命周期计数)、权限确认、任务中断、长期记忆、费用/缓存统计。
3
+ import { loadMemory, writeMemory, dedupeMemory } from '../../../memory.js';
4
+ import { recordUsage, listCacheStats, summarizeCacheStats, costBreakdown } from '../../../cachestats.js';
5
+ import { costGuardStatus } from '../../../cost-guard.js';
6
+
7
+ /**
8
+ * 杂项域路由。命中返回 true,未命中返回 false。
9
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
10
+ * @param {any} deps
11
+ * @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
12
+ */
13
+ export async function handle({ req, res, method, p, url }, deps, shared) {
14
+ const { json, readBody, MAX_API_BODY } = shared;
15
+ const { tasks, refs, MAX_CONCURRENT, handleChat, pruneTasks } = deps;
16
+
17
+ if (method === 'POST' && p === '/api/chat') {
18
+ // 质检 S2:inflight 计数绑定请求生命周期(进入 ++ / 结束 --)。
19
+ // 此前检查发生在 readBody 之前且占位即删,多请求可同时卡在 readBody 后批量登记 → 瞬时超并发
20
+ refs.inflight += 1;
21
+ if (refs.inflight > MAX_CONCURRENT) {
22
+ refs.inflight -= 1;
23
+ return json(res, 429, { error: `并发任务已达上限(${MAX_CONCURRENT}),请等待任务完成或中断` });
24
+ }
25
+ res.writeHead(200, {
26
+ 'Content-Type': 'text/event-stream; charset=utf-8',
27
+ 'Cache-Control': 'no-cache',
28
+ Connection: 'keep-alive',
29
+ 'X-Accel-Buffering': 'no',
30
+ });
31
+ let body;
32
+ try {
33
+ // chat 保留默认 40MB 上限(附件 base64 可达 ~26MB);普通 JSON 接口才用 MAX_API_BODY=1MB
34
+ body = await readBody(req);
35
+ } catch (/** @type {any} */ e) {
36
+ refs.inflight -= 1;
37
+ res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
38
+ res.end();
39
+ return true;
40
+ }
41
+ try {
42
+ await handleChat(res, body);
43
+ } catch (/** @type {any} */ err) {
44
+ // 兜底(审计:「点发送无反应」根因防护):handleChat 内任意未捕获异常若直接外抛,
45
+ // SSE 已开流却无事件 → 前端永远停在「正在思考…」。此处统一转为 error 事件回给界面。
46
+ try {
47
+ res.write(`data: ${JSON.stringify({ type: 'error', message: '对话失败:' + String(err?.message || err) })}\n\n`);
48
+ } catch {}
49
+ try {
50
+ res.end();
51
+ } catch {}
52
+ pruneTasks();
53
+ }
54
+ refs.inflight -= 1; // 与 handleChat 的 finally 无关:handleChat 内部路径全部会返回/结束
55
+ return true;
56
+ }
57
+
58
+ if (method === 'POST' && p === '/api/permission') {
59
+ const body = await readBody(req, MAX_API_BODY);
60
+ const entry = tasks.get(body.taskId);
61
+ if (!entry || !entry.pendingAsk) return json(res, 409, { error: '没有挂起的权限确认' });
62
+ const pa = entry.pendingAsk;
63
+ entry.pendingAsk = null;
64
+ const answer = String(body.answer ?? '');
65
+ if (pa.options && Array.isArray(pa.options)) {
66
+ const opt = pa.options.find((/** @type {any} */ o) => String(o.value) === answer);
67
+ pa.resolve(opt ? opt.value : answer);
68
+ } else {
69
+ pa.resolve(answer);
70
+ }
71
+ json(res, 200, { ok: true });
72
+ return true;
73
+ }
74
+
75
+ if (method === 'POST' && p === '/api/abort') {
76
+ const body = await readBody(req, MAX_API_BODY);
77
+ if (body.taskId) {
78
+ const entry = tasks.get(body.taskId);
79
+ if (entry?.abortHandler) {
80
+ try {
81
+ entry.abortHandler();
82
+ } catch {}
83
+ }
84
+ } else {
85
+ // 未指定任务:中断全部运行中任务
86
+ for (const t of tasks.values()) {
87
+ if (t.status === 'running' && t.abortHandler) {
88
+ try {
89
+ t.abortHandler();
90
+ } catch {}
91
+ }
92
+ }
93
+ }
94
+ json(res, 200, { ok: true });
95
+ return true;
96
+ }
97
+
98
+ if (method === 'GET' && p === '/api/memory') {
99
+ json(res, 200, { ok: true, content: loadMemory() });
100
+ return true;
101
+ }
102
+
103
+ if (method === 'POST' && p === '/api/memory') {
104
+ const body = await readBody(req, MAX_API_BODY);
105
+ if (body.action === 'dedupe') {
106
+ const removed = dedupeMemory();
107
+ return json(res, 200, { ok: true, removed });
108
+ }
109
+ if (body.content !== undefined) {
110
+ writeMemory(body.content);
111
+ return json(res, 200, { ok: true });
112
+ }
113
+ return json(res, 400, { error: '缺少 content 或 action=dedupe' });
114
+ }
115
+
116
+ if (method === 'GET' && p === '/api/cache-stats') {
117
+ const entries = listCacheStats();
118
+ json(res, 200, {
119
+ ok: true,
120
+ summary: summarizeCacheStats(entries),
121
+ breakdown: costBreakdown(),
122
+ guard: costGuardStatus(),
123
+ recent: entries.slice(-10).reverse(),
124
+ });
125
+ return true;
126
+ }
127
+
128
+ return false;
129
+ }
@@ -0,0 +1,127 @@
1
+ // 调度域(Phase C C1):/api/schedule /api/tasks
2
+ // 定时任务增删暂停恢复/链式编排;聊天 SSE 任务 + 后台 worker + 调度任务合并面板。
3
+ import path from 'node:path';
4
+ import {
5
+ addSchedule,
6
+ listSchedules,
7
+ removeSchedule,
8
+ pauseSchedule,
9
+ resumeSchedule,
10
+ chainSchedules,
11
+ reconcileSchedules,
12
+ } from '../../../schedule.js';
13
+ import { listTasks } from '../../../tasks.js';
14
+
15
+ /**
16
+ * 调度域路由。命中返回 true,未命中返回 false。
17
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
18
+ * @param {any} deps
19
+ * @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
20
+ */
21
+ export async function handle({ req, res, method, p, url }, deps, shared) {
22
+ const { json, readBody, MAX_API_BODY } = shared;
23
+ const { home, state, tasks, MAX_CONCURRENT } = deps;
24
+
25
+ if (method === 'GET' && p === '/api/tasks') {
26
+ const running = [...tasks.values()].filter((t) => t.status === 'running').length;
27
+ const list = [...tasks.entries()].map(([id, t]) => ({
28
+ id,
29
+ status: t.status,
30
+ message: t.message,
31
+ startedAt: t.startedAt,
32
+ durationMs: t.status === 'running' ? Date.now() - t.startedAt : t.durationMs,
33
+ session: t.session ? path.basename(t.session.file) : null,
34
+ }));
35
+ // 质检(等待状态静默):后台 worker 任务与运行中的调度任务并入面板与状态条计数——
36
+ // 此前 /api/tasks 只含聊天 SSE 任务,后台下载/长任务期间界面完全无感知
37
+ let bgRunning = 0;
38
+ const background = [];
39
+ try {
40
+ for (const t of listTasks(home)) {
41
+ if (t.status === 'running') bgRunning += 1;
42
+ background.push({
43
+ id: t.id,
44
+ kind: 'worker',
45
+ status: t.status,
46
+ message: t.message || t.question || '',
47
+ startedAt: t.startedAt,
48
+ durationMs: t.status === 'running' ? Date.now() - t.startedAt : t.durationMs,
49
+ error: t.error || '',
50
+ });
51
+ }
52
+ for (const sch of listSchedules(home)) {
53
+ if (sch.status === 'running') bgRunning += 1;
54
+ background.push({
55
+ id: sch.id,
56
+ kind: 'schedule',
57
+ status: sch.status,
58
+ message: sch.question || sch.id || '',
59
+ startedAt: sch.createdAt || null,
60
+ durationMs: sch.status === 'running' ? Date.now() - (sch.lastRunAt || Date.now()) : null,
61
+ });
62
+ }
63
+ } catch {}
64
+ json(res, 200, { ok: true, running, bgRunning, maxConcurrent: MAX_CONCURRENT, tasks: list, background });
65
+ return true;
66
+ }
67
+
68
+ if (method === 'GET' && p === '/api/schedule') {
69
+ reconcileSchedules(home);
70
+ const jobs = listSchedules(home).map((j) => ({
71
+ id: j.id,
72
+ status: j.status,
73
+ kind: j.kind,
74
+ question: j.question,
75
+ nextRunAt: j.nextRunAt,
76
+ lastRunAt: j.lastRunAt,
77
+ lastTaskId: j.lastTaskId,
78
+ runs: j.runs,
79
+ interval: j.interval,
80
+ anchor: j.anchor,
81
+ after: j.after,
82
+ history: j.history || [],
83
+ note: j.note,
84
+ }));
85
+ json(res, 200, { ok: true, jobs });
86
+ return true;
87
+ }
88
+
89
+ if (method === 'POST' && p === '/api/schedule') {
90
+ const body = await readBody(req, MAX_API_BODY);
91
+ const action = String(body.action || '');
92
+ try {
93
+ if (action === 'add') {
94
+ if (!body.question || !String(body.question).trim()) return json(res, 400, { error: '任务内容不能为空' });
95
+ const r = addSchedule(home, String(body.question).trim(), {
96
+ at: body.at || null,
97
+ every: body.every || null,
98
+ anchor: body.anchor || null,
99
+ after: Array.isArray(body.after) ? body.after.map(String) : body.after ? String(body.after).split(',').map((x) => x.trim()).filter(Boolean) : undefined,
100
+ permission: body.permission || null,
101
+ model: body.model || null,
102
+ cwd: state.workingDir,
103
+ offpeak: body.offpeak === true,
104
+ });
105
+ if (r.error) return json(res, 400, { error: r.error });
106
+ return json(res, 200, { ok: true, id: r.id });
107
+ }
108
+ if (action === 'chain') {
109
+ const qs = Array.isArray(body.questions) ? body.questions.map(String).filter((/** @type {any} */ q) => q.trim()) : [];
110
+ if (qs.length < 2) return json(res, 400, { error: '链式需要至少两个任务' });
111
+ const r = /** @type {any} */ (chainSchedules(home, qs, { permission: body.permission || null, model: body.model || null }));
112
+ if (r.error) return json(res, 400, { error: r.error });
113
+ return json(res, 200, { ok: true, ids: r.ids });
114
+ }
115
+ const id = String(body.id || '');
116
+ if (!id) return json(res, 400, { error: '缺少 id' });
117
+ if (action === 'remove') return json(res, 200, { ok: removeSchedule(home, id) });
118
+ if (action === 'pause') return json(res, 200, { ok: pauseSchedule(home, id) });
119
+ if (action === 'resume') return json(res, 200, { ok: resumeSchedule(home, id) });
120
+ return json(res, 400, { error: '未知操作:add|chain|remove|pause|resume' });
121
+ } catch (/** @type {any} */ err) {
122
+ return json(res, 500, { error: String(err?.message || err) });
123
+ }
124
+ }
125
+
126
+ return false;
127
+ }
@@ -0,0 +1,105 @@
1
+ // 会话域(Phase C C1):/api/sessions /api/session /api/draft
2
+ // 会话列表/搜索、载入(聚焦工作空间)、重命名/删除、按会话隔离的草稿槽。
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import {
6
+ listSessions,
7
+ loadSession,
8
+ searchSessions,
9
+ relativeTime,
10
+ sessionPreview,
11
+ } from '../../../session.js';
12
+ import { sanitizeTitle, renameSessionFile } from '../../../titles.js';
13
+ import {
14
+ workspaceForDir,
15
+ touchWorkspace,
16
+ getSessionWorkspace,
17
+ moveSessionWorkspace,
18
+ removeSessionWorkspace,
19
+ } from '../../../workspace.js';
20
+
21
+ /**
22
+ * 会话域路由。命中返回 true,未命中返回 false。
23
+ * @param {{req:any,res:any,method:any,p:any,url:any}} ctx
24
+ * @param {any} deps
25
+ * @param {{json:any,readBody:any,MAX_API_BODY:any}} shared
26
+ */
27
+ export async function handle({ req, res, method, p, url }, deps, shared) {
28
+ const { json, readBody, MAX_API_BODY } = shared;
29
+ const { home, state, draftTexts } = deps;
30
+
31
+ if (method === 'GET' && p === '/api/sessions') {
32
+ const q = url.searchParams.get('q');
33
+ const found = q
34
+ ? searchSessions(home, q, { limit: 30 }).map((/** @type {any} */ s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${s.snippet}` }))
35
+ : listSessions(home).slice(0, 30).map((s) => ({ file: s.name, mtime: s.mtime, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }));
36
+ json(res, 200, { ok: true, sessions: found });
37
+ return true;
38
+ }
39
+
40
+ if (method === 'GET' && p === '/api/session') {
41
+ const file = url.searchParams.get('file');
42
+ if (!file) return json(res, 400, { error: '缺少 file 参数' });
43
+ try {
44
+ const loaded = loadSession(path.join(home, 'sessions', path.basename(file)));
45
+ // 载入会话时聚焦其工作空间(P3-4):全局默认切到该会话的目录,前端下拉同步显示
46
+ const wsDir = getSessionWorkspace(path.basename(file));
47
+ const ws = workspaceForDir(wsDir);
48
+ if (wsDir && path.resolve(wsDir) !== path.resolve(state.workingDir)) {
49
+ state.workingDir = wsDir; // 审计 P2-5:仅在目录不同时才切换,减少全局状态抖动
50
+ if (ws) touchWorkspace(ws.name);
51
+ }
52
+ json(res, 200, {
53
+ ok: true,
54
+ messages: loaded.messages.filter((m) => m.role === 'user' || m.role === 'assistant').map((m) => ({ role: m.role, content: m.content ?? '' })),
55
+ workspace: ws?.name || null,
56
+ });
57
+ } catch {
58
+ json(res, 404, { error: '会话不存在' });
59
+ }
60
+ return true;
61
+ }
62
+
63
+ if (method === 'POST' && p === '/api/session') {
64
+ const body = await readBody(req, MAX_API_BODY);
65
+ const file = path.basename(String(body.file || ''));
66
+ const full = path.join(home, 'sessions', file);
67
+ if (!file || !fs.existsSync(full)) return json(res, 404, { error: '会话不存在' });
68
+ if (body.action === 'rename') {
69
+ const title = sanitizeTitle(String(body.title || '会话'));
70
+ const renamed = renameSessionFile(fs, path, home, { file: full }, title);
71
+ if (!renamed) return json(res, 500, { error: '重命名失败(可能存在同名会话)' });
72
+ moveSessionWorkspace(file, path.basename(renamed));
73
+ return json(res, 200, { ok: true, file: path.basename(renamed) });
74
+ }
75
+ if (body.action === 'delete') {
76
+ try {
77
+ fs.unlinkSync(full);
78
+ removeSessionWorkspace(file);
79
+ return json(res, 200, { ok: true });
80
+ } catch (/** @type {any} */ err) {
81
+ return json(res, 500, { error: String(err?.message || err) });
82
+ }
83
+ }
84
+ return json(res, 400, { error: '未知操作:rename|delete' });
85
+ }
86
+
87
+ if (method === 'GET' && p === '/api/draft') {
88
+ // 质检 A5:按会话维度读取(无 file 参数用全局槽,兼容旧客户端)
89
+ const key = String(url.searchParams.get('file') || '');
90
+ const text = draftTexts.get(key) || '';
91
+ draftTexts.delete(key); // 读取即清除
92
+ json(res, 200, { ok: true, text });
93
+ return true;
94
+ }
95
+
96
+ if (method === 'POST' && p === '/api/draft') {
97
+ const body = await readBody(req, MAX_API_BODY);
98
+ const key = String(body.file || '');
99
+ draftTexts.set(key, String(body.text ?? '').slice(0, 200000));
100
+ json(res, 200, { ok: true });
101
+ return true;
102
+ }
103
+
104
+ return false;
105
+ }