@iamsamyiok/agents-chat 3.18.0 → 3.19.1

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
@@ -106,11 +106,14 @@ const CardStore = {
106
106
  writeCards(list);
107
107
  return c;
108
108
  },
109
- // 拖拽重排:按给定 id 顺序重编 order(ids 为看板当前顺序的全量 id)
109
+ // 拖拽重排:order 重编 + priority 按新顺序重映射(保值域、变归属),
110
+ // 使调度顺序恒等于看板顺序(priority 仍是排序主键,但层内次序由拖拽决定)
110
111
  reorder(ids) {
111
112
  const list = readCards();
112
113
  const map = new Map(list.map(c => [c.id, c]));
113
- ids.forEach((id, i) => { const c = map.get(id); if (c) c.order = i + 1; });
114
+ const ordered = ids.map(id => map.get(id)).filter(Boolean);
115
+ const prios = ordered.map(c => (c.priority === undefined ? 999 : c.priority)).sort((a, b) => a - b);
116
+ ordered.forEach((c, i) => { c.order = i + 1; c.priority = prios[i]; });
114
117
  writeCards(list);
115
118
  return true;
116
119
  },
@@ -224,9 +227,20 @@ class CardRunner {
224
227
  this.running = false;
225
228
  this.timer = null;
226
229
  this.procs = new Map(); // cardId -> { pid, child, lastActive, status }
230
+ this.followups = new Set(); // 追加聊天中的卡牌(并发防护)
231
+ this.baseline = null; // 本轮编排开始时的 done/failed 基线(all_done 报增量)
227
232
  }
228
233
  isRunning() { return this.running; }
229
234
 
235
+ // 并行度:cards_config.maxParallel 可热更新(1-8),未配置时用 env 默认
236
+ maxP() {
237
+ try {
238
+ const n = Number(readConfig().maxParallel);
239
+ if (n >= 1 && n <= 8) return Math.floor(n);
240
+ } catch { /* ignore */ }
241
+ return MAX_PARALLEL;
242
+ }
243
+
230
244
  // 终止单个任务对应的子进程
