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

File diff suppressed because it is too large Load Diff

44
src/lib/docker-tasking.js Normal file
View File

@@ -0,0 +1,44 @@
export const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000
export function buildDockerDispatchTargets(targets = []) {
if (!Array.isArray(targets)) return []
return targets.map(target => ({
containerId: target.id,
containerName: target.name,
nodeId: target.nodeId || null,
}))
}
export function buildDockerInstanceSwitchContext({ containerId, name, port, gatewayPort, nodeId }) {
if (!containerId) throw new Error('缺少容器 ID')
if (!name) throw new Error('缺少容器名称')
const panelPort = parseRequiredPort(port, '面板端口')
const parsedGatewayPort = parseOptionalPort(gatewayPort, 18789)
return {
instanceId: `docker-${containerId.slice(0, 12)}`,
reloadRoute: true,
registration: {
name,
type: 'docker',
endpoint: `http://127.0.0.1:${panelPort}`,
gatewayPort: parsedGatewayPort,
containerId,
nodeId: nodeId || null,
note: 'Added from Docker page',
},
}
}
function parseRequiredPort(value, label) {
const port = Number.parseInt(value, 10)
if (Number.isInteger(port) && port > 0) return port
throw new Error(`${label}无效`)
}
function parseOptionalPort(value, fallback) {
const port = Number.parseInt(value, 10)
if (Number.isInteger(port) && port > 0) return port
return fallback
}

View File

