@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 +49 -10
- package/app/public/cards.html +206 -40
- package/app/public/index.html +1 -1
- package/app/server.js +64 -5
- package/package.json +1 -1
package/app/lib/cards.js
CHANGED
|
@@ -106,11 +106,14 @@ const CardStore = {
|
|
|
106
106
|
writeCards(list);
|
|
107
107
|
return c;
|
|
108
108
|
},
|
|
109
|
-
//
|
|
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.
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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();
|
package/app/public/cards.html
CHANGED
|
@@ -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:
|
|
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);
|
|
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
|
|
342
|
-
const
|
|
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
|
-
|
|
348
|
-
$('#
|
|
349
|
-
$('#
|
|
350
|
-
$('#
|
|
351
|
-
$('#
|
|
352
|
-
$('#
|
|
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
|
-
|
|
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
|
|
362
|
-
dependsOn:
|
|
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()=>{
|
|
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
|
-
|
|
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()=>{
|
|
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
|
|
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='';
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
592
|
-
if(!el){ el=document.createElement('div'); el.className='msg-assistant';
|
|
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'
|
|
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
|
-
|
|
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.
|
|
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)); }
|
package/app/public/index.html
CHANGED
|
@@ -634,7 +634,7 @@
|
|
|
634
634
|
</div>
|
|
635
635
|
|
|
636
636
|
<script>
|
|
637
|
-
const PAGE_VERSION = '3.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
972
|
-
|
|
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:
|
|
1157
|
+
send({ type: 'init', running: cardRunner.isRunning(), maxParallel: cardRunner.maxP() });
|
|
1099
1158
|
req.on('close', unsub);
|
|
1100
1159
|
return;
|
|
1101
1160
|
}
|