@iamsamyiok/agents-chat 3.18.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.
@@ -0,0 +1,628 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>多任务编排</title>
7
+ <style>
8
+ :root{
9
+ --bg:#f5f5f5; --panel:#fff; --line:#e7e7e7; --line2:#f0f0f0;
10
+ --txt:#191919; --dim:#888; --acc:#07c160; --acc2:#10aeff; --warn:#e8a33d; --err:#fa5151;
11
+ --pending:#c9cdd4; --running:#10aeff; --done:#07c160; --failed:#fa5151;
12
+ --shadow:0 0 18px rgba(0,0,0,.06);
13
+ }
14
+ *{box-sizing:border-box}
15
+ html,body{height:100%}
16
+ body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
17
+ background:var(--bg);color:var(--txt);font-size:14px;display:flex;flex-direction:column;overflow:hidden}
18
+ header{display:flex;align-items:center;gap:12px;padding:12px 18px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}
19
+ header h1{font-size:16px;margin:0;font-weight:600}
20
+ .backlink{font-size:13px;color:var(--acc);text-decoration:none;border:1px solid var(--acc);border-radius:6px;padding:5px 10px}
21
+ .backlink:hover{background:#f0faf4}
22
+ .sp{flex:1}
23
+ .btn{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 12px;cursor:pointer;font-size:13px;transition:.15s}
24
+ .btn:hover{border-color:var(--acc)}
25
+ .btn.primary{background:var(--acc);border-color:var(--acc);color:#fff}
26
+ .btn.primary:hover{filter:brightness(1.05)}
27
+ .btn.ghost{border-color:var(--warn);color:var(--warn)}
28
+ .btn.ghost:hover{background:#fdf6ec}
29
+ .btn.danger{border-color:var(--err);color:var(--err)}
30
+ .btn:disabled{opacity:.45;cursor:not-allowed}
31
+ .status-pill{font-size:12px;color:var(--dim)}
32
+ .ws-box{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 8px}
33
+ .ws-box input{border:none;outline:none;font-size:12px;width:200px;background:transparent}
34
+ .ws-box .ws-save{font-size:12px;color:var(--acc);cursor:pointer;white-space:nowrap}
35
+ .ws-box .ws-pick{font-size:12px;color:var(--acc2);cursor:pointer;white-space:nowrap}
36
+ /* 进程状态条 */
37
+ .procbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:6px 18px;background:#0b0d11;color:#e6e9ef;font-size:12px;border-bottom:1px solid #222}
38
+ .procbar .ptitle{color:#9aa3b2;margin-right:4px}
39
+ .proc-chip{display:inline-flex;align-items:center;gap:6px;background:#1a1f29;border:1px solid #2a3340;border-radius:20px;padding:2px 10px}
40
+ .proc-chip .dot{width:7px;height:7px;border-radius:50%;background:var(--done)}
41
+ .proc-chip.idle .dot{background:var(--warn)}
42
+ .proc-chip .pid{font-family:monospace;color:#cfe; }
43
+ .proc-chip .st{color:#9aa3b2}
44
+ .proc-empty{color:#5a6472}
45
+ main{padding:16px 18px;display:flex;gap:14px;align-items:stretch;flex:1;min-height:0}
46
+ /* 左侧依赖图面板(约 1/4 宽,可收起) */
47
+ #graphPanel{flex:0 0 25%;min-width:250px;max-width:340px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);display:flex;flex-direction:column;overflow:hidden}
48
+ #graphPanel.collapsed{display:none}
49
+ .gp-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line2);flex:none}
50
+ .gp-head h2{font-size:12px;margin:0;color:var(--dim);letter-spacing:.5px;flex:1;display:flex;align-items:center;gap:6px}
51
+ .gp-body{flex:1;overflow:auto;background:#fbfcfe;padding:10px 6px}
52
+ .gp-legend{font-size:10.5px;color:var(--dim);padding:6px 12px 8px;border-bottom:1px solid var(--line2);flex:none;line-height:1.7;background:#fff}
53
+ .gp-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin:0 3px 0 8px;vertical-align:middle}
54
+ #boardArea{flex:1;min-width:0;display:flex;gap:14px;align-items:flex-start;overflow-x:auto}
55
+ #boardArea .col{flex:1;min-width:190px;min-height:120px}
56
+ .col{flex:1;min-width:200px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;box-shadow:var(--shadow);display:flex;flex-direction:column;max-height:100%;overflow:hidden}
57
+ .col .drop-zone{flex:1;overflow-y:auto;min-height:0}
58
+ .col h2{font-size:13px;margin:0 0 10px;color:var(--dim);display:flex;align-items:center;gap:8px;text-transform:uppercase;letter-spacing:.5px}
59
+ .dot{width:8px;height:8px;border-radius:50%}
60
+ .card{background:#fafbfc;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-bottom:10px;cursor:grab;transition:.12s;position:relative}
61
+ .card:hover{border-color:var(--acc);box-shadow:0 2px 8px rgba(0,0,0,.08)}
62
+ .card.dragging{opacity:.4}
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}
65
+ .card .meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:6px}
66
+ .badge{font-size:11px;padding:2px 7px;border-radius:6px;background:#eef0f3;color:var(--dim);border:1px solid var(--line)}
67
+ .badge.prio-high{background:#fdeaea;color:var(--err);border-color:#f3c9c9}
68
+ .badge.prio-mid{background:#fbf2e0;color:var(--warn);border-color:#f0dcb8}
69
+ .badge.mode-continue{background:#e8f3ff;color:var(--acc2);border-color:#c4e2ff}
70
+ .badge.mode-parallel{background:#f1e9ff;color:#8a6fe8;border-color:#ddd0ff}
71
+ .badge.mode-new{background:#e8f7ed;color:var(--acc);border-color:#c4ecd3}
72
+ .card .op{position:absolute;top:8px;right:8px;display:none;gap:4px}
73
+ .card:hover .op{display:flex}
74
+ .icon-btn{background:transparent;border:1px solid var(--line);color:var(--dim);border-radius:6px;width:24px;height:24px;cursor:pointer;font-size:12px;line-height:1}
75
+ .icon-btn:hover{color:var(--acc);border-color:var(--acc)}
76
+ .empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 0}
77
+ .mask{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:50}
78
+ .mask.show{display:flex}
79
+ .modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(680px,94vw);max-height:90vh;overflow:auto;padding:20px;box-shadow:var(--shadow)}
80
+ .modal h3{margin:0 0 14px;font-size:16px}
81
+ label{display:block;font-size:12px;color:var(--dim);margin:10px 0 4px}
82
+ input,textarea,select{width:100%;background:#fff;border:1px solid var(--line);color:var(--txt);border-radius:8px;padding:8px 10px;font-size:13px;font-family:inherit}
83
+ textarea{resize:vertical;min-height:90px}
84
+ .row{display:flex;gap:10px}.row>*{flex:1}
85
+ .modal .foot{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}
86
+ .detail-log{background:#0b0d11;border:1px solid var(--line);border-radius:10px;padding:12px;max-height:42vh;overflow:auto;
87
+ font-family:"SFMono-Regular",Consolas,monospace;font-size:12.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:#e6e9ef}
88
+ .detail-log .msg-user{color:#7fd0ff}
89
+ .detail-log .msg-assistant{color:#e6e9ef}
90
+ .detail-log .msg-tool{color:#e0a23b}
91
+ .detail-log .msg-err{color:#ff8a8a}
92
+ .detail-result{background:#fafbfc;border:1px solid var(--line);border-radius:10px;padding:12px;margin-top:12px;white-space:pre-wrap;word-break:break-word;max-height:22vh;overflow:auto;line-height:1.6}
93
+ .dep-tag{font-size:11px;color:var(--acc2);margin-top:4px}
94
+ /* 失败原因摘要(看板卡) */
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
+ .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
+ .detail-err b{color:var(--err)}
99
+ /* 追加聊天 */
100
+ .followup-box{border-top:1px dashed var(--line);margin-top:14px;padding-top:10px}
101
+ .followup-box .fu-head{font-size:12.5px;color:var(--acc2);margin-bottom:8px;display:flex;align-items:center;gap:6px}
102
+ .followup-box .fu-head .fu-st{font-size:11px;color:var(--dim)}
103
+ .fu-row{display:flex;gap:8px}
104
+ .fu-row textarea{flex:1;min-height:52px;resize:vertical;font-size:13px}
105
+ .fu-send{background:var(--acc2);border-color:var(--acc2);color:#fff;align-self:flex-end;white-space:nowrap}
106
+ .fu-send:disabled{opacity:.45;cursor:not-allowed}
107
+ .fu-tip{font-size:11px;color:var(--dim);margin-top:6px}
108
+ /* 迷你依赖图(左侧面板) */
109
+ .mini-svg .g-node{cursor:pointer}
110
+ .mini-svg .g-node rect{fill:#fff;stroke:#c9cdd4;stroke-width:1.4}
111
+ .mini-svg .g-node:hover rect{stroke:var(--acc);stroke-width:2.2}
112
+ .mini-svg .g-node.running rect{fill:#eef7ff;stroke:var(--running);stroke-dasharray:5 3}
113
+ .mini-svg .g-node.done rect{fill:#eef7f0;stroke:var(--done)}
114
+ .mini-svg .g-node.failed rect{fill:#fdeceb;stroke:var(--failed)}
115
+ .mini-svg .g-node text{font-size:11px;fill:#222}
116
+ .mini-svg .g-node .gt-sub{font-size:9.5px;fill:#999}
117
+ .mini-svg .edge{fill:none;stroke:#9aa8c7;stroke-width:1.6}
118
+ .mini-svg .edge.continue{stroke:var(--acc2);stroke-dasharray:5 3}
119
+ .mini-svg marker{fill:#9aa8c7}
120
+ .mini-svg marker.cont{fill:var(--acc2)}
121
+ .toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.8);color:#fff;padding:10px 16px;border-radius:10px;z-index:99;opacity:0;transition:.25s;pointer-events:none}
122
+ .toast.show{opacity:1}
123
+ /* 依赖图(见左侧面板 mini-svg 样式) */
124
+ /* 垃圾桶 */
125
+ .trash-fab{position:fixed;right:22px;bottom:22px;width:56px;height:56px;border-radius:50%;background:var(--panel);border:1px solid var(--line);box-shadow:0 6px 18px rgba(0,0,0,.18);cursor:pointer;font-size:24px;display:flex;align-items:center;justify-content:center;z-index:40}
126
+ .trash-fab:hover{border-color:var(--err);color:var(--err)}
127
+ .trash-fab .cnt{position:absolute;top:-6px;right:-6px;background:var(--err);color:#fff;font-size:11px;border-radius:10px;padding:1px 6px}
128
+ #trashMask .modal{width:min(640px,94vw)}
129
+ .trash-item{display:flex;align-items:center;gap:10px;padding:10px;border:1px solid var(--line);border-radius:10px;margin-bottom:8px}
130
+ .trash-item .ti-info{flex:1;min-width:0}
131
+ .trash-item .ti-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
132
+ .trash-item .ti-sub{font-size:11px;color:var(--dim)}
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <header>
137
+ <a class="backlink" href="/" title="返回 Agents Chat 群聊">← 群聊</a>
138
+ <h1>🗂️ 多任务编排</h1>
139
+ <span class="status-pill" id="statusPill">空闲</span>
140
+ <span class="sp"></span>
141
+ <div class="ws-box" title="工作区:选定后 Agent 在该目录读写相关文件(可选)">
142
+ 📁 <input id="wsInput" placeholder="工作文件夹(点右侧选择)" />
143
+ <span class="ws-pick" id="wsPick" title="弹窗选择文件夹">选择</span>
144
+ <span class="ws-save" id="wsSave">保存</span>
145
+ </div>
146
+ <button class="btn" id="btnNew">+ 新建任务</button>
147
+ <button class="btn primary" id="btnRun">▶ 开始编排</button>
148
+ <button class="btn danger" id="btnStop" disabled>⏹ 停止</button>
149
+ <button class="btn" id="btnClear">清空</button>
150
+ </header>
151
+
152
+ <div class="procbar" id="procBar"><span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span></div>
153
+
154
+ <main>
155
+ <!-- 左侧:依赖图面板(约 1/4 宽,可收起) -->
156
+ <aside id="graphPanel">
157
+ <div class="gp-head">
158
+ <h2><span class="dot" style="background:var(--acc2)"></span>🔗 依赖图</h2>
159
+ <button class="icon-btn" id="gpCollapse" title="收起面板">◀</button>
160
+ </div>
161
+ <div class="gp-legend">
162
+ 自上而下按依赖分层;实线=依赖,蓝色虚线=同会话续聊。
163
+ <i style="background:var(--pending)"></i>待执行
164
+ <i style="background:var(--running)"></i>执行中
165
+ <i style="background:var(--done)"></i>完成
166
+ <i style="background:var(--failed)"></i>失败
167
+ </div>
168
+ <div class="gp-body" id="graphBody"></div>
169
+ </aside>
170
+ <!-- 右侧:四列看板 -->
171
+ <div id="boardArea">
172
+ <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
+ <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
+ <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>
176
+ </div>
177
+ </main>
178
+ <span id="gpExpand" style="display:none;position:fixed;left:14px;bottom:80px;z-index:41" title="展开依赖图"><button class="btn" id="gpExpandBtn">▶ 依赖图</button></span>
179
+
180
+ <!-- 新建/编辑任务 -->
181
+ <div class="mask" id="maskEdit">
182
+ <div class="modal">
183
+ <h3 id="editTitle">新建任务</h3>
184
+ <label>标题</label>
185
+ <input id="fTitle" placeholder="一句话概括任务" />
186
+ <label>任务内容(描述,支持 Markdown)</label>
187
+ <textarea id="fContent" placeholder="把要做的事详细写在这里,Agent 会据此执行"></textarea>
188
+ <div class="row">
189
+ <div><label>优先级(数字越小越优先)</label><input id="fPriority" type="number" value="999" /></div>
190
+ <div><label>执行模式</label>
191
+ <select id="fMode">
192
+ <option value="new">新进程(new)</option>
193
+ <option value="continue">同会话续聊(continue)</option>
194
+ <option value="parallel">并行独立进程(parallel)</option>
195
+ </select>
196
+ </div>
197
+ </div>
198
+ <div class="row">
199
+ <div><label>续聊链首(仅 continue 模式)</label><select id="fChain"></select></div>
200
+ <div><label>依赖任务(完成后才可执行)</label><select id="fDeps" multiple size="3"></select></div>
201
+ </div>
202
+ <label>模型(留空用默认)</label><input id="fModel" placeholder="provider/model" />
203
+ <div class="foot">
204
+ <button class="btn" id="btnEditCancel">取消</button>
205
+ <button class="btn primary" id="btnEditSave">保存</button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+
210
+ <!-- 任务详情:失败原因 + 过程 + 结果 + 追加聊天 -->
211
+ <div class="mask" id="maskDetail">
212
+ <div class="modal">
213
+ <h3 id="dTitle">任务详情</h3>
214
+ <div id="dMeta" class="meta" style="margin-bottom:10px"></div>
215
+ <div class="dep-tag" id="dDeps"></div>
216
+ <div class="detail-err" id="dError" style="display:none"></div>
217
+ <label>执行过程(实时)</label>
218
+ <div class="detail-log" id="dLog"><span class="empty">暂无过程记录</span></div>
219
+ <label>最终结果</label>
220
+ <div class="detail-result" id="dResult"><span class="empty">尚未产生结果</span></div>
221
+ <div class="followup-box" id="fuBox">
222
+ <div class="fu-head">💬 追加聊天 <span class="fu-st" id="fuState"></span></div>
223
+ <div class="fu-row">
224
+ <textarea id="fuInput" placeholder="在本次会话中继续输入,Agent 将带着此前上下文回答或继续执行…(Enter 发送,Shift+Enter 换行)"></textarea>
225
+ <button class="btn fu-send" id="fuSend">发送</button>
226
+ </div>
227
+ <div class="fu-tip" id="fuTip">复用该任务的会话上下文,等效同一进程的第二轮输入</div>
228
+ </div>
229
+ <div class="foot">
230
+ <button class="btn" id="dClose">关闭</button>
231
+ <button class="btn primary" id="dRun">▶ 立即执行</button>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- 垃圾桶 -->
237
+ <div class="mask" id="trashMask">
238
+ <div class="modal">
239
+ <h3>🗑️ 垃圾桶 <span style="font-size:12px;color:var(--dim)">(保留 30 天内删除的任务快照)</span></h3>
240
+ <div id="trashList"></div>
241
+ <div class="foot">
242
+ <button class="btn danger" id="trashEmpty">清空垃圾桶</button>
243
+ <button class="btn" id="trashClose">关闭</button>
244
+ </div>
245
+ </div>
246
+ </div>
247
+
248
+ <div class="trash-fab" id="trashFab" title="垃圾桶">🗑️<span class="cnt" id="trashCnt" style="display:none">0</span></div>
249
+ <input type="file" id="dirPicker" webkitdirectory directory multiple style="display:none" />
250
+
251
+ <div class="toast" id="toast"></div>
252
+
253
+ <script>
254
+ const $ = s => document.querySelector(s);
255
+ const api = async (url, opt) => { const r = await fetch(url, opt); return r.json(); };
256
+ const post = (url, body) => api(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})});
257
+ function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); }
258
+ function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
259
+
260
+ let CARDS = [];
261
+ let OPEN_ID = null;
262
+
263
+ async function loadCards(){
264
+ const data = await api('/api/cards');
265
+ CARDS = data.cards || [];
266
+ $('#statusPill').textContent = data.running ? '编排中…' : '空闲';
267
+ $('#btnRun').disabled = data.running;
268
+ $('#btnStop').disabled = !data.running;
269
+ if(data.config) $('#wsInput').value = data.config.workspace || '';
270
+ render();
271
+ renderGraph();
272
+ loadTrashCount();
273
+ }
274
+
275
+ function badge(card){
276
+ let p = card.priority;
277
+ let pc = p<=100?'prio-high':(p<=500?'prio-mid':'');
278
+ let pm = 'mode-'+card.mode;
279
+ let html = `<span class="badge ${pc}">P${p}</span><span class="badge ${pm}">${card.mode}</span>`;
280
+ if(card.dependsOn && card.dependsOn.length) html += `<span class="badge">依赖 ${card.dependsOn.length}</span>`;
281
+ return html;
282
+ }
283
+ function errSummary(err){
284
+ if(!err) return '';
285
+ return String(err).split('\n').map(s=>s.trim()).filter(Boolean)[0] || '';
286
+ }
287
+ function cardEl(card){
288
+ const el = document.createElement('div');
289
+ el.className='card';
290
+ el.draggable = true;
291
+ el.dataset.id = card.id;
292
+ el.dataset.status = card.status;
293
+ const errHtml = (card.status==='failed' && (card.error||card.followupError))
294
+ ? `<div class="card-err" title="${esc(card.error||card.followupError)}">⚠ ${esc(errSummary(card.error||card.followupError).slice(0,140))}</div>` : '';
295
+ el.innerHTML = `<div class="title">${esc(card.title)}</div>
296
+ <div class="meta">${badge(card)} ${card.status!=='pending'&&card.status!=='running'?`<span class="badge">${card.status}</span>`:''}</div>
297
+ ${errHtml}
298
+ <div class="op">
299
+ <button class="icon-btn" data-act="run" title="执行">▶</button>
300
+ <button class="icon-btn" data-act="edit" title="编辑">✎</button>
301
+ <button class="icon-btn" data-act="del" title="删除">✕</button>
302
+ </div>`;
303
+ 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; }
305
+ openDetail(card.id); };
306
+ el.addEventListener('dragstart', e=>{ dragId=card.id; el.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
307
+ el.addEventListener('dragend', ()=>{ el.classList.remove('dragging'); document.querySelectorAll('.card').forEach(c=>c.classList.remove('drag-over')); });
308
+ el.addEventListener('dragover', e=>{ e.preventDefault(); el.classList.add('drag-over'); });
309
+ el.addEventListener('dragleave', ()=> el.classList.remove('drag-over'));
310
+ el.addEventListener('drop', e=>{ e.preventDefault(); el.classList.remove('drag-over'); onDrop(card.id, el); });
311
+ return el;
312
+ }
313
+ let dragId=null;
314
+ function onDrop(targetId, targetEl){
315
+ if(!dragId || dragId===targetId) return;
316
+ const dragged = document.querySelector(`.card[data-id="${dragId}"]`);
317
+ if(!dragged) return;
318
+ const rect = targetEl.getBoundingClientRect();
319
+ const y = window.__dropY || 0;
320
+ const after = (y > rect.top + rect.height/2);
321
+ if(after) targetEl.parentNode.insertBefore(dragged, targetEl.nextSibling);
322
+ else targetEl.parentNode.insertBefore(dragged, targetEl);
323
+ dragId=null;
324
+ const ids = [...document.querySelectorAll('.card')].map(c=>c.dataset.id);
325
+ post('/api/cards/reorder',{ids}).then(()=>loadCards());
326
+ }
327
+ document.addEventListener('dragover', e=>{ window.__dropY = e.clientY; }, true);
328
+
329
+ function render(){
330
+ const cols = {pending:$('#colPending'),running:$('#colRunning'),done:$('#colDone'),failed:$('#colFailed')};
331
+ for(const k in cols) cols[k].innerHTML='';
332
+ let counts={pending:0,running:0,done:0,failed:0};
333
+ for(const c of CARDS){ counts[c.status]=(counts[c.status]||0)+1; (cols[c.status]||cols.pending).appendChild(cardEl(c)); }
334
+ $('#cntPending').textContent=counts.pending||''; $('#cntRunning').textContent=counts.running||'';
335
+ $('#cntDone').textContent=counts.done||''; $('#cntFailed').textContent=counts.failed||'';
336
+ for(const k in cols){ if(!cols[k].children.length) cols[k].innerHTML='<div class="empty">—</div>'; }
337
+ }
338
+
339
+ // ---- 编辑/新建 ----
340
+ 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('');
343
+ }
344
+ let EDIT_ID=null;
345
+ function openEdit(card){
346
+ 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:'';
353
+ fillSelectors();
354
+ if(card){ $('#fChain').value=card.chainId||''; for(const o of $('#fDeps').options) o.selected=card.dependsOn.includes(o.value); }
355
+ $('#maskEdit').classList.add('show');
356
+ }
357
+ $('#btnNew').onclick=()=>openEdit(null);
358
+ $('#btnEditCancel').onclick=()=>$('#maskEdit').classList.remove('show');
359
+ $('#btnEditSave').onclick=async()=>{
360
+ 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) };
363
+ if(EDIT_ID) await api('/api/cards/'+EDIT_ID,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
364
+ else await post('/api/cards',body);
365
+ $('#maskEdit').classList.remove('show'); await loadCards(); toast('已保存');
366
+ };
367
+ async function delCard(id){ if(!confirm('确认删除该任务?将移入垃圾桶(30 天内可还原)。'))return; await api('/api/cards/'+id,{method:'DELETE'}); await loadCards(); toast('已移入垃圾桶'); }
368
+ $('#btnClear').onclick=async()=>{ if(!confirm('清空全部任务?将移入垃圾桶(30 天内可还原)。'))return; await post('/api/cards/clear'); await loadCards(); };
369
+
370
+ // ---- 执行控制 ----
371
+ $('#btnRun').onclick=async()=>{ await post('/api/cards/run'); await loadCards(); toast('已开始编排,完成即自动注入下一个任务'); };
372
+ $('#btnStop').onclick=async()=>{ await post('/api/cards/stop'); await loadCards(); };
373
+ async function runOne(id){ await post('/api/cards/'+id+'/run'); await loadCards(); }
374
+
375
+ // ---- 工作区(弹窗选择文件夹) ----
376
+ $('#wsPick').onclick=()=>{ $('#dirPicker').value=''; $('#dirPicker').click(); };
377
+ $('#dirPicker').addEventListener('change', ()=>{
378
+ const f = $('#dirPicker').files && $('#dirPicker').files[0];
379
+ if(!f) return;
380
+ // 优先取绝对路径(Electron/桌面环境),否则取顶层文件夹名(标准浏览器)
381
+ let p = (f.path && typeof f.path === 'string') ? f.path : (f.webkitRelativePath ? f.webkitRelativePath.split('/')[0] : '');
382
+ $('#wsInput').value = p;
383
+ if(p) post('/api/cards/config',{workspace:p}).then(()=>toast('工作区已设为:'+p));
384
+ });
385
+ $('#wsSave').onclick=async()=>{ const v=$('#wsInput').value.trim(); const r=await post('/api/cards/config',{workspace:v}); if(r.success){ toast(v?'工作区已设为:'+v:'工作区已清除'); } };
386
+
387
+ // ---- 详情 ----
388
+ function appendLog(el, line, cls){
389
+ const span=document.createElement('div'); if(cls) span.className=cls; span.textContent=line; el.appendChild(span);
390
+ }
391
+ let FU_RUNNING = false;
392
+ let FU_PARTS = new Map(); // 追加聊天正文:partId -> DOM 节点(快照覆盖式渲染)
393
+ function fuSetState(running, tipText){
394
+ FU_RUNNING = running;
395
+ const btn=$('#fuSend');
396
+ btn.disabled = running || !OPEN_ID;
397
+ btn.textContent = running ? '回复中…' : '发送';
398
+ if(tipText!==undefined) $('#fuTip').textContent = tipText;
399
+ }
400
+ async function openDetail(id){
401
+ OPEN_ID=id; const data=await api('/api/cards/'+id+'/log'); const card=data.card; const msgs=data.messages||[];
402
+ $('#dTitle').textContent=card.title;
403
+ $('#dMeta').innerHTML=badge(card)+`<span class="badge">${card.status}</span>`;
404
+ const depTitles = (card.dependsOn||[]).map(d=>{const c=CARDS.find(x=>x.id===d);return c?c.title:d;});
405
+ $('#dDeps').textContent = depTitles.length ? ('依赖:'+depTitles.join('、')) : '';
406
+ // 失败原因块:任务执行错误优先,其次追加聊天的错误
407
+ const errBox=$('#dError');
408
+ const errText = card.error || card.followupError || '';
409
+ if(errText){ errBox.style.display='block'; errBox.innerHTML='<b>⚠ 失败原因</b>\n'+esc(errText); }
410
+ else errBox.style.display='none';
411
+ const log=$('#dLog'); log.innerHTML=''; FU_PARTS.clear();
412
+ if(!msgs.length) log.innerHTML='<span class="empty">暂无过程记录</span>';
413
+ for(const m of msgs){
414
+ if(m.role==='user') appendLog(log,'👤 '+m.content,'msg-user');
415
+ else if(/工具|执行完成/.test(m.content||'')) appendLog(log,'🔧 '+m.content,'msg-tool');
416
+ else if(/出错|失败/.test(m.content||'')) appendLog(log,'⚠ '+m.content,'msg-err');
417
+ else appendLog(log,'🤖 '+(m.content||'').slice(0,4000),'msg-assistant');
418
+ if(m.outputPath) appendLog(log,'📄 产出文件: '+m.outputPath);
419
+ }
420
+ log.scrollTop=log.scrollHeight;
421
+ const res=$('#dResult');
422
+ if(card.result) res.textContent=card.result; else res.innerHTML='<span class="empty">尚未产生结果</span>';
423
+ $('#dRun').style.display = (card.status==='running')?'none':'inline-block';
424
+ // 追加聊天:任务结束(非 pending/running)即可用;无会话时提示限制
425
+ const fuOk = card.status!=='pending' && card.status!=='running';
426
+ $('#fuBox').style.display = fuOk ? 'block' : 'none';
427
+ $('#fuState').textContent = FU_RUNNING ? '回复中…' : '';
428
+ if(fuOk && !card.ocSessionId) $('#fuTip').textContent='当前内核不支持会话续聊,追加输入将开启全新上下文';
429
+ else if(fuOk) $('#fuTip').textContent='复用该任务的会话上下文,等效同一进程的第二轮输入';
430
+ fuSetState(FU_RUNNING);
431
+ $('#maskDetail').classList.add('show');
432
+ }
433
+ $('#dClose').onclick=()=>{ OPEN_ID=null; $('#maskDetail').classList.remove('show'); };
434
+ $('#dRun').onclick=async()=>{ if(!OPEN_ID)return; await runOne(OPEN_ID); openDetail(OPEN_ID); };
435
+
436
+ // ---- 追加聊天:复用会话第二轮输入 ----
437
+ async function sendFollowup(){
438
+ if(!OPEN_ID || FU_RUNNING) return;
439
+ const ta=$('#fuInput'); const text=ta.value.trim();
440
+ if(!text){ toast('请输入追加内容'); return; }
441
+ fuSetState(true,'已发送,等待 Agent 基于原会话上下文回复…');
442
+ appendLog($('#dLog'),'👤 '+text,'msg-user');
443
+ $('#dLog').scrollTop=$('#dLog').scrollHeight;
444
+ ta.value='';
445
+ try{
446
+ const r = await post('/api/cards/'+encodeURIComponent(OPEN_ID)+'/chat',{prompt:text});
447
+ if(!r.success && r.error){ toast('追加聊天失败:'+errSummary(r.error)); appendLog($('#dLog'),'⚠ '+r.error,'msg-err'); }
448
+ }catch(e){ toast('追加聊天请求失败'); }
449
+ finally{ fuSetState(false); }
450
+ }
451
+ $('#fuSend').onclick=sendFollowup;
452
+ $('#fuInput').addEventListener('keydown',e=>{
453
+ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); sendFollowup(); }
454
+ });
455
+
456
+ // ---- 左侧依赖图面板:竖向分层(依赖在上、被依赖者在下),层内横排 ----
457
+ let G_SIG=''; // 结构+状态指纹:无变化时跳过重绘(避免流式输出期间闪烁)
458
+ function renderGraph(force){
459
+ const box=$('#graphBody');
460
+ const cards=CARDS;
461
+ const sig=cards.map(c=>c.id+':'+c.status+':'+(c.dependsOn||[]).join(',')).join('|');
462
+ if(!force && sig===G_SIG && box.querySelector('svg')) return;
463
+ G_SIG=sig;
464
+ if(!cards.length){ box.innerHTML='<div class="empty" style="padding:24px 8px">暂无任务<br/>新建任务后此处展示依赖关系</div>'; return; }
465
+ const byId=Object.fromEntries(cards.map(c=>[c.id,c]));
466
+ const depth={}; const visiting={};
467
+ function calc(id){
468
+ if(depth[id]!==undefined) return depth[id];
469
+ if(visiting[id]){ depth[id]=0; return 0; } // 环兜底
470
+ visiting[id]=true;
471
+ const c=byId[id]; const deps=(c.dependsOn||[]).filter(d=>byId[d]);
472
+ const d=deps.length?Math.max(...deps.map(calc))+1:0;
473
+ visiting[id]=false; depth[id]=d; return d;
474
+ }
475
+ cards.forEach(c=>calc(c.id));
476
+ const layers={};
477
+ cards.forEach(c=>{const d=depth[c.id]||0;(layers[d]=layers[d]||[]).push(c);});
478
+ const layerIdx=Object.keys(layers).map(Number).sort((a,b)=>a-b);
479
+ // 节点尺寸与间距(窄面板适配)
480
+ const W=132,H=46,xGap=14,yGap=74,pad=8;
481
+ let maxW=pad*2;
482
+ const pos={};
483
+ const layerWidth={};
484
+ for(const d of layerIdx){
485
+ const arr=layers[d]; layerWidth[d]=pad*2+arr.length*W+(arr.length-1)*xGap;
486
+ maxW=Math.max(maxW,layerWidth[d]);
487
+ }
488
+ for(const d of layerIdx){
489
+ const arr=layers[d];
490
+ const lw=layerWidth[d];
491
+ arr.forEach((c,i)=>{
492
+ pos[c.id]={x:(maxW-lw)/2+pad+i*(W+xGap), y:pad+d*yGap};
493
+ });
494
+ }
495
+ const svgH=pad*2+(layerIdx.length-1)*yGap+H;
496
+ // 连线:竖向贝塞尔(从依赖底部 → 被依赖顶部)
497
+ let edges='';
498
+ for(const c of cards){
499
+ for(const dep of (c.dependsOn||[])){
500
+ if(!pos[dep]||!pos[c.id]) continue;
501
+ const a=pos[dep], b=pos[c.id];
502
+ const x1=a.x+W/2, y1=a.y+H, x2=b.x+W/2, y2=b.y;
503
+ const my=(y1+y2)/2;
504
+ const isContinue=(c.mode==='continue'&&c.chainId===dep);
505
+ 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
+ }
507
+ }
508
+ // 节点
509
+ let nodes='';
510
+ for(const c of cards){
511
+ const p=pos[c.id];
512
+ const label=c.title.length>9?c.title.slice(0,9)+'…':c.title;
513
+ const sub=`P${c.priority} · ${c.mode}`;
514
+ nodes+=`<g class="g-node ${c.status}" data-id="${c.id}">
515
+ <rect x="${p.x}" y="${p.y}" width="${W}" height="${H}" rx="7"/>
516
+ <text x="${p.x+W/2}" y="${p.y+19}" text-anchor="middle">${esc(label)}</text>
517
+ <text class="gt-sub" x="${p.x+W/2}" y="${p.y+36}" text-anchor="middle">${esc(sub)}</text>
518
+ </g>`;
519
+ }
520
+ const svg=`<svg class="mini-svg" width="${maxW}" height="${svgH}" viewBox="0 0 ${maxW} ${svgH}" style="display:block;margin:0 auto">
521
+ <defs>
522
+ <marker id="mar" markerWidth="8" markerHeight="8" refX="5" refY="2.6" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L5,2.6 L0,5.2 Z"/></marker>
523
+ <marker id="marC" class="cont" markerWidth="8" markerHeight="8" refX="5" refY="2.6" orient="auto" markerUnits="userSpaceOnUse"><path d="M0,0 L5,2.6 L0,5.2 Z"/></marker>
524
+ </defs>
525
+ ${edges}${nodes}</svg>`;
526
+ box.innerHTML=svg;
527
+ box.querySelectorAll('.g-node').forEach(g=>{
528
+ g.onclick=()=>openDetail(g.dataset.id);
529
+ });
530
+ }
531
+ // 面板收起/展开
532
+ function setGraphCollapsed(collapsed){
533
+ $('#graphPanel').classList.toggle('collapsed',collapsed);
534
+ $('#gpExpand').style.display=collapsed?'block':'none';
535
+ }
536
+ $('#gpCollapse').onclick=()=>setGraphCollapsed(true);
537
+ $('#gpExpandBtn').onclick=()=>setGraphCollapsed(false);
538
+
539
+ // ---- 垃圾桶 ----
540
+ async function loadTrashCount(){
541
+ const d = await api('/api/cards/trash');
542
+ const n = (d.trash||[]).length;
543
+ const cnt=$('#trashCnt'); cnt.textContent=n; cnt.style.display = n? 'block':'none';
544
+ }
545
+ async function openTrash(){
546
+ const d = await api('/api/cards/trash');
547
+ const list = d.trash||[];
548
+ const box=$('#trashList');
549
+ if(!list.length){ box.innerHTML='<div class="empty">垃圾桶是空的</div>'; }
550
+ else {
551
+ box.innerHTML = list.map(t=>{
552
+ const c=t.card; const dt=new Date(t.deletedAt).toLocaleString();
553
+ const left = Math.max(0, 30 - Math.floor((Date.now()-t.deletedAt)/(24*3600*1000)));
554
+ return `<div class="trash-item">
555
+ <div class="ti-info">
556
+ <div class="ti-title">${esc(c.title||'未命名任务')}</div>
557
+ <div class="ti-sub">删除于 ${dt} · 剩余保留约 ${left} 天 · ${c.status||''}</div>
558
+ </div>
559
+ <button class="btn" data-restore="${c.id}">还原</button>
560
+ </div>`;
561
+ }).join('');
562
+ box.querySelectorAll('[data-restore]').forEach(b=>{
563
+ b.onclick=async()=>{ await post('/api/cards/trash/restore/'+encodeURIComponent(b.dataset.restore)); await loadCards(); toast('已还原'); };
564
+ });
565
+ }
566
+ $('#trashMask').classList.add('show');
567
+ }
568
+ $('#trashFab').onclick=openTrash;
569
+ $('#trashClose').onclick=()=>$('#trashMask').classList.remove('show');
570
+ $('#trashEmpty').onclick=async()=>{ if(!confirm('确认清空垃圾桶?所有快照及其过程日志将被永久删除,不可恢复。'))return; const r=await post('/api/cards/trash/empty'); toast('已清空 '+r.cleared+' 项'); await loadCards(); };
571
+
572
+ // ---- 进程状态条(PID + 是否工作中) ----
573
+ async function refreshProcs(){
574
+ try{
575
+ const d = await api('/api/cards/processes');
576
+ const procs = d.processes||[];
577
+ const bar=$('#procBar');
578
+ if(!procs.length){ bar.innerHTML='<span class="ptitle">进程:</span><span class="proc-empty">无运行中的 opencode 进程</span>'; return; }
579
+ bar.innerHTML='<span class="ptitle">进程:</span>'+procs.map(p=>{
580
+ const working=p.working;
581
+ return `<span class="proc-chip ${working?'':'idle'}"><span class="dot"></span><span class="pid">PID ${p.pid}</span><span class="st">${working?'工作中':'空闲'}</span></span>`;
582
+ }).join('');
583
+ }catch{ /* ignore */ }
584
+ }
585
+ setInterval(refreshProcs, 2000);
586
+
587
+ // ---- SSE 实时 ----
588
+ function fuRenderText(ev){
589
+ const log=$('#dLog');
590
+ 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); }
593
+ el.textContent='🤖 '+(ev.text||'').slice(0,6000);
594
+ log.scrollTop=log.scrollHeight;
595
+ }
596
+ const es = new EventSource('/api/cards/stream');
597
+ es.onmessage = (e)=>{
598
+ let ev; try{ ev=JSON.parse(e.data); }catch{ return; }
599
+ 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; }
601
+ if(ev.type==='followup_start'||ev.type==='followup_done'){ loadCards(); }
602
+ if(['task_start','text','tool','task_done','proc'].includes(ev.type)){
603
+ loadCards();
604
+ refreshProcs();
605
+ if(OPEN_ID && ev.cardId===OPEN_ID){
606
+ 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');
613
+ else if(ev.type==='task_done'){ loadCards().then(()=>openDetail(OPEN_ID)); }
614
+ log.scrollTop=log.scrollHeight;
615
+ }
616
+ }
617
+ if(ev.type==='followup_done' && OPEN_ID===ev.cardId){
618
+ fuSetState(false);
619
+ if(ev.error){ appendLog($('#dLog'),'⚠ '+ev.error,'msg-err'); toast('追加聊天失败:'+errSummary(ev.error)); }
620
+ loadCards().then(()=>openDetail(OPEN_ID));
621
+ }
622
+ };
623
+ es.onerror=()=>{};
624
+
625
+ loadCards();
626
+ </script>
627
+ </body>
628
+ </html>