/** * 面板设置页面 * 统一管理 ClawPanel 的网络代理、npm 源、模型代理等配置 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { showConfirm } from '../components/modal.js' import { t, getLang, setLang, getAvailableLangs, onLangChange } from '../lib/i18n.js' import { isMacPlatform } from '../lib/app-state.js' import { renderSidebar } from '../components/sidebar.js' import { getActiveEngineId } from '../lib/engine-manager.js' const isTauri = !!window.__TAURI_INTERNALS__ function escapeHtml(str) { if (!str) return '' return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } function platformDefaultDockerEndpoint() { const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows') return isWin ? '//./pipe/docker_engine' : '/var/run/docker.sock' } function effectiveDockerEndpoint(cfg) { return (cfg?.dockerEndpoint || '').trim() || platformDefaultDockerEndpoint() } function effectiveDockerImage(cfg) { return (cfg?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw' } 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()] } const REGISTRIES = [ { label: () => t('settings.registryTaobao'), value: 'https://registry.npmmirror.com' }, { label: () => t('settings.registryNpm'), value: 'https://registry.npmjs.org' }, { label: () => t('settings.registryHuawei'), value: 'https://repo.huaweicloud.com/repository/npm/' }, ] export async function render() { const page = document.createElement('div') page.className = 'page' const isHermes = getActiveEngineId() === 'hermes' page.innerHTML = `
${t('settings.networkProxy')}
${t('settings.modelProxy')}
${isHermes ? '' : `
${t('settings.npmRegistry')}
${t('settings.openclawDir')}
${t('settings.openclawSearchPaths')}
${t('settings.dockerDefaults')}
${t('settings.gitPath')}
${t('settings.openclawCli')}
`}
${t('settings.language')}
${window.__TAURI_INTERNALS__ ? `
${t('settings.autostart')}
` : ''} ` bindEvents(page) loadAll(page) return page } async function loadAll(page) { const isHermes = getActiveEngineId() === 'hermes' const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)] if (!isHermes) { tasks.push(loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadGitPath(page), loadCliBinding(page), loadRegistry(page)) } if (window.__TAURI_INTERNALS__) tasks.push(loadAutostart(page)) await Promise.all(tasks) loadLanguageSwitcher(page) } // ===== 网络代理 ===== async function loadProxyConfig(page) { const bar = page.querySelector('#proxy-bar') if (!bar) return try { const cfg = await api.readPanelConfig() const proxyUrl = cfg?.networkProxy?.url || '' bar.innerHTML = `
${t('settings.proxyHint')}
` } catch (e) { bar.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(String(e))}
` } } // ===== 模型请求代理 ===== async function loadModelProxyConfig(page) { const bar = page.querySelector('#model-proxy-bar') if (!bar) return try { const cfg = await api.readPanelConfig() const proxyUrl = cfg?.networkProxy?.url || '' const modelProxy = !!cfg?.networkProxy?.proxyModelRequests const hasProxy = !!proxyUrl bar.innerHTML = `
${hasProxy ? t('settings.modelProxyHint') : t('settings.modelProxyNoProxy') }
` } catch (e) { bar.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(String(e))}
` } } // ===== npm 源设置 ===== async function loadRegistry(page) { const bar = page.querySelector('#registry-bar') try { const current = await api.getNpmRegistry() const isPreset = REGISTRIES.some(r => r.value === current) bar.innerHTML = `
${t('settings.registryHint')}
` const select = bar.querySelector('[data-name="registry"]') const customInput = bar.querySelector('[data-name="custom-registry"]') select.onchange = () => { customInput.style.display = select.value === 'custom' ? '' : 'none' } } catch (e) { bar.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(String(e))}
` } } // ===== OpenClaw 安装路径 ===== async function loadOpenclawDir(page) { const bar = page.querySelector('#openclaw-dir-bar') if (!bar) return try { const info = await api.getOpenclawDir() const cfg = await api.readPanelConfig() const customValue = cfg?.openclawDir || '' const statusText = info.configExists ? `${t('settings.configExists')}` : `${t('settings.configMissing')}` bar.innerHTML = `
${t('settings.currentPath')}: ${escapeHtml(info.path)} ${statusText} ${info.isCustom ? `${t('settings.customBadge')}` : ''}
${info.isCustom ? `` : ''}
${t('settings.dirHint')}
` } catch (e) { bar.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(String(e))}
` } } async function handleSaveOpenclawDir(page) { const input = page.querySelector('[data-name="openclaw-dir"]') const value = (input?.value || '').trim() const cfg = await api.readPanelConfig() if (value) { cfg.openclawDir = value } else { delete cfg.openclawDir } await api.writePanelConfig(cfg) await loadOpenclawDir(page) await loadCliBinding(page) const savedMsg = value ? t('settings.customPathSaved') : t('settings.defaultRestored') const refreshed = await maybeRefreshGatewayServiceBinding() if (refreshed) { toast(savedMsg, 'success') return } await promptRestart(savedMsg) } async function handleResetOpenclawDir(page) { const cfg = await api.readPanelConfig() delete cfg.openclawDir await api.writePanelConfig(cfg) await loadOpenclawDir(page) await loadCliBinding(page) const refreshed = await maybeRefreshGatewayServiceBinding() if (refreshed) { toast(t('settings.defaultRestored'), 'success') return } await promptRestart(t('settings.defaultRestored')) } async function loadOpenclawSearchPaths(page) { const bar = page.querySelector('#openclaw-search-bar') if (!bar) return try { const cfg = await api.readPanelConfig() const value = Array.isArray(cfg?.openclawSearchPaths) ? cfg.openclawSearchPaths.join('\n') : '' bar.innerHTML = `
${t('settings.searchPathsHint')}
` } catch (e) { bar.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(String(e))}
` } } 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 } async function handleSaveOpenclawSearchPaths(page) { const input = page.querySelector('[data-name="openclaw-search-paths"]') const paths = parseOpenclawSearchPaths(input?.value || '') const cfg = await api.readPanelConfig() if (paths.length > 0) { cfg.openclawSearchPaths = paths } else { delete cfg.openclawSearchPaths } await api.writePanelConfig(cfg) await loadOpenclawSearchPaths(page) await loadCliBinding(page) toast(paths.length > 0 ? t('settings.searchPathsSaved') : t('settings.searchPathsCleared'), 'success') } async function loadDockerDefaults(page) { const bar = page.querySelector('#docker-defaults-bar') if (!bar) return try { const cfg = await api.readPanelConfig() const endpoint = cfg?.dockerEndpoint || '' const image = cfg?.dockerDefaultImage || '' const currentEndpoint = effectiveDockerEndpoint(cfg) const currentImage = effectiveDockerImage(cfg) bar.innerHTML = `
${t('settings.currentDefault')}: ${escapeHtml(currentEndpoint)}
${t('settings.dockerDefaultImage')}: ${escapeHtml(currentImage)}
${t('settings.dockerDefaultsHint')}
` } catch (e) { bar.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(String(e))}
` } } async function handleSaveDockerDefaults(page) { const endpointInput = page.querySelector('[data-name="docker-endpoint"]') const imageInput = page.querySelector('[data-name="docker-default-image"]') const endpoint = (endpointInput?.value || '').trim() const image = (imageInput?.value || '').trim() const cfg = await api.readPanelConfig() if (endpoint) cfg.dockerEndpoint = endpoint else delete cfg.dockerEndpoint if (image) cfg.dockerDefaultImage = image else delete cfg.dockerDefaultImage await api.writePanelConfig(cfg) await loadDockerDefaults(page) toast(t('settings.dockerDefaultsSaved'), 'success') } 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 (!isTauri) { 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') } } // ===== 事件绑定 ===== function bindEvents(page) { page.addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]') if (!btn) return const action = btn.dataset.action btn.disabled = true try { switch (action) { case 'save-proxy': await handleSaveProxy(page) break case 'test-proxy': await handleTestProxy(page) break case 'clear-proxy': await handleClearProxy(page) break case 'save-model-proxy': await handleSaveModelProxy(page) break case 'save-registry': await handleSaveRegistry(page) break case 'save-openclaw-dir': await handleSaveOpenclawDir(page) break case 'reset-openclaw-dir': await handleResetOpenclawDir(page) break case 'save-openclaw-search-paths': await handleSaveOpenclawSearchPaths(page) break case 'save-docker-defaults': await handleSaveDockerDefaults(page) break case 'save-git-path': await handleSaveGitPath(page) break case 'reset-git-path': await handleResetGitPath(page) break case 'scan-git-paths': await handleScanGitPaths(page) break case 'use-scanned-git': page.querySelector('[data-name="git-path"]').value = btn.dataset.gitPath || '' await handleSaveGitPath(page) break case 'bind-cli': await handleBindCli(page, btn.dataset.path) break case 'unbind-cli': await handleUnbindCli(page) break } } catch (e) { toast(e.toString(), 'error') } finally { btn.disabled = false } }) } function normalizeProxyUrl(value) { const url = String(value || '').trim() if (!url) return '' if (!/^https?:\/\//i.test(url)) { throw new Error(t('settings.proxyUrlInvalid')) } return url } async function handleTestProxy(page) { const resultEl = page.querySelector('#proxy-test-result') if (resultEl) resultEl.innerHTML = `${t('settings.testingProxy')}` try { const r = await api.testProxy() if (resultEl) { resultEl.innerHTML = r.ok ? `✓ ${t('settings.proxyOk', { status: r.status, ms: r.elapsed_ms, target: escapeHtml(r.target) })}` : `⚠ ${t('settings.proxyWarn', { status: r.status, ms: r.elapsed_ms })}` } } catch (e) { if (resultEl) resultEl.innerHTML = `✗ ${escapeHtml(String(e))}` } } async function handleSaveProxy(page) { const input = page.querySelector('[data-name="proxy-url"]') const proxyUrl = normalizeProxyUrl(input?.value || '') if (!proxyUrl) { toast(t('settings.proxyUrlEmpty'), 'error') return } const cfg = await api.readPanelConfig() if (!cfg.networkProxy || typeof cfg.networkProxy !== 'object') { cfg.networkProxy = {} } cfg.networkProxy.url = proxyUrl await api.writePanelConfig(cfg) toast(t('settings.proxySaved'), 'success') await loadProxyConfig(page) await loadModelProxyConfig(page) } async function handleClearProxy(page) { const cfg = await api.readPanelConfig() delete cfg.networkProxy await api.writePanelConfig(cfg) toast(t('settings.proxyCleared'), 'success') await loadProxyConfig(page) await loadModelProxyConfig(page) } async function handleSaveModelProxy(page) { const toggle = page.querySelector('[data-name="model-proxy-toggle"]') const checked = toggle?.checked || false const cfg = await api.readPanelConfig() if (!cfg.networkProxy || typeof cfg.networkProxy !== 'object') { cfg.networkProxy = {} } cfg.networkProxy.proxyModelRequests = checked await api.writePanelConfig(cfg) toast(checked ? t('settings.modelProxyOn') : t('settings.modelProxyOff'), 'success') } async function handleSaveRegistry(page) { const select = page.querySelector('[data-name="registry"]') const customInput = page.querySelector('[data-name="custom-registry"]') const registry = select.value === 'custom' ? customInput.value.trim() : select.value if (!registry) { toast(t('settings.registryEmpty'), 'error'); return } await api.setNpmRegistry(registry) toast(t('settings.registrySaved'), 'success') } // ===== Git 路径 ===== async function loadGitPath(page) { const bar = page.querySelector('#git-path-bar') if (!bar) return try { const gitInfo = await api.checkGit() const cfg = await api.readPanelConfig() const customValue = cfg?.gitPath || '' const invalidCustom = gitInfo.isCustom && !gitInfo.installed const statusText = gitInfo.installed ? `✓ ${escapeHtml(gitInfo.version || 'Git')}` : invalidCustom ? `✗ ${t('settings.gitPathInvalid')}` : `✗ Git ${t('setup.notInstalled')}` const pathText = gitInfo.path ? `${escapeHtml(gitInfo.path)}` : '' const customBadge = gitInfo.isCustom ? `${t('settings.customBadge')}` : '' bar.innerHTML = `
${statusText}${customBadge}
${pathText ? `
${pathText}
` : ''}

${t('settings.gitPathHint')}

` } catch (e) { bar.innerHTML = `
${e}
` } } async function handleSaveGitPath(page) { const input = page.querySelector('[data-name="git-path"]') const value = (input?.value || '').trim() const cfg = await api.readPanelConfig() if (value) { cfg.gitPath = value } else { delete cfg.gitPath } await api.writePanelConfig(cfg) const gitInfo = await api.checkGit() if (value && gitInfo.isCustom && !gitInfo.installed) { toast(t('settings.gitPathInvalid'), 'error') } else { toast(value ? t('settings.gitPathSaved') : t('settings.gitPathCleared'), 'success') } await loadGitPath(page) } async function handleScanGitPaths(page) { const container = page.querySelector('#git-scan-results') if (!container) return container.innerHTML = `
${t('settings.gitScanning')}
` try { const results = await api.scanGitPaths() if (!results || results.length === 0) { container.innerHTML = `
${t('settings.gitScanEmpty')}
` return } container.innerHTML = `
${results.map(r => `
${escapeHtml(r.path)} ${escapeHtml(r.version || '')} ${escapeHtml(r.source)}
` ).join('')}
` } catch (e) { container.innerHTML = `
${e}
` } } async function handleResetGitPath(page) { const cfg = await api.readPanelConfig() delete cfg.gitPath await api.writePanelConfig(cfg) toast(t('settings.gitPathCleared'), 'success') await loadGitPath(page) } // ===== CLI 绑定 ===== async function loadCliBinding(page) { const bar = page.querySelector('#cli-binding-bar') if (!bar) return try { const version = await api.getVersionInfo() const cfg = await api.readPanelConfig() const boundPath = cfg?.openclawCliPath || '' const installations = dedupeOpenclawInstallations(version.all_installations || []) const currentPath = version.cli_path || '' const sourceLabel = (src) => ({ standalone: t('dashboard.cliSourceStandalone'), 'npm-zh': t('dashboard.cliSourceNpmZh'), 'npm-official': t('dashboard.cliSourceNpmOfficial'), 'npm-global': t('dashboard.cliSourceNpmGlobal'), })[src] || t('dashboard.cliSourceUnknown') let html = `
${t('settings.cliBindHint')}
` if (currentPath) { html += `
${t('settings.cliCurrent')}: ${escapeHtml(currentPath)} ${boundPath ? `${t('settings.cliBound')}` : ''}
` } if (installations.length > 0) { html += '
' // Auto-detect option html += `
${t('settings.cliAutoDetect')} ${boundPath ? '' : '✓ ' + t('settings.cliActive') + ''}
` for (const inst of installations) { const isActive = inst.active const isBound = boundPath && inst.path === boundPath html += `
${escapeHtml(inst.path)}
${sourceLabel(inst.source)}${inst.version ? ' · v' + inst.version : ''}
${isBound ? '✓ ' + t('settings.cliBound') + '' : ``}
` } html += '
' } else { html += `
${t('common.noData')}
` } bar.innerHTML = html } catch (e) { bar.innerHTML = `
${t('common.loadFailed')}: ${escapeHtml(String(e))}
` } } async function handleBindCli(page, path) { if (!path) return const ok = await showConfirm(t('settings.cliSwitchConfirm')) if (!ok) return const cfg = await api.readPanelConfig() cfg.openclawCliPath = path await api.writePanelConfig(cfg) toast(t('common.saveSuccess'), 'success') await loadCliBinding(page) await maybeRefreshGatewayServiceBinding() } async function handleUnbindCli(page) { const cfg = await api.readPanelConfig() delete cfg.openclawCliPath await api.writePanelConfig(cfg) toast(t('common.saveSuccess'), 'success') await loadCliBinding(page) await maybeRefreshGatewayServiceBinding() } // ===== 语言切换 ===== function loadLanguageSwitcher(page) { const bar = page.querySelector('#language-bar') if (!bar) return const langs = getAvailableLangs() const current = getLang() bar.innerHTML = `
${t('settings.languageHint')}
` const select = bar.querySelector('#lang-select') select.onchange = () => { setLang(select.value) // Re-render sidebar + current page const sidebarEl = document.getElementById('sidebar') if (sidebarEl) renderSidebar(sidebarEl) // Re-render settings page const pageEl = page.closest('.page') || page render().then(newPage => { pageEl.replaceWith(newPage) }).catch(() => {}) } } // ===== 开机自启 ===== async function loadAutostart(page) { const bar = page.querySelector('#autostart-bar') if (!bar) return try { const { isEnabled, enable, disable } = await import('@tauri-apps/plugin-autostart') const enabled = await isEnabled() bar.innerHTML = `
${t('settings.autostartHint')}
` bar.querySelector('#autostart-toggle')?.addEventListener('change', async (e) => { try { if (e.target.checked) { await enable() toast(t('settings.autostartEnabled'), 'success') } else { await disable() toast(t('settings.autostartDisabled'), 'success') } } catch (err) { e.target.checked = !e.target.checked toast(t('settings.autostartFailed') + ': ' + err, 'error') } }) } catch { bar.innerHTML = `
${t('settings.autostartUnavailable')}
` } }