@iamsamyiok/agents-chat 3.19.1 → 3.20.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
@@ -209,6 +209,24 @@ function isEligible(card, all) {
209
209
  return true;
210
210
  }
211
211
 
212
+ // 环检测:把 cardId 的依赖改为 newDeps 后,沿依赖边 DFS 是否能回到 cardId 自身
213
+ // 用于 PUT 写入拦截(间接环也拦截,如 A→B→C→A)
214
+ function wouldCycle(cardId, newDeps) {
215
+ const all = CardStore.list();
216
+ const byId = new Map(all.filter(c => c.id !== cardId).map(c => [c.id, c]));
217
+ const seen = new Set();
218
+ const stack = [...(newDeps || [])];
219
+ while (stack.length) {
220
+ const cur = stack.pop();
221
+ if (cur === cardId) return true;
222
+ if (seen.has(cur)) continue;
223
+ seen.add(cur);
224
+ const c = byId.get(cur);
225
+ if (c) stack.push(...(c.dependsOn || []));
226
+ }
227
+ return false;
228
+ }
229
+
212
230
  function pickEligible(all, activeIds) {
213
231
  const eligible = all.filter(c => isEligible(c, all) && !activeIds.has(c.id));
214
232
  eligible.sort((a, b) => {
@@ -356,6 +374,9 @@ class CardRunner {
356
374
  let sesId = ocSessionId;
357
375
 
358
376
  let child = null;
377
+ // 进程登记:spawn 前占位(执行中即可见于进程条),spawn 后回填真实 PID
378
+ this.procs.set(card.id, { pid: null, child: null, lastActive: Date.now() });
379
+ broadcast({ type: 'proc', cardId: card.id, pid: null });
359
380
  try {
360
381
  await new Promise((resolve) => {
361
382
  child = oc.chatSolo(kind, runner, {
@@ -385,15 +406,15 @@ class CardRunner {
385
406
  resolve();
386
407
  }
387
408
  });
409
+ // chatSolo 同步返回 child(spawn 已完成):立即回填真实 PID,执行中即可见于进程条
410
+ const proc0 = this.procs.get(card.id);
411
+ if (proc0 && child) { proc0.pid = child.pid; proc0.child = child; }
412
+ broadcast({ type: 'proc', cardId: card.id, pid: child ? child.pid : null });
388
413
  });
389
414
  } catch (err) {
390
415
  doneError = String((err && err.message) || err).slice(0, 2000);
391
416
  }
392
417
 
393
- // 登记进程信息(PID + 是否工作中),并广播给前端
394
- this.procs.set(card.id, { pid: child ? child.pid : null, child, lastActive: Date.now() });
395
- broadcast({ type: 'proc', cardId: card.id, pid: child ? child.pid : null });
396
-
397
418
  const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
398
419
  const stopped = myToken !== this.token;
399
420
  if (finalText) {
@@ -403,8 +424,9 @@ class CardRunner {
403
424
  CardStore.update(card.id, {
404
425
  status,
405
426
  ocSessionId: sesId,
406
- result: (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
407
- error: doneError || '',
427
+ // 手动停止回到待执行:清掉残留,避免 pending 卡带着脏结果/错误
428
+ result: stopped ? '' : (doneError ? `执行出错:${doneError}` : finalText).slice(0, 20000),
429
+ error: stopped ? '' : (doneError || ''),
408
430
  finishedAt: Date.now()
409
431
  });
410
432
  broadcast({ type: 'task_done', cardId: card.id, status, title: card.title });
@@ -415,6 +437,8 @@ class CardRunner {
415
437
  async runOne(cardId) {
416
438
  const card = CardStore.get(cardId);
417
439
  if (!card || card.status === 'running') return false;
440
+ // 并发防护:追加聊天进行中的卡不可同时执行(避免同一会话被两条链路并发续写)
441
+ if (this.followups.has(cardId)) return false;
418
442
  this.active.add(cardId);
419
443
  const myToken = this.token;
420
444
  await this.runCard(card, myToken).catch(() => {});
@@ -428,6 +452,7 @@ class CardRunner {
428
452
  const card = CardStore.get(cardId);
429
453
  if (!card) return { ok: false, error: '任务不存在' };
430
454
  if (card.status === 'running' || card.status === 'pending') return { ok: false, error: '任务尚未执行完成,先运行任务再追加聊天' };
455
+ if (this.active.has(cardId)) return { ok: false, error: '任务正在执行中,稍后再追加聊天' };
431
456
  if (this.followups.has(cardId)) return { ok: false, error: '该任务已有追加聊天进行中' };
432
457
  this.followups.add(cardId);
433
458
 
@@ -449,6 +474,8 @@ class CardRunner {
449
474
  if (!userText) return { ok: false, error: '请输入追加内容' };
450
475
  store.addMessage({ role: 'user', agentId: 'solo', agentName: '我', actor: 'user', phase: 'followup', taskId: cardId, content: userText.slice(0, 20000) });
451
476
  broadcast({ type: 'followup_start', cardId: cardId });
477
+ // 进程登记:spawn 前占位,spawn 后回填 PID(追加聊天执行中亦可见于进程条)
478
+ this.procs.set(cardId, { pid: null, child: null, lastActive: Date.now() });
452
479
 
453
480
  const texts = new Map();
454
481
  const order = [];
@@ -481,9 +508,14 @@ class CardRunner {
481
508
  resolve();
482
509
  }
483
510
  });
511
+ const proc0 = this.procs.get(cardId);
512
+ if (proc0 && child) { proc0.pid = child.pid; proc0.child = child; }
513
+ broadcast({ type: 'proc', cardId, pid: child ? child.pid : null });
484
514
  });
485
515
  } catch (err) {
486
516
  doneError = String((err && err.message) || err).slice(0, 2000);
517
+ } finally {
518
+ this.procs.delete(cardId);
487
519
  }
488
520
 
489
521
  const finalText = order.map(id => texts.get(id)).join('\n\n').trim();
@@ -515,4 +547,4 @@ function isValidDir(p) {
515
547
 
516
548
  const runner = new CardRunner();
517
549
 
518
- module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, MAX_PARALLEL, CARDS_PATH, CARDS_CFG_PATH };
550
+ module.exports = { CardStore, CardRunner, runner, sseSubscribe, buildCardPrompt, isEligible, pickEligible, wouldCycle, MAX_PARALLEL, CARDS_PATH, CARDS_CFG_PATH };
@@ -310,10 +310,20 @@ function errSummary(err){
310
310
  if(!err) return '';
311
311
  return String(err).split('\n').map(s=>s.trim()).filter(Boolean)[0] || '';
312
312
  }
313
- // 依赖阻塞判定:pending 卡的依赖存在未完成(等待)或已失败(阻塞)
313
+ // 依赖阻塞判定:pending 卡的依赖存在未完成(等待)或已失败(阻塞);依赖链成环时明确提示
314
314
  function blockedInfo(card){
315
315
  if(card.status!=='pending' || !(card.dependsOn&&card.dependsOn.length)) return null;
316
316
  const byId=Object.fromEntries(CARDS.map(c=>[c.id,c]));
317
+ // 环检测:沿依赖边 DFS,回到自身即成环(历史数据兜底,正常写入已被服务端拦截)
318
+ const seen=new Set(), stack=[...card.dependsOn];
319
+ while(stack.length){
320
+ const cur=stack.pop();
321
+ if(cur===card.id) return {kind:'fail',text:'循环依赖:依赖链最终依赖本任务,全量执行时将被跳过'};
322
+ if(seen.has(cur)) continue;
323
+ seen.add(cur);
324
+ const c=byId[cur];
325
+ if(c) stack.push(...(c.dependsOn||[]));
326
+ }
317
327
  const failedDeps=[], waitDeps=[];
318
328
  for(const d of card.dependsOn){
319
329
  const dc=byId[d];
@@ -525,8 +535,15 @@ function fuSetState(running, tipText){
525
535
  btn.textContent = running ? '回复中…' : '发送';
526
536
  if(tipText!==undefined) $('#fuTip').textContent = tipText;
527
537
  }
538
+ let DETAIL_SEQ = 0; // 详情加载序号:快速切换任务时只认最新请求(响应乱序防护)
528
539
  async function openDetail(id){
529
- OPEN_ID=id; const data=await api('/api/cards/'+id+'/log'); const card=data.card; const msgs=data.messages||[];
540
+ if(!id) return;
541
+ const seq = ++DETAIL_SEQ;
542
+ OPEN_ID = id;
543
+ const data = await api('/api/cards/'+encodeURIComponent(id)+'/log');
544
+ if (seq !== DETAIL_SEQ || OPEN_ID !== id) return; // 已切换到其他任务或关闭弹窗:丢弃过期响应
545
+ if(!data || !data.success || !data.card){ toast('任务不存在或已被删除'); return; }
546
+ const card=data.card; const msgs=data.messages||[];
530
547
  $('#dTitle').textContent=card.title;
531
548
  $('#dMeta').innerHTML=badge(card)+`<span class="badge">${card.status}</span>`;
532
549
  const depTitles = (card.dependsOn||[]).map(d=>{const c=CARDS.find(x=>x.id===d);return c?c.title:d;});
@@ -560,7 +577,7 @@ async function openDetail(id){
560
577
  fuSetState(FU_RUNNING);
561
578
  $('#maskDetail').classList.add('show');
562
579
  }
563
- $('#dClose').onclick=()=>{ OPEN_ID=null; $('#maskDetail').classList.remove('show'); };
580
+ $('#dClose').onclick=()=>{ OPEN_ID=null; DETAIL_SEQ++; $('#maskDetail').classList.remove('show'); };
564
581
  $('#dRun').onclick=async()=>{ if(!OPEN_ID)return; await runOne(OPEN_ID); openDetail(OPEN_ID); };
565
582
 
566
583
  // ---- 追加聊天:复用会话第二轮输入 ----
@@ -774,7 +791,7 @@ es.onmessage = (e)=>{
774
791
  const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
775
792
  if(ev.type==='task_start') TEXT_PARTS.clear();
776
793
  else if(ev.type==='tool'){ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight; }
777
- else if(ev.type==='task_done'){ loadCards().then(()=>openDetail(OPEN_ID)); }
794
+ else if(ev.type==='task_done'){ loadCards().then(()=>{ if(OPEN_ID===ev.cardId) openDetail(ev.cardId); }); }
778
795
  }
779
796
  }
780
797
  // 正文快照独立渲染(不依赖上面的状态事件)
@@ -783,7 +800,7 @@ es.onmessage = (e)=>{
783
800
  if(ev.type==='followup_done' && OPEN_ID===ev.cardId){
784
801
  fuSetState(false);
785
802
  if(ev.error){ appendLog($('#dLog'),'⚠ '+ev.error,'msg-err'); toast('追加聊天失败:'+errSummary(ev.error)); }
786
- loadCards().then(()=>openDetail(OPEN_ID));
803
+ loadCards().then(()=>{ if(OPEN_ID===ev.cardId) openDetail(ev.cardId); });
787
804
  }
788
805
  };
789
806
  es.onerror=()=>{};
@@ -634,7 +634,7 @@
634
634
  </div>
635
635
 
636
636
  <script>
637
- const PAGE_VERSION = '3.19.1'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
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.19.1'; // 页面与服务端版本互检,不一致提示强刷
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}
@@ -1071,16 +1071,22 @@ const server = http.createServer(async (req, res) => {
1071
1071
  const card = CardStore.get(id);
1072
1072
  if (!card) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
1073
1073
  const msgs = store.getMessages(id);
1074
+ // 中文友好的日期时间(YYYY-MM-DD HH:mm,避免随系统 locale 变成英文格式)
1075
+ const fmtDateTime = (t) => {
1076
+ const d = new Date(t || Date.now());
1077
+ const p2 = (n) => String(n).padStart(2, '0');
1078
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
1079
+ };
1074
1080
  const roleOf = (m) => (m.role === 'user' ? '用户' : (m.phase === 'archive' ? '归档' : (m.phase === 'system' ? '系统' : 'Agent')));
1075
1081
  const lines = [
1076
1082
  `# ${card.title}`, '',
1077
1083
  `- 状态:${card.status}|模式:${card.mode}|优先级:P${card.priority}`,
1078
- `- 创建:${new Date(card.createdAt).toLocaleString()}${card.finishedAt ? `|完成:${new Date(card.finishedAt).toLocaleString()}` : ''}`,
1084
+ `- 创建:${fmtDateTime(card.createdAt)}${card.finishedAt ? `|完成:${fmtDateTime(card.finishedAt)}` : ''}`,
1079
1085
  card.error ? `- 失败原因:${card.error.replace(/\n/g, ' ')}` : '',
1080
1086
  '', '## 任务内容', '', String(card.content || '').trim(), '', '## 执行过程', ''
1081
1087
  ];
1082
1088
  for (const m of msgs) {
1083
- lines.push(`### ${new Date(m.timestamp || Date.now()).toLocaleString()} · ${roleOf(m)}`, '');
1089
+ lines.push(`### ${fmtDateTime(m.timestamp || Date.now())} · ${roleOf(m)}`, '');
1084
1090
  lines.push(String(m.content || '').trim() || '(无内容)');
1085
1091
  lines.push('');
1086
1092
  }
@@ -1111,9 +1117,14 @@ const server = http.createServer(async (req, res) => {
1111
1117
  if (body.title !== undefined) patch.title = String(body.title).slice(0, 500);
1112
1118
  if (body.content !== undefined) patch.content = String(body.content).slice(0, 20000);
1113
1119
  if (body.priority !== undefined) patch.priority = Number(body.priority) || 999;
1114
- if (body.mode !== undefined) patch.mode = body.mode;
1115
- if (body.chainId !== undefined) patch.chainId = body.chainId;
1116
- if (Array.isArray(body.dependsOn)) patch.dependsOn = body.dependsOn.map(String);
1120
+ if (body.mode !== undefined) patch.mode = ['new', 'continue', 'parallel'].includes(body.mode) ? body.mode : 'new';
1121
+ if (body.chainId !== undefined) patch.chainId = String(body.chainId).slice(0, 100);
1122
+ if (Array.isArray(body.dependsOn)) {
1123
+ const deps = body.dependsOn.map(String).slice(0, 50);
1124
+ // 环检测:修改依赖后若形成(直接或间接)循环依赖则拒绝写入
1125
+ if (wouldCycle(id, deps)) { json(res, 409, { success: false, error: '循环依赖:该任务的依赖链最终会依赖它自己,请调整依赖关系' }); return; }
1126
+ patch.dependsOn = deps;
1127
+ }
1117
1128
  if (body.model !== undefined) patch.model = String(body.model).slice(0, 80);
1118
1129
  // 状态手动复位:failed/pending -> pending 可重跑
1119
1130
  if (body.status === 'pending') { patch.status = 'pending'; patch.result = ''; patch.error = ''; patch.ocSessionId = ''; }
@@ -1125,6 +1136,7 @@ const server = http.createServer(async (req, res) => {
1125
1136
 
1126
1137
  if (p.startsWith('/api/cards/') && req.method === 'DELETE') {
1127
1138
  const id = p.slice('/api/cards/'.length);
1139
+ if (!CardStore.get(id)) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
1128
1140
  cardRunner.killCard(id);
1129
1141
  CardStore.remove(id);
1130
1142
  json(res, 200, { success: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.19.1",
3
+ "version": "3.20.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {