/**
* 面板设置页面
* 统一管理 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.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')}
`
}
}