From a084e236712a1756a54fb9de11bc8c9a43838691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Mon, 9 Mar 2026 06:24:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(docker):=20=E9=85=8D=E7=BD=AE=E5=90=8C?= =?UTF-8?q?=E6=AD=A5+=E6=80=A7=E6=A0=BC=E6=B3=A8=E5=85=A5+Gateway=E8=AE=A4?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/dev-api.js | 207 ++++++++++++++++++++++++++++++++++++-- src/components/sidebar.js | 35 ++++--- src/lib/icons.js | 1 + src/lib/tauri-api.js | 3 +- src/pages/docker.js | 137 +++++++------------------ src/style/layout.css | 8 ++ 6 files changed, 258 insertions(+), 133 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index ff5cb4a..82f7c51 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -614,6 +614,22 @@ async function instanceHealthCheck(instance) { } catch {} return result } + // Docker 类型实例:通过 Docker API 检查容器状态 + if (instance.type === 'docker' && instance.containerId) { + try { + const nodes = readDockerNodes() + const node = instance.nodeId ? nodes.find(n => n.id === instance.nodeId) : nodes[0] + if (node) { + const resp = await dockerRequest('GET', `/containers/${instance.containerId}/json`, null, node.endpoint) + if (resp.status < 400 && resp.data?.State?.Running) { + result.online = true + result.gatewayRunning = true + } + } + } catch {} + return result + } + if (!instance.endpoint) return result try { const resp = await fetch(`${instance.endpoint}/__api/check_installation`, { @@ -651,7 +667,7 @@ const ALWAYS_LOCAL = new Set([ 'instance_health_check', 'instance_health_all', '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_gateway_chat', 'docker_pull_image', 'docker_pull_status', + 'docker_remove_container', 'docker_container_logs', 'docker_container_exec', 'docker_init_worker', 'docker_gateway_chat', 'docker_pull_image', 'docker_pull_status', 'docker_list_images', 'docker_list_nodes', 'docker_add_node', 'docker_remove_node', 'docker_cluster_overview', 'auth_check', 'auth_login', 'auth_logout', @@ -1002,18 +1018,62 @@ const handlers = { if (!gwBinding || !gwBinding[0]?.HostPort) throw new Error('该容器没有暴露 Gateway 端口 (18789)') const gwPort = gwBinding[0].HostPort - // 2. 通过 WebSocket 连接 Gateway(Node 22 内置 WebSocket) + // 2. TCP 端口预检 — 快速判断 Gateway 是否在监听 + const containerName = resp.data?.Name?.replace(/^\//, '') || containerId.slice(0, 12) + await new Promise((resolve, reject) => { + const sock = net.connect({ host: '127.0.0.1', port: gwPort, timeout: 5000 }) + sock.on('connect', () => { sock.destroy(); resolve() }) + sock.on('timeout', () => { sock.destroy(); reject(new Error(`Gateway 端口 ${gwPort} 无响应 — 容器 ${containerName} 内的 Gateway 可能未启动,请检查容器日志`)) }) + sock.on('error', (e) => { reject(new Error(`Gateway 端口 ${gwPort} 不可达 — ${e.code === 'ECONNREFUSED' ? `容器 ${containerName} 内的 Gateway 未启动` : e.message}`)) }) + }) + + // 2b. 从容器配置中读取 Gateway auth token(Gateway 启动时自动生成) + let gatewayToken = '' + try { + const tokenExec = await dockerRequest('POST', `/containers/${containerId}/exec`, { + AttachStdout: true, AttachStderr: true, + Cmd: ['sh', '-c', 'node -e "const c=JSON.parse(require(\'fs\').readFileSync(\'/root/.openclaw/openclaw.json\',\'utf8\'));process.stdout.write(c.gateway?.auth?.token||\'\')"'] + }, node.endpoint) + const tokenExecId = tokenExec.data?.Id + if (tokenExecId) { + const tokenResp = await new Promise((ok, no) => { + const opts = { path: `/exec/${tokenExecId}/start`, method: 'POST', headers: { 'Content-Type': 'application/json' } } + if (node.endpoint && node.endpoint.startsWith('tcp://')) { + const url = new URL(node.endpoint.replace('tcp://', 'http://')) + opts.hostname = url.hostname + opts.port = parseInt(url.port) || 2375 + } else { + opts.socketPath = node.endpoint || DOCKER_SOCKET + } + const req = http.request(opts, res => { + let d = '' + res.on('data', c => d += c.toString().replace(/[\x00-\x08]/g, '')) + res.on('end', () => ok(d.trim())) + }) + req.on('error', () => ok('')) + req.write(JSON.stringify({ Detach: false, Tty: false })) + req.end() + }) + gatewayToken = tokenResp.replace(/[^a-zA-Z0-9_\-\.]/g, '') + if (gatewayToken) console.log(`[gateway-chat] 读取到 auth token: ${gatewayToken.slice(0, 8)}...`) + } + } catch (e) { + console.warn(`[gateway-chat] 读取 auth token 失败: ${e.message}`) + } + + // 3. 通过 WebSocket 连接 Gateway(Node 22 内置 WebSocket) return new Promise((resolve, reject) => { const wsUrl = `ws://127.0.0.1:${gwPort}/ws` let ws - try { ws = new WebSocket(wsUrl) } catch (e) { return reject(new Error(`无法连接 Gateway: ${e.message}`)) } + try { ws = new WebSocket(wsUrl) } catch (e) { return reject(new Error(`无法创建 WebSocket: ${e.message}`)) } let result = '', handshakeOk = false, sessionKey = 'agent:main:cluster-task', done = false - const timer = setTimeout(() => { if (!done) { done = true; ws.close(); reject(new Error('Gateway 通信超时')) } }, timeout) + const timer = setTimeout(() => { if (!done) { done = true; ws.close(); reject(new Error('Gateway 通信超时(120s)')) } }, timeout) + // 如果 3s 内没收到 challenge,主动发 connect const challengeTimer = setTimeout(() => { if (!handshakeOk) doConnect('') }, 3000) function doConnect(nonce) { try { - const frame = handlers.create_connect_frame({ nonce, gatewayToken: '' }) + const frame = handlers.create_connect_frame({ nonce, gatewayToken }) ws.send(JSON.stringify(frame)) } catch { ws.send(JSON.stringify({ type: 'req', id: 'connect-1', method: 'connect', params: {} })) @@ -1028,6 +1088,10 @@ const handlers = { })) } + ws.addEventListener('open', () => { + console.log(`[gateway-chat] WebSocket 已连接 ${wsUrl}`) + }) + ws.addEventListener('message', (evt) => { let msg try { msg = JSON.parse(typeof evt.data === 'string' ? evt.data : evt.data.toString()) } catch { return } @@ -1043,13 +1107,18 @@ const handlers = { clearTimeout(challengeTimer) if (msg.ok) { handshakeOk = true + console.log(`[gateway-chat] 握手成功: ${containerName}`) const defaults = msg.payload?.snapshot?.sessionDefaults if (defaults?.mainSessionKey) sessionKey = defaults.mainSessionKey else sessionKey = `agent:${defaults?.defaultAgentId || 'main'}:cluster-task` sendChat() } else { done = true; clearTimeout(timer); ws.close() - reject(new Error(msg.error?.message || 'Gateway 握手失败')) + const errMsg = msg.error?.message || '' + if (errMsg.includes('origin not allowed') || errMsg.includes('not paired')) + reject(new Error(`Gateway 需要设备配对 — 请先在容器 ${containerName} 的面板中完成配对`)) + else + reject(new Error(errMsg || 'Gateway 握手失败')) } return } @@ -1068,30 +1137,146 @@ const handlers = { } return } - // chat.send 确认 + // chat.send 响应 if (msg.type === 'res' && !msg.id?.startsWith('connect')) { if (!msg.ok) { done = true; clearTimeout(timer); ws.close() - reject(new Error(msg.error?.message || '任务发送失败')) + const errMsg = msg.error?.message || '任务发送失败' + if (errMsg.includes('no model') || errMsg.includes('model')) + reject(new Error(`${containerName}: 未配置模型 — 请先在容器面板中配置 AI 模型`)) + else + reject(new Error(errMsg)) } } }) - ws.addEventListener('error', () => { - if (!done) { done = true; clearTimeout(timer); clearTimeout(challengeTimer); reject(new Error(`无法连接 ${wsUrl}`)) } + ws.addEventListener('error', (e) => { + if (!done) { + done = true; clearTimeout(timer); clearTimeout(challengeTimer) + reject(new Error(`WebSocket 连接失败 ${wsUrl}: ${e.message || '连接被拒绝'}`)) + } }) ws.addEventListener('close', (e) => { clearTimeout(timer); clearTimeout(challengeTimer) if (!done) { done = true if (result) resolve({ ok: true, result }) - else if (e.code === 4001 || e.code === 4003) reject(new Error('Gateway 认证失败')) + else if (e.code === 4001 || e.code === 4003) reject(new Error(`Gateway 认证失败 — 请检查容器 ${containerName} 的 Token 配置`)) else resolve({ ok: true, result: result || '(无回复)' }) } }) }) }, + async docker_init_worker({ nodeId, containerId, role = 'general' } = {}) { + if (!containerId) throw new Error('缺少 containerId') + const nodes = readDockerNodes() + const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0] + if (!node) throw new Error('节点不存在') + + const results = { config: false, personality: false, files: [] } + + // helper: base64 encode string + const b64 = (s) => Buffer.from(s, 'utf8').toString('base64') + + // helper: exec command in container + const cExec = async (cmd) => { + const createResp = await dockerRequest('POST', `/containers/${containerId}/exec`, { + AttachStdout: true, AttachStderr: true, Cmd: ['sh', '-c', cmd] + }, node.endpoint) + if (createResp.status >= 400) throw new Error(`exec 失败: ${createResp.status}`) + const execId = createResp.data?.Id + if (!execId) return + await dockerRequest('POST', `/exec/${execId}/start`, { Detach: true }, node.endpoint) + // 给 exec 一点时间完成 + await new Promise(r => setTimeout(r, 300)) + } + + // 1. 同步 openclaw.json(模型 + API Key 配置) + try { + if (fs.existsSync(CONFIG_PATH)) { + const localConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + // 只同步 OpenClaw 认识的字段,避免 Unrecognized key 导致 Gateway 崩溃 + const syncConfig = {} + if (localConfig.meta) syncConfig.meta = localConfig.meta // 保持原始 meta,不加自定义字段 + if (localConfig.env) syncConfig.env = localConfig.env + if (localConfig.models) syncConfig.models = localConfig.models + if (localConfig.auth) syncConfig.auth = localConfig.auth + // Gateway 配置:只设置 controlUi(允许连接),不复制 host/bind 等本机特定字段 + syncConfig.gateway = { + port: 18789, + mode: 'local', + bind: 'lan', + controlUi: { allowedOrigins: ['*'] }, + } + + const configB64 = b64(JSON.stringify(syncConfig, null, 2)) + await cExec(`mkdir -p /root/.openclaw && echo '${configB64}' | base64 -d > /root/.openclaw/openclaw.json`) + results.config = true + results.files.push('openclaw.json') + console.log(`[init-worker] 配置已同步 → ${containerId.slice(0, 12)}`) + } + } catch (e) { + console.warn(`[init-worker] 配置同步失败: ${e.message}`) + } + + // 2. 角色性格注入(SOUL.md + IDENTITY.md + AGENTS.md) + try { + // 角色性格模板 + const ROLE_SOULS = { + general: { identity: '# 龙虾步兵\n通用作战单位,隶属统帅龙虾军团', soul: '# 龙虾步兵 · 性格\n\n## 核心\n- 忠诚可靠,执行力强\n- 能处理各类任务:写作、编程、翻译、分析\n- 回复简洁专业\n- 主动报告任务进展\n\n## 边界\n- 尊重隐私,不泄露信息\n- 不确定时先询问统帅\n- 每次回复聚焦任务本身' }, + coder: { identity: '# 龙虾突击兵\n编程作战专家,隶属统帅龙虾军团', soul: '# 龙虾突击兵 · 性格\n\n## 核心\n- 精通多种编程语言和框架\n- 代码质量第一,回复包含可运行示例\n- 擅长调试、重构、Code Review\n- 主动提示潜在问题和最佳实践\n\n## 边界\n- 修改文件前先理解上下文\n- 不跳过测试\n- 不引入不必要的依赖' }, + translator: { identity: '# 龙虾翻译官\n多语言作战专家,隶属统帅龙虾军团', soul: '# 龙虾翻译官 · 性格\n\n## 核心\n- 精通中英日韩法德西等主流语言互译\n- 追求信达雅,翻译精准\n- 保留原文语境和风格\n- 对专业术语严格把关\n\n## 边界\n- 不确定的术语标注原文\n- 不过度意译\n- 保持文体一致性' }, + writer: { identity: '# 龙虾文书官\n写作任务专家,隶属统帅龙虾军团', soul: '# 龙虾文书官 · 性格\n\n## 核心\n- 文思敏捷,创意丰富\n- 能调整语气适应不同场景\n- 精通博客、技术文档、营销文案等\n- 善于讲故事,引人入胜\n\n## 边界\n- 不抄袭\n- 保持原创性\n- 注重可读性和准确性' }, + analyst: { identity: '# 龙虾参谋\n数据分析专家,隶属统帅龙虾军团', soul: '# 龙虾参谋 · 性格\n\n## 核心\n- 逻辑清晰,善用数据说话\n- 结论有理有据,给出可行建议\n- 善用图表和结构化格式呈现\n- 擅长统计分析、商业分析、竞品分析\n\n## 边界\n- 不编造数据\n- 区分相关性和因果性\n- 标注不确定性' }, + custom: { identity: '# 龙虾特种兵\n特殊任务执行者,隶属统帅龙虾军团', soul: '# 龙虾特种兵 · 性格\n\n## 核心\n- 灵活多变,适应力强\n- 按需配置技能\n- 不拘泥形式,主动寻找最优解\n\n## 边界\n- 行动前确认方向\n- 不超出授权范围' }, + } + + const roleSoul = ROLE_SOULS[role] || ROLE_SOULS.general + + // 每个兵种独立的 AGENTS.md(操作指令) + const ROLE_AGENTS = { + general: '# 操作指令\n\n你是龙虾军团的步兵,接受统帅通过 ClawPanel 下达的任务指令。\n\n## 规则\n- 收到任务后立即执行,完成后简要汇报结果\n- 如果任务不清楚,先确认再行动\n- 保持回复简洁,重点突出\n- 你有独立的记忆空间,会自动记录重要信息', + coder: '# 操作指令\n\n你是龙虾军团的突击兵,专精编程作战。\n\n## 规则\n- 收到编程任务后,先分析需求再写代码\n- 代码必须可运行,包含必要的注释\n- 主动进行错误处理和边界检查\n- 如果涉及多个文件,说明修改顺序\n- 完成后给出测试建议\n\n## 专长\n- 全栈开发、API 设计、数据库优化\n- Bug 定位与修复、代码重构\n- 性能优化、安全审计', + translator: '# 操作指令\n\n你是龙虾军团的翻译官,专精多语言互译。\n\n## 规则\n- 翻译要信达雅,保持原文风格\n- 专业术语保留原文标注\n- 长文分段翻译,保持上下文一致\n- 文学作品注重意境传达\n- 技术文档注重准确性\n\n## 专长\n- 中英日韩法德西等主流语言\n- 技术文档、文学作品、商务邮件', + writer: '# 操作指令\n\n你是龙虾军团的文书官,专精写作任务。\n\n## 规则\n- 根据场景调整语气和风格\n- 注重结构清晰、逻辑连贯\n- 创意写作要有个性和亮点\n- 技术文档要准确严谨\n- 营销文案要抓住痛点\n\n## 专长\n- 博客文章、技术文档、营销文案\n- 故事创作、剧本、诗歌\n- SEO 优化、社交媒体内容', + analyst: '# 操作指令\n\n你是龙虾军团的参谋,专精数据分析和战略规划。\n\n## 规则\n- 用数据说话,结论必须有依据\n- 区分事实、推断和假设\n- 善用表格和结构化格式呈现\n- 给出可执行的建议\n- 标注不确定性和风险\n\n## 专长\n- 市场分析、竞品研究、用户画像\n- 数据可视化、统计分析\n- 商业计划、策略建议', + custom: '# 操作指令\n\n你是龙虾军团的特种兵,执行特殊任务。\n\n## 规则\n- 灵活应对各类非标准任务\n- 行动前确认方向\n- 不超出授权范围\n- 主动寻找最优解决方案', + } + + const wsFiles = { + 'SOUL.md': roleSoul.soul, + 'IDENTITY.md': roleSoul.identity, + 'AGENTS.md': ROLE_AGENTS[role] || ROLE_AGENTS.general, + } + + // 写入兵种专属文件(不复制本机的 TOOLS.md/USER.md/记忆,每个士兵独立发展) + await cExec('mkdir -p /root/.openclaw/workspace') + for (const [fname, content] of Object.entries(wsFiles)) { + const encoded = b64(content) + await cExec(`echo '${encoded}' | base64 -d > /root/.openclaw/workspace/${fname}`) + results.files.push(`workspace/${fname}`) + } + results.personality = true + console.log(`[init-worker] 兵种配置注入完成 (${role}) → ${containerId.slice(0, 12)}`) + } catch (e) { + console.warn(`[init-worker] 兵种配置注入失败: ${e.message}`) + } + + // 5. 清理无效字段 + 重启 Gateway + try { + // entrypoint 会 sed 注入 gateway.host(OpenClaw 不认识),doctor --fix 清理 + await cExec('openclaw doctor --fix 2>/dev/null || true') + // 停止旧 Gateway,启动新的(合并为一条命令确保 exec 会话存活足够久) + await cExec('pkill -f openclaw-gateway 2>/dev/null; pkill -f "openclaw gateway" 2>/dev/null; sleep 1; mkdir -p /root/.openclaw/logs; nohup openclaw gateway >> /root/.openclaw/logs/gateway.log 2>&1 & sleep 3') + console.log(`[init-worker] Gateway 已重启 → ${containerId.slice(0, 12)}`) + } catch (e) { + console.warn(`[init-worker] Gateway 重启失败: ${e.message}`) + } + + return results + }, + async docker_container_exec({ nodeId, containerId, cmd } = {}) { const nodes = readDockerNodes() const node = nodeId ? nodes.find(n => n.id === nodeId) : nodes[0] diff --git a/src/components/sidebar.js b/src/components/sidebar.js index 62b3aaf..37274c8 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -43,7 +43,7 @@ const NAV_ITEMS_FULL = [ { section: '龙虾军团', items: [ - { route: '/docker', label: '🦞 龙虾军团', icon: 'docker' }, + { route: '/docker', label: '龙虾军团', icon: 'docker' }, ] }, { @@ -64,10 +64,9 @@ const NAV_ITEMS_SETUP = [ ] }, { - section: '扩展', + section: '', items: [ { route: '/extensions', label: '扩展工具', icon: 'extensions' }, - { route: '/skills', label: 'Skills', icon: 'skills' }, ] }, { @@ -275,7 +274,7 @@ async function _toggleInstanceDropdown(sidebarEl) { if (!dd) return if (dd.classList.contains('open')) { dd.classList.remove('open'); return } - dd.innerHTML = '
loading...
' + dd.innerHTML = '
加载中...
' dd.classList.add('open') try { @@ -287,7 +286,7 @@ async function _toggleInstanceDropdown(sidebarEl) { const h = healthMap[inst.id] || {} const active = inst.id === activeId ? ' active' : '' const dot = h.online !== false ? 'online' : 'offline' - const badge = inst.type === 'docker' ? 'Docker' : inst.type === 'remote' ? 'Remote' : '' + const badge = inst.type === 'docker' ? '🦞 龙虾' : inst.type === 'remote' ? '远程' : '' html += `
${_escSidebar(inst.name)} @@ -295,7 +294,7 @@ async function _toggleInstanceDropdown(sidebarEl) {
` } html += '
' - html += '
+ Add Instance
' + html += '
+ 添加实例
' dd.innerHTML = html } catch (e) { dd.innerHTML = `
${_escSidebar(e.message)}
` @@ -307,27 +306,27 @@ async function _showAddInstanceDialog(sidebarEl) { overlay.className = 'docker-dialog-overlay' overlay.innerHTML = `
-
Add Instance
+
添加远程实例
- - + +
- +
- +
- The remote server must be running ClawPanel (serve.js).
- Example: http://192.168.1.100:1420 + 远程服务器需要运行 ClawPanel (serve.js)。
+ 示例: http://192.168.1.100:1420
- - + +
` @@ -339,16 +338,16 @@ async function _showAddInstanceDialog(sidebarEl) { const endpoint = overlay.querySelector('#inst-endpoint').value.trim() const gwPort = parseInt(overlay.querySelector('#inst-gw-port').value) || 18789 const errEl = overlay.querySelector('#inst-add-error') - if (!name || !endpoint) { errEl.textContent = 'Name and endpoint are required'; return } + if (!name || !endpoint) { errEl.textContent = '请填写名称和面板地址'; return } const btn = overlay.querySelector('#inst-confirm') - btn.disabled = true; btn.textContent = 'Adding...' + btn.disabled = true; btn.textContent = '添加中...' try { await api.instanceAdd({ name, type: 'remote', endpoint, gatewayPort: gwPort }) overlay.remove() renderSidebar(sidebarEl) } catch (e) { errEl.textContent = e.message || String(e) - btn.disabled = false; btn.textContent = 'Add' + btn.disabled = false; btn.textContent = '添加' } } } diff --git a/src/lib/icons.js b/src/lib/icons.js index 857aa69..bfbefaa 100644 --- a/src/lib/icons.js +++ b/src/lib/icons.js @@ -42,6 +42,7 @@ const PATHS = { 'clock': '', 'send': '', 'download': '', + 'upload': '', 'inbox': '', 'radio': '', 'lightbulb': '', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 2d7a2bb..81bdddd 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -26,7 +26,7 @@ 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_gateway_chat', 'docker_pull_image', 'docker_pull_status', + 'docker_remove_container', 'docker_container_logs', 'docker_container_exec', 'docker_init_worker', 'docker_gateway_chat', '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', @@ -422,6 +422,7 @@ export const api = { dockerRemoveContainer: (nodeId, containerId, force = false) => { invalidate('docker_cluster_overview', 'docker_list_containers'); return invoke('docker_remove_container', { nodeId, containerId, force }) }, 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 }), dockerPullImage: (nodeId, image, tag, requestId) => invoke('docker_pull_image', { nodeId, image, tag, requestId }), dockerPullStatus: (requestId) => invoke('docker_pull_status', { requestId }), diff --git a/src/pages/docker.js b/src/pages/docker.js index 99db4e1..90e9fd0 100644 --- a/src/pages/docker.js +++ b/src/pages/docker.js @@ -41,101 +41,6 @@ function isManagedContainer(c) { return isOpenClawContainer(c) || getAdoptedIds().has(c.id) } -// 兵种性格模板 — 部署后写入容器 workspace 的 SOUL.md 和 IDENTITY.md -const ROLE_SOULS = { - general: { - identity: '龙虾步兵 · 通用作战单位', - soul: `你是一名通用龙虾步兵,隶属于统帅的龙虾军团。 -## 核心能力 -- 能处理各类任务:写作、编程、翻译、分析 -- 灵活应变,根据任务类型自动调整工作方式 -## 性格 -- 忠诚可靠,执行力强 -- 回复简洁专业,不废话 -- 主动报告任务进展和结果`, - }, - coder: { - identity: '龙虾突击兵 · 编程作战专家', - soul: `你是一名编程突击兵,隶属于统帅的龙虾军团,专精代码作战。 -## 核心能力 -- 精通多种编程语言和框架 -- 擅长代码编写、调试、重构和 Code Review -- 能快速定位 Bug 并提供修复方案 -## 性格 -- 严谨精确,代码质量第一 -- 回复包含可运行的代码示例 -- 主动提示潜在问题和最佳实践`, - }, - translator: { - identity: '龙虾翻译官 · 多语言作战专家', - soul: `你是一名翻译官,隶属于统帅的龙虾军团,精通多国语言。 -## 核心能力 -- 精通中英日韩法德西等主流语言互译 -- 理解文化差异,翻译自然流畅 -- 支持技术文档、文学作品、商务邮件等多种文体 -## 性格 -- 追求信达雅,翻译精准 -- 保留原文语境和风格 -- 对专业术语严格把关`, - }, - writer: { - identity: '龙虾文书官 · 写作任务专家', - soul: `你是一名文书官,隶属于统帅的龙虾军团,笔下生花。 -## 核心能力 -- 擅长文案撰写、文章创作、创意写作 -- 能调整语气风格适应不同场景 -- 精通各种文体:博客、新闻稿、技术文档、营销文案 -## 性格 -- 文思敏捷,创意丰富 -- 注重可读性和表达力 -- 善于讲故事,引人入胜`, - }, - analyst: { - identity: '龙虾参谋 · 数据分析专家', - soul: `你是一名参谋,隶属于统帅的龙虾军团,运筹帷幄。 -## 核心能力 -- 擅长数据分析、趋势判断和战略规划 -- 能从复杂信息中提炼关键洞察 -- 精通统计分析、商业分析、竞品分析 -## 性格 -- 逻辑清晰,善用数据说话 -- 结论有理有据,给出可行建议 -- 善于用图表和结构化格式呈现分析结果`, - }, - custom: { - identity: '龙虾特种兵 · 特殊任务执行者', - soul: `你是一名特种兵,隶属于统帅的龙虾军团,执行特殊任务。 -## 核心能力 -- 按统帅需求灵活配置技能 -- 适应各种非标准任务场景 -- 快速学习和适应新领域 -## 性格 -- 灵活多变,适应力强 -- 完成任务不拘泥于形式 -- 主动寻找最优解决方案`, - }, -} - -/** - * 部署后注入角色性格到容器 workspace - * @param {string} nodeId - * @param {string} containerId - * @param {string} role - */ -async function _injectRolePersonality(nodeId, containerId, role) { - const soul = ROLE_SOULS[role] || ROLE_SOULS.general - try { - // 创建 workspace 目录并写入 IDENTITY.md 和 SOUL.md - await api.dockerContainerExec(nodeId, containerId, [ - 'sh', '-c', - `mkdir -p /root/.openclaw/workspace && echo '${soul.identity}' > /root/.openclaw/workspace/IDENTITY.md && cat > /root/.openclaw/workspace/SOUL.md << 'CLAWEOF'\n${soul.soul}\nCLAWEOF` - ]) - console.log(`[cluster] 角色性格已注入: ${role} → ${containerId}`) - } catch (e) { - console.warn(`[cluster] 角色性格注入失败: ${e.message}`) - } -} - // 军事化术语 & 兵种系统 const MILITARY = { roles: { @@ -406,7 +311,8 @@ function _renderUnitCard(c, showAdopt) {
${isRunning ? ` - ` + + ` : `` } @@ -779,6 +685,24 @@ function bindEvents(page) { return } + if (action === 'sync-config') { + const cid = btn.dataset.ct + const nid = btn.dataset.node || null + const name = btn.dataset.name || cid + const role = btn.dataset.role || 'general' + toast(`正在同步配置到 ${name}...`, 'info') + try { + const result = await api.dockerInitWorker(nid, cid, role) + const count = result?.files?.length || 0 + // docker_init_worker 内部已重启 Gateway,不需要重启容器(重启会触发 entrypoint 覆盖配置) + toast(`${name}: 已同步 ${count} 个文件,Gateway 已重启`, 'success') + setTimeout(() => refreshCluster(page), 3000) + } catch (e) { + toast(`${name} 同步失败: ${e.message}`, 'error') + } + return + } + if (action === 'quick-chat') { const cid = btn.dataset.containerId const nid = btn.dataset.nodeId || null @@ -1240,18 +1164,25 @@ async function showDeployDialog(page, nodeId) { stepCreate.classList.add('done') createDetail.textContent = '完成' - // Step 3: 启动完成 + // Step 3: 启动 + 初始化 stepStart.classList.add('active') startDetail.textContent = '启动中...' - await new Promise(r => setTimeout(r, 1000)) + await new Promise(r => setTimeout(r, 1500)) - // 注入角色性格(SOUL.md + IDENTITY.md) + // 全套初始化:配置同步 + 性格注入 + 记忆同步 + MCP const selectedRoleForInject = overlay.querySelector('#dd-role')?.value || 'general' - if (selectedRoleForInject !== 'custom') { - startDetail.textContent = '注入角色性格...' - const cid = result.id || result.containerId || name - await _injectRolePersonality(nodeId, cid, selectedRoleForInject) + const cid = result.id || result.containerId || name + try { + startDetail.textContent = '同步配置 & 注入性格...' + const initResult = await api.dockerInitWorker(nodeId, cid, selectedRoleForInject) + const synced = initResult?.files?.length || 0 + startDetail.textContent = `已同步 ${synced} 个文件` + console.log('[deploy] 初始化结果:', initResult) + } catch (e) { + console.warn('[deploy] 初始化警告:', e.message) + startDetail.textContent = '初始化部分失败(不影响运行)' } + await new Promise(r => setTimeout(r, 500)) stepStart.classList.remove('active') stepStart.classList.add('done') diff --git a/src/style/layout.css b/src/style/layout.css index d8354f6..c05b496 100644 --- a/src/style/layout.css +++ b/src/style/layout.css @@ -143,6 +143,14 @@ color: var(--text-tertiary); flex-shrink: 0; } +.instance-badge.docker { + background: rgba(231,76,60,.1); + color: #e74c3c; +} +.instance-badge.remote { + background: rgba(6,182,212,.1); + color: #06b6d4; +} .instance-divider { height: 1px; background: var(--border-secondary);