@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 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
- try { return JSON.parse(fs.readFileSync(CARDS_PATH, 'utf8')); } catch { return []; }
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
- try { return JSON.parse(fs.readFileSync(CARDS_CFG_PATH, 'utf8')); } catch { return { workspace: '' }; }
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
- try { return JSON.parse(fs.readFileSync(TRASH_PATH, 'utf8')); } catch { return []; }
92
+ const v = readTrashRaw();
93
+ return Array.isArray(v) ? v.slice() : [];
43
94
  }
44
95
  function writeTrash(list) {
45
- ensureDir();
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
- const eligible = pickEligible(all, this.active);
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 status = stopped ? 'pending' : (doneError ? 'failed' : 'done');
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: stopped ? '' : (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
429
- error: stopped ? '' : (doneError || ''),
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='solo')
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
 
@@ -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>优先级(数字越小越优先)</label><input id="fPriority" type="number" value="999" /></div>
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,'&quot;').replace(/'/g,'&#39;');}
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="run" title="执行">▶</button>
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'); await loadCards(); toast('已保存');
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
- if(!procs.length){ bar.innerHTML='<span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span>'; return; }
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 为正文快照(不改变卡片状态):仅实时渲染,不触发全量刷新,避免流式期间频繁重建 DOM
787
- if(['task_start','tool','task_done','proc'].includes(ev.type)){
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
- json(res, 200, { success: true, cards, running: cardRunner.isRunning(), maxParallel: cardRunner.maxP(), msgCounts: counts, config: CardStore.getConfig() });
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.20.0",
3
+ "version": "3.21.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {