/**
* 初始设置页面 — openclaw 未安装时的引导
* 自动检测环境 → 版本选择 → 一键安装 → 自动跳转
*/
import { api, invalidate, isTauriRuntime, safeTauriListen } from '../lib/tauri-api.js'
import { showConfirm, showUpgradeModal } from '../components/modal.js'
import { toast } from '../components/toast.js'
import { setUpgrading, isMacPlatform } from '../lib/app-state.js'
import { getActiveEngine } from '../lib/engine-manager.js'
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
import { icon, statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
function escapeHtml(str) {
if (str == null) return ''
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
function openclawSourceLabel(src) {
return ({
portable: t('dashboard.cliSourcePortable'),
standalone: t('dashboard.cliSourceStandalone'),
'npm-zh': t('dashboard.cliSourceNpmZh'),
'npm-official': t('dashboard.cliSourceNpmOfficial'),
'npm-global': t('dashboard.cliSourceNpmGlobal'),
})[src] || t('dashboard.cliSourceUnknown')
}
function parseOpenclawSearchPaths(raw) {
const values = []
const seen = new Set()
for (const part of String(raw || '').split(/[\r\n;]+/)) {
const value = part.trim()
if (!value) continue
const key = value.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
values.push(value)
}
return values
}
function buildStatusMeta(...parts) {
return parts
.map(part => String(part || '').trim())
.filter(Boolean)
.join(' · ')
}
function isWindowsPlatform() {
return navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
}
function isLinuxPlatform() {
return navigator.platform?.toLowerCase().includes('linux') || navigator.userAgent?.toLowerCase().includes('linux')
}
function canAutoUpgradeNode() {
return isTauriRuntime() && isWindowsPlatform()
}
function nodeRuntimeHint(nodeTooOld) {
if (!nodeTooOld) return t('setup.winNodeHint')
if (isWindowsPlatform()) return t('setup.winNodeUpgradeHint')
if (isMacPlatform()) return t('setup.macNodeUpgradeHint')
if (isLinuxPlatform()) return t('setup.linuxNodeUpgradeHint')
return t('setup.genericNodeUpgradeHint')
}
function nodePathPlaceholder() {
if (isMacPlatform()) return '/usr/local/bin'
if (isLinuxPlatform()) return '/usr/bin'
return 'F:\\AI\\Node'
}
function normalizeNodeUpgradeLog(line) {
const text = String(line || '').trimEnd()
if (!text.trim()) return null
if (/[█▓▒░]/.test(text)) return null
if (/^[\s\\|/\-]+$/.test(text)) return null
return text
}
function renderDetectionHint(pathValue, sourceLabel = '') {
const normalizedPath = String(pathValue || '').trim()
const normalizedSource = String(sourceLabel || '').trim()
if (!normalizedPath && !normalizedSource) return ''
return `
${normalizedPath ? `
${t('setup.detectedPathLabel')}: ${escapeHtml(normalizedPath)}
` : ''}
${normalizedSource ? `
${t('setup.detectedFromLabel')}: ${escapeHtml(normalizedSource)}
` : ''}
`
}
function renderStatusCard(title, ok, meta) {
return `
${ok ? '✓' : '✦'}
${title}
${escapeHtml(meta)}
`
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
${t('setup.headerTitle')}
${t('setup.headerDesc')}
`
page.querySelector('#btn-recheck').addEventListener('click', () => runDetect(page))
// 便携模式徽标:enabled 才显示;状态在进程生命周期内固定,接口失败静默
api.getPortableStatus().then(st => {
if (!st?.enabled) return
const el = page.querySelector('#setup-portable-badge')
if (!el) return
const warnings = (st.warnings || []).map(w => {
if (w === 'node-missing') return t('setup.portableWarnNode')
if (w === 'cli-missing') return t('setup.portableWarnCli')
if (w === 'hermes-cli-missing') return t('setup.portableWarnHermesCli')
if (typeof w === 'string' && w.startsWith('absolute-path:')) {
return t('setup.portableWarnAbsPath', { field: w.slice('absolute-path:'.length) })
}
return String(w)
})
el.style.display = 'block'
el.innerHTML = `
💾 ${t('setup.portableBadge')} · ${escapeHtml(st.root || '')}
${warnings.length ? `${warnings.map(escapeHtml).join('
')}
` : ''}
`
}).catch(() => {})
// #Compat-4: 用户在浏览器里手动装完 Node.js 后切回 panel,或用户装完 Git/OpenClaw
// 后 app 失焦又重新获得焦点时,自动重新检测,避免「装完不识别」。
// handler 自带 guard:page 从 DOM 移除后自动卸载监听器,防止跨页面泄漏。
// 同时监听 visibilitychange(tab 切换)和 window focus(桌面端窗口激活),兜底不同平台行为。
let _lastRedetectAt = 0
const onVisibilityChange = () => {
if (!page.isConnected) {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('focus', onVisibilityChange)
return
}
if (document.visibilityState !== 'visible') return
// 3 秒内不重复触发(避免 focus + visibilitychange 同时连发)
const now = Date.now()
if (now - _lastRedetectAt < 3000) return
_lastRedetectAt = now
// 聚焦重检走 silent:需要刷新缓存(可能刚装完 Node/Git),但不清空已渲染内容
runDetect(page, { silent: true })
}
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('focus', onVisibilityChange)
// 首次进入:跳过缓存清理,尽快出首屏
runDetect(page, { fresh: false })
return page
}
async function maybeRefreshGatewayServiceBinding() {
if (!isMacPlatform()) return false
const [versionInfo, dirInfo] = await Promise.all([
api.getVersionInfo().catch(() => null),
api.getOpenclawDir().catch(() => null),
])
if (!versionInfo?.cli_path || dirInfo?.configExists === false) {
return false
}
const shouldRefresh = await showConfirm(t('settings.gatewayServiceRefreshConfirm'))
if (!shouldRefresh) return false
toast(t('settings.gatewayServiceRefreshing'), 'info')
try {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
const shouldStartAgain = gw?.running === true && gw?.owned_by_current_instance !== false
await api.uninstallGateway().catch(() => {})
await api.installGateway()
if (shouldStartAgain) {
await api.startService('ai.openclaw.gateway')
}
toast(t('settings.gatewayServiceRefreshed'), 'success')
return true
} catch (e) {
toast(`${t('settings.gatewayServiceRefreshFailed')}: ${e?.message || e}`, 'warning')
return false
}
}
async function promptRestart(msg) {
if (!window.__TAURI_INTERNALS__) { toast(msg, 'success'); return }
const ok = await showConfirm(`${msg}\n\n${t('settings.restartConfirm')}`)
if (ok) {
toast(t('settings.restarting'), 'info')
try { await api.relaunchApp() } catch { toast(t('settings.restartFailed'), 'warning') }
} else {
toast(`${msg}, ${t('settings.effectNextLaunch')}`, 'success')
}
}
// 检测序号:并发触发(首次加载 / 重新检测 / 窗口聚焦)时,只允许最新一次的结果上屏
let _detectSeq = 0
async function runDetect(page, { fresh = true, silent = false } = {}) {
const seq = ++_detectSeq
const stepsEl = page.querySelector('#setup-steps')
// silent(窗口聚焦触发的后台重检):保留已渲染的步骤卡片,检测完成后原地更新,
// 不再每次聚焦都清成骨架屏,避免"一直在检测"的观感
const hasRenderedContent = stepsEl.children.length > 0 && !stepsEl.querySelector('.loading-placeholder')
if (!silent || !hasRenderedContent) {
stepsEl.innerHTML = `
`
}
// 首次进入页面(fresh=false)跳过缓存清理:进程刚启动时 PATH 缓存是新鲜的,
// 还能复用启动流程已缓存的检测结果,让首屏尽快出现;
// 手动「重新检测」、窗口聚焦重检和安装后的重试仍然完整清理(fresh=true)。
if (fresh) {
// 清除前端 invoke 缓存
invalidate('get_version_info', 'check_node', 'check_git', 'get_services_status', 'check_installation')
// #Compat-4: 同步刷新 Rust 端 PATH 缓存 + CLI 检测缓存
// 用户手动装完 Node.js/Git 后,Tauri 进程的 PATH 仍是启动时快照,且 enhanced_path 有缓存。
// 必须先调此命令扫描文件系统新装路径,才能让 where/which 找到新二进制。
try { await api.invalidatePathCache() } catch {}
}
try {
// 版本信息包含 npm registry 网络查询与全盘安装扫描,是最慢的一项;
// 步骤卡片的通过/失败判定不依赖它,先用本地检测结果渲染,版本详情返回后再补充
const versionPromise = api.getVersionInfo().catch(() => null)
// 并行检测 Node.js、Git、OpenClaw CLI、配置文件
const [nodeRes, gitRes, clawRes, configRes] = await Promise.allSettled([
api.checkNode(),
api.checkGit(),
api.getServicesStatus(),
api.checkInstallation(),
])
if (seq !== _detectSeq || !page.isConnected) return
const node = nodeRes.status === 'fulfilled' ? nodeRes.value : { installed: false }
const git = gitRes.status === 'fulfilled' ? gitRes.value : { installed: false }
const cliOk = clawRes.status === 'fulfilled'
&& clawRes.value?.length > 0
&& clawRes.value[0]?.cli_installed !== false
let config = configRes.status === 'fulfilled' ? configRes.value : { installed: false }
// Git 已安装时,自动配置 HTTPS 替代 SSH(静默执行)
if (git.installed) {
api.configureGitHttps().catch(() => {})
}
const nodeOk = node.installed && node.compatible !== false
const allOk = nodeOk && cliOk && config.installed
// 全部通过 → 自动跳转到仪表盘
if (allOk) {
const engine = getActiveEngine()
if (engine?.detect) await engine.detect()
window.location.hash = '/dashboard'
return
}
renderSteps(page, { node, git, cliOk, config, version: null })
const version = await versionPromise
if (seq !== _detectSeq || !page.isConnected || !version) return
renderSteps(page, { node, git, cliOk, config, version })
} catch (error) {
// 兜底:任何异常都不能让骨架屏永久残留
console.error('[setup] 环境检测失败', error)
if (seq !== _detectSeq || !page.isConnected) return
stepsEl.innerHTML = `
${escapeHtml(error?.message || String(error))}
${escapeHtml(t('setup.recheck'))}
`
}
}
function stepIcon(ok) {
const color = ok ? 'var(--success)' : 'var(--text-tertiary)'
return `${ok ? '✓' : '✗'}`
}
function renderSteps(page, { node, git, cliOk, config, version }) {
const stepsEl = page.querySelector('#setup-steps')
const nodeOk = node.installed && node.compatible !== false
const gitOk = git?.installed || false
const allOk = nodeOk && cliOk && config.installed
const nodeStatusMeta = node.installed && node.compatible === false
? t('setup.nodeVersionUnsupported', { version: node.version || t('common.unknown'), required: node.requiredVersion || t('common.unknown') })
: nodeOk
? buildStatusMeta(node.version || t('setup.statusReady'), node.path)
: t('setup.statusActionNeeded')
const gitStatusMeta = gitOk
? buildStatusMeta(git.version || t('setup.statusReady'), git.path)
: t('setup.statusActionNeeded')
const cliPrimaryMeta = cliOk
? buildStatusMeta(version?.cli_source ? openclawSourceLabel(version.cli_source) : '', version?.current ? `v${version.current}` : t('setup.statusReady'))
: ''
const cliStatusMeta = cliOk
? buildStatusMeta(cliPrimaryMeta, version?.cli_path)
: t('setup.statusActionNeeded')
const configStatusMeta = config.installed
? (config.path || t('setup.statusReady'))
: t('setup.statusActionNeeded')
const statusCards = [
renderStatusCard(t('setup.stepNode'), nodeOk, nodeStatusMeta),
renderStatusCard(t('setup.stepGit'), gitOk, gitStatusMeta),
renderStatusCard('OpenClaw CLI', cliOk, cliStatusMeta),
renderStatusCard(t('setup.stepConfig'), config.installed, configStatusMeta),
].join('')
let html = `
${statusCards}
`
// 第一步:Node.js
if (!nodeOk) {
const nodeTooOld = node.installed && node.compatible === false
html += `
${stepIcon(nodeOk)} ${t('setup.stepNode')}
${nodeTooOld ? t('setup.nodeUpgradeHint', { version: node.version || t('common.unknown'), required: node.requiredVersion || t('common.unknown') }) : t('setup.stepNodeHint')}
${nodeTooOld && canAutoUpgradeNode()
? `
`
: ''}
${nodeTooOld ? t('setup.downloadLatestNode') : t('setup.downloadNode')}
${t('setup.recheckAfterInstall')}
${nodeTooOld ? t('setup.nodeUnsupportedTitle') : t('setup.nodeInstalledButNotDetected')}
${isMacPlatform()
? `${nodeTooOld ? t('setup.macNodeUpgradeHint') : t('setup.macNodeHint')}
open /Applications/ClawPanel.app`
: `${nodeRuntimeHint(nodeTooOld)}`
}
${t('setup.orManualPath')}
`
}
// 第二步:Git
if (!gitOk) {
html += `
${stepIcon(gitOk)} ${t('setup.stepGit')}
${t('setup.stepGitHint')}
${t('setup.gitOptionalHint')}
`
}
// 第三步:OpenClaw CLI
html += `
${stepIcon(cliOk)} OpenClaw CLI
${cliOk
? `
${t('setup.cliAvailable')}
${renderDetectionHint(version?.cli_path, version?.cli_source ? openclawSourceLabel(version.cli_source) : '')}
${version?.ahead_of_recommended && version?.recommended
? `
${t('setup.cliAheadWarning', { current: version.current || '', recommended: version.recommended })}
`
: ''}`
: renderInstallSection()
}
`
html += `
`
// 第四步:配置文件 + 自定义路径
html += `
${stepIcon(config.installed)} ${t('setup.stepConfig')}
${config.installed
? `
${t('setup.configAt', { path: config.path || '' })}
${renderDetectionHint(config.path)}`
: `
${t('setup.configMissing')}
${renderDetectionHint(config.path)}
`
}
${t('setup.customDirTitle')}
${t('setup.customDirHint')}
${t('setup.customDirNotice')}
`
// AI 助手入口
html += `
${t('setup.aiAssistant')}
${t('setup.aiAssistantDesc')}${!allOk ? t('setup.aiAssistantDescProblem') : ''}。
${!allOk ? `
` : ''}
`
html += `
`
if (!cliOk) {
html += renderEnvironmentHint()
}
// 全部就绪 → 进入面板
if (allOk) {
html += `
${t('setup.nextStepsTitle')}
${t('setup.nextStepsDesc')}
- ${t('setup.nextStep1')}
- ${t('setup.nextStep2')}
- ${t('setup.nextStep3')}
`
}
stepsEl.innerHTML = html
bindEvents(page, nodeOk, { node, git, cliOk, config })
}
function renderInstallSection() {
return `
${t('setup.searchOpenclawTitle')}
${t('setup.searchOpenclawDesc')}
${t('setup.searchOpenclawHint')}
${t('setup.searchOpenclawAdvancedTitle')}
${t('setup.searchOpenclawAdvancedHint')}
${t('setup.searchOpenclawExtraPathsHint')}
${t('setup.installOpenclaw')}
${t('setup.installHint')}
${t('setup.installHint2')}
`
}
function renderEnvironmentHint() {
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
const isMac = navigator.platform?.startsWith('Mac') || navigator.userAgent?.includes('Macintosh')
const isDesktop = !!window.__TAURI_INTERNALS__
if (!isDesktop) return ''
return `
${t('setup.envHintTitle')}
${t('setup.envHintDesc')}
${t('setup.envHintInstallManage')}
${isWin ? `
- ${t('setup.envHintWsl')} — ${t('setup.envHintWslDesc')}
- ${t('setup.envHintDocker')} — ${t('setup.envHintDockerDesc')}
` : ''}
${isMac ? `
- ${t('setup.envHintDocker')} — ${t('setup.envHintDockerDesc')}
- ${t('setup.envHintRemote')} — ${t('setup.envHintRemoteDesc')}
` : ''}
${!isWin && !isMac ? `
- ${t('setup.envHintDocker')} — ${t('setup.envHintDockerDesc')}
` : ''}
${isWin ? `
${t('setup.wslWebHint')}
${t('setup.wslWebDesc')}
curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash
${t('setup.domesticMirror')} curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash
${t('setup.wslWebPostDeploy')}
` : ''}
${t('setup.dockerHint')}
${t('setup.dockerDesc')}
npm i -g @qingchencloud/openclaw-zh
curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash
${t('setup.domesticMirrorShort')} curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash
${t('setup.remoteHint')}
${t('setup.remoteDesc')}
curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash
${t('setup.domesticMirrorShort')} curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash
${t('setup.envHintLocalReinstall')}
`
}
function buildSetupProblemPrompt({ node, git, cliOk, config }) {
const problems = []
if (!node.installed) problems.push(`- ${t('setup.promptNodeMissing')}`)
else if (node.compatible === false) problems.push(`- ${t('setup.promptNodeUnsupported', { version: node.version || t('common.unknown'), required: node.requiredVersion || t('common.unknown') })}`)
else problems.push(`- ${t('setup.promptNodeOk', { version: node.version || t('common.unknown') })}`)
if (!git?.installed) problems.push(`- ${t('setup.promptGitMissing')}`)
else problems.push(`- ${t('setup.promptGitOk', { version: git.version || t('common.unknown') })}`)
if (!cliOk) problems.push(`- ${t('setup.promptCliMissing')}`)
else problems.push(`- ${t('setup.promptCliOk')}`)
if (!config.installed) problems.push(`- ${t('setup.promptConfigMissing')}`)
else problems.push(`- ${t('setup.promptConfigOk', { path: config.path || '' })}`)
return `${t('setup.promptIntro')}
${problems.join('\n')}
${t('setup.promptOutro')}`
}
function bindEvents(page, nodeOk, detectState) {
// 打开 AI 助手
page.querySelector('#btn-goto-assistant')?.addEventListener('click', () => {
window.location.hash = '/assistant'
})
// 让 AI 帮我解决(带问题上下文)
page.querySelector('#btn-ask-ai-help')?.addEventListener('click', () => {
if (detectState) {
const prompt = buildSetupProblemPrompt(detectState)
sessionStorage.setItem('assistant-auto-prompt', prompt)
}
window.location.hash = '/assistant'
})
// 进入面板(刷新引擎 ready 状态,触发侧边栏更新)
async function refreshAndNavigate(route) {
const engine = getActiveEngine()
if (engine?.detect) await engine.detect()
window.location.hash = route
}
page.querySelector('#btn-enter')?.addEventListener('click', () => refreshAndNavigate('/dashboard'))
page.querySelector('#btn-goto-models')?.addEventListener('click', () => refreshAndNavigate('/models'))
page.querySelector('#btn-goto-gateway')?.addEventListener('click', () => refreshAndNavigate('/gateway'))
page.querySelector('#btn-goto-channels')?.addEventListener('click', () => refreshAndNavigate('/channels'))
// 一键安装 Git
page.querySelector('#btn-auto-install-git')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-auto-install-git')
const resultEl = page.querySelector('#git-install-result')
btn.disabled = true
btn.textContent = t('setup.installingGit')
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `${t('setup.gitInstallingHint')}`
}
try {
const msg = await api.autoInstallGit()
if (resultEl) resultEl.innerHTML = `✓ ${msg}`
toast(t('setup.gitInstallSuccess'), 'success')
// 安装成功后自动配置 HTTPS
api.configureGitHttps().catch(() => {})
setTimeout(() => runDetect(page), 1000)
} catch (e) {
const errMsg = String(e.message || e)
if (resultEl) {
resultEl.innerHTML = `
${t('setup.gitAutoInstallFailed', { err: errMsg })}
${t('setup.gitManualHint')}
${t('setup.gitManualInstallHtml')}
`
}
toast(t('setup.gitAutoInstallFailedToast'), 'warning')
} finally {
btn.disabled = false
btn.textContent = t('setup.autoInstallGitBtn')
}
})
// 一键安装 / 升级 Node.js
page.querySelector('#btn-auto-install-node')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-auto-install-node')
const yes = await showConfirm({
title: t('setup.nodeUpgradeConfirmTitle'),
message: t('setup.nodeUpgradeConfirmMessage'),
impact: [
t('setup.nodeUpgradeConfirmImpactWinget'),
t('setup.nodeUpgradeConfirmImpactPermission'),
t('setup.nodeUpgradeConfirmImpactRedetect'),
],
confirmText: t('setup.autoUpgradeNodeBtn'),
cancelText: t('common.cancel'),
variant: 'primary',
})
if (!yes) return
const modal = showUpgradeModal(t('setup.nodeUpgradeTitle'))
modal.setProgressLabels({
preparing: t('setup.nodeUpgradePreparing'),
downloading: t('setup.nodeUpgradeInstalling'),
installing: t('setup.nodeUpgradeVerifying'),
done: t('setup.nodeUpgradeDone'),
})
modal.setProgress(15)
modal.appendLog(t('setup.nodeUpgradeStarting'))
let unlistenLog, unlistenProgress
const cleanup = () => {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}
setUpgrading(true)
btn.disabled = true
btn.textContent = t('setup.upgradingNode')
try {
unlistenLog = await safeTauriListen('upgrade-log', (e) => {
const line = normalizeNodeUpgradeLog(e.payload)
if (line) modal.appendLog(line)
})
unlistenProgress = await safeTauriListen('upgrade-progress', (e) => modal.setProgress(e.payload))
const msg = await api.autoInstallNode()
modal.setProgress(100)
modal.setDone(msg || t('setup.nodeUpgradeSuccess'))
modal.setCloseText(t('common.completed'))
modal.appendLog(t('setup.nodeUpgradeRedetecting'))
modal.appendLog(t('setup.nodeUpgradeStartGatewayHint'))
toast(msg || t('setup.nodeUpgradeSuccess'), 'success')
await api.invalidatePathCache().catch(() => {})
setTimeout(() => runDetect(page), 800)
} catch (e) {
const errMsg = String(e?.message || e)
modal.setError(t('setup.nodeAutoUpgradeFailedTitle'))
modal.appendLog(errMsg)
modal.appendLog('')
modal.appendLog(t('setup.nodeManualInstallHint'))
modal.appendLog('https://nodejs.org/')
modal.appendLog(t('setup.nodeUpgradeRestartHint'))
toast(t('setup.nodeAutoUpgradeFailed', { err: errMsg }), 'error')
btn.disabled = false
btn.textContent = t('setup.autoUpgradeNodeBtn')
} finally {
cleanup()
}
})
// 自定义 OpenClaw 安装路径
const dirInput = page.querySelector('#input-openclaw-dir')
const dirResultEl = page.querySelector('#openclaw-dir-result')
// 预填当前自定义路径
if (dirInput) {
api.getOpenclawDir().then(info => {
if (info.isCustom) {
dirInput.value = info.path
// 已有自定义路径时自动展开
const details = page.querySelector('#custom-dir-details')
if (details) details.open = true
}
}).catch(() => {})
}
const searchPathsInput = page.querySelector('#input-openclaw-search-paths')
api.readPanelConfig().then(cfg => {
if (searchPathsInput) {
const values = Array.isArray(cfg?.openclawSearchPaths) ? cfg.openclawSearchPaths : []
searchPathsInput.value = values.join('\n')
}
}).catch(() => {})
page.querySelector('#btn-save-openclaw-dir')?.addEventListener('click', async () => {
const value = dirInput?.value?.trim()
if (!value) { toast(t('setup.enterPath'), 'warning'); return }
const btn = page.querySelector('#btn-save-openclaw-dir')
btn.disabled = true
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `${t('setup.saving')}` }
try {
const cfg = await api.readPanelConfig()
cfg.openclawDir = value
await api.writePanelConfig(cfg)
invalidate()
if (dirResultEl) dirResultEl.innerHTML = `✓ ${t('setup.pathSaved')}`
const savedMsg = t('setup.customPathSaved')
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) toast(savedMsg, 'success')
else await promptRestart(savedMsg)
setTimeout(() => runDetect(page), 500)
} catch (e) {
if (dirResultEl) dirResultEl.innerHTML = `${t('setup.saveFailed', { err: e })}`
toast(t('setup.saveFailed', { err: e }), 'error')
} finally {
btn.disabled = false
}
})
page.querySelector('#btn-save-openclaw-search-paths')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-save-openclaw-search-paths')
const resultEl = page.querySelector('#openclaw-search-paths-result')
const paths = parseOpenclawSearchPaths(searchPathsInput?.value || '')
btn.disabled = true
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `${t('setup.saving')}`
}
try {
const cfg = await api.readPanelConfig()
if (paths.length > 0) {
cfg.openclawSearchPaths = paths
} else {
delete cfg.openclawSearchPaths
}
await api.writePanelConfig(cfg)
invalidate()
if (resultEl) {
resultEl.innerHTML = `✓ ${paths.length > 0 ? t('setup.searchOpenclawExtraPathsSaved') : t('setup.searchOpenclawExtraPathsCleared')}`
}
toast(paths.length > 0 ? t('setup.searchOpenclawExtraPathsSaved') : t('setup.searchOpenclawExtraPathsCleared'), 'success')
setTimeout(() => runDetect(page), 300)
} catch (e) {
if (resultEl) {
resultEl.innerHTML = `${t('setup.saveFailed', { err: e })}`
}
toast(t('setup.saveFailed', { err: e }), 'error')
} finally {
btn.disabled = false
}
})
page.querySelector('#btn-reset-openclaw-dir')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-reset-openclaw-dir')
btn.disabled = true
try {
const cfg = await api.readPanelConfig()
delete cfg.openclawDir
await api.writePanelConfig(cfg)
invalidate()
if (dirInput) dirInput.value = ''
if (dirResultEl) { dirResultEl.style.display = 'block'; dirResultEl.innerHTML = `✓ ${t('setup.defaultRestored')}` }
const restoredMsg = t('setup.defaultRestoredToast')
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) toast(restoredMsg, 'success')
else await promptRestart(restoredMsg)
setTimeout(() => runDetect(page), 500)
} catch (e) {
toast(t('setup.restoreFailed', { err: e }), 'error')
} finally {
btn.disabled = false
}
})
// 一键初始化配置
page.querySelector('#btn-init-config')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-init-config')
btn.disabled = true
btn.textContent = t('setup.initializing')
try {
const result = await api.initOpenclawConfig()
if (result?.restored) {
toast(t('setup.configRestored'), 'success')
} else if (result?.created) {
toast(t('setup.configCreated'), 'success')
} else {
toast(result?.message || t('setup.configExists'), 'info')
}
setTimeout(() => runDetect(page), 500)
} catch (e) {
toast(t('setup.initFailed', { err: e }), 'error')
btn.disabled = false
btn.textContent = t('setup.initConfigLabel')
}
})
// 自动扫描 Node.js
page.querySelector('#btn-scan-node')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-scan-node')
const resultEl = page.querySelector('#scan-result')
btn.disabled = true
btn.textContent = t('setup.scanning')
resultEl.style.display = 'block'
resultEl.innerHTML = `${t('setup.scanningPaths')}`
try {
const results = await api.scanNodePaths()
if (results.length === 0) {
resultEl.innerHTML = `${t('setup.scanNotFound')}`
} else {
resultEl.innerHTML = results.map(r => {
const compatible = r.compatible !== false
const color = compatible ? 'var(--success)' : 'var(--danger)'
const status = compatible
? ''
: `${t('setup.nodeVersionTooLowShort', { required: r.requiredVersion || t('common.unknown') })}`
return `
${compatible ? '✓' : '✗'}
${escapeHtml(r.path)}
${escapeHtml(r.version)}
${status}
`
}
).join('')
resultEl.querySelectorAll('.btn-use-path').forEach(b => {
b.addEventListener('click', async () => {
try {
await api.saveCustomNodePath(b.dataset.path)
toast(t('setup.nodeSaved'), 'success')
setTimeout(() => runDetect(page), 300)
} catch (e) {
toast(t('setup.nodePathSaveFailed', { err: e?.message || e }), 'error')
}
})
})
}
} catch (e) {
resultEl.innerHTML = `${t('setup.scanFailed', { err: e })}`
} finally {
btn.disabled = false
btn.innerHTML = `${icon('search', 12)} ${t('setup.scanNodeBtn')}`
}
})
// 手动指定路径检测
page.querySelector('#btn-check-path')?.addEventListener('click', async () => {
const input = page.querySelector('#input-node-path')
const resultEl = page.querySelector('#scan-result')
const dir = input?.value?.trim()
if (!dir) { toast(t('setup.enterNodeDir'), 'warning'); return }
resultEl.style.display = 'block'
resultEl.innerHTML = `${t('setup.detecting2')}`
try {
const result = await api.checkNodeAtPath(dir)
if (result.installed) {
if (result.compatible === false) {
resultEl.innerHTML = `✗ ${t('setup.nodeVersionUnsupported', { version: result.version || t('common.unknown'), required: result.requiredVersion || t('common.unknown') })}`
return
}
await api.saveCustomNodePath(dir)
resultEl.innerHTML = `✓ ${t('setup.nodeFoundSaved', { version: result.version })}`
toast(t('setup.nodeSaved'), 'success')
setTimeout(() => runDetect(page), 300)
} else {
resultEl.innerHTML = `${t('setup.nodeNotFoundAtPath')}`
}
} catch (e) {
resultEl.innerHTML = `${t('setup.checkFailed', { err: e })}`
}
})
const bindOpenclawCliPath = async (cliPath, btnEl, resultEl, successText = t('setup.searchOpenclawSelectSuccess'), originalText = btnEl?.textContent) => {
if (!cliPath) return false
if (btnEl) {
btnEl.disabled = true
btnEl.textContent = t('setup.searchOpenclawUsing')
}
try {
const cfg = await api.readPanelConfig()
cfg.openclawCliPath = cliPath
await api.writePanelConfig(cfg)
await api.invalidatePathCache().catch(() => {})
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `✓ ${successText}`
}
const refreshed = await maybeRefreshGatewayServiceBinding()
if (refreshed) toast(successText, 'success')
else await promptRestart(successText)
setTimeout(() => runDetect(page), 300)
return true
} catch (e) {
if (btnEl) {
btnEl.disabled = false
btnEl.textContent = originalText || t('setup.scanUseBtn')
}
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `${t('setup.searchOpenclawSelectFailed', { err: e?.message || e })}`
}
toast(t('setup.searchOpenclawSelectFailed', { err: e?.message || e }), 'error')
return false
}
}
page.querySelector('#btn-check-openclaw-path')?.addEventListener('click', async () => {
const input = page.querySelector('#input-openclaw-cli-path')
const resultEl = page.querySelector('#scan-openclaw-result')
const btn = page.querySelector('#btn-check-openclaw-path')
const cliPath = input?.value?.trim()
if (!cliPath) { toast(t('setup.enterPath'), 'warning'); return }
btn.disabled = true
btn.textContent = t('setup.detecting2')
resultEl.style.display = 'block'
resultEl.innerHTML = `${t('setup.detecting2')}`
try {
const result = await api.checkOpenclawAtPath(cliPath)
if (result?.installed && result?.path) {
await bindOpenclawCliPath(result.path, btn, resultEl, t('setup.searchOpenclawManualSaved'), t('setup.searchOpenclawManualBtn'))
} else {
resultEl.innerHTML = `${t('setup.searchOpenclawManualNotFound')}`
btn.disabled = false
btn.textContent = t('setup.searchOpenclawManualBtn')
}
} catch (e) {
resultEl.innerHTML = `${t('setup.scanFailed', { err: e })}`
btn.disabled = false
btn.textContent = t('setup.searchOpenclawManualBtn')
}
})
page.querySelector('#btn-scan-openclaw')?.addEventListener('click', async () => {
const btn = page.querySelector('#btn-scan-openclaw')
const resultEl = page.querySelector('#scan-openclaw-result')
if (!btn || !resultEl) return
btn.disabled = true
btn.innerHTML = `${icon('search', 12)} ${t('setup.searchOpenclawScanning')}`
resultEl.style.display = 'block'
resultEl.innerHTML = `${t('setup.searchOpenclawScanning')}`
try {
const results = await api.scanOpenclawPaths()
if (!Array.isArray(results) || results.length === 0) {
resultEl.innerHTML = `${t('setup.searchOpenclawEmpty')}`
return
}
resultEl.innerHTML = `${results.map((item, index) => `
✓
${escapeHtml(item.path)}
${escapeHtml(openclawSourceLabel(item.source))}${item.version ? ` · v${escapeHtml(item.version)}` : ''}
`).join('')}
${t('setup.searchOpenclawHint')}
`
resultEl.querySelectorAll('.btn-use-openclaw-path').forEach(btnEl => {
btnEl.addEventListener('click', async () => {
const item = results[Number(btnEl.dataset.index)]
if (!item?.path) return
await bindOpenclawCliPath(item.path, btnEl, resultEl)
})
})
} catch (e) {
resultEl.innerHTML = `${t('setup.scanFailed', { err: e })}`
} finally {
btn.disabled = false
btn.innerHTML = `${icon('search', 12)} ${t('setup.searchOpenclawBtn')}`
}
})
// 安装方式联动:源切换时更新方式选项可见性
const methodSection = page.querySelector('#install-method-section')
const registrySection = page.querySelector('#registry-section')
const methodSelect = page.querySelector('#install-method')
const methodHint = page.querySelector('#method-hint')
const sourceRadios = page.querySelectorAll('input[name="install-source"]')
const METHOD_HINTS = {
'auto': t('setup.methodHintAuto'),
'standalone-r2': t('setup.methodHintR2'),
'standalone-github': t('setup.methodHintGithub'),
'npm': t('setup.methodHintNpm'),
}
function updateMethodVisibility() {
const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese'
if (source === 'official') {
if (methodSection) methodSection.style.display = 'none'
if (registrySection) registrySection.style.display = ''
} else {
if (methodSection) methodSection.style.display = ''
const method = methodSelect?.value || 'auto'
if (registrySection) registrySection.style.display = (method === 'npm') ? '' : 'none'
}
if (methodHint && methodSelect) methodHint.textContent = METHOD_HINTS[methodSelect.value] || ''
}
sourceRadios.forEach(r => r.addEventListener('change', updateMethodVisibility))
if (methodSelect) methodSelect.addEventListener('change', updateMethodVisibility)
updateMethodVisibility()
// 一键安装
const installBtn = page.querySelector('#btn-install')
if (!installBtn || !nodeOk) return
installBtn.addEventListener('click', async () => {
const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese'
const method = (source === 'official') ? 'npm' : (page.querySelector('#install-method')?.value || 'auto')
const registry = page.querySelector('#registry-select')?.value
const modal = showUpgradeModal(t('setup.installOpenclaw'))
let unlistenLog, unlistenProgress
setUpgrading(true)
const cleanup = () => {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
unlistenDone?.()
unlistenError?.()
}
let unlistenDone, unlistenError
try {
if (window.__TAURI_INTERNALS__) {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
// 后台任务完成:继续安装 Gateway + 自动配置
unlistenDone = await listen('upgrade-done', async (e) => {
cleanup()
modal.setDone(typeof e.payload === 'string' ? e.payload : t('setup.installComplete'))
// 安装成功后自动安装 Gateway
modal.appendLog(t('setup.installingGateway'))
try {
await api.installGateway()
modal.appendHtmlLog(`${statusIcon('ok', 14)} ${t('setup.gatewayInstalled')}`)
} catch (ge) {
modal.appendHtmlLog(`${statusIcon('warn', 14)} ${t('setup.gatewayInstallFailed', { err: ge })}`)
}
// 确保 openclaw.json 有关键默认值
try {
const config = await api.readOpenclawConfig()
if (config) {
let patched = false
if (!config.gateway) config.gateway = {}
if (!config.gateway.mode) {
config.gateway.mode = 'local'
patched = true
modal.appendHtmlLog(`${statusIcon('ok', 14)} ${t('setup.gwModeSet')}`)
}
if (!config.tools || config.tools.profile !== 'full') {
config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) }
config.tools.profile = 'full'
if (!config.tools.sessions) config.tools.sessions = {}
config.tools.sessions.visibility = 'all'
patched = true
modal.appendHtmlLog(`${statusIcon('ok', 14)} ${t('setup.toolsFullEnabled')}`)
}
if (patched) await api.writeOpenclawConfig(config)
}
} catch (ce) {
modal.appendHtmlLog(`${statusIcon('warn', 14)} ${t('setup.autoConfigFailed', { err: ce })}`)
}
toast(t('setup.installSuccess'), 'success')
setTimeout(() => window.location.reload(), 1500)
})
// 后台任务失败
unlistenError = await listen('upgrade-error', async (e) => {
cleanup()
const errStr = String(e.payload || t('common.unknown'))
modal.appendLog(errStr)
await new Promise(r => setTimeout(r, 150))
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: t('setup.installScene'), hint: diagnosis.hint })
}
})
// 先设置镜像源
if (registry) {
modal.appendLog(t('setup.setRegistry', { url: registry }))
try { await api.setNpmRegistry(registry) } catch {}
}
// 发起后台任务(立即返回)
await api.upgradeOpenclaw(source, null, method)
modal.appendLog(t('setup.bgTaskStarted'))
} else {
// Web 模式:同步等待
modal.appendLog(t('setup.webModeLogHint'))
if (registry) {
modal.appendLog(t('setup.setRegistry', { url: registry }))
try { await api.setNpmRegistry(registry) } catch {}
}
const msg = await api.upgradeOpenclaw(source, null, method)
modal.setDone(msg)
toast(t('setup.installSuccess'), 'success')
setTimeout(() => window.location.reload(), 1500)
cleanup()
}
} catch (e) {
cleanup()
const errStr = String(e)
modal.appendLog(errStr)
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
}
})
}