@iamsamyiok/agents-chat 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/server.js ADDED
@@ -0,0 +1,1334 @@
1
+ // Agents Chat Portable - 零依赖 HTTP 服务
2
+ // 启动:node app/server.js [--port 3456]
3
+ const APP_VERSION = '3.18.0'; // 页面与服务端版本互检,不一致提示强刷
4
+ const http = require('http');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const url = require('url');
8
+ const { spawn } = require('child_process');
9
+
10
+ // 首先加载 .env(根目录,行为开关配置)
11
+ const { loadEnv } = require('./lib/env');
12
+ const ROOT_DIR = path.join(__dirname, '..');
13
+ loadEnv(path.join(ROOT_DIR, '.env'));
14
+
15
+ // ---------- 日志 tee:控制台输出同时写入 .data/server.log ----------
16
+ const LOG_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT_DIR, '.data');
17
+ try { fs.mkdirSync(LOG_DIR, { recursive: true }); } catch { /* ignore */ }
18
+ const LOG_PATH = path.join(LOG_DIR, 'server.log');
19
+ try { fs.writeFileSync(LOG_PATH, `=== Agents Chat started ${new Date().toISOString()} ===\n`); } catch { /* ignore */ }
20
+ function teeWrite(args) {
21
+ try { fs.appendFileSync(LOG_PATH, args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') + '\n'); } catch { /* ignore */ }
22
+ }
23
+ const origLog = console.log.bind(console);
24
+ const origErr = console.error.bind(console);
25
+ console.log = (...args) => { origLog(...args); teeWrite(args); };
26
+ console.error = (...args) => { origErr(...args); teeWrite(args); };
27
+ process.on('uncaughtException', (err) => {
28
+ console.error('[uncaughtException]', err && err.stack || err);
29
+ });
30
+ process.on('unhandledRejection', (err) => {
31
+ console.error('[unhandledRejection]', err && (err.stack || err) || err);
32
+ });
33
+
34
+ const PORT = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : (process.env.PORT || 3456));
35
+ const PUBLIC_DIR = path.join(__dirname, 'public');
36
+ const store = require('./lib/store');
37
+ const { runAgent, stopScope, stopAllChildren } = require('./lib/agent');
38
+ const { runButler, runMentioned, runRoundtable, runTasks, prepareRerun } = require('./lib/orchestrator');
39
+ const oc = require('./lib/oc');
40
+ const memoryMod = require('./lib/memory');
41
+ const { CardStore, runner: cardRunner, sseSubscribe, MAX_PARALLEL } = require('./lib/cards');
42
+
43
+ // ---------- 人工审批关卡:orchestrator 暂停等待用户放行(方案/交付),SSE 断线后可经 /api/approvals 恢复 ----------
44
+ const pendingApprovals = new Map(); // approvalId -> {kind,label,taskId,resolve,timer}
45
+ const APPROVAL_TIMEOUT_MS = Number(process.env.AGENTS_CHAT_APPROVAL_TIMEOUT_MS) > 0
46
+ ? Number(process.env.AGENTS_CHAT_APPROVAL_TIMEOUT_MS) : 600000; // 默认 10 分钟未审批视为拒绝
47
+
48
+ function makeRequestApproval() {
49
+ return (kind, label, taskId) => new Promise((resolve) => {
50
+ const id = 'apr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
51
+ const timer = setTimeout(() => finishApproval(id, false, true), APPROVAL_TIMEOUT_MS);
52
+ pendingApprovals.set(id, { kind, label, taskId: taskId || '', resolve, timer, createdAt: new Date().toISOString() });
53
+ });
54
+ }
55
+ function finishApproval(id, approved, timedOut) {
56
+ const a = pendingApprovals.get(id);
57
+ if (!a) return null;
58
+ clearTimeout(a.timer);
59
+ pendingApprovals.delete(id);
60
+ a.resolve(!!approved);
61
+ return { ...a, timedOut: !!timedOut };
62
+ }
63
+ // 审批模式:'off' | 'plan'(方案后)| 'verify'(交付前)| 'all';config.approval 优先,其次 env 默认
64
+ function approvalSetting() {
65
+ const v = String(store.getConfig().approval || process.env.AGENTS_CHAT_APPROVAL || 'off').toLowerCase();
66
+ return ['off', 'plan', 'verify', 'all'].includes(v) ? v : 'off';
67
+ }
68
+
69
+ // ---------- 执行互斥与停止控制 ----------
70
+ // chat / tasks / solo 三个作用域各自单飞(防止双击或 API 直调并发执行);
71
+ // 任务执行中仍可正常聊天,互不阻塞
72
+ const runLocks = { chat: false, tasks: false, solo: false };
73
+ // 停止令牌:每次停止递增,编排循环通过对比快照感知「执行期间被要求停止」
74
+ const stopTokens = { chat: 0, tasks: 0 };
75
+
76
+ const MIME = {
77
+ '.html': 'text/html; charset=utf-8',
78
+ '.js': 'text/javascript; charset=utf-8',
79
+ '.css': 'text/css; charset=utf-8',
80
+ '.json': 'application/json; charset=utf-8',
81
+ '.svg': 'image/svg+xml',
82
+ '.png': 'image/png',
83
+ '.ico': 'image/x-icon'
84
+ };
85
+
86
+ function json(res, code, data) {
87
+ const body = JSON.stringify(data);
88
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
89
+ res.end(body);
90
+ }
91
+
92
+ function readBody(req) {
93
+ return new Promise((resolve) => {
94
+ let buf = '';
95
+ req.on('data', (c) => { buf += c; if (buf.length > 5 * 1024 * 1024) req.destroy(); });
96
+ req.on('end', () => {
97
+ try { resolve(JSON.parse(buf || '{}')); } catch { resolve({}); }
98
+ });
99
+ });
100
+ }
101
+
102
+ function serveStatic(res, filePath) {
103
+ fs.readFile(filePath, (err, data) => {
104
+ if (err) {
105
+ res.writeHead(404);
106
+ res.end('Not Found');
107
+ return;
108
+ }
109
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' });
110
+ res.end(data);
111
+ });
112
+ }
113
+
114
+ // SSE helper
115
+ // sseConns:活跃 SSE 连接计数(聊天/任务/终端),页面关闭后归零,供自动退出判断
116
+ // 注意:body 被 readBody 消费后 req 的 'close' 在部分 Node 版本不再触发,
117
+ // 因此以 res 的 'close'(响应结束或连接断开都触发)为准,双保险 + 幂等
118
+ let sseConns = 0;
119
+ function sse(req, res) {
120
+ res.writeHead(200, {
121
+ 'Content-Type': 'text/event-stream; charset=utf-8',
122
+ 'Cache-Control': 'no-cache',
123
+ Connection: 'keep-alive'
124
+ });
125
+ sseConns++;
126
+ const send = (obj) => {
127
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch { /* closed */ }
128
+ };
129
+ // 心跳防止代理断开
130
+ const hb = setInterval(() => {
131
+ try { res.write(': hb\n\n'); } catch { clearInterval(hb); }
132
+ }, 15000);
133
+ let closed = false;
134
+ const onClose = () => {
135
+ if (closed) return;
136
+ closed = true;
137
+ clearInterval(hb);
138
+ sseConns--;
139
+ };
140
+ req.on('close', onClose);
141
+ res.on('close', onClose);
142
+ return send;
143
+ }
144
+
145
+ // ---------- 页面全关自动退出(便携免维护体验) ----------
146
+ // 前端页面每 25s 心跳一次;所有页面关闭且无 SSE 连接、无审批等待、无编排执行后,
147
+ // 空闲约 1 分钟自动退出。例外:存在待触发的定时任务时保持存活(睡到任务触发后
148
+ // 再给 10 分钟执行宽限,全部触发完才允许退出),保证无人值守定时任务可靠执行。
149
+ // .env AGENTS_CHAT_AUTOSTOP=0 可完全关闭
150
+ const AUTOSTOP = process.env.AGENTS_CHAT_AUTOSTOP !== '0';
151
+ const AUTOSTOP_IDLE_MS = Number(process.env.AGENTS_CHAT_AUTOSTOP_IDLE_MS) > 0
152
+ ? Number(process.env.AGENTS_CHAT_AUTOSTOP_IDLE_MS)
153
+ : 50 * 1000;
154
+ const SCHED_KEEPALIVE_GRACE_MS = 10 * 60 * 1000; // 定时任务触发后的执行宽限
155
+ let lastClientSeen = 0;
156
+ let everSeenClient = false;
157
+ function touchClient() {
158
+ lastClientSeen = Date.now();
159
+ everSeenClient = true;
160
+ }
161
+ function startAutoStop() {
162
+ if (!AUTOSTOP) return;
163
+ const timer = setInterval(() => {
164
+ if (!everSeenClient) return;
165
+ if (sseConns > 0 || pendingApprovals.size > 0 || runLocks.chat || runLocks.tasks || runLocks.solo) return;
166
+ if (Date.now() - lastClientSeen < AUTOSTOP_IDLE_MS) return;
167
+ if (store.getSchedEnabled()) {
168
+ // 最近的待触发定时任务:未触发 → 推迟退出到触发点;已触发 → 推迟到执行宽限后
169
+ const pendingSched = store.getTasks()
170
+ .filter(t => t.kind === 'scheduled' && t.status === 'pending' && t.scheduledAt)
171
+ .map(t => t.scheduledAt)
172
+ .sort((a, b) => a - b)[0];
173
+ if (pendingSched !== undefined) {
174
+ // 未触发:睡到触发点;已到点(等待扫描/刚触发):从现在起给执行宽限
175
+ const deadline = Math.max(pendingSched, Date.now()) + SCHED_KEEPALIVE_GRACE_MS;
176
+ if (Date.now() < deadline) return; // 保活:等定时任务触发/执行完成
177
+ }
178
+ }
179
+ console.log('所有页面已关闭且空闲约 1 分钟,服务自动退出(重开 start 即可)');
180
+ shutdown(0);
181
+ }, 10000);
182
+ timer.unref();
183
+ }
184
+
185
+ // ---------- 单聊模式:Web 终端(对接 OpenCode 等内核 CLI) ----------
186
+ // 零依赖实现:常驻 shell 进程 + SSE 下行输出 + POST 上行输入;
187
+ // 交互式 TUI(如直接运行 opencode)需要伪终端,本版先支持命令式使用(opencode run 等)
188
+ const termClients = new Set(); // 活跃终端 SSE 的 send 函数
189
+ let termProc = null;
190
+ let termBuf = []; // 输出回放缓冲(重连/刷新后补回)
191
+ const TERM_BUF_MAX = 600;
192
+ function termCwd() {
193
+ const gc = String(store.getConfig().globalCwd || '').trim();
194
+ if (gc) { try { if (fs.existsSync(gc)) return gc; } catch { /* ignore */ } }
195
+ return store.DATA_DIR;
196
+ }
197
+ // 终端输出清洗:去掉 ANSI 控制序列,避免网页端乱码
198
+ function termClean(s) {
199
+ return String(s)
200
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
201
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '')
202
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
203
+ }
204
+ function termPush(raw) {
205
+ const data = termClean(raw);
206
+ if (!data) return;
207
+ termBuf.push(data);
208
+ if (termBuf.length > TERM_BUF_MAX) termBuf = termBuf.slice(termBuf.length - TERM_BUF_MAX);
209
+ for (const send of termClients) send({ type: 'data', data });
210
+ }
211
+ function termShell() {
212
+ if (termProc) return termProc;
213
+ const isWin = process.platform === 'win32';
214
+ const cmd = isWin ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/bash');
215
+ try {
216
+ termProc = spawn(cmd, isWin ? ['/Q'] : [], { cwd: termCwd(), env: process.env });
217
+ } catch (err) {
218
+ termPush(`\n[无法启动 shell:${err && err.message || err}]\n`);
219
+ return null;
220
+ }
221
+ termProc.stdout.on('data', b => termPush(b.toString('utf8')));
222
+ termProc.stderr.on('data', b => termPush(b.toString('utf8')));
223
+ termProc.on('exit', (code) => {
224
+ termPush(`\n[shell 已退出(code=${code}),下次输入时自动重启]\n`);
225
+ termProc = null;
226
+ });
227
+ return termProc;
228
+ }
229
+
230
+ // ---------- @ 点名解析(支持中文名称) ----------
231
+ const MENTION_RE = /@([^\s@,。,.;;!!??、()()【】[\]"'「」]+)/g;
232
+
233
+ function resolveMentions(message, agents) {
234
+ const out = [];
235
+ const seen = new Set();
236
+ let m;
237
+ while ((m = MENTION_RE.exec(message)) !== null) {
238
+ const tok = m[1];
239
+ const ag = agents.find(a => a.id === tok) || agents.find(a => a.name === tok);
240
+ if (ag && !seen.has(ag.id)) { seen.add(ag.id); out.push(ag); }
241
+ }
242
+ return out;
243
+ }
244
+
245
+ function stripMentions(message) {
246
+ return message.replace(MENTION_RE, '').replace(/\s+/g, ' ').trim();
247
+ }
248
+
249
+ // 会话历史背景(仅保留用户消息与智能体正式产出;规划卡片、验收意见等调度过程不进入上下文)
250
+ // 主会话:只取当前 epoch(新会话开启后旧消息不再传入)
251
+ function buildHistoryText(taskId) {
252
+ let list = store.getMessages(taskId).filter(m =>
253
+ m.role === 'user' || (m.role === 'assistant' && (m.phase === 'work' || m.phase === 'report'))
254
+ );
255
+ if (!taskId) {
256
+ const epoch = Number(store.getConfig().mainEpoch) || 0;
257
+ list = list.filter(m => (Number(m.epoch) || 0) === epoch);
258
+ }
259
+ const recent = list.slice(-14);
260
+ if (!recent.length) return '';
261
+ return recent.map(m => `${m.role === 'user' ? '用户' : (m.agentName || '智能体')}:${String(m.content).slice(0, 1000)}`).join('\n');
262
+ }
263
+
264
+ const server = http.createServer(async (req, res) => {
265
+ const parsed = url.parse(req.url, true);
266
+ const p = parsed.pathname;
267
+ // 页面存活感知:任何请求都视为「有客户端在看」,供自动退出判断
268
+ touchClient();
269
+
270
+ // ---------- 静态 ----------
271
+ if (req.method === 'GET' && (p === '/' || p === '/index.html')) {
272
+ serveStatic(res, path.join(PUBLIC_DIR, 'index.html'));
273
+ return;
274
+ }
275
+
276
+ if (req.method === 'GET' && (p === '/cards' || p === '/cards.html')) {
277
+ serveStatic(res, path.join(PUBLIC_DIR, 'cards.html'));
278
+ return;
279
+ }
280
+ if (req.method === 'GET' && p.startsWith('/static/')) {
281
+ const safe = path.normalize(p.slice('/static/'.length)).replace(/^(\.\.[/\\])+/, '');
282
+ serveStatic(res, path.join(PUBLIC_DIR, safe));
283
+ return;
284
+ }
285
+
286
+ // ---------- API ----------
287
+ if (p === '/api/health' && req.method === 'GET') {
288
+ const { resolveRunner, detectKernels } = require('./lib/agent');
289
+ const runner = resolveRunner();
290
+ const kernels = detectKernels();
291
+ json(res, 200, {
292
+ success: true,
293
+ version: APP_VERSION,
294
+ runner: runner.kind,
295
+ kernelLabel: runner.kernel ? runner.kernel.label : '',
296
+ kernelCmd: runner.cmd || '',
297
+ kernels: Object.values(kernels).map(k => ({ id: k.id, label: k.label, ok: k.ok, cmd: k.cmd })),
298
+ configKernel: String(store.getConfig().kernel || 'auto'),
299
+ model: process.env.AGENTS_CHAT_MODEL || '',
300
+ autoApprove: process.env.AGENTS_CHAT_AUTO_APPROVE !== '0',
301
+ port: PORT
302
+ });
303
+ return;
304
+ }
305
+
306
+ if (p === '/api/stop' && req.method === 'POST') {
307
+ // 手动停止:kill 对应作用域的全部子进程;编排循环检测令牌后跳过剩余工作
308
+ const body = await readBody(req);
309
+ const scope = ['tasks', 'solo', 'chat'].includes(body.scope) ? body.scope : 'chat';
310
+ if (scope === 'tasks') stopTokens.tasks++;
311
+ const n = stopScope(scope);
312
+ json(res, 200, { success: true, scope, stopped: n });
313
+ return;
314
+ }
315
+
316
+ if (p === '/api/approvals' && req.method === 'GET') {
317
+ // 当前等待中的审批(前端刷新/断线重连后恢复审批卡片)
318
+ json(res, 200, {
319
+ success: true,
320
+ approvals: [...pendingApprovals.entries()].map(([id, a]) => ({ id, kind: a.kind, label: a.label, taskId: a.taskId, createdAt: a.createdAt }))
321
+ });
322
+ return;
323
+ }
324
+
325
+ if (p === '/api/approval' && req.method === 'POST') {
326
+ // 审批裁决:approved=true 放行继续编排;false 终止编排
327
+ const body = await readBody(req);
328
+ const id = String(body.id || '');
329
+ const a = pendingApprovals.get(id);
330
+ if (!a) { json(res, 404, { success: false, error: '审批不存在或已处理' }); return; }
331
+ finishApproval(id, !!body.approved, false);
332
+ json(res, 200, { success: true, id, approved: !!body.approved });
333
+ return;
334
+ }
335
+
336
+ if (p === '/api/flow/runs' && req.method === 'GET') {
337
+ // 最近编排列表(流转视图的 run 选择器)
338
+ json(res, 200, { success: true, runs: store.listFlowRuns(40) });
339
+ return;
340
+ }
341
+
342
+ if (p === '/api/flow' && req.method === 'GET') {
343
+ const runId = String(parsed.query.run || '').slice(0, 60);
344
+ if (!runId) { json(res, 400, { success: false, error: '缺少 run 参数' }); return; }
345
+ json(res, 200, { success: true, run: runId, events: store.getFlow(runId) });
346
+ return;
347
+ }
348
+
349
+ if (p === '/api/flow/rerun' && req.method === 'POST') {
350
+ // 断点重跑:从历史编排的某个阶段重新执行(前置阶段产出复用)
351
+ // 走 chat 作用域锁(与聊天互斥);消息写回原会话;SSE 实时回传全过程
352
+ const body = await readBody(req);
353
+ const runId = String(body.run || '').slice(0, 60);
354
+ const fromStage = Number(body.fromStage) || 1;
355
+ const events = runId ? store.getFlow(runId) : [];
356
+ if (!events.length) { json(res, 404, { success: false, error: '编排记录不存在' }); return; }
357
+ let prepared;
358
+ try {
359
+ const agentsAll = store.getAgents();
360
+ prepared = prepareRerun(events, fromStage, agentsAll.filter(a => a.id !== 'butler'));
361
+ } catch (err) {
362
+ json(res, 400, { success: false, error: err && err.message || String(err) });
363
+ return;
364
+ }
365
+ if (runLocks.chat) {
366
+ json(res, 409, { success: false, error: '当前有编排进行中,请等待完成或先停止' });
367
+ return;
368
+ }
369
+ runLocks.chat = true;
370
+ const myToken = stopTokens.chat;
371
+ const agentsAll = store.getAgents();
372
+ const butler = agentsAll.find(a => a.id === 'butler');
373
+ const subAgents = agentsAll.filter(a => a.id !== 'butler');
374
+ const taskId = prepared.taskId || '';
375
+ const opts = {
376
+ taskId, history: buildHistoryText(taskId), scope: 'chat',
377
+ isStopped: () => stopTokens.chat !== myToken,
378
+ approval: approvalSetting(), requestApproval: makeRequestApproval(),
379
+ resume: { phases: prepared.phases, priorResults: prepared.priorResults, fromStage: prepared.fromStage, baseRun: runId }
380
+ };
381
+ const send = sse(req, res);
382
+ const persist = (m) => store.addMessage({ ...m, taskId, timestamp: new Date().toISOString() });
383
+ try {
384
+ send({ type: 'notice', content: `↻ 断点重跑:从第 ${prepared.fromStage} 阶段开始(前序 ${prepared.priorResults.length} 份产出复用)${prepared.dropped.length ? `;注意:智能体 ${prepared.dropped.join('、')} 已不存在,相关步骤被跳过` : ''}`, taskId });
385
+ await runButler(butler, subAgents, prepared.message, opts, send, persist);
386
+ } catch (err) {
387
+ console.error('[flow/rerun] 编排异常:', err && (err.stack || err));
388
+ send({ type: 'error', content: `重跑异常:${err && err.message || err}` });
389
+ } finally {
390
+ runLocks.chat = false;
391
+ try { res.end(); } catch { /* closed */ }
392
+ }
393
+ return;
394
+ }
395
+
396
+ if (p === '/api/agents' && req.method === 'GET') {
397
+ json(res, 200, { success: true, agents: store.getAgents(), butlerId: store.BUTLER.id, globalCwd: store.getConfig().globalCwd || '', kernel: String(store.getConfig().kernel || 'auto'), approval: approvalSetting() });
398
+ return;
399
+ }
400
+
401
+ if (p === '/api/teams' && req.method === 'GET') {
402
+ // 团队仓库:经典智能体团队预设(前端展示与应用)
403
+ json(res, 200, { success: true, teams: store.getTeamPresets() });
404
+ return;
405
+ }
406
+
407
+ if (p === '/api/sched' && req.method === 'GET') {
408
+ // 定时任务调度总开关状态(侧栏「启动/关闭定时任务」按钮)
409
+ json(res, 200, { success: true, enabled: store.getSchedEnabled() });
410
+ return;
411
+ }
412
+ if (p === '/api/sched/toggle' && req.method === 'POST') {
413
+ const body = await readBody(req);
414
+ const enabled = !!body.enabled;
415
+ store.setSchedEnabled(enabled);
416
+ json(res, 200, { success: true, enabled });
417
+ return;
418
+ }
419
+
420
+ if (p === '/api/memory' && req.method === 'GET') {
421
+ // 管家长期记忆查看(含各仓字符用量)
422
+ const data = store.getMemoryData();
423
+ json(res, 200, {
424
+ success: true,
425
+ enabled: memoryMod.memoryEnabled(),
426
+ memory: data.memory,
427
+ user: data.user,
428
+ usage: { memory: memoryMod.usage('memory'), user: memoryMod.usage('user') }
429
+ });
430
+ return;
431
+ }
432
+ if (p === '/api/memory' && req.method === 'POST') {
433
+ // 手动编辑管家记忆(配置页;整体覆盖,逐条字符串)
434
+ const body = await readBody(req);
435
+ const data = {
436
+ memory: (Array.isArray(body.memory) ? body.memory : []).map(s => String(s).trim()).filter(Boolean).slice(0, 50),
437
+ user: (Array.isArray(body.user) ? body.user : []).map(s => String(s).trim()).filter(Boolean).slice(0, 50)
438
+ };
439
+ store.saveMemoryData(data);
440
+ json(res, 200, { success: true, usage: { memory: memoryMod.usage('memory'), user: memoryMod.usage('user') } });
441
+ return;
442
+ }
443
+
444
+ if (p === '/api/session/new' && req.method === 'POST') {
445
+ // 开启新会话:主会话历史不再纳入上下文(聊天记录仍保留显示)
446
+ const cfg = store.getConfig();
447
+ cfg.mainEpoch = (Number(cfg.mainEpoch) || 0) + 1;
448
+ store.saveConfig(cfg);
449
+ store.addMessage({
450
+ role: 'sys',
451
+ content: '── 已开启新会话:此前内容不再纳入上下文 ──',
452
+ timestamp: new Date().toISOString()
453
+ });
454
+ json(res, 200, { success: true, epoch: cfg.mainEpoch });
455
+ return;
456
+ }
457
+
458
+ if (p === '/api/agents' && req.method === 'POST') {
459
+ // 保存用户自定义子智能体(管家内置,不接受修改)+ 全局统一工作目录
460
+ const body = await readBody(req);
461
+ if (!Array.isArray(body.agents)) {
462
+ json(res, 400, { success: false, error: 'agents 必须是数组' });
463
+ return;
464
+ }
465
+ const clean = [];
466
+ const names = new Set(['管家', 'butler']); // 保留管家名称,避免 @ 点名歧义
467
+ const dirWarn = [];
468
+ const isDir = (d) => { try { return fs.existsSync(d) && fs.statSync(d).isDirectory(); } catch { return false; } };
469
+ for (const a of body.agents) {
470
+ if (!a || typeof a !== 'object' || String(a.id || '').trim() === 'butler') continue;
471
+ let name = String(a.name || '').replace(/\s+/g, '').slice(0, 20);
472
+ if (!name) name = `智能体${clean.length + 1}`;
473
+ let final = name;
474
+ let i = 2;
475
+ while (names.has(final)) final = `${name}${i++}`; // 名称唯一,保证 @ 点名无歧义
476
+ names.add(final);
477
+ clean.push({
478
+ id: String(a.id || '').replace(/[^\w-]/g, '') || `ag-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
479
+ name: final,
480
+ icon: String(a.icon || '').trim().slice(0, 8) || '',
481
+ desc: String(a.desc || '').slice(0, 100),
482
+ model: '', // 统一使用 OpenCode 默认模型配置,不接受自定义
483
+ systemPrompt: String(a.systemPrompt || '').slice(0, 8000), // 下发任务时附加在用户提示词前
484
+ behavior: String(a.behavior || 'echo')
485
+ });
486
+ }
487
+ // 全局统一工作目录:所有智能体共用一处读写文件
488
+ let globalCwd = String(body.globalCwd || '').trim().replace(/["']/g, '');
489
+ if (globalCwd && !isDir(globalCwd)) {
490
+ dirWarn.push(`统一工作目录不存在或不是文件夹:${globalCwd},已忽略(将使用默认目录)`);
491
+ globalCwd = '';
492
+ }
493
+ // 执行内核:'auto' 或注册表内的内核 id;选中未安装的内核给出警告(仍保存,运行时报错兜底)
494
+ const { KERNEL_DEFS, detectKernels } = require('./lib/agent');
495
+ let kernel = String(body.kernel || 'auto').trim() || 'auto';
496
+ const kernelDef = KERNEL_DEFS.find(k => k.id === kernel);
497
+ if (kernel !== 'auto' && !kernelDef) kernel = 'auto';
498
+ if (kernel !== 'auto' && !detectKernels()[kernel].ok) {
499
+ dirWarn.push(`已选择内核 ${kernelDef.label},但本机未检测到(${kernelDef.install}),保存后任务将无法执行`);
500
+ }
501
+ // 审批模式(协作关卡):off=关闭 / plan=方案后 / verify=交付前 / all=两者
502
+ const approvalIn = String(body.approval || '').toLowerCase();
503
+ const approval = ['off', 'plan', 'verify', 'all'].includes(approvalIn) ? approvalIn : undefined;
504
+ store.saveAgents(clean, globalCwd, kernel, approval);
505
+ json(res, 200, { success: true, agents: store.getAgents(), butlerId: store.BUTLER.id, globalCwd: store.getConfig().globalCwd || '', kernel: String(store.getConfig().kernel || 'auto'), approval: approvalSetting(), warnings: dirWarn });
506
+ return;
507
+ }
508
+
509
+ if (p === '/api/tasks' && req.method === 'GET') {
510
+ // 按 seq(拖拽可调)排序返回
511
+ json(res, 200, { success: true, tasks: store.getTasks() });
512
+ return;
513
+ }
514
+
515
+ if (p === '/api/tasks/clear' && req.method === 'POST') {
516
+ store.saveTasks([]);
517
+ json(res, 200, { success: true });
518
+ return;
519
+ }
520
+
521
+ if (p === '/api/maintenance/prune' && req.method === 'POST') {
522
+ // 一键清理超期历史数据(智能体配置页「数据维护」调用);days 默认 15
523
+ const body = await readBody(req).catch(() => ({}));
524
+ const days = Math.max(1, Number(body.days) || PRUNE_DAYS);
525
+ try {
526
+ const stat = store.pruneOldData(days);
527
+ console.log(`[maintenance] 手动清理完成(>${days} 天):`, JSON.stringify(stat));
528
+ json(res, 200, { success: true, days, stat });
529
+ } catch (e) {
530
+ json(res, 500, { success: false, error: String((e && e.message) || e) });
531
+ }
532
+ return;
533
+ }
534
+
535
+ if (p === '/api/tasks/import' && req.method === 'POST') {
536
+ // 从文本自动提取任务;mode: sequential=顺序任务(1. 编号)| scheduled=定时任务(行首定时时间)
537
+ // runner='solo' 时任务由单聊 OpenCode 直接执行(不经管家编排),侧栏在单聊模式展示
538
+ const body = await readBody(req);
539
+ if (!body.text || !String(body.text).trim()) {
540
+ json(res, 400, { success: false, error: 'text 不能为空' });
541
+ return;
542
+ }
543
+ const mode = body.mode === 'scheduled' ? 'scheduled' : 'sequential';
544
+ const runner = body.runner === 'solo' ? 'solo' : '';
545
+ const { added, warnings } = store.importTasks(body.text, mode, runner, body.model);
546
+ json(res, 200, { success: true, added, warnings, mode, runner, tasks: store.getTasks() });
547
+ return;
548
+ }
549
+
550
+ if (p === '/api/tasks/reorder' && req.method === 'POST') {
551
+ // 拖拽排序:按给定 id 顺序重编执行顺序
552
+ const body = await readBody(req);
553
+ if (!Array.isArray(body.ids)) {
554
+ json(res, 400, { success: false, error: 'ids 必须是数组' });
555
+ return;
556
+ }
557
+ store.reorderTasks(body.ids.map(String));
558
+ json(res, 200, { success: true, tasks: store.getTasks() });
559
+ return;
560
+ }
561
+
562
+ if (p === '/api/tasks/delete' && req.method === 'POST') {
563
+ // 删除任务及其会话消息
564
+ const body = await readBody(req);
565
+ if (!body.id) { json(res, 400, { success: false, error: 'id 不能为空' }); return; }
566
+ store.deleteTask(String(body.id));
567
+ json(res, 200, { success: true, tasks: store.getTasks() });
568
+ return;
569
+ }
570
+
571
+ if (p === '/api/tasks/run' && req.method === 'POST') {
572
+ // 顺序执行任务:scope='solo' 时由 OpenCode 单体逐个执行(每个任务独立一次性对话);
573
+ // 默认(群聊)每个任务 = 独立会话 + 一次完整管家调度
574
+ // 未指定 ids 时仅执行顺序待办(未到点的定时任务不在此列,由定时调度器负责)
575
+ if (runLocks.tasks) {
576
+ json(res, 409, { success: false, error: '已有一批任务正在执行,请等待完成或先停止' });
577
+ return;
578
+ }
579
+ runLocks.tasks = true;
580
+ const myToken = stopTokens.tasks; // 执行期间令牌变化 = 用户请求了停止
581
+ const body = await readBody(req);
582
+ const soloScope = body.scope === 'solo';
583
+ const all = store.getTasks().slice().sort((a, b) => a.createdAt - b.createdAt);
584
+ const selected = Array.isArray(body.taskIds) && body.taskIds.length > 0
585
+ ? all.filter(t => body.taskIds.includes(t.id))
586
+ : all.filter(t => (t.status === 'pending' || t.status === 'failed')
587
+ && !(t.kind === 'scheduled' && t.status === 'pending')
588
+ && (soloScope ? t.runner === 'solo' : t.runner !== 'solo'));
589
+ if (selected.length === 0) {
590
+ runLocks.tasks = false;
591
+ json(res, 400, { success: false, error: '没有可执行的任务' });
592
+ return;
593
+ }
594
+
595
+ const send = sse(req, res);
596
+ try {
597
+ if (soloScope) {
598
+ await executeSoloTaskBatch(selected, send, myToken);
599
+ } else {
600
+ await executeTaskBatch(selected, send, myToken);
601
+ }
602
+ } catch (err) {
603
+ console.error('[tasks/run] 编排异常:', err && (err.stack || err));
604
+ send({ type: 'error', content: `任务编排异常:${err && err.message || err}` });
605
+ } finally {
606
+ runLocks.tasks = false;
607
+ try { res.end(); } catch { /* closed */ }
608
+ }
609
+ return;
610
+ }
611
+
612
+ if (p === '/api/chat' && req.method === 'POST') {
613
+ // 聊天:@点名 → 点名智能体串行流水线;未点名 → 管家调度
614
+ // taskId 非空时为任务会话内聊天(携带该会话历史背景)
615
+ // 任务批量执行中仍可聊天(互不阻塞),但同时只允许一个聊天编排
616
+ if (runLocks.chat) {
617
+ json(res, 409, { success: false, error: '上一条消息还在处理中,请等待完成或点「停止」' });
618
+ return;
619
+ }
620
+ runLocks.chat = true;
621
+ const myToken = stopTokens.chat;
622
+ const body = await readBody(req);
623
+ const message = String(body.message || '').trim();
624
+ const taskId = String(body.taskId || '');
625
+ if (!message) {
626
+ runLocks.chat = false;
627
+ json(res, 400, { success: false, error: 'message 不能为空' });
628
+ return;
629
+ }
630
+ const agents = store.getAgents();
631
+ if (agents.length === 0) {
632
+ runLocks.chat = false;
633
+ json(res, 400, { success: false, error: '没有可用的 Agent,请先配置' });
634
+ return;
635
+ }
636
+ const butler = agents.find(a => a.id === 'butler');
637
+ const subAgents = agents.filter(a => a.id !== 'butler');
638
+ if (!butler) {
639
+ runLocks.chat = false;
640
+ json(res, 400, { success: false, error: '管家智能体缺失,配置异常' });
641
+ return;
642
+ }
643
+
644
+ const mentionAgents = resolveMentions(message, agents);
645
+ const clean = stripMentions(message) || message;
646
+
647
+ // 先构建历史背景(此时还不含当前消息),再落库当前用户消息
648
+ const opts = { taskId, history: buildHistoryText(taskId), scope: 'chat', isStopped: () => stopTokens.chat !== myToken, approval: approvalSetting(), requestApproval: makeRequestApproval() };
649
+ store.addMessage({ role: 'user', content: message, taskId, timestamp: new Date().toISOString() });
650
+
651
+ const send = sse(req, res);
652
+ const persist = (m) => store.addMessage({ ...m, taskId, timestamp: new Date().toISOString() });
653
+ try {
654
+ let run;
655
+ if (body.mode === 'roundtable') {
656
+ // 圆桌讨论:@点名者参与(管家作为主持人不算发言席),未点名则全体子智能体参与
657
+ const speakers = mentionAgents.filter(a => a.id !== butler.id);
658
+ const participants = speakers.length ? speakers : subAgents;
659
+ run = runRoundtable(butler, participants, clean, opts, send, persist);
660
+ } else {
661
+ run = mentionAgents.length > 0
662
+ ? runMentioned(mentionAgents, clean, opts, send, persist)
663
+ : runButler(butler, subAgents, clean, opts, send, persist);
664
+ }
665
+ await run;
666
+ } catch (err) {
667
+ console.error('[chat] 编排异常:', err && (err.stack || err));
668
+ send({ type: 'error', content: `编排异常:${err && err.message || err}` });
669
+ } finally {
670
+ runLocks.chat = false;
671
+ try { res.end(); } catch { /* closed */ }
672
+ }
673
+ return;
674
+ }
675
+
676
+ if (p === '/api/messages' && req.method === 'GET') {
677
+ // 不带参数返回全部(前端按 taskId 分组成会话);?taskId=xxx 仅返回该任务会话
678
+ const q = parsed.query || {};
679
+ const msgs = q.taskId !== undefined ? store.getMessages(String(q.taskId || '')) : store.getMessages();
680
+ json(res, 200, { success: true, messages: msgs });
681
+ return;
682
+ }
683
+
684
+ if (p === '/api/messages/clear' && req.method === 'POST') {
685
+ store.clearMessages();
686
+ json(res, 200, { success: true });
687
+ return;
688
+ }
689
+
690
+ // ---------- 单聊模式:终端 API ----------
691
+ if (p === '/api/term/stream' && req.method === 'GET') {
692
+ const send = sse(req, res);
693
+ termClients.add(send);
694
+ res.on('close', () => termClients.delete(send));
695
+ send({ type: 'init', cwd: termCwd(), platform: process.platform });
696
+ termPush(''); // no-op 占位,确保连接建立
697
+ if (termBuf.length) send({ type: 'data', data: termBuf.join('') });
698
+ return;
699
+ }
700
+
701
+ if (p === '/api/term/input' && req.method === 'POST') {
702
+ const body = await readBody(req);
703
+ const data = String(body.data || '');
704
+ if (!data.trim()) { json(res, 200, { success: true }); return; }
705
+ const proc = termShell();
706
+ if (proc) {
707
+ // 输入回显由前端负责(提示符 + 命令),这里只写 stdin
708
+ try { proc.stdin.write(data + '\n'); } catch { termShell() && termProc.stdin.write(data + '\n'); }
709
+ }
710
+ json(res, 200, { success: true });
711
+ return;
712
+ }
713
+
714
+ if (p === '/api/term/signal' && req.method === 'POST') {
715
+ const body = await readBody(req);
716
+ if (termProc) {
717
+ try { termProc.kill(body.signal === 'kill' ? 'SIGKILL' : 'SIGINT'); } catch { /* ignore */ }
718
+ }
719
+ json(res, 200, { success: true });
720
+ return;
721
+ }
722
+
723
+ if (p === '/api/term/clear' && req.method === 'POST') {
724
+ termBuf = [];
725
+ for (const send of termClients) send({ type: 'clear' });
726
+ json(res, 200, { success: true });
727
+ return;
728
+ }
729
+
730
+ // ---------- 单聊工作台(OpenCode):模型与会话管理 ----------
731
+ if (p === '/api/oc/models' && req.method === 'GET') {
732
+ const { resolveRunner } = require('./lib/agent');
733
+ const runner = resolveRunner();
734
+ if (runner.kind === 'demo') {
735
+ json(res, 200, { success: true, demo: true, models: oc.demoModels() });
736
+ return;
737
+ }
738
+ if (runner.kind !== 'opencode') {
739
+ // 其他内核无模型列表命令:返回空 + 内核标注(前端提示手动填写或用默认)
740
+ json(res, 200, { success: true, models: [], kernel: runner.kernel ? runner.kernel.label : '', noResume: true });
741
+ return;
742
+ }
743
+ const models = oc.listOcModels(runner, parsed.query.refresh === '1');
744
+ json(res, 200, { success: true, models });
745
+ return;
746
+ }
747
+
748
+ if (p === '/api/oc/sessions' && req.method === 'GET') {
749
+ json(res, 200, { success: true, sessions: store.getOcSessions() });
750
+ return;
751
+ }
752
+
753
+ if (p === '/api/oc/sessions' && req.method === 'POST') {
754
+ // 新建单聊会话
755
+ const body = await readBody(req);
756
+ const id = `oc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
757
+ const rec = store.upsertOcSession(id, { title: String(body.title || '').slice(0, 60) });
758
+ json(res, 200, { success: true, session: rec });
759
+ return;
760
+ }
761
+
762
+ if (p === '/api/oc/sessions/rename' && req.method === 'POST') {
763
+ const body = await readBody(req);
764
+ const id = String(body.id || '');
765
+ if (!store.getOcSession(id)) { json(res, 404, { success: false, error: '会话不存在' }); return; }
766
+ const rec = store.upsertOcSession(id, { title: String(body.title || '').trim().slice(0, 60) });
767
+ json(res, 200, { success: true, session: rec });
768
+ return;
769
+ }
770
+
771
+ if (p === '/api/oc/sessions/delete' && req.method === 'POST') {
772
+ const body = await readBody(req);
773
+ const id = String(body.id || '');
774
+ if (!store.getOcSession(id)) { json(res, 404, { success: false, error: '会话不存在' }); return; }
775
+ store.deleteOcSession(id);
776
+ json(res, 200, { success: true, sessions: store.getOcSessions() });
777
+ return;
778
+ }
779
+
780
+ if (p === '/api/oc/chat' && req.method === 'POST') {
781
+ // 单聊对话:opencode run(-s 续聊),SSE 转发快照事件
782
+ if (runLocks.solo) {
783
+ json(res, 409, { success: false, error: '上一条消息还在处理中,请等待完成或点「停止」' });
784
+ return;
785
+ }
786
+ const body = await readBody(req);
787
+ const sessionId = String(body.sessionId || '');
788
+ const message = String(body.message || '').trim();
789
+ const model = String(body.model || '');
790
+ const rec = store.getOcSession(sessionId);
791
+ if (!rec) { json(res, 404, { success: false, error: '会话不存在,请先新建' }); return; }
792
+ if (!message) { json(res, 400, { success: false, error: 'message 不能为空' }); return; }
793
+
794
+ const { resolveRunner, missingHint } = require('./lib/agent');
795
+ const runner = resolveRunner();
796
+ if (runner.kind === 'missing') {
797
+ json(res, 400, { success: false, error: missingHint(runner) });
798
+ return;
799
+ }
800
+
801
+ runLocks.solo = true;
802
+ store.addMessage({ role: 'user', content: message, taskId: sessionId, timestamp: new Date().toISOString() });
803
+ // 标题留空时取首条消息;模型选择随会话记忆
804
+ const patch = {};
805
+ if (!rec.title) patch.title = message.slice(0, 24);
806
+ if (model) patch.model = model;
807
+ store.upsertOcSession(sessionId, patch);
808
+
809
+ const send = sse(req, res);
810
+ const texts = new Map(); // partId -> 最新快照
811
+ const order = []; // 正文 part 出现顺序(多段拼接用)
812
+ send({ type: 'start', sessionId, model });
813
+ try {
814
+ await new Promise((resolve) => {
815
+ const kind = runner.kind === 'demo' ? 'demo' : (runner.kind === 'opencode' ? 'opencode' : 'fallback');
816
+ oc.chatSolo(kind, runner, { prompt: message, model, ocSessionId: rec.ocSessionId || '' }, (ev) => {
817
+ if (ev.type === 'session') {
818
+ // 首个 sessionID 回填:后续轮次经 -s 在同一 opencode 会话续聊
819
+ store.upsertOcSession(sessionId, { ocSessionId: ev.ocSessionId });
820
+ send({ type: 'session', sessionId, ocSessionId: ev.ocSessionId });
821
+ } else if (ev.type === 'text') {
822
+ if (!texts.has(ev.partId)) order.push(ev.partId);
823
+ texts.set(ev.partId, ev.text);
824
+ send({ type: 'text', sessionId, partId: ev.partId, text: ev.text });
825
+ } else if (ev.type === 'reasoning') {
826
+ send({ type: 'reasoning', sessionId, partId: ev.partId, text: ev.text });
827
+ } else if (ev.type === 'tool') {
828
+ send({ type: 'tool', sessionId, name: ev.name, summary: ev.summary });
829
+ } else if (ev.type === 'done') {
830
+ send({ type: 'done', sessionId, error: ev.error || undefined, noResume: !!ev.noResume });
831
+ resolve();
832
+ }
833
+ });
834
+ });
835
+ // 最终正文快照落库(reasoning/tool 过程信息不持久化)
836
+ const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
837
+ if (finalText) {
838
+ store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'OpenCode', phase: 'work', taskId: sessionId, content: finalText, timestamp: new Date().toISOString() });
839
+ }
840
+ store.upsertOcSession(sessionId, {}); // 刷新 updatedAt(侧栏排序)
841
+ } catch (err) {
842
+ console.error('[oc/chat] 单聊异常:', err && (err.stack || err));
843
+ send({ type: 'error', content: `单聊异常:${err && err.message || err}` });
844
+ send({ type: 'done', sessionId, error: '内部异常' });
845
+ } finally {
846
+ runLocks.solo = false;
847
+ try { res.end(); } catch { /* closed */ }
848
+ }
849
+ return;
850
+ }
851
+
852
+ // ---------- Markdown 文件预览(只读,限文本扩展名与大小;本机任意路径均可) ----------
853
+ if (p === '/api/file' && req.method === 'GET') {
854
+ const fp = path.resolve(String(parsed.query.path || ''));
855
+ if (!/\.(md|markdown|txt|json|log|csv|js|mjs|ts|html|htm|css|py|sh|yml|yaml|xml)$/i.test(fp)) {
856
+ json(res, 403, { success: false, error: '仅支持文本类文件预览' });
857
+ return;
858
+ }
859
+ fs.stat(fp, (err, st) => {
860
+ if (err || !st.isFile()) { json(res, 404, { success: false, error: '文件不存在' }); return; }
861
+ if (st.size > 2 * 1024 * 1024) { json(res, 413, { success: false, error: '文件超过 2MB,不支持网页预览' }); return; }
862
+ fs.readFile(fp, 'utf8', (err2, data) => {
863
+ if (err2) { json(res, 500, { success: false, error: '读取失败' }); return; }
864
+ json(res, 200, { success: true, path: fp, name: path.basename(fp), size: st.size, content: data });
865
+ });
866
+ });
867
+ return;
868
+ }
869
+
870
+ // ---------- 历史管理:一键清空全部会话 / 导出 sessions.md ----------
871
+ if (p === '/api/history/clear' && req.method === 'POST') {
872
+ const msgCount = store.getMessages().length;
873
+ let outDirs = 0;
874
+ store.clearMessages();
875
+ // 单聊会话记录一并清空(消息已清,保留空会话列表无意义)
876
+ store.saveOcSessions([]);
877
+ // 会话产出目录(BOARD.md、过程存档等)一并清理
878
+ const outRoot = path.join(store.DATA_DIR, 'outputs');
879
+ try {
880
+ for (const d of fs.readdirSync(outRoot)) {
881
+ const full = path.join(outRoot, d);
882
+ try { if (fs.statSync(full).isDirectory()) { fs.rmSync(full, { recursive: true, force: true }); outDirs++; } } catch { /* ignore */ }
883
+ }
884
+ } catch { /* 目录不存在 */ }
885
+ // 流转日志(历史编排记录)同步清空
886
+ try { fs.writeFileSync(path.join(store.DATA_DIR, 'flow.jsonl'), ''); } catch { /* ignore */ }
887
+ json(res, 200, { success: true, messages: msgCount, outputDirs: outDirs });
888
+ return;
889
+ }
890
+
891
+ if (p === '/api/history/export' && req.method === 'GET') {
892
+ const msgs = store.getMessages();
893
+ const tasksAll = store.getTasks();
894
+ const ocAll = store.getOcSessions();
895
+ const fmtTs = (t) => { const d = new Date(t); const p2 = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`; };
896
+ const roleOf = (m) => m.role === 'user' ? '👤 用户' : (m.agentName || '智能体') + (m.phase ? `(${m.phase})` : '');
897
+ const lines = [
898
+ '# Agents Chat 会话导出', '',
899
+ `- 导出时间:${fmtTs(Date.now())}`,
900
+ `- 会话数:${1 + tasksAll.filter(t => store.getMessages(t.id).length > 0).length + ocAll.filter(s => store.getMessages(s.id).length > 0).length}(主会话 + 任务会话 + 单聊会话)`,
901
+ `- 消息总数:${msgs.length}`, ''
902
+ ];
903
+ const main = msgs.filter(m => !m.taskId);
904
+ lines.push('---', '', '## 主会话', '');
905
+ for (const m of main) {
906
+ lines.push(`### ${fmtTs(m.timestamp)} · ${roleOf(m)}`, '');
907
+ lines.push(String(m.content || '').trim() || '(无内容)');
908
+ if (m.outputPath) lines.push('', `> 过程存档:${m.outputPath}`);
909
+ lines.push('');
910
+ }
911
+ for (const t of tasksAll.sort((a, b) => (a.scheduledAt || a.createdAt) - (b.scheduledAt || b.createdAt))) {
912
+ const arr = store.getMessages(t.id);
913
+ if (!arr.length) continue;
914
+ lines.push('---', '', `## 任务:${t.title}`, '',
915
+ `- 状态:${({ pending: '待执行', running: '执行中', done: '已完成', failed: '失败' })[t.status] || t.status}`,
916
+ `- 类型:${t.kind === 'scheduled' ? '定时任务' : '顺序任务'}${t.runner === 'solo' ? '(单聊 OpenCode 执行)' : ''}`, '');
917
+ for (const m of arr) {
918
+ lines.push(`### ${fmtTs(m.timestamp)} · ${roleOf(m)}`, '');
919
+ lines.push(String(m.content || '').trim() || '(无内容)');
920
+ if (m.outputPath) lines.push('', `> 过程存档:${m.outputPath}`);
921
+ lines.push('');
922
+ }
923
+ }
924
+ for (const s of ocAll) {
925
+ const arr = store.getMessages(s.id);
926
+ if (!arr.length) continue;
927
+ lines.push('---', '', `## 单聊会话:${s.title || s.id}`, '',
928
+ `- 模型:${s.model || '默认'}`, '');
929
+ for (const m of arr) {
930
+ lines.push(`### ${fmtTs(m.timestamp)} · ${roleOf(m)}`, '');
931
+ lines.push(String(m.content || '').trim() || '(无内容)');
932
+ lines.push('');
933
+ }
934
+ }
935
+ res.writeHead(200, {
936
+ 'Content-Type': 'text/markdown; charset=utf-8',
937
+ 'Content-Disposition': 'attachment; filename="sessions.md"'
938
+ });
939
+ res.end(lines.join('\n'));
940
+ return;
941
+ }
942
+
943
+ // ---------- 事项管控(卡牌)API ----------
944
+ if (p === '/api/cards' && req.method === 'GET') {
945
+ const cards = CardStore.list();
946
+ // 附带每个卡牌当前过程消息条数(供 UI 角标展示)
947
+ const counts = {};
948
+ try {
949
+ for (const m of store.getMessages()) if (m.taskId) counts[m.taskId] = (counts[m.taskId] || 0) + 1;
950
+ } catch { /* ignore */ }
951
+ json(res, 200, { success: true, cards, running: cardRunner.isRunning(), maxParallel: MAX_PARALLEL, msgCounts: counts, config: CardStore.getConfig() });
952
+ return;
953
+ }
954
+
955
+ // 拖拽重排:按给定 id 顺序重编 order
956
+ if (p === '/api/cards/reorder' && req.method === 'POST') {
957
+ const body = await readBody(req);
958
+ if (!Array.isArray(body.ids)) { json(res, 400, { success: false, error: 'ids 必须是数组' }); return; }
959
+ CardStore.reorder(body.ids.map(String));
960
+ json(res, 200, { success: true, cards: CardStore.list() });
961
+ return;
962
+ }
963
+
964
+ // 工作区等配置(可选;指定后 Agent 在其目录读写相关文件)
965
+ if (p === '/api/cards/config' && req.method === 'GET') {
966
+ json(res, 200, { success: true, config: CardStore.getConfig() });
967
+ return;
968
+ }
969
+ if (p === '/api/cards/config' && req.method === 'POST') {
970
+ const body = await readBody(req);
971
+ const cfg = CardStore.setConfig({ workspace: String(body.workspace || '').trim().slice(0, 500) });
972
+ json(res, 200, { success: true, config: cfg });
973
+ return;
974
+ }
975
+
976
+ if (p === '/api/cards' && req.method === 'POST') {
977
+ const body = await readBody(req);
978
+ if (!String(body.title || '').trim() && !String(body.content || '').trim()) {
979
+ json(res, 400, { success: false, error: '标题或内容至少填一项' });
980
+ return;
981
+ }
982
+ const card = CardStore.add({
983
+ title: String(body.title || '').trim() || String(body.content || '').slice(0, 40),
984
+ content: String(body.content || ''),
985
+ priority: Number(body.priority) || 999,
986
+ mode: body.mode,
987
+ chainId: body.chainId,
988
+ dependsOn: body.dependsOn,
989
+ model: body.model
990
+ });
991
+ json(res, 200, { success: true, card });
992
+ return;
993
+ }
994
+
995
+ if (p === '/api/cards/clear' && req.method === 'POST') {
996
+ const all = CardStore.list();
997
+ for (const c of all) CardStore.remove(c.id);
998
+ json(res, 200, { success: true });
999
+ return;
1000
+ }
1001
+
1002
+ // 垃圾桶:已删除任务的快照(默认保留 30 天)
1003
+ if (p === '/api/cards/trash' && req.method === 'GET') {
1004
+ json(res, 200, { success: true, trash: CardStore.getTrash() });
1005
+ return;
1006
+ }
1007
+ if (p === '/api/cards/trash/empty' && req.method === 'POST') {
1008
+ const n = CardStore.emptyTrash();
1009
+ json(res, 200, { success: true, cleared: n });
1010
+ return;
1011
+ }
1012
+ // 从垃圾桶还原任务
1013
+ const mTrashRestore = p.match(/^\/api\/cards\/trash\/restore\/([^/]+)$/);
1014
+ if (mTrashRestore && req.method === 'POST') {
1015
+ const card = CardStore.restoreFromTrash(decodeURIComponent(mTrashRestore[1]));
1016
+ json(res, card ? 200 : 404, { success: !!card, card });
1017
+ return;
1018
+ }
1019
+
1020
+ // 当前运行的进程(PID + 是否工作中)
1021
+ if (p === '/api/cards/processes' && req.method === 'GET') {
1022
+ json(res, 200, { success: true, processes: cardRunner.getProcesses() });
1023
+ return;
1024
+ }
1025
+
1026
+ if (p === '/api/cards/run' && req.method === 'POST') {
1027
+ cardRunner.start();
1028
+ json(res, 200, { success: true, running: true });
1029
+ return;
1030
+ }
1031
+
1032
+ if (p === '/api/cards/stop' && req.method === 'POST') {
1033
+ cardRunner.stop();
1034
+ json(res, 200, { success: true, running: false });
1035
+ return;
1036
+ }
1037
+
1038
+ if (p.startsWith('/api/cards/') && req.method === 'GET' && p.endsWith('/log')) {
1039
+ // 卡牌过程与结果:返回该卡牌的全部消息(含工具/产出)+ 当前卡牌状态
1040
+ const id = p.slice('/api/cards/'.length, -'/log'.length);
1041
+ const card = CardStore.get(id);
1042
+ if (!card) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
1043
+ const msgs = store.getMessages(id);
1044
+ json(res, 200, { success: true, card, messages: msgs });
1045
+ return;
1046
+ }
1047
+
1048
+ if (p.startsWith('/api/cards/') && req.method === 'PUT') {
1049
+ const id = p.slice('/api/cards/'.length);
1050
+ const body = await readBody(req);
1051
+ const patch = {};
1052
+ if (body.title !== undefined) patch.title = String(body.title).slice(0, 500);
1053
+ if (body.content !== undefined) patch.content = String(body.content).slice(0, 20000);
1054
+ if (body.priority !== undefined) patch.priority = Number(body.priority) || 999;
1055
+ if (body.mode !== undefined) patch.mode = body.mode;
1056
+ if (body.chainId !== undefined) patch.chainId = body.chainId;
1057
+ if (Array.isArray(body.dependsOn)) patch.dependsOn = body.dependsOn.map(String);
1058
+ if (body.model !== undefined) patch.model = String(body.model).slice(0, 80);
1059
+ // 状态手动复位:failed/pending -> pending 可重跑
1060
+ if (body.status === 'pending') { patch.status = 'pending'; patch.result = ''; patch.error = ''; patch.ocSessionId = ''; }
1061
+ const updated = CardStore.update(id, patch);
1062
+ if (!updated) { json(res, 404, { success: false, error: '任务不存在' }); return; }
1063
+ json(res, 200, { success: true, card: updated });
1064
+ return;
1065
+ }
1066
+
1067
+ if (p.startsWith('/api/cards/') && req.method === 'DELETE') {
1068
+ const id = p.slice('/api/cards/'.length);
1069
+ cardRunner.killCard(id);
1070
+ CardStore.remove(id);
1071
+ json(res, 200, { success: true });
1072
+ return;
1073
+ }
1074
+
1075
+ if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/run')) {
1076
+ const id = p.slice('/api/cards/'.length, -'/run'.length);
1077
+ const ok = await cardRunner.runOne(id);
1078
+ json(res, ok ? 200 : 409, { success: ok, running: cardRunner.isRunning() });
1079
+ return;
1080
+ }
1081
+
1082
+ // 追加聊天:任务完成后复用其 opencode 会话续聊(同一进程的第二轮输入)
1083
+ if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/chat')) {
1084
+ const id = p.slice('/api/cards/'.length, -'/chat'.length);
1085
+ const body = await readBody(req);
1086
+ const prompt = String(body.prompt || '').trim();
1087
+ if (!prompt) { json(res, 400, { success: false, error: '请输入追加内容' }); return; }
1088
+ if (!CardStore.get(id)) { json(res, 404, { success: false, error: '任务不存在' }); return; }
1089
+ const r = await cardRunner.chatFollowup(id, prompt);
1090
+ json(res, r.ok ? 200 : 409, { success: r.ok, error: r.error || '' });
1091
+ return;
1092
+ }
1093
+
1094
+ if (p === '/api/cards/stream' && req.method === 'GET') {
1095
+ // SSE:实时推送卡牌生命周期事件(task_start/text/tool/task_done/all_done/runner_*)
1096
+ const send = sse(req, res);
1097
+ const unsub = sseSubscribe(send);
1098
+ send({ type: 'init', running: cardRunner.isRunning(), maxParallel: MAX_PARALLEL });
1099
+ req.on('close', unsub);
1100
+ return;
1101
+ }
1102
+
1103
+ res.writeHead(404);
1104
+ res.end('Not Found');
1105
+ });
1106
+
1107
+ // 优雅退出:停掉全部子进程、把执行中任务复位为待执行,避免残留与假死状态
1108
+ function shutdown(code) {
1109
+ try {
1110
+ if (termProc) { try { termProc.kill(); } catch { /* ignore */ } termProc = null; }
1111
+ const n = stopAllChildren();
1112
+ const m = store.resetRunningTasks();
1113
+ const mc = CardStore.resetRunning();
1114
+ if (n || m || mc) console.log(`退出清理:终止 ${n} 个子进程,复位 ${m} 个执行中任务、${mc} 张卡牌`);
1115
+ } catch (err) {
1116
+ console.error('[shutdown] 清理失败:', err && (err.stack || err));
1117
+ }
1118
+ process.exit(code);
1119
+ }
1120
+ process.on('SIGINT', () => shutdown(0));
1121
+ process.on('SIGTERM', () => shutdown(0));
1122
+
1123
+ // ---------- 任务批次执行(SSE 手动触发与定时调度共用) ----------
1124
+ // send: 事件推送(SSE 为真实推送,定时触发为 no-op,消息仍会持久化)
1125
+ async function executeTaskBatch(selected, send, myToken) {
1126
+ const agents = store.getAgents();
1127
+ const butler = agents.find(a => a.id === 'butler');
1128
+ if (!butler) throw new Error('管家智能体缺失,配置异常');
1129
+ const subAgents = agents.filter(a => a.id !== 'butler');
1130
+ const resolveAssign = (task) => {
1131
+ if (!task.assign) return null;
1132
+ return agents.find(a => a.id === task.assign) || null;
1133
+ };
1134
+ const persist = (m) => store.addMessage({ ...m, timestamp: new Date().toISOString() });
1135
+ await runTasks(
1136
+ selected, butler, subAgents,
1137
+ { getHistory: (tid) => buildHistoryText(tid), resolveAssign, scope: 'tasks', isStopped: () => stopTokens.tasks !== myToken, approval: approvalSetting(), requestApproval: makeRequestApproval() },
1138
+ send, persist,
1139
+ // 任务会话首条消息:任务本身(用户视角)
1140
+ (task) => {
1141
+ if (store.getMessages(task.id).length === 0) {
1142
+ store.addMessage({
1143
+ role: 'user',
1144
+ content: `任务:${task.title}${task.notes ? `\n补充说明:${task.notes}` : ''}`,
1145
+ taskId: task.id,
1146
+ timestamp: new Date().toISOString()
1147
+ });
1148
+ }
1149
+ },
1150
+ (taskId, patch) => store.updateTask(taskId, patch)
1151
+ );
1152
+ }
1153
+
1154
+ // ---------- 单聊任务批次执行(OpenCode 单体逐个完成,不经管家编排) ----------
1155
+ // 单聊任务批量执行:按 link 字段编排(导入时 2.-xxx / 3.//xxx 语法解析而来)
1156
+ // new(默认):独立新会话执行;continue:接续上一串行任务的 opencode 会话(同进程续聊);
1157
+ // parallel:连续多个并行任务各自独立进程同时执行(Promise.all)
1158
+ // 排序:seq 优先,其次 createdAt;会话 ID 记录在任务上(task.ocSessionId)供续聊
1159
+ // SSE 事件:task_start / text(快照) / tool / notice / task_done / all_done
1160
+ async function executeSoloTaskBatch(selected, send, myToken) {
1161
+ const { resolveRunner, missingHint } = require('./lib/agent');
1162
+ const list = selected.slice().sort((a, b) => ((a.seq ?? 0) - (b.seq ?? 0)) || (a.createdAt - b.createdAt));
1163
+
1164
+ // 执行单个任务;ocSessionId 非空 = 在该 opencode 会话中续聊;返回本次会话 id
1165
+ const runOne = async (task, ocSessionId) => {
1166
+ store.updateTask(task.id, { status: 'running' });
1167
+ send({ type: 'task_start', taskId: task.id, title: task.title, solo: true, link: task.link || 'new' });
1168
+ const cont = !!ocSessionId; // 续聊:prompt 提示模型这是同一工作的延续
1169
+ if (store.getMessages(task.id).length === 0) {
1170
+ store.addMessage({
1171
+ role: 'user',
1172
+ content: `任务:${task.title}${task.notes ? `\n补充说明:${task.notes}` : ''}`,
1173
+ taskId: task.id,
1174
+ timestamp: new Date().toISOString()
1175
+ });
1176
+ }
1177
+
1178
+ const runner = resolveRunner();
1179
+ if (runner.kind === 'missing') {
1180
+ store.updateTask(task.id, { status: 'failed', result: missingHint(runner).slice(0, 2000) });
1181
+ send({ type: 'task_done', taskId: task.id, title: task.title, status: 'failed' });
1182
+ return '';
1183
+ }
1184
+
1185
+ const texts = new Map();
1186
+ const order = [];
1187
+ let doneError = '';
1188
+ let sesId = ocSessionId || '';
1189
+ await new Promise((resolve) => {
1190
+ const kind = runner.kind === 'demo' ? 'demo' : (runner.kind === 'opencode' ? 'opencode' : 'fallback');
1191
+ oc.chatSolo(kind, runner, {
1192
+ prompt: cont
1193
+ ? `请在当前会话已有工作成果的基础上继续完成下一项任务:\n\n${task.title}${task.notes ? `\n补充说明:${task.notes}` : ''}`
1194
+ : `请完成以下任务并给出结果:\n\n${task.title}${task.notes ? `\n补充说明:${task.notes}` : ''}`,
1195
+ model: task.model || '', // 导入时记录的用户所选模型(单聊定时任务用页面所选模型执行)
1196
+ ocSessionId: sesId,
1197
+ behavior: 'solo-task'
1198
+ }, (ev) => {
1199
+ if (ev.type === 'session') {
1200
+ // 首个 sessionID 回填:continue 链与手动重跑都能续上同一会话
1201
+ sesId = ev.ocSessionId;
1202
+ store.updateTask(task.id, { ocSessionId: sesId });
1203
+ } else if (ev.type === 'text') {
1204
+ if (!texts.has(ev.partId)) order.push(ev.partId);
1205
+ texts.set(ev.partId, ev.text);
1206
+ send({ type: 'text', taskId: task.id, partId: ev.partId, text: ev.text, agentId: 'solo', agentName: 'OpenCode', phase: 'work' });
1207
+ } else if (ev.type === 'tool') {
1208
+ send({ type: 'notice', content: ev.summary, taskId: task.id });
1209
+ } else if (ev.type === 'done') {
1210
+ doneError = ev.error || '';
1211
+ resolve();
1212
+ }
1213
+ });
1214
+ });
1215
+
1216
+ const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
1217
+ if (finalText) {
1218
+ store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'OpenCode', phase: 'work', taskId: task.id, content: finalText, timestamp: new Date().toISOString() });
1219
+ }
1220
+ const stopped = stopTokens.tasks !== myToken;
1221
+ store.updateTask(task.id, {
1222
+ status: stopped ? 'pending' : (doneError ? 'failed' : 'done'),
1223
+ result: (doneError ? `执行出错:${doneError}` : finalText).slice(0, 2000)
1224
+ });
1225
+ send({ type: 'task_done', taskId: task.id, title: task.title, status: stopped ? 'pending' : (doneError ? 'failed' : 'done') });
1226
+ return stopped ? '' : sesId;
1227
+ };
1228
+
1229
+ // 编排:串行任务(new/continue)按序执行,continue 复用串行链会话;
1230
+ // 连续 parallel 任务聚成一块同时执行(各独立会话),并行块等待前序串行任务完成
1231
+ let lastChainSession = '';
1232
+ for (let i = 0; i < list.length; i++) {
1233
+ if (stopTokens.tasks !== myToken) break;
1234
+ const link = list[i].link || 'new';
1235
+ if (link === 'parallel') {
1236
+ const block = [list[i]];
1237
+ while (i + 1 < list.length && (list[i + 1].link || 'new') === 'parallel') block.push(list[++i]);
1238
+ if (block.length > 1) {
1239
+ send({ type: 'notice', content: `⚡ ${block.length} 个任务并行执行(各自独立进程):${block.map(t => `「${t.title.slice(0, 20)}」`).join('、')}` });
1240
+ }
1241
+ await Promise.all(block.map(t => runOne(t, '')));
1242
+ continue;
1243
+ }
1244
+ if (link === 'continue') {
1245
+ const tt = String(list[i].title || '').slice(0, 20);
1246
+ if (lastChainSession) send({ type: 'notice', content: `↪ 任务「${tt}」接续上一任务的会话执行(同进程续聊)`, taskId: list[i].id });
1247
+ else send({ type: 'notice', content: `任务「${tt}」标记续聊但没有前序会话,已按新会话执行`, taskId: list[i].id });
1248
+ }
1249
+ const ses = await runOne(list[i], link === 'continue' ? lastChainSession : '');
1250
+ if (ses) lastChainSession = ses;
1251
+ }
1252
+ send({ type: 'all_done' });
1253
+ }
1254
+
1255
+ // ---------- 定时调度器:到点的定时任务自动执行(无人值守) ----------
1256
+ // 执行过程照常持久化到对应任务会话,用户打开会话即可查看全过程
1257
+ const SCHED_INTERVAL_MS = 15000;
1258
+ function startScheduler() {
1259
+ const timer = setInterval(() => {
1260
+ if (runLocks.tasks) return; // 手动批次执行中,下轮再查
1261
+ if (store.getSchedEnabled() === false) return; // 用户关闭了定时调度总开关
1262
+ const due = store.getTasks().filter(t =>
1263
+ t.kind === 'scheduled' && t.status === 'pending' && t.scheduledAt && t.scheduledAt <= Date.now());
1264
+ if (!due.length) return;
1265
+ runLocks.tasks = true;
1266
+ const myToken = stopTokens.tasks;
1267
+ console.log(`[scheduler] 定时触发 ${due.length} 个任务:${due.map(t => t.title).join('、')}`);
1268
+ const groups = due.filter(t => t.runner !== 'solo');
1269
+ const solos = due.filter(t => t.runner === 'solo');
1270
+ (async () => {
1271
+ if (groups.length) await executeTaskBatch(groups, () => {}, myToken);
1272
+ if (solos.length) await executeSoloTaskBatch(solos, () => {}, myToken);
1273
+ })()
1274
+ .catch(err => console.error('[scheduler] 定时执行异常:', err && (err.stack || err)))
1275
+ .finally(() => { runLocks.tasks = false; });
1276
+ }, SCHED_INTERVAL_MS);
1277
+ timer.unref();
1278
+ return timer;
1279
+ }
1280
+
1281
+ // ---------- 历史数据自动清理(动态文件与历史记录超期滚动清理) ----------
1282
+ // 启动时清理一次 + 每日一次;天数 .env AGENTS_CHAT_PRUNE_DAYS 可调(默认 15)
1283
+ const PRUNE_DAYS = Number(process.env.AGENTS_CHAT_PRUNE_DAYS) > 0 ? Number(process.env.AGENTS_CHAT_PRUNE_DAYS) : 15;
1284
+ function pruneOldDataQuiet() {
1285
+ try {
1286
+ const stat = store.pruneOldData(PRUNE_DAYS);
1287
+ const touched = Object.values(stat).reduce((a, b) => a + b, 0);
1288
+ if (touched) console.log(`[maintenance] 自动清理超 ${PRUNE_DAYS} 天的历史数据:`, JSON.stringify(stat));
1289
+ } catch (e) { console.error('[maintenance] 自动清理失败:', e && (e.message || e)); }
1290
+ }
1291
+ function startPruneTimer() {
1292
+ pruneOldDataQuiet(); // 启动即清理一次
1293
+ const timer = setInterval(pruneOldDataQuiet, 24 * 3600 * 1000); // 每日滚动清理
1294
+ timer.unref();
1295
+ }
1296
+
1297
+ server.on('error', (err) => {
1298
+ if (err && err.code === 'EADDRINUSE') {
1299
+ console.error(`\n❌ 端口 ${PORT} 已被占用:很可能有一个旧版 Agents Chat 进程还在运行!`);
1300
+ console.error(` 你现在访问的是旧版页面,新代码从未生效。请先结束旧进程:`);
1301
+ console.error(` 1) 打开命令行执行:netstat -ano | findstr :${PORT}`);
1302
+ console.error(` 2) 找到 LISTENING 行最后的 PID,执行:taskkill /F /PID <该PID> /T`);
1303
+ console.error(` 3) 再重新运行 npm start,并浏览器 Ctrl+F5 强刷页面\n`);
1304
+ process.exit(1);
1305
+ }
1306
+ console.error('服务器启动失败:', err && (err.stack || err));
1307
+ process.exit(1);
1308
+ });
1309
+
1310
+ server.listen(PORT, () => {
1311
+ const { resolveRunner, detectKernels, KERNEL_DEFS } = require('./lib/agent');
1312
+ const runner = resolveRunner();
1313
+ const kindText = runner.kind === 'demo'
1314
+ ? '演示模式(AGENTS_CHAT_MOCK=1,输出为模拟结果)'
1315
+ : runner.kind === 'missing'
1316
+ ? (runner.missingKernel
1317
+ ? `已选择内核 ${runner.missingKernel.label} 但未检测到!任务将报错,请先安装并重启`
1318
+ : `未检测到任何内核!任务将报错,可安装其一:${KERNEL_DEFS.map(k => k.install).join(' / ')}`)
1319
+ : `${runner.kernel.label} 真实执行(${runner.cmd})`;
1320
+ const detected = detectKernels();
1321
+ const avail = KERNEL_DEFS.filter(k => detected[k.id].ok).map(k => k.label).join('、') || '无';
1322
+ // 启动即修复孤儿状态:上次异常退出时仍标记「执行中」的任务复位为待执行
1323
+ const orphan = store.resetRunningTasks();
1324
+ if (orphan > 0) console.log(`检测到 ${orphan} 个上次未正常结束的任务,已复位为待执行`);
1325
+ const orphanCards = CardStore.resetRunning();
1326
+ if (orphanCards > 0) console.log(`检测到 ${orphanCards} 张上次未正常结束的卡牌,已复位为待执行`);
1327
+ startScheduler();
1328
+ startAutoStop();
1329
+ startPruneTimer();
1330
+ console.log(`Agents Chat 已启动: http://localhost:${PORT}`);
1331
+ console.log(`运行内核: ${kindText}`);
1332
+ console.log(`本机可用内核: ${avail}(配置页可切换)`);
1333
+ console.log(`数据目录: ${store.DATA_DIR}`);
1334
+ });