231
245
  killCard(cardId) {
232
246
  this.active.delete(cardId);
@@ -259,6 +273,9 @@ class CardRunner {
259
273
  if (this.running) return;
260
274
  this.running = true;
261
275
  this.token++;
276
+ // 记录基线:all_done 时报告本轮增量(成功/失败数),避免混入历史任务
277
+ const all0 = CardStore.list();
278
+ this.baseline = { done: all0.filter(c => c.status === 'done').length, failed: all0.filter(c => c.status === 'failed').length };
262
279
  broadcast({ type: 'runner_started' });
263
280
  this.tick();
264
281
  }
@@ -269,10 +286,19 @@ class CardRunner {
269
286
  const all = CardStore.list();
270
287
  const eligible = pickEligible(all, this.active);
271
288
  if (!eligible.length) {
272
- if (this.active.size === 0) { this.running = false; broadcast({ type: 'all_done' }); }
289
+ if (this.active.size === 0) {
290
+ this.running = false;
291
+ // 完成通知附本轮增量统计(成功/失败数),前端据此提醒
292
+ const all = CardStore.list();
293
+ const done = Math.max(0, all.filter(c => c.status === 'done').length - (this.baseline ? this.baseline.done : 0));
294
+ const failed = Math.max(0, all.filter(c => c.status === 'failed').length - (this.baseline ? this.baseline.failed : 0));
295
+ this.baseline = null;
296
+ broadcast({ type: 'all_done', done, failed });
297
+ }
273
298
  return;
274
299
  }
275
- while (this.active.size < MAX_PARALLEL && eligible.length > 0) {
300
+ const limit = this.maxP();
301
+ while (this.active.size < limit && eligible.length > 0) {
276
302
  const card = eligible.shift();
277
303
  this.active.add(card.id);
278
304
  const p = this.runCard(card, myToken);
@@ -284,15 +310,19 @@ class CardRunner {
284
310
  }
285
311
 
286
312
  async runCard(card, myToken) {
313
+ // 重跑保护(最先执行):上次结果先归档进过程日志,可追溯,再清空
314
+ if (card.result) {
315
+ store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'archive', taskId: card.id, content: `[上次结果归档]\n${String(card.result).slice(0, 20000)}` });
316
+ }
287
317
  const runner = resolveRunner();
288
318
  if (runner.kind === 'missing') {
289
319
  const hint = require('./agent').missingHint(runner);
290
- CardStore.update(card.id, { status: 'failed', error: hint.slice(0, 2000), finishedAt: Date.now() });
320
+ CardStore.update(card.id, { status: 'failed', error: hint.slice(0, 2000), result: '', finishedAt: Date.now() });
291
321
  broadcast({ type: 'task_done', cardId: card.id, status: 'failed', title: card.title });
292
322
  return;
293
323
  }
294
324
  if (runner.kind === 'demo') {
295
- CardStore.update(card.id, { status: 'failed', error: '演示模式下任务执行不可用,请安装 opencode/claude/codex/pi 内核', finishedAt: Date.now() });
325
+ CardStore.update(card.id, { status: 'failed', error: '演示模式下任务执行不可用,请安装 opencode/claude/codex/pi 内核', result: '', finishedAt: Date.now() });
296
326
  broadcast({ type: 'task_done', cardId: card.id, status: 'failed', title: card.title });
297
327
  return;
298
328
  }
@@ -309,7 +339,17 @@ class CardRunner {
309
339
  const kind = runner.kind === 'opencode' ? 'opencode' : 'fallback';
310
340
  const cfg = CardStore.getConfig();
311
341
  const workspace = (cfg.workspace || '').trim();
312
- const prompt = buildCardPrompt(card, workspace && isValidDir(workspace) ? workspace : '');
342
+ // 工作区校验:路径无效时显式警告(禁止静默降级到默认目录)
343
+ let ws = '';
344
+ if (workspace) {
345
+ if (isValidDir(workspace)) ws = workspace;
346
+ else {
347
+ const warn = `[系统提示] 工作区路径无效:${workspace},本任务将在默认目录执行`;
348
+ store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'system', taskId: card.id, content: warn });
349
+ broadcast({ type: 'ws_warning', cardId: card.id, path: workspace });
350
+ }
351
+ }
352
+ const prompt = buildCardPrompt(card, ws);
313
353
  const texts = new Map();
314
354
  const order = [];
315
355
  let doneError = '';
@@ -323,7 +363,7 @@ class CardRunner {
323
363
  model: card.model || '',
324
364
  ocSessionId: sesId,
325
365
  behavior: 'card',
326
- cwd: workspace && isValidDir(workspace) ? workspace : undefined
366
+ cwd: ws || undefined
327
367
  }, (ev) => {
328
368
  const proc = this.procs.get(card.id);
329
369
  if (proc) { proc.lastActive = Date.now(); }
@@ -388,8 +428,7 @@ class CardRunner {
388
428
  const card = CardStore.get(cardId);
389
429
  if (!card) return { ok: false, error: '任务不存在' };
390
430
  if (card.status === 'running' || card.status === 'pending') return { ok: false, error: '任务尚未执行完成,先运行任务再追加聊天' };
391
- if (this.followups && this.followups.has(cardId)) return { ok: false, error: '该任务已有追加聊天进行中' };
392
- if (!this.followups) this.followups = new Set();
431
+ if (this.followups.has(cardId)) return { ok: false, error: '该任务已有追加聊天进行中' };
393
432
  this.followups.add(cardId);
394
433
 
395
434
  const runner = resolveRunner();
@@ -61,7 +61,7 @@
61
61
  .card:hover{border-color:var(--acc);box-shadow:0 2px 8px rgba(0,0,0,.08)}
62
62
  .card.dragging{opacity:.4}
63
63
  .card.drag-over{border-color:var(--acc);border-style:dashed}
64
- .card .title{font-weight:600;margin-bottom:6px;line-height:1.4;word-break:break-word;padding-right:60px}
64
+ .card .title{font-weight:600;margin-bottom:6px;line-height:1.4;word-break:break-word;padding-right:150px}
65
65
  .card .meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:6px}
66
66
  .badge{font-size:11px;padding:2px 7px;border-radius:6px;background:#eef0f3;color:var(--dim);border:1px solid var(--line)}
67
67
  .badge.prio-high{background:#fdeaea;color:var(--err);border-color:#f3c9c9}
@@ -93,6 +93,24 @@
93
93
  .dep-tag{font-size:11px;color:var(--acc2);margin-top:4px}
94
94
  /* 失败原因摘要(看板卡) */
95
95
  .card-err{margin-top:6px;font-size:11.5px;line-height:1.5;color:var(--err);background:#fdeceb;border:1px solid #f3c9c9;border-radius:6px;padding:4px 8px;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
96
+ /* 阻塞/等待提示(待执行卡) */
97
+ .card-blocked{margin-top:6px;font-size:11.5px;line-height:1.5;color:#8a5a00;background:#fdf6ec;border:1px solid #f0dcb8;border-radius:6px;padding:4px 8px;word-break:break-word}
98
+ .card-blocked.wait{color:#4a6cf7;background:#f0f4ff;border-color:#c9d8ff}
99
+ /* 服务重启复位提示(灰字) */
100
+ .card-reset{margin-top:4px;font-size:11px;color:var(--dim);line-height:1.4}
101
+ /* 列头小按钮(批量重跑等) */
102
+ .col-h2{display:flex;align-items:center;gap:8px}
103
+ .col-h2 .mini-act{font-size:11px;color:var(--err);background:#fff;border:1px solid #f3c9c9;border-radius:6px;padding:2px 7px;cursor:pointer;white-space:nowrap}
104
+ .col-h2 .mini-act:hover{background:#fdeceb}
105
+ /* 编排完成通知卡(右下角) */
106
+ .done-card{position:fixed;right:18px;bottom:84px;z-index:60;width:280px;background:#fff;border:1px solid #d9f2e3;border-left:3px solid var(--acc);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:10px 12px;animation:dt-in .25s ease;cursor:pointer}
107
+ .done-card.has-fail{border-color:#f3c9c9;border-left-color:var(--err)}
108
+ .done-card .dc-t{font-size:13px;font-weight:600;color:var(--acc);margin-bottom:2px}
109
+ .done-card.has-fail .dc-t{color:var(--err)}
110
+ .done-card .dc-b{font-size:12px;color:#555}
111
+ /* 依赖图阻塞节点 */
112
+ .mini-svg .g-node.blocked rect{stroke:var(--warn);stroke-dasharray:3 2}
113
+ .gp-body{touch-action:none}
96
114
  /* 详情弹窗:完整错误块 */
97
115
  .detail-err{background:#fdeceb;border:1px solid #f3c9c9;border-left:3px solid var(--err);border-radius:8px;padding:10px 12px;margin-bottom:10px;white-space:pre-wrap;word-break:break-word;max-height:18vh;overflow:auto;font-size:12.5px;line-height:1.6;color:#b03a31}
98
116
  .detail-err b{color:var(--err)}
@@ -143,6 +161,11 @@
143
161
  <span class="ws-pick" id="wsPick" title="弹窗选择文件夹">选择</span>
144
162
  <span class="ws-save" id="wsSave">保存</span>
145
163
  </div>
164
+ <div class="ws-box" title="并行执行的最多任务数(1-8,热更新)">
165
+ ⚡ 并行 <select id="parallelSel" style="border:none;outline:none;font-size:12px;background:transparent;width:42px">
166
+ <option>1</option><option>2</option><option>3</option><option>4</option><option>5</option><option>6</option><option>7</option><option>8</option>
167
+ </select>
168
+ </div>
146
169
  <button class="btn" id="btnNew">+ 新建任务</button>
147
170
  <button class="btn primary" id="btnRun">▶ 开始编排</button>
148
171
  <button class="btn danger" id="btnStop" disabled>⏹ 停止</button>
@@ -159,7 +182,7 @@
159
182
  <button class="icon-btn" id="gpCollapse" title="收起面板">◀</button>
160
183
  </div>
161
184
  <div class="gp-legend">
162
- 自上而下按依赖分层;实线=依赖,蓝色虚线=同会话续聊。
185
+ 自上而下按依赖分层;实线=依赖,蓝色虚线=同会话续聊;橙色虚线框=被阻塞;滚轮缩放。
163
186
  <i style="background:var(--pending)"></i>待执行
164
187
  <i style="background:var(--running)"></i>执行中
165
188
  <i style="background:var(--done)"></i>完成
@@ -172,7 +195,7 @@
172
195
  <div class="col"><h2><span class="dot" style="background:var(--pending)"></span>待执行 <span id="cntPending" class="badge"></span></h2><div id="colPending" class="drop-zone"></div></div>
173
196
  <div class="col"><h2><span class="dot" style="background:var(--running)"></span>执行中 <span id="cntRunning" class="badge"></span></h2><div id="colRunning" class="drop-zone"></div></div>
174
197
  <div class="col"><h2><span class="dot" style="background:var(--done)"></span>已完成 <span id="cntDone" class="badge"></span></h2><div id="colDone" class="drop-zone"></div></div>
175
- <div class="col"><h2><span class="dot" style="background:var(--failed)"></span>失败 <span id="cntFailed" class="badge"></span></h2><div id="colFailed" class="drop-zone"></div></div>
198
+ <div class="col"><h2 class="col-h2"><span class="dot" style="background:var(--failed)"></span>失败 <span id="cntFailed" class="badge"></span><span style="flex:1"></span><button class="mini-act" id="btnRetryFailed" title="把全部失败任务重置为待执行并开始编排">↻ 全部重跑</button></h2><div id="colFailed" class="drop-zone"></div></div>
176
199
  </div>
177
200
  </main>
178
201
  <span id="gpExpand" style="display:none;position:fixed;left:14px;bottom:80px;z-index:41" title="展开依赖图"><button class="btn" id="gpExpandBtn">▶ 依赖图</button></span>
@@ -199,7 +222,8 @@
199
222
  <div><label>续聊链首(仅 continue 模式)</label><select id="fChain"></select></div>
200
223
  <div><label>依赖任务(完成后才可执行)</label><select id="fDeps" multiple size="3"></select></div>
201
224
  </div>
202
- <label>模型(留空用默认)</label><input id="fModel" placeholder="provider/model" />
225
+ <label>模型(留空用默认)</label><input id="fModel" placeholder="provider/model" list="modelList" />
226
+ <datalist id="modelList"></datalist>
203
227
  <div class="foot">
204
228
  <button class="btn" id="btnEditCancel">取消</button>
205
229
  <button class="btn primary" id="btnEditSave">保存</button>
@@ -228,6 +252,7 @@
228
252
  </div>
229
253
  <div class="foot">
230
254
  <button class="btn" id="dClose">关闭</button>
255
+ <button class="btn" id="dExport" title="导出为 Markdown(元信息+过程+结果)">⬇ 导出</button>
231
256
  <button class="btn primary" id="dRun">▶ 立即执行</button>
232
257
  </div>
233
258
  </div>
@@ -267,6 +292,7 @@ async function loadCards(){
267
292
  $('#btnRun').disabled = data.running;
268
293
  $('#btnStop').disabled = !data.running;
269
294
  if(data.config) $('#wsInput').value = data.config.workspace || '';
295
+ if(data.maxParallel) $('#parallelSel').value = String(data.maxParallel);
270
296
  render();
271
297
  renderGraph();
272
298
  loadTrashCount();
@@ -284,6 +310,20 @@ function errSummary(err){
284
310
  if(!err) return '';
285
311
  return String(err).split('\n').map(s=>s.trim()).filter(Boolean)[0] || '';
286
312
  }
313
+ // 依赖阻塞判定:pending 卡的依赖存在未完成(等待)或已失败(阻塞)
314
+ function blockedInfo(card){
315
+ if(card.status!=='pending' || !(card.dependsOn&&card.dependsOn.length)) return null;
316
+ const byId=Object.fromEntries(CARDS.map(c=>[c.id,c]));
317
+ const failedDeps=[], waitDeps=[];
318
+ for(const d of card.dependsOn){
319
+ const dc=byId[d];
320
+ if(!dc||dc.status==='failed') failedDeps.push(dc?dc.title:'(已删除)');
321
+ else if(dc.status!=='done') waitDeps.push(dc.title);
322
+ }
323
+ if(failedDeps.length) return {kind:'fail',text:`阻塞:依赖失败(${failedDeps.slice(0,2).join('、')}${failedDeps.length>2?' 等':''})`};
324
+ if(waitDeps.length) return {kind:'wait',text:`等待依赖完成(${waitDeps.slice(0,2).join('、')}${waitDeps.length>2?' 等':''})`};
325
+ return null;
326
+ }
287
327
  function cardEl(card){
288
328
  const el = document.createElement('div');
289
329
  el.className='card';
@@ -292,16 +332,27 @@ function cardEl(card){
292
332
  el.dataset.status = card.status;
293
333
  const errHtml = (card.status==='failed' && (card.error||card.followupError))
294
334
  ? `<div class="card-err" title="${esc(card.error||card.followupError)}">⚠ ${esc(errSummary(card.error||card.followupError).slice(0,140))}</div>` : '';
335
+ const blk = blockedInfo(card);
336
+ const blkHtml = blk ? `<div class="card-blocked${blk.kind==='wait'?' wait':''}">${blk.kind==='fail'?'⛔':'⏳'} ${esc(blk.text)}</div>` : '';
337
+ const resetHtml = (card.status==='pending' && /服务重启/.test(card.error||''))
338
+ ? `<div class="card-reset">↻ ${esc(errSummary(card.error))}</div>` : '';
295
339
  el.innerHTML = `<div class="title">${esc(card.title)}</div>
296
340
  <div class="meta">${badge(card)} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
297
- ${errHtml}
341
+ ${errHtml}${blkHtml}${resetHtml}
298
342
  <div class="op">
299
343
  <button class="icon-btn" data-act="run" title="执行">▶</button>
344
+ <button class="icon-btn" data-act="reset" title="重置(清除结果回到待执行)">⟲</button>
345
+ <button class="icon-btn" data-act="copy" title="复制为新任务">⧉</button>
300
346
  <button class="icon-btn" data-act="edit" title="编辑">✎</button>
301
347
  <button class="icon-btn" data-act="del" title="删除">✕</button>
302
348
  </div>`;
303
349
  el.onclick = (e)=>{ const b=e.target.closest('[data-act]'); if(b){e.stopPropagation(); const a=b.dataset.act;
304
- if(a==='del') delCard(card.id); else if(a==='edit') openEdit(card); else if(a==='run') runOne(card.id); return; }
350
+ if(a==='del') delCard(card.id);
351
+ else if(a==='edit') openEdit(card);
352
+ else if(a==='run') runOne(card.id);
353
+ else if(a==='reset') resetCard(card.id);
354
+ else if(a==='copy') copyCard(card);
355
+ return; }
305
356
  openDetail(card.id); };
306
357
  el.addEventListener('dragstart', e=>{ dragId=card.id; el.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
307
358
  el.addEventListener('dragend', ()=>{ el.classList.remove('dragging'); document.querySelectorAll('.card').forEach(c=>c.classList.remove('drag-over')); });
@@ -322,7 +373,7 @@ function onDrop(targetId, targetEl){
322
373
  else targetEl.parentNode.insertBefore(dragged, targetEl);
323
374
  dragId=null;
324
375
  const ids = [...document.querySelectorAll('.card')].map(c=>c.dataset.id);
325
- post('/api/cards/reorder',{ids}).then(()=>loadCards());
376
+ post('/api/cards/reorder',{ids}).then(()=>{ loadCards(); toast('已按新顺序调整调度顺序'); });
326
377
  }
327
378
  document.addEventListener('dragover', e=>{ window.__dropY = e.clientY; }, true);
328
379
 
@@ -337,29 +388,64 @@ function render(){
337
388
  }
338
389
 
339
390
  // ---- 编辑/新建 ----
391
+ // 候选列表排除自身:禁止把自己选为依赖/链首(防自环)
340
392
  function fillSelectors(){
341
- const ch=$('#fChain'); ch.innerHTML='<option value="">(无)</option>'+CARDS.map(c=>`<option value="${c.id}">${esc(c.title)}</option>`).join('');
342
- const dp=$('#fDeps'); dp.innerHTML=CARDS.map(c=>`<option value="${c.id}">${esc(c.title)}</option>`).join('');
393
+ const others=CARDS.filter(c=>c.id!==EDIT_ID);
394
+ const ch=$('#fChain'); ch.innerHTML='<option value="">(无)</option>'+others.map(c=>`<option value="${c.id}">${esc(c.title)}</option>`).join('');
395
+ const dp=$('#fDeps'); dp.innerHTML=others.map(c=>`<option value="${c.id}">${esc(c.title)} · ${c.status==='done'?'✓':c.status}</option>`).join('');
343
396
  }
344
397
  let EDIT_ID=null;
345
- function openEdit(card){
398
+ function openEdit(card, prefill){
346
399
  EDIT_ID = card?card.id:null;
347
- $('#editTitle').textContent = card?'编辑任务':'新建任务';
348
- $('#fTitle').value = card?card.title:'';
349
- $('#fContent').value = card?card.content:'';
350
- $('#fPriority').value = card?card.priority:999;
351
- $('#fMode').value = card?card.mode:'new';
352
- $('#fModel').value = card?card.model:'';
400
+ const src = card || prefill || {};
401
+ $('#editTitle').textContent = card?'编辑任务':(prefill?'复制任务':'新建任务');
402
+ $('#fTitle').value = src.title||'';
403
+ $('#fContent').value = src.content||'';
404
+ $('#fPriority').value = src.priority!==undefined?src.priority:999;
405
+ $('#fMode').value = src.mode||'new';
406
+ $('#fModel').value = src.model||'';
353
407
  fillSelectors();
354
- if(card){ $('#fChain').value=card.chainId||''; for(const o of $('#fDeps').options) o.selected=card.dependsOn.includes(o.value); }
408
+ $('#fChain').value=src.chainId||'';
409
+ for(const o of $('#fDeps').options) o.selected=(src.dependsOn||[]).includes(o.value);
355
410
  $('#maskEdit').classList.add('show');
411
+ loadModelList();
412
+ }
413
+ // 模型候选:复用单聊的内核模型列表(datalist,仍可手输)
414
+ async function loadModelList(){
415
+ try{
416
+ const d=await api('/api/oc/models');
417
+ const list=(d.models||[]).map(m=>`<option value="${esc(m.id)}">`).join('');
418
+ $('#modelList').innerHTML=list;
419
+ }catch{ /* ignore */ }
420
+ }
421
+ // 依赖环检测:新增 deps/chain 后,从候选依赖出发能否走回自身
422
+ function depCycle(id, deps){
423
+ const byId=Object.fromEntries(CARDS.map(c=>[c.id,c]));
424
+ const seen=new Set();
425
+ const stack=[...deps];
426
+ while(stack.length){
427
+ const cur=stack.pop();
428
+ if(cur===id) return true;
429
+ if(seen.has(cur)) continue;
430
+ seen.add(cur);
431
+ const c=byId[cur];
432
+ if(c) stack.push(...(c.dependsOn||[]), c.chainId||'');
433
+ }
434
+ return false;
356
435
  }
357
436
  $('#btnNew').onclick=()=>openEdit(null);
358
437
  $('#btnEditCancel').onclick=()=>$('#maskEdit').classList.remove('show');
359
438
  $('#btnEditSave').onclick=async()=>{
439
+ const mode=$('#fMode').value, chainId=$('#fChain').value;
440
+ const deps=[...$('#fDeps').selectedOptions].map(o=>o.value);
441
+ // continue 必须有链首:避免"名义续聊、实际新进程"的语义错位
442
+ if(mode==='continue' && !chainId){ toast('同会话续聊(continue)必须选择续聊链首'); return; }
443
+ // 环检测(chainId 会成为隐式依赖,一并纳入)
444
+ const effDeps=[...deps]; if(mode==='continue'&&chainId&&!effDeps.includes(chainId)) effDeps.push(chainId);
445
+ if(EDIT_ID && depCycle(EDIT_ID, effDeps)){ toast('依赖设置成环:所选依赖(直接或间接)依赖于本任务,请调整'); return; }
360
446
  const body={ title:$('#fTitle').value, content:$('#fContent').value, priority:Number($('#fPriority').value)||999,
361
- mode:$('#fMode').value, chainId:$('#fChain').value, model:$('#fModel').value,
362
- dependsOn:[...$('#fDeps').selectedOptions].map(o=>o.value) };
447
+ mode, chainId, model:$('#fModel').value,
448
+ dependsOn:deps };
363
449
  if(EDIT_ID) await api('/api/cards/'+EDIT_ID,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
364
450
  else await post('/api/cards',body);
365
451
  $('#maskEdit').classList.remove('show'); await loadCards(); toast('已保存');
@@ -368,9 +454,47 @@ async function delCard(id){ if(!confirm('确认删除该任务?将移入垃圾
368
454
  $('#btnClear').onclick=async()=>{ if(!confirm('清空全部任务?将移入垃圾桶(30 天内可还原)。'))return; await post('/api/cards/clear'); await loadCards(); };
369
455
 
370
456
  // ---- 执行控制 ----
371
- $('#btnRun').onclick=async()=>{ await post('/api/cards/run'); await loadCards(); toast('已开始编排,完成即自动注入下一个任务'); };
457
+ $('#btnRun').onclick=async()=>{
458
+ // 顺手请求系统通知权限(无侵入,拒绝也不影响)
459
+ try{ if('Notification' in window && Notification.permission==='default') Notification.requestPermission(); }catch{ /* ignore */ }
460
+ await post('/api/cards/run'); await loadCards(); toast('已开始编排,完成即自动注入下一个任务');
461
+ };
372
462
  $('#btnStop').onclick=async()=>{ await post('/api/cards/stop'); await loadCards(); };
373
- async function runOne(id){ await post('/api/cards/'+id+'/run'); await loadCards(); }
463
+ $('#btnRetryFailed').onclick=retryAllFailed;
464
+ // 并行度热更新(1-8)
465
+ $('#parallelSel').onchange=async()=>{
466
+ const n=Number($('#parallelSel').value);
467
+ const r=await post('/api/cards/config',{maxParallel:n});
468
+ if(r.success) toast('并行数已设为 '+n);
469
+ };
470
+ // 导出当前任务为 Markdown
471
+ $('#dExport').onclick=()=>{ if(!OPEN_ID)return; window.open('/api/cards/'+encodeURIComponent(OPEN_ID)+'/export.md','_blank'); };
472
+ async function runOne(id){
473
+ const c=CARDS.find(x=>x.id===id);
474
+ // 重跑保护:已有结果时先确认(旧结果会归档进过程日志)
475
+ if(c && (c.status==='done'||c.status==='failed') && c.result){
476
+ if(!confirm('重新执行将把上次结果归档到过程日志,然后开始新一轮执行。确认?')) return;
477
+ }
478
+ await post('/api/cards/'+id+'/run'); await loadCards();
479
+ }
480
+ // 重置:清除结果/错误/会话,回到待执行
481
+ async function resetCard(id){
482
+ if(!confirm('重置该任务?将清除结果与失败原因,回到待执行。'))return;
483
+ await api('/api/cards/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:'pending'})});
484
+ await loadCards(); toast('已重置');
485
+ }
486
+ // 复制为新任务:预填全部字段(状态清零)
487
+ function copyCard(card){
488
+ openEdit(null,{ title:(card.title||'')+' (副本)', content:card.content||'', priority:card.priority, mode:card.mode, model:card.model||'', chainId:card.chainId||'', dependsOn:[...(card.dependsOn||[])] });
489
+ }
490
+ // 批量重跑失败:failed -> pending 后由调度器接管
491
+ async function retryAllFailed(){
492
+ const n=CARDS.filter(c=>c.status==='failed').length;
493
+ if(!n){ toast('没有失败的任务'); return; }
494
+ if(!confirm(`重跑全部 ${n} 个失败任务?`)) return;
495
+ const r=await post('/api/cards/retry-failed');
496
+ await loadCards(); toast(r.retried?('已重跑 '+r.retried+' 个失败任务'):'重跑失败');
497
+ }
374
498
 
375
499
  // ---- 工作区(弹窗选择文件夹) ----
376
500
  $('#wsPick').onclick=()=>{ $('#dirPicker').value=''; $('#dirPicker').click(); };
@@ -382,14 +506,18 @@ $('#dirPicker').addEventListener('change', ()=>{
382
506
  $('#wsInput').value = p;
383
507
  if(p) post('/api/cards/config',{workspace:p}).then(()=>toast('工作区已设为:'+p));
384
508
  });
385
- $('#wsSave').onclick=async()=>{ const v=$('#wsInput').value.trim(); const r=await post('/api/cards/config',{workspace:v}); if(r.success){ toast(v?'工作区已设为:'+v:'工作区已清除'); } };
509
+ $('#wsSave').onclick=async()=>{
510
+ const v=$('#wsInput').value.trim();
511
+ const r=await post('/api/cards/config',{workspace:v});
512
+ if(r.success){ toast(v?'工作区已设为:'+v:'工作区已清除'); if(r.warning) toast(r.warning); }
513
+ };
386
514
 
387
515
  // ---- 详情 ----
388
516
  function appendLog(el, line, cls){
389
517
  const span=document.createElement('div'); if(cls) span.className=cls; span.textContent=line; el.appendChild(span);
390
518
  }
391
- let FU_RUNNING = false;
392
- let FU_PARTS = new Map(); // 追加聊天正文:partId -> DOM 节点(快照覆盖式渲染)
519
+ let FU_RUNNING = false;
520
+ let TEXT_PARTS = new Map(); // 正文快照:partId -> DOM 节点(覆盖式渲染)
393
521
  function fuSetState(running, tipText){
394
522
  FU_RUNNING = running;
395
523
  const btn=$('#fuSend');
@@ -408,10 +536,12 @@ async function openDetail(id){
408
536
  const errText = card.error || card.followupError || '';
409
537
  if(errText){ errBox.style.display='block'; errBox.innerHTML='<b>⚠ 失败原因</b>\n'+esc(errText); }
410
538
  else errBox.style.display='none';
411
- const log=$('#dLog'); log.innerHTML=''; FU_PARTS.clear();
539
+ const log=$('#dLog'); log.innerHTML=''; TEXT_PARTS.clear();
412
540
  if(!msgs.length) log.innerHTML='<span class="empty">暂无过程记录</span>';
413
541
  for(const m of msgs){
414
542
  if(m.role==='user') appendLog(log,'👤 '+m.content,'msg-user');
543
+ else if(m.phase==='archive') appendLog(log,'📦 '+m.content,'msg-tool');
544
+ else if(m.phase==='system') appendLog(log,'⚠ '+m.content,'msg-err');
415
545
  else if(/工具|执行完成/.test(m.content||'')) appendLog(log,'🔧 '+m.content,'msg-tool');
416
546
  else if(/出错|失败/.test(m.content||'')) appendLog(log,'⚠ '+m.content,'msg-err');
417
547
  else appendLog(log,'🤖 '+(m.content||'').slice(0,4000),'msg-assistant');
@@ -458,7 +588,7 @@ let G_SIG=''; // 结构+状态指纹:无变化时跳过重绘(避免流式
458
588
  function renderGraph(force){
459
589
  const box=$('#graphBody');
460
590
  const cards=CARDS;
461
- const sig=cards.map(c=>c.id+':'+c.status+':'+(c.dependsOn||[]).join(',')).join('|');
591
+ const sig=cards.map(c=>c.id+':'+c.status+':'+(c.dependsOn||[]).join(',')+':'+c.title+':'+c.mode).join('|');
462
592
  if(!force && sig===G_SIG && box.querySelector('svg')) return;
463
593
  G_SIG=sig;
464
594
  if(!cards.length){ box.innerHTML='<div class="empty" style="padding:24px 8px">暂无任务<br/>新建任务后此处展示依赖关系</div>'; return; }
@@ -505,13 +635,14 @@ function renderGraph(force){
505
635
  edges+=`<path class="edge ${isContinue?'continue':''}" d="M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2-3}" marker-end="${isContinue?'url(#marC)':'url(#mar)'}"/>`;
506
636
  }
507
637
  }
508
- // 节点
638
+ // 节点(pending 且被阻塞的用橙色虚线标识)
509
639
  let nodes='';
510
640
  for(const c of cards){
511
641
  const p=pos[c.id];
512
642
  const label=c.title.length>9?c.title.slice(0,9)+'…':c.title;
513
643
  const sub=`P${c.priority} · ${c.mode}`;
514
- nodes+=`<g class="g-node ${c.status}" data-id="${c.id}">
644
+ const blk=(c.status==='pending' && (c.dependsOn||[]).some(d=>{const dc=byId[d];return !dc||dc.status!=='done';}));
645
+ nodes+=`<g class="g-node ${c.status}${blk?' blocked':''}" data-id="${c.id}">
515
646
  <rect x="${p.x}" y="${p.y}" width="${W}" height="${H}" rx="7"/>
516
647
  <text x="${p.x+W/2}" y="${p.y+19}" text-anchor="middle">${esc(label)}</text>
517
648
  <text class="gt-sub" x="${p.x+W/2}" y="${p.y+36}" text-anchor="middle">${esc(sub)}</text>
@@ -524,6 +655,9 @@ function renderGraph(force){
524
655
  </defs>
525
656
  ${edges}${nodes}</svg>`;
526
657
  box.innerHTML=svg;
658
+ // 保持缩放状态(重绘后恢复)
659
+ const svgEl=box.querySelector('svg');
660
+ if(svgEl && G_SCALE!==1){ svgEl.style.transformOrigin='0 0'; svgEl.style.transform=`scale(${G_SCALE})`; }
527
661
  box.querySelectorAll('.g-node').forEach(g=>{
528
662
  g.onclick=()=>openDetail(g.dataset.id);
529
663
  });
@@ -535,6 +669,18 @@ function setGraphCollapsed(collapsed){
535
669
  }
536
670
  $('#gpCollapse').onclick=()=>setGraphCollapsed(true);
537
671
  $('#gpExpandBtn').onclick=()=>setGraphCollapsed(false);
672
+ // 依赖图滚轮缩放(0.4x - 2.5x,以面板中心为基准)
673
+ let G_SCALE=1;
674
+ $('#graphBody').addEventListener('wheel',(e)=>{
675
+ const svg=$('#graphBody').querySelector('svg');
676
+ if(!svg) return;
677
+ e.preventDefault();
678
+ const factor=(e.deltaY<0?1.12:1/1.12);
679
+ G_SCALE=Math.min(2.5,Math.max(0.4,G_SCALE*factor));
680
+ svg.style.transformOrigin='0 0';
681
+ svg.style.transform=`scale(${G_SCALE})`;
682
+ svg.style.transition='transform .08s linear';
683
+ },{passive:false});
538
684
 
539
685
  // ---- 垃圾桶 ----
540
686
  async function loadTrashCount(){
@@ -585,35 +731,55 @@ async function refreshProcs(){
585
731
  setInterval(refreshProcs, 2000);
586
732
 
587
733
  // ---- SSE 实时 ----
588
- function fuRenderText(ev){
734
+ // 正文快照统一覆盖式渲染(opencode text 事件为全量快照,追加渲染会重复刷屏)
735
+ // TEXT_PARTS 声明于详情区(openDetail / renderSnapText 共用)
736
+ function renderSnapText(ev){
589
737
  const log=$('#dLog');
590
738
  if(log.querySelector('.empty')) log.innerHTML='';
591
- let el=FU_PARTS.get(ev.partId);
592
- if(!el){ el=document.createElement('div'); el.className='msg-assistant'; FU_PARTS.set(ev.partId,el); log.appendChild(el); }
739
+ let el=TEXT_PARTS.get(ev.partId);
740
+ if(!el){ el=document.createElement('div'); el.className='msg-assistant'; TEXT_PARTS.set(ev.partId,el); log.appendChild(el); }
593
741
  el.textContent='🤖 '+(ev.text||'').slice(0,6000);
594
742
  log.scrollTop=log.scrollHeight;
595
743
  }
744
+ // 编排完成通知:页内卡片 + 系统通知(已授权时)
745
+ function notifyDone(done,failed){
746
+ const el=document.createElement('div');
747
+ el.className='done-card'+(failed?' has-fail':'');
748
+ el.innerHTML=`<div class="dc-t">${failed?'编排结束(有失败)':'编排全部完成'}</div><div class="dc-b">成功 ${done} · 失败 ${failed},点击关闭</div>`;
749
+ el.onclick=()=>el.remove();
750
+ document.body.appendChild(el);
751
+ setTimeout(()=>el.remove(), 12000);
752
+ try{
753
+ if('Notification' in window && Notification.permission==='granted'){
754
+ new Notification(failed?'编排结束(有失败)':'编排全部完成',{body:`成功 ${done} · 失败 ${failed}`});
755
+ }
756
+ }catch{ /* ignore */ }
757
+ }
596
758
  const es = new EventSource('/api/cards/stream');
597
759
  es.onmessage = (e)=>{
598
760
  let ev; try{ ev=JSON.parse(e.data); }catch{ return; }
599
761
  if(ev.type==='runner_started'){ $('#statusPill').textContent='编排中…'; $('#btnRun').disabled=true; $('#btnStop').disabled=false; }
600
- if(ev.type==='runner_stopped'||ev.type==='all_done'){ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true; }
762
+ if(ev.type==='runner_stopped'){ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true; }
763
+ if(ev.type==='all_done'){
764
+ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true;
765
+ notifyDone(Number(ev.done)||0, Number(ev.failed)||0);
766
+ }
767
+ if(ev.type==='ws_warning'){ toast('⚠ 工作区路径无效:'+ev.path+',任务将在默认目录执行'); }
601
768
  if(ev.type==='followup_start'||ev.type==='followup_done'){ loadCards(); }
602
- if(['task_start','text','tool','task_done','proc'].includes(ev.type)){
769
+ // text 为正文快照(不改变卡片状态):仅实时渲染,不触发全量刷新,避免流式期间频繁重建 DOM
770
+ if(['task_start','tool','task_done','proc'].includes(ev.type)){
603
771
  loadCards();
604
772
  refreshProcs();
605
773
  if(OPEN_ID && ev.cardId===OPEN_ID){
606
774
  const log=$('#dLog'); if(log.querySelector('.empty')) log.innerHTML='';
607
- if(ev.phase==='followup'){
608
- // 追加聊天:正文覆盖式渲染(快照),工具逐行记录
609
- if(ev.type==='text') fuRenderText(ev);
610
- else if(ev.type==='tool'){ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight; }
611
- } else if(ev.type==='text') appendLog(log,(ev.text||'').slice(0,4000),'msg-assistant');
612
- else if(ev.type==='tool') appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool');
775
+ if(ev.type==='task_start') TEXT_PARTS.clear();
776
+ else if(ev.type==='tool'){ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight; }
613
777
  else if(ev.type==='task_done'){ loadCards().then(()=>openDetail(OPEN_ID)); }
614
- log.scrollTop=log.scrollHeight;
615
778
  }
616
779
  }
780
+ // 正文快照独立渲染(不依赖上面的状态事件)
781
+ if(ev.type==='text' && OPEN_ID===ev.cardId) renderSnapText(ev);
782
+ if(ev.type==='followup_start' && OPEN_ID===ev.cardId) TEXT_PARTS.clear();
617
783
  if(ev.type==='followup_done' && OPEN_ID===ev.cardId){
618
784
  fuSetState(false);
619
785
  if(ev.error){ appendLog($('#dLog'),'⚠ '+ev.error,'msg-err'); toast('追加聊天失败:'+errSummary(ev.error)); }
@@ -634,7 +634,7 @@
634
634
  </div>
635
635
 
636
636
  <script>
637
- const PAGE_VERSION = '3.18.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
637
+ const PAGE_VERSION = '3.19.1'; // 与服务端 /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
  // 职业图标库(智能体配置可选)
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.18.0'; // 页面与服务端版本互检,不一致提示强刷
3
+ const APP_VERSION = '3.19.1'; // 页面与服务端版本互检,不一致提示强刷
4
4
  const http = require('http');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
@@ -948,7 +948,7 @@ 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: MAX_PARALLEL, msgCounts: counts, config: CardStore.getConfig() });
951
+ json(res, 200, { success: true, cards, running: cardRunner.isRunning(), maxParallel: cardRunner.maxP(), msgCounts: counts, config: CardStore.getConfig() });
952
952
  return;
953
953
  }
954
954
 
@@ -968,8 +968,24 @@ const server = http.createServer(async (req, res) => {
968
968
  }
969
969
  if (p === '/api/cards/config' && req.method === 'POST') {
970
970
  const body = await readBody(req);
971
- const cfg = CardStore.setConfig({ workspace: String(body.workspace || '').trim().slice(0, 500) });
972
- json(res, 200, { success: true, config: cfg });
971
+ // patch 语义合并:仅写入请求中显式出现的字段(避免单项更新时清空另一项)
972
+ const patch = {};
973
+ if (body.workspace !== undefined) patch.workspace = String(body.workspace || '').trim().slice(0, 500);
974
+ let parallelBad = false;
975
+ if (body.maxParallel !== undefined) {
976
+ const n = Number(body.maxParallel);
977
+ if (n >= 1 && n <= 8) patch.maxParallel = Math.floor(n);
978
+ else parallelBad = true;
979
+ }
980
+ if (parallelBad) { json(res, 400, { success: false, error: 'maxParallel 需在 1-8 之间' }); return; }
981
+ const cfg = Object.keys(patch).length ? CardStore.setConfig(patch) : CardStore.getConfig();
982
+ // 工作区校验:保存允许(可能是尚未创建的目录),但路径不存在时显式警告
983
+ let warning = '';
984
+ const ws = (cfg.workspace || '').trim();
985
+ if (patch.workspace !== undefined && ws) {
986
+ try { if (!fs.existsSync(ws) || !fs.statSync(ws).isDirectory()) warning = `工作区路径当前不存在或不是目录:${ws},任务执行时将回退到默认目录`; } catch { warning = `工作区路径无法访问:${ws}`; }
987
+ }
988
+ json(res, 200, { success: true, config: cfg, warning });
973
989
  return;
974
990
  }
975
991
 
@@ -1035,6 +1051,49 @@ const server = http.createServer(async (req, res) => {
1035
1051
  return;
1036
1052
  }
1037
1053
 
1054
+ // 批量重跑失败任务:failed -> pending 后交给调度器(天然遵守并行度)
1055
+ if (p === '/api/cards/retry-failed' && req.method === 'POST') {
1056
+ let n = 0;
1057
+ for (const c of CardStore.list()) {
1058
+ if (c.status === 'failed') {
1059
+ CardStore.update(c.id, { status: 'pending', result: '', error: '' });
1060
+ n++;
1061
+ }
1062
+ }
1063
+ if (n) cardRunner.start();
1064
+ json(res, 200, { success: true, retried: n, running: cardRunner.isRunning() });
1065
+ return;
1066
+ }
1067
+
1068
+ // 单任务导出 Markdown(元信息 + 过程 + 结果)
1069
+ if (p.startsWith('/api/cards/') && req.method === 'GET' && p.endsWith('/export.md')) {
1070
+ const id = p.slice('/api/cards/'.length, -'/export.md'.length);
1071
+ const card = CardStore.get(id);
1072
+ if (!card) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
1073
+ const msgs = store.getMessages(id);
1074
+ const roleOf = (m) => (m.role === 'user' ? '用户' : (m.phase === 'archive' ? '归档' : (m.phase === 'system' ? '系统' : 'Agent')));
1075
+ const lines = [
1076
+ `# ${card.title}`, '',
1077
+ `- 状态:${card.status}|模式:${card.mode}|优先级:P${card.priority}`,
1078
+ `- 创建:${new Date(card.createdAt).toLocaleString()}${card.finishedAt ? `|完成:${new Date(card.finishedAt).toLocaleString()}` : ''}`,
1079
+ card.error ? `- 失败原因:${card.error.replace(/\n/g, ' ')}` : '',
1080
+ '', '## 任务内容', '', String(card.content || '').trim(), '', '## 执行过程', ''
1081
+ ];
1082
+ for (const m of msgs) {
1083
+ lines.push(`### ${new Date(m.timestamp || Date.now()).toLocaleString()} · ${roleOf(m)}`, '');
1084
+ lines.push(String(m.content || '').trim() || '(无内容)');
1085
+ lines.push('');
1086
+ }
1087
+ lines.push('## 最终结果', '', String(card.result || '').trim() || '(无)');
1088
+ const fname = `task-${String(card.title || 'task').replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 40)}.md`;
1089
+ res.writeHead(200, {
1090
+ 'Content-Type': 'text/markdown; charset=utf-8',
1091
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(fname)}"`
1092
+ });
1093
+ res.end(lines.filter(l => l !== undefined).join('\n'));
1094
+ return;
1095
+ }
1096
+
1038
1097
  if (p.startsWith('/api/cards/') && req.method === 'GET' && p.endsWith('/log')) {
1039
1098
  // 卡牌过程与结果:返回该卡牌的全部消息(含工具/产出)+ 当前卡牌状态
1040
1099
  const id = p.slice('/api/cards/'.length, -'/log'.length);
@@ -1095,7 +1154,7 @@ const server = http.createServer(async (req, res) => {
1095
1154
  // SSE:实时推送卡牌生命周期事件(task_start/text/tool/task_done/all_done/runner_*)
1096
1155
  const send = sse(req, res);
1097
1156
  const unsub = sseSubscribe(send);
1098
- send({ type: 'init', running: cardRunner.isRunning(), maxParallel: MAX_PARALLEL });
1157
+ send({ type: 'init', running: cardRunner.isRunning(), maxParallel: cardRunner.maxP() });
1099
1158
  req.on('close', unsub);
1100
1159
  return;
1101
1160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsamyiok/agents-chat",
3
- "version": "3.18.0",
3
+ "version": "3.19.1",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {