coderfleet 0.1.0__py3-none-any.whl

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.
Files changed (45) hide show
  1. coderfleet/__init__.py +1 -0
  2. coderfleet/__main__.py +4 -0
  3. coderfleet/cli.py +212 -0
  4. coderfleet/compose.py +176 -0
  5. coderfleet/config.py +69 -0
  6. coderfleet/config_cmds.py +243 -0
  7. coderfleet/data/Dockerfile +92 -0
  8. coderfleet/data/__init__.py +0 -0
  9. coderfleet/data/accounts.conf.example +26 -0
  10. coderfleet/data/config.conf.example +31 -0
  11. coderfleet/data/entrypoint.sh +56 -0
  12. coderfleet/data/projects.conf.example +17 -0
  13. coderfleet/data/scripts/coderfleet_usage_status.py +138 -0
  14. coderfleet/docker_ops.py +385 -0
  15. coderfleet/init_wizard.py +227 -0
  16. coderfleet/login_cmd.py +168 -0
  17. coderfleet/server/__init__.py +0 -0
  18. coderfleet/server/docker_mgr.py +45 -0
  19. coderfleet/server/main.py +546 -0
  20. coderfleet/server/models.py +285 -0
  21. coderfleet/server/scheduler.py +1219 -0
  22. coderfleet/server/static/css/main.css +2906 -0
  23. coderfleet/server/static/index.html +378 -0
  24. coderfleet/server/static/js/accounts.js +85 -0
  25. coderfleet/server/static/js/app.js +28 -0
  26. coderfleet/server/static/js/chat.js +743 -0
  27. coderfleet/server/static/js/log.js +145 -0
  28. coderfleet/server/static/js/nav.js +46 -0
  29. coderfleet/server/static/js/projects.js +298 -0
  30. coderfleet/server/static/js/renderer.js +586 -0
  31. coderfleet/server/static/js/state.js +76 -0
  32. coderfleet/server/static/js/submit.js +200 -0
  33. coderfleet/server/static/js/tasks.js +92 -0
  34. coderfleet/server/static/js/terminal.js +347 -0
  35. coderfleet/server/static/js/utils.js +147 -0
  36. coderfleet/server/static/vendor/marked.min.js +6 -0
  37. coderfleet/server/static/vendor/xterm/addon-fit.js +2 -0
  38. coderfleet/server/static/vendor/xterm/xterm.css +218 -0
  39. coderfleet/server/static/vendor/xterm/xterm.js +2 -0
  40. coderfleet/server/terminal.py +129 -0
  41. coderfleet/task_cmds.py +311 -0
  42. coderfleet-0.1.0.dist-info/METADATA +492 -0
  43. coderfleet-0.1.0.dist-info/RECORD +45 -0
  44. coderfleet-0.1.0.dist-info/WHEEL +4 -0
  45. coderfleet-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,586 @@
