@iamsamyiok/agents-chat 3.20.0 → 3.22.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/lib/cards.js +111 -19
- package/app/lib/oc.js +9 -4
- package/app/lib/orchestrator.js +3 -1
- package/app/lib/safejson.js +34 -0
- package/app/lib/store.js +110 -29
- package/app/public/cards.html +218 -13
- package/app/public/index.html +62 -12
- package/app/server.js +30 -2
- package/package.json +2 -2
package/app/lib/cards.js
CHANGED
|
@@ -13,6 +13,7 @@ const path = require('path');
|
|
|
13
13
|
const { resolveRunner, detectKernels, KERNEL_DEFS, stopScope } = require('./agent');
|
|
14
14
|
const oc = require('./oc');
|
|
15
15
|
const store = require('./store');
|
|
16
|
+
const safejson = require('./safejson');
|
|
16
17
|
|
|
17
18
|
const ROOT = path.join(__dirname, '..', '..');
|
|
18
19
|
const DATA_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT, '.data');
|
|
@@ -22,28 +23,78 @@ const TRASH_PATH = path.join(DATA_DIR, 'cards_trash.json');
|
|
|
22
23
|
const TRASH_TTL = 30 * 24 * 3600 * 1000; // 垃圾桶默认保留 30 天
|
|
23
24
|
|
|
24
25
|
function ensureDir() { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); }
|
|
26
|
+
// 数据损坏保护(safejson 公共层):解析失败 → 备份 .corrupt-* 并只读;写入一律原子替换
|
|
27
|
+
const corrupted = new Set(); // 已损坏的文件路径(本模块文件)
|
|
28
|
+
function cachedReader(file, cacheBox) {
|
|
29
|
+
return function read() {
|
|
30
|
+
if (safejson.isCorrupted(file)) return [];
|
|
31
|
+
try {
|
|
32
|
+
const st = fs.statSync(file);
|
|
33
|
+
if (cacheBox.list && st.mtimeMs === cacheBox.mtime) return cacheBox.list;
|
|
34
|
+
const list = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
35
|
+
cacheBox.mtime = st.mtimeMs; cacheBox.list = list;
|
|
36
|
+
return list;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err && err.code === 'ENOENT') { // 文件不存在:正常的初始状态
|
|
39
|
+
cacheBox.mtime = 0; cacheBox.list = null;
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
// 文件存在但解析失败:经 safejson 备份损坏现场并登记,进入只读保护
|
|
43
|
+
safejson.readJson(file, []);
|
|
44
|
+
corrupted.add(file);
|
|
45
|
+
cacheBox.mtime = 0; cacheBox.list = null;
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const _cardsCache = { mtime: 0, list: null };
|
|
51
|
+
const _cfgCache = { mtime: 0, list: null };
|
|
52
|
+
const _trashCache = { mtime: 0, list: null };
|
|
53
|
+
const readCardsRaw = cachedReader(CARDS_PATH, _cardsCache);
|
|
54
|
+
const readConfigRaw = cachedReader(CARDS_CFG_PATH, _cfgCache);
|
|
55
|
+
const readTrashRaw = cachedReader(TRASH_PATH, _trashCache);
|
|
25
56
|
function readCards() {
|
|
26
|
-
|
|
57
|
+
// 返回浅拷贝:调用方(list 的 sort 等)对数组的操作不影响缓存
|
|
58
|
+
const v = readCardsRaw();
|
|
59
|
+
return Array.isArray(v) ? v.slice() : [];
|
|
60
|
+
}
|
|
61
|
+
function invalidate(file) {
|
|
62
|
+
if (file === CARDS_PATH) { _cardsCache.mtime = 0; _cardsCache.list = null; }
|
|
63
|
+
else if (file === CARDS_CFG_PATH) { _cfgCache.mtime = 0; _cfgCache.list = null; }
|
|
64
|
+
else if (file === TRASH_PATH) { _trashCache.mtime = 0; _trashCache.list = null; }
|
|
65
|
+
}
|
|
66
|
+
// 损坏保护下的写入守卫:拒绝覆盖写(保留备份供人工恢复)
|
|
67
|
+
function guardWrite(file) {
|
|
68
|
+
if (corrupted.has(file) || safejson.isCorrupted(file)) {
|
|
69
|
+
corrupted.add(file);
|
|
70
|
+
throw new Error(`数据文件 ${path.basename(file)} 已损坏(原文件已备份为 .corrupt-*),为防数据丢失已停止写入,请人工检查 ${DATA_DIR} 后删除损坏标记文件`);
|
|
71
|
+
}
|
|
27
72
|
}
|
|
28
73
|
function writeCards(list) {
|
|
29
74
|
ensureDir();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
75
|
+
guardWrite(CARDS_PATH);
|
|
76
|
+
invalidate(CARDS_PATH);
|
|
77
|
+
safejson.writeJson(CARDS_PATH, list);
|
|
33
78
|
}
|
|
34
79
|
function readConfig() {
|
|
35
|
-
|
|
80
|
+
const v = readConfigRaw();
|
|
81
|
+
return (v && typeof v === 'object' && !Array.isArray(v)) ? v : { workspace: '' };
|
|
36
82
|
}
|
|
37
83
|
function writeConfig(cfg) {
|
|
38
84
|
ensureDir();
|
|
39
|
-
|
|
85
|
+
guardWrite(CARDS_CFG_PATH);
|
|
86
|
+
invalidate(CARDS_CFG_PATH);
|
|
87
|
+
safejson.writeJson(CARDS_CFG_PATH, cfg);
|
|
40
88
|
}
|
|
41
89
|
function readTrash() {
|
|
42
|
-
|
|
90
|
+
const v = readTrashRaw();
|
|
91
|
+
return Array.isArray(v) ? v.slice() : [];
|
|
43
92
|
}
|
|
44
93
|
function writeTrash(list) {
|
|
45
94
|
ensureDir();
|
|
46
|
-
|
|
95
|
+
guardWrite(TRASH_PATH);
|
|
96
|
+
invalidate(TRASH_PATH);
|
|
97
|
+
safejson.writeJson(TRASH_PATH, list);
|
|
47
98
|
}
|
|
48
99
|
// 启动时清理超过 30 天的垃圾桶快照,并连带清除其日志,避免占用磁盘
|
|
49
100
|
(function purgeTrash() {
|
|
@@ -109,11 +160,15 @@ const CardStore = {
|
|
|
109
160
|
// 拖拽重排:order 重编 + priority 按新顺序重映射(保值域、变归属),
|
|
110
161
|
// 使调度顺序恒等于看板顺序(priority 仍是排序主键,但层内次序由拖拽决定)
|
|
111
162
|
reorder(ids) {
|
|
163
|
+
// 全量重编:传入 ids 按新顺序排前,未涉及的卡保持原相对顺序排后,
|
|
164
|
+
// 保证全表 order/priority 唯一且连续(部分重排不再产生并列 order)
|
|
112
165
|
const list = readCards();
|
|
113
|
-
const
|
|
114
|
-
const ordered = ids.map(id =>
|
|
115
|
-
const
|
|
116
|
-
|
|
166
|
+
const idSet = new Set(ids);
|
|
167
|
+
const ordered = ids.map(id => list.find(c => c.id === id)).filter(Boolean);
|
|
168
|
+
const rest = list.filter(c => !idSet.has(c.id));
|
|
169
|
+
const seq = [...ordered, ...rest];
|
|
170
|
+
const prios = seq.map(c => (c.priority === undefined ? 999 : c.priority)).sort((a, b) => a - b);
|
|
171
|
+
seq.forEach((c, i) => { c.order = i + 1; c.priority = prios[i]; });
|
|
117
172
|
writeCards(list);
|
|
118
173
|
return true;
|
|
119
174
|
},
|
|
@@ -246,10 +301,16 @@ class CardRunner {
|
|
|
246
301
|
this.timer = null;
|
|
247
302
|
this.procs = new Map(); // cardId -> { pid, child, lastActive, status }
|
|
248
303
|
this.followups = new Set(); // 追加聊天中的卡牌(并发防护)
|
|
304
|
+
this.killedCards = new Set(); // 本轮编排中被单卡停止的卡:跳过调度直到本轮结束
|
|
249
305
|
this.baseline = null; // 本轮编排开始时的 done/failed 基线(all_done 报增量)
|
|
250
306
|
}
|
|
251
307
|
isRunning() { return this.running; }
|
|
252
308
|
|
|
309
|
+
// 并行度热更新:调大后立即补齐在跑任务(无需等下一个任务完成触发 tick)
|
|
310
|
+
onConfigChanged() {
|
|
311
|
+
if (this.running) this.tick();
|
|
312
|
+
}
|
|
313
|
+
|
|
253
314
|
// 并行度:cards_config.maxParallel 可热更新(1-8),未配置时用 env 默认
|
|
254
315
|
maxP() {
|
|
255
316
|
try {
|
|
@@ -259,7 +320,7 @@ class CardRunner {
|
|
|
259
320
|
return MAX_PARALLEL;
|
|
260
321
|
}
|
|
261
322
|
|
|
262
|
-
//
|
|
323
|
+
// 终止单个任务对应的子进程(删除卡等场景:不改变调度语义)
|
|
263
324
|
killCard(cardId) {
|
|
264
325
|
this.active.delete(cardId);
|
|
265
326
|
const p = this.procs.get(cardId);
|
|
@@ -267,6 +328,21 @@ class CardRunner {
|
|
|
267
328
|
this.procs.delete(cardId);
|
|
268
329
|
}
|
|
269
330
|
|
|
331
|
+
// 单卡停止:杀进程 + 标记本轮不再自动调度(状态回待执行,与全局停止的复位语义一致)
|
|
332
|
+
stopOne(cardId) {
|
|
333
|
+
const card = CardStore.get(cardId);
|
|
334
|
+
if (!card || card.status !== 'running') return false;
|
|
335
|
+
if (this.followups.has(cardId)) return false; // 追加聊天进行中:走 chatFollowup 自己的错误路径
|
|
336
|
+
this.killedCards.add(cardId);
|
|
337
|
+
this.killCard(cardId);
|
|
338
|
+
// 主动复位状态:以这里为准(子进程 close 回调到达时 runCard 会再写一次 pending,幂等),
|
|
339
|
+
// 避免回调异常丢失时卡片永远停留在 running
|
|
340
|
+
CardStore.update(cardId, { status: 'pending', result: '', error: '', finishedAt: Date.now() });
|
|
341
|
+
broadcast({ type: 'notice', content: `⏹ 已停止任务「${card.title}」,该任务回到待执行(本轮编排不再自动调度它)` });
|
|
342
|
+
broadcast({ type: 'task_done', cardId, status: 'stopped', title: card.title });
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
270
346
|
// 供前端展示:当前存活进程 + opencode 是否在工作中(近 5s 有活动即视为工作中)
|
|
271
347
|
getProcesses() {
|
|
272
348
|
const out = [];
|
|
@@ -280,6 +356,7 @@ class CardRunner {
|
|
|
280
356
|
stop() {
|
|
281
357
|
this.token++;
|
|
282
358
|
this.running = false;
|
|
359
|
+
this.killedCards.clear();
|
|
283
360
|
if (this.timer) { try { this.timer.unref(); } catch { /* ignore */ } }
|
|
284
361
|
try { stopScope('solo'); } catch { /* ignore */ }
|
|
285
362
|
this.procs.clear();
|
|
@@ -291,6 +368,7 @@ class CardRunner {
|
|
|
291
368
|
if (this.running) return;
|
|
292
369
|
this.running = true;
|
|
293
370
|
this.token++;
|
|
371
|
+
this.killedCards.clear();
|
|
294
372
|
// 记录基线:all_done 时报告本轮增量(成功/失败数),避免混入历史任务
|
|
295
373
|
const all0 = CardStore.list();
|
|
296
374
|
this.baseline = { done: all0.filter(c => c.status === 'done').length, failed: all0.filter(c => c.status === 'failed').length };
|
|
@@ -302,7 +380,8 @@ class CardRunner {
|
|
|
302
380
|
if (!this.running) return;
|
|
303
381
|
const myToken = this.token;
|
|
304
382
|
const all = CardStore.list();
|
|
305
|
-
|
|
383
|
+
// 单卡停止的任务本轮跳过(用户已明确表示停它,不让调度器立刻拉起)
|
|
384
|
+
const eligible = pickEligible(all, this.active).filter(c => !this.killedCards.has(c.id));
|
|
306
385
|
if (!eligible.length) {
|
|
307
386
|
if (this.active.size === 0) {
|
|
308
387
|
this.running = false;
|
|
@@ -349,6 +428,12 @@ class CardRunner {
|
|
|
349
428
|
if (card.mode === 'continue' && card.chainId) {
|
|
350
429
|
const prev = CardStore.get(card.chainId);
|
|
351
430
|
ocSessionId = prev && prev.ocSessionId ? prev.ocSessionId : '';
|
|
431
|
+
// 语义降级显式提示:避免「名义续聊、实际新会话」静默发生
|
|
432
|
+
if (!ocSessionId) {
|
|
433
|
+
const warn = '续聊链首无可用会话(可能已被重置,或由不支持会话续聊的内核执行),本任务将以全新会话执行';
|
|
434
|
+
store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'system', taskId: card.id, content: `[系统提示] ${warn}` });
|
|
435
|
+
broadcast({ type: 'notice', content: `⚠ ${card.title}:${warn}` });
|
|
436
|
+
}
|
|
352
437
|
}
|
|
353
438
|
|
|
354
439
|
CardStore.update(card.id, { status: 'running', startedAt: Date.now(), error: '', result: '' });
|
|
@@ -417,16 +502,18 @@ class CardRunner {
|
|
|
417
502
|
|
|
418
503
|
const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
|
|
419
504
|
const stopped = myToken !== this.token;
|
|
505
|
+
const cardStopped = this.killedCards.has(card.id); // 单卡停止:与全局停止同样复位为待执行
|
|
420
506
|
if (finalText) {
|
|
421
507
|
store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'work', taskId: card.id, content: finalText.slice(0, 20000) });
|
|
422
508
|
}
|
|
423
|
-
const
|
|
509
|
+
const stoppedAny = stopped || cardStopped;
|
|
510
|
+
const status = stoppedAny ? 'pending' : (doneError ? 'failed' : 'done');
|
|
424
511
|
CardStore.update(card.id, {
|
|
425
512
|
status,
|
|
426
513
|
ocSessionId: sesId,
|
|
427
514
|
// 手动停止回到待执行:清掉残留,避免 pending 卡带着脏结果/错误
|
|
428
|
-
result:
|
|
429
|
-
error:
|
|
515
|
+
result: stoppedAny ? '' : (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
|
|
516
|
+
error: stoppedAny ? '' : (doneError || ''),
|
|
430
517
|
finishedAt: Date.now()
|
|
431
518
|
});
|
|
432
519
|
broadcast({ type: 'task_done', cardId: card.id, status, title: card.title });
|
|
@@ -489,7 +576,8 @@ class CardRunner {
|
|
|
489
576
|
model: card.model || '',
|
|
490
577
|
ocSessionId: sesId,
|
|
491
578
|
behavior: 'card',
|
|
492
|
-
cwd
|
|
579
|
+
cwd,
|
|
580
|
+
scope: 'card-fu' // 独立进程域:停止编排不牵连追加聊天
|
|
493
581
|
}, (ev) => {
|
|
494
582
|
const proc = this.procs.get(cardId);
|
|
495
583
|
if (proc) proc.lastActive = Date.now();
|
|
@@ -539,12 +627,16 @@ class CardRunner {
|
|
|
539
627
|
}
|
|
540
628
|
|
|
541
629
|
isFollowupRunning(cardId) { return !!this.followups && this.followups.has(cardId); }
|
|
630
|
+
getFollowupIds() { return [...this.followups]; }
|
|
542
631
|
}
|
|
543
632
|
|
|
633
|
+
// 数据文件损坏状态(供 API 告警展示)
|
|
634
|
+
function getCorruptedFiles() { return [...new Set([...corrupted, ...safejson.corruptedFiles()])]; }
|
|
635
|
+
|
|
544
636
|
function isValidDir(p) {
|
|
545
637
|
try { return fs.existsSync(p) && fs.statSync(p).isDirectory(); } catch { return false; }
|
|
546
638
|
}
|
|
547
639
|
|
|
548
640
|
const runner = new CardRunner();
|
|
549
641
|
|
|
550
|
-
module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, wouldCycle, MAX_PARALLEL, CARDS_PATH, CARDS_CFG_PATH };
|
|
642
|
+
module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, wouldCycle, MAX_PARALLEL, CARDS_PATH, CARDS_CFG_PATH, getCorruptedFiles };
|
package/app/lib/oc.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// - 首个 sessionID 事件回填 store,实现同一网页会话跨轮次续聊
|
|
5
5
|
// - 非 opencode 内核(claude/codex/pi)无 -s 续聊能力:单聊退化为一次性对话,每轮全新上下文
|
|
6
6
|
// - 演示模式(AGENTS_CHAT_MOCK=1)走 mock 子进程,输出模拟为快照事件
|
|
7
|
+
const fs = require('fs');
|
|
7
8
|
const path = require('path');
|
|
8
9
|
const { spawn, execFileSync } = require('child_process');
|
|
9
10
|
const { registerChild, describeTool, resolveCwd } = require('./agent');
|
|
@@ -98,7 +99,7 @@ function parseSoloEventLine(line, onEvent, state) {
|
|
|
98
99
|
|
|
99
100
|
// ---------- 通用子进程运行器 ----------
|
|
100
101
|
// runnerSpec: { cmd, shell, json, cwd };json=false 时把输出行累积为快照(演示模式用)
|
|
101
|
-
function spawnSoloRunner(runnerSpec, args, prompt, env, onEvent, finish) {
|
|
102
|
+
function spawnSoloRunner(runnerSpec, args, prompt, env, onEvent, finish, scope) {
|
|
102
103
|
let child;
|
|
103
104
|
try {
|
|
104
105
|
child = spawn(runnerSpec.cmd, args, {
|
|
@@ -112,7 +113,7 @@ function spawnSoloRunner(runnerSpec, args, prompt, env, onEvent, finish) {
|
|
|
112
113
|
finish(`启动失败:${error.message}`);
|
|
113
114
|
return null;
|
|
114
115
|
}
|
|
115
|
-
registerChild(child, 'solo'); // 停止/退出清理复用 agent.js 的登记表(scope
|
|
116
|
+
registerChild(child, scope || 'solo'); // 停止/退出清理复用 agent.js 的登记表(scope 可区分编排与追加聊天)
|
|
116
117
|
|
|
117
118
|
child.stdin.on('error', () => { /* stdin 已关闭则忽略 */ });
|
|
118
119
|
if (prompt) child.stdin.write(prompt);
|
|
@@ -190,6 +191,8 @@ function chatSolo(runnerKind, runner, opts, onEvent) {
|
|
|
190
191
|
const prompt = String(opts.prompt || '');
|
|
191
192
|
const model = String(opts.model || '');
|
|
192
193
|
const ocSessionId = String(opts.ocSessionId || '');
|
|
194
|
+
// 进程归属 scope:停止编排(stopScope('solo'))只杀编排任务,追加聊天用独立 scope 免受牵连
|
|
195
|
+
const scope = String(opts.scope || 'solo');
|
|
193
196
|
// 工作区:指定后 Agent 在该目录读写文件(卡牌可选 workspace)
|
|
194
197
|
const cwd = opts.cwd && fs.existsSync(opts.cwd) && fs.statSync(opts.cwd).isDirectory() ? opts.cwd : '';
|
|
195
198
|
const cwdEnv = cwd ? { ...process.env, AGENTS_CHAT_CWD: cwd } : process.env;
|
|
@@ -206,7 +209,8 @@ function chatSolo(runnerKind, runner, opts, onEvent) {
|
|
|
206
209
|
// 演示模式的会话 ID:续聊时复用传入 ID(打通链路),新任务本地生成(无真实续聊)
|
|
207
210
|
onEvent({ type: 'session', ocSessionId: ocSessionId || 'ses_demo-' + Date.now().toString(36) });
|
|
208
211
|
onEvent({ type: 'done', error });
|
|
209
|
-
}
|
|
212
|
+
},
|
|
213
|
+
scope
|
|
210
214
|
);
|
|
211
215
|
}
|
|
212
216
|
|
|
@@ -222,7 +226,8 @@ function chatSolo(runnerKind, runner, opts, onEvent) {
|
|
|
222
226
|
{ cmd: runner.cmd, shell: runner.shell, json: true, cwd: cwd || resolveCwd() },
|
|
223
227
|
args, prompt, cwdEnv,
|
|
224
228
|
onEvent,
|
|
225
|
-
(error) => onEvent({ type: 'done', error })
|
|
229
|
+
(error) => onEvent({ type: 'done', error }),
|
|
230
|
+
scope
|
|
226
231
|
);
|
|
227
232
|
}
|
|
228
233
|
|
package/app/lib/orchestrator.js
CHANGED
|
@@ -1173,5 +1173,7 @@ module.exports = {
|
|
|
1173
1173
|
// 测试导出(单测用,业务代码请勿依赖)
|
|
1174
1174
|
testBoardInit: boardInit, testBoardAppend: boardAppend, testBoardRead: boardRead,
|
|
1175
1175
|
testExtractBoardNote: extractBoardNote, testParseHandoff: parseHandoff,
|
|
1176
|
-
testApprovalGate: approvalGate
|
|
1176
|
+
testApprovalGate: approvalGate,
|
|
1177
|
+
testExtractPlanJSON: extractPlanJSON, testResolveAgentRef: resolveAgentRef,
|
|
1178
|
+
testSplitByDependency: splitByDependency, testNormalizePhases: normalizePhases
|
|
1177
1179
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 数据文件损坏保护 + 原子写公共层(零依赖)
|
|
2
|
+
// 策略:解析失败(文件存在但 JSON 损坏)→ 备份现场为 .corrupt-<ts> 并登记;
|
|
3
|
+
// 之后该文件的写请求一律抛错,防止「读到空数据 → 全量覆盖写」冲掉用户数据。
|
|
4
|
+
// 正常写入走 tmp + rename 原子替换,进程中断不会留下半截文件。
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const corrupted = new Set(); // 已损坏的文件绝对路径
|
|
9
|
+
|
|
10
|
+
function isCorrupted(file) { return corrupted.has(file); }
|
|
11
|
+
function corruptedFiles() { return [...corrupted]; }
|
|
12
|
+
|
|
13
|
+
function readJson(file, fallback) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err && err.code === 'ENOENT') return fallback; // 文件不存在:正常初始状态
|
|
18
|
+
try { fs.copyFileSync(file, `${file}.corrupt-${Date.now()}`); } catch { /* 备份失败也要继续登记 */ }
|
|
19
|
+
corrupted.add(file);
|
|
20
|
+
console.error(`[safejson] 数据文件损坏,已备份并进入只读保护:${file}`);
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeJson(file, data) {
|
|
26
|
+
if (corrupted.has(file)) {
|
|
27
|
+
throw new Error(`数据文件 ${path.basename(file)} 已损坏(原文件已备份为 .corrupt-*),为防数据丢失已停止写入,请人工检查 ${path.dirname(file)} 后处理备份文件`);
|
|
28
|
+
}
|
|
29
|
+
const tmp = file + '.tmp';
|
|
30
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
31
|
+
fs.renameSync(tmp, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { readJson, writeJson, isCorrupted, corruptedFiles };
|
package/app/lib/store.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// 数据存储:纯 JSON 文件,零依赖
|
|
2
2
|
// 文件位于数据目录(默认 <root>/.data),任务与消息持久化
|
|
3
|
+
// 损坏保护与原子写由 safejson 公共层提供:损坏文件备份 .corrupt-* 后只读,防覆盖丢数据
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const path = require('path');
|
|
6
|
+
const safejson = require('./safejson');
|
|
5
7
|
|
|
6
8
|
const ROOT = path.join(__dirname, '..', '..');
|
|
7
9
|
const DATA_DIR = process.env.AGENTS_CHAT_DATA || path.join(ROOT, '.data');
|
|
8
10
|
const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
|
|
9
11
|
const TASKS_PATH = path.join(DATA_DIR, 'tasks.json');
|
|
10
|
-
const MESSAGES_PATH = path.join(DATA_DIR, 'messages.json');
|
|
12
|
+
const MESSAGES_PATH = path.join(DATA_DIR, 'messages.json'); // 旧版单文件(启动时一次性迁移到 messages/ 分片)
|
|
13
|
+
const MSG_DIR = path.join(DATA_DIR, 'messages');
|
|
11
14
|
const MEMORY_PATH = path.join(DATA_DIR, 'memory.json');
|
|
12
15
|
const OC_SESSIONS_PATH = path.join(DATA_DIR, 'oc-sessions.json');
|
|
13
16
|
|
|
@@ -16,18 +19,12 @@ function ensureDir() {
|
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
function readJson(file, fallback) {
|
|
19
|
-
|
|
20
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
21
|
-
} catch {
|
|
22
|
-
return fallback;
|
|
23
|
-
}
|
|
22
|
+
return safejson.readJson(file, fallback);
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
function writeJson(file, data) {
|
|
27
26
|
ensureDir();
|
|
28
|
-
|
|
29
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
30
|
-
fs.renameSync(tmp, file);
|
|
27
|
+
safejson.writeJson(file, data);
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
// ---------- 内置管家智能体(不可修改、不可删除,始终置顶) ----------
|
|
@@ -87,7 +84,7 @@ function getConfig() {
|
|
|
87
84
|
let cfg = readJson(CONFIG_PATH, null);
|
|
88
85
|
if (!cfg || !Array.isArray(cfg.agents)) {
|
|
89
86
|
cfg = defaultConfig();
|
|
90
|
-
writeJson(CONFIG_PATH, cfg);
|
|
87
|
+
try { writeJson(CONFIG_PATH, cfg); } catch { /* config 处于损坏保护:本次运行用默认配置,文件保留待人工恢复 */ }
|
|
91
88
|
}
|
|
92
89
|
// 管家始终置顶且使用内置定义(保证内置人设更新后自动生效)
|
|
93
90
|
cfg.agents = [BUTLER, ...cfg.agents.filter(a => a && a.id !== 'butler')];
|
|
@@ -442,16 +439,80 @@ function getTask(id) {
|
|
|
442
439
|
return getTasks().find(x => x.id === id) || null;
|
|
443
440
|
}
|
|
444
441
|
|
|
445
|
-
// ----------
|
|
446
|
-
// taskId 为空 =
|
|
442
|
+
// ---------- 消息(分片存储:messages/<key>.json,每个会话一个文件) ----------
|
|
443
|
+
// taskId 为空 = 主会话;否则属于对应任务/单聊会话
|
|
444
|
+
// 旧版全部消息集中在单个 messages.json:每条消息都要全量读写整个文件,历史越长 IO 越大;
|
|
445
|
+
// 分片后单会话读写只涉及自己的文件;旧文件在首次访问时一次性迁移(原件保留为 .migrated 备份)
|
|
446
|
+
let msgMigrated = false;
|
|
447
|
+
function migrateLegacyMessages() {
|
|
448
|
+
if (msgMigrated) return;
|
|
449
|
+
msgMigrated = true;
|
|
450
|
+
let raw = null;
|
|
451
|
+
try {
|
|
452
|
+
raw = fs.readFileSync(MESSAGES_PATH, 'utf8');
|
|
453
|
+
} catch { return; } // 无旧文件
|
|
454
|
+
let all;
|
|
455
|
+
try {
|
|
456
|
+
all = JSON.parse(raw);
|
|
457
|
+
} catch {
|
|
458
|
+
// 旧文件损坏:备份现场后放弃迁移(各会话从空开始,原件可供人工恢复)
|
|
459
|
+
try { fs.copyFileSync(MESSAGES_PATH, `${MESSAGES_PATH}.corrupt-${Date.now()}`); } catch { /* ignore */ }
|
|
460
|
+
console.error(`[store] 旧版 messages.json 损坏,已备份并跳过迁移:${MESSAGES_PATH}`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!Array.isArray(all)) {
|
|
464
|
+
try { fs.renameSync(MESSAGES_PATH, MESSAGES_PATH + '.migrated'); } catch { /* ignore */ }
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const groups = new Map();
|
|
468
|
+
for (const m of all) {
|
|
469
|
+
const k = (m && m.taskId) || '';
|
|
470
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
471
|
+
groups.get(k).push(m);
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
fs.mkdirSync(MSG_DIR, { recursive: true });
|
|
475
|
+
for (const [k, list] of groups) writeJson(msgShardPath(k), list);
|
|
476
|
+
fs.renameSync(MESSAGES_PATH, MESSAGES_PATH + '.migrated'); // 保留备份供人工核对
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.error('[store] messages 迁移失败(下次启动重试):', err && err.message);
|
|
479
|
+
msgMigrated = false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 会话 key -> 分片文件名:常规 id 原样使用;其余(空/特殊字符)十六进制编码,避免文件名问题
|
|
484
|
+
function msgShardName(key) {
|
|
485
|
+
const k = key == null ? '' : String(key);
|
|
486
|
+
if (k === '') return '_main.json';
|
|
487
|
+
if (/^[A-Za-z0-9_-]{1,80}$/.test(k)) return k + '.json';
|
|
488
|
+
return '~' + Buffer.from(k).toString('hex').slice(0, 160) + '.json';
|
|
489
|
+
}
|
|
490
|
+
function msgShardPath(key) { return path.join(MSG_DIR, msgShardName(key)); }
|
|
491
|
+
|
|
447
492
|
function getMessages(taskId) {
|
|
448
|
-
|
|
449
|
-
if (taskId === undefined)
|
|
450
|
-
|
|
493
|
+
migrateLegacyMessages();
|
|
494
|
+
if (taskId === undefined) {
|
|
495
|
+
// 全量视图:合并所有分片,按时间排序还原全局顺序
|
|
496
|
+
let files = [];
|
|
497
|
+
try { files = fs.readdirSync(MSG_DIR); } catch { return []; }
|
|
498
|
+
const all = [];
|
|
499
|
+
for (const name of files) {
|
|
500
|
+
if (!name.endsWith('.json') || name.startsWith('.')) continue;
|
|
501
|
+
const list = readJson(path.join(MSG_DIR, name), []);
|
|
502
|
+
if (Array.isArray(list)) all.push(...list);
|
|
503
|
+
}
|
|
504
|
+
all.sort((a, b) => ((a && a.timestamp) || '') < ((b && b.timestamp) || '') ? -1 : 1);
|
|
505
|
+
return all;
|
|
506
|
+
}
|
|
507
|
+
const list = readJson(msgShardPath(String(taskId)), []);
|
|
508
|
+
return Array.isArray(list) ? list : [];
|
|
451
509
|
}
|
|
452
510
|
|
|
453
511
|
function addMessage(msg) {
|
|
454
|
-
|
|
512
|
+
migrateLegacyMessages();
|
|
513
|
+
fs.mkdirSync(MSG_DIR, { recursive: true });
|
|
514
|
+
const key = msg.taskId || '';
|
|
515
|
+
const msgs = readJson(msgShardPath(key), []);
|
|
455
516
|
// 主会话消息记录所属 epoch:新会话开启后,旧 epoch 消息不再传入上下文
|
|
456
517
|
const rec = {
|
|
457
518
|
id: msg.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
@@ -468,11 +529,19 @@ function addMessage(msg) {
|
|
|
468
529
|
timestamp: msg.timestamp || new Date().toISOString()
|
|
469
530
|
};
|
|
470
531
|
msgs.push(rec);
|
|
471
|
-
writeJson(
|
|
532
|
+
writeJson(msgShardPath(key), msgs);
|
|
472
533
|
}
|
|
473
534
|
|
|
474
535
|
function clearMessages() {
|
|
475
|
-
|
|
536
|
+
migrateLegacyMessages();
|
|
537
|
+
// 清空全部会话消息:删除所有分片,主会话分片重置为空数组
|
|
538
|
+
try {
|
|
539
|
+
for (const name of fs.readdirSync(MSG_DIR)) {
|
|
540
|
+
if (!name.endsWith('.json') || name.startsWith('.')) continue;
|
|
541
|
+
try { fs.unlinkSync(path.join(MSG_DIR, name)); } catch { /* ignore */ }
|
|
542
|
+
}
|
|
543
|
+
} catch { /* 目录不存在 */ }
|
|
544
|
+
writeJson(msgShardPath(''), []);
|
|
476
545
|
}
|
|
477
546
|
|
|
478
547
|
// ---------- 流转日志(智能体之间的派发/交接/返工/验收事件,append-only) ----------
|
|
@@ -566,7 +635,7 @@ function getOcSession(id) {
|
|
|
566
635
|
|
|
567
636
|
function deleteOcSession(id) {
|
|
568
637
|
saveOcSessions(getOcSessions().filter(s => s.id !== id));
|
|
569
|
-
|
|
638
|
+
try { fs.unlinkSync(msgShardPath(id)); } catch { /* 分片不存在 */ }
|
|
570
639
|
}
|
|
571
640
|
|
|
572
641
|
// ---------- 管家长期记忆(跨会话偏好与教训,读写由 memory.js 负责) ----------
|
|
@@ -614,16 +683,27 @@ function pruneOldData(days) {
|
|
|
614
683
|
// 其消息由下方第 3 步统一按孤儿清理并计数(避免重复统计)
|
|
615
684
|
}
|
|
616
685
|
|
|
617
|
-
// 3.
|
|
686
|
+
// 3. 消息(分片):孤儿会话分片整文件删除;主会话分片按 timestamp 过滤
|
|
618
687
|
const validIds = new Set([...keptTasks.map(t => t.id), ...keptSess.map(s => s.id)]);
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
688
|
+
try {
|
|
689
|
+
for (const name of fs.readdirSync(MSG_DIR)) {
|
|
690
|
+
if (!name.endsWith('.json') || name.startsWith('.')) continue;
|
|
691
|
+
const fp = path.join(MSG_DIR, name);
|
|
692
|
+
let list;
|
|
693
|
+
try { list = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { continue; } // 损坏分片留给损坏保护处理
|
|
694
|
+
if (!Array.isArray(list)) continue;
|
|
695
|
+
const tid = list.length ? (list[0].taskId || '') : '';
|
|
696
|
+
if (tid && !validIds.has(tid)) {
|
|
697
|
+
// 孤儿会话(任务/单聊已删或超期):整分片删除
|
|
698
|
+
stat.messages += list.length;
|
|
699
|
+
try { fs.unlinkSync(fp); } catch { /* ignore */ }
|
|
700
|
+
} else if (!tid) {
|
|
701
|
+
// 主会话:按时间过滤
|
|
702
|
+
const kept = list.filter(m => !tsOf(m) || tsOf(m) >= cutoff);
|
|
703
|
+
if (kept.length !== list.length) { stat.messages += list.length - kept.length; writeJson(fp, kept); }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} catch { /* 无目录 */ }
|
|
627
707
|
|
|
628
708
|
// 4. 流转日志:超期事件行过滤重写
|
|
629
709
|
try {
|
|
@@ -682,5 +762,6 @@ module.exports = {
|
|
|
682
762
|
upsertOcSession,
|
|
683
763
|
getOcSession,
|
|
684
764
|
deleteOcSession,
|
|
685
|
-
pruneOldData
|
|
765
|
+
pruneOldData,
|
|
766
|
+
getCorruptedFiles: () => safejson.corruptedFiles()
|
|
686
767
|
};
|
package/app/public/cards.html
CHANGED
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
.btn.danger{border-color:var(--err);color:var(--err)}
|
|
30
30
|
.btn:disabled{opacity:.45;cursor:not-allowed}
|
|
31
31
|
.status-pill{font-size:12px;color:var(--dim)}
|
|
32
|
+
.filter-box{display:flex;align-items:center;gap:4px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
|
|
33
|
+
.filter-box input{border:none;outline:none;font-size:12px;width:130px;background:transparent;color:inherit}
|
|
34
|
+
/* 详情复制按钮 */
|
|
35
|
+
.copy-btn{border:1px solid var(--line);background:transparent;color:var(--dim);font-size:11px;border-radius:4px;padding:1px 8px;cursor:pointer;margin-left:6px}
|
|
36
|
+
.copy-btn:hover{color:var(--acc2);border-color:var(--acc2)}
|
|
37
|
+
.copy-btn.done{color:#2f9e44;border-color:#2f9e44}
|
|
38
|
+
/* 看板统计条:总览 + 完成率进度 */
|
|
39
|
+
#boardStats{display:flex;align-items:center;gap:10px;padding:8px 18px;font-size:12px;color:var(--dim);border-bottom:1px solid var(--line);background:var(--panel)}
|
|
40
|
+
#boardStats b{color:inherit}
|
|
41
|
+
.done-bar{flex:1;height:6px;border-radius:3px;background:var(--line);overflow:hidden;max-width:360px}
|
|
42
|
+
.done-bar i{display:block;height:100%;background:linear-gradient(90deg,var(--acc2),#2f9e44);border-radius:3px;transition:width .4s}
|
|
32
43
|
.ws-box{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
|
|
33
44
|
.ws-box input{border:none;outline:none;font-size:12px;width:200px;background:transparent}
|
|
34
45
|
.ws-box .ws-save{font-size:12px;color:var(--acc);cursor:pointer;white-space:nowrap}
|
|
@@ -148,6 +159,25 @@
|
|
|
148
159
|
.trash-item .ti-info{flex:1;min-width:0}
|
|
149
160
|
.trash-item .ti-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
150
161
|
.trash-item .ti-sub{font-size:11px;color:var(--dim)}
|
|
162
|
+
/* 内核状态横幅(缺失/演示模式/数据保护时显示) */
|
|
163
|
+
.kernel-banner{display:none;gap:8px;align-items:center;padding:8px 18px;font-size:12.5px;line-height:1.6;border-bottom:1px solid var(--line)}
|
|
164
|
+
.kernel-banner.show{display:flex}
|
|
165
|
+
.kernel-banner.kb-missing{background:#fdeceb;color:#b03a31}
|
|
166
|
+
.kernel-banner.kb-demo{background:#fdf6ec;color:#8a5a00}
|
|
167
|
+
.kernel-banner.kb-corrupt{background:#fdeceb;color:#b03a31}
|
|
168
|
+
/* 内核徽标(正常时显示在状态胶囊旁) */
|
|
169
|
+
.kernel-chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dim);background:#eef0f3;border:1px solid var(--line);border-radius:6px;padding:2px 7px}
|
|
170
|
+
/* 执行中卡的运行时长徽标 */
|
|
171
|
+
.badge.run-elapsed{background:#eef7ff;color:var(--acc2);border-color:#c4e2ff;font-variant-numeric:tabular-nums}
|
|
172
|
+
/* 追加聊天进行中徽标 */
|
|
173
|
+
.badge.fu-live{background:#f1e9ff;color:#8a6fe8;border-color:#ddd0ff}
|
|
174
|
+
/* 单卡停止按钮 */
|
|
175
|
+
.icon-btn.danger:hover{color:var(--err);border-color:var(--err)}
|
|
176
|
+
/* 详情弹窗:任务内容块 */
|
|
177
|
+
.detail-content{background:#fafbfc;border:1px solid var(--line);border-radius:10px;padding:12px;margin:2px 0 10px;white-space:pre-wrap;word-break:break-word;max-height:18vh;overflow:auto;line-height:1.6;font-size:13px}
|
|
178
|
+
/* 空看板引导 */
|
|
179
|
+
.board-guide{grid-column:1/-1;text-align:center;padding:40px 10px;color:var(--dim)}
|
|
180
|
+
.board-guide .bg-actions{display:flex;gap:10px;justify-content:center;margin-top:14px}
|
|
151
181
|
</style>
|
|
152
182
|
</head>
|
|
153
183
|
<body>
|
|
@@ -155,6 +185,10 @@
|
|
|
155
185
|
<a class="backlink" href="/" title="返回 Agents Chat 群聊">← 群聊</a>
|
|
156
186
|
<h1>🗂️ 多任务编排</h1>
|
|
157
187
|
<span class="status-pill" id="statusPill">空闲</span>
|
|
188
|
+
<span class="kernel-chip" id="kernelChip" style="display:none"></span>
|
|
189
|
+
<div class="filter-box" title="按标题或内容即时过滤看板">
|
|
190
|
+
🔍 <input id="cardFilter" placeholder="搜索任务…" autocomplete="off" />
|
|
191
|
+
</div>
|
|
158
192
|
<span class="sp"></span>
|
|
159
193
|
<div class="ws-box" title="工作区:选定后 Agent 在该目录读写相关文件(可选)">
|
|
160
194
|
📁 <input id="wsInput" placeholder="工作文件夹(点右侧选择)" />
|
|
@@ -167,13 +201,22 @@
|
|
|
167
201
|
</select>
|
|
168
202
|
</div>
|
|
169
203
|
<button class="btn" id="btnNew">+ 新建任务</button>
|
|
170
|
-
<button class="btn primary" id="btnRun">▶ 开始编排</button>
|
|
171
|
-
<button class="btn danger" id="btnStop" disabled>⏹ 停止</button>
|
|
204
|
+
<button class="btn primary" id="btnRun" title="按依赖与优先级调度全部待执行任务">▶ 开始编排</button>
|
|
205
|
+
<button class="btn danger" id="btnStop" disabled title="停止编排:进行中的任务复位为待执行(不影响追加聊天),未开始的任务保留">⏹ 停止</button>
|
|
172
206
|
<button class="btn" id="btnClear">清空</button>
|
|
173
207
|
</header>
|
|
174
208
|
|
|
209
|
+
<div class="kernel-banner" id="kernelBanner"></div>
|
|
210
|
+
|
|
175
211
|
<div class="procbar" id="procBar"><span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span></div>
|
|
176
212
|
|
|
213
|
+
<div id="boardStats">
|
|
214
|
+
<b id="stTotal">0</b> 项任务
|
|
215
|
+
<span id="stDoneRate">完成率 0%</span>
|
|
216
|
+
<div class="done-bar"><i id="stDoneBar" style="width:0%"></i></div>
|
|
217
|
+
<span id="stFailed" style="color:#c92a2a"></span>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
177
220
|
<main>
|
|
178
221
|
<!-- 左侧:依赖图面板(约 1/4 宽,可收起) -->
|
|
179
222
|
<aside id="graphPanel">
|
|
@@ -209,7 +252,7 @@
|
|
|
209
252
|
<label>任务内容(描述,支持 Markdown)</label>
|
|
210
253
|
<textarea id="fContent" placeholder="把要做的事详细写在这里,Agent 会据此执行"></textarea>
|
|
211
254
|
<div class="row">
|
|
212
|
-
<div><label
|
|
255
|
+
<div><label>优先级(数字越小越优先;拖拽看板卡会按新顺序重写优先级)</label><input id="fPriority" type="number" value="999" /></div>
|
|
213
256
|
<div><label>执行模式</label>
|
|
214
257
|
<select id="fMode">
|
|
215
258
|
<option value="new">新进程(new)</option>
|
|
@@ -237,10 +280,12 @@
|
|
|
237
280
|
<h3 id="dTitle">任务详情</h3>
|
|
238
281
|
<div id="dMeta" class="meta" style="margin-bottom:10px"></div>
|
|
239
282
|
<div class="dep-tag" id="dDeps"></div>
|
|
283
|
+
<label>任务内容</label>
|
|
284
|
+
<div class="detail-content" id="dContent"><span class="empty">(无任务内容)</span></div>
|
|
240
285
|
<div class="detail-err" id="dError" style="display:none"></div>
|
|
241
286
|
<label>执行过程(实时)</label>
|
|
242
287
|
<div class="detail-log" id="dLog"><span class="empty">暂无过程记录</span></div>
|
|
243
|
-
<label
|
|
288
|
+
<label>最终结果 <button class="copy-btn" id="dResultCopy" title="复制完整结果文本">复制</button></label>
|
|
244
289
|
<div class="detail-result" id="dResult"><span class="empty">尚未产生结果</span></div>
|
|
245
290
|
<div class="followup-box" id="fuBox">
|
|
246
291
|
<div class="fu-head">💬 追加聊天 <span class="fu-st" id="fuState"></span></div>
|
|
@@ -280,24 +325,81 @@ const $ = s => document.querySelector(s);
|
|
|
280
325
|
const api = async (url, opt) => { const r = await fetch(url, opt); return r.json(); };
|
|
281
326
|
const post = (url, body) => api(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})});
|
|
282
327
|
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); }
|
|
283
|
-
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
|
328
|
+
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML.replace(/"/g,'"').replace(/'/g,''');}
|
|
284
329
|
|
|
285
330
|
let CARDS = [];
|
|
286
331
|
let OPEN_ID = null;
|
|
332
|
+
let FOLLOWUP_IDS = []; // 追加聊天进行中的卡 id
|
|
333
|
+
let RUNNER_INFO = null; // { kind, label }
|
|
334
|
+
let CORRUPTED = []; // 已进入损坏保护的数据文件
|
|
335
|
+
let LOAD_SEQ = 0; // loadCards 响应乱序守卫:快速触发多次时只认最新请求
|
|
287
336
|
|
|
288
337
|
async function loadCards(){
|
|
338
|
+
const seq = ++LOAD_SEQ;
|
|
289
339
|
const data = await api('/api/cards');
|
|
340
|
+
if (seq !== LOAD_SEQ) return; // 已有更新的请求发出:丢弃本次过期响应
|
|
290
341
|
CARDS = data.cards || [];
|
|
342
|
+
FOLLOWUP_IDS = data.followupIds || [];
|
|
343
|
+
RUNNER_INFO = data.runner || null;
|
|
344
|
+
CORRUPTED = data.corrupted || [];
|
|
291
345
|
$('#statusPill').textContent = data.running ? '编排中…' : '空闲';
|
|
292
346
|
$('#btnRun').disabled = data.running;
|
|
293
347
|
$('#btnStop').disabled = !data.running;
|
|
294
348
|
if(data.config) $('#wsInput').value = data.config.workspace || '';
|
|
295
349
|
if(data.maxParallel) $('#parallelSel').value = String(data.maxParallel);
|
|
350
|
+
renderKernelBanner();
|
|
296
351
|
render();
|
|
297
352
|
renderGraph();
|
|
298
353
|
loadTrashCount();
|
|
299
354
|
}
|
|
300
355
|
|
|
356
|
+
// 文本复制(优先剪贴板 API,旧环境回退 execCommand)
|
|
357
|
+
function copyText(text, btn){
|
|
358
|
+
const done=()=>{ if(!btn) return; const old=btn.textContent; btn.textContent='已复制'; btn.classList.add('done'); setTimeout(()=>{ btn.textContent=old; btn.classList.remove('done'); },1200); };
|
|
359
|
+
const s=String(text==null?'':text);
|
|
360
|
+
if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(s).then(done).catch(()=>{ fallbackCopy(s); done(); }); }
|
|
361
|
+
else { fallbackCopy(s); done(); }
|
|
362
|
+
}
|
|
363
|
+
function fallbackCopy(s){
|
|
364
|
+
const ta=document.createElement('textarea');
|
|
365
|
+
ta.value=s; ta.style.cssText='position:fixed;opacity:0';
|
|
366
|
+
document.body.appendChild(ta); ta.select();
|
|
367
|
+
try{ document.execCommand('copy'); }catch(e){}
|
|
368
|
+
document.body.removeChild(ta);
|
|
369
|
+
}
|
|
370
|
+
// 搜索过滤:输入即时重渲染
|
|
371
|
+
$('#cardFilter').addEventListener('input', render);
|
|
372
|
+
|
|
373
|
+
// 内核状态展示:正常=头部小徽标;缺失/演示模式/数据保护=醒目横幅
|
|
374
|
+
function renderKernelBanner(){
|
|
375
|
+
const chip=$('#kernelChip'), banner=$('#kernelBanner');
|
|
376
|
+
const k = RUNNER_INFO && RUNNER_INFO.kind;
|
|
377
|
+
if (CORRUPTED.length){
|
|
378
|
+
chip.style.display='none';
|
|
379
|
+
banner.className='kernel-banner show kb-corrupt';
|
|
380
|
+
banner.innerHTML='⚠ <b>数据保护已触发</b>:'+CORRUPTED.map(f=>'<code>'+esc(f.split(/[\\/]/).pop())+'</code>').join('、')+' 解析失败(原文件已备份为 .corrupt-*),为防数据丢失写入已暂停。请检查数据目录后恢复备份文件。';
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (k === 'missing'){
|
|
384
|
+
chip.style.display='none';
|
|
385
|
+
banner.className='kernel-banner show kb-missing';
|
|
386
|
+
banner.innerHTML='❌ <b>未检测到执行内核</b>:任务无法真实执行。请安装 opencode(推荐):终端运行 <code>npm install -g opencode-ai</code>,安装并登录后刷新本页。';
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (k === 'demo'){
|
|
390
|
+
chip.style.display='none';
|
|
391
|
+
banner.className='kernel-banner show kb-demo';
|
|
392
|
+
banner.innerHTML='⚠ <b>演示模式</b>(AGENTS_CHAT_MOCK=1):输出均为模拟结果,任务执行不可用。删除 .env 中该配置并安装内核后即为真实执行。';
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
banner.className='kernel-banner';
|
|
396
|
+
if (RUNNER_INFO && RUNNER_INFO.label){
|
|
397
|
+
chip.style.display='inline-flex';
|
|
398
|
+
chip.textContent='⚡ '+RUNNER_INFO.label;
|
|
399
|
+
chip.title='当前执行内核:'+RUNNER_INFO.label;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
301
403
|
function badge(card){
|
|
302
404
|
let p = card.priority;
|
|
303
405
|
let pc = p<=100?'prio-high':(p<=500?'prio-mid':'');
|
|
@@ -346,11 +448,16 @@ function cardEl(card){
|
|
|
346
448
|
const blkHtml = blk ? `<div class="card-blocked${blk.kind==='wait'?' wait':''}">${blk.kind==='fail'?'⛔':'⏳'} ${esc(blk.text)}</div>` : '';
|
|
347
449
|
const resetHtml = (card.status==='pending' && /服务重启/.test(card.error||''))
|
|
348
450
|
? `<div class="card-reset">↻ ${esc(errSummary(card.error))}</div>` : '';
|
|
451
|
+
// 执行中:实时运行时长徽标(每秒刷新);追加聊天中:紫色徽标
|
|
452
|
+
const elapsedHtml = (card.status==='running' && card.startedAt)
|
|
453
|
+
? `<span class="badge run-elapsed" data-start="${card.startedAt}">⏱ ${fmtElapsed(Date.now()-card.startedAt)}</span>` : '';
|
|
454
|
+
const fuHtml = FOLLOWUP_IDS.includes(card.id) ? '<span class="badge fu-live">💬 回复中</span>' : '';
|
|
349
455
|
el.innerHTML = `<div class="title">${esc(card.title)}</div>
|
|
350
|
-
<div class="meta">${badge(card)} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
|
|
456
|
+
<div class="meta">${badge(card)} ${elapsedHtml} ${fuHtml} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
|
|
351
457
|
${errHtml}${blkHtml}${resetHtml}
|
|
352
458
|
<div class="op">
|
|
353
|
-
<button class="icon-btn" data-act="
|
|
459
|
+
${card.status==='running'?'<button class="icon-btn danger" data-act="stop" title="停止该任务(复位为待执行,本轮编排不再自动调度)">⏹</button>':''}
|
|
460
|
+
<button class="icon-btn" data-act="run" title="执行${card.status==='pending'?'(若依赖未完成将先确认)':'(旧结果先归档)'}">▶</button>
|
|
354
461
|
<button class="icon-btn" data-act="reset" title="重置(清除结果回到待执行)">⟲</button>
|
|
355
462
|
<button class="icon-btn" data-act="copy" title="复制为新任务">⧉</button>
|
|
356
463
|
<button class="icon-btn" data-act="edit" title="编辑">✎</button>
|
|
@@ -360,6 +467,7 @@ function cardEl(card){
|
|
|
360
467
|
if(a==='del') delCard(card.id);
|
|
361
468
|
else if(a==='edit') openEdit(card);
|
|
362
469
|
else if(a==='run') runOne(card.id);
|
|
470
|
+
else if(a==='stop') stopOne(card.id);
|
|
363
471
|
else if(a==='reset') resetCard(card.id);
|
|
364
472
|
else if(a==='copy') copyCard(card);
|
|
365
473
|
return; }
|
|
@@ -371,6 +479,20 @@ function cardEl(card){
|
|
|
371
479
|
el.addEventListener('drop', e=>{ e.preventDefault(); el.classList.remove('drag-over'); onDrop(card.id, el); });
|
|
372
480
|
return el;
|
|
373
481
|
}
|
|
482
|
+
// 运行时长格式化:ms -> "1:05" / "12:03:44"
|
|
483
|
+
function fmtElapsed(ms){
|
|
484
|
+
const s = Math.max(0, Math.floor(ms/1000));
|
|
485
|
+
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
|
|
486
|
+
const p2 = n => String(n).padStart(2,'0');
|
|
487
|
+
return h ? `${h}:${p2(m)}:${p2(sec)}` : `${m}:${p2(sec)}`;
|
|
488
|
+
}
|
|
489
|
+
// 每秒刷新执行中卡片的运行时长(不重建 DOM,只更新徽标文本)
|
|
490
|
+
setInterval(()=>{
|
|
491
|
+
document.querySelectorAll('.badge.run-elapsed').forEach(b=>{
|
|
492
|
+
const st = Number(b.dataset.start);
|
|
493
|
+
if (st) b.textContent = '⏱ ' + fmtElapsed(Date.now()-st);
|
|
494
|
+
});
|
|
495
|
+
}, 1000);
|
|
374
496
|
let dragId=null;
|
|
375
497
|
function onDrop(targetId, targetEl){
|
|
376
498
|
if(!dragId || dragId===targetId) return;
|
|
@@ -390,13 +512,61 @@ document.addEventListener('dragover', e=>{ window.__dropY = e.clientY; }, true);
|
|
|
390
512
|
function render(){
|
|
391
513
|
const cols = {pending:$('#colPending'),running:$('#colRunning'),done:$('#colDone'),failed:$('#colFailed')};
|
|
392
514
|
for(const k in cols) cols[k].innerHTML='';
|
|
515
|
+
// 搜索过滤:按标题 + 内容即时匹配(空串 = 全部)
|
|
516
|
+
const q=(($('#cardFilter').value||'').trim().toLowerCase());
|
|
517
|
+
const shown = q ? CARDS.filter(c=>((c.title||'')+'\n'+(c.content||'')).toLowerCase().includes(q)) : CARDS;
|
|
393
518
|
let counts={pending:0,running:0,done:0,failed:0};
|
|
394
|
-
for(const c of
|
|
519
|
+
for(const c of shown){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
|
|
395
520
|
$('#cntPending').textContent=counts.pending||''; $('#cntRunning').textContent=counts.running||'';
|
|
396
521
|
$('#cntDone').textContent=counts.done||''; $('#cntFailed').textContent=counts.failed||'';
|
|
522
|
+
// 统计条:全部卡的总览(不受过滤影响)+ 完成率
|
|
523
|
+
const total=CARDS.length, doneN=CARDS.filter(c=>c.status==='done').length, failedN=CARDS.filter(c=>c.status==='failed').length;
|
|
524
|
+
$('#stTotal').textContent=total;
|
|
525
|
+
const pct= total? Math.round(doneN/total*100):0;
|
|
526
|
+
$('#stDoneRate').textContent=`完成率 ${pct}%(${doneN}/${total})`;
|
|
527
|
+
$('#stDoneBar').style.width=pct+'%';
|
|
528
|
+
$('#stFailed').textContent= failedN? `失败 ${failedN}` : '';
|
|
529
|
+
// 空看板引导:一个任务都没有时给出上手入口(含示例)
|
|
530
|
+
if(!CARDS.length){
|
|
531
|
+
cols.pending.innerHTML=`<div class="board-guide">
|
|
532
|
+
还没有任何任务<br/>把要做的事写成一张张卡牌,Agent 会按依赖与优先级自动调度执行
|
|
533
|
+
<div class="bg-actions">
|
|
534
|
+
<button class="btn primary" onclick="openEdit(null)">+ 新建第一个任务</button>
|
|
535
|
+
<button class="btn" id="btnExamples">📦 载入示例任务</button>
|
|
536
|
+
</div></div>`;
|
|
537
|
+
const be=$('#btnExamples'); if(be) be.onclick=loadExamples;
|
|
538
|
+
for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// 搜索无匹配:给出明确反馈与一键清除
|
|
542
|
+
if(q && !shown.length){
|
|
543
|
+
cols.pending.innerHTML=`<div class="board-guide">没有匹配「${esc(q)}」的任务<div class="bg-actions"><button class="btn" onclick="document.getElementById('cardFilter').value='';render()">清除搜索</button></div></div>`;
|
|
544
|
+
for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
397
547
|
for(const k in cols){ if(!cols[k].children.length) cols[k].innerHTML='<div class="empty">—</div>'; }
|
|
398
548
|
}
|
|
399
549
|
|
|
550
|
+
// 载入示例:三张卡展示三种执行模式与依赖关系(可编辑/删除,当作模板随意改造)
|
|
551
|
+
async function loadExamples(){
|
|
552
|
+
const mk = (title, content, extra) => post('/api/cards', Object.assign({ title, content }, extra||{})).then(r=>r.card);
|
|
553
|
+
const a = await mk('示例A · 独立任务', '列出当前工作目录下的全部文件,并统计各类型文件的数量。', { priority: 1 });
|
|
554
|
+
await mk('示例B · 续聊任务', '接续上一个任务的结论:把统计结果整理成一份 markdown 表格。', { priority: 2, mode: 'continue', chainId: a.id, dependsOn: [a.id] });
|
|
555
|
+
await mk('示例C · 并行任务', '查询今日天气概况并给出一句话总结(与示例A/B 互不依赖,可同时执行)。', { priority: 3, mode: 'parallel' });
|
|
556
|
+
await loadCards();
|
|
557
|
+
toast('已载入 3 个示例任务,点击「开始编排」体验');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 单卡停止:执行中的任务杀进程并复位为待执行(本轮编排不再自动调度)
|
|
561
|
+
async function stopOne(id){
|
|
562
|
+
const c=CARDS.find(x=>x.id===id);
|
|
563
|
+
if(!c || c.status!=='running') return;
|
|
564
|
+
if(!confirm(`停止任务「${c.title}」?该任务将复位为待执行,本轮编排不再自动调度它。`)) return;
|
|
565
|
+
const r=await post('/api/cards/'+id+'/stop');
|
|
566
|
+
if(!r.success) toast(r.error||'停止失败');
|
|
567
|
+
await loadCards();
|
|
568
|
+
}
|
|
569
|
+
|
|
400
570
|
// ---- 编辑/新建 ----
|
|
401
571
|
// 候选列表排除自身:禁止把自己选为依赖/链首(防自环)
|
|
402
572
|
function fillSelectors(){
|
|
@@ -458,7 +628,11 @@ $('#btnEditSave').onclick=async()=>{
|
|
|
458
628
|
dependsOn:deps };
|
|
459
629
|
if(EDIT_ID) await api('/api/cards/'+EDIT_ID,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
460
630
|
else await post('/api/cards',body);
|
|
461
|
-
$('#maskEdit').classList.remove('show');
|
|
631
|
+
$('#maskEdit').classList.remove('show');
|
|
632
|
+
await loadCards();
|
|
633
|
+
// 编排状态联动提示:编排进行中=新卡自动加入调度;空闲=明确告知需手动开始
|
|
634
|
+
if(!EDIT_ID) toast($('#btnRun').disabled ? '已保存,新任务将自动加入本轮调度' : '已保存。当前编排空闲,点「▶ 开始编排」执行');
|
|
635
|
+
else toast('已保存');
|
|
462
636
|
};
|
|
463
637
|
async function delCard(id){ if(!confirm('确认删除该任务?将移入垃圾桶(30 天内可还原)。'))return; await api('/api/cards/'+id,{method:'DELETE'}); await loadCards(); toast('已移入垃圾桶'); }
|
|
464
638
|
$('#btnClear').onclick=async()=>{ if(!confirm('清空全部任务?将移入垃圾桶(30 天内可还原)。'))return; await post('/api/cards/clear'); await loadCards(); };
|
|
@@ -481,6 +655,13 @@ $('#parallelSel').onchange=async()=>{
|
|
|
481
655
|
$('#dExport').onclick=()=>{ if(!OPEN_ID)return; window.open('/api/cards/'+encodeURIComponent(OPEN_ID)+'/export.md','_blank'); };
|
|
482
656
|
async function runOne(id){
|
|
483
657
|
const c=CARDS.find(x=>x.id===id);
|
|
658
|
+
// 依赖未完成时显式确认:让「立即执行会绕过依赖」这件事说清楚
|
|
659
|
+
if(c && c.status==='pending' && c.dependsOn && c.dependsOn.length){
|
|
660
|
+
const undone = c.dependsOn.filter(d=>{ const dc=CARDS.find(x=>x.id===d); return !dc || dc.status!=='done'; });
|
|
661
|
+
if(undone.length){
|
|
662
|
+
if(!confirm(`该任务还有 ${undone.length} 个依赖未完成(调度器会等它们完成后才自动执行)。\n立即执行将忽略依赖直接运行,确认?`)) return;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
484
665
|
// 重跑保护:已有结果时先确认(旧结果会归档进过程日志)
|
|
485
666
|
if(c && (c.status==='done'||c.status==='failed') && c.result){
|
|
486
667
|
if(!confirm('重新执行将把上次结果归档到过程日志,然后开始新一轮执行。确认?')) return;
|
|
@@ -548,6 +729,10 @@ async function openDetail(id){
|
|
|
548
729
|
$('#dMeta').innerHTML=badge(card)+`<span class="badge">${card.status}</span>`;
|
|
549
730
|
const depTitles = (card.dependsOn||[]).map(d=>{const c=CARDS.find(x=>x.id===d);return c?c.title:d;});
|
|
550
731
|
$('#dDeps').textContent = depTitles.length ? ('依赖:'+depTitles.join('、')) : '';
|
|
732
|
+
// 任务内容:详情首先呈现「这个任务要求做什么」,不必再点编辑查看
|
|
733
|
+
const cbox=$('#dContent');
|
|
734
|
+
if(card.content) cbox.textContent=card.content;
|
|
735
|
+
else cbox.innerHTML='<span class="empty">(无任务内容)</span>';
|
|
551
736
|
// 失败原因块:任务执行错误优先,其次追加聊天的错误
|
|
552
737
|
const errBox=$('#dError');
|
|
553
738
|
const errText = card.error || card.followupError || '';
|
|
@@ -567,6 +752,8 @@ async function openDetail(id){
|
|
|
567
752
|
log.scrollTop=log.scrollHeight;
|
|
568
753
|
const res=$('#dResult');
|
|
569
754
|
if(card.result) res.textContent=card.result; else res.innerHTML='<span class="empty">尚未产生结果</span>';
|
|
755
|
+
const rCopy=$('#dResultCopy');
|
|
756
|
+
if(rCopy){ rCopy.disabled=!card.result; rCopy.title=card.result?'复制完整结果文本':'尚无结果可复制'; rCopy.onclick=()=>copyText(card.result||'', rCopy); }
|
|
570
757
|
$('#dRun').style.display = (card.status==='running')?'none':'inline-block';
|
|
571
758
|
// 追加聊天:任务结束(非 pending/running)即可用;无会话时提示限制
|
|
572
759
|
const fuOk = card.status!=='pending' && card.status!=='running';
|
|
@@ -738,7 +925,8 @@ async function refreshProcs(){
|
|
|
738
925
|
const d = await api('/api/cards/processes');
|
|
739
926
|
const procs = d.processes||[];
|
|
740
927
|
const bar=$('#procBar');
|
|
741
|
-
|
|
928
|
+
const kname = (RUNNER_INFO && RUNNER_INFO.label) || 'opencode';
|
|
929
|
+
if(!procs.length){ bar.innerHTML='<span class="ptitle">进程:</span><span class="proc-empty">无运行中的 '+esc(kname)+' 进程</span>'; return; }
|
|
742
930
|
bar.innerHTML='<span class="ptitle">进程:</span>'+procs.map(p=>{
|
|
743
931
|
const working=p.working;
|
|
744
932
|
return `<span class="proc-chip ${working?'':'idle'}"><span class="dot"></span><span class="pid">PID ${p.pid}</span><span class="st">${working?'工作中':'空闲'}</span></span>`;
|
|
@@ -773,8 +961,17 @@ function notifyDone(done,failed){
|
|
|
773
961
|
}catch{ /* ignore */ }
|
|
774
962
|
}
|
|
775
963
|
const es = new EventSource('/api/cards/stream');
|
|
964
|
+
// 断线重连后补拉:错过的状态迁移事件靠全量刷新弥补(init 只恢复按钮态)
|
|
965
|
+
es.onopen = ()=>{ loadCards(); refreshProcs(); };
|
|
776
966
|
es.onmessage = (e)=>{
|
|
777
967
|
let ev; try{ ev=JSON.parse(e.data); }catch{ return; }
|
|
968
|
+
if(ev.type==='init'){ // 连接建立时的初始状态(含断线重连场景)
|
|
969
|
+
$('#statusPill').textContent = ev.running ? '编排中…' : '空闲';
|
|
970
|
+
$('#btnRun').disabled = !!ev.running; $('#btnStop').disabled = !ev.running;
|
|
971
|
+
if(ev.maxParallel) $('#parallelSel').value = String(ev.maxParallel);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if(ev.type==='notice'){ toast(ev.content); }
|
|
778
975
|
if(ev.type==='runner_started'){ $('#statusPill').textContent='编排中…'; $('#btnRun').disabled=true; $('#btnStop').disabled=false; }
|
|
779
976
|
if(ev.type==='runner_stopped'){ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true; }
|
|
780
977
|
if(ev.type==='all_done'){
|
|
@@ -783,17 +980,25 @@ es.onmessage = (e)=>{
|
|
|
783
980
|
}
|
|
784
981
|
if(ev.type==='ws_warning'){ toast('⚠ 工作区路径无效:'+ev.path+',任务将在默认目录执行'); }
|
|
785
982
|
if(ev.type==='followup_start'||ev.type==='followup_done'){ loadCards(); }
|
|
786
|
-
// text
|
|
787
|
-
if(['task_start','
|
|
983
|
+
// 状态迁移事件:才触发全量刷新(text/tool 快照不改变卡片状态)
|
|
984
|
+
if(['task_start','task_done'].includes(ev.type)){
|
|
788
985
|
loadCards();
|
|
789
986
|
refreshProcs();
|
|
987
|
+
// 失败即时提醒(完成静默:看板移动可见 + all_done 汇总通知)
|
|
988
|
+
if(ev.type==='task_done' && ev.status==='failed') toast('✗「'+(ev.title||'任务')+'」执行失败,点击卡片查看原因');
|
|
790
989
|
if(OPEN_ID && ev.cardId===OPEN_ID){
|
|
791
990
|
const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
|
|
792
991
|
if(ev.type==='task_start') TEXT_PARTS.clear();
|
|
793
|
-
else if(ev.type==='tool'){ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight; }
|
|
794
992
|
else if(ev.type==='task_done'){ loadCards().then(()=>{ if(OPEN_ID===ev.cardId) openDetail(ev.cardId); }); }
|
|
795
993
|
}
|
|
796
994
|
}
|
|
995
|
+
// proc 仅刷新进程条(另有 2s 轮询兜底),不重建看板 DOM
|
|
996
|
+
if(ev.type==='proc'){ refreshProcs(); }
|
|
997
|
+
// tool 事件仅在打开对应详情时追加过程日志,避免工具密集时高频全量刷新
|
|
998
|
+
if(ev.type==='tool' && OPEN_ID===ev.cardId){
|
|
999
|
+
const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
|
|
1000
|
+
appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight;
|
|
1001
|
+
}
|
|
797
1002
|
// 正文快照独立渲染(不依赖上面的状态事件)
|
|
798
1003
|
if(ev.type==='text' && OPEN_ID===ev.cardId) renderSnapText(ev);
|
|
799
1004
|
if(ev.type==='followup_start' && OPEN_ID===ev.cardId) TEXT_PARTS.clear();
|
package/app/public/index.html
CHANGED
|
@@ -66,6 +66,14 @@
|
|
|
66
66
|
.hdr-right { display: flex; align-items: center; gap: 10px; }
|
|
67
67
|
|
|
68
68
|
#messages { flex: 1; overflow-y: auto; padding: 16px 18px; }
|
|
69
|
+
/* 消息复制按钮:hover 消息行时浮现 */
|
|
70
|
+
.copy-btn { border: none; background: rgba(0,0,0,.05); color: #888; font-size: 11px; border-radius: 4px; padding: 2px 7px; cursor: pointer; opacity: 0; transition: opacity .15s; vertical-align: middle; }
|
|
71
|
+
.msg-row:hover .copy-btn, .copy-btn:focus { opacity: 1; }
|
|
72
|
+
.copy-btn:hover { background: rgba(0,0,0,.1); color: #333; }
|
|
73
|
+
.copy-btn.done { color: #07c160; opacity: 1; }
|
|
74
|
+
/* 回到最新:阅读历史时新消息不再强制拽底,悬浮按钮一键回底 */
|
|
75
|
+
#jumpBtn { position: absolute; right: 18px; bottom: 100px; z-index: 5; display: none; border: 1px solid #dcdcdc; background: #fff; color: #333; border-radius: 16px; padding: 6px 14px; font-size: 12px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.15); }
|
|
76
|
+
#jumpBtn:hover { color: #07c160; border-color: #07c160; }
|
|
69
77
|
.sys-tip { text-align: center; margin: 10px 0; }
|
|
70
78
|
.approval-card { max-width: 480px; margin: 10px auto; background: #fff8e6; border: 1px solid #e8a33d; border-radius: 10px; padding: 10px 14px; text-align: left; }
|
|
71
79
|
.approval-card .ap-title { font-weight: 600; font-size: 13px; color: #8a5a00; margin-bottom: 4px; }
|
|
@@ -413,8 +421,9 @@
|
|
|
413
421
|
<button class="cfg-btn" id="helpBtn" onclick="openHelp()" title="使用帮助">❓ 帮助</button>
|
|
414
422
|
</div>
|
|
415
423
|
</header>
|
|
416
|
-
<div id="groupView" style="flex:1;min-height:0;display:flex;flex-direction:column">
|
|
424
|
+
<div id="groupView" style="flex:1;min-height:0;display:flex;flex-direction:column;position:relative">
|
|
417
425
|
<div id="messages"></div>
|
|
426
|
+
<button id="jumpBtn" onclick="jumpBottom()" title="有新消息了,点击回到最新">↓ 新消息</button>
|
|
418
427
|
<footer class="composer" id="groupComposer">
|
|
419
428
|
<div class="input-row">
|
|
420
429
|
<button id="rtBtn" onclick="toggleRoundtable()" title="圆桌讨论模式:所有被 @ 的智能体(未 @ 则全体)围绕主题轮流发言、互相反驳,管家当主持人控制轮数并总结共识与分歧">💬 圆桌</button>
|
|
@@ -666,7 +675,7 @@ let curOcModel = ''; // 当前选中模型(localStorage 记忆)
|
|
|
666
675
|
|
|
667
676
|
const input = document.getElementById('input');
|
|
668
677
|
|
|
669
|
-
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
|
|
678
|
+
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML.replace(/"/g, '"').replace(/'/g, '''); }
|
|
670
679
|
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
671
680
|
function fmtTime(ts) { const d = new Date(ts); return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }
|
|
672
681
|
function fmtSched(ts) { const d = new Date(ts); const p = (n) => String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }
|
|
@@ -731,6 +740,17 @@ async function init() {
|
|
|
731
740
|
|
|
732
741
|
input.addEventListener('input', handleAtInput);
|
|
733
742
|
input.addEventListener('blur', () => setTimeout(hideAt, 150));
|
|
743
|
+
// 消息区滚动:贴底时收起「新消息」悬浮按钮
|
|
744
|
+
document.getElementById('messages').addEventListener('scroll', () => {
|
|
745
|
+
if (atBottom()) { const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'none'; }
|
|
746
|
+
});
|
|
747
|
+
// 输入历史回填:输入框为空时按 ↑ 调出最近发送的一条(再按 Enter 直接重发)
|
|
748
|
+
input.addEventListener('keydown', (e) => {
|
|
749
|
+
if (e.key === 'ArrowUp' && input.value === '' && lastSentInputs.length) {
|
|
750
|
+
e.preventDefault();
|
|
751
|
+
input.value = lastSentInputs[lastSentInputs.length - 1];
|
|
752
|
+
}
|
|
753
|
+
});
|
|
734
754
|
input.addEventListener('keydown', (e) => {
|
|
735
755
|
if (atState) {
|
|
736
756
|
if (e.key === 'ArrowDown') { e.preventDefault(); atState.active = (atState.active + 1) % atState.items.length; renderAt(); return; }
|
|
@@ -897,7 +917,7 @@ function soloMsgNode(m) {
|
|
|
897
917
|
row.className = 'msg-row self';
|
|
898
918
|
row.innerHTML = `
|
|
899
919
|
<div class="avatar me">我</div>
|
|
900
|
-
<div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
|
|
920
|
+
<div class="msg-body"><div class="msg-name" style="text-align:right"><button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)">复制</button></div><div class="bubble right">${esc(m.content)}</div></div>`;
|
|
901
921
|
return row;
|
|
902
922
|
}
|
|
903
923
|
// assistant:Markdown 渲染(opencode 输出天然是 Markdown)
|
|
@@ -906,7 +926,7 @@ function soloMsgNode(m) {
|
|
|
906
926
|
row.innerHTML = `
|
|
907
927
|
<div class="avatar solo-av">🤖</div>
|
|
908
928
|
<div class="msg-body">
|
|
909
|
-
<div class="msg-name">${esc(m.agentName || 'OpenCode')}
|
|
929
|
+
<div class="msg-name">${esc(m.agentName || 'OpenCode')} <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)" title="复制原始文本(含 Markdown 源码)">复制</button></div>
|
|
910
930
|
<div class="bubble left solo-bubble"><div class="solo-part-md">${renderMd(m.content || '')}</div></div>
|
|
911
931
|
</div>`;
|
|
912
932
|
return row;
|
|
@@ -1542,6 +1562,30 @@ function renderHeader() {
|
|
|
1542
1562
|
|
|
1543
1563
|
// ---------- 消息渲染 ----------
|
|
1544
1564
|
function scrollBottom() { const box = document.getElementById('messages'); box.scrollTop = box.scrollHeight; }
|
|
1565
|
+
// 贴底判定:滚离底部超过 80px 视为「正在阅读历史」,新消息不再强制拽底
|
|
1566
|
+
function atBottom() { const b = document.getElementById('messages'); return !b || (b.scrollHeight - b.scrollTop - b.clientHeight < 80); }
|
|
1567
|
+
// 跟随滚动:贴底时自动滚到最新;阅读历史时改亮「新消息」按钮
|
|
1568
|
+
function followScroll() {
|
|
1569
|
+
if (atBottom()) scrollBottom();
|
|
1570
|
+
else { const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'block'; }
|
|
1571
|
+
}
|
|
1572
|
+
function jumpBottom() { scrollBottom(); const j = document.getElementById('jumpBtn'); if (j) j.style.display = 'none'; }
|
|
1573
|
+
|
|
1574
|
+
// 文本复制(优先剪贴板 API,旧环境回退 execCommand),按钮成功后短暂反馈
|
|
1575
|
+
function copyText(text, btn) {
|
|
1576
|
+
const done = () => { if (!btn) return; const old = btn.textContent; btn.textContent = '已复制'; btn.classList.add('done'); setTimeout(() => { btn.textContent = old; btn.classList.remove('done'); }, 1200); };
|
|
1577
|
+
const s = String(text == null ? '' : text);
|
|
1578
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1579
|
+
navigator.clipboard.writeText(s).then(done).catch(() => { fallbackCopy(s); done(); });
|
|
1580
|
+
} else { fallbackCopy(s); done(); }
|
|
1581
|
+
}
|
|
1582
|
+
function fallbackCopy(s) {
|
|
1583
|
+
const ta = document.createElement('textarea');
|
|
1584
|
+
ta.value = s; ta.style.cssText = 'position:fixed;opacity:0';
|
|
1585
|
+
document.body.appendChild(ta); ta.select();
|
|
1586
|
+
try { document.execCommand('copy'); } catch { /* ignore */ }
|
|
1587
|
+
document.body.removeChild(ta);
|
|
1588
|
+
}
|
|
1545
1589
|
|
|
1546
1590
|
function planNode(m) {
|
|
1547
1591
|
const row = document.createElement('div');
|
|
@@ -1559,7 +1603,7 @@ function planNode(m) {
|
|
|
1559
1603
|
row.innerHTML = `
|
|
1560
1604
|
<div class="avatar butler">🎩</div>
|
|
1561
1605
|
<div class="msg-body">
|
|
1562
|
-
<div class="msg-name">管家 ·
|
|
1606
|
+
<div class="msg-name">管家 · 调度规划 <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent((p && p.thought) || m.content || '')}'), this)" title="复制规划思路">复制</button></div>
|
|
1563
1607
|
<div class="bubble left plan-card">${inner}</div>
|
|
1564
1608
|
</div>`;
|
|
1565
1609
|
return row;
|
|
@@ -1708,7 +1752,7 @@ function msgNode(m) {
|
|
|
1708
1752
|
row.className = 'msg-row self';
|
|
1709
1753
|
row.innerHTML = `
|
|
1710
1754
|
<div class="avatar me">我</div>
|
|
1711
|
-
<div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
|
|
1755
|
+
<div class="msg-body"><div class="msg-name" style="text-align:right"><button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)">复制</button></div><div class="bubble right">${esc(m.content)}</div></div>`;
|
|
1712
1756
|
return row;
|
|
1713
1757
|
}
|
|
1714
1758
|
if (m.phase === 'plan') return planNode(m);
|
|
@@ -1722,7 +1766,7 @@ function msgNode(m) {
|
|
|
1722
1766
|
row.innerHTML = `
|
|
1723
1767
|
<div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${avIcon}</div>
|
|
1724
1768
|
<div class="msg-body">
|
|
1725
|
-
<div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}${m.agentId === butlerId ? ' · 管家' : ''}
|
|
1769
|
+
<div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}${m.agentId === butlerId ? ' · 管家' : ''} <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)" title="复制原始文本(含 Markdown 源码)">复制</button></div>
|
|
1726
1770
|
${bodyHtml}
|
|
1727
1771
|
${m.outputPath ? `<div class="msg-file" ${/\.md$/i.test(m.outputPath) ? `data-path="${escAttr(m.outputPath)}" title="点击预览该 Markdown 存档"` : `title="系统过程存档:该智能体本阶段完整输出(内部交接用)。用户最终要的成果文件在产出存档目录中,以管家交付清单里的完整路径为准"`}>📄 过程存档 ${fileNodeText(m.outputPath)}</div>` : ''}
|
|
1728
1772
|
</div>`;
|
|
@@ -1742,17 +1786,18 @@ function renderMessages() {
|
|
|
1742
1786
|
finalizeMsgLinks(m); // 定稿消息中的 .md 路径变成可点击预览链接
|
|
1743
1787
|
}
|
|
1744
1788
|
scrollBottom();
|
|
1789
|
+
const jb = document.getElementById('jumpBtn'); if (jb) jb.style.display = 'none';
|
|
1745
1790
|
}
|
|
1746
1791
|
|
|
1747
1792
|
// 追加或更新一条流式消息(仅当前会话操作 DOM)
|
|
1748
1793
|
function ensureMsgDom(m) {
|
|
1749
1794
|
const key = sessKey(m.taskId || curTaskId);
|
|
1750
1795
|
if (key !== curKey()) return;
|
|
1751
|
-
if (m._dom && document.contains(m._dom)) { if (m._tx) m._tx.textContent = m.content;
|
|
1796
|
+
if (m._dom && document.contains(m._dom)) { if (m._tx) m._tx.textContent = m.content; followScroll(); return; }
|
|
1752
1797
|
const node = msgNode(m);
|
|
1753
1798
|
m._dom = node;
|
|
1754
1799
|
document.getElementById('messages').appendChild(node);
|
|
1755
|
-
|
|
1800
|
+
followScroll();
|
|
1756
1801
|
}
|
|
1757
1802
|
|
|
1758
1803
|
function pushMsg(m) {
|
|
@@ -1851,7 +1896,7 @@ function onSaved(ev) {
|
|
|
1851
1896
|
}
|
|
1852
1897
|
file.innerHTML = '📄 过程存档 ' + fileNodeText(ev.path);
|
|
1853
1898
|
m._dom.querySelector('.msg-body').appendChild(file);
|
|
1854
|
-
|
|
1899
|
+
followScroll();
|
|
1855
1900
|
}
|
|
1856
1901
|
finalizeMsgLinks(m); // 交付文本中的成果文件路径也变成可点击链接
|
|
1857
1902
|
return;
|
|
@@ -1940,6 +1985,7 @@ async function stopRun(scope) {
|
|
|
1940
1985
|
} catch { toast('停止请求失败,请重试'); }
|
|
1941
1986
|
}
|
|
1942
1987
|
|
|
1988
|
+
let lastSentInputs = []; // 输入历史(空输入框按 ↑ 回填最近一条)
|
|
1943
1989
|
async function send() {
|
|
1944
1990
|
const text = input.value.trim();
|
|
1945
1991
|
if (!text || chatBusy) return;
|
|
@@ -1950,6 +1996,10 @@ async function send() {
|
|
|
1950
1996
|
try {
|
|
1951
1997
|
input.value = '';
|
|
1952
1998
|
hideAt();
|
|
1999
|
+
if (lastSentInputs[lastSentInputs.length - 1] !== text) {
|
|
2000
|
+
lastSentInputs.push(text);
|
|
2001
|
+
if (lastSentInputs.length > 20) lastSentInputs.shift();
|
|
2002
|
+
}
|
|
1953
2003
|
pushUser(text);
|
|
1954
2004
|
typing = addSys('智能体处理中…');
|
|
1955
2005
|
|
|
@@ -2131,7 +2181,7 @@ async function openHistory() {
|
|
|
2131
2181
|
if (m.role === 'sys') {
|
|
2132
2182
|
html += `<div class="sys-tip"><span>${esc(m.content)}</span></div>`;
|
|
2133
2183
|
} else if (m.role === 'user') {
|
|
2134
|
-
html += `<div class="msg-row self"><div class="avatar me">我</div><div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div></div>`;
|
|
2184
|
+
html += `<div class="msg-row self"><div class="avatar me">我</div><div class="msg-body"><div class="msg-name" style="text-align:right"><button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)">复制</button></div><div class="bubble right">${esc(m.content)}</div></div></div>`;
|
|
2135
2185
|
} else if (m.phase === 'plan' && m.plan) {
|
|
2136
2186
|
html += `<div class="msg-row"><div class="avatar butler">🎩</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || '管家')} · 调度规划</div><div class="bubble left plan-card"><div class="plan-thought">${esc(m.plan.thought || '')}</div>${(m.plan.phases || []).map((g, gi) => `<div class="plan-phase"><b>阶段 ${gi + 1}</b>(${g.length > 1 ? g.length + ' 项并行' : '单执行'})${g.map(s => `<div class="plan-step">▸ <b>${esc(s.agentName)}</b>:${esc(s.instruction)}</div>`).join('')}</div>`).join('')}</div></div></div>`;
|
|
2137
2187
|
} else {
|
|
@@ -2140,7 +2190,7 @@ async function openHistory() {
|
|
|
2140
2190
|
const contentHtml = isFoldPhase(m.phase)
|
|
2141
2191
|
? `<div class="bubble left"><div class="fold-wrap">${tag}${linkifyMd(esc(m.content))}</div><div class="fold-toggle" onclick="toggleFoldEl(this)">展开全文 ▾</div></div>`
|
|
2142
2192
|
: `<div class="bubble left">${tag}${linkifyMd(esc(m.content))}</div>`;
|
|
2143
|
-
html += `<div class="msg-row"><div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${esc(agentIcon(m.agentId, m.agentName))}</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}
|
|
2193
|
+
html += `<div class="msg-row"><div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${esc(agentIcon(m.agentId, m.agentName))}</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || m.agentId || 'AI')} <button class="copy-btn" onclick="copyText(decodeURIComponent('${encodeURIComponent(m.content || '')}'), this)" title="复制原始文本(含 Markdown 源码)">复制</button></div>${contentHtml}${fileTag}</div></div>`;
|
|
2144
2194
|
}
|
|
2145
2195
|
}
|
|
2146
2196
|
el.innerHTML = html;
|
package/app/server.js
CHANGED
|
@@ -298,7 +298,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
298
298
|
configKernel: String(store.getConfig().kernel || 'auto'),
|
|
299
299
|
model: process.env.AGENTS_CHAT_MODEL || '',
|
|
300
300
|
autoApprove: process.env.AGENTS_CHAT_AUTO_APPROVE !== '0',
|
|
301
|
-
port: PORT
|
|
301
|
+
port: PORT,
|
|
302
|
+
// 数据损坏保护:损坏数据文件清单(已备份 .corrupt-*,写入已冻结,需人工处理)
|
|
303
|
+
corruptedDataFiles: store.getCorruptedFiles()
|
|
302
304
|
});
|
|
303
305
|
return;
|
|
304
306
|
}
|
|
@@ -948,7 +950,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
948
950
|
try {
|
|
949
951
|
for (const m of store.getMessages()) if (m.taskId) counts[m.taskId] = (counts[m.taskId] || 0) + 1;
|
|
950
952
|
} catch { /* ignore */ }
|
|
951
|
-
|
|
953
|
+
// 执行内核状态(前端据此显示内核徽标/缺失警告)与追加聊天进行中的卡
|
|
954
|
+
let runnerInfo = { kind: 'unknown', label: '' };
|
|
955
|
+
try {
|
|
956
|
+
const { resolveRunner } = require('./lib/agent');
|
|
957
|
+
const r = resolveRunner();
|
|
958
|
+
runnerInfo = { kind: r.kind, label: r.kernel ? r.kernel.label : '' };
|
|
959
|
+
} catch { /* ignore */ }
|
|
960
|
+
const { getCorruptedFiles } = require('./lib/cards');
|
|
961
|
+
json(res, 200, {
|
|
962
|
+
success: true, cards, running: cardRunner.isRunning(), maxParallel: cardRunner.maxP(), msgCounts: counts, config: CardStore.getConfig(),
|
|
963
|
+
followupIds: cardRunner.getFollowupIds(), runner: runnerInfo, corrupted: getCorruptedFiles()
|
|
964
|
+
});
|
|
952
965
|
return;
|
|
953
966
|
}
|
|
954
967
|
|
|
@@ -979,6 +992,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
979
992
|
}
|
|
980
993
|
if (parallelBad) { json(res, 400, { success: false, error: 'maxParallel 需在 1-8 之间' }); return; }
|
|
981
994
|
const cfg = Object.keys(patch).length ? CardStore.setConfig(patch) : CardStore.getConfig();
|
|
995
|
+
if (patch.maxParallel !== undefined) cardRunner.onConfigChanged(); // 并行度调大时立即补齐
|
|
982
996
|
// 工作区校验:保存允许(可能是尚未创建的目录),但路径不存在时显式警告
|
|
983
997
|
let warning = '';
|
|
984
998
|
const ws = (cfg.workspace || '').trim();
|
|
@@ -1113,6 +1127,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1113
1127
|
if (p.startsWith('/api/cards/') && req.method === 'PUT') {
|
|
1114
1128
|
const id = p.slice('/api/cards/'.length);
|
|
1115
1129
|
const body = await readBody(req);
|
|
1130
|
+
// 运行中/追加聊天中的卡禁止编辑与重置:子进程结束时会回写状态,中途改写会产生竞争
|
|
1131
|
+
const target = CardStore.get(id);
|
|
1132
|
+
if (target && (target.status === 'running' || cardRunner.isFollowupRunning(id))) {
|
|
1133
|
+
json(res, 409, { success: false, error: target.status === 'running' ? '任务正在执行中,无法编辑或重置,请等待完成或先停止编排' : '该任务追加聊天进行中,稍后再修改' });
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1116
1136
|
const patch = {};
|
|
1117
1137
|
if (body.title !== undefined) patch.title = String(body.title).slice(0, 500);
|
|
1118
1138
|
if (body.content !== undefined) patch.content = String(body.content).slice(0, 20000);
|
|
@@ -1150,6 +1170,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1150
1170
|
return;
|
|
1151
1171
|
}
|
|
1152
1172
|
|
|
1173
|
+
// 单卡停止:杀掉该卡子进程,状态复位为待执行(本轮编排不再自动调度它)
|
|
1174
|
+
if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/stop')) {
|
|
1175
|
+
const id = p.slice('/api/cards/'.length, -'/stop'.length);
|
|
1176
|
+
const ok = cardRunner.stopOne(id);
|
|
1177
|
+
json(res, ok ? 200 : 409, { success: ok, error: ok ? '' : '任务不在执行中,无法停止' });
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1153
1181
|
// 追加聊天:任务完成后复用其 opencode 会话续聊(同一进程的第二轮输入)
|
|
1154
1182
|
if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/chat')) {
|
|
1155
1183
|
const id = p.slice('/api/cards/'.length, -'/chat'.length);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iamsamyiok/agents-chat",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.0",
|
|
4
4
|
"description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
|
|
5
5
|
"main": "lib/start.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
"homepage": "https://github.com/iamsamyiok/agents-chat",
|
|
35
35
|
"scripts": {
|
|
36
36
|
"start": "node app/server.js",
|
|
37
|
-
"test": "
|
|
37
|
+
"test": "node --test \"test/*.test.js\""
|
|
38
38
|
}
|
|
39
39
|
}
|