mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat(docker): 配置同步+性格注入+Gateway认证
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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 = '添加'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user