mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +08:00
feat(docker): 完善龙虾军团任务调度与提示
This commit is contained in:
1042
scripts/dev-api.js
1042
scripts/dev-api.js
File diff suppressed because it is too large
Load Diff
44
src/lib/docker-tasking.js
Normal file
44
src/lib/docker-tasking.js
Normal 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
|
||||
}
|
||||
@@ -1,32 +1,17 @@
|
||||
/**
|
||||
* Tauri API 封装层
|
||||
* 开发阶段用 mock 数据,Tauri 环境用 invoke
|
||||
* Tauri 环境用 invoke,Web 模式走 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 }),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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?.()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 分栏标题 */
|
||||
|
||||
Reference in New Issue
Block a user