@iamsamyiok/agents-chat 3.19.2 → 3.21.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 +118 -11
- package/app/lib/oc.js +9 -4
- package/app/public/cards.html +171 -12
- package/app/public/index.html +1 -2
- package/app/server.js +44 -6
- package/package.json +1 -1
package/app/lib/cards.js
CHANGED
|
@@ -22,27 +22,79 @@ const TRASH_PATH = path.join(DATA_DIR, 'cards_trash.json');
|
|
|
22
22
|
const TRASH_TTL = 30 * 24 * 3600 * 1000; // 垃圾桶默认保留 30 天
|
|
23
23
|
|
|
24
24
|
function ensureDir() { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); }
|
|
25
|
+
// 数据损坏保护:解析失败(文件存在但 JSON 损坏)时备份原文件并置损坏标记;
|
|
26
|
+
// 后续写入直接拒绝,防止「读到空列表 → 新增一条 → 覆盖写」把用户全部卡牌冲掉
|
|
27
|
+
const corrupted = new Set(); // 已损坏的文件路径
|
|
28
|
+
function cachedReader(file, cacheBox) {
|
|
29
|
+
return function read() {
|
|
30
|
+
if (corrupted.has(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
|
+
// 文件存在但解析失败:备份损坏现场,进入只读保护
|
|
43
|
+
try { fs.copyFileSync(file, `${file}.corrupt-${Date.now()}`); } catch { /* ignore */ }
|
|
44
|
+
corrupted.add(file);
|
|
45
|
+
cacheBox.mtime = 0; cacheBox.list = null;
|
|
46
|
+
console.error(`[cards] 数据文件损坏已备份并进入保护:${file}`);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const _cardsCache = { mtime: 0, list: null };
|
|
52
|
+
const _cfgCache = { mtime: 0, list: null };
|
|
53
|
+
const _trashCache = { mtime: 0, list: null };
|
|
54
|
+
const readCardsRaw = cachedReader(CARDS_PATH, _cardsCache);
|
|
55
|
+
const readConfigRaw = cachedReader(CARDS_CFG_PATH, _cfgCache);
|
|
56
|
+
const readTrashRaw = cachedReader(TRASH_PATH, _trashCache);
|
|
25
57
|
function readCards() {
|
|
26
|
-
|
|
58
|
+
// 返回浅拷贝:调用方(list 的 sort 等)对数组的操作不影响缓存
|
|
59
|
+
const v = readCardsRaw();
|
|
60
|
+
return Array.isArray(v) ? v.slice() : [];
|
|
61
|
+
}
|
|
62
|
+
function invalidate(file) {
|
|
63
|
+
if (file === CARDS_PATH) { _cardsCache.mtime = 0; _cardsCache.list = null; }
|
|
64
|
+
else if (file === CARDS_CFG_PATH) { _cfgCache.mtime = 0; _cfgCache.list = null; }
|
|
65
|
+
else if (file === TRASH_PATH) { _trashCache.mtime = 0; _trashCache.list = null; }
|
|
66
|
+
}
|
|
67
|
+
// 损坏保护下的写入守卫:拒绝覆盖写(保留备份供人工恢复)
|
|
68
|
+
function guardWrite(file) {
|
|
69
|
+
if (corrupted.has(file)) {
|
|
70
|
+
throw new Error(`数据文件 ${path.basename(file)} 已损坏(原文件已备份为 .corrupt-*),为防数据丢失已停止写入,请人工检查 ${DATA_DIR} 后删除损坏标记文件`);
|
|
71
|
+
}
|
|
27
72
|
}
|
|
28
73
|
function writeCards(list) {
|
|
29
74
|
ensureDir();
|
|
75
|
+
guardWrite(CARDS_PATH);
|
|
76
|
+
invalidate(CARDS_PATH);
|
|
30
77
|
const tmp = CARDS_PATH + '.tmp';
|
|
31
78
|
fs.writeFileSync(tmp, JSON.stringify(list, null, 2), 'utf8');
|
|
32
79
|
fs.renameSync(tmp, CARDS_PATH);
|
|
33
80
|
}
|
|
34
81
|
function readConfig() {
|
|
35
|
-
|
|
82
|
+
const v = readConfigRaw();
|
|
83
|
+
return (v && typeof v === 'object' && !Array.isArray(v)) ? v : { workspace: '' };
|
|
36
84
|
}
|
|
37
85
|
function writeConfig(cfg) {
|
|
38
86
|
ensureDir();
|
|
87
|
+
guardWrite(CARDS_CFG_PATH);
|
|
88
|
+
invalidate(CARDS_CFG_PATH);
|
|
39
89
|
fs.writeFileSync(CARDS_CFG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
|
|
40
90
|
}
|
|
41
91
|
function readTrash() {
|
|
42
|
-
|
|
92
|
+
const v = readTrashRaw();
|
|
93
|
+
return Array.isArray(v) ? v.slice() : [];
|
|
43
94
|
}
|
|
44
95
|
function writeTrash(list) {
|
|
45
|
-
|
|
96
|
+
guardWrite(TRASH_PATH);
|
|
97
|
+
invalidate(TRASH_PATH);
|
|
46
98
|
fs.writeFileSync(TRASH_PATH, JSON.stringify(list, null, 2), 'utf8');
|
|
47
99
|
}
|
|
48
100
|
// 启动时清理超过 30 天的垃圾桶快照,并连带清除其日志,避免占用磁盘
|
|
@@ -209,6 +261,24 @@ function isEligible(card, all) {
|
|
|
209
261
|
return true;
|
|
210
262
|
}
|
|
211
263
|
|
|
264
|
+
// 环检测:把 cardId 的依赖改为 newDeps 后,沿依赖边 DFS 是否能回到 cardId 自身
|
|
265
|
+
// 用于 PUT 写入拦截(间接环也拦截,如 A→B→C→A)
|
|
266
|
+
function wouldCycle(cardId, newDeps) {
|
|
267
|
+
const all = CardStore.list();
|
|
268
|
+
const byId = new Map(all.filter(c => c.id !== cardId).map(c => [c.id, c]));
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
const stack = [...(newDeps || [])];
|
|
271
|
+
while (stack.length) {
|
|
272
|
+
const cur = stack.pop();
|
|
273
|
+
if (cur === cardId) return true;
|
|
274
|
+
if (seen.has(cur)) continue;
|
|
275
|
+
seen.add(cur);
|
|
276
|
+
const c = byId.get(cur);
|
|
277
|
+
if (c) stack.push(...(c.dependsOn || []));
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
212
282
|
function pickEligible(all, activeIds) {
|
|
213
283
|
const eligible = all.filter(c => isEligible(c, all) && !activeIds.has(c.id));
|
|
214
284
|
eligible.sort((a, b) => {
|
|
@@ -228,10 +298,16 @@ class CardRunner {
|
|
|
228
298
|
this.timer = null;
|
|
229
299
|
this.procs = new Map(); // cardId -> { pid, child, lastActive, status }
|
|
230
300
|
this.followups = new Set(); // 追加聊天中的卡牌(并发防护)
|
|
301
|
+
this.killedCards = new Set(); // 本轮编排中被单卡停止的卡:跳过调度直到本轮结束
|
|
231
302
|
this.baseline = null; // 本轮编排开始时的 done/failed 基线(all_done 报增量)
|
|
232
303
|
}
|
|
233
304
|
isRunning() { return this.running; }
|
|
234
305
|
|
|
306
|
+
// 并行度热更新:调大后立即补齐在跑任务(无需等下一个任务完成触发 tick)
|
|
307
|
+
onConfigChanged() {
|
|
308
|
+
if (this.running) this.tick();
|
|
309
|
+
}
|
|
310
|
+
|
|
235
311
|
// 并行度:cards_config.maxParallel 可热更新(1-8),未配置时用 env 默认
|
|
236
312
|
maxP() {
|
|
237
313
|
try {
|
|
@@ -241,7 +317,7 @@ class CardRunner {
|
|
|
241
317
|
return MAX_PARALLEL;
|
|
242
318
|
}
|
|
243
319
|
|
|
244
|
-
//
|
|
320
|
+
// 终止单个任务对应的子进程(删除卡等场景:不改变调度语义)
|
|
245
321
|
killCard(cardId) {
|
|
246
322
|
this.active.delete(cardId);
|
|
247
323
|
const p = this.procs.get(cardId);
|
|
@@ -249,6 +325,21 @@ class CardRunner {
|
|
|
249
325
|
this.procs.delete(cardId);
|
|
250
326
|
}
|
|
251
327
|
|
|
328
|
+
// 单卡停止:杀进程 + 标记本轮不再自动调度(状态回待执行,与全局停止的复位语义一致)
|
|
329
|
+
stopOne(cardId) {
|
|
330
|
+
const card = CardStore.get(cardId);
|
|
331
|
+
if (!card || card.status !== 'running') return false;
|
|
332
|
+
if (this.followups.has(cardId)) return false; // 追加聊天进行中:走 chatFollowup 自己的错误路径
|
|
333
|
+
this.killedCards.add(cardId);
|
|
334
|
+
this.killCard(cardId);
|
|
335
|
+
// 主动复位状态:以这里为准(子进程 close 回调到达时 runCard 会再写一次 pending,幂等),
|
|
336
|
+
// 避免回调异常丢失时卡片永远停留在 running
|
|
337
|
+
CardStore.update(cardId, { status: 'pending', result: '', error: '', finishedAt: Date.now() });
|
|
338
|
+
broadcast({ type: 'notice', content: `⏹ 已停止任务「${card.title}」,该任务回到待执行(本轮编排不再自动调度它)` });
|
|
339
|
+
broadcast({ type: 'task_done', cardId, status: 'stopped', title: card.title });
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
252
343
|
// 供前端展示:当前存活进程 + opencode 是否在工作中(近 5s 有活动即视为工作中)
|
|
253
344
|
getProcesses() {
|
|
254
345
|
const out = [];
|
|
@@ -262,6 +353,7 @@ class CardRunner {
|
|
|
262
353
|
stop() {
|
|
263
354
|
this.token++;
|
|
264
355
|
this.running = false;
|
|
356
|
+
this.killedCards.clear();
|
|
265
357
|
if (this.timer) { try { this.timer.unref(); } catch { /* ignore */ } }
|
|
266
358
|
try { stopScope('solo'); } catch { /* ignore */ }
|
|
267
359
|
this.procs.clear();
|
|
@@ -273,6 +365,7 @@ class CardRunner {
|
|
|
273
365
|
if (this.running) return;
|
|
274
366
|
this.running = true;
|
|
275
367
|
this.token++;
|
|
368
|
+
this.killedCards.clear();
|
|
276
369
|
// 记录基线:all_done 时报告本轮增量(成功/失败数),避免混入历史任务
|
|
277
370
|
const all0 = CardStore.list();
|
|
278
371
|
this.baseline = { done: all0.filter(c => c.status === 'done').length, failed: all0.filter(c => c.status === 'failed').length };
|
|
@@ -284,7 +377,8 @@ class CardRunner {
|
|
|
284
377
|
if (!this.running) return;
|
|
285
378
|
const myToken = this.token;
|
|
286
379
|
const all = CardStore.list();
|
|
287
|
-
|
|
380
|
+
// 单卡停止的任务本轮跳过(用户已明确表示停它,不让调度器立刻拉起)
|
|
381
|
+
const eligible = pickEligible(all, this.active).filter(c => !this.killedCards.has(c.id));
|
|
288
382
|
if (!eligible.length) {
|
|
289
383
|
if (this.active.size === 0) {
|
|
290
384
|
this.running = false;
|
|
@@ -331,6 +425,12 @@ class CardRunner {
|
|
|
331
425
|
if (card.mode === 'continue' && card.chainId) {
|
|
332
426
|
const prev = CardStore.get(card.chainId);
|
|
333
427
|
ocSessionId = prev && prev.ocSessionId ? prev.ocSessionId : '';
|
|
428
|
+
// 语义降级显式提示:避免「名义续聊、实际新会话」静默发生
|
|
429
|
+
if (!ocSessionId) {
|
|
430
|
+
const warn = '续聊链首无可用会话(可能已被重置,或由不支持会话续聊的内核执行),本任务将以全新会话执行';
|
|
431
|
+
store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'system', taskId: card.id, content: `[系统提示] ${warn}` });
|
|
432
|
+
broadcast({ type: 'notice', content: `⚠ ${card.title}:${warn}` });
|
|
433
|
+
}
|
|
334
434
|
}
|
|
335
435
|
|
|
336
436
|
CardStore.update(card.id, { status: 'running', startedAt: Date.now(), error: '', result: '' });
|
|
@@ -399,16 +499,18 @@ class CardRunner {
|
|
|
399
499
|
|
|
400
500
|
const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
|
|
401
501
|
const stopped = myToken !== this.token;
|
|
502
|
+
const cardStopped = this.killedCards.has(card.id); // 单卡停止:与全局停止同样复位为待执行
|
|
402
503
|
if (finalText) {
|
|
403
504
|
store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'work', taskId: card.id, content: finalText.slice(0, 20000) });
|
|
404
505
|
}
|
|
405
|
-
const
|
|
506
|
+
const stoppedAny = stopped || cardStopped;
|
|
507
|
+
const status = stoppedAny ? 'pending' : (doneError ? 'failed' : 'done');
|
|
406
508
|
CardStore.update(card.id, {
|
|
407
509
|
status,
|
|
408
510
|
ocSessionId: sesId,
|
|
409
511
|
// 手动停止回到待执行:清掉残留,避免 pending 卡带着脏结果/错误
|
|
410
|
-
result:
|
|
411
|
-
error:
|
|
512
|
+
result: stoppedAny ? '' : (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
|
|
513
|
+
error: stoppedAny ? '' : (doneError || ''),
|
|
412
514
|
finishedAt: Date.now()
|
|
413
515
|
});
|
|
414
516
|
broadcast({ type: 'task_done', cardId: card.id, status, title: card.title });
|
|
@@ -471,7 +573,8 @@ class CardRunner {
|
|
|
471
573
|
model: card.model || '',
|
|
472
574
|
ocSessionId: sesId,
|
|
473
575
|
behavior: 'card',
|
|
474
|
-
cwd
|
|
576
|
+
cwd,
|
|
577
|
+
scope: 'card-fu' // 独立进程域:停止编排不牵连追加聊天
|
|
475
578
|
}, (ev) => {
|
|
476
579
|
const proc = this.procs.get(cardId);
|
|
477
580
|
if (proc) proc.lastActive = Date.now();
|
|
@@ -521,12 +624,16 @@ class CardRunner {
|
|
|
521
624
|
}
|
|
522
625
|
|
|
523
626
|
isFollowupRunning(cardId) { return !!this.followups && this.followups.has(cardId); }
|
|
627
|
+
getFollowupIds() { return [...this.followups]; }
|
|
524
628
|
}
|
|
525
629
|
|
|
630
|
+
// 数据文件损坏状态(供 API 告警展示)
|
|
631
|
+
function getCorruptedFiles() { return [...corrupted]; }
|
|
632
|
+
|
|
526
633
|
function isValidDir(p) {
|
|
527
634
|
try { return fs.existsSync(p) && fs.statSync(p).isDirectory(); } catch { return false; }
|
|
528
635
|
}
|
|
529
636
|
|
|
530
637
|
const runner = new CardRunner();
|
|
531
638
|
|
|
532
|
-
module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, MAX_PARALLEL, CARDS_PATH, CARDS_CFG_PATH };
|
|
639
|
+
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/public/cards.html
CHANGED
|
@@ -148,6 +148,25 @@
|
|
|
148
148
|
.trash-item .ti-info{flex:1;min-width:0}
|
|
149
149
|
.trash-item .ti-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
150
150
|
.trash-item .ti-sub{font-size:11px;color:var(--dim)}
|
|
151
|
+
/* 内核状态横幅(缺失/演示模式/数据保护时显示) */
|
|
152
|
+
.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)}
|
|
153
|
+
.kernel-banner.show{display:flex}
|
|
154
|
+
.kernel-banner.kb-missing{background:#fdeceb;color:#b03a31}
|
|
155
|
+
.kernel-banner.kb-demo{background:#fdf6ec;color:#8a5a00}
|
|
156
|
+
.kernel-banner.kb-corrupt{background:#fdeceb;color:#b03a31}
|
|
157
|
+
/* 内核徽标(正常时显示在状态胶囊旁) */
|
|
158
|
+
.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}
|
|
159
|
+
/* 执行中卡的运行时长徽标 */
|
|
160
|
+
.badge.run-elapsed{background:#eef7ff;color:var(--acc2);border-color:#c4e2ff;font-variant-numeric:tabular-nums}
|
|
161
|
+
/* 追加聊天进行中徽标 */
|
|
162
|
+
.badge.fu-live{background:#f1e9ff;color:#8a6fe8;border-color:#ddd0ff}
|
|
163
|
+
/* 单卡停止按钮 */
|
|
164
|
+
.icon-btn.danger:hover{color:var(--err);border-color:var(--err)}
|
|
165
|
+
/* 详情弹窗:任务内容块 */
|
|
166
|
+
.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}
|
|
167
|
+
/* 空看板引导 */
|
|
168
|
+
.board-guide{grid-column:1/-1;text-align:center;padding:40px 10px;color:var(--dim)}
|
|
169
|
+
.board-guide .bg-actions{display:flex;gap:10px;justify-content:center;margin-top:14px}
|
|
151
170
|
</style>
|
|
152
171
|
</head>
|
|
153
172
|
<body>
|
|
@@ -155,6 +174,7 @@
|
|
|
155
174
|
<a class="backlink" href="/" title="返回 Agents Chat 群聊">← 群聊</a>
|
|
156
175
|
<h1>🗂️ 多任务编排</h1>
|
|
157
176
|
<span class="status-pill" id="statusPill">空闲</span>
|
|
177
|
+
<span class="kernel-chip" id="kernelChip" style="display:none"></span>
|
|
158
178
|
<span class="sp"></span>
|
|
159
179
|
<div class="ws-box" title="工作区:选定后 Agent 在该目录读写相关文件(可选)">
|
|
160
180
|
📁 <input id="wsInput" placeholder="工作文件夹(点右侧选择)" />
|
|
@@ -167,11 +187,13 @@
|
|
|
167
187
|
</select>
|
|
168
188
|
</div>
|
|
169
189
|
<button class="btn" id="btnNew">+ 新建任务</button>
|
|
170
|
-
<button class="btn primary" id="btnRun">▶ 开始编排</button>
|
|
171
|
-
<button class="btn danger" id="btnStop" disabled>⏹ 停止</button>
|
|
190
|
+
<button class="btn primary" id="btnRun" title="按依赖与优先级调度全部待执行任务">▶ 开始编排</button>
|
|
191
|
+
<button class="btn danger" id="btnStop" disabled title="停止编排:进行中的任务复位为待执行(不影响追加聊天),未开始的任务保留">⏹ 停止</button>
|
|
172
192
|
<button class="btn" id="btnClear">清空</button>
|
|
173
193
|
</header>
|
|
174
194
|
|
|
195
|
+
<div class="kernel-banner" id="kernelBanner"></div>
|
|
196
|
+
|
|
175
197
|
<div class="procbar" id="procBar"><span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span></div>
|
|
176
198
|
|
|
177
199
|
<main>
|
|
@@ -209,7 +231,7 @@
|
|
|
209
231
|
<label>任务内容(描述,支持 Markdown)</label>
|
|
210
232
|
<textarea id="fContent" placeholder="把要做的事详细写在这里,Agent 会据此执行"></textarea>
|
|
211
233
|
<div class="row">
|
|
212
|
-
<div><label
|
|
234
|
+
<div><label>优先级(数字越小越优先;拖拽看板卡会按新顺序重写优先级)</label><input id="fPriority" type="number" value="999" /></div>
|
|
213
235
|
<div><label>执行模式</label>
|
|
214
236
|
<select id="fMode">
|
|
215
237
|
<option value="new">新进程(new)</option>
|
|
@@ -237,6 +259,8 @@
|
|
|
237
259
|
<h3 id="dTitle">任务详情</h3>
|
|
238
260
|
<div id="dMeta" class="meta" style="margin-bottom:10px"></div>
|
|
239
261
|
<div class="dep-tag" id="dDeps"></div>
|
|
262
|
+
<label>任务内容</label>
|
|
263
|
+
<div class="detail-content" id="dContent"><span class="empty">(无任务内容)</span></div>
|
|
240
264
|
<div class="detail-err" id="dError" style="display:none"></div>
|
|
241
265
|
<label>执行过程(实时)</label>
|
|
242
266
|
<div class="detail-log" id="dLog"><span class="empty">暂无过程记录</span></div>
|
|
@@ -280,24 +304,64 @@ const $ = s => document.querySelector(s);
|
|
|
280
304
|
const api = async (url, opt) => { const r = await fetch(url, opt); return r.json(); };
|
|
281
305
|
const post = (url, body) => api(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})});
|
|
282
306
|
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;}
|
|
307
|
+
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML.replace(/"/g,'"').replace(/'/g,''');}
|
|
284
308
|
|
|
285
309
|
let CARDS = [];
|
|
286
310
|
let OPEN_ID = null;
|
|
311
|
+
let FOLLOWUP_IDS = []; // 追加聊天进行中的卡 id
|
|
312
|
+
let RUNNER_INFO = null; // { kind, label }
|
|
313
|
+
let CORRUPTED = []; // 已进入损坏保护的数据文件
|
|
314
|
+
let LOAD_SEQ = 0; // loadCards 响应乱序守卫:快速触发多次时只认最新请求
|
|
287
315
|
|
|
288
316
|
async function loadCards(){
|
|
317
|
+
const seq = ++LOAD_SEQ;
|
|
289
318
|
const data = await api('/api/cards');
|
|
319
|
+
if (seq !== LOAD_SEQ) return; // 已有更新的请求发出:丢弃本次过期响应
|
|
290
320
|
CARDS = data.cards || [];
|
|
321
|
+
FOLLOWUP_IDS = data.followupIds || [];
|
|
322
|
+
RUNNER_INFO = data.runner || null;
|
|
323
|
+
CORRUPTED = data.corrupted || [];
|
|
291
324
|
$('#statusPill').textContent = data.running ? '编排中…' : '空闲';
|
|
292
325
|
$('#btnRun').disabled = data.running;
|
|
293
326
|
$('#btnStop').disabled = !data.running;
|
|
294
327
|
if(data.config) $('#wsInput').value = data.config.workspace || '';
|
|
295
328
|
if(data.maxParallel) $('#parallelSel').value = String(data.maxParallel);
|
|
329
|
+
renderKernelBanner();
|
|
296
330
|
render();
|
|
297
331
|
renderGraph();
|
|
298
332
|
loadTrashCount();
|
|
299
333
|
}
|
|
300
334
|
|
|
335
|
+
// 内核状态展示:正常=头部小徽标;缺失/演示模式/数据保护=醒目横幅
|
|
336
|
+
function renderKernelBanner(){
|
|
337
|
+
const chip=$('#kernelChip'), banner=$('#kernelBanner');
|
|
338
|
+
const k = RUNNER_INFO && RUNNER_INFO.kind;
|
|
339
|
+
if (CORRUPTED.length){
|
|
340
|
+
chip.style.display='none';
|
|
341
|
+
banner.className='kernel-banner show kb-corrupt';
|
|
342
|
+
banner.innerHTML='⚠ <b>数据保护已触发</b>:'+CORRUPTED.map(f=>'<code>'+esc(f.split(/[\\/]/).pop())+'</code>').join('、')+' 解析失败(原文件已备份为 .corrupt-*),为防数据丢失写入已暂停。请检查数据目录后恢复备份文件。';
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (k === 'missing'){
|
|
346
|
+
chip.style.display='none';
|
|
347
|
+
banner.className='kernel-banner show kb-missing';
|
|
348
|
+
banner.innerHTML='❌ <b>未检测到执行内核</b>:任务无法真实执行。请安装 opencode(推荐):终端运行 <code>npm install -g opencode-ai</code>,安装并登录后刷新本页。';
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (k === 'demo'){
|
|
352
|
+
chip.style.display='none';
|
|
353
|
+
banner.className='kernel-banner show kb-demo';
|
|
354
|
+
banner.innerHTML='⚠ <b>演示模式</b>(AGENTS_CHAT_MOCK=1):输出均为模拟结果,任务执行不可用。删除 .env 中该配置并安装内核后即为真实执行。';
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
banner.className='kernel-banner';
|
|
358
|
+
if (RUNNER_INFO && RUNNER_INFO.label){
|
|
359
|
+
chip.style.display='inline-flex';
|
|
360
|
+
chip.textContent='⚡ '+RUNNER_INFO.label;
|
|
361
|
+
chip.title='当前执行内核:'+RUNNER_INFO.label;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
301
365
|
function badge(card){
|
|
302
366
|
let p = card.priority;
|
|
303
367
|
let pc = p<=100?'prio-high':(p<=500?'prio-mid':'');
|
|
@@ -310,10 +374,20 @@ function errSummary(err){
|
|
|
310
374
|
if(!err) return '';
|
|
311
375
|
return String(err).split('\n').map(s=>s.trim()).filter(Boolean)[0] || '';
|
|
312
376
|
}
|
|
313
|
-
// 依赖阻塞判定:pending
|
|
377
|
+
// 依赖阻塞判定:pending 卡的依赖存在未完成(等待)或已失败(阻塞);依赖链成环时明确提示
|
|
314
378
|
function blockedInfo(card){
|
|
315
379
|
if(card.status!=='pending' || !(card.dependsOn&&card.dependsOn.length)) return null;
|
|
316
380
|
const byId=Object.fromEntries(CARDS.map(c=>[c.id,c]));
|
|
381
|
+
// 环检测:沿依赖边 DFS,回到自身即成环(历史数据兜底,正常写入已被服务端拦截)
|
|
382
|
+
const seen=new Set(), stack=[...card.dependsOn];
|
|
383
|
+
while(stack.length){
|
|
384
|
+
const cur=stack.pop();
|
|
385
|
+
if(cur===card.id) return {kind:'fail',text:'循环依赖:依赖链最终依赖本任务,全量执行时将被跳过'};
|
|
386
|
+
if(seen.has(cur)) continue;
|
|
387
|
+
seen.add(cur);
|
|
388
|
+
const c=byId[cur];
|
|
389
|
+
if(c) stack.push(...(c.dependsOn||[]));
|
|
390
|
+
}
|
|
317
391
|
const failedDeps=[], waitDeps=[];
|
|
318
392
|
for(const d of card.dependsOn){
|
|
319
393
|
const dc=byId[d];
|
|
@@ -336,11 +410,16 @@ function cardEl(card){
|
|
|
336
410
|
const blkHtml = blk ? `<div class="card-blocked${blk.kind==='wait'?' wait':''}">${blk.kind==='fail'?'⛔':'⏳'} ${esc(blk.text)}</div>` : '';
|
|
337
411
|
const resetHtml = (card.status==='pending' && /服务重启/.test(card.error||''))
|
|
338
412
|
? `<div class="card-reset">↻ ${esc(errSummary(card.error))}</div>` : '';
|
|
413
|
+
// 执行中:实时运行时长徽标(每秒刷新);追加聊天中:紫色徽标
|
|
414
|
+
const elapsedHtml = (card.status==='running' && card.startedAt)
|
|
415
|
+
? `<span class="badge run-elapsed" data-start="${card.startedAt}">⏱ ${fmtElapsed(Date.now()-card.startedAt)}</span>` : '';
|
|
416
|
+
const fuHtml = FOLLOWUP_IDS.includes(card.id) ? '<span class="badge fu-live">💬 回复中</span>' : '';
|
|
339
417
|
el.innerHTML = `<div class="title">${esc(card.title)}</div>
|
|
340
|
-
<div class="meta">${badge(card)} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
|
|
418
|
+
<div class="meta">${badge(card)} ${elapsedHtml} ${fuHtml} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
|
|
341
419
|
${errHtml}${blkHtml}${resetHtml}
|
|
342
420
|
<div class="op">
|
|
343
|
-
<button class="icon-btn" data-act="
|
|
421
|
+
${card.status==='running'?'<button class="icon-btn danger" data-act="stop" title="停止该任务(复位为待执行,本轮编排不再自动调度)">⏹</button>':''}
|
|
422
|
+
<button class="icon-btn" data-act="run" title="执行${card.status==='pending'?'(若依赖未完成将先确认)':'(旧结果先归档)'}">▶</button>
|
|
344
423
|
<button class="icon-btn" data-act="reset" title="重置(清除结果回到待执行)">⟲</button>
|
|
345
424
|
<button class="icon-btn" data-act="copy" title="复制为新任务">⧉</button>
|
|
346
425
|
<button class="icon-btn" data-act="edit" title="编辑">✎</button>
|
|
@@ -350,6 +429,7 @@ function cardEl(card){
|
|
|
350
429
|
if(a==='del') delCard(card.id);
|
|
351
430
|
else if(a==='edit') openEdit(card);
|
|
352
431
|
else if(a==='run') runOne(card.id);
|
|
432
|
+
else if(a==='stop') stopOne(card.id);
|
|
353
433
|
else if(a==='reset') resetCard(card.id);
|
|
354
434
|
else if(a==='copy') copyCard(card);
|
|
355
435
|
return; }
|
|
@@ -361,6 +441,20 @@ function cardEl(card){
|
|
|
361
441
|
el.addEventListener('drop', e=>{ e.preventDefault(); el.classList.remove('drag-over'); onDrop(card.id, el); });
|
|
362
442
|
return el;
|
|
363
443
|
}
|
|
444
|
+
// 运行时长格式化:ms -> "1:05" / "12:03:44"
|
|
445
|
+
function fmtElapsed(ms){
|
|
446
|
+
const s = Math.max(0, Math.floor(ms/1000));
|
|
447
|
+
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
|
|
448
|
+
const p2 = n => String(n).padStart(2,'0');
|
|
449
|
+
return h ? `${h}:${p2(m)}:${p2(sec)}` : `${m}:${p2(sec)}`;
|
|
450
|
+
}
|
|
451
|
+
// 每秒刷新执行中卡片的运行时长(不重建 DOM,只更新徽标文本)
|
|
452
|
+
setInterval(()=>{
|
|
453
|
+
document.querySelectorAll('.badge.run-elapsed').forEach(b=>{
|
|
454
|
+
const st = Number(b.dataset.start);
|
|
455
|
+
if (st) b.textContent = '⏱ ' + fmtElapsed(Date.now()-st);
|
|
456
|
+
});
|
|
457
|
+
}, 1000);
|
|
364
458
|
let dragId=null;
|
|
365
459
|
function onDrop(targetId, targetEl){
|
|
366
460
|
if(!dragId || dragId===targetId) return;
|
|
@@ -384,9 +478,41 @@ function render(){
|
|
|
384
478
|
for(const c of CARDS){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
|
|
385
479
|
$('#cntPending').textContent=counts.pending||''; $('#cntRunning').textContent=counts.running||'';
|
|
386
480
|
$('#cntDone').textContent=counts.done||''; $('#cntFailed').textContent=counts.failed||'';
|
|
481
|
+
// 空看板引导:一个任务都没有时给出上手入口(含示例)
|
|
482
|
+
if(!CARDS.length){
|
|
483
|
+
cols.pending.innerHTML=`<div class="board-guide">
|
|
484
|
+
还没有任何任务<br/>把要做的事写成一张张卡牌,Agent 会按依赖与优先级自动调度执行
|
|
485
|
+
<div class="bg-actions">
|
|
486
|
+
<button class="btn primary" onclick="openEdit(null)">+ 新建第一个任务</button>
|
|
487
|
+
<button class="btn" id="btnExamples">📦 载入示例任务</button>
|
|
488
|
+
</div></div>`;
|
|
489
|
+
const be=$('#btnExamples'); if(be) be.onclick=loadExamples;
|
|
490
|
+
for(const k of ['running','done','failed']) cols[k].innerHTML='<div class="empty">—</div>';
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
387
493
|
for(const k in cols){ if(!cols[k].children.length) cols[k].innerHTML='<div class="empty">—</div>'; }
|
|
388
494
|
}
|
|
389
495
|
|
|
496
|
+
// 载入示例:三张卡展示三种执行模式与依赖关系(可编辑/删除,当作模板随意改造)
|
|
497
|
+
async function loadExamples(){
|
|
498
|
+
const mk = (title, content, extra) => post('/api/cards', Object.assign({ title, content }, extra||{})).then(r=>r.card);
|
|
499
|
+
const a = await mk('示例A · 独立任务', '列出当前工作目录下的全部文件,并统计各类型文件的数量。', { priority: 1 });
|
|
500
|
+
await mk('示例B · 续聊任务', '接续上一个任务的结论:把统计结果整理成一份 markdown 表格。', { priority: 2, mode: 'continue', chainId: a.id, dependsOn: [a.id] });
|
|
501
|
+
await mk('示例C · 并行任务', '查询今日天气概况并给出一句话总结(与示例A/B 互不依赖,可同时执行)。', { priority: 3, mode: 'parallel' });
|
|
502
|
+
await loadCards();
|
|
503
|
+
toast('已载入 3 个示例任务,点击「开始编排」体验');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 单卡停止:执行中的任务杀进程并复位为待执行(本轮编排不再自动调度)
|
|
507
|
+
async function stopOne(id){
|
|
508
|
+
const c=CARDS.find(x=>x.id===id);
|
|
509
|
+
if(!c || c.status!=='running') return;
|
|
510
|
+
if(!confirm(`停止任务「${c.title}」?该任务将复位为待执行,本轮编排不再自动调度它。`)) return;
|
|
511
|
+
const r=await post('/api/cards/'+id+'/stop');
|
|
512
|
+
if(!r.success) toast(r.error||'停止失败');
|
|
513
|
+
await loadCards();
|
|
514
|
+
}
|
|
515
|
+
|
|
390
516
|
// ---- 编辑/新建 ----
|
|
391
517
|
// 候选列表排除自身:禁止把自己选为依赖/链首(防自环)
|
|
392
518
|
function fillSelectors(){
|
|
@@ -448,7 +574,11 @@ $('#btnEditSave').onclick=async()=>{
|
|
|
448
574
|
dependsOn:deps };
|
|
449
575
|
if(EDIT_ID) await api('/api/cards/'+EDIT_ID,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
450
576
|
else await post('/api/cards',body);
|
|
451
|
-
$('#maskEdit').classList.remove('show');
|
|
577
|
+
$('#maskEdit').classList.remove('show');
|
|
578
|
+
await loadCards();
|
|
579
|
+
// 编排状态联动提示:编排进行中=新卡自动加入调度;空闲=明确告知需手动开始
|
|
580
|
+
if(!EDIT_ID) toast($('#btnRun').disabled ? '已保存,新任务将自动加入本轮调度' : '已保存。当前编排空闲,点「▶ 开始编排」执行');
|
|
581
|
+
else toast('已保存');
|
|
452
582
|
};
|
|
453
583
|
async function delCard(id){ if(!confirm('确认删除该任务?将移入垃圾桶(30 天内可还原)。'))return; await api('/api/cards/'+id,{method:'DELETE'}); await loadCards(); toast('已移入垃圾桶'); }
|
|
454
584
|
$('#btnClear').onclick=async()=>{ if(!confirm('清空全部任务?将移入垃圾桶(30 天内可还原)。'))return; await post('/api/cards/clear'); await loadCards(); };
|
|
@@ -471,6 +601,13 @@ $('#parallelSel').onchange=async()=>{
|
|
|
471
601
|
$('#dExport').onclick=()=>{ if(!OPEN_ID)return; window.open('/api/cards/'+encodeURIComponent(OPEN_ID)+'/export.md','_blank'); };
|
|
472
602
|
async function runOne(id){
|
|
473
603
|
const c=CARDS.find(x=>x.id===id);
|
|
604
|
+
// 依赖未完成时显式确认:让「立即执行会绕过依赖」这件事说清楚
|
|
605
|
+
if(c && c.status==='pending' && c.dependsOn && c.dependsOn.length){
|
|
606
|
+
const undone = c.dependsOn.filter(d=>{ const dc=CARDS.find(x=>x.id===d); return !dc || dc.status!=='done'; });
|
|
607
|
+
if(undone.length){
|
|
608
|
+
if(!confirm(`该任务还有 ${undone.length} 个依赖未完成(调度器会等它们完成后才自动执行)。\n立即执行将忽略依赖直接运行,确认?`)) return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
474
611
|
// 重跑保护:已有结果时先确认(旧结果会归档进过程日志)
|
|
475
612
|
if(c && (c.status==='done'||c.status==='failed') && c.result){
|
|
476
613
|
if(!confirm('重新执行将把上次结果归档到过程日志,然后开始新一轮执行。确认?')) return;
|
|
@@ -538,6 +675,10 @@ async function openDetail(id){
|
|
|
538
675
|
$('#dMeta').innerHTML=badge(card)+`<span class="badge">${card.status}</span>`;
|
|
539
676
|
const depTitles = (card.dependsOn||[]).map(d=>{const c=CARDS.find(x=>x.id===d);return c?c.title:d;});
|
|
540
677
|
$('#dDeps').textContent = depTitles.length ? ('依赖:'+depTitles.join('、')) : '';
|
|
678
|
+
// 任务内容:详情首先呈现「这个任务要求做什么」,不必再点编辑查看
|
|
679
|
+
const cbox=$('#dContent');
|
|
680
|
+
if(card.content) cbox.textContent=card.content;
|
|
681
|
+
else cbox.innerHTML='<span class="empty">(无任务内容)</span>';
|
|
541
682
|
// 失败原因块:任务执行错误优先,其次追加聊天的错误
|
|
542
683
|
const errBox=$('#dError');
|
|
543
684
|
const errText = card.error || card.followupError || '';
|
|
@@ -728,7 +869,8 @@ async function refreshProcs(){
|
|
|
728
869
|
const d = await api('/api/cards/processes');
|
|
729
870
|
const procs = d.processes||[];
|
|
730
871
|
const bar=$('#procBar');
|
|
731
|
-
|
|
872
|
+
const kname = (RUNNER_INFO && RUNNER_INFO.label) || 'opencode';
|
|
873
|
+
if(!procs.length){ bar.innerHTML='<span class="ptitle">进程:</span><span class="proc-empty">无运行中的 '+esc(kname)+' 进程</span>'; return; }
|
|
732
874
|
bar.innerHTML='<span class="ptitle">进程:</span>'+procs.map(p=>{
|
|
733
875
|
const working=p.working;
|
|
734
876
|
return `<span class="proc-chip ${working?'':'idle'}"><span class="dot"></span><span class="pid">PID ${p.pid}</span><span class="st">${working?'工作中':'空闲'}</span></span>`;
|
|
@@ -763,8 +905,17 @@ function notifyDone(done,failed){
|
|
|
763
905
|
}catch{ /* ignore */ }
|
|
764
906
|
}
|
|
765
907
|
const es = new EventSource('/api/cards/stream');
|
|
908
|
+
// 断线重连后补拉:错过的状态迁移事件靠全量刷新弥补(init 只恢复按钮态)
|
|
909
|
+
es.onopen = ()=>{ loadCards(); refreshProcs(); };
|
|
766
910
|
es.onmessage = (e)=>{
|
|
767
911
|
let ev; try{ ev=JSON.parse(e.data); }catch{ return; }
|
|
912
|
+
if(ev.type==='init'){ // 连接建立时的初始状态(含断线重连场景)
|
|
913
|
+
$('#statusPill').textContent = ev.running ? '编排中…' : '空闲';
|
|
914
|
+
$('#btnRun').disabled = !!ev.running; $('#btnStop').disabled = !ev.running;
|
|
915
|
+
if(ev.maxParallel) $('#parallelSel').value = String(ev.maxParallel);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if(ev.type==='notice'){ toast(ev.content); }
|
|
768
919
|
if(ev.type==='runner_started'){ $('#statusPill').textContent='编排中…'; $('#btnRun').disabled=true; $('#btnStop').disabled=false; }
|
|
769
920
|
if(ev.type==='runner_stopped'){ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true; }
|
|
770
921
|
if(ev.type==='all_done'){
|
|
@@ -773,17 +924,25 @@ es.onmessage = (e)=>{
|
|
|
773
924
|
}
|
|
774
925
|
if(ev.type==='ws_warning'){ toast('⚠ 工作区路径无效:'+ev.path+',任务将在默认目录执行'); }
|
|
775
926
|
if(ev.type==='followup_start'||ev.type==='followup_done'){ loadCards(); }
|
|
776
|
-
// text
|
|
777
|
-
if(['task_start','
|
|
927
|
+
// 状态迁移事件:才触发全量刷新(text/tool 快照不改变卡片状态)
|
|
928
|
+
if(['task_start','task_done'].includes(ev.type)){
|
|
778
929
|
loadCards();
|
|
779
930
|
refreshProcs();
|
|
931
|
+
// 失败即时提醒(完成静默:看板移动可见 + all_done 汇总通知)
|
|
932
|
+
if(ev.type==='task_done' && ev.status==='failed') toast('✗「'+(ev.title||'任务')+'」执行失败,点击卡片查看原因');
|
|
780
933
|
if(OPEN_ID && ev.cardId===OPEN_ID){
|
|
781
934
|
const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
|
|
782
935
|
if(ev.type==='task_start') TEXT_PARTS.clear();
|
|
783
|
-
else if(ev.type==='tool'){ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight; }
|
|
784
936
|
else if(ev.type==='task_done'){ loadCards().then(()=>{ if(OPEN_ID===ev.cardId) openDetail(ev.cardId); }); }
|
|
785
937
|
}
|
|
786
938
|
}
|
|
939
|
+
// proc 仅刷新进程条(另有 2s 轮询兜底),不重建看板 DOM
|
|
940
|
+
if(ev.type==='proc'){ refreshProcs(); }
|
|
941
|
+
// tool 事件仅在打开对应详情时追加过程日志,避免工具密集时高频全量刷新
|
|
942
|
+
if(ev.type==='tool' && OPEN_ID===ev.cardId){
|
|
943
|
+
const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
|
|
944
|
+
appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight;
|
|
945
|
+
}
|
|
787
946
|
// 正文快照独立渲染(不依赖上面的状态事件)
|
|
788
947
|
if(ev.type==='text' && OPEN_ID===ev.cardId) renderSnapText(ev);
|
|
789
948
|
if(ev.type==='followup_start' && OPEN_ID===ev.cardId) TEXT_PARTS.clear();
|
package/app/public/index.html
CHANGED
|
@@ -634,7 +634,7 @@
|
|
|
634
634
|
</div>
|
|
635
635
|
|
|
636
636
|
<script>
|
|
637
|
-
const PAGE_VERSION = '3.
|
|
637
|
+
const PAGE_VERSION = '3.20.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
|
|
638
638
|
const AV_COLORS = ['#5b8def','#07c160','#fa9d3b','#10aeff','#8a6fe8','#fa5151','#e8a33d','#3aa7a3'];
|
|
639
639
|
const PHASE_LABEL = { plan: '调度规划', work: '执行', review: '验收', report: '汇总', talk: '圆桌发言', task: '任务执行' };
|
|
640
640
|
// 职业图标库(智能体配置可选)
|
|
@@ -2319,7 +2319,6 @@ async function doImport() {
|
|
|
2319
2319
|
(data.warnings || []).forEach(w => chatMode === 'solo' ? addSoloSys('⚠ ' + w) : addSys('⚠ ' + w));
|
|
2320
2320
|
closeImport();
|
|
2321
2321
|
importText.value = '';
|
|
2322
|
-
if (importMode === 'scheduled' && schedOpen) renderSchedList();
|
|
2323
2322
|
} else toast(data.error || '导入失败');
|
|
2324
2323
|
}
|
|
2325
2324
|
|
package/app/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Agents Chat Portable - 零依赖 HTTP 服务
|
|
2
2
|
// 启动:node app/server.js [--port 3456]
|
|
3
|
-
const APP_VERSION = '3.
|
|
3
|
+
const APP_VERSION = '3.20.0'; // 页面与服务端版本互检,不一致提示强刷
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
@@ -38,7 +38,7 @@ const { runAgent, stopScope, stopAllChildren } = require('./lib/agent');
|
|
|
38
38
|
const { runButler, runMentioned, runRoundtable, runTasks, prepareRerun } = require('./lib/orchestrator');
|
|
39
39
|
const oc = require('./lib/oc');
|
|
40
40
|
const memoryMod = require('./lib/memory');
|
|
41
|
-
const { CardStore, runner: cardRunner, sseSubscribe, MAX_PARALLEL } = require('./lib/cards');
|
|
41
|
+
const { CardStore, runner: cardRunner, sseSubscribe, MAX_PARALLEL, wouldCycle } = require('./lib/cards');
|
|
42
42
|
|
|
43
43
|
// ---------- 人工审批关卡:orchestrator 暂停等待用户放行(方案/交付),SSE 断线后可经 /api/approvals 恢复 ----------
|
|
44
44
|
const pendingApprovals = new Map(); // approvalId -> {kind,label,taskId,resolve,timer}
|
|
@@ -948,7 +948,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
948
948
|
try {
|
|
949
949
|
for (const m of store.getMessages()) if (m.taskId) counts[m.taskId] = (counts[m.taskId] || 0) + 1;
|
|
950
950
|
} catch { /* ignore */ }
|
|
951
|
-
|
|
951
|
+
// 执行内核状态(前端据此显示内核徽标/缺失警告)与追加聊天进行中的卡
|
|
952
|
+
let runnerInfo = { kind: 'unknown', label: '' };
|
|
953
|
+
try {
|
|
954
|
+
const { resolveRunner } = require('./lib/agent');
|
|
955
|
+
const r = resolveRunner();
|
|
956
|
+
runnerInfo = { kind: r.kind, label: r.kernel ? r.kernel.label : '' };
|
|
957
|
+
} catch { /* ignore */ }
|
|
958
|
+
const { getCorruptedFiles } = require('./lib/cards');
|
|
959
|
+
json(res, 200, {
|
|
960
|
+
success: true, cards, running: cardRunner.isRunning(), maxParallel: cardRunner.maxP(), msgCounts: counts, config: CardStore.getConfig(),
|
|
961
|
+
followupIds: cardRunner.getFollowupIds(), runner: runnerInfo, corrupted: getCorruptedFiles()
|
|
962
|
+
});
|
|
952
963
|
return;
|
|
953
964
|
}
|
|
954
965
|
|
|
@@ -979,6 +990,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
979
990
|
}
|
|
980
991
|
if (parallelBad) { json(res, 400, { success: false, error: 'maxParallel 需在 1-8 之间' }); return; }
|
|
981
992
|
const cfg = Object.keys(patch).length ? CardStore.setConfig(patch) : CardStore.getConfig();
|
|
993
|
+
if (patch.maxParallel !== undefined) cardRunner.onConfigChanged(); // 并行度调大时立即补齐
|
|
982
994
|
// 工作区校验:保存允许(可能是尚未创建的目录),但路径不存在时显式警告
|
|
983
995
|
let warning = '';
|
|
984
996
|
const ws = (cfg.workspace || '').trim();
|
|
@@ -1071,16 +1083,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
1071
1083
|
const card = CardStore.get(id);
|
|
1072
1084
|
if (!card) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
|
|
1073
1085
|
const msgs = store.getMessages(id);
|
|
1086
|
+
// 中文友好的日期时间(YYYY-MM-DD HH:mm,避免随系统 locale 变成英文格式)
|
|
1087
|
+
const fmtDateTime = (t) => {
|
|
1088
|
+
const d = new Date(t || Date.now());
|
|
1089
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
1090
|
+
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
|
|
1091
|
+
};
|
|
1074
1092
|
const roleOf = (m) => (m.role === 'user' ? '用户' : (m.phase === 'archive' ? '归档' : (m.phase === 'system' ? '系统' : 'Agent')));
|
|
1075
1093
|
const lines = [
|
|
1076
1094
|
`# ${card.title}`, '',
|
|
1077
1095
|
`- 状态:${card.status}|模式:${card.mode}|优先级:P${card.priority}`,
|
|
1078
|
-
`- 创建:${
|
|
1096
|
+
`- 创建:${fmtDateTime(card.createdAt)}${card.finishedAt ? `|完成:${fmtDateTime(card.finishedAt)}` : ''}`,
|
|
1079
1097
|
card.error ? `- 失败原因:${card.error.replace(/\n/g, ' ')}` : '',
|
|
1080
1098
|
'', '## 任务内容', '', String(card.content || '').trim(), '', '## 执行过程', ''
|
|
1081
1099
|
];
|
|
1082
1100
|
for (const m of msgs) {
|
|
1083
|
-
lines.push(`### ${
|
|
1101
|
+
lines.push(`### ${fmtDateTime(m.timestamp || Date.now())} · ${roleOf(m)}`, '');
|
|
1084
1102
|
lines.push(String(m.content || '').trim() || '(无内容)');
|
|
1085
1103
|
lines.push('');
|
|
1086
1104
|
}
|
|
@@ -1107,13 +1125,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
1107
1125
|
if (p.startsWith('/api/cards/') && req.method === 'PUT') {
|
|
1108
1126
|
const id = p.slice('/api/cards/'.length);
|
|
1109
1127
|
const body = await readBody(req);
|
|
1128
|
+
// 运行中/追加聊天中的卡禁止编辑与重置:子进程结束时会回写状态,中途改写会产生竞争
|
|
1129
|
+
const target = CardStore.get(id);
|
|
1130
|
+
if (target && (target.status === 'running' || cardRunner.isFollowupRunning(id))) {
|
|
1131
|
+
json(res, 409, { success: false, error: target.status === 'running' ? '任务正在执行中,无法编辑或重置,请等待完成或先停止编排' : '该任务追加聊天进行中,稍后再修改' });
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1110
1134
|
const patch = {};
|
|
1111
1135
|
if (body.title !== undefined) patch.title = String(body.title).slice(0, 500);
|
|
1112
1136
|
if (body.content !== undefined) patch.content = String(body.content).slice(0, 20000);
|
|
1113
1137
|
if (body.priority !== undefined) patch.priority = Number(body.priority) || 999;
|
|
1114
1138
|
if (body.mode !== undefined) patch.mode = ['new', 'continue', 'parallel'].includes(body.mode) ? body.mode : 'new';
|
|
1115
1139
|
if (body.chainId !== undefined) patch.chainId = String(body.chainId).slice(0, 100);
|
|
1116
|
-
if (Array.isArray(body.dependsOn))
|
|
1140
|
+
if (Array.isArray(body.dependsOn)) {
|
|
1141
|
+
const deps = body.dependsOn.map(String).slice(0, 50);
|
|
1142
|
+
// 环检测:修改依赖后若形成(直接或间接)循环依赖则拒绝写入
|
|
1143
|
+
if (wouldCycle(id, deps)) { json(res, 409, { success: false, error: '循环依赖:该任务的依赖链最终会依赖它自己,请调整依赖关系' }); return; }
|
|
1144
|
+
patch.dependsOn = deps;
|
|
1145
|
+
}
|
|
1117
1146
|
if (body.model !== undefined) patch.model = String(body.model).slice(0, 80);
|
|
1118
1147
|
// 状态手动复位:failed/pending -> pending 可重跑
|
|
1119
1148
|
if (body.status === 'pending') { patch.status = 'pending'; patch.result = ''; patch.error = ''; patch.ocSessionId = ''; }
|
|
@@ -1125,6 +1154,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1125
1154
|
|
|
1126
1155
|
if (p.startsWith('/api/cards/') && req.method === 'DELETE') {
|
|
1127
1156
|
const id = p.slice('/api/cards/'.length);
|
|
1157
|
+
if (!CardStore.get(id)) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
|
|
1128
1158
|
cardRunner.killCard(id);
|
|
1129
1159
|
CardStore.remove(id);
|
|
1130
1160
|
json(res, 200, { success: true });
|
|
@@ -1138,6 +1168,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1138
1168
|
return;
|
|
1139
1169
|
}
|
|
1140
1170
|
|
|
1171
|
+
// 单卡停止:杀掉该卡子进程,状态复位为待执行(本轮编排不再自动调度它)
|
|
1172
|
+
if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/stop')) {
|
|
1173
|
+
const id = p.slice('/api/cards/'.length, -'/stop'.length);
|
|
1174
|
+
const ok = cardRunner.stopOne(id);
|
|
1175
|
+
json(res, ok ? 200 : 409, { success: ok, error: ok ? '' : '任务不在执行中,无法停止' });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1141
1179
|
// 追加聊天:任务完成后复用其 opencode 会话续聊(同一进程的第二轮输入)
|
|
1142
1180
|
if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/chat')) {
|
|
1143
1181
|
const id = p.slice('/api/cards/'.length, -'/chat'.length);
|