feat: improve gateway compatibility and complete i18n cleanup

This commit is contained in:
晴天
2026-04-01 15:06:25 +08:00
parent 57b8b25946
commit b427a6b000
59 changed files with 6830 additions and 964 deletions

View File

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

View 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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
}

View File

@@ -3,12 +3,19 @@
* Tauri 环境用 invokeWeb 模式走 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'),