@iamsamyiok/agents-chat 3.20.0 → 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 +100 -11
- package/app/lib/oc.js +9 -4
- package/app/public/cards.html +160 -11
- package/app/server.js +27 -1
- 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 天的垃圾桶快照,并连带清除其日志,避免占用磁盘
|
|
@@ -246,10 +298,16 @@ class CardRunner {
|
|
|
246
298
|
this.timer = null;
|
|
247
299
|
this.procs = new Map(); // cardId -> { pid, child, lastActive, status }
|
|
248
300
|
this.followups = new Set(); // 追加聊天中的卡牌(并发防护)
|
|
301
|
+
this.killedCards = new Set(); // 本轮编排中被单卡停止的卡:跳过调度直到本轮结束
|
|
249
302
|
this.baseline = null; // 本轮编排开始时的 done/failed 基线(all_done 报增量)
|
|
250
303
|
}
|
|
251
304
|
isRunning() { return this.running; }
|
|
252
305
|
|
|
306
|
+
// 并行度热更新:调大后立即补齐在跑任务(无需等下一个任务完成触发 tick)
|
|
307
|
+
onConfigChanged() {
|
|
308
|
+
if (this.running) this.tick();
|
|
309
|
+
}
|
|
310
|
+
|
|
253
311
|
// 并行度:cards_config.maxParallel 可热更新(1-8),未配置时用 env 默认
|
|
254
312
|
maxP() {
|
|
255
313
|
try {
|
|
@@ -259,7 +317,7 @@ class CardRunner {
|
|
|
259
317
|
return MAX_PARALLEL;
|
|
260
318
|
}
|
|
261
319
|
|
|
262
|
-
//
|
|
320
|
+
// 终止单个任务对应的子进程(删除卡等场景:不改变调度语义)
|
|
263
321
|
killCard(cardId) {
|
|
264
322
|
this.active.delete(cardId);
|
|
265
323
|
const p = this.procs.get(cardId);
|
|
@@ -267,6 +325,21 @@ class CardRunner {
|
|
|
267
325
|
this.procs.delete(cardId);
|
|
268
326
|
}
|
|
269
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
|
+
|
|
270
343
|
// 供前端展示:当前存活进程 + opencode 是否在工作中(近 5s 有活动即视为工作中)
|
|
271
344
|
getProcesses() {
|
|
272
345
|
const out = [];
|
|
@@ -280,6 +353,7 @@ class CardRunner {
|
|
|
280
353
|
stop() {
|
|
281
354
|
this.token++;
|
|
282
355
|
this.running = false;
|
|
356
|
+
this.killedCards.clear();
|
|
283
357
|
if (this.timer) { try { this.timer.unref(); } catch { /* ignore */ } }
|
|
284
358
|
try { stopScope('solo'); } catch { /* ignore */ }
|
|
285
359
|
this.procs.clear();
|
|
@@ -291,6 +365,7 @@ class CardRunner {
|
|
|
291
365
|
if (this.running) return;
|
|
292
366
|
this.running = true;
|
|
293
367
|
this.token++;
|
|
368
|
+
this.killedCards.clear();
|
|
294
369
|
// 记录基线:all_done 时报告本轮增量(成功/失败数),避免混入历史任务
|
|
295
370
|
const all0 = CardStore.list();
|
|
296
371
|
this.baseline = { done: all0.filter(c => c.status === 'done').length, failed: all0.filter(c => c.status === 'failed').length };
|
|
@@ -302,7 +377,8 @@ class CardRunner {
|
|
|
302
377
|
if (!this.running) return;
|
|
303
378
|
const myToken = this.token;
|
|
304
379
|
const all = CardStore.list();
|
|
305
|
-
|
|
380
|
+
// 单卡停止的任务本轮跳过(用户已明确表示停它,不让调度器立刻拉起)
|
|
381
|
+
const eligible = pickEligible(all, this.active).filter(c => !this.killedCards.has(c.id));
|
|
306
382
|
if (!eligible.length) {
|
|
307
383
|
if (this.active.size === 0) {
|
|
308
384
|
this.running = false;
|
|
@@ -349,6 +425,12 @@ class CardRunner {
|
|
|
349
425
|
if (card.mode === 'continue' && card.chainId) {
|
|
350
426
|
const prev = CardStore.get(card.chainId);
|
|
351
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
|
+
}
|
|
352
434
|
}
|
|
353
435
|
|
|
354
436
|
CardStore.update(card.id, { status: 'running', startedAt: Date.now(), error: '', result: '' });
|
|
@@ -417,16 +499,18 @@ class CardRunner {
|
|
|
417
499
|
|
|
418
500
|
const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
|
|
419
501
|
const stopped = myToken !== this.token;
|
|
502
|
+
const cardStopped = this.killedCards.has(card.id); // 单卡停止:与全局停止同样复位为待执行
|
|
420
503
|
if (finalText) {
|
|
421
504
|
store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'work', taskId: card.id, content: finalText.slice(0, 20000) });
|
|
422
505
|
}
|
|
423
|
-
const
|
|
506
|
+
const stoppedAny = stopped || cardStopped;
|
|
507
|
+
const status = stoppedAny ? 'pending' : (doneError ? 'failed' : 'done');
|
|
424
508
|
CardStore.update(card.id, {
|
|
425
509
|
status,
|
|
426
510
|
ocSessionId: sesId,
|
|
427
511
|
// 手动停止回到待执行:清掉残留,避免 pending 卡带着脏结果/错误
|
|
428
|
-
result:
|
|
429
|
-
error:
|
|
512
|
+
result: stoppedAny ? '' : (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
|
|
513
|
+
error: stoppedAny ? '' : (doneError || ''),
|
|
430
514
|
finishedAt: Date.now()
|
|
431
515
|
});
|
|
432
516
|
broadcast({ type: 'task_done', cardId: card.id, status, title: card.title });
|
|
@@ -489,7 +573,8 @@ class CardRunner {
|
|
|
489
573
|
model: card.model || '',
|
|
490
574
|
ocSessionId: sesId,
|
|
491
575
|
behavior: 'card',
|
|
492
|
-
cwd
|
|
576
|
+
cwd,
|
|
577
|
+
scope: 'card-fu' // 独立进程域:停止编排不牵连追加聊天
|
|
493
578
|
}, (ev) => {
|
|
494
579
|
const proc = this.procs.get(cardId);
|
|
495
580
|
if (proc) proc.lastActive = Date.now();
|
|
@@ -539,12 +624,16 @@ class CardRunner {
|
|
|
539
624
|
}
|
|
540
625
|
|
|
541
626
|
isFollowupRunning(cardId) { return !!this.followups && this.followups.has(cardId); }
|
|
627
|
+
getFollowupIds() { return [...this.followups]; }
|
|
542
628
|
}
|
|
543
629
|
|
|
630
|
+
// 数据文件损坏状态(供 API 告警展示)
|
|
631
|
+
function getCorruptedFiles() { return [...corrupted]; }
|
|
632
|
+
|
|
544
633
|
function isValidDir(p) {
|
|
545
634
|
try { return fs.existsSync(p) && fs.statSync(p).isDirectory(); } catch { return false; }
|
|
546
635
|
}
|
|
547
636
|
|
|
548
637
|
const runner = new CardRunner();
|
|
549
638
|
|
|
550
|
-
module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, wouldCycle, 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':'');
|
|
@@ -346,11 +410,16 @@ function cardEl(card){
|
|
|
346
410
|
const blkHtml = blk ? `<div class="card-blocked${blk.kind==='wait'?' wait':''}">${blk.kind==='fail'?'⛔':'⏳'} ${esc(blk.text)}</div>` : '';
|
|
347
411
|
const resetHtml = (card.status==='pending' && /服务重启/.test(card.error||''))
|
|
348
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>' : '';
|
|
349
417
|
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>
|
|
418
|
+
<div class="meta">${badge(card)} ${elapsedHtml} ${fuHtml} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
|
|
351
419
|
${errHtml}${blkHtml}${resetHtml}
|
|
352
420
|
<div class="op">
|
|
353
|
-
<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>
|
|
354
423
|
<button class="icon-btn" data-act="reset" title="重置(清除结果回到待执行)">⟲</button>
|
|
355
424
|
<button class="icon-btn" data-act="copy" title="复制为新任务">⧉</button>
|
|
356
425
|
<button class="icon-btn" data-act="edit" title="编辑">✎</button>
|
|
@@ -360,6 +429,7 @@ function cardEl(card){
|
|
|
360
429
|
if(a==='del') delCard(card.id);
|
|
361
430
|
else if(a==='edit') openEdit(card);
|
|
362
431
|
else if(a==='run') runOne(card.id);
|
|
432
|
+
else if(a==='stop') stopOne(card.id);
|
|
363
433
|
else if(a==='reset') resetCard(card.id);
|
|
364
434
|
else if(a==='copy') copyCard(card);
|
|
365
435
|
return; }
|
|
@@ -371,6 +441,20 @@ function cardEl(card){
|
|
|
371
441
|
el.addEventListener('drop', e=>{ e.preventDefault(); el.classList.remove('drag-over'); onDrop(card.id, el); });
|
|
372
442
|
return el;
|
|
373
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);
|
|
374
458
|
let dragId=null;
|
|
375
459
|
function onDrop(targetId, targetEl){
|
|
376
460
|
if(!dragId || dragId===targetId) return;
|
|
@@ -394,9 +478,41 @@ function render(){
|
|
|
394
478
|
for(const c of CARDS){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
|
|
395
479
|
$('#cntPending').textContent=counts.pending||''; $('#cntRunning').textContent=counts.running||'';
|
|
396
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
|
+
}
|
|
397
493
|
for(const k in cols){ if(!cols[k].children.length) cols[k].innerHTML='<div class="empty">—</div>'; }
|
|
398
494
|
}
|
|
399
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
|
+
|
|
400
516
|
// ---- 编辑/新建 ----
|
|
401
517
|
// 候选列表排除自身:禁止把自己选为依赖/链首(防自环)
|
|
402
518
|
function fillSelectors(){
|
|
@@ -458,7 +574,11 @@ $('#btnEditSave').onclick=async()=>{
|
|
|
458
574
|
dependsOn:deps };
|
|
459
575
|
if(EDIT_ID) await api('/api/cards/'+EDIT_ID,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
460
576
|
else await post('/api/cards',body);
|
|
461
|
-
$('#maskEdit').classList.remove('show');
|
|
577
|
+
$('#maskEdit').classList.remove('show');
|
|
578
|
+
await loadCards();
|
|
579
|
+
// 编排状态联动提示:编排进行中=新卡自动加入调度;空闲=明确告知需手动开始
|
|
580
|
+
if(!EDIT_ID) toast($('#btnRun').disabled ? '已保存,新任务将自动加入本轮调度' : '已保存。当前编排空闲,点「▶ 开始编排」执行');
|
|
581
|
+
else toast('已保存');
|
|
462
582
|
};
|
|
463
583
|
async function delCard(id){ if(!confirm('确认删除该任务?将移入垃圾桶(30 天内可还原)。'))return; await api('/api/cards/'+id,{method:'DELETE'}); await loadCards(); toast('已移入垃圾桶'); }
|
|
464
584
|
$('#btnClear').onclick=async()=>{ if(!confirm('清空全部任务?将移入垃圾桶(30 天内可还原)。'))return; await post('/api/cards/clear'); await loadCards(); };
|
|
@@ -481,6 +601,13 @@ $('#parallelSel').onchange=async()=>{
|
|
|
481
601
|
$('#dExport').onclick=()=>{ if(!OPEN_ID)return; window.open('/api/cards/'+encodeURIComponent(OPEN_ID)+'/export.md','_blank'); };
|
|
482
602
|
async function runOne(id){
|
|
483
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
|
+
}
|
|
484
611
|
// 重跑保护:已有结果时先确认(旧结果会归档进过程日志)
|
|
485
612
|
if(c && (c.status==='done'||c.status==='failed') && c.result){
|
|
486
613
|
if(!confirm('重新执行将把上次结果归档到过程日志,然后开始新一轮执行。确认?')) return;
|
|
@@ -548,6 +675,10 @@ async function openDetail(id){
|
|
|
548
675
|
$('#dMeta').innerHTML=badge(card)+`<span class="badge">${card.status}</span>`;
|
|
549
676
|
const depTitles = (card.dependsOn||[]).map(d=>{const c=CARDS.find(x=>x.id===d);return c?c.title:d;});
|
|
550
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>';
|
|
551
682
|
// 失败原因块:任务执行错误优先,其次追加聊天的错误
|
|
552
683
|
const errBox=$('#dError');
|
|
553
684
|
const errText = card.error || card.followupError || '';
|
|
@@ -738,7 +869,8 @@ async function refreshProcs(){
|
|
|
738
869
|
const d = await api('/api/cards/processes');
|
|
739
870
|
const procs = d.processes||[];
|
|
740
871
|
const bar=$('#procBar');
|
|
741
|
-
|
|
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; }
|
|
742
874
|
bar.innerHTML='<span class="ptitle">进程:</span>'+procs.map(p=>{
|
|
743
875
|
const working=p.working;
|
|
744
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>`;
|
|
@@ -773,8 +905,17 @@ function notifyDone(done,failed){
|
|
|
773
905
|
}catch{ /* ignore */ }
|
|
774
906
|
}
|
|
775
907
|
const es = new EventSource('/api/cards/stream');
|
|
908
|
+
// 断线重连后补拉:错过的状态迁移事件靠全量刷新弥补(init 只恢复按钮态)
|
|
909
|
+
es.onopen = ()=>{ loadCards(); refreshProcs(); };
|
|
776
910
|
es.onmessage = (e)=>{
|
|
777
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); }
|
|
778
919
|
if(ev.type==='runner_started'){ $('#statusPill').textContent='编排中…'; $('#btnRun').disabled=true; $('#btnStop').disabled=false; }
|
|
779
920
|
if(ev.type==='runner_stopped'){ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true; }
|
|
780
921
|
if(ev.type==='all_done'){
|
|
@@ -783,17 +924,25 @@ es.onmessage = (e)=>{
|
|
|
783
924
|
}
|
|
784
925
|
if(ev.type==='ws_warning'){ toast('⚠ 工作区路径无效:'+ev.path+',任务将在默认目录执行'); }
|
|
785
926
|
if(ev.type==='followup_start'||ev.type==='followup_done'){ loadCards(); }
|
|
786
|
-
// text
|
|
787
|
-
if(['task_start','
|
|
927
|
+
// 状态迁移事件:才触发全量刷新(text/tool 快照不改变卡片状态)
|
|
928
|
+
if(['task_start','task_done'].includes(ev.type)){
|
|
788
929
|
loadCards();
|
|
789
930
|
refreshProcs();
|
|
931
|
+
// 失败即时提醒(完成静默:看板移动可见 + all_done 汇总通知)
|
|
932
|
+
if(ev.type==='task_done' && ev.status==='failed') toast('✗「'+(ev.title||'任务')+'」执行失败,点击卡片查看原因');
|
|
790
933
|
if(OPEN_ID && ev.cardId===OPEN_ID){
|
|
791
934
|
const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
|
|
792
935
|
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
936
|
else if(ev.type==='task_done'){ loadCards().then(()=>{ if(OPEN_ID===ev.cardId) openDetail(ev.cardId); }); }
|
|
795
937
|
}
|
|
796
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
|
+
}
|
|
797
946
|
// 正文快照独立渲染(不依赖上面的状态事件)
|
|
798
947
|
if(ev.type==='text' && OPEN_ID===ev.cardId) renderSnapText(ev);
|
|
799
948
|
if(ev.type==='followup_start' && OPEN_ID===ev.cardId) TEXT_PARTS.clear();
|
package/app/server.js
CHANGED
|
@@ -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();
|
|
@@ -1113,6 +1125,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1113
1125
|
if (p.startsWith('/api/cards/') && req.method === 'PUT') {
|
|
1114
1126
|
const id = p.slice('/api/cards/'.length);
|
|
1115
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
|
+
}
|
|
1116
1134
|
const patch = {};
|
|
1117
1135
|
if (body.title !== undefined) patch.title = String(body.title).slice(0, 500);
|
|
1118
1136
|
if (body.content !== undefined) patch.content = String(body.content).slice(0, 20000);
|
|
@@ -1150,6 +1168,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1150
1168
|
return;
|
|
1151
1169
|
}
|
|
1152
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
|
+
|
|
1153
1179
|
// 追加聊天:任务完成后复用其 opencode 会话续聊(同一进程的第二轮输入)
|
|
1154
1180
|
if (p.startsWith('/api/cards/') && req.method === 'POST' && p.endsWith('/chat')) {
|
|
1155
1181
|
const id = p.slice('/api/cards/'.length, -'/chat'.length);
|