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,743 @@
1
+ // ── AI 对话(聊天室)核心逻辑 ───────────────────────────────────
2
+
3
+ // 加载会话列表及数据
4
+ async function loadConversations(renderWorkspace = true) {
5
+ try {
6
+ const [convs, projects, tasks] = await Promise.all([
7
+ fetch(`${API}/api/conversations`).then(r => r.json()),
8
+ fetch(`${API}/api/projects`).then(r => r.json()).catch(() => []),
9
+ fetch(`${API}/api/tasks?limit=1000`).then(r => r.json()).catch(() => []),
10
+ ]);
11
+ projectsCache = projects;
12
+ tasksCache = tasks;
13
+
14
+ conversationsCache = {};
15
+ convs.forEach(c => { conversationsCache[c.id] = c.name; });
16
+
17
+ renderConversations(convs, projects, tasks);
18
+
19
+ if (renderWorkspace) {
20
+ if (!activeConversationId) {
21
+ renderEmptyChatState();
22
+ } else if (activeConversationId.startsWith('task-')) {
23
+ const taskId = activeConversationId.replace('task-', '');
24
+ const task = tasks.find(t => t.id === taskId);
25
+ if (task) {
26
+ const virtualConv = {
27
+ id: `task-${task.id}`,
28
+ name: task.prompt,
29
+ project: task.project,
30
+ project_name: projects.find(p => taskBelongsToProject(task, p))?.name || '未配置项目',
31
+ account: task.account,
32
+ updated: task.created,
33
+ isOneOff: true
34
+ };
35
+ renderChatWorkspace(virtualConv);
36
+ } else {
37
+ startNewChat();
38
+ }
39
+ } else {
40
+ const active = convs.find(c => c.id === activeConversationId);
41
+ if (active) {
42
+ renderChatWorkspace(active);
43
+ } else {
44
+ startNewChat();
45
+ }
46
+ }
47
+ }
48
+ } catch (e) {
49
+ document.getElementById('chat-history-list').innerHTML = `<div class="empty" style="padding: 20px 0;">加载失败: ${esc(e.message)}</div>`;
50
+ }
51
+ }
52
+
53
+ // 以项目大标题分组渲染会话列表
54
+ function renderConversations(convs, projects, tasks) {
55
+ const list = document.getElementById('chat-history-list');
56
+ if (!projects.length) {
57
+ list.innerHTML = `<div class="empty" style="padding: 20px 0;">暂无项目配置</div>`;
58
+ return;
59
+ }
60
+
61
+ const folderSvg = `<svg class="proj-folder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>`;
62
+ let html = '';
63
+
64
+ projects.forEach(proj => {
65
+ const projConvs = convs.filter(c => conversationBelongsToProject(c, proj));
66
+ const projTasks = tasks.filter(t => !t.conversation_id && taskBelongsToProject(t, proj));
67
+
68
+ const items = [];
69
+ projConvs.forEach(c => {
70
+ items.push({
71
+ type: 'conversation',
72
+ id: c.id,
73
+ name: c.name || c.id,
74
+ time: c.updated || c.created || 0
75
+ });
76
+ });
77
+ projTasks.forEach(t => {
78
+ items.push({
79
+ type: 'one-off',
80
+ id: `task-${t.id}`,
81
+ name: t.prompt,
82
+ time: t.created || 0
83
+ });
84
+ });
85
+
86
+ items.sort((a, b) => new Date(b.time) - new Date(a.time));
87
+
88
+ html += `
89
+ <div class="chat-project-group">
90
+ <div class="chat-project-header">
91
+ <div class="proj-header-title" title="${esc(proj.name)}">
92
+ ${folderSvg}
93
+ <span>${esc(proj.name)}</span>
94
+ </div>
95
+ <button class="proj-new-chat-btn" onclick="event.stopPropagation(); startNewChat({ projectName: '${esc(proj.name)}' })" title="在 ${esc(proj.name)} 中开始新对话">
96
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
97
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
98
+ <path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
99
+ </svg>
100
+ </button>
101
+ </div>
102
+ <div class="chat-project-items">
103
+ `;
104
+
105
+ if (items.length === 0) {
106
+ html += `<div class="chat-project-empty">暂无对话</div>`;
107
+ } else {
108
+ html += items.map(item => {
109
+ const isActive = item.id === activeConversationId;
110
+ const displayTime = fmtTimeFriendly(item.time);
111
+ const archiveBtn = item.type === 'conversation'
112
+ ? `<button class="session-action-btn" title="归档" onclick="event.stopPropagation(); archiveConversation('${esc(item.id)}')">
113
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
114
+ </button>`
115
+ : (item.type === 'one-off'
116
+ ? `<button class="session-action-btn" title="归档" onclick="event.stopPropagation(); archiveOneOff('${esc(item.id)}')">
117
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
118
+ </button>`
119
+ : '');
120
+ const deleteBtn = item.type === 'conversation'
121
+ ? `<button class="session-action-btn danger" title="删除" onclick="event.stopPropagation(); deleteConversation('${esc(item.id)}')">
122
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14H6L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4h6v2"></path></svg>
123
+ </button>`
124
+ : (item.type === 'one-off'
125
+ ? `<button class="session-action-btn danger" title="删除" onclick="event.stopPropagation(); deleteOneOff('${esc(item.id)}')">
126
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14H6L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4h6v2"></path></svg>
127
+ </button>`
128
+ : '');
129
+ return `
130
+ <div class="chat-session-item ${isActive ? 'active' : ''}" onclick="selectConversation('${esc(item.id)}')">
131
+ <span class="session-name" title="${esc(item.name)}">${esc(item.name)}</span>
132
+ <span class="session-time">${esc(displayTime)}</span>
133
+ <span class="session-actions">${archiveBtn}${deleteBtn}</span>
134
+ </div>`;
135
+ }).join('');
136
+ }
137
+
138
+ html += `
139
+ </div>
140
+ </div>
141
+ `;
142
+ });
143
+
144
+ list.innerHTML = html;
145
+ }
146
+
147
+ // 选择会话
148
+ async function selectConversation(convId) {
149
+ stopChatFollow();
150
+ activeConversationId = convId;
151
+ await loadConversations();
152
+ }
153
+
154
+ // 开启新会话
155
+ function startNewChat(options = {}) {
156
+ stopChatFollow();
157
+ activeConversationId = null;
158
+ currentChatTaskId = null;
159
+ chatNewSessionProject = options.projectName || '';
160
+ showPage('chat');
161
+ loadConversations();
162
+ }
163
+
164
+ // 渲染新会话空状态(无历史记录的初始界面)
165
+ function renderEmptyChatState() {
166
+ const workspace = document.getElementById('chat-workspace');
167
+ if (!workspace) return;
168
+
169
+ const projectLabel = chatNewSessionProject || '未指定项目';
170
+ currentChatProjectName = chatNewSessionProject;
171
+ pendingImages = [];
172
+
173
+ workspace.innerHTML = `
174
+ <div class="chat-main-header">
175
+ <div class="chat-main-title-area">
176
+ <div class="chat-main-title">新对话</div>
177
+ <div class="chat-main-subtitle">项目: <strong style="color: var(--accent);">${esc(projectLabel)}</strong></div>
178
+ </div>
179
+ </div>
180
+
181
+ <div class="chat-main-viewport" id="chat-viewport">
182
+ <div id="chat-content">
183
+ <div class="empty" style="margin-top: 60px;">输入第一条指令,开始与 AI 结对开发</div>
184
+ </div>
185
+ </div>
186
+
187
+ ${buildChatInputHTML('输入您的开发指令... (按 Enter 发送,Shift+Enter 换行)', '发送第一条消息后将自动创建会话链')}
188
+ `;
189
+
190
+ const textarea = document.getElementById('chat-input');
191
+ bindChatTextareaEvents(textarea);
192
+ if (textarea) textarea.focus();
193
+ }
194
+
195
+ // 自适应高度及快捷键发送绑定
196
+ function bindChatTextareaEvents(textarea) {
197
+ if (!textarea) return;
198
+ textarea.addEventListener('input', () => {
199
+ textarea.style.height = 'auto';
200
+ textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
201
+ });
202
+ textarea.addEventListener('keydown', e => {
203
+ if (e.key === 'Enter' && !e.shiftKey) {
204
+ if (e.isComposing || e.keyCode === 229) {
205
+ return;
206
+ }
207
+ e.preventDefault();
208
+ sendChatMessage();
209
+ }
210
+ });
211
+ }
212
+
213
+ function scrollChatViewportToBottom() {
214
+ const viewport = document.getElementById('chat-viewport');
215
+ if (viewport) {
216
+ viewport.scrollTop = viewport.scrollHeight;
217
+ }
218
+ }
219
+
220
+ // 渲染右侧会话主工作区
221
+ async function renderChatWorkspace(conv) {
222
+ const workspace = document.getElementById('chat-workspace');
223
+ if (!workspace) return;
224
+
225
+ currentChatProjectName = conv.project_name || conv.project?.split('/').pop() || '';
226
+ pendingImages = [];
227
+
228
+ workspace.innerHTML = `
229
+ <!-- 会话头部 -->
230
+ <div class="chat-main-header">
231
+ <div class="chat-main-title-area">
232
+ <div class="chat-main-title" title="${esc(conv.name)}">${esc(conv.name)}</div>
233
+ <div class="chat-main-subtitle">
234
+ 项目: <strong style="color: var(--accent);">${esc(conv.project_name || conv.project?.split('/').pop() || '未知')}</strong> ·
235
+ 账号: <span>${esc(conv.account || '未指定')}</span> ·
236
+ 活跃: <span>${fmtTime(conv.updated)}</span>
237
+ </div>
238
+ </div>
239
+ <div style="display: flex; gap: 8px;" id="chat-header-actions">
240
+ <!-- 动态渲染终止和刷新按钮 -->
241
+ </div>
242
+ </div>
243
+
244
+ <!-- 对话渲染区 -->
245
+ <div class="chat-main-viewport" id="chat-viewport">
246
+ <div id="chat-content"></div>
247
+ </div>
248
+
249
+ <!-- 底部输入框 -->
250
+ ${buildChatInputHTML('输入您下一轮的指令... (按 Enter 发送,Shift+Enter 换行)', conv.isOneOff ? '一次性单任务 (发送消息升级为任务链)' : 'AI 连续问答 · 会话链模式')}
251
+ `;
252
+
253
+ const textarea = document.getElementById('chat-input');
254
+ bindChatTextareaEvents(textarea);
255
+
256
+ const chatContent = document.getElementById('chat-content');
257
+ chatContent.innerHTML = '<div style="color:var(--text-3);font-size:12px;padding:20px">正在加载会话历史...</div>';
258
+
259
+ chatRenderer = new ChatLogRenderer(chatContent);
260
+
261
+ try {
262
+ // 找出该会话下的所有 task,如果是一次性任务,过滤出这单条任务
263
+ const convTasks = conv.isOneOff
264
+ ? tasksCache.filter(t => t.id === conv.id.replace('task-', ''))
265
+ : tasksCache
266
+ .filter(t => t.conversation_id === conv.id)
267
+ .sort((a, b) => new Date(a.created || 0) - new Date(b.created || 0));
268
+
269
+ // 渲染头部动作按钮与状态文本
270
+ const headerActions = document.getElementById('chat-header-actions');
271
+ const runningTask = convTasks.find(t => t.status === 'running');
272
+ const pendingTask = convTasks.find(t => t.status === 'pending');
273
+ const scheduledTask = convTasks.find(t => t.status === 'scheduled');
274
+
275
+ let actionBtnHtml = '';
276
+ if (runningTask) {
277
+ currentChatTaskId = runningTask.id;
278
+ actionBtnHtml = `<button class="btn danger" onclick="killChatTask('${runningTask.id}')">终止执行</button>`;
279
+ } else if (pendingTask) {
280
+ currentChatTaskId = pendingTask.id;
281
+ actionBtnHtml = `<button class="btn danger" onclick="killChatTask('${pendingTask.id}')">取消排队</button>`;
282
+ } else if (scheduledTask) {
283
+ currentChatTaskId = scheduledTask.id;
284
+ actionBtnHtml = `<button class="btn danger" onclick="killChatTask('${scheduledTask.id}')">取消定时</button>`;
285
+ } else {
286
+ currentChatTaskId = null;
287
+ }
288
+
289
+ headerActions.innerHTML = `
290
+ ${actionBtnHtml}
291
+ <button class="btn" onclick="selectConversation('${conv.id}')">刷新</button>
292
+ `;
293
+
294
+ const runningCount = convTasks.filter(t => t.status === 'running').length;
295
+ const pendingCount = convTasks.filter(t => t.status === 'pending').length;
296
+ const scheduledCount = convTasks.filter(t => t.status === 'scheduled').length;
297
+
298
+ let statusHtml = '';
299
+ if (runningCount > 0) {
300
+ statusHtml = `<span style="color: var(--green); animation: pulse 1.5s infinite;">● AI 正在执行任务...</span>`;
301
+ if (pendingCount > 0) {
302
+ statusHtml += ` <span style="color: var(--text-2); font-size: 11px;">(${pendingCount}个任务在排队...)</span>`;
303
+ }
304
+ } else if (pendingCount > 0) {
305
+ statusHtml = `<span style="color: var(--text-2);">● 任务在排队中 (${pendingCount}个)</span>`;
306
+ } else if (scheduledCount > 0) {
307
+ statusHtml = `<span style="color: var(--text-2);">● 已定时待发送 (${scheduledCount}个)</span>`;
308
+ } else {
309
+ statusHtml = `<span style="color: var(--text-2);">就绪</span>`;
310
+ }
311
+ document.getElementById('chat-status-text').innerHTML = statusHtml;
312
+
313
+ if (convTasks.length === 0) {
314
+ chatContent.innerHTML = `<div class="empty">会话目前没有指令记录,发送消息开始交流。</div>`;
315
+ return;
316
+ }
317
+
318
+ // 并行获取所有的日志
319
+ const logPromises = convTasks.map(t =>
320
+ fetch(`${API}/api/tasks/${t.id}/logs`)
321
+ .then(r => r.text())
322
+ .catch(() => '')
323
+ );
324
+
325
+ const logs = await Promise.all(logPromises);
326
+ chatContent.innerHTML = '';
327
+
328
+ // 循环独立渲染每一个任务的提问和日志
329
+ convTasks.forEach((task, idx) => {
330
+ const logText = logs[idx];
331
+
332
+ // 1. 渲染用户提问蓝气泡
333
+ const userWrap = document.createElement('div');
334
+ userWrap.className = 'timeline-node-wrapper user-wrapper';
335
+ userWrap.innerHTML = `
336
+ <div class="user-bubble">
337
+ <div class="user-bubble-title-row">
338
+ <div class="user-bubble-title">你:</div>
339
+ <button class="user-copy-btn" onclick="copyUserBubble(this)" title="复制">${copyBtnSVG()}</button>
340
+ </div>
341
+ <div class="user-bubble-content">${esc(task.prompt)}</div>
342
+ </div>`;
343
+ chatContent.appendChild(userWrap);
344
+
345
+ // 2. 渲染日志输出的容器
346
+ const logWrap = document.createElement('div');
347
+ logWrap.style.marginBottom = '24px';
348
+ chatContent.appendChild(logWrap);
349
+
350
+ // 3. 构建局部的 ChatLogRenderer 并进行渲染
351
+ // 每一个任务在渲染时,均传入 foldProcess=true,把中间执行步骤折叠起来,把回复直接展现
352
+ const localRenderer = new ChatLogRenderer(logWrap, task.status === 'running', true);
353
+ if (task.status === 'pending') {
354
+ localRenderer.renderPending();
355
+ } else if (task.status === 'scheduled') {
356
+ localRenderer.renderScheduled(task.execute_at);
357
+ } else {
358
+ localRenderer.render(logText);
359
+ }
360
+
361
+ // 4. 如果是最后一个任务,把该 localRenderer 赋给全局 chatRenderer 方便 SSE 追加
362
+ if (idx === convTasks.length - 1) {
363
+ chatRenderer = localRenderer;
364
+ }
365
+ });
366
+
367
+ scrollChatViewportToBottom();
368
+
369
+ // 追踪任务选择:优先正在运行的任务,其次是第一个 pending 任务,最后是第一个 scheduled 任务
370
+ const activeTask = runningTask
371
+ || convTasks.find(t => t.status === 'pending')
372
+ || convTasks.find(t => t.status === 'scheduled');
373
+ if (activeTask) {
374
+ startChatFollow(activeTask.id);
375
+ }
376
+ } catch (e) {
377
+ chatContent.innerHTML = `<div style="color:var(--red);padding:16px">加载会话历史失败: ${esc(e.message)}<br><pre style="font-size:11px;color:var(--text-3);margin-top:8px">${esc(e.stack)}</pre></div>`;
378
+ }
379
+ }
380
+
381
+ // 发送消息及自动升级任务链
382
+ async function sendChatMessage() {
383
+ const textarea = document.getElementById('chat-input');
384
+ if (!textarea) return;
385
+ const promptText = textarea.value.trim();
386
+ if (!promptText) return;
387
+
388
+ const sendBtn = document.getElementById('chat-send-btn');
389
+ const autoMode = document.getElementById('chat-auto-mode')?.checked || false;
390
+
391
+ const schedCheckbox = document.getElementById('chat-schedule-checkbox');
392
+ const schedTimeInput = document.getElementById('chat-sched-time');
393
+ let executeAt = null;
394
+ if (schedCheckbox && schedCheckbox.checked && schedTimeInput && schedTimeInput.value) {
395
+ executeAt = schedTimeInput.value;
396
+ if (executeAt.includes(':') && executeAt.split(':').length === 2) {
397
+ executeAt += ':00';
398
+ }
399
+ }
400
+
401
+ let convId = activeConversationId;
402
+ let projectName = null;
403
+ let conversationName = null;
404
+
405
+ // 1. 新会话:在提交首条消息时,透明进行:提交 task -> 升级为 conversation 这一完整事务
406
+ if (!convId) {
407
+ projectName = chatNewSessionProject;
408
+ if (!projectName) {
409
+ alert('请先从左侧项目列表选择项目后再开始对话!');
410
+ return;
411
+ }
412
+ conversationName = promptText.substring(0, 20) + (promptText.length > 20 ? '...' : '');
413
+
414
+ sendBtn.disabled = true;
415
+ sendBtn.textContent = '建会话...';
416
+
417
+ try {
418
+ // 提交第一个任务并直接创建会话
419
+ const rTask = await fetch(`${API}/api/tasks`, {
420
+ method: 'POST',
421
+ headers: { 'Content-Type': 'application/json' },
422
+ body: JSON.stringify({
423
+ prompt: promptText,
424
+ project_name: projectName,
425
+ auto: autoMode,
426
+ conversation_name: conversationName,
427
+ images: pendingImages.map(i => i.container_path),
428
+ execute_at: executeAt,
429
+ })
430
+ });
431
+ const taskData = await rTask.json();
432
+ if (!rTask.ok) throw new Error(taskData.detail || rTask.statusText);
433
+
434
+ if (!taskData.conversation_id) {
435
+ throw new Error('后端未返回会话 ID');
436
+ }
437
+
438
+ activeConversationId = taskData.conversation_id;
439
+ textarea.value = '';
440
+ textarea.style.height = 'auto';
441
+ pendingImages = [];
442
+ renderImagePreviews();
443
+
444
+ if (schedCheckbox) schedCheckbox.checked = false;
445
+ toggleSchedTimeInput(false);
446
+ if (schedTimeInput) schedTimeInput.value = '';
447
+
448
+ sendBtn.disabled = false;
449
+ sendBtn.textContent = '发送';
450
+
451
+ await selectConversation(activeConversationId);
452
+ loadTasks();
453
+ loadProjectsDashboard();
454
+ return;
455
+ } catch (e) {
456
+ alert('开启会话失败: ' + e.message);
457
+ sendBtn.disabled = false;
458
+ sendBtn.textContent = '发送';
459
+ return;
460
+ }
461
+ }
462
+
463
+ // 2. 一次性任务升级为正式的任务链会话
464
+ if (convId && convId.startsWith('task-')) {
465
+ const taskId = convId.replace('task-', '');
466
+ const task = tasksCache.find(t => t.id === taskId);
467
+ if (!task) {
468
+ alert('未找到该一次性任务,无法升级会话!');
469
+ return;
470
+ }
471
+ sendBtn.disabled = true;
472
+ sendBtn.textContent = '升级会话...';
473
+
474
+ try {
475
+ const rConv = await fetch(`${API}/api/conversations`, {
476
+ method: 'POST',
477
+ headers: { 'Content-Type': 'application/json' },
478
+ body: JSON.stringify({
479
+ name: task.prompt.substring(0, 15) + (task.prompt.length > 15 ? '...' : ''),
480
+ task_id: taskId
481
+ })
482
+ });
483
+ const convData = await rConv.json();
484
+ if (!rConv.ok) throw new Error(convData.detail || rConv.statusText);
485
+
486
+ convId = convData.id;
487
+ activeConversationId = convId;
488
+ } catch (e) {
489
+ alert('升级任务链失败: ' + e.message);
490
+ sendBtn.disabled = false;
491
+ sendBtn.textContent = '发送';
492
+ return;
493
+ }
494
+ }
495
+
496
+ // 3. 正常发送追问消息
497
+ sendBtn.disabled = true;
498
+ sendBtn.textContent = '发送中...';
499
+
500
+ try {
501
+ const r = await fetch(`${API}/api/tasks`, {
502
+ method: 'POST',
503
+ headers: { 'Content-Type': 'application/json' },
504
+ body: JSON.stringify({
505
+ prompt: promptText,
506
+ auto: autoMode,
507
+ conversation_id: convId,
508
+ images: pendingImages.map(i => i.container_path),
509
+ execute_at: executeAt,
510
+ })
511
+ });
512
+ const data = await r.json();
513
+ if (!r.ok) throw new Error(data.detail || r.statusText);
514
+
515
+ textarea.value = '';
516
+ textarea.style.height = 'auto';
517
+ pendingImages = [];
518
+ renderImagePreviews();
519
+
520
+ if (schedCheckbox) schedCheckbox.checked = false;
521
+ toggleSchedTimeInput(false);
522
+ if (schedTimeInput) schedTimeInput.value = '';
523
+
524
+ sendBtn.disabled = false;
525
+ sendBtn.textContent = '发送';
526
+
527
+ await selectConversation(convId);
528
+ loadTasks();
529
+ loadProjectsDashboard();
530
+ } catch (e) {
531
+ alert('发送消息失败: ' + e.message);
532
+ sendBtn.disabled = false;
533
+ sendBtn.textContent = '发送';
534
+ }
535
+ }
536
+
537
+ // 实时日志 SSE 追踪
538
+ function startChatFollow(taskId) {
539
+ stopChatFollow();
540
+ chatFollowMode = true;
541
+ sseChatSource = new EventSource(`${API}/api/tasks/${taskId}/logs/stream?tail=0`);
542
+ sseChatSource.onmessage = e => {
543
+ if (e.data === '[DONE]') {
544
+ stopChatFollow();
545
+ if (activeConversationId) {
546
+ selectConversation(activeConversationId);
547
+ }
548
+ return;
549
+ }
550
+ if (chatRenderer) {
551
+ chatRenderer.push(e.data);
552
+ }
553
+ scrollChatViewportToBottom();
554
+ };
555
+ sseChatSource.onerror = () => stopChatFollow();
556
+ }
557
+
558
+ function stopChatFollow() {
559
+ chatFollowMode = false;
560
+ if (sseChatSource) {
561
+ sseChatSource.close();
562
+ sseChatSource = null;
563
+ }
564
+ }
565
+
566
+ async function archiveConversation(convId) {
567
+ try {
568
+ const r = await fetch(`${API}/api/conversations/${convId}`, {
569
+ method: 'PATCH',
570
+ headers: { 'Content-Type': 'application/json' },
571
+ body: JSON.stringify({ status: 'archived' }),
572
+ });
573
+ if (!r.ok) { const d = await r.json(); throw new Error(d.detail || r.statusText); }
574
+ if (activeConversationId === convId) {
575
+ activeConversationId = null;
576
+ currentChatTaskId = null;
577
+ }
578
+ loadConversations();
579
+ } catch (e) {
580
+ alert('归档失败: ' + e.message);
581
+ }
582
+ }
583
+
584
+ async function deleteConversation(convId) {
585
+ if (!confirm('确定要永久删除该对话吗?任务记录将保留,但对话链不可恢复。')) return;
586
+ try {
587
+ const r = await fetch(`${API}/api/conversations/${convId}`, { method: 'DELETE' });
588
+ if (!r.ok && r.status !== 204) { const d = await r.json(); throw new Error(d.detail || r.statusText); }
589
+ if (activeConversationId === convId) {
590
+ activeConversationId = null;
591
+ currentChatTaskId = null;
592
+ }
593
+ loadConversations();
594
+ } catch (e) {
595
+ alert('删除失败: ' + e.message);
596
+ }
597
+ }
598
+
599
+ async function killChatTask(taskId) {
600
+ if (!confirm('确定要终止当前 AI 任务的执行吗?')) return;
601
+ try {
602
+ const r = await fetch(`${API}/api/tasks/${taskId}`, { method: 'DELETE' });
603
+ if (!r.ok) {
604
+ const data = await r.json();
605
+ throw new Error(data.detail || r.statusText);
606
+ }
607
+ if (activeConversationId) {
608
+ selectConversation(activeConversationId);
609
+ }
610
+ loadTasks();
611
+ } catch (e) {
612
+ alert('终止失败: ' + e.message);
613
+ }
614
+ }
615
+
616
+ async function archiveOneOff(itemId) {
617
+ const taskId = itemId.replace('task-', '');
618
+ try {
619
+ const r = await fetch(`${API}/api/tasks/${taskId}`, {
620
+ method: 'PATCH',
621
+ headers: { 'Content-Type': 'application/json' },
622
+ body: JSON.stringify({ archived: true }),
623
+ });
624
+ if (!r.ok) { const d = await r.json(); throw new Error(d.detail || r.statusText); }
625
+ if (activeConversationId === itemId) {
626
+ activeConversationId = null;
627
+ currentChatTaskId = null;
628
+ }
629
+ loadConversations();
630
+ } catch (e) {
631
+ alert('归档失败: ' + e.message);
632
+ }
633
+ }
634
+
635
+ async function deleteOneOff(itemId) {
636
+ if (!confirm('确定要永久删除该一次性任务吗?任务记录与日志将全部被清除且不可恢复。')) return;
637
+ const taskId = itemId.replace('task-', '');
638
+ try {
639
+ const r = await fetch(`${API}/api/tasks/${taskId}/record`, { method: 'DELETE' });
640
+ if (!r.ok && r.status !== 204) { const d = await r.json(); throw new Error(d.detail || r.statusText); }
641
+ if (activeConversationId === itemId) {
642
+ activeConversationId = null;
643
+ currentChatTaskId = null;
644
+ }
645
+ loadConversations();
646
+ } catch (e) {
647
+ alert('删除失败: ' + e.message);
648
+ }
649
+ }
650
+
651
+ // ── 图片上传 ──────────────────────────────────────────────
652
+
653
+ function buildChatInputHTML(placeholder, hint) {
654
+ const imgIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
655
+ return `
656
+ <div class="chat-input-container">
657
+ <div id="chat-image-previews" class="chat-image-preview-row" style="display:none;"></div>
658
+ <div class="chat-input-row">
659
+ <input type="file" id="chat-file-input" accept="image/*" multiple style="display:none" onchange="handleImageSelect(this)">
660
+ <button class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" title="附加图片">${imgIcon}</button>
661
+ <textarea class="chat-input-textarea" id="chat-input" placeholder="${placeholder}" rows="1"></textarea>
662
+ <button class="btn primary" id="chat-send-btn" onclick="sendChatMessage()" style="min-height: 40px;">发送</button>
663
+ </div>
664
+ <div class="chat-input-actions">
665
+ <div class="chat-input-options">
666
+ <label class="toggle-row" style="cursor: pointer;">
667
+ <input type="checkbox" id="chat-auto-mode" checked> 全自动模式 (--dangerously-skip-permissions)
668
+ </label>
669
+ <span style="color: var(--border-md);">|</span>
670
+ <label class="toggle-row" style="cursor: pointer;">
671
+ <input type="checkbox" id="chat-schedule-checkbox" onchange="toggleSchedTimeInput(this.checked)"> 定时发送 ⏰
672
+ </label>
673
+ <span id="chat-sched-time-wrap" style="display: none; align-items: center; gap: 4px;">
674
+ <input type="datetime-local" id="chat-sched-time" style="background: var(--surface-2); border: 1px solid var(--border-md); color: var(--text); border-radius: var(--radius); font-size: 11px; padding: 2px 4px; outline: none; min-height: 24px; width: auto;">
675
+ </span>
676
+ <span style="color: var(--border-md);">|</span>
677
+ <span id="chat-status-text" style="color: var(--text-2);">就绪</span>
678
+ </div>
679
+ <div style="font-size: 11px; color: var(--text-3);">${hint}</div>
680
+ </div>
681
+ </div>`;
682
+ }
683
+
684
+ function toggleSchedTimeInput(checked) {
685
+ const el = document.getElementById('chat-sched-time-wrap');
686
+ if (el) {
687
+ el.style.display = checked ? 'inline-flex' : 'none';
688
+ }
689
+ }
690
+
691
+ async function handleImageSelect(input) {
692
+ const files = Array.from(input.files);
693
+ input.value = '';
694
+ if (!files.length) return;
695
+
696
+ if (!currentChatProjectName) {
697
+ alert('请先从左侧选择项目后再上传图片');
698
+ return;
699
+ }
700
+
701
+ for (const file of files) {
702
+ try {
703
+ const fd = new FormData();
704
+ fd.append('file', file);
705
+ const r = await fetch(
706
+ `${API}/api/uploads?project_name=${encodeURIComponent(currentChatProjectName)}`,
707
+ { method: 'POST', body: fd }
708
+ );
709
+ const data = await r.json();
710
+ if (!r.ok) throw new Error(data.detail || r.statusText);
711
+ pendingImages.push({
712
+ container_path: data.container_path,
713
+ preview_url: data.preview_url,
714
+ name: data.filename,
715
+ });
716
+ } catch (e) {
717
+ alert(`上传图片失败: ${e.message}`);
718
+ }
719
+ }
720
+ renderImagePreviews();
721
+ }
722
+
723
+ function renderImagePreviews() {
724
+ const container = document.getElementById('chat-image-previews');
725
+ if (!container) return;
726
+ if (pendingImages.length === 0) {
727
+ container.style.display = 'none';
728
+ container.innerHTML = '';
729
+ return;
730
+ }
731
+ container.style.display = 'flex';
732
+ container.innerHTML = pendingImages.map((img, i) => `
733
+ <div class="chat-image-thumb" title="${esc(img.name)}">
734
+ <img src="${API}${img.preview_url}" alt="${esc(img.name)}">
735
+ <button class="chat-image-thumb-remove" onclick="removePendingImage(${i})" title="移除">×</button>
736
+ </div>`).join('');
737
+ }
738
+
739
+ function removePendingImage(index) {
740
+ pendingImages.splice(index, 1);
741
+ renderImagePreviews();
742
+ }
743
+