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.
- coderfleet/__init__.py +1 -0
- coderfleet/__main__.py +4 -0
- coderfleet/cli.py +212 -0
- coderfleet/compose.py +176 -0
- coderfleet/config.py +69 -0
- coderfleet/config_cmds.py +243 -0
- coderfleet/data/Dockerfile +92 -0
- coderfleet/data/__init__.py +0 -0
- coderfleet/data/accounts.conf.example +26 -0
- coderfleet/data/config.conf.example +31 -0
- coderfleet/data/entrypoint.sh +56 -0
- coderfleet/data/projects.conf.example +17 -0
- coderfleet/data/scripts/coderfleet_usage_status.py +138 -0
- coderfleet/docker_ops.py +385 -0
- coderfleet/init_wizard.py +227 -0
- coderfleet/login_cmd.py +168 -0
- coderfleet/server/__init__.py +0 -0
- coderfleet/server/docker_mgr.py +45 -0
- coderfleet/server/main.py +546 -0
- coderfleet/server/models.py +285 -0
- coderfleet/server/scheduler.py +1219 -0
- coderfleet/server/static/css/main.css +2906 -0
- coderfleet/server/static/index.html +378 -0
- coderfleet/server/static/js/accounts.js +85 -0
- coderfleet/server/static/js/app.js +28 -0
- coderfleet/server/static/js/chat.js +743 -0
- coderfleet/server/static/js/log.js +145 -0
- coderfleet/server/static/js/nav.js +46 -0
- coderfleet/server/static/js/projects.js +298 -0
- coderfleet/server/static/js/renderer.js +586 -0
- coderfleet/server/static/js/state.js +76 -0
- coderfleet/server/static/js/submit.js +200 -0
- coderfleet/server/static/js/tasks.js +92 -0
- coderfleet/server/static/js/terminal.js +347 -0
- coderfleet/server/static/js/utils.js +147 -0
- coderfleet/server/static/vendor/marked.min.js +6 -0
- coderfleet/server/static/vendor/xterm/addon-fit.js +2 -0
- coderfleet/server/static/vendor/xterm/xterm.css +218 -0
- coderfleet/server/static/vendor/xterm/xterm.js +2 -0
- coderfleet/server/terminal.py +129 -0
- coderfleet/task_cmds.py +311 -0
- coderfleet-0.1.0.dist-info/METADATA +492 -0
- coderfleet-0.1.0.dist-info/RECORD +45 -0
- coderfleet-0.1.0.dist-info/WHEEL +4 -0
- coderfleet-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// ── 提交页辅助 ────────────────────────────────────────────
|
|
2
|
+
function populateConversations(conversations, tasks = [], projectName = "") {
|
|
3
|
+
const sel = document.getElementById('f-conversation');
|
|
4
|
+
const prev = sel.value;
|
|
5
|
+
const runningConvIds = new Set(tasks.filter(t => t.status === 'running' && t.conversation_id).map(t => t.conversation_id));
|
|
6
|
+
const filteredConversations = projectName
|
|
7
|
+
? conversations.filter(c => c.project_name === projectName || projectsCache.some(p => p.name === projectName && conversationBelongsToProject(c, p)))
|
|
8
|
+
: conversations;
|
|
9
|
+
sel.innerHTML = '<option value="">选择任务链</option>' +
|
|
10
|
+
filteredConversations.map(c => {
|
|
11
|
+
const running = runningConvIds.has(c.id);
|
|
12
|
+
const proj = c.project?.split('/').pop() || '';
|
|
13
|
+
return `<option value="${esc(c.id)}" data-running="${running}" ${running ? 'style="color:var(--red)"' : ''}>
|
|
14
|
+
${esc(c.name)} · ${esc(c.account)} · ${esc(proj)} · ${fmtTime(c.updated)}${running ? ' [运行中]' : ''}
|
|
15
|
+
</option>`;
|
|
16
|
+
}).join('');
|
|
17
|
+
sel.value = prev;
|
|
18
|
+
checkConvWarning();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function populateProjects(projects, lockedProjectName = "") {
|
|
22
|
+
const sel = document.getElementById('f-project');
|
|
23
|
+
const prev = sel.value;
|
|
24
|
+
const filteredProjects = lockedProjectName ? projects.filter(p => p.name === lockedProjectName) : projects;
|
|
25
|
+
sel.innerHTML = '<option value="">选择项目</option>' +
|
|
26
|
+
filteredProjects.map(p => `<option value="${esc(p.name)}">${esc(p.name)} · ${esc(p.account)} · ${esc(p.path.split('/').pop())}</option>`).join('');
|
|
27
|
+
sel.value = lockedProjectName || prev;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function moveSubmitPanel(slotId) {
|
|
31
|
+
const panel = document.getElementById('task-submit-panel');
|
|
32
|
+
const slot = document.getElementById(slotId);
|
|
33
|
+
if (panel && slot && panel.parentElement !== slot) slot.appendChild(panel);
|
|
34
|
+
return panel;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function openTaskSubmitPanel(options = {}) {
|
|
38
|
+
submitContext = { surface: 'task', projectName: options.projectName || '' };
|
|
39
|
+
showPage('tasks');
|
|
40
|
+
const panel = moveSubmitPanel('task-submit-slot');
|
|
41
|
+
document.getElementById('submit-modal').style.display = 'none';
|
|
42
|
+
document.getElementById('submit-panel-close-btn').style.display = '';
|
|
43
|
+
panel.style.display = '';
|
|
44
|
+
await loadAccountOptions();
|
|
45
|
+
if (options.mode) switchSubmitMode(options.mode);
|
|
46
|
+
if (options.projectName) document.getElementById('f-project').value = options.projectName;
|
|
47
|
+
if (options.conversationId) document.getElementById('f-conversation').value = options.conversationId;
|
|
48
|
+
if (options.focus !== false) document.getElementById('f-prompt').focus();
|
|
49
|
+
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function openProjectSubmitModal(options = {}) {
|
|
53
|
+
submitContext = { surface: 'project', projectName: options.projectName || projectContext?.name || '' };
|
|
54
|
+
const panel = moveSubmitPanel('submit-modal-slot');
|
|
55
|
+
const modal = document.getElementById('submit-modal');
|
|
56
|
+
const subtitle = document.getElementById('submit-modal-subtitle');
|
|
57
|
+
subtitle.textContent = submitContext.projectName ? `项目:${submitContext.projectName}` : '当前项目上下文';
|
|
58
|
+
document.getElementById('submit-panel-close-btn').style.display = 'none';
|
|
59
|
+
panel.style.display = '';
|
|
60
|
+
modal.style.display = '';
|
|
61
|
+
await loadAccountOptions();
|
|
62
|
+
switchSubmitMode(options.mode || 'one-off');
|
|
63
|
+
if (options.projectName) document.getElementById('f-project').value = options.projectName;
|
|
64
|
+
if (options.conversationId) document.getElementById('f-conversation').value = options.conversationId;
|
|
65
|
+
if (options.focus !== false) document.getElementById('f-prompt').focus();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function closeTaskSubmitPanel() {
|
|
69
|
+
document.getElementById('task-submit-panel').style.display = 'none';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function closeSubmitModal(e) {
|
|
73
|
+
if (e && e.target !== document.getElementById('submit-modal')) return;
|
|
74
|
+
document.getElementById('submit-modal').style.display = 'none';
|
|
75
|
+
submitContext = { surface: 'task', projectName: '' };
|
|
76
|
+
const panel = moveSubmitPanel('task-submit-slot');
|
|
77
|
+
document.getElementById('submit-panel-close-btn').style.display = '';
|
|
78
|
+
panel.style.display = 'none';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function closeSubmitSurface() {
|
|
82
|
+
if (document.getElementById('submit-modal').style.display !== 'none') closeSubmitModal();
|
|
83
|
+
else closeTaskSubmitPanel();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function loadAccountOptions() {
|
|
87
|
+
try {
|
|
88
|
+
const [accounts, convs, projects, tasks] = await Promise.all([
|
|
89
|
+
fetch(`${API}/api/accounts`).then(r => r.json()),
|
|
90
|
+
fetch(`${API}/api/conversations`).then(r => r.json()).catch(() => []),
|
|
91
|
+
fetch(`${API}/api/projects`).then(r => r.json()).catch(() => []),
|
|
92
|
+
fetch(`${API}/api/tasks?limit=100`).then(r => r.json()).catch(() => []),
|
|
93
|
+
]);
|
|
94
|
+
projectsCache = projects;
|
|
95
|
+
populateAccountFilters(accounts);
|
|
96
|
+
populateConversations(convs, tasks, submitContext.projectName);
|
|
97
|
+
populateProjects(projects, submitContext.projectName);
|
|
98
|
+
const projectGroup = document.getElementById('group-project');
|
|
99
|
+
projectGroup.style.display = submitContext.projectName ? 'none' : projectGroup.style.display;
|
|
100
|
+
} catch { }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function switchSubmitMode(mode) {
|
|
104
|
+
document.querySelectorAll('input[name="submit-mode"]').forEach(r => r.checked = r.value === mode);
|
|
105
|
+
document.getElementById('group-project').style.display = (!submitContext.projectName && (mode === 'one-off' || mode === 'new-chain')) ? '' : 'none';
|
|
106
|
+
document.getElementById('group-conversation').style.display = mode === 'resume' ? '' : 'none';
|
|
107
|
+
document.getElementById('group-conversation-name').style.display = mode === 'new-chain' ? '' : 'none';
|
|
108
|
+
if (mode === 'resume') checkConvWarning();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function checkConvWarning() {
|
|
112
|
+
const sel = document.getElementById('f-conversation');
|
|
113
|
+
const opt = sel.options[sel.selectedIndex];
|
|
114
|
+
const warn = document.getElementById('conversation-warning');
|
|
115
|
+
const btn = document.getElementById('submit-btn');
|
|
116
|
+
const running = opt?.getAttribute('data-running') === 'true';
|
|
117
|
+
warn.style.display = running ? '' : 'none';
|
|
118
|
+
if (!btn.classList.contains('submitting')) btn.disabled = running;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
document.getElementById('f-conversation').addEventListener('change', checkConvWarning);
|
|
122
|
+
|
|
123
|
+
// ── 提交任务 ──────────────────────────────────────────────
|
|
124
|
+
async function submitTask() {
|
|
125
|
+
const prompt = document.getElementById('f-prompt').value.trim();
|
|
126
|
+
if (!prompt) { alert('请填写任务描述'); return; }
|
|
127
|
+
|
|
128
|
+
const mode = document.querySelector('input[name="submit-mode"]:checked').value;
|
|
129
|
+
let conversationId = null, conversationName = null, projectName = null;
|
|
130
|
+
|
|
131
|
+
if (mode === 'one-off') {
|
|
132
|
+
projectName = document.getElementById('f-project').value || null;
|
|
133
|
+
if (!projectName) { alert('请选择项目'); return; }
|
|
134
|
+
} else if (mode === 'resume') {
|
|
135
|
+
conversationId = document.getElementById('f-conversation').value || null;
|
|
136
|
+
if (!conversationId) { alert('请选择任务链'); return; }
|
|
137
|
+
const opt = document.getElementById('f-conversation').options[document.getElementById('f-conversation').selectedIndex];
|
|
138
|
+
if (opt?.getAttribute('data-running') === 'true') { alert('该任务链有任务运行中!'); return; }
|
|
139
|
+
} else if (mode === 'new-chain') {
|
|
140
|
+
projectName = document.getElementById('f-project').value || null;
|
|
141
|
+
conversationName = document.getElementById('f-conversation-name').value.trim() || null;
|
|
142
|
+
if (!projectName) { alert('请选择项目'); return; }
|
|
143
|
+
if (!conversationName) { alert('请填写任务链名称'); return; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const btn = document.getElementById('submit-btn');
|
|
147
|
+
const msg = document.getElementById('submit-msg');
|
|
148
|
+
btn.disabled = true; btn.classList.add('submitting'); btn.textContent = '提交中...';
|
|
149
|
+
msg.style.display = 'none';
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const r = await fetch(`${API}/api/tasks`, {
|
|
153
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ prompt, project_name: projectName, auto: document.getElementById('f-auto').checked, conversation_id: conversationId, conversation_name: conversationName }),
|
|
155
|
+
});
|
|
156
|
+
const data = await r.json();
|
|
157
|
+
if (!r.ok) throw new Error(data.detail || r.statusText);
|
|
158
|
+
msg.style.display = '';
|
|
159
|
+
msg.innerHTML = `<div style="color:var(--green);font-weight:bold">已提交:${esc(data.id)}</div>
|
|
160
|
+
<div style="margin-top:8px"><button class="btn primary" onclick="openLogModal('${data.id}')">查看日志</button></div>`;
|
|
161
|
+
resetForm(false);
|
|
162
|
+
loadAccountOptions();
|
|
163
|
+
loadTasks();
|
|
164
|
+
if (projectContext) loadProjectsDashboard();
|
|
165
|
+
} catch (e) {
|
|
166
|
+
msg.style.display = '';
|
|
167
|
+
msg.innerHTML = `<div style="color:var(--red)">提交失败:${esc(e.message)}</div>`;
|
|
168
|
+
} finally {
|
|
169
|
+
btn.disabled = false; btn.classList.remove('submitting'); btn.textContent = '提交任务';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resetForm(clearMsg = true) {
|
|
174
|
+
['f-prompt', 'f-project', 'f-conversation', 'f-conversation-name'].forEach(id => { document.getElementById(id).value = ''; });
|
|
175
|
+
if (submitContext.projectName) document.getElementById('f-project').value = submitContext.projectName;
|
|
176
|
+
document.getElementById('f-auto').checked = true;
|
|
177
|
+
switchSubmitMode('one-off');
|
|
178
|
+
if (clearMsg) document.getElementById('submit-msg').style.display = 'none';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 终止任务 ──────────────────────────────────────────────
|
|
182
|
+
async function killTask(id) {
|
|
183
|
+
if (!confirm(`确认终止任务 ${id}?`)) return;
|
|
184
|
+
try { await fetch(`${API}/api/tasks/${id}`, { method: 'DELETE' }); loadTasks(); }
|
|
185
|
+
catch (e) { alert('终止失败:' + e.message); }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function killCurrentTask() {
|
|
189
|
+
if (!currentTaskId || !confirm(`确认终止任务 ${currentTaskId}?`)) return;
|
|
190
|
+
try { await fetch(`${API}/api/tasks/${currentTaskId}`, { method: 'DELETE' }); closeLogModal(); loadTasks(); }
|
|
191
|
+
catch (e) { alert('终止失败:' + e.message); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function cleanTasks() {
|
|
195
|
+
if (!confirm('清理旧记录(保留最近30条)?')) return;
|
|
196
|
+
try {
|
|
197
|
+
const r = await fetch(`${API}/api/tasks/clean`, { method: 'POST' }).then(r => r.json());
|
|
198
|
+
alert(`已清理 ${r.cleaned} 条`); loadTasks();
|
|
199
|
+
} catch (e) { alert('失败:' + e.message); }
|
|
200
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ── 全局缓存(accounts + conversations),低频刷新 ───────────
|
|
2
|
+
async function refreshGlobalCaches() {
|
|
3
|
+
try {
|
|
4
|
+
const [accounts, convs] = await Promise.all([
|
|
5
|
+
fetch(`${API}/api/accounts`).then(r => r.json()).catch(() => []),
|
|
6
|
+
fetch(`${API}/api/conversations`).then(r => r.json()).catch(() => []),
|
|
7
|
+
]);
|
|
8
|
+
globalAccountsCache = accounts;
|
|
9
|
+
convs.forEach(c => { conversationsCache[c.id] = c.name; });
|
|
10
|
+
if (currentPage === 'tasks') populateAccountFilters(accounts);
|
|
11
|
+
if (currentPage === 'accounts') {
|
|
12
|
+
renderAccounts(accounts);
|
|
13
|
+
populateAccountFilters(accounts);
|
|
14
|
+
}
|
|
15
|
+
} catch { }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── 任务列表 ──────────────────────────────────────────────
|
|
19
|
+
async function loadTasks(options = {}) {
|
|
20
|
+
if (options.resetPage) taskPage = 1;
|
|
21
|
+
const status = document.getElementById('filter-status').value;
|
|
22
|
+
const account = document.getElementById('filter-account').value;
|
|
23
|
+
let url = `${API}/api/tasks?limit=100`;
|
|
24
|
+
if (status) url += `&status=${status}`;
|
|
25
|
+
if (account) url += `&account=${encodeURIComponent(account)}`;
|
|
26
|
+
try {
|
|
27
|
+
const tasks = await fetch(url).then(r => r.json());
|
|
28
|
+
updateTaskMetrics(tasks, globalAccountsCache);
|
|
29
|
+
taskRowsCache = [...tasks].sort((a, b) => new Date(b.created || 0) - new Date(a.created || 0));
|
|
30
|
+
renderTasks(tasks);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
document.getElementById('task-tbody').innerHTML =
|
|
33
|
+
`<tr><td colspan="7"><div class="empty">加载失败:${esc(e.message)}</div></td></tr>`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderTasks(tasks) {
|
|
38
|
+
const tbody = document.getElementById('task-tbody');
|
|
39
|
+
const rows = taskRowsCache.length || tasks.length ? taskRowsCache : tasks;
|
|
40
|
+
const pageCount = Math.max(1, Math.ceil(rows.length / TASK_PAGE_SIZE));
|
|
41
|
+
taskPage = Math.min(Math.max(taskPage, 1), pageCount);
|
|
42
|
+
if (!rows.length) {
|
|
43
|
+
tbody.innerHTML = `<tr><td colspan="7"><div class="empty">暂无任务记录</div></td></tr>`;
|
|
44
|
+
renderTaskPagination(0, 0, 0);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const start = (taskPage - 1) * TASK_PAGE_SIZE;
|
|
48
|
+
const pageRows = rows.slice(start, start + TASK_PAGE_SIZE);
|
|
49
|
+
tbody.innerHTML = pageRows.map(t => {
|
|
50
|
+
const convName = t.conversation_id ? (conversationsCache[t.conversation_id] || t.conversation_id.slice(0, 8)) : '';
|
|
51
|
+
const convBadge = convName
|
|
52
|
+
? `<span class="chain-badge" title="任务链: ${esc(convName)}">${esc(convName)}</span>`
|
|
53
|
+
: '';
|
|
54
|
+
const dur = fmtDuration(t.created, t.finished);
|
|
55
|
+
return `
|
|
56
|
+
<tr onclick="openLogModal('${t.id}')">
|
|
57
|
+
<td><span class="status-dot ${t.status}">${statusLabel(t.status)}</span></td>
|
|
58
|
+
<td><div style="display:flex;align-items:center;max-width:360px">${convBadge}<div class="task-prompt" title="${esc(t.prompt)}">${esc(t.prompt)}</div></div></td>
|
|
59
|
+
<td><span class="badge ${t.type}">${t.type}</span> <span style="font-size:12px">${esc(t.account)}</span></td>
|
|
60
|
+
<td><div class="task-project" title="${esc(t.project)}">${esc(t.project.split('/').pop())}</div></td>
|
|
61
|
+
<td class="text-muted text-sm">${fmtTime(t.created)}</td>
|
|
62
|
+
<td class="duration-cell${t.status === 'running' ? ' running' : ''}">${dur}</td>
|
|
63
|
+
<td onclick="event.stopPropagation()">
|
|
64
|
+
${t.status === 'running' ? `<button class="btn danger" style="padding:3px 8px;font-size:12px" onclick="killTask('${t.id}')">终止</button>` : ''}
|
|
65
|
+
</td>
|
|
66
|
+
</tr>`;
|
|
67
|
+
}).join('');
|
|
68
|
+
renderTaskPagination(rows.length, start + 1, Math.min(start + TASK_PAGE_SIZE, rows.length));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderTaskPagination(total, start, end) {
|
|
72
|
+
const bar = document.getElementById('task-pagination');
|
|
73
|
+
if (!bar) return;
|
|
74
|
+
if (!total) {
|
|
75
|
+
bar.style.display = 'none';
|
|
76
|
+
bar.innerHTML = '';
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const pageCount = Math.max(1, Math.ceil(total / TASK_PAGE_SIZE));
|
|
80
|
+
bar.style.display = '';
|
|
81
|
+
bar.innerHTML = `<span>显示 ${start}-${end} / ${total} 条 · 第 ${taskPage} / ${pageCount} 页</span>
|
|
82
|
+
<div class="pagination-actions">
|
|
83
|
+
<button class="btn" style="font-size:12px" onclick="setTaskPage(${taskPage - 1})" ${taskPage <= 1 ? 'disabled' : ''}>上一页</button>
|
|
84
|
+
<button class="btn" style="font-size:12px" onclick="setTaskPage(${taskPage + 1})" ${taskPage >= pageCount ? 'disabled' : ''}>下一页</button>
|
|
85
|
+
</div>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setTaskPage(page) {
|
|
89
|
+
taskPage = page;
|
|
90
|
+
renderTasks(taskRowsCache);
|
|
91
|
+
}
|
|
92
|
+
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// ── 顶部多 Tab 终端系统 JS 实现 ──
|
|
2
|
+
let topbarTabs = [{ id: 'chat', label: '📁 AI 对话', closable: false }];
|
|
3
|
+
let activeTabId = 'chat';
|
|
4
|
+
let multiTerminalContexts = {}; // projectName -> { terminal, fitAddon, socket, connected }
|
|
5
|
+
|
|
6
|
+
// 初始化 Tab 系统
|
|
7
|
+
function initTopbarTabs() {
|
|
8
|
+
renderTopbarTabs();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// 渲染 Tabs
|
|
12
|
+
function renderTopbarTabs() {
|
|
13
|
+
const container = document.getElementById('topbar-tabs');
|
|
14
|
+
if (!container) return;
|
|
15
|
+
container.innerHTML = '';
|
|
16
|
+
|
|
17
|
+
topbarTabs.forEach(tab => {
|
|
18
|
+
const btn = document.createElement('button');
|
|
19
|
+
btn.className = 'tab-item' + (activeTabId === tab.id ? ' active' : '');
|
|
20
|
+
btn.type = 'button';
|
|
21
|
+
btn.onclick = () => switchTab(tab.id);
|
|
22
|
+
|
|
23
|
+
btn.innerHTML = `<span>${esc(tab.label)}</span>`;
|
|
24
|
+
|
|
25
|
+
if (tab.closable) {
|
|
26
|
+
const closeBtn = document.createElement('span');
|
|
27
|
+
closeBtn.className = 'tab-close';
|
|
28
|
+
closeBtn.innerHTML = '×';
|
|
29
|
+
closeBtn.onclick = (e) => {
|
|
30
|
+
e.stopPropagation();
|
|
31
|
+
closeTab(tab.id);
|
|
32
|
+
};
|
|
33
|
+
btn.appendChild(closeBtn);
|
|
34
|
+
}
|
|
35
|
+
container.appendChild(btn);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 切换 Tab
|
|
40
|
+
function switchTab(tabId) {
|
|
41
|
+
activeTabId = tabId;
|
|
42
|
+
renderTopbarTabs();
|
|
43
|
+
|
|
44
|
+
// 先隐藏所有 page 和多终端容器
|
|
45
|
+
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
46
|
+
document.querySelectorAll('.multi-term-container').forEach(c => c.classList.remove('active'));
|
|
47
|
+
|
|
48
|
+
// 显示顶级 Tab 状态下的 topbar-tabs 和 + 按钮
|
|
49
|
+
const tabsEl = document.getElementById('topbar-tabs');
|
|
50
|
+
const addBtn = document.getElementById('add-tab-btn');
|
|
51
|
+
if (tabsEl) tabsEl.style.display = '';
|
|
52
|
+
if (addBtn) addBtn.style.display = '';
|
|
53
|
+
|
|
54
|
+
if (tabId === 'chat') {
|
|
55
|
+
currentPage = 'chat';
|
|
56
|
+
document.getElementById('page-chat').classList.add('active');
|
|
57
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
58
|
+
document.querySelector('[data-page="chat"]').classList.add('active');
|
|
59
|
+
document.getElementById('page-title').textContent = '任务开发';
|
|
60
|
+
} else if (tabId.startsWith('terminal-')) {
|
|
61
|
+
currentPage = 'terminal';
|
|
62
|
+
const projectName = tabId.replace('terminal-', '');
|
|
63
|
+
document.getElementById('page-tab-terminal').classList.add('active');
|
|
64
|
+
|
|
65
|
+
// 激活对应的终端容器
|
|
66
|
+
const termContainer = document.getElementById(`term-container-${projectName}`);
|
|
67
|
+
if (termContainer) {
|
|
68
|
+
termContainer.classList.add('active');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 连接或重置该项目的终端
|
|
72
|
+
openMultiTerminal(projectName);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 关闭 Tab
|
|
77
|
+
function closeTab(tabId) {
|
|
78
|
+
const idx = topbarTabs.findIndex(t => t.id === tabId);
|
|
79
|
+
if (idx === -1) return;
|
|
80
|
+
|
|
81
|
+
const projectName = tabId.replace('terminal-', '');
|
|
82
|
+
// 断开该终端的 websocket
|
|
83
|
+
disconnectMultiTerminal(projectName);
|
|
84
|
+
|
|
85
|
+
// 移除 DOM 容器
|
|
86
|
+
const termContainer = document.getElementById(`term-container-${projectName}`);
|
|
87
|
+
if (termContainer) termContainer.remove();
|
|
88
|
+
|
|
89
|
+
topbarTabs.splice(idx, 1);
|
|
90
|
+
|
|
91
|
+
// 如果关闭的是当前激活的 Tab,则自动切回前一个 Tab 或者是 'chat'
|
|
92
|
+
if (activeTabId === tabId) {
|
|
93
|
+
const nextActiveId = topbarTabs[idx - 1] ? topbarTabs[idx - 1].id : 'chat';
|
|
94
|
+
switchTab(nextActiveId);
|
|
95
|
+
} else {
|
|
96
|
+
renderTopbarTabs();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 显示添加 Tab 的项目弹出菜单
|
|
101
|
+
function showAddTabMenu(event) {
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
// 移除可能存在的旧菜单
|
|
104
|
+
const oldMenu = document.querySelector('.add-tab-menu');
|
|
105
|
+
if (oldMenu) oldMenu.remove();
|
|
106
|
+
|
|
107
|
+
const menu = document.createElement('div');
|
|
108
|
+
menu.className = 'add-tab-menu';
|
|
109
|
+
|
|
110
|
+
// 计算定位
|
|
111
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
112
|
+
menu.style.left = `${rect.left}px`;
|
|
113
|
+
menu.style.top = `${rect.bottom + window.scrollY + 6}px`;
|
|
114
|
+
|
|
115
|
+
// 获取当前所有的项目,展示为菜单项
|
|
116
|
+
const projects = projectDashboardData?.projects || [];
|
|
117
|
+
if (projects.length === 0) {
|
|
118
|
+
menu.innerHTML = `<div class="add-tab-menu-header">暂无项目</div>`;
|
|
119
|
+
} else {
|
|
120
|
+
menu.innerHTML = `<div class="add-tab-menu-header">连接项目容器终端</div>`;
|
|
121
|
+
projects.forEach(p => {
|
|
122
|
+
const btn = document.createElement('button');
|
|
123
|
+
btn.className = 'add-tab-menu-item';
|
|
124
|
+
btn.innerHTML = `💻 终端: ${esc(p.name)}`;
|
|
125
|
+
btn.onclick = () => {
|
|
126
|
+
addTerminalTab(p.name);
|
|
127
|
+
menu.remove();
|
|
128
|
+
};
|
|
129
|
+
menu.appendChild(btn);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
document.body.appendChild(menu);
|
|
134
|
+
|
|
135
|
+
// 点击外部时关闭菜单
|
|
136
|
+
const closeMenu = (e) => {
|
|
137
|
+
if (!menu.contains(e.target) && e.target !== event.currentTarget) {
|
|
138
|
+
menu.remove();
|
|
139
|
+
document.removeEventListener('click', closeMenu);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 新增终端 Tab
|
|
146
|
+
function addTerminalTab(projectName) {
|
|
147
|
+
const tabId = `terminal-${projectName}`;
|
|
148
|
+
const exists = topbarTabs.some(t => t.id === tabId);
|
|
149
|
+
|
|
150
|
+
if (!exists) {
|
|
151
|
+
topbarTabs.push({
|
|
152
|
+
id: tabId,
|
|
153
|
+
label: `💻 终端: ${projectName}`,
|
|
154
|
+
closable: true
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// 创建对应的终端容器
|
|
158
|
+
const pageContainer = document.getElementById('page-tab-terminal');
|
|
159
|
+
const termContainer = document.createElement('div');
|
|
160
|
+
termContainer.className = 'multi-term-container';
|
|
161
|
+
termContainer.id = `term-container-${projectName}`;
|
|
162
|
+
termContainer.innerHTML = `
|
|
163
|
+
<div class="terminal-card" style="height: 100%; border: none;">
|
|
164
|
+
<div class="terminal-toolbar">
|
|
165
|
+
<div class="terminal-status">
|
|
166
|
+
<span class="status-dot killed" id="term-dot-${projectName}">未连接</span>
|
|
167
|
+
<span class="terminal-status-text" id="term-status-${projectName}">点击或切换到此 Tab 后连接项目容器</span>
|
|
168
|
+
</div>
|
|
169
|
+
<button class="btn" type="button" onclick="reconnectMultiTerminal('${projectName}')">重新连接</button>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="terminal-warning" id="term-warning-${projectName}"></div>
|
|
172
|
+
<div class="terminal-mount" id="term-mount-${projectName}" style="flex: 1; height: calc(100% - 50px);"></div>
|
|
173
|
+
</div>
|
|
174
|
+
`;
|
|
175
|
+
pageContainer.appendChild(termContainer);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
switchTab(tabId);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 多终端实例化和 WebSocket 连接逻辑 ──
|
|
182
|
+
function openMultiTerminal(projectName) {
|
|
183
|
+
updateMultiTerminalWarning(projectName);
|
|
184
|
+
let ctx = multiTerminalContexts[projectName];
|
|
185
|
+
if (!ctx) {
|
|
186
|
+
ctx = {
|
|
187
|
+
terminal: null,
|
|
188
|
+
fitAddon: null,
|
|
189
|
+
socket: null,
|
|
190
|
+
connected: false
|
|
191
|
+
};
|
|
192
|
+
multiTerminalContexts[projectName] = ctx;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!ctx.socket || ctx.socket.readyState === WebSocket.CLOSED) {
|
|
196
|
+
connectMultiTerminal(projectName);
|
|
197
|
+
} else {
|
|
198
|
+
resizeMultiTerminal(projectName);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ensureMultiTerminalMounted(projectName) {
|
|
203
|
+
const mount = document.getElementById(`term-mount-${projectName}`);
|
|
204
|
+
if (!mount || !window.Terminal || !window.FitAddon) return false;
|
|
205
|
+
|
|
206
|
+
let ctx = multiTerminalContexts[projectName];
|
|
207
|
+
if (ctx && ctx.terminal && mount.childElementCount) return true;
|
|
208
|
+
|
|
209
|
+
mount.innerHTML = '';
|
|
210
|
+
ctx.fitAddon = new window.FitAddon.FitAddon();
|
|
211
|
+
ctx.terminal = new window.Terminal({
|
|
212
|
+
cursorBlink: true,
|
|
213
|
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
|
214
|
+
fontSize: 13,
|
|
215
|
+
scrollback: 5000,
|
|
216
|
+
theme: {
|
|
217
|
+
background: '#111827',
|
|
218
|
+
foreground: '#e5e7eb',
|
|
219
|
+
cursor: '#f97316',
|
|
220
|
+
selectionBackground: '#334155',
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
ctx.terminal.loadAddon(ctx.fitAddon);
|
|
224
|
+
ctx.terminal.open(mount);
|
|
225
|
+
ctx.terminal.onData(data => {
|
|
226
|
+
if (ctx.socket && ctx.socket.readyState === WebSocket.OPEN) {
|
|
227
|
+
ctx.socket.send(JSON.stringify({ type: 'input', data }));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// 窗口大小变化时自适应
|
|
232
|
+
window.removeEventListener('resize', ctx.resizeHandler);
|
|
233
|
+
ctx.resizeHandler = () => resizeMultiTerminal(projectName);
|
|
234
|
+
window.addEventListener('resize', ctx.resizeHandler);
|
|
235
|
+
|
|
236
|
+
resizeMultiTerminal(projectName);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function connectMultiTerminal(projectName) {
|
|
241
|
+
disconnectMultiTerminal(projectName);
|
|
242
|
+
|
|
243
|
+
let ctx = multiTerminalContexts[projectName];
|
|
244
|
+
if (!ctx) {
|
|
245
|
+
ctx = { terminal: null, fitAddon: null, socket: null, connected: false };
|
|
246
|
+
multiTerminalContexts[projectName] = ctx;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!ensureMultiTerminalMounted(projectName)) {
|
|
250
|
+
setMultiTerminalStatus(projectName, 'error', '终端资源未加载,请刷新页面后重试');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
updateMultiTerminalWarning(projectName);
|
|
255
|
+
setMultiTerminalStatus(projectName, 'closed', '正在连接项目容器...');
|
|
256
|
+
ctx.terminal.clear();
|
|
257
|
+
ctx.terminal.writeln(`Connecting to terminal of project container [${projectName}]...`);
|
|
258
|
+
|
|
259
|
+
const socket = new WebSocket(terminalWsUrl(projectName));
|
|
260
|
+
ctx.socket = socket;
|
|
261
|
+
|
|
262
|
+
socket.addEventListener('open', () => {
|
|
263
|
+
ctx.connected = true;
|
|
264
|
+
resizeMultiTerminal(projectName);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
socket.addEventListener('message', (e) => {
|
|
268
|
+
const d = JSON.parse(e.data);
|
|
269
|
+
if (d.type === 'output') {
|
|
270
|
+
ctx.terminal.write(d.data);
|
|
271
|
+
} else if (d.type === 'status') {
|
|
272
|
+
setMultiTerminalStatus(projectName, d.status, d.message);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
socket.addEventListener('close', () => {
|
|
277
|
+
ctx.connected = false;
|
|
278
|
+
setMultiTerminalStatus(projectName, 'closed', '连接已断开');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
socket.addEventListener('error', () => {
|
|
282
|
+
ctx.connected = false;
|
|
283
|
+
setMultiTerminalStatus(projectName, 'error', '连接出错');
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function disconnectMultiTerminal(projectName) {
|
|
288
|
+
const ctx = multiTerminalContexts[projectName];
|
|
289
|
+
if (!ctx) return;
|
|
290
|
+
if (ctx.socket) {
|
|
291
|
+
ctx.socket.close();
|
|
292
|
+
ctx.socket = null;
|
|
293
|
+
}
|
|
294
|
+
ctx.connected = false;
|
|
295
|
+
setMultiTerminalStatus(projectName, 'closed', '未连接');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function reconnectMultiTerminal(projectName) {
|
|
299
|
+
connectMultiTerminal(projectName);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resizeMultiTerminal(projectName) {
|
|
303
|
+
const ctx = multiTerminalContexts[projectName];
|
|
304
|
+
if (!ctx || !ctx.terminal || !ctx.fitAddon) return;
|
|
305
|
+
|
|
306
|
+
// 延迟确保 DOM 已经完全渲染
|
|
307
|
+
setTimeout(() => {
|
|
308
|
+
try {
|
|
309
|
+
ctx.fitAddon.fit();
|
|
310
|
+
const dims = ctx.fitAddon.proposeDimensions();
|
|
311
|
+
if (dims && ctx.socket && ctx.socket.readyState === WebSocket.OPEN) {
|
|
312
|
+
ctx.socket.send(JSON.stringify({
|
|
313
|
+
type: 'resize',
|
|
314
|
+
cols: dims.cols,
|
|
315
|
+
rows: dims.rows
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.warn('fit xterm error', e);
|
|
320
|
+
}
|
|
321
|
+
}, 50);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function setMultiTerminalStatus(projectName, status, message) {
|
|
325
|
+
const dot = document.getElementById(`term-dot-${projectName}`);
|
|
326
|
+
const txt = document.getElementById(`term-status-${projectName}`);
|
|
327
|
+
if (!dot || !txt) return;
|
|
328
|
+
|
|
329
|
+
dot.className = 'status-dot ' + (status === 'connected' ? 'running' : 'killed');
|
|
330
|
+
dot.textContent = status === 'connected' ? '已连接' : (status === 'error' ? '错误' : '未连接');
|
|
331
|
+
txt.textContent = message;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function updateMultiTerminalWarning(projectName) {
|
|
335
|
+
const warning = document.getElementById(`term-warning-${projectName}`);
|
|
336
|
+
if (!warning) return;
|
|
337
|
+
|
|
338
|
+
// 可以获取当前项目下是否有正在运行的任务
|
|
339
|
+
const running = projectDashboardData?.tasks?.some(t => taskBelongsToProject(t, { name: projectName }) && t.status === 'running');
|
|
340
|
+
if (running) {
|
|
341
|
+
warning.textContent = '警告:当前项目有后台任务正在运行,在终端手动执行命令可能会干扰其运行。';
|
|
342
|
+
warning.classList.add('active');
|
|
343
|
+
} else {
|
|
344
|
+
warning.textContent = '';
|
|
345
|
+
warning.classList.remove('active');
|
|
346
|
+
}
|
|
347
|
+
}
|