@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 +41 -8
- package/app/public/cards.html +200 -38
- package/app/public/index.html +1 -1
- package/app/server.js +60 -3
- 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
|
},
|
|
@@ -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) {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
359
|
+
cwd: ws || undefined
|
|
327
360
|
}, (ev) => {
|
|
328
361
|
const proc = this.procs.get(card.id);
|
|
329
362
|
if (proc) { proc.lastActive = Date.now(); }
|
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,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='';
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
592
|
-
if(!el){ el=document.createElement('div'); el.className='msg-assistant';
|
|
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'
|
|
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.
|
|
608
|
-
|
|
609
|
-
|
|
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)); }
|
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.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.
|
|
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:
|
|
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
|
-
|
|
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);
|