1
+ // ══════════════════════════════════════════════════════════════
2
+ // ChatLogRenderer — 把 JSONL 日志渲染成对话
3
+ // ══════════════════════════════════════════════════════════════
4
+ class ChatLogRenderer {
5
+ constructor(container, isRunning = false, foldProcess = false) {
6
+ this.container = container; // #log-content div
7
+ this.inner = null; // .chat-log div
8
+ this.toolMap = {}; // tool_use_id → { headerEl, badgeEl, outputEl, exitEl, toolName, input, wrap }
9
+ this._buf = ''; // SSE 行缓冲
10
+ this._footerRendered = false;
11
+ this.isRunning = isRunning; // 是否正在运行
12
+ this.foldProcess = foldProcess; // 是否折叠最新步骤
13
+
14
+ // 折叠辅助字段
15
+ this.processWrapper = null;
16
+ this.processBody = null;
17
+ this.toolCount = 0;
18
+ this.idPrefix = Math.random().toString(36).slice(2, 8);
19
+ this.lastBubbleEl = null; // 保存最后一条 AI 回复节点的引用
20
+ }
21
+
22
+ _ensureProcessWrapper() {
23
+ if (this.processWrapper) return;
24
+
25
+ this.processWrapper = document.createElement('div');
26
+ this.processWrapper.className = 'timeline-node intermediate-process-wrapper';
27
+
28
+ const bodyClass = this.isRunning ? 'intermediate-process-body' : 'intermediate-process-body collapsed';
29
+ const btnText = this.isRunning ? '收起' : '展开';
30
+
31
+ this.processWrapper.innerHTML = `
32
+ <div class="intermediate-process-header">
33
+ <div class="process-title-area">
34
+ <span class="process-icon">⚙️</span>
35
+ <span class="process-title-text">任务执行过程</span>
36
+ <span class="process-stats-badge" id="ps-${this.idPrefix}" style="display:none">0 个步骤</span>
37
+ </div>
38
+ <button class="process-toggle-btn" id="pb-${this.idPrefix}">${btnText}</button>
39
+ </div>
40
+ <div class="${bodyClass}" id="pbody-${this.idPrefix}"></div>
41
+ `;
42
+
43
+ this.inner.appendChild(this.processWrapper);
44
+ this.processBody = this.processWrapper.querySelector(`#pbody-${this.idPrefix}`);
45
+
46
+ const header = this.processWrapper.querySelector('.intermediate-process-header');
47
+ const toggleBtn = this.processWrapper.querySelector(`#pb-${this.idPrefix}`);
48
+ header.addEventListener('click', () => {
49
+ const isCollapsed = this.processBody.classList.toggle('collapsed');
50
+ toggleBtn.textContent = isCollapsed ? '展开' : '收起';
51
+ });
52
+ }
53
+
54
+ _appendToProcess(el) {
55
+ this._ensureProcessWrapper();
56
+ this.processBody.appendChild(el);
57
+ this.toolCount++;
58
+ const badge = this.processWrapper.querySelector(`#ps-${this.idPrefix}`);
59
+ if (badge) {
60
+ badge.style.display = '';
61
+ badge.textContent = `${this.toolCount} 个步骤`;
62
+ }
63
+ }
64
+
65
+ _appendNode(el) {
66
+ if (this.foldProcess) {
67
+ this._appendToProcess(el);
68
+ } else {
69
+ this.inner.appendChild(el);
70
+ }
71
+ }
72
+
73
+ renderPending() {
74
+ this.container.innerHTML = '<div class="chat-log timeline"></div>';
75
+ this.inner = this.container.querySelector('.chat-log');
76
+ const el = document.createElement('div');
77
+ el.className = 'chat-sys-event timeline-node is-muted';
78
+ el.innerHTML = `
79
+ <span class="chat-sys-pill" style="background: rgba(148, 163, 184, 0.08); border-color: rgba(148, 163, 184, 0.22); color: #cbd5e1;">
80
+ <span style="display: inline-block; animation: spin 2s linear infinite; margin-right: 6px;">⏳</span> 任务排队中,等待空闲账号...
81
+ </span>
82
+ `;
83
+ this.inner.appendChild(el);
84
+ }
85
+
86
+ renderScheduled(executeAt) {
87
+ this.container.innerHTML = '<div class="chat-log timeline"></div>';
88
+ this.inner = this.container.querySelector('.chat-log');
89
+ const displayTime = executeAt ? executeAt.replace('T', ' ') : '';
90
+ const el = document.createElement('div');
91
+ el.className = 'chat-sys-event timeline-node is-muted';
92
+ el.innerHTML = `
93
+ <span class="chat-sys-pill" style="background: rgba(245, 158, 11, 0.08); border-color: rgba(245, 158, 11, 0.22); color: #f59e0b;">
94
+ <span style="margin-right: 6px;">⏰</span> 已定时:将在 ${esc(displayTime)} 自动发送
95
+ </span>
96
+ `;
97
+ this.inner.appendChild(el);
98
+ }
99
+
100
+ // ── 全量渲染 ─────────────────────────────────────────────
101
+ render(text) {
102
+ this.container.innerHTML = '<div class="chat-log timeline"></div>';
103
+ this.inner = this.container.querySelector('.chat-log');
104
+ this.toolMap = {};
105
+ this._buf = '';
106
+ this._footerRendered = false;
107
+ this.processWrapper = null;
108
+ this.processBody = null;
109
+ this.toolCount = 0;
110
+ this.lastBubbleEl = null;
111
+
112
+ const lines = text.split('\n');
113
+ let state = 'before'; // before | header | body | footer
114
+ let headerLines = [];
115
+ let footerLines = [];
116
+
117
+ for (const raw of lines) {
118
+ const line = raw.trimEnd();
119
+ if (state === 'before') {
120
+ if (line.startsWith('=== CoderFleet Task Log ===')) { state = 'header'; headerLines = [line]; }
121
+ continue;
122
+ }
123
+ if (state === 'header') {
124
+ if (line.startsWith('======')) { state = 'body'; this._renderHeader(headerLines); continue; }
125
+ headerLines.push(line);
126
+ continue;
127
+ }
128
+ if (state === 'body') {
129
+ if (line.startsWith('======')) { state = 'footer'; continue; }
130
+ if (line.trim()) this._processLine(line.trim());
131
+ continue;
132
+ }
133
+ if (state === 'footer') {
134
+ if (line.trim()) footerLines.push(line);
135
+ }
136
+ }
137
+
138
+ if (footerLines.length) this._renderFooter(footerLines.join(' '));
139
+ }
140
+
141
+ // ── 增量推送(SSE 每行调用一次)─────────────────────────
142
+ push(line) {
143
+ if (!line.trim()) return;
144
+
145
+ if (line.startsWith('=== CoderFleet Task Log ===')) {
146
+ if (this.inner && this.inner.querySelector('.chat-meta-block')) {
147
+ return;
148
+ }
149
+ if (this.inner) {
150
+ this.inner.innerHTML = '';
151
+ }
152
+ this.toolMap = {};
153
+ this._footerRendered = false;
154
+ this.processWrapper = null;
155
+ this.processBody = null;
156
+ this.toolCount = 0;
157
+ this.lastBubbleEl = null;
158
+
159
+ this._isCollectingHeader = true;
160
+ this._headerLines = [line];
161
+ return;
162
+ }
163
+
164
+ if (this._isCollectingHeader) {
165
+ if (line.startsWith('======')) {
166
+ this._isCollectingHeader = false;
167
+ this._renderHeader(this._headerLines);
168
+ } else {
169
+ this._headerLines.push(line);
170
+ }
171
+ return;
172
+ }
173
+
174
+ // 跳过已渲染的头尾标记(初次全量加载后 SSE 会重复推)
175
+ if (line.startsWith('=== CoderFleet') || line.startsWith('======')) return;
176
+ if (line.startsWith('id:') || line.startsWith('account:') ||
177
+ line.startsWith('project:') || line.startsWith('started:') ||
178
+ line.startsWith('prompt:') || line.startsWith('container')) return;
179
+ if (line.startsWith('finished:')) {
180
+ if (!this._footerRendered) this._renderFooter(line);
181
+ return;
182
+ }
183
+ this._processLine(line.trim());
184
+ }
185
+
186
+ // ── 私有:单行处理 ────────────────────────────────────────
187
+ _processLine(line) {
188
+ if (!line.startsWith('{')) { this._rawLine(line); return; }
189
+ let d;
190
+ try { d = JSON.parse(line); } catch { this._rawLine(line); return; }
191
+ this._event(d);
192
+ }
193
+
194
+ // ── 私有:事件分发 ────────────────────────────────────────
195
+ _event(d) {
196
+ switch (d.type) {
197
+ // Claude
198
+ case 'system': return this._claudeSys(d);
199
+ case 'assistant': return this._claudeAssistant(d.message);
200
+ case 'user': return this._claudeUser(d.message);
201
+ case 'result': return this._claudeResult(d);
202
+ // Codex
203
+ case 'thread.started': return this._pill('会话开始', d.thread_id ? '#' + String(d.thread_id).slice(0, 8) : '');
204
+ case 'turn.started': return; // 太噪,静默
205
+ case 'turn.ended': return;
206
+ case 'thread.ended': return this._codexEnd(d);
207
+ case 'message': return this._codexMessage(d);
208
+ case 'tool_call': return this._codexToolCall(d);
209
+ case 'tool_result': return this._codexToolResult(d);
210
+ case 'reasoning': return this._thinking(d.text || d.thinking || '');
211
+ case 'item.started': return this._codexItemStarted(d.item);
212
+ case 'item.completed': return this._codexItemCompleted(d.item);
213
+ // 其余静默忽略
214
+ }
215
+ }
216
+
217
+ // ── Header 块 ─────────────────────────────────────────────
218
+ _renderHeader(lines) {
219
+ const el = document.createElement('div');
220
+ el.className = 'chat-meta-block';
221
+ el.classList.add('timeline-node', 'is-muted');
222
+ const html = lines
223
+ .filter(l => !l.startsWith('===') && l.includes(':'))
224
+ .map(l => {
225
+ const i = l.indexOf(':');
226
+ const key = l.slice(0, i).trim();
227
+ let val = l.slice(i + 1).trim();
228
+ if (key === 'prompt') {
229
+ val = val.replace(/\\n/g, '\n');
230
+ }
231
+ return `<div class="meta-row"><span class="meta-key">${esc(key)}:</span> <span class="meta-val">${esc(val)}</span></div>`;
232
+ }).join('');
233
+ el.innerHTML = html;
234
+ this._appendNode(el);
235
+ }
236
+
237
+ // ── Claude: system ────────────────────────────────────────
238
+ _claudeSys(d) {
239
+ if (d.subtype === 'init') {
240
+ const model = (d.model || '').replace(/^claude-/, '');
241
+ const n = (d.tools || []).length;
242
+ this._pill('Claude 就绪', model + (n ? ` · ${n} 工具` : ''));
243
+ }
244
+ }
245
+
246
+ // ── Claude: assistant ─────────────────────────────────────
247
+ _claudeAssistant(msg) {
248
+ if (!msg?.content) return;
249
+ for (const b of msg.content) {
250
+ if (b.type === 'text' && b.text?.trim()) this._bubble(b.text, msg.model);
251
+ else if (b.type === 'thinking' && b.thinking) this._thinking(b.thinking);
252
+ else if (b.type === 'tool_use') this._toolUse(b);
253
+ }
254
+ }
255
+
256
+ // ── Claude: user (tool results) ───────────────────────────
257
+ _claudeUser(msg) {
258
+ if (!msg?.content) return;
259
+ for (const b of msg.content) {
260
+ if (b.type === 'tool_result') {
261
+ let text = '';
262
+ if (typeof b.content === 'string') text = b.content;
263
+ else if (Array.isArray(b.content)) text = b.content.map(c => c.text || '').join('\n');
264
+ this._fillTool(b.tool_use_id, text, b.is_error);
265
+ }
266
+ }
267
+ }
268
+
269
+ // ── Claude: result ────────────────────────────────────────
270
+ _claudeResult(d) {
271
+ // 1. 如果最终成功且有答复内容,先渲染为 AI 气泡
272
+ if (!d.is_error && d.result && d.result.trim()) {
273
+ this._bubble(d.result);
274
+ }
275
+
276
+ // 2. 提取并渲染 Token 用量与费用统计
277
+ const usage = d.usage || {};
278
+ const input = d.input_tokens ?? usage.input_tokens;
279
+ const output = d.output_tokens ?? usage.output_tokens;
280
+ const cacheRead = usage.cache_read_input_tokens;
281
+ const cost = d.cost_usd ?? d.total_cost_usd;
282
+ const turns = d.num_turns;
283
+
284
+ const items = [];
285
+ if (input != null) items.push(`输入 <span>${input.toLocaleString()}</span> tok`);
286
+ if (output != null) items.push(`输出 <span>${output.toLocaleString()}</span> tok`);
287
+ if (cacheRead != null && cacheRead > 0) {
288
+ items.push(`缓存读取 <span>${cacheRead.toLocaleString()}</span> tok`);
289
+ }
290
+ if (cost != null) items.push(`费用 <span>$${cost.toFixed(4)}</span>`);
291
+ if (turns != null) items.push(`轮次 <span>${turns}</span>`);
292
+
293
+ if (items.length) {
294
+ const el = document.createElement('div');
295
+ el.className = 'chat-usage timeline-node is-muted';
296
+ el.innerHTML = items.map(i => `<div class="usage-item">${i}</div>`).join('');
297
+ this._appendNode(el);
298
+ }
299
+
300
+ // 3. 如果是错误,渲染异常阻断横幅
301
+ if (d.is_error && d.result) {
302
+ const el = document.createElement('div');
303
+ el.className = 'chat-sys-event timeline-node is-error';
304
+ el.innerHTML = `<span class="chat-sys-pill" style="background:#2a0e0e; color:#f87171; border-color:#5a1e1e; font-weight:600;">异常阻断 · ${esc(d.result)}</span>`;
305
+ this._appendNode(el);
306
+ }
307
+
308
+ // 4. 更新页脚
309
+ const ok = d.subtype === 'success' && !d.is_error;
310
+ if (!this._footerRendered) {
311
+ this._renderFooter(ok ? 'done' : 'failed');
312
+ }
313
+ }
314
+
315
+ // ── Codex ─────────────────────────────────────────────────
316
+ _codexMessage(d) {
317
+ if (d.role === 'assistant') {
318
+ const t = typeof d.content === 'string' ? d.content :
319
+ (Array.isArray(d.content) ? d.content.map(b => b.text || '').join('') : '');
320
+ if (t.trim()) this._bubble(t);
321
+ }
322
+ }
323
+
324
+ _codexToolCall(d) {
325
+ this._toolUse({
326
+ id: d.id || ('cx-' + Math.random().toString(36).slice(2, 8)),
327
+ name: d.name || 'tool',
328
+ input: d.arguments || d.input || {},
329
+ });
330
+ }
331
+
332
+ _codexToolResult(d) {
333
+ const text = typeof d.result === 'string' ? d.result :
334
+ (Array.isArray(d.result) ? d.result.map(c => c.text || '').join('\n') : JSON.stringify(d.result));
335
+ this._fillTool(d.tool_call_id, text, d.is_error);
336
+ }
337
+
338
+ _codexEnd(d) {
339
+ if (!this._footerRendered) this._renderFooter(d.result ? 'done' : 'failed');
340
+ }
341
+
342
+ _codexItemStarted(item) {
343
+ if (!item) return;
344
+ if (item.type === 'command_execution') {
345
+ const id = item.id;
346
+ const cmd = item.command || '';
347
+ let displayCmd = cmd;
348
+ if (cmd.startsWith('/bin/bash -lc "') && cmd.endsWith('"')) {
349
+ displayCmd = cmd.substring(15, cmd.length - 1).replace(/\\"/g, '"');
350
+ } else if (cmd.startsWith('/bin/bash -lc \'') && cmd.endsWith('\'')) {
351
+ displayCmd = cmd.substring(15, cmd.length - 1);
352
+ }
353
+ this._toolUse({
354
+ id: id,
355
+ name: 'Bash',
356
+ input: { command: displayCmd }
357
+ });
358
+ }
359
+ }
360
+
361
+ _codexItemCompleted(item) {
362
+ if (!item) return;
363
+ if (item.type === 'command_execution') {
364
+ const id = item.id;
365
+ if (!this.toolMap[id]) {
366
+ this._codexItemStarted(item);
367
+ }
368
+ const exitCode = item.exit_code;
369
+ const isError = exitCode != null && exitCode !== 0;
370
+ this._fillTool(id, item.aggregated_output || '', isError);
371
+ } else if (item.type === 'agent_message') {
372
+ if (item.text?.trim()) {
373
+ this._bubble(item.text);
374
+ }
375
+ } else if (item.type === 'file_change') {
376
+ const changes = item.changes || [];
377
+ for (const ch of changes) {
378
+ const path = ch.path || '';
379
+ const kind = ch.kind || 'update';
380
+
381
+ let opClass = 'edit';
382
+ let opLabel = 'UPDATE';
383
+ if (kind === 'add' || kind === 'create') {
384
+ opClass = 'create';
385
+ opLabel = 'CREATE';
386
+ } else if (kind === 'delete' || kind === 'remove') {
387
+ opClass = 'delete';
388
+ opLabel = 'DELETE';
389
+ }
390
+
391
+ const el = document.createElement('div');
392
+ el.className = 'chat-file-card timeline-node';
393
+ let cleanPath = path;
394
+ if (path.startsWith('/workspace/')) {
395
+ cleanPath = path.substring(11);
396
+ }
397
+ el.innerHTML = `
398
+ <span class="file-op-badge ${opClass}">${opLabel}</span>
399
+ <span class="file-path">${esc(cleanPath)}</span>
400
+ `;
401
+ this._appendNode(el);
402
+ }
403
+ }
404
+ }
405
+
406
+ // ── AI 文字气泡 ───────────────────────────────────────────
407
+ _bubble(text, model) {
408
+ const label = model
409
+ ? model.replace(/^claude-/, '').split('-').slice(0, 2).join('-')
410
+ : 'AI';
411
+ const el = document.createElement('div');
412
+ el.className = 'chat-bubble-wrap';
413
+ el.classList.add('timeline-node');
414
+ el.innerHTML = `
415
+ <div class="chat-avatar ai" aria-hidden="true">AI</div>
416
+ <div class="bubble-body">
417
+ <div class="bubble-label-row">
418
+ <div class="bubble-label">${esc(label)}</div>
419
+ <button class="bubble-copy-btn" title="复制">${copyBtnSVG()}</button>
420
+ </div>
421
+ <div class="bubble">${renderMd(text)}</div>
422
+ </div>`;
423
+
424
+ el.querySelector('.bubble-copy-btn').addEventListener('click', () => {
425
+ copyTextToClipboard(text, el.querySelector('.bubble-copy-btn'));
426
+ });
427
+
428
+ // 如果之前有渲染过 AI 回复,说明之前的回复并非最后一条,需要移入折叠包中
429
+ if (this.lastBubbleEl) {
430
+ if (this.foldProcess) {
431
+ this._appendToProcess(this.lastBubbleEl);
432
+ } else {
433
+ this.inner.appendChild(this.lastBubbleEl);
434
+ }
435
+ }
436
+
437
+ this.inner.appendChild(el);
438
+ this.lastBubbleEl = el;
439
+ }
440
+
441
+ // ── 思考气泡 ──────────────────────────────────────────────
442
+ _thinking(text) {
443
+ if (!text?.trim()) return;
444
+ const el = document.createElement('div');
445
+ el.className = 'chat-bubble-wrap';
446
+ el.classList.add('timeline-node', 'is-muted');
447
+ el.innerHTML = `
448
+ <div class="chat-avatar think" aria-hidden="true">TH</div>
449
+ <div class="bubble-body">
450
+ <div class="bubble-label think-label">思考过程</div>
451
+ <div class="bubble think-bubble">${renderMd(text)}</div>
452
+ </div>`;
453
+ this._appendNode(el);
454
+ }
455
+
456
+ // ── 工具调用卡片 ──────────────────────────────────────────
457
+ _toolUse(block) {
458
+ const { id, name, input } = block;
459
+ const summary = formatToolSummary(name, input);
460
+ const detail = formatToolInput(name, input);
461
+ const icon = toolIcon(name);
462
+ const hasInput = detail && detail !== summary;
463
+
464
+ const wrap = document.createElement('div');
465
+ wrap.className = 'chat-tool-wrap';
466
+ wrap.classList.add('timeline-node');
467
+ wrap.dataset.toolId = id;
468
+
469
+ const card = document.createElement('div');
470
+ card.className = 'chat-tool-card';
471
+ card.innerHTML = `
472
+ <div class="chat-tool-header">
473
+ <span class="tool-status" id="ts-${id}">⏳</span>
474
+ <span class="tool-badge pending" id="tb-${id}">${esc(icon)} ${esc(name)}</span>
475
+ <span class="tool-cmd" title="${esc(summary)}">${esc(summary)}</span>
476
+ <button class="tool-toggle" id="tt-${id}">展开</button>
477
+ </div>
478
+ <div class="tool-body collapsed" id="tbody-${id}">
479
+ ${hasInput ? `<div class="tool-input">${esc(detail)}</div>` : ''}
480
+ <div class="tool-output" id="to-${id}" style="display:none"></div>
481
+ <div class="tool-exit" id="te-${id}" style="display:none"></div>
482
+ </div>`;
483
+
484
+ wrap.appendChild(card);
485
+ this._appendNode(wrap);
486
+
487
+ // Toggle 逻辑
488
+ const header = card.querySelector('.chat-tool-header');
489
+ const body = card.querySelector(`#tbody-${id}`);
490
+ const btn = card.querySelector(`#tt-${id}`);
491
+ header.addEventListener('click', () => {
492
+ const collapsed = body.classList.toggle('collapsed');
493
+ btn.textContent = collapsed ? '展开' : '收起';
494
+ });
495
+ btn.addEventListener('click', e => { e.stopPropagation(); header.click(); });
496
+
497
+ this.toolMap[id] = {
498
+ statusEl: card.querySelector(`#ts-${id}`),
499
+ badgeEl: card.querySelector(`#tb-${id}`),
500
+ outputEl: card.querySelector(`#to-${id}`),
501
+ exitEl: card.querySelector(`#te-${id}`),
502
+ bodyEl: body,
503
+ btnEl: btn,
504
+ wrap, name, input,
505
+ };
506
+ }
507
+
508
+ // ── 填充工具结果 ──────────────────────────────────────────
509
+ _fillTool(id, text, isError) {
510
+ const e = this.toolMap[id];
511
+ if (!e) return;
512
+
513
+ const ok = !isError;
514
+ e.statusEl.textContent = ok ? '✓' : '✗';
515
+ e.badgeEl.className = `tool-badge ${ok ? 'ok' : 'fail'}`;
516
+ e.badgeEl.textContent = `${toolIcon(e.name)} ${e.name}`;
517
+
518
+ const out = (text || '').trim();
519
+ e.outputEl.style.display = '';
520
+ e.outputEl.className = `tool-output${ok ? '' : ' is-error'}`;
521
+ e.outputEl.textContent = out || (ok ? '(无输出)' : '(执行失败)');
522
+
523
+ e.exitEl.style.display = '';
524
+ e.exitEl.className = `tool-exit ${ok ? 'ok-exit' : 'fail-exit'}`;
525
+ e.exitEl.textContent = ok ? '✓ exit 0' : '✗ error';
526
+
527
+ // 短输出自动展开
528
+ const lineCount = out.split('\n').length;
529
+ if (lineCount <= 6 && out.length <= 400) {
530
+ e.bodyEl.classList.remove('collapsed');
531
+ e.btnEl.textContent = '收起';
532
+ }
533
+
534
+ // 文件操作:追加变更徽章
535
+ if (['Write', 'Edit', 'NotebookEdit'].includes(e.name) && ok) {
536
+ const fp = e.input?.file_path || e.input?.path || '';
537
+ if (fp) {
538
+ const kind = e.name === 'Write' ? 'create' : 'edit';
539
+ const label = e.name === 'Write' ? 'CREATE' : 'EDIT';
540
+ const fc = document.createElement('div');
541
+ fc.className = 'chat-file-card timeline-node';
542
+ fc.innerHTML = `<span class="file-op-badge ${kind}">${label}</span><span class="file-path">${esc(fp)}</span>`;
543
+ e.wrap.after(fc);
544
+ }
545
+ }
546
+ }
547
+
548
+ // ── Footer ────────────────────────────────────────────────
549
+ _renderFooter(text) {
550
+ if (this._footerRendered) return;
551
+ this._footerRendered = true;
552
+ let status = 'done', label = '✓ 任务完成';
553
+ if (typeof text === 'string') {
554
+ if (text.includes('killed')) { status = 'killed'; label = '⊘ 任务已终止'; }
555
+ else if (text.includes('failed') || text.includes('error')) {
556
+ status = 'failed';
557
+ const m = text.match(/\[([^\]]+)\]/);
558
+ label = '✗ 任务失败' + (m ? ' · ' + m[1] : '');
559
+ }
560
+ }
561
+ const el = document.createElement('div');
562
+ el.className = 'chat-footer';
563
+ el.classList.add('timeline-node', status === 'failed' ? 'is-error' : 'is-muted');
564
+ el.innerHTML = `<span class="footer-pill ${status}">${esc(label)}</span>`;
565
+ this._appendNode(el);
566
+ }
567
+
568
+ // ── 系统药丸 ──────────────────────────────────────────────
569
+ _pill(label, detail) {
570
+ const el = document.createElement('div');
571
+ el.className = 'chat-sys-event';
572
+ el.classList.add('timeline-node', 'is-muted');
573
+ el.innerHTML = `<span class="chat-sys-pill">${esc(label)}${detail ? ` <span class="pill-detail">· ${esc(detail)}</span>` : ''}</span>`;
574
+ this._appendNode(el);
575
+ }
576
+
577
+ // ── 原始/错误行 ───────────────────────────────────────────
578
+ _rawLine(line) {
579
+ if (!line.trim()) return;
580
+ const el = document.createElement('div');
581
+ el.className = 'chat-raw-line';
582
+ el.classList.add('timeline-node', 'is-error');
583
+ el.textContent = line;
584
+ this._appendNode(el);
585
+ }
586
+ }
@@ -0,0 +1,76 @@
1
+
2
+ // ══════════════════════════════════════════════════════════════
3
+ // 页面状态
4
+ // ══════════════════════════════════════════════════════════════
5
+ let currentPage = 'chat';
6
+ let currentTaskId = null;
7
+ let currentTaskData = null;
8
+ let sseSource = null;
9
+ let followMode = false;
10
+ let renderer = null;
11
+ let conversationsCache = {};
12
+ let projectsCache = [];
13
+ let projectContext = null;
14
+ let projectDashboardData = { tasks: [], accounts: [] };
15
+
16
+ // ── AI 对话(聊天室)逻辑 ───────────────────────────────────
17
+ let activeConversationId = null;
18
+ let sseChatSource = null;
19
+ let chatFollowMode = false;
20
+ let currentChatTaskId = null;
21
+ let chatRenderer = null;
22
+ let chatNewSessionProject = '';
23
+ let currentChatProjectName = '';
24
+ let pendingImages = [];
25
+ let tasksCache = [];
26
+ let terminalContext = {
27
+ projectName: '',
28
+ socket: null,
29
+ terminal: null,
30
+ fitAddon: null,
31
+ connected: false,
32
+ resizeTimer: null,
33
+ lastState: 'closed',
34
+ };
35
+ let submitContext = { surface: 'task', projectName: '' };
36
+ let taskRowsCache = [];
37
+ let taskPage = 1;
38
+ const TASK_PAGE_SIZE = 12;
39
+ let globalAccountsCache = [];
40
+
41
+ function setText(id, value) {
42
+ const el = document.getElementById(id);
43
+ if (el) el.textContent = value;
44
+ }
45
+
46
+ function updateTaskMetrics(tasks = [], accounts = []) {
47
+ const count = status => tasks.filter(t => t.status === status).length;
48
+ setText('metric-running', count('running'));
49
+ setText('metric-done', count('done'));
50
+ setText('metric-failed', count('failed'));
51
+ setText('metric-accounts', accounts.length || '-');
52
+ const online = accounts.filter(a => a.running).length;
53
+ setText('metric-account-note', accounts.length ? `${online}/${accounts.length} 在线` : '暂无账号数据');
54
+ }
55
+
56
+ function applySidebarCollapsed(collapsed) {
57
+ const layout = document.querySelector('.layout');
58
+ const btn = document.getElementById('sidebar-toggle');
59
+ if (!layout || !btn) return;
60
+ layout.classList.toggle('sidebar-collapsed', collapsed);
61
+ btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
62
+ btn.setAttribute('aria-label', collapsed ? '展开侧边栏' : '折叠侧边栏');
63
+ btn.setAttribute('title', collapsed ? '展开侧边栏' : '折叠侧边栏');
64
+ }
65
+
66
+ function initSidebarState() {
67
+ applySidebarCollapsed(localStorage.getItem('coderfleet.sidebarCollapsed') === 'true');
68
+ }
69
+
70
+ function toggleSidebar() {
71
+ const layout = document.querySelector('.layout');
72
+ const collapsed = !(layout?.classList.contains('sidebar-collapsed'));
73
+ localStorage.setItem('coderfleet.sidebarCollapsed', collapsed ? 'true' : 'false');
74
+ applySidebarCollapsed(collapsed);
75
+ }
76
+