@iamsamyiok/agents-chat 3.18.0 → 3.19.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
@@ -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
  },
@@ -227,6 +230,15 @@ class CardRunner {
227
230
  }
228
231
  isRunning() { return this.running; }
229
232
 
233
+ // 并行度:cards_config.maxParallel 可热更新(1-8),未配置时用 env 默认
234
+ maxP() {
235
+ try {
236
+ const n = Number(readConfig().maxParallel);
237
+ if (n >= 1 && n <= 8) return Math.floor(n);
238
+ } catch { /* ignore */ }
239
+ return MAX_PARALLEL;
240
+ }
241
+
230
242
  // 终止单个任务对应的子进程
231
243
  killCard(cardId) {
232
244
  this.active.delete(cardId);
@@ -269,10 +281,17 @@ class CardRunner {
269
281
  const all = CardStore.list();
270
282
  const eligible = pickEligible(all, this.active);
271
283
  if (!eligible.length) {
272
- if (this.active.size === 0) { this.running = false; broadcast({ type: 'all_done' }); }
284
+ if (this.active.size === 0) {
285
+ this.running = false;
286
+ // 完成通知附统计(成功/失败数),前端据此提醒
287
+ const done = all.filter(c => c.status === 'done').length;
288
+ const failed = all.filter(c => c.status === 'failed').length;
289
+ broadcast({ type: 'all_done', done, failed });
290
+ }
273
291
  return;
274
292
  }
275
- while (this.active.size < MAX_PARALLEL && eligible.length > 0) {
293
+ const limit = this.maxP();
294
+ while (this.active.size < limit && eligible.length > 0) {
276
295
  const card = eligible.shift();
277
296
  this.active.add(card.id);
278
297
  const p = this.runCard(card, myToken);
@@ -284,15 +303,19 @@ class CardRunner {
284
303
  }
285
304
 
286
305
  async runCard(card, myToken) {
306
+ // 重跑保护(最先执行):上次结果先归档进过程日志,可追溯,再清空
307
+ if (card.result) {
308
+ store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'archive', taskId: card.id, content: `[上次结果归档]\n${String(card.result).slice(0, 20000)}` });
309
+ }
287
310
  const runner = resolveRunner();
288
311
  if (runner.kind === 'missing') {
289
312
  const hint = require('./agent').missingHint(runner);
290
- CardStore.update(card.id, { status: 'failed', error: hint.slice(0, 2000), finishedAt: Date.now() });
313
+ CardStore.update(card.id, { status: 'failed', error: hint.slice(0, 2000), result: '', finishedAt: Date.now() });
291
314
  broadcast({ type: 'task_done', cardId: card.id, status: 'failed', title: card.title });
292
315
  return;
293
316
  }
294
317
  if (runner.kind === 'demo') {
295
- CardStore.update(card.id, { status: 'failed', error: '演示模式下任务执行不可用,请安装 opencode/claude/codex/pi 内核', finishedAt: Date.now() });
318
+ CardStore.update(card.id, { status: 'failed', error: '演示模式下任务执行不可用,请安装 opencode/claude/codex/pi 内核', result: '', finishedAt: Date.now() });
296
319
  broadcast({ type: 'task_done', cardId: card.id, status: 'failed', title: card.title });
297
320
  return;
298
321
  }
@@ -309,7 +332,17 @@ class CardRunner {
309
332
  const kind = runner.kind === 'opencode' ? 'opencode' : 'fallback';
310
333
  const cfg = CardStore.getConfig();
311
334
  const workspace = (cfg.workspace || '').trim();
312
- const prompt = buildCardPrompt(card, workspace && isValidDir(workspace) ? workspace : '');
335
+ // 工作区校验:路径无效时显式警告(禁止静默降级到默认目录)
336
+ let ws = '';
337
+ if (workspace) {
338
+ if (isValidDir(workspace)) ws = workspace;
339
+ else {
340
+ const warn = `[系统提示] 工作区路径无效:${workspace},本任务将在默认目录执行`;
341
+ store.addMessage({ role: 'assistant', agentId: 'solo', agentName: 'Agent', actor: 'assistant', phase: 'system', taskId: card.id, content: warn });
342
+ broadcast({ type: 'ws_warning', cardId: card.id, path: workspace });
343
+ }
344
+ }
345
+ const prompt = buildCardPrompt(card, ws);
313
346
  const texts = new Map();
314
347
  const order = [];
315
348
  let doneError = '';
@@ -323,7 +356,7 @@ class CardRunner {
323
356
  model: card.model || '',
324
357
  ocSessionId: sesId,
325
358
  behavior: 'card',
326
- cwd: workspace && isValidDir(workspace) ? workspace : undefined
359
+ cwd: ws || undefined
327
360
  }, (ev) => {
328
361
  const proc = this.procs.get(card.id);
329
362
  if (proc) { proc.lastActive = Date.now(); }
@@ -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,7 +536,7 @@ 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');
@@ -505,13 +633,14 @@ function renderGraph(force){
505
633
  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
634
  }
507
635
  }
508
- // 节点
636
+ // 节点(pending 且被阻塞的用橙色虚线标识)
509
637
  let nodes='';
510
638
  for(const c of cards){
511
639
  const p=pos[c.id];
512
640
  const label=c.title.length>9?c.title.slice(0,9)+'…':c.title;
513
641
  const sub=`P${c.priority} · ${c.mode}`;
514
- nodes+=`<g class="g-node ${c.status}" data-id="${c.id}">
642
+ const blk=(c.status==='pending' && (c.dependsOn||[]).some(d=>{const dc=byId[d];return !dc||dc.status!=='done';}));
643
+ nodes+=`<g class="g-node ${c.status}${blk?' blocked':''}" data-id="${c.id}">
515
644
  <rect x="${p.x}" y="${p.y}" width="${W}" height="${H}" rx="7"/>
516
645
  <text x="${p.x+W/2}" y="${p.y+19}" text-anchor="middle">${esc(label)}</text>
517
646
  <text class="gt-sub" x="${p.x+W/2}" y="${p.y+36}" text-anchor="middle">${esc(sub)}</text>
@@ -524,6 +653,9 @@ function renderGraph(force){
524
653
  </defs>
525
654
  ${edges}${nodes}</svg>`;
526
655
  box.innerHTML=svg;
656
+ // 保持缩放状态(重绘后恢复)
657
+ const svgEl=box.querySelector('svg');
658
+ if(svgEl && G_SCALE!==1){ svgEl.style.transformOrigin='0 0'; svgEl.style.transform=`scale(${G_SCALE})`; }
527
659
  box.querySelectorAll('.g-node').forEach(g=>{
528
660
  g.onclick=()=>openDetail(g.dataset.id);
529
661
  });
@@ -535,6 +667,18 @@ function setGraphCollapsed(collapsed){
535
667
  }
536
668
  $('#gpCollapse').onclick=()=>setGraphCollapsed(true);
537
669
  $('#gpExpandBtn').onclick=()=>setGraphCollapsed(false);
670
+ // 依赖图滚轮缩放(0.4x - 2.5x,以面板中心为基准)
671
+ let G_SCALE=1;
672
+ $('#graphBody').addEventListener('wheel',(e)=>{
673
+ const svg=$('#graphBody').querySelector('svg');
674
+ if(!svg) return;
675
+ e.preventDefault();
676
+ const factor=(e.deltaY<0?1.12:1/1.12);
677
+ G_SCALE=Math.min(2.5,Math.max(0.4,G_SCALE*factor));
678
+ svg.style.transformOrigin='0 0';
679
+ svg.style.transform=`scale(${G_SCALE})`;
680
+ svg.style.transition='transform .08s linear';
681
+ },{passive:false});
538
682
 
539
683
  // ---- 垃圾桶 ----
540
684
  async function loadTrashCount(){
@@ -585,35 +729,53 @@ async function refreshProcs(){
585
729
  setInterval(refreshProcs, 2000);
586
730
 
587
731
  // ---- SSE 实时 ----
588
- function fuRenderText(ev){
732
+ // 正文快照统一覆盖式渲染(opencode text 事件为全量快照,追加渲染会重复刷屏)
733
+ // TEXT_PARTS 声明于详情区(openDetail / renderSnapText 共用)
734
+ function renderSnapText(ev){
589
735
  const log=$('#dLog');
590
736
  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); }
737
+ let el=TEXT_PARTS.get(ev.partId);
738
+ if(!el){ el=document.createElement('div'); el.className='msg-assistant'; TEXT_PARTS.set(ev.partId,el); log.appendChild(el); }
593
739
  el.textContent='🤖 '+(ev.text||'').slice(0,6000);
594
740
  log.scrollTop=log.scrollHeight;
595
741
  }
742
+ // 编排完成通知:页内卡片 + 系统通知(已授权时)
743
+ function notifyDone(done,failed){
744
+ const el=document.createElement('div');
745
+ el.className='done-card'+(failed?' has-fail':'');
746
+ el.innerHTML=`<div class="dc-t">${failed?'编排结束(有失败)':'编排全部完成'}</div><div class="dc-b">成功 ${done} · 失败 ${failed},点击关闭</div>`;
747
+ el.onclick=()=>el.remove();
748
+ document.body.appendChild(el);
749
+ setTimeout(()=>el.remove(), 12000);
750
+ try{
751
+ if('Notification' in window && Notification.permission==='granted'){
752
+ new Notification(failed?'编排结束(有失败)':'编排全部完成',{body:`成功 ${done} · 失败 ${failed}`});
753
+ }
754
+ }catch{ /* ignore */ }
755
+ }
596
756
  const es = new EventSource('/api/cards/stream');
597
757
  es.onmessage = (e)=>{
598
758
  let ev; try{ ev=JSON.parse(e.data); }catch{ return; }
599
759
  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; }
760
+ if(ev.type==='runner_stopped'){ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true; }
761
+ if(ev.type==='all_done'){
762
+ $('#statusPill').textContent='空闲'; $('#btnRun').disabled=false; $('#btnStop').disabled=true;
763
+ notifyDone(Number(ev.done)||0, Number(ev.failed)||0);
764
+ }
765
+ if(ev.type==='ws_warning'){ toast('⚠ 工作区路径无效:'+ev.path+',任务将在默认目录执行'); }
601
766
  if(ev.type==='followup_start'||ev.type==='followup_done'){ loadCards(); }
602
767
  if(['task_start','text','tool','task_done','proc'].includes(ev.type)){
603
768
  loadCards();
604
769
  refreshProcs();
605
770
  if(OPEN_ID && ev.cardId===OPEN_ID){
606
771
  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');
772
+ if(ev.type==='task_start') TEXT_PARTS.clear();
773
+ if(ev.type==='text') renderSnapText(ev);
774
+ else if(ev.type==='tool'){ appendLog(log,'🔧 ['+ev.name+'] '+(ev.summary||''),'msg-tool'); log.scrollTop=log.scrollHeight; }
613
775
  else if(ev.type==='task_done'){ loadCards().then(()=>openDetail(OPEN_ID)); }
614
- log.scrollTop=log.scrollHeight;
615
776
  }
616
777
  }
778
+ if(ev.type==='followup_start' && OPEN_ID===ev.cardId) TEXT_PARTS.clear();
617
779
  if(ev.type==='followup_done' && OPEN_ID===ev.cardId){
618
780
  fuSetState(false);
619
781
  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.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
  // 职业图标库(智能体配置可选)
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.0'; // 页面与服务端版本互检,不一致提示强刷
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
 
@@ -969,7 +969,21 @@ const server = http.createServer(async (req, res) => {
969
969
  if (p === '/api/cards/config' && req.method === 'POST') {
970
970
  const body = await readBody(req);
971
971
  const cfg = CardStore.setConfig({ workspace: String(body.workspace || '').trim().slice(0, 500) });
972
- json(res, 200, { success: true, config: cfg });
972
+ // 工作区校验:保存允许(可能是尚未创建的目录),但路径不存在时显式警告
973
+ let warning = '';
974
+ const ws = (cfg.workspace || '').trim();
975
+ if (body.workspace !== undefined && ws) {
976
+ try { if (!fs.existsSync(ws) || !fs.statSync(ws).isDirectory()) warning = `工作区路径当前不存在或不是目录:${ws},任务执行时将回退到默认目录`; } catch { warning = `工作区路径无法访问:${ws}`; }
977
+ }
978
+ // 并行度(1-8):热更新,调度器每轮 tick 动态读取
979
+ let parallelBad = false;
980
+ if (body.maxParallel !== undefined) {
981
+ const n = Number(body.maxParallel);
982
+ if (n >= 1 && n <= 8) CardStore.setConfig({ maxParallel: Math.floor(n) });
983
+ else parallelBad = true;
984
+ }
985
+ if (parallelBad) { json(res, 400, { success: false, error: 'maxParallel 需在 1-8 之间' }); return; }
986
+ json(res, 200, { success: true, config: CardStore.getConfig(), warning });
973
987
  return;
974
988
  }
975
989
 
@@ -1035,6 +1049,49 @@ const server = http.createServer(async (req, res) => {
1035
1049
  return;
1036
1050
  }
1037
1051
 
1052
+ // 批量重跑失败任务:failed -> pending 后交给调度器(天然遵守并行度)
1053
+ if (p === '/api/cards/retry-failed' && req.method === 'POST') {
1054
+ let n = 0;
1055
+ for (const c of CardStore.list()) {
1056
+ if (c.status === 'failed') {
1057
+ CardStore.update(c.id, { status: 'pending', result: '', error: '' });
1058
+ n++;
1059
+ }
1060
+ }
1061
+ if (n) cardRunner.start();
1062
+ json(res, 200, { success: true, retried: n, running: cardRunner.isRunning() });
1063
+ return;
1064
+ }
1065
+
1066
+ // 单任务导出 Markdown(元信息 + 过程 + 结果)
1067
+ if (p.startsWith('/api/cards/') && req.method === 'GET' && p.endsWith('/export.md')) {
1068
+ const id = p.slice('/api/cards/'.length, -'/export.md'.length);
1069
+ const card = CardStore.get(id);
1070
+ if (!card) { json(res, 404, { success: false, error: '卡牌不存在' }); return; }
1071
+ const msgs = store.getMessages(id);
1072
+ const roleOf = (m) => (m.role === 'user' ? '用户' : (m.phase === 'archive' ? '归档' : (m.phase === 'system' ? '系统' : 'Agent')));
1073
+ const lines = [
1074
+ `# ${card.title}`, '',
1075
+ `- 状态:${card.status}|模式:${card.mode}|优先级:P${card.priority}`,
1076
+ `- 创建:${new Date(card.createdAt).toLocaleString()}${card.finishedAt ? `|完成:${new Date(card.finishedAt).toLocaleString()}` : ''}`,
1077
+ card.error ? `- 失败原因:${card.error.replace(/\n/g, ' ')}` : '',
1078
+ '', '## 任务内容', '', String(card.content || '').trim(), '', '## 执行过程', ''
1079
+ ];
1080
+ for (const m of msgs) {
1081
+ lines.push(`### ${new Date(m.timestamp || Date.now()).toLocaleString()} · ${roleOf(m)}`, '');
1082
+ lines.push(String(m.content || '').trim() || '(无内容)');
1083
+ lines.push('');
1084
+ }
1085
+ lines.push('## 最终结果', '', String(card.result || '').trim() || '(无)');
1086
+ const fname = `task-${String(card.title || 'task').replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 40)}.md`;
1087
+ res.writeHead(200, {
1088
+ 'Content-Type': 'text/markdown; charset=utf-8',
1089
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(fname)}"`
1090
+ });
1091
+ res.end(lines.filter(l => l !== undefined).join('\n'));
1092
+ return;
1093
+ }
1094
+
1038
1095
  if (p.startsWith('/api/cards/') && req.method === 'GET' && p.endsWith('/log')) {
1039
1096
  // 卡牌过程与结果:返回该卡牌的全部消息(含工具/产出)+ 当前卡牌状态
1040
1097
  const id = p.slice('/api/cards/'.length, -'/log'.length);
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.0",
4
4
  "description": "多智能体群聊工具 - 支持 OpenCode/Claude Code/Codex/pi 内核,微信风格聊天界面",
5
5
  "main": "lib/start.js",
6
6
  "bin": {