feat(docker): 配置同步+性格注入+Gateway认证

This commit is contained in:
晴天
2026-03-09 06:24:21 +08:00
parent 727903f94b
commit a084e23671
6 changed files with 258 additions and 133 deletions

View File

@@ -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 连接 GatewayNode 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 tokenGateway 启动时自动生成)
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 连接 GatewayNode 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.hostOpenClaw 不认识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]

View File

@@ -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 = '<div style="padding:8px;color:var(--text-tertiary);font-size:12px">loading...</div>'
dd.innerHTML = '<div style="padding:8px;color:var(--text-tertiary);font-size:12px">加载中...</div>'
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' ? '<span class="instance-badge">Docker</span>' : inst.type === 'remote' ? '<span class="instance-badge">Remote</span>' : ''
const badge = inst.type === 'docker' ? '<span class="instance-badge docker">🦞 龙虾</span>' : inst.type === 'remote' ? '<span class="instance-badge remote">远程</span>' : ''
html += `<div class="instance-option${active}" data-id="${inst.id}">
<span class="instance-dot ${dot}"></span>
<span class="instance-opt-name">${_escSidebar(inst.name)}</span>
@@ -295,7 +294,7 @@ async function _toggleInstanceDropdown(sidebarEl) {
</div>`
}
html += '<div class="instance-divider"></div>'
html += '<div class="instance-option instance-add" id="btn-instance-add">+ Add Instance</div>'
html += '<div class="instance-option instance-add" id="btn-instance-add">+ 添加实例</div>'
dd.innerHTML = html
} catch (e) {
dd.innerHTML = `<div style="padding:8px;color:var(--error);font-size:12px">${_escSidebar(e.message)}</div>`
@@ -307,27 +306,27 @@ async function _showAddInstanceDialog(sidebarEl) {
overlay.className = 'docker-dialog-overlay'
overlay.innerHTML = `
<div class="docker-dialog">
<div class="docker-dialog-title">Add Instance</div>
<div class="docker-dialog-title">添加远程实例</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">Name</label>
<input class="form-input" id="inst-name" placeholder="My Server" />
<label class="form-label">名称</label>
<input class="form-input" id="inst-name" placeholder="远程服务器" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">Panel Endpoint</label>
<label class="form-label">面板地址</label>
<input class="form-input" id="inst-endpoint" placeholder="http://192.168.1.100:1420" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">Gateway Port (optional)</label>
<label class="form-label">Gateway 端口(可选)</label>
<input class="form-input" id="inst-gw-port" type="number" value="18789" />
</div>
<div class="docker-dialog-hint">
The remote server must be running ClawPanel (serve.js).<br/>
Example: <code>http://192.168.1.100:1420</code>
远程服务器需要运行 ClawPanel (serve.js)<br/>
示例: <code>http://192.168.1.100:1420</code>
</div>
<div id="inst-add-error" style="color:var(--error);font-size:12px;margin-top:var(--space-sm)"></div>
<div class="docker-dialog-actions">
<button class="btn btn-secondary btn-sm" id="inst-cancel">Cancel</button>
<button class="btn btn-primary btn-sm" id="inst-confirm">Add</button>
<button class="btn btn-secondary btn-sm" id="inst-cancel">取消</button>
<button class="btn btn-primary btn-sm" id="inst-confirm">添加</button>
</div>
</div>
`
@@ -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 = '添加'
}
}
}

View File

@@ -42,6 +42,7 @@ const PATHS = {
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'send': '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
'download': '<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'upload': '<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/>',
'radio': '<circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 010 8.49m-8.48-.01a6 6 0 010-8.49m11.31-2.82a10 10 0 010 14.14m-14.14 0a10 10 0 010-14.14"/>',
'lightbulb': '<line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0018 8 6 6 0 006 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 018.91 14"/>',

View File

@@ -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 }),

View File

@@ -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) {
<div class="unit-actions">
${isRunning
? `<button class="btn-icon" data-action="stop" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="休整">${icon('stop', 14)}</button>
<button class="btn-icon" data-action="restart" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="整编">${icon('refresh-cw', 14)}</button>`
<button class="btn-icon" data-action="restart" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="整编">${icon('refresh-cw', 14)}</button>
<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="inspect" data-ct="${esc(c.id)}" data-node="${esc(c.nodeId)}" title="军情">${icon('search', 14)}</button>
@@ -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')

View File

@@ -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);