diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 47e328f..eaed7e6 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -1213,6 +1213,263 @@ function getLocalIps() { return ips } +const CALIBRATION_RESET_INHERIT_KEYS = [ + 'agents', + 'auth', + 'bindings', + 'browser', + 'channels', + 'commands', + 'env', + 'hooks', + 'models', + 'plugins', + 'session', + 'skills', + 'wizard', +] + +function requiredControlUiOrigins() { + const origins = [ + 'tauri://localhost', + 'https://tauri.localhost', + 'http://tauri.localhost', + 'http://localhost', + 'http://localhost:1420', + 'http://127.0.0.1:1420', + 'http://localhost:18777', + 'http://127.0.0.1:18777', + ] + for (const ip of getLocalIps()) { + origins.push(`http://${ip}:1420`) + origins.push(`http://${ip}:18777`) + } + return [...new Set(origins)] +} + +function calibrationLastTouchedVersion() { + return recommendedVersionFor('chinese') || '2026.1.1' +} + +function calibrationDefaultWorkspace() { + return path.join(OPENCLAW_DIR, 'workspace') +} + +function generateCalibrationToken() { + return `cp-${crypto.randomBytes(16).toString('hex')}` +} + +function decodeJsonFileContent(filePath) { + const raw = fs.readFileSync(filePath) + if (raw.length >= 3 && raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) { + return raw.subarray(3).toString('utf8') + } + return raw.toString('utf8') +} + +function readJsonFileRelaxed(filePath) { + if (!fs.existsSync(filePath)) return null + try { + return JSON.parse(decodeJsonFileContent(filePath)) + } catch { + return null + } +} + +function calibrationHasUsableGatewayAuth(auth) { + const mode = auth?.mode + if (mode === 'token') return !!String(auth?.token || '').trim() + if (mode === 'password') return !!String(auth?.password || '').trim() + return false +} + +function calibrationRichnessScore(config) { + if (!config || typeof config !== 'object' || Array.isArray(config)) return 0 + let score = 0 + if (config.models?.providers && Object.keys(config.models.providers).length) score += 4 + if (config.auth?.profiles && Object.keys(config.auth.profiles).length) score += 3 + if (config.agents?.defaults) score += 2 + if (Array.isArray(config.agents?.list) && config.agents.list.length) score += 3 + if (config.channels && Object.keys(config.channels).length) score += 2 + if (Array.isArray(config.bindings) && config.bindings.length) score += 2 + if (config.plugins?.entries && Object.keys(config.plugins.entries).length) score += 2 + if (config.plugins?.installs && Object.keys(config.plugins.installs).length) score += 2 + if (config.env && Object.keys(config.env).length) score += 1 + if (calibrationHasUsableGatewayAuth(config.gateway?.auth)) score += 3 + if (Array.isArray(config.gateway?.controlUi?.allowedOrigins) && config.gateway.controlUi.allowedOrigins.length) score += 1 + return score +} + +function selectCalibrationSource(current, backup) { + if (current && backup) { + return calibrationRichnessScore(backup) > calibrationRichnessScore(current) + ? ['backup', backup] + : ['current', current] + } + if (current) return ['current', current] + if (backup) return ['backup', backup] + return ['empty', {}] +} + +function buildCalibrationBaseline() { + return { + $schema: 'https://openclaw.ai/schema/config.json', + meta: { lastTouchedVersion: calibrationLastTouchedVersion() }, + models: { providers: {} }, + auth: { profiles: {} }, + agents: { + defaults: { workspace: calibrationDefaultWorkspace() }, + list: [], + }, + bindings: [], + channels: {}, + commands: { + native: 'auto', + nativeSkills: 'auto', + ownerDisplay: 'raw', + restart: true, + }, + plugins: {}, + session: { dmScope: 'per-channel-peer' }, + skills: { entries: {} }, + tools: { + profile: 'full', + sessions: { visibility: 'all' }, + }, + gateway: { + mode: 'local', + bind: 'loopback', + port: 18789, + auth: { + mode: 'token', + token: generateCalibrationToken(), + }, + controlUi: { + enabled: true, + allowedOrigins: requiredControlUiOrigins(), + allowInsecureAuth: true, + }, + }, + } +} + +function applyResetInheritance(baseConfig, seed) { + const config = { ...baseConfig } + const inheritedKeys = [] + if (!seed || typeof seed !== 'object' || Array.isArray(seed)) return [config, inheritedKeys] + for (const key of CALIBRATION_RESET_INHERIT_KEYS) { + if (key in seed) { + config[key] = seed[key] + inheritedKeys.push(key) + } + } + if (seed.tools?.web) { + config.tools = config.tools && typeof config.tools === 'object' && !Array.isArray(config.tools) ? config.tools : {} + config.tools.web = seed.tools.web + inheritedKeys.push('tools.web') + } + return [config, inheritedKeys] +} + +function normalizeCalibratedConfig(input) { + const config = input && typeof input === 'object' && !Array.isArray(input) ? input : buildCalibrationBaseline() + const origins = requiredControlUiOrigins() + config.$schema = 'https://openclaw.ai/schema/config.json' + config.meta = config.meta && typeof config.meta === 'object' && !Array.isArray(config.meta) ? config.meta : {} + config.meta.lastTouchedVersion = calibrationLastTouchedVersion() + config.meta.lastTouchedAt = new Date().toISOString() + + config.models = config.models && typeof config.models === 'object' && !Array.isArray(config.models) ? config.models : {} + config.models.providers = config.models.providers && typeof config.models.providers === 'object' && !Array.isArray(config.models.providers) ? config.models.providers : {} + + config.auth = config.auth && typeof config.auth === 'object' && !Array.isArray(config.auth) ? config.auth : {} + config.auth.profiles = config.auth.profiles && typeof config.auth.profiles === 'object' && !Array.isArray(config.auth.profiles) ? config.auth.profiles : {} + + config.agents = config.agents && typeof config.agents === 'object' && !Array.isArray(config.agents) ? config.agents : {} + config.agents.defaults = config.agents.defaults && typeof config.agents.defaults === 'object' && !Array.isArray(config.agents.defaults) ? config.agents.defaults : {} + if (!String(config.agents.defaults.workspace || '').trim()) config.agents.defaults.workspace = calibrationDefaultWorkspace() + if (!Array.isArray(config.agents.list)) config.agents.list = [] + + if (!Array.isArray(config.bindings)) config.bindings = [] + config.channels = config.channels && typeof config.channels === 'object' && !Array.isArray(config.channels) ? config.channels : {} + config.commands = config.commands && typeof config.commands === 'object' && !Array.isArray(config.commands) ? config.commands : {} + if (!String(config.commands.native || '').trim()) config.commands.native = 'auto' + if (!String(config.commands.nativeSkills || '').trim()) config.commands.nativeSkills = 'auto' + if (!String(config.commands.ownerDisplay || '').trim()) config.commands.ownerDisplay = 'raw' + if (typeof config.commands.restart !== 'boolean') config.commands.restart = true + config.plugins = config.plugins && typeof config.plugins === 'object' && !Array.isArray(config.plugins) ? config.plugins : {} + config.session = config.session && typeof config.session === 'object' && !Array.isArray(config.session) ? config.session : {} + if (!String(config.session.dmScope || '').trim()) config.session.dmScope = 'per-channel-peer' + config.skills = config.skills && typeof config.skills === 'object' && !Array.isArray(config.skills) ? config.skills : {} + config.skills.entries = config.skills.entries && typeof config.skills.entries === 'object' && !Array.isArray(config.skills.entries) ? config.skills.entries : {} + + config.tools = config.tools && typeof config.tools === 'object' && !Array.isArray(config.tools) ? config.tools : {} + if (!String(config.tools.profile || '').trim()) config.tools.profile = 'full' + config.tools.sessions = config.tools.sessions && typeof config.tools.sessions === 'object' && !Array.isArray(config.tools.sessions) ? config.tools.sessions : {} + if (!String(config.tools.sessions.visibility || '').trim()) config.tools.sessions.visibility = 'all' + + config.gateway = config.gateway && typeof config.gateway === 'object' && !Array.isArray(config.gateway) ? config.gateway : {} + if (!String(config.gateway.mode || '').trim()) config.gateway.mode = 'local' + const port = Number(config.gateway.port) + config.gateway.port = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 18789 + if (!String(config.gateway.bind || '').trim()) config.gateway.bind = 'loopback' + if (!calibrationHasUsableGatewayAuth(config.gateway.auth)) { + config.gateway.auth = { + mode: 'token', + token: generateCalibrationToken(), + } + } + config.gateway.controlUi = config.gateway.controlUi && typeof config.gateway.controlUi === 'object' && !Array.isArray(config.gateway.controlUi) ? config.gateway.controlUi : {} + const existingOrigins = Array.isArray(config.gateway.controlUi.allowedOrigins) ? config.gateway.controlUi.allowedOrigins.filter(Boolean) : [] + config.gateway.controlUi.allowedOrigins = [...new Set([...existingOrigins, ...origins])] + config.gateway.controlUi.enabled = true + config.gateway.controlUi.allowInsecureAuth = true + + return config +} + +function calibrateOpenclawConfig(mode = 'inherit') { + const normalizedMode = mode === 'reinitialize' ? 'reset' : String(mode || 'inherit').trim() + if (normalizedMode !== 'inherit' && normalizedMode !== 'reset') { + throw new Error('mode 必须是 inherit 或 reset') + } + if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true }) + const warnings = [] + let preBackup = null + if (fs.existsSync(CONFIG_PATH)) { + try { + preBackup = handlers.create_backup().name || null + } catch (error) { + warnings.push(`修复前备份失败: ${error?.message || error}`) + } + } + const current = readJsonFileRelaxed(CONFIG_PATH) + const backup = readJsonFileRelaxed(CONFIG_PATH + '.bak') + const [source, seed] = selectCalibrationSource(current, backup) + + let calibrated + let inheritedKeys + if (normalizedMode === 'inherit') { + inheritedKeys = seed && typeof seed === 'object' && !Array.isArray(seed) ? Object.keys(seed) : [] + calibrated = mergeConfigsPreservingFields(buildCalibrationBaseline(), seed || {}) + } else { + ;[calibrated, inheritedKeys] = applyResetInheritance(buildCalibrationBaseline(), seed || {}) + } + inheritedKeys = [...new Set(inheritedKeys)].sort() + calibrated = stripUiFields(normalizeCalibratedConfig(calibrated)) + const serialized = JSON.stringify(calibrated, null, 2) + fs.writeFileSync(CONFIG_PATH, serialized) + fs.writeFileSync(CONFIG_PATH + '.bak', serialized) + return { + mode: normalizedMode, + source, + backup: preBackup, + inheritedKeys, + warnings, + message: normalizedMode === 'inherit' ? '配置已按继承模式校准' : '配置已按完全初始化修复模式校准', + } +} + // === Raw WebSocket(支持 Origin header,绕过 Gateway origin 检查)=== function rawWsConnect(host, port, wsPath) { return new Promise((ok, no) => { @@ -1228,6 +1485,7 @@ function rawWsConnect(host, port, wsPath) { req.end() }) } + function wsReadFrame(socket, timeout = 8000) { return new Promise((ok, no) => { let settled = false @@ -1260,6 +1518,7 @@ function wsReadFrame(socket, timeout = 8000) { socket.on('close', () => onClose(new Error('ws closed'))) }) } + function wsSendFrame(socket, text) { const p = Buffer.from(text, 'utf8'), mask = crypto.randomBytes(4) let h @@ -1268,7 +1527,7 @@ function wsSendFrame(socket, text) { const m = Buffer.alloc(p.length); for (let i = 0; i < p.length; i++) m[i] = p[i] ^ mask[i % 4] socket.write(Buffer.concat([h, mask, m])) } -// 持续读取 WS 帧,每条消息调用 onMessage,支持超时和取消 + function wsReadLoop(socket, onMessage, timeoutMs = DOCKER_TASK_TIMEOUT_MS) { let buf = Buffer.alloc(0), done = false const timer = setTimeout(() => { done = true; socket.destroy() }, timeoutMs) @@ -1303,16 +1562,7 @@ function wsReadLoop(socket, onMessage, timeoutMs = DOCKER_TASK_TIMEOUT_MS) { function patchGatewayOrigins() { if (!fs.existsSync(CONFIG_PATH)) return false const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) - const origins = [ - 'tauri://localhost', - 'https://tauri.localhost', - 'http://localhost', - 'http://localhost:1420', - 'http://127.0.0.1:1420', - ] - for (const ip of getLocalIps()) { - origins.push(`http://${ip}:1420`) - } + const origins = requiredControlUiOrigins() const existing = config?.gateway?.controlUi?.allowedOrigins || [] // 合并:保留用户已有的 origins,只追加 ClawPanel 需要的 const merged = [...new Set([...existing, ...origins])] @@ -1390,6 +1640,71 @@ function resolveAgentWorkspace(config, id) { return id === 'main' ? resolveDefaultWorkspace(config) : path.join(resolveAgentDir(config, id), 'workspace') } +const WORKSPACE_TEXT_EXTENSIONS = new Set([ + 'md', 'markdown', 'mdx', 'txt', 'json', 'jsonc', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', + 'log', 'csv', 'env', 'gitignore', 'gitattributes', 'editorconfig', 'js', 'mjs', 'cjs', 'ts', + 'tsx', 'jsx', 'html', 'htm', 'css', 'scss', 'less', 'rs', 'py', 'sh', 'bash', 'zsh', 'fish', + 'ps1', 'bat', 'cmd', 'sql', 'xml', 'java', 'kt', 'go', 'rb', 'php', 'c', 'cc', 'cpp', 'h', + 'hpp', 'vue', 'svelte', 'lock', 'sample' +]) + +const WORKSPACE_TEXT_BASENAMES = new Set([ + 'dockerfile', + 'makefile', + 'readme', + 'license', + '.env', + '.env.local', + '.env.example', + '.gitignore', + '.gitattributes', + '.editorconfig', + '.npmrc' +]) + +const WORKSPACE_PREVIEW_EXTENSIONS = new Set(['md', 'markdown', 'mdx']) +const MAX_WORKSPACE_FILE_SIZE = 1024 * 1024 + +function normalizeWorkspaceRelativePath(raw) { + const trimmed = String(raw || '').trim() + if (!trimmed) return '' + if (path.isAbsolute(trimmed)) throw new Error('不允许使用绝对路径') + const normalized = path.normalize(trimmed).replace(/\\/g, '/') + if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) { + throw new Error('不允许访问工作区外部路径') + } + return normalized.split('/').filter(part => part && part !== '.').join('/') +} + +function resolveAgentWorkspaceChild(config, id, relativePath = '') { + const root = resolveAgentWorkspace(config, id) + const normalized = normalizeWorkspaceRelativePath(relativePath) + return { + root, + relativePath: normalized, + fullPath: normalized ? path.join(root, normalized) : root, + } +} + +function isWorkspaceTextFile(filePath) { + const base = path.basename(filePath).toLowerCase() + const ext = path.extname(base).replace(/^\./, '') + return WORKSPACE_TEXT_EXTENSIONS.has(ext) || WORKSPACE_TEXT_BASENAMES.has(base) +} + +function isWorkspacePreviewableFile(filePath) { + const ext = path.extname(filePath).replace(/^\./, '').toLowerCase() + return WORKSPACE_PREVIEW_EXTENSIONS.has(ext) +} + +function looksBinaryBuffer(buffer) { + return buffer.subarray(0, Math.min(buffer.length, 512)).includes(0) +} + +function toWorkspaceRelativePath(root, fullPath) { + return path.relative(root, fullPath).split(path.sep).join('/') +} + function resolveMemoryDir(config, agentId, category) { const workspace = resolveAgentWorkspace(config, agentId || 'main') if (category === 'archive') return path.join(path.dirname(workspace), 'workspace-memory') @@ -2380,6 +2695,10 @@ const handlers = { return JSON.parse(content) }, + calibrate_openclaw_config({ mode } = {}) { + return calibrateOpenclawConfig(mode) + }, + write_openclaw_config({ config }) { const existing = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : null const merged = existing ? mergeConfigsPreservingFields(existing, config) : config @@ -4189,7 +4508,7 @@ const handlers = { list_agent_files({ id }) { if (!id) throw new Error('Agent ID 不能为空') const cfg = readOpenclawConfigOptional() - const agentDir = resolveAgentDir(cfg, id) + const workspaceDir = resolveAgentWorkspace(cfg, id) // Bootstrap 文件列表 const BOOTSTRAP_FILES = [ @@ -4204,7 +4523,7 @@ const handlers = { ] return BOOTSTRAP_FILES.map(f => { - const filePath = path.join(agentDir, f.name) + const filePath = path.join(workspaceDir, f.name) const exists = fs.existsSync(filePath) let size = 0, mtime = null if (exists) { @@ -4227,9 +4546,9 @@ const handlers = { if (!ALLOWED.includes(name)) throw new Error('不允许读取此文件') const cfg = readOpenclawConfigOptional() - const agentDir = resolveAgentDir(cfg, id) + const workspaceDir = resolveAgentWorkspace(cfg, id) - const filePath = path.join(agentDir, name) + const filePath = path.join(workspaceDir, name) if (!fs.existsSync(filePath)) return { exists: false, content: '' } return { exists: true, content: fs.readFileSync(filePath, 'utf8') } }, @@ -4243,14 +4562,90 @@ const handlers = { if (typeof content !== 'string') throw new Error('内容必须是字符串') const cfg = readOpenclawConfigOptional() - const agentDir = resolveAgentDir(cfg, id) + const workspaceDir = resolveAgentWorkspace(cfg, id) // 确保目录存在 - if (!fs.existsSync(agentDir)) fs.mkdirSync(agentDir, { recursive: true }) - fs.writeFileSync(path.join(agentDir, name), content, 'utf8') + if (!fs.existsSync(workspaceDir)) fs.mkdirSync(workspaceDir, { recursive: true }) + fs.writeFileSync(path.join(workspaceDir, name), content, 'utf8') return { ok: true } }, + get_agent_workspace_info({ id }) { + if (!id) throw new Error('Agent ID 不能为空') + const cfg = readOpenclawConfigOptional() + const workspaceDir = resolveAgentWorkspace(cfg, id) + return { + agentId: id, + workspacePath: workspaceDir, + exists: fs.existsSync(workspaceDir), + isDefault: id === 'main', + } + }, + + list_agent_workspace_entries({ id, relativePath }) { + if (!id) throw new Error('Agent ID 不能为空') + const cfg = readOpenclawConfigOptional() + const { root, fullPath } = resolveAgentWorkspaceChild(cfg, id, relativePath || '') + if (!fs.existsSync(root)) return [] + if (!fs.existsSync(fullPath)) throw new Error('目录不存在') + const stat = fs.statSync(fullPath) + if (!stat.isDirectory()) throw new Error('目标不是目录') + + return fs.readdirSync(fullPath, { withFileTypes: true }) + .map(entry => { + const absPath = path.join(fullPath, entry.name) + const meta = fs.statSync(absPath) + const isDir = meta.isDirectory() + return { + name: entry.name, + relativePath: toWorkspaceRelativePath(root, absPath), + type: isDir ? 'dir' : 'file', + size: isDir ? 0 : meta.size, + mtime: meta.mtime?.toISOString?.() || null, + editable: !isDir && isWorkspaceTextFile(absPath), + previewable: !isDir && isWorkspacePreviewableFile(absPath), + } + }) + .sort((a, b) => { + const rankA = a.type === 'dir' ? 0 : 1 + const rankB = b.type === 'dir' ? 0 : 1 + return rankA - rankB || a.name.localeCompare(b.name) + }) + }, + + read_agent_workspace_file({ id, relativePath }) { + if (!id) throw new Error('Agent ID 不能为空') + const cfg = readOpenclawConfigOptional() + const { relativePath: normalized, fullPath } = resolveAgentWorkspaceChild(cfg, id, relativePath || '') + if (!normalized) throw new Error('文件路径不能为空') + if (!fs.existsSync(fullPath)) throw new Error('文件不存在') + const stat = fs.statSync(fullPath) + if (!stat.isFile()) throw new Error('目标不是文件') + if (stat.size > MAX_WORKSPACE_FILE_SIZE) throw new Error('文件过大,暂不支持在面板中打开') + const buffer = fs.readFileSync(fullPath) + if (looksBinaryBuffer(buffer)) throw new Error('暂不支持在面板中打开二进制文件') + return { + relativePath: normalized, + path: fullPath, + size: stat.size, + mtime: stat.mtime?.toISOString?.() || null, + editable: true, + previewable: isWorkspacePreviewableFile(fullPath), + content: buffer.toString('utf8'), + } + }, + + write_agent_workspace_file({ id, relativePath, content }) { + if (!id) throw new Error('Agent ID 不能为空') + if (typeof content !== 'string') throw new Error('内容必须是字符串') + const cfg = readOpenclawConfigOptional() + const { relativePath: normalized, fullPath } = resolveAgentWorkspaceChild(cfg, id, relativePath || '') + if (!normalized) throw new Error('文件路径不能为空') + fs.mkdirSync(path.dirname(fullPath), { recursive: true }) + fs.writeFileSync(fullPath, content, 'utf8') + return { ok: true, relativePath: normalized, size: Buffer.byteLength(content, 'utf8') } + }, + // 更新 Agent 概览配置(写入 openclaw.json agents.list[]) update_agent_config({ id, config }) { if (!id) throw new Error('Agent ID 不能为空') @@ -4611,21 +5006,16 @@ const handlers = { init_openclaw_config() { if (fs.existsSync(CONFIG_PATH)) return { created: false, message: '配置文件已存在' } if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true }) - const lastTouchedVersion = recommendedVersionFor('chinese') || '2026.1.1' - const defaultConfig = { - "$schema": "https://openclaw.ai/schema/config.json", - meta: { lastTouchedVersion }, - models: { providers: {} }, - gateway: { - mode: "local", - port: 18789, - auth: { mode: "none" }, - controlUi: { allowedOrigins: ["*"], allowInsecureAuth: true } - }, - tools: { profile: "full", sessions: { visibility: "all" } } + const backupPath = CONFIG_PATH + '.bak' + if (fs.existsSync(backupPath)) { + const backupContent = fs.readFileSync(backupPath, 'utf8') + JSON.parse(backupContent) + fs.writeFileSync(CONFIG_PATH, backupContent) + return { created: false, restored: true, message: '已从 openclaw.json.bak 恢复配置文件' } } + const defaultConfig = stripUiFields(normalizeCalibratedConfig(buildCalibrationBaseline())) fs.writeFileSync(CONFIG_PATH, JSON.stringify(defaultConfig, null, 2)) - return { created: true, message: '配置文件已创建' } + return { created: true, restored: false, message: '配置文件已创建' } }, get_deploy_config() { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index a8b640c..e1139bf 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -605,6 +605,541 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> { Ok(()) } +const CALIBRATION_RESET_INHERIT_KEYS: &[&str] = &[ + "agents", + "auth", + "bindings", + "browser", + "channels", + "commands", + "env", + "hooks", + "models", + "plugins", + "session", + "skills", + "wizard", +]; + +fn calibration_required_origins() -> Vec { + vec![ + "tauri://localhost".into(), + "https://tauri.localhost".into(), + "http://tauri.localhost".into(), + "http://localhost".into(), + "http://localhost:1420".into(), + "http://127.0.0.1:1420".into(), + "http://localhost:18777".into(), + "http://127.0.0.1:18777".into(), + ] +} + +fn calibration_last_touched_version() -> String { + recommended_version_for("chinese").unwrap_or_else(|| "2026.1.1".to_string()) +} + +fn calibration_default_workspace() -> String { + super::openclaw_dir() + .join("workspace") + .to_string_lossy() + .to_string() +} + +fn generate_calibration_token() -> String { + format!( + "cp-{:016x}{:016x}", + rand::random::(), + rand::random::() + ) +} + +fn decode_json_bytes(raw: &[u8]) -> String { + if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { + String::from_utf8_lossy(&raw[3..]).into_owned() + } else { + String::from_utf8_lossy(raw).into_owned() + } +} + +fn parse_json_relaxed(content: &str) -> Option { + serde_json::from_str(content) + .ok() + .or_else(|| serde_json::from_str(&fix_common_json_errors(content)).ok()) +} + +fn read_json_file_relaxed(path: &PathBuf) -> Option { + let raw = fs::read(path).ok()?; + let content = decode_json_bytes(&raw); + parse_json_relaxed(&content) +} + +fn calibration_has_usable_gateway_auth(auth: &Value) -> bool { + let mode = auth.get("mode").and_then(|v| v.as_str()).unwrap_or(""); + match mode { + "token" => auth + .get("token") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false), + "password" => auth + .get("password") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false), + _ => false, + } +} + +fn calibration_richness_score(config: &Value) -> usize { + let mut score = 0; + if config + .pointer("/models/providers") + .and_then(|v| v.as_object()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 4; + } + if config + .pointer("/auth/profiles") + .and_then(|v| v.as_object()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 3; + } + if config.pointer("/agents/defaults").is_some() { + score += 2; + } + if config + .pointer("/agents/list") + .and_then(|v| v.as_array()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 3; + } + if config + .get("channels") + .and_then(|v| v.as_object()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 2; + } + if config + .get("bindings") + .and_then(|v| v.as_array()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 2; + } + if config + .pointer("/plugins/entries") + .and_then(|v| v.as_object()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + || config + .pointer("/plugins/installs") + .and_then(|v| v.as_object()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 2; + } + if config + .get("env") + .and_then(|v| v.as_object()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 1; + } + if config + .pointer("/gateway/auth") + .map(calibration_has_usable_gateway_auth) + .unwrap_or(false) + { + score += 3; + } + if config + .pointer("/gateway/controlUi/allowedOrigins") + .and_then(|v| v.as_array()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + score += 1; + } + score +} + +fn select_calibration_source(current: Option, backup: Option) -> (String, Value) { + match (current, backup) { + (Some(current), Some(backup)) => { + let current_score = calibration_richness_score(¤t); + let backup_score = calibration_richness_score(&backup); + if backup_score > current_score { + ("backup".into(), backup) + } else { + ("current".into(), current) + } + } + (Some(current), None) => ("current".into(), current), + (None, Some(backup)) => ("backup".into(), backup), + (None, None) => ("empty".into(), json!({})), + } +} + +fn build_calibration_baseline() -> Value { + json!({ + "$schema": "https://openclaw.ai/schema/config.json", + "meta": { + "lastTouchedVersion": calibration_last_touched_version(), + }, + "models": { "providers": {} }, + "auth": { "profiles": {} }, + "agents": { + "defaults": { + "workspace": calibration_default_workspace(), + }, + "list": [], + }, + "bindings": [], + "channels": {}, + "commands": { + "native": "auto", + "nativeSkills": "auto", + "ownerDisplay": "raw", + "restart": true, + }, + "plugins": {}, + "session": { "dmScope": "per-channel-peer" }, + "skills": { "entries": {} }, + "tools": { + "profile": "full", + "sessions": { "visibility": "all" }, + }, + "gateway": { + "mode": "local", + "bind": "loopback", + "port": 18789, + "auth": { + "mode": "token", + "token": generate_calibration_token(), + }, + "controlUi": { + "enabled": true, + "allowedOrigins": calibration_required_origins(), + "allowInsecureAuth": true, + }, + }, + }) +} + +fn apply_reset_inheritance(mut config: Value, seed: &Value) -> (Value, Vec) { + let mut inherited = Vec::new(); + let Some(root) = config.as_object_mut() else { + return (config, inherited); + }; + + for key in CALIBRATION_RESET_INHERIT_KEYS { + if let Some(value) = seed.get(*key) { + root.insert((*key).to_string(), value.clone()); + inherited.push((*key).to_string()); + } + } + + if let Some(web) = seed.pointer("/tools/web").cloned() { + let tools = root.entry("tools").or_insert_with(|| json!({})); + if !tools.is_object() { + *tools = json!({}); + } + if let Some(tools_obj) = tools.as_object_mut() { + tools_obj.insert("web".into(), web); + inherited.push("tools.web".into()); + } + } + + (config, inherited) +} + +fn normalize_calibrated_config(mut config: Value) -> Value { + let required_origins = calibration_required_origins(); + let last_touched_version = calibration_last_touched_version(); + let default_workspace = calibration_default_workspace(); + + let Some(root) = config.as_object_mut() else { + return build_calibration_baseline(); + }; + + root.insert( + "$schema".into(), + Value::String("https://openclaw.ai/schema/config.json".into()), + ); + + let meta = root.entry("meta").or_insert_with(|| json!({})); + if !meta.is_object() { + *meta = json!({}); + } + if let Some(meta_obj) = meta.as_object_mut() { + meta_obj.insert( + "lastTouchedVersion".into(), + Value::String(last_touched_version), + ); + meta_obj.insert( + "lastTouchedAt".into(), + Value::String(chrono::Utc::now().to_rfc3339()), + ); + } + + let models = root.entry("models").or_insert_with(|| json!({})); + if !models.is_object() { + *models = json!({}); + } + if let Some(models_obj) = models.as_object_mut() { + let providers = models_obj.entry("providers").or_insert_with(|| json!({})); + if !providers.is_object() { + *providers = json!({}); + } + } + + let auth = root.entry("auth").or_insert_with(|| json!({})); + if !auth.is_object() { + *auth = json!({}); + } + if let Some(auth_obj) = auth.as_object_mut() { + let profiles = auth_obj.entry("profiles").or_insert_with(|| json!({})); + if !profiles.is_object() { + *profiles = json!({}); + } + } + + let agents = root.entry("agents").or_insert_with(|| json!({})); + if !agents.is_object() { + *agents = json!({}); + } + if let Some(agents_obj) = agents.as_object_mut() { + let defaults = agents_obj.entry("defaults").or_insert_with(|| json!({})); + if !defaults.is_object() { + *defaults = json!({}); + } + if let Some(defaults_obj) = defaults.as_object_mut() { + if defaults_obj + .get("workspace") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + == false + { + defaults_obj.insert("workspace".into(), Value::String(default_workspace)); + } + } + let list = agents_obj.entry("list").or_insert_with(|| json!([])); + if !list.is_array() { + *list = json!([]); + } + } + + let bindings = root.entry("bindings").or_insert_with(|| json!([])); + if !bindings.is_array() { + *bindings = json!([]); + } + + let channels = root.entry("channels").or_insert_with(|| json!({})); + if !channels.is_object() { + *channels = json!({}); + } + + let plugins = root.entry("plugins").or_insert_with(|| json!({})); + if !plugins.is_object() { + *plugins = json!({}); + } + + let tools = root.entry("tools").or_insert_with(|| json!({})); + if !tools.is_object() { + *tools = json!({}); + } + if let Some(tools_obj) = tools.as_object_mut() { + if tools_obj + .get("profile") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + == false + { + tools_obj.insert("profile".into(), Value::String("full".into())); + } + let sessions = tools_obj + .entry("sessions") + .or_insert_with(|| json!({})); + if !sessions.is_object() { + *sessions = json!({}); + } + if let Some(sessions_obj) = sessions.as_object_mut() { + if sessions_obj + .get("visibility") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + == false + { + sessions_obj.insert("visibility".into(), Value::String("all".into())); + } + } + } + + let gateway = root.entry("gateway").or_insert_with(|| json!({})); + if !gateway.is_object() { + *gateway = json!({}); + } + if let Some(gateway_obj) = gateway.as_object_mut() { + if gateway_obj + .get("mode") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + == false + { + gateway_obj.insert("mode".into(), Value::String("local".into())); + } + + let port_valid = gateway_obj + .get("port") + .and_then(|v| v.as_u64()) + .map(|port| (1..=65535).contains(&port)) + .unwrap_or(false); + if !port_valid { + gateway_obj.insert("port".into(), json!(18789)); + } + + if gateway_obj + .get("bind") + .and_then(|v| v.as_str()) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + == false + { + gateway_obj.insert("bind".into(), Value::String("loopback".into())); + } + + let auth_valid = gateway_obj + .get("auth") + .map(calibration_has_usable_gateway_auth) + .unwrap_or(false); + if !auth_valid { + gateway_obj.insert( + "auth".into(), + json!({ + "mode": "token", + "token": generate_calibration_token(), + }), + ); + } + + let control_ui = gateway_obj + .entry("controlUi") + .or_insert_with(|| json!({})); + if !control_ui.is_object() { + *control_ui = json!({}); + } + if let Some(control_ui_obj) = control_ui.as_object_mut() { + let existing: Vec = control_ui_obj + .get("allowedOrigins") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|value| value.as_str().map(|value| value.to_string())) + .collect() + }) + .unwrap_or_default(); + let mut merged = existing; + for origin in required_origins { + if !merged.iter().any(|existing| existing == &origin) { + merged.push(origin); + } + } + control_ui_obj.insert("allowedOrigins".into(), json!(merged)); + control_ui_obj.insert("enabled".into(), Value::Bool(true)); + control_ui_obj.insert("allowInsecureAuth".into(), Value::Bool(true)); + } + } + + config +} + +#[tauri::command] +pub fn calibrate_openclaw_config(mode: String) -> Result { + let normalized_mode = match mode.trim() { + "inherit" => "inherit", + "reset" | "reinitialize" => "reset", + _ => return Err("mode 必须是 inherit 或 reset".into()), + }; + + let dir = super::openclaw_dir(); + let config_path = dir.join("openclaw.json"); + let backup_path = dir.join("openclaw.json.bak"); + fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败: {e}"))?; + + let mut warnings: Vec = vec![]; + let pre_backup = if config_path.exists() { + match create_backup() { + Ok(result) => result + .get("name") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()), + Err(err) => { + warnings.push(format!("修复前备份失败: {err}")); + None + } + } + } else { + None + }; + + let current = read_json_file_relaxed(&config_path); + let backup = read_json_file_relaxed(&backup_path); + let (source, seed) = select_calibration_source(current, backup); + + let (calibrated, mut inherited_keys) = if normalized_mode == "inherit" { + let inherited = seed + .as_object() + .map(|obj| obj.keys().cloned().collect()) + .unwrap_or_else(Vec::new); + ( + merge_configs_preserving_fields(&build_calibration_baseline(), &seed), + inherited, + ) + } else { + apply_reset_inheritance(build_calibration_baseline(), &seed) + }; + + inherited_keys.sort(); + inherited_keys.dedup(); + + let calibrated = strip_ui_fields(normalize_calibrated_config(calibrated)); + let json = + serde_json::to_string_pretty(&calibrated).map_err(|e| format!("序列化校准配置失败: {e}"))?; + + fs::write(&config_path, &json).map_err(|e| format!("写入校准配置失败: {e}"))?; + fs::write(&backup_path, &json).map_err(|e| format!("写入配置备份失败: {e}"))?; + + sync_providers_to_agent_models(&calibrated); + + Ok(json!({ + "mode": normalized_mode, + "source": source, + "backup": pre_backup, + "inheritedKeys": inherited_keys, + "warnings": warnings, + "message": if normalized_mode == "inherit" { + "配置已按继承模式校准" + } else { + "配置已按完全初始化修复模式校准" + } + })) +} + /// 合并两个配置对象,保留现有配置中的合法字段 /// /// Issue #127: 修复配置合并时丢失 browser.* 等合法字段的问题 @@ -3218,6 +3753,7 @@ async fn uninstall_openclaw_inner( pub fn init_openclaw_config() -> Result { let dir = super::openclaw_dir(); let config_path = dir.join("openclaw.json"); + let backup_path = dir.join("openclaw.json.bak"); let mut result = serde_json::Map::new(); if config_path.exists() { @@ -3231,26 +3767,31 @@ pub fn init_openclaw_config() -> Result { std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?; } - let last_touched_version = - recommended_version_for("chinese").unwrap_or_else(|| "2026.1.1".to_string()); - let default_config = serde_json::json!({ - "$schema": "https://openclaw.ai/schema/config.json", - "meta": { "lastTouchedVersion": last_touched_version }, - "models": { "providers": {} }, - "gateway": { - "mode": "local", - "port": 18789, - "auth": { "mode": "none" }, - "controlUi": { "allowedOrigins": ["*"], "allowInsecureAuth": true } - }, - "tools": { "profile": "full", "sessions": { "visibility": "all" } } - }); + if backup_path.exists() { + let backup_content = + std::fs::read_to_string(&backup_path).map_err(|e| format!("读取配置备份失败: {e}"))?; + serde_json::from_str::(&backup_content) + .map_err(|e| format!("配置备份损坏,无法恢复: {e}"))?; + std::fs::write(&config_path, backup_content) + .map_err(|e| format!("恢复配置备份失败: {e}"))?; + + result.insert("created".into(), Value::Bool(false)); + result.insert("restored".into(), Value::Bool(true)); + result.insert( + "message".into(), + Value::String("已从 openclaw.json.bak 恢复配置文件".into()), + ); + return Ok(Value::Object(result)); + } + + let default_config = strip_ui_fields(normalize_calibrated_config(build_calibration_baseline())); let content = serde_json::to_string_pretty(&default_config).map_err(|e| format!("序列化失败: {e}"))?; std::fs::write(&config_path, content).map_err(|e| format!("写入失败: {e}"))?; result.insert("created".into(), Value::Bool(true)); + result.insert("restored".into(), Value::Bool(false)); result.insert("message".into(), Value::String("配置文件已创建".into())); Ok(Value::Object(result)) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6bb2611..9c4b5d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,6 +74,7 @@ pub fn run() { config::get_version_info, config::check_installation, config::init_openclaw_config, + config::calibrate_openclaw_config, config::check_node, config::check_node_at_path, config::check_openclaw_at_path, @@ -145,6 +146,10 @@ pub fn run() { agent::list_agent_files, agent::read_agent_file, agent::write_agent_file, + agent::get_agent_workspace_info, + agent::list_agent_workspace_entries, + agent::read_agent_workspace_file, + agent::write_agent_workspace_file, agent::add_agent, agent::delete_agent, agent::update_agent_config, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index ed26265..4502b93 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -187,6 +187,7 @@ export const api = { getVersionInfo: () => cachedInvoke('get_version_info', {}, 30000), getStatusSummary: () => cachedInvoke('get_status_summary', {}, 60000), readOpenclawConfig: () => cachedInvoke('read_openclaw_config'), + calibrateOpenclawConfig: (mode = 'inherit') => { invalidate('read_openclaw_config', 'check_installation', 'list_backups', 'get_services_status', 'get_status_summary'); return invoke('calibrate_openclaw_config', { mode }).then(r => { _debouncedReloadGateway(); return r }) }, writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }).then(r => { _debouncedReloadGateway(); return r }) }, readMcpConfig: () => cachedInvoke('read_mcp_config'), writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) }, @@ -210,6 +211,13 @@ export const api = { 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 }) }, + getAgentWorkspaceInfo: (id) => cachedInvoke('get_agent_workspace_info', { id }, 5000), + listAgentWorkspaceEntries: (id, relativePath) => cachedInvoke('list_agent_workspace_entries', { id, relativePath: relativePath || null }, 5000), + readAgentWorkspaceFile: (id, relativePath) => cachedInvoke('read_agent_workspace_file', { id, relativePath }, 5000), + writeAgentWorkspaceFile: (id, relativePath, content) => { + invalidate('get_agent_workspace_info', 'list_agent_workspace_entries', 'read_agent_workspace_file', 'list_agent_files', 'read_agent_file') + return invoke('write_agent_workspace_file', { id, relativePath, 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', 'get_agent_detail'); return invoke('delete_agent', { id }) }, diff --git a/src/locales/modules/services.js b/src/locales/modules/services.js index 5847b93..8d1b5f2 100644 --- a/src/locales/modules/services.js +++ b/src/locales/modules/services.js @@ -103,6 +103,20 @@ export default { policyDefault: _('默认只建议当前面板已验证的推荐稳定版。如需尝试其它版本或最新特性,请到「关于」页手动切换版本并自行验证兼容性;若希望面板优先适配最新版,欢迎提交 issue。', 'Only the panel-verified recommended stable version is suggested by default. To try other versions or latest features, manually switch in the About page and verify compatibility yourself. To request newer version support, file an issue.', '預設只建議目前面板已驗證的推薦穩定版。如需尝試其它版本或最新特性,請到「關於」頁手動切換版本並自行驗證相容性;若希望面板優先適配最新版,欢迎提交 issue。'), configEditor: _('配置文件编辑', 'Config Editor', '設定檔案編輯'), configEditorHint: _('直接编辑 openclaw.json 主配置文件。保存前会自动创建备份,修改后可能需要重启 Gateway 生效。', 'Edit the openclaw.json config file directly. A backup is auto-created before saving. Changes may require a Gateway restart.', '直接編輯 openclaw.json 主設定檔案。儲存前會自動建立備份,修改后可能需要重啟 Gateway 生效。'), + configCalibration: _('配置校准', 'Config Calibration', '設定校準'), + configCalibrationHint: _('用于修复损坏、截断或不安全的 openclaw.json。会先创建修复前备份,再按所选模式校准核心配置。', 'Repair a broken, truncated, or unsafe openclaw.json. A pre-repair backup is created first, then core settings are calibrated using the selected mode.', '用於修復損壞、截斷或不安全的 openclaw.json。會先建立修復前備份,再按所選模式校準核心設定。'), + calibrateInherit: _('继承校准', 'Inherit Calibration', '繼承校準'), + calibrateReset: _('完全初始化修复', 'Full Initialization Repair', '完全初始化修復'), + calibrateInheritHint: _('保留现有模型、渠道、Agent、绑定、认证档案等业务配置,只修复 Gateway、工具配置和必要默认项。', 'Preserve models, channels, agents, bindings, auth profiles, and other business config while repairing Gateway, tool settings, and required defaults.', '保留現有模型、渠道、Agent、綁定、認證檔案等業務設定,只修復 Gateway、工具設定和必要預設值。'), + calibrateResetHint: _('重建一份安全的基线配置,再择优继承模型、渠道、Agent、绑定、认证档案等关键业务配置。适合配置严重损坏时使用。', 'Rebuild a safe baseline config, then selectively inherit critical business config such as models, channels, agents, bindings, and auth profiles. Best for severely broken configs.', '重建一份安全的基線設定,再擇優繼承模型、渠道、Agent、綁定、認證檔案等關鍵業務設定。適合設定嚴重損壞時使用。'), + calibrateInheritConfirm: _('确定按“继承校准”修复配置吗?\n会优先保留现有业务配置,并先创建修复前备份。', 'Run "Inherit Calibration" now?\nExisting business config will be preserved as much as possible, and a pre-repair backup will be created first.', '確定按「繼承校準」修復設定嗎?\n會優先保留現有業務設定,並先建立修復前備份。'), + calibrateResetConfirm: _('确定按“完全初始化修复”修复配置吗?\n会重建安全基线,并仅继承关键业务配置;同样会先创建修复前备份。', 'Run "Full Initialization Repair" now?\nA safe baseline will be rebuilt and only critical business config will be inherited; a pre-repair backup will also be created first.', '確定按「完全初始化修復」修復設定嗎?\n會重建安全基線,並僅繼承關鍵業務設定;同樣會先建立修復前備份。'), + calibrating: _('正在校准配置...', 'Calibrating config...', '正在校準設定...'), + calibrationDone: _('配置校准完成', 'Config calibration complete', '設定校準完成'), + calibrationSummary: _('已按 {mode} 完成修复,来源:{source},继承项:{count}', 'Repair completed with {mode}. Source: {source}. Inherited items: {count}', '已按 {mode} 完成修復,來源:{source},繼承項:{count}'), + calibrationSourceCurrent: _('当前配置', 'Current config', '目前設定'), + calibrationSourceBackup: _('备份配置', 'Backup config', '備份設定'), + calibrationSourceEmpty: _('空白基线', 'Empty baseline', '空白基線'), saveAndRestart: _('保存并重启', 'Save & Restart', '儲存並重啟'), saveOnly: _('仅保存', 'Save Only', '僅儲存'), reloadConfig: _('重新加载', 'Reload', '重新載入'), diff --git a/src/pages/services.js b/src/pages/services.js index 6f9bef9..6b8a025 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -48,6 +48,19 @@ export async function render() {
+
+
${t('services.configCalibration')}
+
${t('services.configCalibrationHint')}
+
+ + +
+
+
${t('services.calibrateInheritHint')}
+
${t('services.calibrateResetHint')}
+
+
+
${t('services.configBackup')}
${t('services.configBackupHint')}
@@ -561,6 +574,12 @@ function bindEvents(page) { case 'reload-config': await loadConfigEditor(page) break + case 'calibrate-config-inherit': + await handleCalibrateConfig(page, 'inherit') + break + case 'calibrate-config-reset': + await handleCalibrateConfig(page, 'reset') + break case 'create-backup': await handleCreateBackup(page) break @@ -740,6 +759,40 @@ async function handleDeleteBackup(name, page) { await loadBackups(page) } +function calibrationSourceLabel(source) { + if (source === 'backup') return t('services.calibrationSourceBackup') + if (source === 'current') return t('services.calibrationSourceCurrent') + return t('services.calibrationSourceEmpty') +} + +async function handleCalibrateConfig(page, mode) { + const yes = await showConfirm(mode === 'reset' + ? t('services.calibrateResetConfirm') + : t('services.calibrateInheritConfirm')) + if (!yes) return + + const status = page.querySelector('#config-calibration-status') + if (status) status.innerHTML = `${t('services.calibrating')}` + + const result = await api.calibrateOpenclawConfig(mode) + const summary = t('services.calibrationSummary', { + mode: mode === 'reset' ? t('services.calibrateReset') : t('services.calibrateInherit'), + source: calibrationSourceLabel(result?.source), + count: String(result?.inheritedKeys?.length || 0), + }) + const warnings = Array.isArray(result?.warnings) ? result.warnings.filter(Boolean) : [] + + if (status) status.innerHTML = `${escapeHtml(summary)}${warnings.length ? `
${escapeHtml(warnings.join(';'))}` : ''}` + toast(t('services.calibrationDone') + ' · ' + summary, 'success') + if (warnings.length) toast(warnings.join(';'), 'warning') + + await Promise.all([ + loadConfigEditor(page), + loadBackups(page), + loadServices(page), + ]) +} + // ===== 配置文件编辑器 ===== let _configOriginal = ''