mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-06 00:00:06 +08:00
feat: improve gateway compatibility and complete i18n cleanup
This commit is contained in:
@@ -129,7 +129,8 @@ export async function detectOpenclawStatus() {
|
||||
|
||||
// 顺便检测 Gateway 运行状态
|
||||
if (services.status === 'fulfilled' && services.value?.length > 0) {
|
||||
_setGatewayRunning(services.value[0]?.running === true)
|
||||
const gw = services.value.find?.(s => s.label === 'ai.openclaw.gateway') || services.value[0]
|
||||
_setGatewayRunning(gw?.running === true && gw?.owned_by_current_instance !== false)
|
||||
}
|
||||
} catch {
|
||||
_openclawReady = false
|
||||
@@ -176,13 +177,17 @@ async function _tryAutoRestart() {
|
||||
// 重启前再次确认端口确实空闲,防止端口被其他程序占用时无限拉起
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
const gw = services?.[0]
|
||||
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0]
|
||||
if (gw?.running) {
|
||||
console.log('[guardian] 端口仍在使用中,跳过自动重启')
|
||||
console.log(gw?.owned_by_current_instance === false
|
||||
? '[guardian] 检测到外部 Gateway 正在占用端口,跳过自动重启'
|
||||
: '[guardian] 端口仍在使用中,跳过自动重启')
|
||||
_gwStopCount = 0
|
||||
_gatewayRunning = true
|
||||
_gatewayRunningSince = Date.now()
|
||||
_gwListeners.forEach(fn => { try { fn(true) } catch {} })
|
||||
if (gw?.owned_by_current_instance !== false) {
|
||||
_gatewayRunning = true
|
||||
_gatewayRunningSince = Date.now()
|
||||
_gwListeners.forEach(fn => { try { fn(true) } catch {} })
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
@@ -204,7 +209,10 @@ export async function refreshGatewayStatus() {
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
if (services?.length > 0) {
|
||||
const nowRunning = services[0]?.running === true
|
||||
const gw = services.find?.(s => s.label === 'ai.openclaw.gateway') || services[0]
|
||||
const ownedRunning = gw?.running === true && gw?.owned_by_current_instance !== false
|
||||
const foreignRunning = gw?.running === true && gw?.owned_by_current_instance === false
|
||||
const nowRunning = ownedRunning
|
||||
if (nowRunning) {
|
||||
_gwStopCount = 0
|
||||
if (!_gatewayRunning) {
|
||||
@@ -217,8 +225,12 @@ export async function refreshGatewayStatus() {
|
||||
_autoRestartCount = 0
|
||||
}
|
||||
} else {
|
||||
_gwStopCount++
|
||||
if (_gwStopCount >= 2 || !_gatewayRunning) {
|
||||
if (foreignRunning) {
|
||||
_gwStopCount = 0
|
||||
} else {
|
||||
_gwStopCount++
|
||||
}
|
||||
if (foreignRunning || _gwStopCount >= 2 || !_gatewayRunning) {
|
||||
_setGatewayRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
197
src/lib/gateway-ownership.js
Normal file
197
src/lib/gateway-ownership.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { api } from './tauri-api.js'
|
||||
import { showContentModal } from '../components/modal.js'
|
||||
import { t } from './i18n.js'
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function cliSourceLabel(source) {
|
||||
if (source === 'standalone') return t('dashboard.cliSourceStandalone')
|
||||
if (source === 'npm-zh') return t('dashboard.cliSourceNpmZh')
|
||||
if (source === 'npm-official') return t('dashboard.cliSourceNpmOfficial')
|
||||
if (source === 'npm-global') return t('dashboard.cliSourceNpmGlobal')
|
||||
return t('dashboard.cliSourceUnknown')
|
||||
}
|
||||
|
||||
function openclawInstallationIdentity(installation) {
|
||||
const rawPath = String(installation?.path || '').trim()
|
||||
if (!rawPath) return ''
|
||||
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
|
||||
if (!isWin) return rawPath
|
||||
return rawPath
|
||||
.replace(/\//g, '\\')
|
||||
.replace(/\\openclaw(?:\.exe|\.ps1)?$/i, '\\openclaw.cmd')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function dedupeOpenclawInstallations(list = []) {
|
||||
const map = new Map()
|
||||
const preferCmd = inst => /openclaw\.cmd$/i.test(String(inst?.path || ''))
|
||||
for (const installation of Array.isArray(list) ? list : []) {
|
||||
const key = openclawInstallationIdentity(installation)
|
||||
if (!key) continue
|
||||
const existing = map.get(key)
|
||||
if (!existing || (!existing.active && installation.active) || (!preferCmd(existing) && preferCmd(installation))) {
|
||||
map.set(key, installation)
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function readBoundCliPath(panelConfig) {
|
||||
return String(panelConfig?.openclawCliPath || '').trim()
|
||||
}
|
||||
|
||||
let _foreignGatewayPromptKey = ''
|
||||
|
||||
export function isForeignGatewayService(service) {
|
||||
return service?.ownership === 'foreign' || (service?.running === true && service?.owned_by_current_instance === false)
|
||||
}
|
||||
|
||||
export function isForeignGatewayError(error) {
|
||||
const text = String(error?.message || error || '')
|
||||
return text.includes('不属于当前面板实例')
|
||||
|| text.includes('误接管')
|
||||
|| text.includes('其他 OpenClaw Gateway')
|
||||
}
|
||||
|
||||
export async function maybeShowForeignGatewayBindingPrompt({ service = null, onRefresh = null } = {}) {
|
||||
if (!isForeignGatewayService(service)) {
|
||||
_foreignGatewayPromptKey = ''
|
||||
return false
|
||||
}
|
||||
const panelConfig = await api.readPanelConfig().catch(() => null)
|
||||
if (readBoundCliPath(panelConfig)) {
|
||||
return false
|
||||
}
|
||||
const promptKey = `${service?.label || 'ai.openclaw.gateway'}::${service?.pid || 'unknown'}::${service?.ownership || 'foreign'}`
|
||||
if (_foreignGatewayPromptKey === promptKey) {
|
||||
return false
|
||||
}
|
||||
_foreignGatewayPromptKey = promptKey
|
||||
await showGatewayConflictGuidance({ service, onRefresh })
|
||||
return true
|
||||
}
|
||||
|
||||
export async function showGatewayConflictGuidance({ error = null, service = null, onRefresh = null, reason = null } = {}) {
|
||||
const [versionInfo, dirInfo, panelConfig] = await Promise.all([
|
||||
api.getVersionInfo().catch(() => null),
|
||||
api.getOpenclawDir().catch(() => null),
|
||||
api.readPanelConfig().catch(() => null),
|
||||
])
|
||||
|
||||
const currentCli = versionInfo?.cli_path || t('common.unknown')
|
||||
const currentCliSource = cliSourceLabel(versionInfo?.cli_source)
|
||||
const currentDir = dirInfo?.path || t('common.unknown')
|
||||
const boundCliPath = readBoundCliPath(panelConfig)
|
||||
const displayBoundCliPath = boundCliPath || t('services.guidanceCliBindingAuto')
|
||||
const installations = dedupeOpenclawInstallations(Array.isArray(versionInfo?.all_installations) ? versionInfo.all_installations : [])
|
||||
const message = error ? escapeHtml(String(error.message || error)) : ''
|
||||
const pid = service?.pid || null
|
||||
const hasForeignGateway = reason === 'foreign-gateway'
|
||||
|| (!!error && reason !== 'multiple-installations')
|
||||
|| (reason !== 'multiple-installations' && isForeignGatewayService(service))
|
||||
const hasUnboundForeignGateway = hasForeignGateway && !boundCliPath
|
||||
const hasMultiInstall = reason === 'multiple-installations' || installations.length > 1
|
||||
const settingsLabel = t('sidebar.settings')
|
||||
const title = hasUnboundForeignGateway
|
||||
? t('services.guidanceTitleForeignUnbound')
|
||||
: hasForeignGateway
|
||||
? t('services.guidanceTitleForeign')
|
||||
: hasMultiInstall
|
||||
? t('services.guidanceTitleMultiInstall')
|
||||
: t('services.guidanceTitleCheck')
|
||||
const summaryText = hasUnboundForeignGateway
|
||||
? t('services.guidanceSummaryForeignUnbound')
|
||||
: hasForeignGateway
|
||||
? t('services.guidanceSummaryForeign')
|
||||
: hasMultiInstall
|
||||
? t('services.guidanceSummaryMultiInstall')
|
||||
: t('services.guidanceSummaryCheck')
|
||||
const suggestionOne = hasUnboundForeignGateway
|
||||
? t('services.guidanceSuggestionBindAutoDetected', { settings: settingsLabel })
|
||||
: hasForeignGateway
|
||||
? t('services.guidanceSuggestionBindForeign', { settings: settingsLabel })
|
||||
: t('services.guidanceSuggestionBind', { settings: settingsLabel })
|
||||
const suggestionTwo = hasForeignGateway
|
||||
? t('services.guidanceSuggestionStopForeign')
|
||||
: t('services.guidanceSuggestionRefresh')
|
||||
const suggestionThree = t('services.guidanceSuggestionInstallations')
|
||||
const settingsButtonLabel = hasUnboundForeignGateway ? t('services.guidanceBindCliBtn') : t('sidebar.settings')
|
||||
|
||||
const installationHtml = installations.length
|
||||
? installations.map(inst => {
|
||||
const badges = [
|
||||
inst.active ? `<span class="clawhub-badge" style="font-size:11px">${escapeHtml(t('settings.cliActive'))}</span>` : '',
|
||||
inst.version ? `<span class="clawhub-badge" style="font-size:11px">${escapeHtml(t('settings.cliVersion'))}: ${escapeHtml(inst.version)}</span>` : '',
|
||||
inst.source ? `<span class="clawhub-badge" style="font-size:11px">${escapeHtml(cliSourceLabel(inst.source))}</span>` : '',
|
||||
].filter(Boolean).join(' ')
|
||||
return `
|
||||
<div style="padding:10px 12px;border:1px solid var(--border-light);border-radius:10px;background:var(--bg-secondary);margin-top:8px">
|
||||
<div style="font-size:12px;word-break:break-all;font-family:var(--font-mono)">${escapeHtml(inst.path)}</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">${badges}</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
: `<div style="padding:10px 12px;border:1px dashed var(--border-light);border-radius:10px;background:var(--bg-secondary);margin-top:8px;color:var(--text-secondary)">${escapeHtml(t('services.guidanceNoInstallations', { settings: settingsLabel }))}</div>`
|
||||
|
||||
const content = `
|
||||
<div style="display:flex;flex-direction:column;gap:12px;font-size:var(--font-size-sm);color:var(--text-secondary);line-height:1.7">
|
||||
<div style="padding:12px;border-radius:10px;background:rgba(245,158,11,0.12);color:var(--warning)">
|
||||
${escapeHtml(summaryText)}
|
||||
</div>
|
||||
${message ? `<div style="padding:10px 12px;border-radius:10px;background:var(--bg-secondary);font-family:var(--font-mono);word-break:break-all">${message}</div>` : ''}
|
||||
<div style="display:grid;grid-template-columns:1fr;gap:8px">
|
||||
<div><strong>${escapeHtml(t('services.guidanceCurrentBindingTitle'))}</strong><div style="margin-top:4px;font-family:var(--font-mono);word-break:break-all">${escapeHtml(displayBoundCliPath)}</div></div>
|
||||
<div><strong>${escapeHtml(t('settings.openclawCli'))}</strong><div style="margin-top:4px;font-family:var(--font-mono);word-break:break-all">${escapeHtml(currentCli)}</div><div style="margin-top:4px;color:var(--text-tertiary)">${escapeHtml(currentCliSource)}</div></div>
|
||||
<div><strong>${escapeHtml(t('settings.openclawDir'))}</strong><div style="margin-top:4px;font-family:var(--font-mono);word-break:break-all">${escapeHtml(currentDir)}</div></div>
|
||||
${pid ? `<div><strong>PID</strong><div style="margin-top:4px">${escapeHtml(pid)}</div></div>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<strong>${escapeHtml(t('services.guidanceHandlingTitle'))}</strong>
|
||||
<div style="margin-top:6px">
|
||||
${escapeHtml(suggestionOne)}
|
||||
</div>
|
||||
<div style="margin-top:6px">
|
||||
${escapeHtml(suggestionTwo)}
|
||||
</div>
|
||||
<div style="margin-top:6px">
|
||||
${escapeHtml(suggestionThree)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>${escapeHtml(t('services.guidanceInstallationsTitle'))}</strong>
|
||||
${installationHtml}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const overlay = showContentModal({
|
||||
title,
|
||||
content,
|
||||
width: 760,
|
||||
buttons: [
|
||||
{ id: 'gateway-conflict-open-settings', label: settingsButtonLabel, className: 'btn btn-primary btn-sm' },
|
||||
{ id: 'gateway-conflict-refresh', label: t('services.refreshStatus'), className: 'btn btn-secondary btn-sm' },
|
||||
],
|
||||
})
|
||||
|
||||
overlay.querySelector('#gateway-conflict-open-settings')?.addEventListener('click', () => {
|
||||
overlay.close()
|
||||
window.location.hash = '#/settings'
|
||||
})
|
||||
|
||||
overlay.querySelector('#gateway-conflict-refresh')?.addEventListener('click', async () => {
|
||||
overlay.close()
|
||||
if (typeof onRefresh === 'function') {
|
||||
await onRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
return overlay
|
||||
}
|
||||
@@ -3,12 +3,19 @@
|
||||
* Tauri 环境用 invoke,Web 模式走 dev-api 后端
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js'
|
||||
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
// 仅在 Node.js 后端实现的命令(Tauri Rust 不处理),强制走 webInvoke
|
||||
const WEB_ONLY_CMDS = new Set([
|
||||
'instance_list', 'instance_add', 'instance_remove', 'instance_set_active',
|
||||
'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_pull_image', 'docker_pull_status',
|
||||
'docker_list_images', 'docker_list_nodes', 'docker_add_node',
|
||||
'docker_remove_node', 'docker_cluster_overview',
|
||||
'get_deploy_mode',
|
||||
])
|
||||
|
||||
@@ -76,6 +83,10 @@ function cachedInvoke(cmd, args = {}, ttl = CACHE_TTL) {
|
||||
|
||||
// 清除指定命令的缓存(写操作后调用)
|
||||
function invalidate(...cmds) {
|
||||
if (!cmds.length) {
|
||||
_cache.clear()
|
||||
return
|
||||
}
|
||||
for (const [k] of _cache) {
|
||||
if (cmds.some(c => k.startsWith(c))) _cache.delete(k)
|
||||
}
|
||||
@@ -110,12 +121,12 @@ async function webInvoke(cmd, args) {
|
||||
if (resp.status === 401) {
|
||||
// Tauri 模式下不触发登录浮层(Tauri 有自己的认证流程)
|
||||
if (!isTauri && window.__clawpanel_show_login) window.__clawpanel_show_login()
|
||||
throw new Error('需要登录')
|
||||
throw new Error(t('common.loginRequired'))
|
||||
}
|
||||
// 检测后端是否可用:如果返回的是 HTML(非 JSON),说明后端未运行
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase()
|
||||
if (ct.includes('text/html') || ct.includes('text/plain')) {
|
||||
throw new Error('后端服务未运行,该功能需要 Web 部署模式')
|
||||
throw new Error(t('common.backendWebModeRequired'))
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }))
|
||||
@@ -195,10 +206,15 @@ export const api = {
|
||||
|
||||
// Agent 管理
|
||||
listAgents: () => cachedInvoke('list_agents'),
|
||||
getAgentDetail: (id) => cachedInvoke('get_agent_detail', { id }, 5000),
|
||||
listAgentFiles: (id) => cachedInvoke('list_agent_files', { id }, 5000),
|
||||
readAgentFile: (id, name) => invoke('read_agent_file', { id, name }),
|
||||
writeAgentFile: (id, name, content) => { invalidate('list_agent_files', 'read_agent_file'); return invoke('write_agent_file', { id, name, content }) },
|
||||
updateAgentConfig: (id, config) => { invalidate('list_agents', 'get_agent_detail'); return invoke('update_agent_config', { id, config }) },
|
||||
addAgent: (name, model, workspace) => { invalidate('list_agents'); return invoke('add_agent', { name, model, workspace: workspace || null }) },
|
||||
deleteAgent: (id) => { invalidate('list_agents'); return invoke('delete_agent', { id }) },
|
||||
updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents'); return invoke('update_agent_identity', { id, name, emoji }) },
|
||||
updateAgentModel: (id, model) => { invalidate('list_agents'); return invoke('update_agent_model', { id, model }) },
|
||||
deleteAgent: (id) => { invalidate('list_agents', 'get_agent_detail'); return invoke('delete_agent', { id }) },
|
||||
updateAgentIdentity: (id, name, emoji) => { invalidate('list_agents', 'get_agent_detail'); return invoke('update_agent_identity', { id, name, emoji }) },
|
||||
updateAgentModel: (id, model) => { invalidate('list_agents', 'get_agent_detail'); return invoke('update_agent_model', { id, model }) },
|
||||
backupAgent: (id) => invoke('backup_agent', { id }),
|
||||
|
||||
// 日志(短缓存)
|
||||
@@ -234,14 +250,14 @@ export const api = {
|
||||
getAgentBindings: (agentId) => invoke('get_agent_bindings', { agentId }),
|
||||
listAllBindings: () => invoke('list_all_bindings'),
|
||||
saveAgentBinding: (agentId, channel, accountId, bindingConfig) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('save_agent_binding', { agentId, channel, accountId: accountId || null, bindingConfig: bindingConfig || {} }) },
|
||||
deleteAgentBinding: (agentId, channel, accountId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_binding', { agentId, channel, accountId: accountId || null }) },
|
||||
deleteAgentBinding: (agentId, channel, accountId, bindingConfig) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_binding', { agentId, channel, accountId: accountId || null, bindingConfig: bindingConfig || null }) },
|
||||
deleteAgentAllBindings: (agentId) => { invalidate('read_openclaw_config', 'list_configured_platforms'); return invoke('delete_agent_all_bindings', { agentId }) },
|
||||
|
||||
// 面板配置 (clawpanel.json)
|
||||
getOpenclawDir: () => invoke('get_openclaw_dir'),
|
||||
relaunchApp: () => invoke('relaunch_app'),
|
||||
readPanelConfig: () => invoke('read_panel_config'),
|
||||
writePanelConfig: (config) => invoke('write_panel_config', { config }),
|
||||
writePanelConfig: (config) => { invalidate(); return invoke('write_panel_config', { config }).then(r => { invoke('invalidate_path_cache').catch(() => {}); return r }) },
|
||||
testProxy: (url) => invoke('test_proxy', { url: url || null }),
|
||||
|
||||
// 安装/部署
|
||||
@@ -249,7 +265,9 @@ export const api = {
|
||||
initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') },
|
||||
checkNode: () => cachedInvoke('check_node', {}, 60000),
|
||||
checkNodeAtPath: (nodeDir) => invoke('check_node_at_path', { nodeDir }),
|
||||
checkOpenclawAtPath: (cliPath) => invoke('check_openclaw_at_path', { cliPath }),
|
||||
scanNodePaths: () => invoke('scan_node_paths'),
|
||||
scanOpenclawPaths: () => invoke('scan_openclaw_paths'),
|
||||
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node', 'get_services_status'); invoke('invalidate_path_cache').catch(() => {}); return r }),
|
||||
invalidatePathCache: () => invoke('invalidate_path_cache'),
|
||||
checkGit: () => cachedInvoke('check_git', {}, 60000),
|
||||
@@ -307,6 +325,22 @@ export const api = {
|
||||
instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
|
||||
instanceHealthAll: () => invoke('instance_health_all'),
|
||||
|
||||
// Docker 管理(当前由 Web/dev-api 提供)
|
||||
dockerInfo: (nodeId) => invoke('docker_info', { nodeId: nodeId || null }),
|
||||
dockerListContainers: (nodeId, all = true) => invoke('docker_list_containers', { nodeId: nodeId || null, all }),
|
||||
dockerCreateContainer: (payload) => invoke('docker_create_container', payload || {}),
|
||||
dockerStartContainer: (nodeId, containerId) => invoke('docker_start_container', { nodeId: nodeId || null, containerId }),
|
||||
dockerStopContainer: (nodeId, containerId) => invoke('docker_stop_container', { nodeId: nodeId || null, containerId }),
|
||||
dockerRestartContainer: (nodeId, containerId) => invoke('docker_restart_container', { nodeId: nodeId || null, containerId }),
|
||||
dockerRemoveContainer: (nodeId, containerId, force = false) => invoke('docker_remove_container', { nodeId: nodeId || null, containerId, force }),
|
||||
dockerPullImage: (payload) => invoke('docker_pull_image', payload || {}),
|
||||
dockerPullStatus: (requestId) => invoke('docker_pull_status', { requestId }),
|
||||
dockerListImages: (nodeId) => invoke('docker_list_images', { nodeId: nodeId || null }),
|
||||
dockerListNodes: () => invoke('docker_list_nodes', {}),
|
||||
dockerAddNode: (name, endpoint) => invoke('docker_add_node', { name, endpoint }),
|
||||
dockerRemoveNode: (nodeId) => invoke('docker_remove_node', { nodeId }),
|
||||
dockerClusterOverview: () => invoke('docker_cluster_overview', {}),
|
||||
|
||||
|
||||
// 前端热更新
|
||||
checkFrontendUpdate: () => invoke('check_frontend_update'),
|
||||
|
||||
Reference in New Issue
Block a user