@@ -1,32 +1,17 @@
/**
* Tauri API 封装层
* 开发阶段用 mock 数据,Tauri 环境用 invoke
* Tauri 环境用 invokeWeb 模式走 dev-api 后端
*/
import { DOCKER_TASK_TIMEOUT_MS } from './docker-tasking.js'
const isTauri = !!window.__TAURI_INTERNALS__
// 写操作不应静默回退 mock否则会“假成功”
const NO_MOCK_CMDS = new Set([
'start_service', 'stop_service', 'restart_service',
'upgrade_openclaw', 'install_gateway', 'uninstall_gateway',
'write_openclaw_config', 'write_mcp_config',
'create_backup', 'restore_backup', 'delete_backup',
'write_memory_file', 'delete_memory_file',
'set_npm_registry', 'reload_gateway', 'restart_gateway',
'auto_pair_device',
'assistant_exec', 'assistant_write_file',
'docker_create_container', 'docker_start_container', 'docker_stop_container',
'docker_restart_container', 'docker_remove_container', 'docker_container_exec', 'docker_gateway_chat', 'docker_pull_image',
'docker_add_node', 'docker_remove_node',
'instance_add', 'instance_remove', 'instance_set_active',
])
// 仅在 Node.js 后端实现的命令Tauri Rust 不处理),强制走 webInvoke
const WEB_ONLY_CMDS = new Set([
'docker_test_endpoint',
'docker_info', 'docker_list_containers', 'docker_create_container',
'docker_start_container', 'docker_stop_container', 'docker_restart_container',
'docker_remove_container', 'docker_container_logs', 'docker_container_exec', 'docker_init_worker', 'docker_gateway_chat', 'docker_pull_image', 'docker_pull_status',
'docker_remove_container', 'docker_rebuild_container', 'docker_container_logs', 'docker_container_exec', 'docker_init_worker', 'docker_gateway_chat', 'docker_agent', 'docker_agent_broadcast', 'docker_dispatch_task', 'docker_dispatch_broadcast', 'docker_task_status', 'docker_task_list', 'docker_pull_image', 'docker_pull_status',
'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node',
'docker_cluster_overview',
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
@@ -103,24 +88,11 @@ async function invoke(cmd, args = {}) {
logRequest(cmd, args, duration, false)
return result
}
// Web 模式:优先调用 Vite 开发 API真实后端失败时回退 mock
try {
const result = await webInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
} catch (e) {
// 写操作不回退 mock直接报错避免“假成功”
if (NO_MOCK_CMDS.has(cmd)) {
logRequest(cmd, args, Date.now() - start, false)
throw e
}
console.warn(`[api] webInvoke(${cmd}) failed:`, e.message, '→ fallback mock')
const result = mockInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
}
// Web 模式:调用 dev-api 后端(真实数据)
const result = await webInvoke(cmd, args)
const duration = Date.now() - start
logRequest(cmd, args, duration, false)
return result
}
// Web 模式:通过 Vite 开发服务器的 API 端点调用真实后端
@@ -142,162 +114,36 @@ async function webInvoke(cmd, args) {
return resp.json()
}
// Mock 数据,方便纯浏览器开发调试
function mockInvoke(cmd, args) {
const mocks = {
get_services_status: () => [
{ label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway', cli_installed: true },
],
get_version_info: () => ({
current: '2026.2.23',
latest: null,
update_available: false,
}),
read_openclaw_config: () => ({
meta: { lastTouchedVersion: '2026.2.23' },
models: {
mode: 'replace',
providers: {
'newapi-claude': {
baseUrl: 'http://localhost:30080/v1',
api: 'openai-completions',
models: [
{ id: 'claude-opus-4-6' },
{ id: 'claude-sonnet-4-5' },
],
},
},
},
agents: {
defaults: {
model: { primary: 'newapi-claude/claude-opus-4-6', fallbacks: ['newapi-claude/claude-sonnet-4-5'] },
maxConcurrent: 4,
subagents: 2,
},
},
gateway: { port: 18789, mode: 'local', bind: 'loopback', authToken: '' },
}),
write_openclaw_config: () => true,
read_log_tail: ({ logName }) => {
const logs = {
'gateway': [
'2026-02-26 13:29:01 [INFO] Gateway started on :18789',
'2026-02-26 13:29:02 [INFO] Agent connected: claude-opus-4-6',
'2026-02-26 13:29:05 [INFO] Request /v1/chat/completions → 200 (1.2s)',
'2026-02-26 13:30:12 [INFO] Request /v1/chat/completions → 200 (3.8s)',
'2026-02-26 13:31:00 [WARN] Rate limit approaching: 45/50 rpm',
'2026-02-26 13:32:15 [INFO] Request /v1/chat/completions → 200 (2.1s)',
],
'gateway-err': ['2026-02-26 12:00:01 [ERROR] Upstream 502: connection refused'],
'guardian': ['2026-02-26 13:29:00 [INFO] Health check passed', '2026-02-26 13:30:00 [INFO] Health check passed'],
'guardian-backup': ['2026-02-26 12:00:00 [INFO] Backup completed: openclaw.json.bak'],
'config-audit': ['{"ts":"2026-02-26T13:29:00Z","action":"config.read","file":"openclaw.json"}'],
}
return (logs[logName] || logs['gateway']).join('\n')
},
search_log: ({ query }) => [
`2026-02-26 13:29:01 [INFO] Match: ${query}`,
`2026-02-26 13:30:12 [INFO] Found: ${query} in request`,
],
list_memory_files: ({ category }) => {
const files = {
memory: ['active-context.md', 'decisions.md', 'progress.md'],
archive: ['2026-02-sprint1.md', '2026-02-sprint2.md'],
core: ['AGENTS.md', 'CLAUDE.md'],
}
return files[category] || files.memory
},
read_memory_file: ({ path }) => `# ${path}\n\n这是 ${path} 的内容示例。\n\n## 概述\n\n在此记录工作记忆...`,
write_memory_file: () => true,
delete_memory_file: () => true,
export_memory_zip: ({ category }) => `/tmp/openclaw-${category}-20260226-160000.zip`,
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23', inDocker: false }),
get_deploy_mode: () => ({ inDocker: false, dockerAvailable: true, mode: 'local' }),
docker_cluster_overview: () => [
{ id: 'local', name: '本机', type: 'socket', endpoint: '/var/run/docker.sock', online: true, dockerVersion: '27.4.1', os: 'Docker Desktop', cpus: 8, memory: 16 * 1024 * 1024 * 1024, totalContainers: 3, runningContainers: 2, stoppedContainers: 1, containers: [
{ id: 'a1b2c3d4e5f6', name: 'openclaw', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'running', status: 'Up 2 hours', ports: '1420→1420, 18789→18789' },
{ id: 'f6e5d4c3b2a1', name: 'openclaw-gw-2', image: 'ghcr.io/qingchencloud/openclaw:latest-gateway', state: 'running', status: 'Up 5 hours', ports: '18790→18789' },
{ id: 'b3c4d5e6f7a8', name: 'openclaw-test', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'exited', status: 'Exited (0) 1 day ago', ports: '' },
]},
],
docker_list_containers: () => [
{ id: 'a1b2c3d4e5f6', name: 'openclaw', image: 'ghcr.io/qingchencloud/openclaw:latest', state: 'running', status: 'Up 2 hours', ports: '1420→1420, 18789→18789', nodeId: 'local', nodeName: '本机' },
],
docker_info: () => ({ nodeId: 'local', nodeName: '本机', containers: 3, containersRunning: 2, containersStopped: 1, images: 5, serverVersion: '27.4.1', os: 'Docker Desktop', arch: 'x86_64', cpus: 8, memory: 16 * 1024 * 1024 * 1024 }),
docker_list_nodes: () => [{ id: 'local', name: '本机', type: 'socket', endpoint: '/var/run/docker.sock' }],
docker_container_logs: () => '[INFO] OpenClaw Gateway started\n[INFO] Listening on :18789\n[INFO] Panel available at :1420',
docker_list_images: () => [{ id: 'sha256abcdef', tags: ['ghcr.io/qingchencloud/openclaw:latest'], size: 450 * 1024 * 1024, created: Date.now() / 1000 - 86400 }],
instance_list: () => ({ activeId: 'local', instances: [{ id: 'local', name: '本机', type: 'local', endpoint: null, gatewayPort: 18789, addedAt: 0, note: '' }] }),
instance_add: () => ({ id: 'remote-mock', name: 'mock' }),
instance_remove: () => true,
instance_set_active: () => ({ activeId: 'local' }),
instance_health_check: () => ({ id: 'local', online: true, version: '2026.3.5', gatewayRunning: true, lastCheck: Date.now() }),
instance_health_all: () => [{ id: 'local', online: true, version: '2026.3.5', gatewayRunning: true, lastCheck: Date.now() }],
check_node: () => ({ installed: true, version: 'v20.11.0' }),
get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }),
read_mcp_config: () => ({
mcpServers: {
'exa': { command: 'npx', args: ['-y', '@anthropic/exa-mcp-server'], env: { EXA_API_KEY: '***' } },
'web-reader': { command: 'npx', args: ['-y', '@anthropic/web-reader-mcp'], env: {} },
'pal': { command: 'node', args: ['/opt/pal-mcp/index.js'], env: {} },
},
}),
write_mcp_config: () => true,
start_service: () => true,
stop_service: () => true,
restart_service: () => true,
reload_gateway: () => 'Gateway 已重载',
restart_gateway: () => 'Gateway 已重启',
list_agents: () => [
{ id: 'main', isDefault: true, identityName: null, model: null, workspace: null },
],
upgrade_openclaw: () => '升级成功,当前版本: 2026.2.26-zh.3 (mock)',
install_gateway: () => 'Gateway 服务已安装 (mock)',
uninstall_gateway: () => 'Gateway 服务已卸载 (mock)',
get_npm_registry: () => 'https://registry.npmmirror.com',
set_npm_registry: () => true,
test_model: ({ modelId }) => `模型 ${modelId} 连通正常 (mock)`,
list_remote_models: () => ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o3-mini', 'dall-e-3', 'text-embedding-3-small'],
patch_model_vision: () => false,
check_panel_update: () => ({ latest: '0.2.0', url: 'https://github.com/qingchencloud/clawpanel/releases' }),
write_env_file: () => true,
list_backups: () => [
{ name: 'openclaw-20260226-143000.json', size: 8542, created_at: 1740577800 },
{ name: 'openclaw-20260225-100000.json', size: 8210, created_at: 1740474000 },
],
create_backup: () => ({ name: 'openclaw-20260226-160000.json', size: 8542 }),
restore_backup: () => true,
delete_backup: () => true,
get_cftunnel_status: () => ({
installed: true, version: 'cftunnel 0.7.0', running: true,
tunnel_name: 'mac-home', pid: 73325,
routes: [
{ name: 'webapp', domain: 'app.example.com', service: 'http://localhost:3210' },
{ name: 'api', domain: 'api.example.com', service: 'http://localhost:30080' },
{ name: 'webhook', domain: 'hook.example.com', service: 'http://localhost:9801' },
],
}),
cftunnel_action: () => true,
get_cftunnel_logs: () => '2026-02-26 13:29:01 [INFO] Tunnel started\n2026-02-26 13:30:00 [INFO] Connection healthy',
get_clawapp_status: () => ({ running: true, pid: 7752, port: 3210, url: 'http://localhost:3210' }),
// AI 助手工具
assistant_exec: ({ command }) => `[mock] 执行: ${command}\n这是模拟输出`,
assistant_read_file: ({ path }) => `[mock] 文件内容: ${path}\n# 示例文件\n这是模拟文件内容`,
assistant_write_file: ({ path, content }) => `已写入 ${path} (${content.length} 字节)`,
assistant_list_dir: ({ path }) => '[DIR] src/\n[DIR] docs/\n[FILE] README.md (1024 bytes)\n[FILE] package.json (512 bytes)',
assistant_system_info: () => `OS: ${navigator.platform.includes('Win') ? 'windows' : navigator.platform.includes('Mac') ? 'macos' : 'linux'}\nArch: x86_64\nHome: ${navigator.platform.includes('Win') ? 'C:\\Users\\user' : '/Users/user'}\nHostname: mock-host\nShell: ${navigator.platform.includes('Win') ? 'powershell / cmd' : 'zsh'}\nPath separator: ${navigator.platform.includes('Win') ? '\\\\' : '/'}`,
assistant_list_processes: ({ filter }) => filter ? `Id ProcessName\n-- -----------\n1234 ${filter}\n5678 ${filter}-helper` : 'Id ProcessName\n-- -----------\n1 System\n1234 node\n5678 openclaw',
assistant_check_port: ({ port }) => port === 18789 ? `端口 ${port} 已被占用(正在监听)\n占用进程: node` : `端口 ${port} 未被占用(空闲)`,
assistant_web_search: ({ query }) => `搜索「${query}」找到 3 条结果:\n\n1. **${query} - 文档**\n https://example.com/docs\n 这是关于 ${query} 的文档页面\n\n2. **${query} 常见问题**\n https://example.com/faq\n 常见问题解答\n\n3. **${query} GitHub**\n https://github.com/example\n 开源仓库`,
assistant_fetch_url: ({ url }) => `# ${url}\n\n这是从 ${url} 抓取的网页内容mock\n\n## 主要内容\n\n示例文本...`,
// 数据目录 & 图片存储
assistant_ensure_data_dir: () => (navigator.platform.includes('Win') ? 'C:\\Users\\user\\.openclaw\\clawpanel' : '/Users/user/.openclaw/clawpanel'),
assistant_save_image: ({ id }) => `/mock/images/${id}.jpg`,
assistant_load_image: () => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==',
assistant_delete_image: () => null,
// 后端连接状态
let _backendOnline = null // null=未检测, true=在线, false=离线
const _backendListeners = []
export function onBackendStatusChange(fn) {
_backendListeners.push(fn)
return () => { const i = _backendListeners.indexOf(fn); if (i >= 0) _backendListeners.splice(i, 1) }
}
export function isBackendOnline() { return _backendOnline }
function _setBackendOnline(v) {
if (_backendOnline !== v) {
_backendOnline = v
_backendListeners.forEach(fn => { try { fn(v) } catch {} })
}
}
// 后端健康检查
export async function checkBackendHealth() {
if (isTauri) { _setBackendOnline(true); return true }
try {
const resp = await fetch('/__api/health', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
const ok = resp.ok
_setBackendOnline(ok)
return ok
} catch {
_setBackendOnline(false)
return false
}
const fn = mocks[cmd]
return fn ? Promise.resolve(fn(args)) : Promise.reject(`未知命令: ${cmd}`)
}
// 导出 API
@@ -307,6 +153,7 @@ export const api = {
startService: (label) => { invalidate('get_services_status'); return invoke('start_service', { label }) },
stopService: (label) => { invalidate('get_services_status'); return invoke('stop_service', { label }) },
restartService: (label) => { invalidate('get_services_status'); return invoke('restart_service', { label }) },
guardianStatus: () => invoke('guardian_status'),
// 配置(读缓存,写清缓存)
getVersionInfo: () => cachedInvoke('get_version_info', {}, 30000),
@@ -367,14 +214,6 @@ export const api = {
restoreBackup: (name) => invoke('restore_backup', { name }),
deleteBackup: (name) => { invalidate('list_backups'); return invoke('delete_backup', { name }) },
// 扩展工具
getCftunnelStatus: () => cachedInvoke('get_cftunnel_status', {}, 10000),
cftunnelAction: (action) => { invalidate('get_cftunnel_status'); return invoke('cftunnel_action', { action }) },
getCftunnelLogs: (lines = 20) => cachedInvoke('get_cftunnel_logs', { lines }, 5000),
getClawappStatus: () => cachedInvoke('get_clawapp_status', {}, 5000),
installCftunnel: () => invoke('install_cftunnel'),
installClawapp: () => invoke('install_clawapp'),
// 设备密钥 + Gateway 握手
createConnectFrame: (nonce, gatewayToken) => invoke('create_connect_frame', { nonce, gatewayToken }),
@@ -423,7 +262,14 @@ export const api = {
dockerContainerLogs: (nodeId, containerId, tail = 200) => invoke('docker_container_logs', { nodeId, containerId, tail }),
dockerContainerExec: (nodeId, containerId, cmd) => invoke('docker_container_exec', { nodeId, containerId, cmd }),
dockerInitWorker: (nodeId, containerId, role) => invoke('docker_init_worker', { nodeId, containerId, role }),
dockerGatewayChat: (nodeId, containerId, message) => invoke('docker_gateway_chat', { nodeId, containerId, message }),
dockerGatewayChat: (nodeId, containerId, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_gateway_chat', { nodeId, containerId, message, timeout }),
dockerAgent: (nodeId, containerId, cmd) => invoke('docker_agent', { nodeId, containerId, cmd }),
dockerAgentBroadcast: (nodeId, containerIds, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_agent_broadcast', { nodeId, containerIds, message, timeout }),
dockerDispatchTask: (nodeId, containerId, containerName, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_dispatch_task', { nodeId, containerId, containerName, message, timeout }),
dockerDispatchBroadcast: (nodeId, targets, message, timeout = DOCKER_TASK_TIMEOUT_MS) => invoke('docker_dispatch_broadcast', { nodeId, targets, message, timeout }),
dockerTaskStatus: (taskId) => invoke('docker_task_status', { taskId }),
dockerTaskList: (containerId, status) => invoke('docker_task_list', { containerId, status }),
dockerRebuildContainer: (nodeId, containerId, pullLatest = true) => invoke('docker_rebuild_container', { nodeId, containerId, pullLatest }),
dockerPullImage: (nodeId, image, tag, requestId) => invoke('docker_pull_image', { nodeId, image, tag, requestId }),
dockerPullStatus: (requestId) => invoke('docker_pull_status', { requestId }),
dockerListImages: (nodeId) => invoke('docker_list_images', { nodeId }),

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?.()
}
}

View File

@@ -605,105 +605,6 @@
word-break: break-all;
}
/* 隧道路由卡片 */
.tunnel-routes {
display: grid;
gap: var(--space-md);
margin-top: var(--space-md);
}
.tunnel-route-card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-lg);
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.tunnel-route-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
opacity: 0;
transition: opacity var(--transition-fast);
}
.tunnel-route-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-focus);
}
.tunnel-route-card:hover::before {
opacity: 1;
}
.tunnel-route-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.tunnel-route-name {
font-weight: 600;
font-size: var(--font-size-md);
color: var(--text-primary);
}
.tunnel-route-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
background: var(--success-muted);
color: var(--success);
}
.tunnel-route-domain {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.tunnel-route-domain a {
color: var(--accent);
text-decoration: none;
font-size: var(--font-size-sm);
transition: color var(--transition-fast);
}
.tunnel-route-domain a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.tunnel-route-service {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.tunnel-route-service code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
/* Gateway 配置页 — 选项卡片 */
.gw-option-cards {
display: grid;
@@ -1015,6 +916,32 @@
color: var(--text-tertiary);
}
/* 活跃实例标记 */
.unit-card.active-instance {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
}
.unit-active-tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
background: rgba(99,102,241,.12);
color: var(--accent);
flex-shrink: 0;
}
.unit-switch-btn {
flex-shrink: 0;
font-size: 10px !important;
padding: 1px 8px !important;
opacity: 0;
transition: opacity .15s;
}
.unit-card:hover .unit-switch-btn { opacity: 1; }
.unit-links {
display: flex;
gap: 6px;
@@ -1831,6 +1758,28 @@ details.docker-other-section[open] > .docker-other-toggle::before {
}
.pick-target:has(input:checked) .pick-dot { opacity: 1; box-shadow: 0 0 4px var(--pick-color); }
.task-beta-note {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-top: 1px solid var(--border-primary);
border-bottom: 1px solid var(--border-primary);
background:
linear-gradient(90deg, rgba(245, 158, 11, 0.12) 0%, rgba(245, 158, 11, 0.04) 100%);
color: #b45309;
font-size: 12px;
font-weight: 500;
}
.task-beta-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #d97706;
flex-shrink: 0;
}
.task-input-row {
display: flex;
gap: 0;
@@ -1871,90 +1820,283 @@ details.docker-other-section[open] > .docker-other-toggle::before {
}
.task-send-btn:disabled { opacity: .35; pointer-events: none; }
/* 任务结果 */
.task-results {
/* 异步工作区 */
.task-workspace {
margin-bottom: var(--space-lg);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg, 12px);
overflow: hidden;
background: var(--bg-primary);
}
.task-results:empty { display: none; }
.task-results-header {
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
}
.workspace-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
color: var(--text-primary);
}
.task-results-mode {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
background: var(--bg-primary);
padding: 2px 8px;
border-radius: 10px;
.ws-worker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-sm);
padding: var(--space-md);
}
.ws-worker {
border: 1px solid var(--border-primary);
border-radius: var(--radius-md, 8px);
padding: 12px;
background: var(--bg-secondary);
cursor: pointer;
transition: all .15s;
position: relative;
overflow: hidden;
}
.task-results-grid {
display: flex;
flex-direction: column;
.ws-worker::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--worker-color, var(--accent));
opacity: 0;
transition: opacity .15s;
}
.task-result-card {
border-bottom: 1px solid var(--border-primary);
.ws-worker:hover { background: var(--bg-card-hover); border-color: var(--border-focus); }
.ws-worker:hover::before { opacity: 1; }
.ws-worker.working {
border-color: color-mix(in srgb, var(--worker-color, #f59e0b) 40%, transparent);
animation: ws-pulse 2s ease-in-out infinite;
}
.task-result-card:last-child { border-bottom: none; }
.task-result-header {
.ws-worker.working::before { opacity: 1; }
@keyframes ws-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--worker-color, #f59e0b) 15%, transparent); }
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--worker-color, #f59e0b) 8%, transparent); }
}
.ws-worker-top {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--bg-secondary);
gap: 10px;
margin-bottom: 8px;
}
.task-result-name {
font-size: 12px;
.ws-worker-info { flex: 1; min-width: 0; }
.ws-worker-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-result-status {
margin-left: auto;
.ws-worker-role {
font-size: 11px;
color: var(--text-tertiary);
}
.ws-worker-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 8px;
white-space: nowrap;
}
.task-result-status.pending { color: var(--text-tertiary); }
.task-result-status.running { color: #f59e0b; }
.task-result-status.done { color: #22c55e; }
.task-result-status.error { color: #ef4444; }
.task-result-body {
padding: 10px 14px;
font-size: 13px;
line-height: 1.7;
.ws-worker-badge.running { color: #f59e0b; background: rgba(245,158,11,.1); }
.ws-worker-badge.done { color: #22c55e; background: rgba(34,197,94,.1); }
.ws-worker-badge.error { color: #ef4444; background: rgba(239,68,68,.1); }
.ws-worker-task {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-primary);
}
.ws-worker-msg {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-worker-time {
font-size: 11px;
color: var(--text-tertiary);
font-family: var(--font-mono);
white-space: nowrap;
}
/* 任务记录 */
.ws-history-title {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
padding: 8px 14px 4px;
}
.ws-history-list {
padding: 4px 14px 10px;
}
.ws-history-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius-sm, 4px);
font-size: 12px;
cursor: pointer;
transition: background .1s;
}
.ws-history-item:hover { background: var(--bg-secondary); }
.ws-history-icon { flex-shrink: 0; }
.ws-history-item.done .ws-history-icon { color: #22c55e; }
.ws-history-item.error .ws-history-icon { color: #ef4444; }
.ws-history-name {
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-primary);
max-height: 240px;
white-space: nowrap;
}
.ws-history-msg {
flex: 1;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.ws-history-meta {
font-size: 11px;
color: var(--text-tertiary);
font-family: var(--font-mono);
white-space: nowrap;
}
.btn-xs {
font-size: 11px;
padding: 1px 8px;
border-radius: 6px;
}
/* 任务详情弹窗 */
.task-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.task-detail-modal {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg, 12px);
width: 100%;
max-width: 640px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0,0,0,.2);
}
.task-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.task-detail-header span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.task-detail-body {
padding: 18px;
overflow-y: auto;
flex: 1;
}
.task-detail-section {
margin-bottom: 16px;
}
.task-detail-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
}
.task-detail-content {
font-size: 13px;
color: var(--text-primary);
line-height: 1.6;
}
.task-detail-result {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md, 8px);
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
font-family: var(--font-mono);
}
.task-result-body .typing-cursor {
display: inline-block;
width: 2px; height: 14px;
background: var(--primary);
margin-left: 2px;
vertical-align: text-bottom;
animation: cursor-blink .8s step-end infinite;
.task-detail-result.error { color: #ef4444; }
.task-detail-tools {
display: flex;
flex-direction: column;
gap: 8px;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
.task-detail-tool {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm, 4px);
padding: 8px 10px;
}
.task-detail-tool code {
font-size: 11px;
font-weight: 600;
color: var(--accent);
}
.task-detail-tool pre {
font-size: 11px;
line-height: 1.5;
margin: 4px 0 0;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
}
.task-detail-tool pre.tool-output { color: var(--text-tertiary); }
.task-detail-meta {
font-size: 11px;
color: var(--text-tertiary);
text-align: right;
}
/* 分栏标题 */