feat(docker): 完善龙虾军团任务调度与提示

This commit is contained in:
晴天
2026-03-10 00:11:47 +08:00
parent 0f899e38ae
commit 117de4665d
6 changed files with 1493 additions and 1108 deletions

View File

@@ -7,6 +7,10 @@ import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
import { icon } from '../lib/icons.js'
import { pixelRole, pixelBarracks } from '../lib/pixel-roles.js'
import { getActiveInstance, switchInstance } from '../lib/app-state.js'
import { renderSidebar } from '../components/sidebar.js'
import { reloadCurrentRoute } from '../router.js'
import { DOCKER_TASK_TIMEOUT_MS, buildDockerDispatchTargets, buildDockerInstanceSwitchContext } from '../lib/docker-tasking.js'
function esc(str) {
if (!str) return ''
@@ -89,6 +93,7 @@ function roleIcon(role, size = 14) {
}
let _refreshTimer = null
let _workspaceTimer = null
let _lastContainers = []
export async function render() {
@@ -115,13 +120,24 @@ export async function render() {
</div>
</div>
<div class="task-pick-bar" id="task-pick" style="display:none"></div>
<div class="task-beta-note">
<span class="task-beta-icon">${icon('alert-triangle', 12)}</span>
<span>测试功能,当前能力与稳定性仍在完善中。</span>
</div>
<div class="task-input-row">
<textarea class="task-input" id="task-input" rows="1" placeholder="输入任务指令,分配给龙虾兵执行..."></textarea>
<button class="task-send-btn" id="task-send" disabled>${icon('send', 16)}</button>
</div>
</div>
<div class="task-results" id="task-results"></div>
<div class="task-workspace" id="task-workspace" style="display:none">
<div class="workspace-header">
<span class="workspace-title">${icon('activity', 14)} 异步工作区</span>
<button class="btn btn-sm" data-action="workspace-clear">${icon('x', 12)} 清空历史</button>
</div>
<div class="workspace-workers" id="workspace-workers"></div>
<div class="workspace-history" id="workspace-history"></div>
</div>
<div class="section-bar">
<span class="section-title">${icon('swords', 14)} 兵力部署</span>
@@ -129,9 +145,12 @@ export async function render() {
<div class="batch-actions" id="batch-actions" style="display:none">
<label class="batch-select-all"><input type="checkbox" id="ct-select-all"/> 全选</label>
<span class="batch-count" id="batch-count">0 已选</span>
<button class="btn btn-sm" data-action="batch-start">${icon('play', 12)} 出征</button>
<button class="btn btn-sm" data-action="batch-stop">${icon('stop', 12)} 休整</button>
<button class="btn btn-sm" data-action="batch-restart">${icon('refresh-cw', 12)} 整编</button>
<button class="btn btn-sm batch-btn" data-action="batch-start" disabled>${icon('play', 12)} 出征</button>
<button class="btn btn-sm batch-btn" data-action="batch-stop" disabled>${icon('stop', 12)} 休整</button>
<button class="btn btn-sm batch-btn" data-action="batch-restart" disabled>${icon('refresh-cw', 12)} 整编</button>
<button class="btn btn-sm batch-btn" data-action="batch-sync" disabled>${icon('upload', 12)} 同步配置</button>
<button class="btn btn-sm batch-btn" data-action="batch-rebuild" disabled>${icon('hammer', 12)} 重建</button>
<button class="btn btn-sm batch-btn danger" data-action="batch-remove" disabled>${icon('trash', 12)} 退役</button>
</div>
</div>
</div>
@@ -154,6 +173,7 @@ export async function render() {
export function cleanup() {
if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null }
if (_workspaceTimer) { clearInterval(_workspaceTimer); _workspaceTimer = null }
}
async function loadClusterOverview(page) {
@@ -288,7 +308,11 @@ function _renderUnitCard(c, showAdopt) {
</div>`
}
return `<div class="unit-card ${stateClass}" style="--unit-color:${roleInfo.color}">
// 检查是否为当前管理的活跃实例
const activeInst = getActiveInstance()
const isActive = activeInst.type === 'docker' && activeInst.id === `docker-${c.id.slice(0, 12)}`
return `<div class="unit-card ${stateClass}${isActive ? ' active-instance' : ''}" style="--unit-color:${roleInfo.color}">
<div class="unit-card-select">
<input type="checkbox" class="ct-select" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-state="${esc(c.state)}"/>
</div>
@@ -298,6 +322,12 @@ function _renderUnitCard(c, showAdopt) {
<div class="unit-name">${esc(c.name)}</div>
<div class="unit-role">${icon(roleInfo.iconName, 12)} ${roleInfo.title}${roleInfo.desc}</div>
</div>
${isActive
? `<span class="unit-active-tag">${icon('monitor', 10)} 管理中</span>`
: isRunning && ports.panel
? `<button class="btn btn-xs unit-switch-btn" data-action="switch-instance" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-name="${esc(c.name)}" data-port="${ports.panel}" data-gateway-port="${esc(ports.gateway || '')}">${icon('arrow-right', 10)} 切换管理</button>`
: ''
}
<span class="unit-state ${stateClass}">${isRunning ? icon('swords', 12) + ' 出征中' : icon('tent', 12) + ' 休整中'}</span>
</div>
${isRunning && (ports.panel || ports.gateway) ? `
@@ -315,6 +345,7 @@ function _renderUnitCard(c, showAdopt) {
<button class="btn-icon" data-action="sync-config" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-name="${esc(c.name)}" data-role="${esc(role)}" title="同步配置API Key + 性格 + 记忆)">${icon('upload', 14)}</button>`
: `<button class="btn-icon" data-action="start" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="出征">${icon('play', 14)}</button>`
}
<button class="btn-icon" data-action="rebuild" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" data-name="${esc(c.name)}" title="重建(拉取最新镜像重新创建)">${icon('hammer', 14)}</button>
<button class="btn-icon" data-action="inspect" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="军情">${icon('search', 14)}</button>
<button class="btn-icon" data-action="logs" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="战报">${icon('clipboard', 14)}</button>
${isAdopted ? `<button class="btn-icon" data-action="unadopt" data-ct="${esc(c.id)}" title="脱编">${icon('x', 14)}</button>` : ''}
@@ -346,9 +377,10 @@ function renderWorkers(page, nodes) {
el.innerHTML = `<div class="unit-grid">${managed.map(c => _renderUnitCard(c, false)).join('')}</div>`
// 有军团成员时显示批量操作栏
// 有军团成员时显示批量操作栏 + 重置全选状态
const batchEl = page.querySelector('#batch-actions')
if (batchEl) batchEl.style.display = managed.length > 0 ? 'flex' : 'none'
_updateBatchUI(page)
}
function renderOthers(page, nodes) {
@@ -426,17 +458,6 @@ function _smartRoute(command) {
return _runningWorkers.length > 0 ? [_runningWorkers[0]] : []
}
async function _sendToWorker(worker, command, onChunk) {
if (!worker.id) throw new Error(`${worker.name} 缺少容器 ID`)
if (onChunk) onChunk('⏳ 连接 Gateway...')
try {
const resp = await api.dockerGatewayChat(worker.nodeId || null, worker.id, command)
return resp?.result || '(无回复)'
} catch (e) {
throw new Error(`${worker.name}: ${e.message}`)
}
}
function initTaskHub(page) {
const input = page.querySelector('#task-input')
const sendBtn = page.querySelector('#task-send')
@@ -486,71 +507,216 @@ function initTaskHub(page) {
if (targets.length === 0) { toast('没有可用的目标', 'error'); return }
// 渲染结果区
const resultsEl = page.querySelector('#task-results')
resultsEl.style.display = ''
resultsEl.innerHTML = `
<div class="task-results-header">
<span>${icon('clipboard', 14)} 任务执行 · ${targets.length} 个目标</span>
<span class="task-results-mode">${currentMode === 'broadcast' ? '全体广播' : currentMode === 'smart' ? '智能分配' : '指定成员'}</span>
</div>
<div class="task-results-grid">
${targets.map(w => {
const r = MILITARY.roles[w.role]
return `<div class="task-result-card" id="result-${w.id}">
<div class="task-result-header" style="--worker-color:${r.color}">
${pixelRole(w.role, 20)}
<span class="task-result-name">${esc(w.name.replace(/^openclaw-/, ''))}</span>
<span class="task-result-status pending">${icon('radio', 10)} 等待中</span>
</div>
<div class="task-result-body"><span class="typing-cursor"></span></div>
</div>`
}).join('')}
</div>
`
// 异步派发 — 立即返回,不阻塞 UI
sendBtn.disabled = true
input.disabled = true
try {
const dispatchTargets = buildDockerDispatchTargets(targets)
await api.dockerDispatchBroadcast(null, dispatchTargets, command, DOCKER_TASK_TIMEOUT_MS)
toast(`任务已派发给 ${targets.length} 名龙虾兵`, 'success')
} catch (e) {
toast(`派发失败: ${e.message}`, 'error')
sendBtn.disabled = false
return
}
// 并发发送到所有目标
const promises = targets.map(async (w) => {
const card = resultsEl.querySelector(`#result-${w.id}`)
const statusEl = card?.querySelector('.task-result-status')
const bodyEl = card?.querySelector('.task-result-body')
if (statusEl) { statusEl.className = 'task-result-status running'; statusEl.innerHTML = `${icon('zap', 10)} 执行中` }
try {
const result = await _sendToWorker(w, command, (partial) => {
if (bodyEl) bodyEl.textContent = partial
bodyEl?.scrollTo(0, bodyEl.scrollHeight)
})
if (bodyEl) bodyEl.textContent = result || '(无回复)'
if (statusEl) { statusEl.className = 'task-result-status done'; statusEl.innerHTML = `${icon('check-circle', 10)} 完成` }
} catch (e) {
if (bodyEl) bodyEl.textContent = `失败: ${e.message}`
if (statusEl) { statusEl.className = 'task-result-status error'; statusEl.innerHTML = `${icon('x-circle', 10)} 失败` }
}
})
await Promise.allSettled(promises)
sendBtn.disabled = false
input.disabled = false
// 清空输入,启动工作区轮询
input.value = ''
input.style.height = 'auto'
input.focus()
sendBtn.disabled = !input.value.trim() || _runningWorkers.length === 0
_startWorkspacePolling(page)
_refreshWorkspace(page)
}
}
// === 异步工作区 ===
function _startWorkspacePolling(page) {
if (_workspaceTimer) return
_workspaceTimer = setInterval(() => _refreshWorkspace(page), 3000)
}
function _stopWorkspacePolling() {
if (_workspaceTimer) { clearInterval(_workspaceTimer); _workspaceTimer = null }
}
async function _refreshWorkspace(page) {
const wsEl = page.querySelector('#task-workspace')
if (!wsEl) return
try {
const tasks = await api.dockerTaskList()
if (!tasks || tasks.length === 0) {
wsEl.style.display = 'none'
_stopWorkspacePolling()
return
}
wsEl.style.display = ''
_renderWorkspaceWorkers(page, tasks)
_renderWorkspaceHistory(page, tasks)
// 没有正在运行的任务时停止轮询
const hasRunning = tasks.some(t => t.status === 'running')
if (!hasRunning) _stopWorkspacePolling()
} catch (e) {
console.warn('[workspace] 刷新失败:', e.message)
}
}
function _renderWorkspaceWorkers(page, tasks) {
const el = page.querySelector('#workspace-workers')
if (!el) return
// 用 containerId 去重,取每个容器最新的任务
const latestByContainer = new Map()
for (const t of tasks) {
if (!latestByContainer.has(t.containerId) || t.startedAt > latestByContainer.get(t.containerId).startedAt) {
latestByContainer.set(t.containerId, t)
}
}
// 只展示有任务的工人
const workers = [...latestByContainer.values()]
if (workers.length === 0) { el.innerHTML = ''; return }
el.innerHTML = `<div class="ws-worker-grid">${workers.map(t => {
const role = MILITARY.inferRole(t.containerName)
const r = MILITARY.roles[role] || MILITARY.roles.general
const shortName = (t.containerName || '').replace(/^openclaw-/, '')
const isRunning = t.status === 'running'
const isError = t.status === 'error'
const elapsed = t.elapsed ? (t.elapsed / 1000).toFixed(0) : '0'
const msgPreview = (t.message || '').slice(0, 40) + ((t.message || '').length > 40 ? '...' : '')
return `<div class="ws-worker ${isRunning ? 'working' : 'idle'}" data-task-id="${esc(t.id)}" style="--worker-color:${r.color}">
<div class="ws-worker-top">
${pixelRole(role, 28)}
<div class="ws-worker-info">
<div class="ws-worker-name">${esc(shortName)}</div>
<div class="ws-worker-role">${r.title}${r.desc}</div>
</div>
<div class="ws-worker-badge ${isRunning ? 'running' : isError ? 'error' : 'done'}">
${isRunning ? `${icon('zap', 10)} 工作中` : isError ? `${icon('x-circle', 10)} 失败` : `${icon('check-circle', 10)} 完成`}
</div>
</div>
<div class="ws-worker-task">
<div class="ws-worker-msg">${icon('message-square', 10)} ${esc(msgPreview)}</div>
<div class="ws-worker-time">${isRunning ? `${elapsed}s...` : `${elapsed}s`}</div>
</div>
</div>`
}).join('')}</div>`
}
function _renderWorkspaceHistory(page, tasks) {
const el = page.querySelector('#workspace-history')
if (!el) return
// 只显示已完成/失败的任务
const finished = tasks.filter(t => t.status !== 'running')
if (finished.length === 0) { el.innerHTML = ''; return }
el.innerHTML = `
<div class="ws-history-title">${icon('clock', 12)} 任务记录</div>
<div class="ws-history-list">
${finished.map(t => {
const shortName = (t.containerName || '').replace(/^openclaw-/, '')
const elapsed = t.elapsed ? (t.elapsed / 1000).toFixed(1) : '0'
const msgPreview = (t.message || '').slice(0, 50) + ((t.message || '').length > 50 ? '...' : '')
const isError = t.status === 'error'
const time = new Date(t.startedAt)
const timeStr = `${time.getHours().toString().padStart(2,'0')}:${time.getMinutes().toString().padStart(2,'0')}`
return `<div class="ws-history-item ${isError ? 'error' : 'done'}" data-task-id="${esc(t.id)}">
<span class="ws-history-icon">${isError ? icon('x-circle', 12) : icon('check-circle', 12)}</span>
<span class="ws-history-name">${esc(shortName)}</span>
<span class="ws-history-msg">${esc(msgPreview)}</span>
<span class="ws-history-meta">${elapsed}s · ${timeStr}</span>
${t.hasResult ? `<button class="btn btn-xs btn-secondary ws-history-view" data-task-id="${esc(t.id)}">查看结果</button>` : ''}
</div>`
}).join('')}
</div>
`
}
async function _showTaskDetail(page, taskId) {
try {
const task = await api.dockerTaskStatus(taskId)
if (!task) { toast('任务不存在', 'error'); return }
const shortName = (task.containerName || '').replace(/^openclaw-/, '')
const elapsed = task.elapsed ? (task.elapsed / 1000).toFixed(1) : '0'
const isError = task.status === 'error'
// 提取结果文本
let resultText = ''
if (task.result?.result) {
resultText = task.result.result
} else if (task.error) {
resultText = `错误: ${task.error}`
} else if (task.events?.length) {
const finals = task.events.filter(e => e.type === 'final' || e.type === 'result')
resultText = finals.map(e => e.text || e.message || JSON.stringify(e)).join('\n')
}
if (!resultText) resultText = '(无回复)'
// 提取工具调用日志
const toolCalls = (task.events || []).filter(e => e.type === 'tool_call' || e.type === 'tool_result')
const toolHtml = toolCalls.length > 0 ? `
<div class="task-detail-section">
<div class="task-detail-label">${icon('gear', 12)} 工具调用 (${toolCalls.length})</div>
<div class="task-detail-tools">
${toolCalls.map(tc => `<div class="task-detail-tool">
<code>${esc(tc.name || tc.tool || tc.type)}</code>
${tc.input ? `<pre>${esc(typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input, null, 2)).slice(0, 500)}</pre>` : ''}
${tc.output ? `<pre class="tool-output">${esc(typeof tc.output === 'string' ? tc.output : JSON.stringify(tc.output, null, 2)).slice(0, 500)}</pre>` : ''}
</div>`).join('')}
</div>
</div>
` : ''
// 展示详情弹窗
const { showConfirm: _ } = await import('../components/modal.js')
const overlay = document.createElement('div')
overlay.className = 'task-detail-overlay'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
overlay.innerHTML = `
<div class="task-detail-modal">
<div class="task-detail-header">
<span>${isError ? icon('x-circle', 16) : icon('check-circle', 16)} ${esc(shortName)} — 任务详情</span>
<button class="btn btn-sm" onclick="this.closest('.task-detail-overlay').remove()">${icon('x', 14)}</button>
</div>
<div class="task-detail-body">
<div class="task-detail-section">
<div class="task-detail-label">${icon('message-square', 12)} 指令</div>
<div class="task-detail-content">${esc(task.message)}</div>
</div>
<div class="task-detail-section">
<div class="task-detail-label">${isError ? icon('x-circle', 12) + ' 错误' : icon('check-circle', 12) + ' 结果'}</div>
<pre class="task-detail-result ${isError ? 'error' : ''}">${esc(resultText)}</pre>
</div>
${toolHtml}
<div class="task-detail-meta">
耗时 ${elapsed}s · ${new Date(task.startedAt).toLocaleTimeString()}
</div>
</div>
</div>
`
document.body.appendChild(overlay)
} catch (e) {
toast(`加载任务详情失败: ${e.message}`, 'error')
}
}
function _updateBatchUI(page) {
const checks = page.querySelectorAll('.ct-select:checked')
const batchEl = page.querySelector('#batch-actions')
const countEl = page.querySelector('#batch-count')
if (batchEl) batchEl.style.display = checks.length > 0 ? 'flex' : 'none'
if (countEl) countEl.textContent = `${checks.length} 名已选`
const selectAll = page.querySelector('#ct-select-all')
const allChecks = page.querySelectorAll('.ct-select')
if (selectAll && allChecks.length) selectAll.checked = checks.length === allChecks.length
// 批量按钮启用/禁用
for (const btn of page.querySelectorAll('.batch-btn')) {
btn.disabled = checks.length === 0
}
}
function bindEvents(page) {
@@ -566,30 +732,131 @@ function bindEvents(page) {
})
page.addEventListener('click', async (e) => {
// 工作区:点击工人卡片或历史条目查看详情
const wsWorker = e.target.closest('.ws-worker[data-task-id]')
const wsView = e.target.closest('.ws-history-view[data-task-id]')
const wsItem = e.target.closest('.ws-history-item[data-task-id]')
if (wsView) { _showTaskDetail(page, wsView.dataset.taskId); return }
if (wsWorker && !wsWorker.querySelector('.ws-worker-badge.running')) { _showTaskDetail(page, wsWorker.dataset.taskId); return }
if (wsItem && !wsView) { _showTaskDetail(page, wsItem.dataset.taskId); return }
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
// 工作区清空
if (action === 'workspace-clear') {
const wsEl = page.querySelector('#task-workspace')
if (wsEl) wsEl.style.display = 'none'
_stopWorkspacePolling()
return
}
// 切换管理实例
if (action === 'switch-instance') {
const ct = btn.dataset.ct
const name = btn.dataset.name
const port = btn.dataset.port
const gatewayPort = btn.dataset.gatewayPort
const nodeId = btn.dataset.node || null
if (!ct || !port) return
const switchCtx = buildDockerInstanceSwitchContext({
containerId: ct,
name,
port,
gatewayPort,
nodeId,
})
const originalHtml = btn.innerHTML
btn.disabled = true
btn.textContent = '切换中...'
try {
await switchInstance(switchCtx.instanceId)
toast(`已切换管理 → ${name}模型配置、Agent 等将管理该士兵)`, 'success')
const sidebar = document.getElementById('sidebar')
if (sidebar) renderSidebar(sidebar)
if (switchCtx.reloadRoute) {
reloadCurrentRoute()
return
}
await loadClusterOverview(page)
} catch (e) {
try {
const added = await api.instanceAdd(switchCtx.registration)
await switchInstance(added.id)
toast(`已注册并切换管理 → ${name}`, 'success')
const sidebar = document.getElementById('sidebar')
if (sidebar) renderSidebar(sidebar)
if (switchCtx.reloadRoute) {
reloadCurrentRoute()
return
}
await loadClusterOverview(page)
} catch (e2) {
btn.disabled = false
btn.innerHTML = originalHtml
toast(`切换失败: ${e2.message}`, 'error')
}
}
return
}
// 批量操作
if (action.startsWith('batch-')) {
const op = action.replace('batch-', '')
const checks = page.querySelectorAll('.ct-select:checked')
if (checks.length === 0) { toast('请先勾选士兵', 'error'); return }
const opName = op === 'start' ? '出征' : op === 'stop' ? '休整' : '整编'
const ok = await showConfirm(`军令:${opName} ${checks.length} 名士兵?`, '将对所有已勾选的士兵执行命令。')
const OP_NAMES = { start: '出征', stop: '休整', restart: '整编', sync: '同步配置', rebuild: '重建', remove: '退役' }
const opName = OP_NAMES[op] || op
const confirmMsgs = {
start: '将启动所有已勾选的士兵。',
stop: '将停止所有已勾选的士兵。',
restart: '将重启所有已勾选的士兵。',
sync: '将向所有已勾选的士兵同步 API Key、兵种配置和 Agent。',
rebuild: '将拉取最新镜像并重建所有已勾选的士兵(数据卷保留)。\n⚠ 重建过程中士兵将暂时离线。',
remove: '⚠ 此操作不可撤销!将永久退役所有已勾选的士兵。',
}
const ok = await showConfirm(`军令:${opName} ${checks.length} 名士兵?`, confirmMsgs[op] || '将对所有已勾选的士兵执行命令。')
if (!ok) return
toast(`正在执行军令: ${opName}...`)
toast(`正在执行军令: ${opName}...`, 'info')
// 禁用所有批量按钮
page.querySelectorAll('.batch-btn').forEach(b => b.disabled = true)
let success = 0, fail = 0
const total = checks.length
const errors = []
for (const cb of checks) {
const nId = cb.dataset.node, cId = cb.dataset.ct
const cName = cb.closest('.unit-card')?.querySelector('.unit-name')?.textContent || cId
try {
const nId = cb.dataset.node, cId = cb.dataset.ct
if (op === 'start') await api.dockerStartContainer(nId, cId)
else if (op === 'stop') await api.dockerStopContainer(nId, cId)
else if (op === 'restart') await api.dockerRestartContainer(nId, cId)
else if (op === 'sync') {
const role = MILITARY.inferRole(cName)
await api.dockerInitWorker(nId, cId, role)
}
else if (op === 'rebuild') await api.dockerRebuildContainer(nId, cId, true)
else if (op === 'remove') await api.dockerRemoveContainer(nId, cId, true)
success++
} catch { fail++ }
toast(`${opName}进度: ${success + fail}/${total}`, 'info')
} catch (e) {
fail++
errors.push(`${cName}: ${e.message}`)
console.error(`[batch-${op}] ${cName} 失败:`, e.message)
}
}
toast(`军令执行完毕: ${success}${opName}${fail ? `${fail} 名失败` : ''}`)
const resultType = fail === 0 ? 'success' : fail === total ? 'error' : 'info'
let msg = `军令执行完毕: ${success}${opName}${fail ? `${fail} 名失败` : ''}`
if (errors.length > 0) msg += `\n${errors.slice(0, 3).join('\n')}${errors.length > 3 ? `\n...还有 ${errors.length - 3} 个错误` : ''}`
toast(msg, resultType)
await loadClusterOverview(page)
return
}
@@ -685,6 +952,23 @@ function bindEvents(page) {
return
}
if (action === 'rebuild') {
const name = btn.dataset.name || containerId
const ok = await showConfirm(`重建 ${name}`, '将拉取最新镜像并重新创建容器,数据卷保留。\n重建期间士兵将暂时离线。')
if (!ok) return
btn.disabled = true
toast(`正在重建 ${name}...`, 'info')
try {
const result = await api.dockerRebuildContainer(nodeId, containerId, true)
toast(`${result.name || name} 已重建完成`, 'success')
await loadClusterOverview(page)
} catch (e) {
toast(`${name} 重建失败: ${e.message}`, 'error')
btn.disabled = false
}
return
}
if (action === 'sync-config') {
const cid = btn.dataset.ct
const nid = btn.dataset.node || null
@@ -696,7 +980,7 @@ function bindEvents(page) {
const count = result?.files?.length || 0
// docker_init_worker 内部已重启 Gateway不需要重启容器重启会触发 entrypoint 覆盖配置)
toast(`${name}: 已同步 ${count} 个文件Gateway 已重启`, 'success')
setTimeout(() => refreshCluster(page), 3000)
setTimeout(() => loadClusterOverview(page), 3000)
} catch (e) {
toast(`${name} 同步失败: ${e.message}`, 'error')
}
@@ -709,7 +993,7 @@ function bindEvents(page) {
const name = btn.dataset.name || cid
toast(`正在连接 ${name} 的 Gateway...`, 'info')
try {
const resp = await api.dockerGatewayChat(nid, cid, '你好,报告你的兵种和状态')
const resp = await api.dockerAgent(nid, cid, { cmd: 'task.run', message: '你好,报告你的兵种和状态' })
toast(`${name} 回复: ${(resp?.result || '(无回复)').slice(0, 100)}`, 'success')
} catch (e) {
toast(`${name} 通讯失败: ${e.message}`, 'error')

View File

@@ -1,397 +0,0 @@
/**
* 扩展工具页面
* cftunnel 隧道管理 + ClawApp 状态
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { statusIcon } from '../lib/icons.js'
// HTML 转义,防止 XSS
function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">扩展工具</h1>
<p class="page-desc">管理 cftunnel 内网穿透和 ClawApp 移动客户端</p>
</div>
<div id="cftunnel-card" class="config-section">
<div class="config-section-title">cftunnel 内网穿透</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
<div id="cftunnel-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
</div>
<div id="clawapp-card" class="config-section">
<div class="config-section-title">ClawApp 移动客户端</div>
<div class="form-hint" style="margin-bottom:var(--space-md)">H5 移动聊天客户端,通过代理服务端连接 Gateway。支持本地和外网访问。</div>
<div id="clawapp-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
</div>
`
bindEvents(page)
loadAll(page)
return page
}
async function loadAll(page) {
await Promise.all([
loadCftunnel(page),
loadClawapp(page),
])
}
// ===== cftunnel =====
async function loadCftunnel(page) {
const el = page.querySelector('#cftunnel-content')
try {
const status = await api.getCftunnelStatus()
renderCftunnel(el, status)
} catch (e) {
el.innerHTML = `<div style="color:var(--error)">加载失败: ${e}</div>`
}
}
function renderCftunnel(el, s) {
if (!s.installed) {
el.innerHTML = `
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">cftunnel 未安装</div>
<div style="display:flex;gap:var(--space-sm);align-items:center">
<button class="btn btn-primary btn-sm" data-action="install-cftunnel">一键安装</button>
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/cftunnel" target="_blank" rel="noopener">查看文档</a>
</div>
<div id="install-progress-area"></div>
`
return
}
const running = s.running
const routes = s.routes || []
el.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-md)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">状态</span>
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${s.tunnel_name || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">版本</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-md)">${s.version || '未知'}</div>
<div class="stat-card-meta">${routes.length} 条路由</div>
</div>
</div>
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-md)">
${running
? '<button class="btn btn-danger btn-sm" data-action="cftunnel-down">停止隧道</button>'
: '<button class="btn btn-primary btn-sm" data-action="cftunnel-up">启动隧道</button>'
}
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">查看日志</button>
<button class="btn btn-secondary btn-sm" data-action="cftunnel-refresh">刷新</button>
</div>
${renderRoutes(routes)}
<div id="cftunnel-logs-area"></div>
`
}
function renderRoutes(routes) {
if (!routes.length) return '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">暂无路由</div>'
return `
<div class="tunnel-routes">
${routes.map(r => `
<div class="tunnel-route-card">
<div class="tunnel-route-header">
<span class="tunnel-route-name">${escapeHtml(r.name)}</span>
<span class="tunnel-route-badge">
<span class="status-dot running" style="width:6px;height:6px"></span>
活跃
</span>
</div>
<div class="tunnel-route-domain">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<a href="https://${escapeHtml(r.domain)}" target="_blank" rel="noopener">${escapeHtml(r.domain)}</a>
</div>
<div class="tunnel-route-service">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-tertiary)">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<span>本地服务:</span>
<code>${escapeHtml(r.service)}</code>
</div>
</div>
`).join('')}
</div>
`
}
// ===== ClawApp =====
async function loadClawapp(page) {
const el = page.querySelector('#clawapp-content')
try {
const status = await api.getClawappStatus()
renderClawapp(el, status)
} catch (e) {
el.innerHTML = `<div style="color:var(--error)">加载失败: ${e}</div>`
}
}
function renderClawapp(el, s) {
if (!s.installed) {
el.innerHTML = `
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">ClawApp 未安装</div>
<div style="display:flex;gap:var(--space-sm);align-items:center">
<button class="btn btn-primary btn-sm" data-action="install-clawapp">一键安装</button>
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawapp" target="_blank" rel="noopener">查看文档</a>
</div>
<div id="install-clawapp-progress-area"></div>
`
return
}
const running = s.running
el.innerHTML = `
<div class="stat-cards" style="margin-bottom:var(--space-md)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">状态</span>
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${s.pid ? 'PID: ' + s.pid : ''}${s.port ? ' 端口: ' + s.port : ''}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">访问地址</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-sm)">${s.url || 'http://localhost:3210'}</div>
<div class="stat-card-meta">外网: chat.qrj.ai</div>
</div>
</div>
<div style="display:flex;gap:var(--space-sm)">
<a class="btn btn-primary btn-sm" href="${s.url || 'http://localhost:3210'}" target="_blank" rel="noopener">打开 ClawApp</a>
<a class="btn btn-secondary btn-sm" href="https://chat.qrj.ai" target="_blank" rel="noopener">打开外网地址</a>
<button class="btn btn-secondary btn-sm" data-action="clawapp-refresh">刷新</button>
</div>
`
}
// ===== 事件绑定 =====
function bindEvents(page) {
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
switch (action) {
case 'cftunnel-up':
await handleCftunnelAction(page, 'up')
break
case 'cftunnel-down':
await handleCftunnelAction(page, 'down')
break
case 'cftunnel-logs':
await handleCftunnelLogs(page)
break
case 'cftunnel-refresh':
await loadCftunnel(page)
break
case 'clawapp-refresh':
await loadClawapp(page)
break
case 'install-cftunnel':
await handleInstallCftunnel(page)
break
case 'install-clawapp':
await handleInstallClawapp(page)
break
}
})
}
async function handleCftunnelAction(page, action) {
const label = action === 'up' ? '启动' : '停止'
const btn = page.querySelector(`[data-action="cftunnel-${action === 'up' ? 'up' : 'down'}"]`)
if (btn) { btn.classList.add('btn-loading'); btn.disabled = true; btn.textContent = `${label}中...` }
try {
await api.cftunnelAction(action)
toast(`隧道已${label}`, 'success')
await loadCftunnel(page)
} catch (e) {
toast(`${label}失败: ${e}`, 'error')
if (btn) { btn.classList.remove('btn-loading'); btn.disabled = false; btn.textContent = `${label}隧道` }
}
}
async function handleCftunnelLogs(page) {
const area = page.querySelector('#cftunnel-logs-area')
if (!area) return
// 切换显示
if (area.innerHTML) {
area.innerHTML = ''
return
}
try {
const logs = await api.getCftunnelLogs(30)
area.innerHTML = `
<div style="margin-top:var(--space-md)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-sm)">
<span style="font-weight:600;font-size:var(--font-size-sm)">最近日志</span>
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">收起</button>
</div>
<pre class="log-viewer">${escapeHtml(logs) || '暂无日志'}</pre>
</div>
`
} catch (e) {
area.innerHTML = `<div style="color:var(--error);margin-top:var(--space-sm)">读取日志失败: ${e}</div>`
}
}
async function handleInstallCftunnel(page) {
const area = page.querySelector('#install-progress-area')
if (!area) return
// 显示进度条
area.innerHTML = `
<div style="margin-top:var(--space-lg)">
<div class="upgrade-progress-wrap">
<div class="upgrade-progress-bar">
<div class="upgrade-progress-fill" id="install-progress-fill" style="width:0%"></div>
</div>
<div class="upgrade-progress-text" id="install-progress-text">准备安装...</div>
</div>
<div class="upgrade-log-box" id="install-log-box"></div>
</div>
`
const progressFill = area.querySelector('#install-progress-fill')
const progressText = area.querySelector('#install-progress-text')
const logBox = area.querySelector('#install-log-box')
let unlistenLog, unlistenProgress
try {
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
} catch { /* Web 模式无 Tauri event */ }
} else {
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
}
await api.installCftunnel()
progressFill.classList.add('done')
progressText.innerHTML = `${statusIcon('ok', 14)} 安装完成`
toast('cftunnel 安装成功', 'success')
// 3 秒后刷新状态
setTimeout(() => loadCftunnel(page), 3000)
} catch (e) {
progressFill.classList.add('error')
progressText.innerHTML = `${statusIcon('err', 14)} 安装失败`
logBox.textContent += '\n错误: ' + e
toast('安装失败: ' + e, 'error')
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({
title: '安装 cftunnel 失败',
error: logBox.textContent,
scene: '安装 cftunnel 内网穿透工具',
hint: String(e),
})
}
} finally {
unlistenLog?.()
unlistenProgress?.()
}
}
async function handleInstallClawapp(page) {
const area = page.querySelector('#install-clawapp-progress-area')
if (!area) return
area.innerHTML = `
<div style="margin-top:var(--space-lg)">
<div class="upgrade-progress-wrap">
<div class="upgrade-progress-bar">
<div class="upgrade-progress-fill" id="install-clawapp-progress-fill" style="width:0%"></div>
</div>
<div class="upgrade-progress-text" id="install-clawapp-progress-text">准备安装...</div>
</div>
<div class="upgrade-log-box" id="install-clawapp-log-box"></div>
</div>
`
const progressFill = area.querySelector('#install-clawapp-progress-fill')
const progressText = area.querySelector('#install-clawapp-progress-text')
const logBox = area.querySelector('#install-clawapp-log-box')
let unlistenLog, unlistenProgress
try {
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('install-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('install-progress', (e) => {
const progress = e.payload
progressFill.style.width = progress + '%'
progressText.textContent = `安装中... ${progress}%`
})
} catch { /* Web 模式无 Tauri event */ }
} else {
logBox.textContent += 'Web 模式:安装日志不可用,请等待完成...\n'
}
await api.installClawapp()
progressFill.classList.add('done')
progressText.innerHTML = `${statusIcon('ok', 14)} 安装完成`
toast('ClawApp 安装成功', 'success')
setTimeout(() => loadClawapp(page), 3000)
} catch (e) {
progressFill.classList.add('error')
progressText.innerHTML = `${statusIcon('err', 14)} 安装失败`
logBox.textContent += '\n错误: ' + e
toast('安装失败: ' + e, 'error')
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({
title: '安装 ClawApp 失败',
error: logBox.textContent,
scene: '安装 ClawApp 手机客户端',
hint: String(e),
})
}
} finally {
unlistenLog?.()
unlistenProgress?.()
}
}