diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ac77887..6303c86 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -292,8 +301,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -301,12 +312,14 @@ dependencies = [ name = "clawpanel" version = "0.1.0" dependencies = [ + "chrono", "dirs", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-shell", + "zip", ] [[package]] @@ -500,6 +513,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -4927,8 +4951,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.13.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 97a4d6f..0af225d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,3 +16,5 @@ tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" +chrono = "0.4" +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 1cfcdbd..7791bfe 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -11,6 +11,10 @@ fn openclaw_dir() -> PathBuf { .join(".openclaw") } +fn backups_dir() -> PathBuf { + openclaw_dir().join("backups") +} + #[tauri::command] pub fn read_openclaw_config() -> Result { let path = openclaw_dir().join("openclaw.json"); @@ -96,3 +100,103 @@ pub fn write_env_file(path: String, config: String) -> Result<(), String> { fs::write(&expanded, &config) .map_err(|e| format!("写入 .env 失败: {e}")) } + +// ===== 备份管理 ===== + +#[tauri::command] +pub fn list_backups() -> Result { + let dir = backups_dir(); + if !dir.exists() { + return Ok(Value::Array(vec![])); + } + let mut backups: Vec = vec![]; + let entries = fs::read_dir(&dir) + .map_err(|e| format!("读取备份目录失败: {e}"))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let meta = fs::metadata(&path).ok(); + let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); + let created = meta + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut obj = serde_json::Map::new(); + obj.insert("name".into(), Value::String(name)); + obj.insert("size".into(), Value::Number(size.into())); + obj.insert("created_at".into(), Value::Number(created.into())); + backups.push(Value::Object(obj)); + } + // 按时间倒序 + backups.sort_by(|a, b| { + let ta = a.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + let tb = b.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + tb.cmp(&ta) + }); + Ok(Value::Array(backups)) +} + +#[tauri::command] +pub fn create_backup() -> Result { + let dir = backups_dir(); + fs::create_dir_all(&dir) + .map_err(|e| format!("创建备份目录失败: {e}"))?; + + let src = openclaw_dir().join("openclaw.json"); + if !src.exists() { + return Err("openclaw.json 不存在".into()); + } + + let now = chrono::Local::now(); + let name = format!("openclaw-{}.json", now.format("%Y%m%d-%H%M%S")); + let dest = dir.join(&name); + fs::copy(&src, &dest) + .map_err(|e| format!("备份失败: {e}"))?; + + let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); + let mut obj = serde_json::Map::new(); + obj.insert("name".into(), Value::String(name)); + obj.insert("size".into(), Value::Number(size.into())); + Ok(Value::Object(obj)) +} + +#[tauri::command] +pub fn restore_backup(name: String) -> Result<(), String> { + // 安全检查 + if name.contains("..") || name.contains('/') { + return Err("非法文件名".into()); + } + let backup_path = backups_dir().join(&name); + if !backup_path.exists() { + return Err(format!("备份文件不存在: {name}")); + } + let target = openclaw_dir().join("openclaw.json"); + + // 恢复前先自动备份当前配置 + if target.exists() { + let _ = create_backup(); + } + + fs::copy(&backup_path, &target) + .map_err(|e| format!("恢复失败: {e}"))?; + Ok(()) +} + +#[tauri::command] +pub fn delete_backup(name: String) -> Result<(), String> { + if name.contains("..") || name.contains('/') { + return Err("非法文件名".into()); + } + let path = backups_dir().join(&name); + if !path.exists() { + return Err(format!("备份文件不存在: {name}")); + } + fs::remove_file(&path) + .map_err(|e| format!("删除失败: {e}")) +} diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs index e1d1282..5f19739 100644 --- a/src-tauri/src/commands/memory.rs +++ b/src-tauri/src/commands/memory.rs @@ -1,5 +1,6 @@ /// 记忆文件管理命令 use std::fs; +use std::io::Write; use std::path::PathBuf; fn openclaw_dir() -> PathBuf { @@ -132,3 +133,44 @@ pub fn delete_memory_file(path: String) -> Result<(), String> { Err(format!("文件不存在: {path}")) } + +#[tauri::command] +pub fn export_memory_zip(category: String) -> Result { + let dir = memory_dir(&category); + if !dir.exists() { + return Err("目录不存在".to_string()); + } + + let mut files = Vec::new(); + collect_files(&dir, &dir, &mut files, &category)?; + if files.is_empty() { + return Err("没有可导出的文件".to_string()); + } + + let tmp_dir = std::env::temp_dir(); + let zip_name = format!( + "openclaw-{}-{}.zip", + category, + chrono::Local::now().format("%Y%m%d-%H%M%S") + ); + let zip_path = tmp_dir.join(&zip_name); + + let file = fs::File::create(&zip_path) + .map_err(|e| format!("创建 zip 失败: {e}"))?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + for rel_path in &files { + let full_path = dir.join(rel_path); + let content = fs::read_to_string(&full_path) + .map_err(|e| format!("读取 {rel_path} 失败: {e}"))?; + zip.start_file(rel_path, options) + .map_err(|e| format!("写入 zip 失败: {e}"))?; + zip.write_all(content.as_bytes()) + .map_err(|e| format!("写入内容失败: {e}"))?; + } + + zip.finish().map_err(|e| format!("完成 zip 失败: {e}"))?; + Ok(zip_path.to_string_lossy().to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4662f85..7a0137b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,10 @@ pub fn run() { config::get_version_info, config::check_installation, config::write_env_file, + config::list_backups, + config::create_backup, + config::restore_backup, + config::delete_backup, // 服务 service::get_services_status, service::start_service, @@ -28,6 +32,7 @@ pub fn run() { memory::read_memory_file, memory::write_memory_file, memory::delete_memory_file, + memory::export_memory_zip, ]) .run(tauri::generate_context!()) .expect("启动 ClawPanel 失败"); diff --git a/src/components/sidebar.js b/src/components/sidebar.js index d3cf25c..1958082 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -17,16 +17,13 @@ const NAV_ITEMS = [ section: '配置', items: [ { route: '/models', label: '模型配置', icon: 'models' }, - { route: '/agents', label: 'Agent 配置', icon: 'agents' }, { route: '/gateway', label: 'Gateway', icon: 'gateway' }, - { route: '/mcp', label: 'MCP 工具', icon: 'mcp' }, ] }, { section: '数据', items: [ { route: '/memory', label: '记忆文件', icon: 'memory' }, - { route: '/deploy', label: 'ClawApp 部署', icon: 'deploy' }, ] } ] @@ -36,11 +33,8 @@ const ICONS = { services: '', logs: '', models: '', - agents: '', gateway: '', - mcp: '', memory: '', - deploy: '', } let _delegated = false diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index e383db2..fc1fd65 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -85,6 +85,7 @@ function mockInvoke(cmd, args) { read_memory_file: ({ path }) => `# ${path}\n\n这是 ${path} 的内容示例。\n\n## 概述\n\n在此记录工作记忆...`, write_memory_file: () => true, delete_memory_file: () => true, + export_memory_zip: ({ category }) => `/tmp/openclaw-${category}-20260226-160000.zip`, check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23' }), get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }), read_mcp_config: () => ({ @@ -99,6 +100,13 @@ function mockInvoke(cmd, args) { stop_service: () => true, restart_service: () => true, write_env_file: () => true, + list_backups: () => [ + { name: 'openclaw-20260226-143000.json', size: 8542, created_at: 1740577800 }, + { name: 'openclaw-20260225-100000.json', size: 8210, created_at: 1740474000 }, + ], + create_backup: () => ({ name: 'openclaw-20260226-160000.json', size: 8542 }), + restore_backup: () => true, + delete_backup: () => true, } const fn = mocks[cmd] return fn ? Promise.resolve(fn(args)) : Promise.reject(`未知命令: ${cmd}`) @@ -128,9 +136,16 @@ export const api = { readMemoryFile: (path) => invoke('read_memory_file', { path }), writeMemoryFile: (path, content) => invoke('write_memory_file', { path, content }), deleteMemoryFile: (path) => invoke('delete_memory_file', { path }), + exportMemoryZip: (category) => invoke('export_memory_zip', { category }), // 安装/部署 checkInstallation: () => invoke('check_installation'), getDeployConfig: () => invoke('get_deploy_config'), writeEnvFile: (path, config) => invoke('write_env_file', { path, config }), + + // 备份管理 + listBackups: () => invoke('list_backups'), + createBackup: () => invoke('create_backup'), + restoreBackup: (name) => invoke('restore_backup', { name }), + deleteBackup: (name) => invoke('delete_backup', { name }), } diff --git a/src/main.js b/src/main.js index 0eb88c1..168e050 100644 --- a/src/main.js +++ b/src/main.js @@ -17,11 +17,8 @@ registerRoute('/dashboard', () => import('./pages/dashboard.js')) registerRoute('/services', () => import('./pages/services.js')) registerRoute('/logs', () => import('./pages/logs.js')) registerRoute('/models', () => import('./pages/models.js')) -registerRoute('/agents', () => import('./pages/agents.js')) registerRoute('/gateway', () => import('./pages/gateway.js')) -registerRoute('/mcp', () => import('./pages/mcp.js')) registerRoute('/memory', () => import('./pages/memory.js')) -registerRoute('/deploy', () => import('./pages/deploy.js')) // 初始化主题 initTheme() diff --git a/src/pages/agents.js b/src/pages/agents.js deleted file mode 100644 index 2397dcd..0000000 --- a/src/pages/agents.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Agent 配置页面 - */ -import { api } from '../lib/tauri-api.js' -import { toast } from '../components/toast.js' - -export async function render() { - const page = document.createElement('div') - page.className = 'page' - - page.innerHTML = ` - -
加载中...
-
- -
- ` - - const state = { config: null } - await loadConfig(page, state) - - page.querySelector('#btn-save-agent').onclick = async () => { - const btn = page.querySelector('#btn-save-agent') - btn.disabled = true - btn.textContent = '保存中...' - try { - await saveConfig(page, state) - } finally { - btn.disabled = false - btn.textContent = '保存配置' - } - } - return page -} - -async function loadConfig(page, state) { - try { - state.config = await api.readOpenclawConfig() - renderConfig(page, state) - } catch (e) { - toast('加载配置失败: ' + e, 'error') - } -} - -function renderConfig(page, state) { - const el = page.querySelector('#agent-config') - const agents = state.config?.agents || {} - const defaults = agents.defaults || {} - const model = defaults.model || {} - - el.innerHTML = ` -
-
主模型
-
- - -
-
- -
-
Fallback 链
-
- ${(model.fallbacks || []).map((f, i) => ` -
- ${i + 1}. - - -
- `).join('')} -
- -
- -
-
并发控制
-
-
- - -
-
- - -
-
-
- ` - - // 删除 fallback - el.querySelectorAll('[data-action="remove-fallback"]').forEach(btn => { - btn.onclick = () => { - const idx = parseInt(btn.dataset.index) - if (model.fallbacks) model.fallbacks.splice(idx, 1) - renderConfig(page, state) - } - }) - - // fallback 输入框实时同步到 state - el.querySelectorAll('.fallback-input').forEach((input, i) => { - input.oninput = () => { - if (model.fallbacks) model.fallbacks[i] = input.value - } - }) - - // 添加 fallback - el.querySelector('#btn-add-fallback').onclick = () => { - if (!model.fallbacks) model.fallbacks = [] - model.fallbacks.push('') - renderConfig(page, state) - } -} - -async function saveConfig(page, state) { - // 从 DOM 收集值 - const primary = page.querySelector('#primary-model')?.value || '' - const fallbacks = [...page.querySelectorAll('.fallback-input')].map(i => i.value).filter(Boolean) - const maxConcurrent = parseInt(page.querySelector('#max-concurrent')?.value) || 4 - const subagents = parseInt(page.querySelector('#max-subagents')?.value) || 2 - - if (!state.config.agents) state.config.agents = {} - if (!state.config.agents.defaults) state.config.agents.defaults = {} - state.config.agents.defaults.model = { primary, fallbacks } - state.config.agents.defaults.maxConcurrent = maxConcurrent - state.config.agents.defaults.subagents = subagents - - try { - await api.writeOpenclawConfig(state.config) - toast('Agent 配置已保存', 'success') - } catch (e) { - toast('保存失败: ' + e, 'error') - } -} diff --git a/src/pages/deploy.js b/src/pages/deploy.js deleted file mode 100644 index b285b1c..0000000 --- a/src/pages/deploy.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * ClawApp 部署页面 - */ -import { api } from '../lib/tauri-api.js' -import { toast } from '../components/toast.js' - -export async function render() { - const page = document.createElement('div') - page.className = 'page' - - page.innerHTML = ` - -
加载中...
- ` - - await loadDeployConfig(page) - return page -} - -async function loadDeployConfig(page) { - const el = page.querySelector('#deploy-content') - try { - const [config, version] = await Promise.all([ - api.readOpenclawConfig(), - api.getVersionInfo(), - ]) - - const gw = config?.gateway || {} - const port = gw.port || 18789 - const bind = gw.bind || 'loopback' - const token = gw.authToken || '' - - // 推断 Gateway URL - let gwUrl = `http://127.0.0.1:${port}` - if (gw.tailscale?.address) { - gwUrl = `http://${gw.tailscale.address}` - } - - const envContent = [ - `# ClawApp 环境配置`, - `# 由 ClawPanel 自动生成`, - `VITE_GATEWAY_URL=${gwUrl}`, - token ? `VITE_AUTH_TOKEN=${token}` : `# VITE_AUTH_TOKEN=`, - `VITE_APP_VERSION=${version?.current || 'unknown'}`, - ].join('\n') - - renderDeployUI(page, el, envContent, gwUrl, token) - } catch (e) { - toast('加载部署配置失败: ' + e, 'error') - el.innerHTML = '
加载失败
' - } -} - -function renderDeployUI(page, el, envContent, gwUrl, token) { - el.innerHTML = ` -
-
连接信息
-
-
-
Gateway URL
-
${gwUrl}
-
-
-
认证状态
-
${token ? '已配置 Token' : '无认证'}
-
-
-
- -
-
.env 文件预览
-
- ${envContent.split('\n').map(l => `
${escapeHtml(l)}
`).join('')} -
-
- - -
-
- -
-
写入路径
-
- -
-
- ` - - // 复制到剪贴板 - el.querySelector('#btn-copy-env').onclick = async () => { - try { - await navigator.clipboard.writeText(envContent) - toast('已复制到剪贴板', 'success') - } catch { - // fallback - const ta = document.createElement('textarea') - ta.value = envContent - document.body.appendChild(ta) - ta.select() - document.execCommand('copy') - ta.remove() - toast('已复制到剪贴板', 'success') - } - } - - // 写入文件 - el.querySelector('#btn-write-env').onclick = async () => { - const path = el.querySelector('#env-path')?.value - if (!path) { - toast('请输入 .env 文件路径', 'error') - return - } - try { - await api.writeEnvFile(path, envContent) - toast('.env 文件已写入', 'success') - } catch (e) { - toast('写入失败: ' + e, 'error') - } - } -} - -function escapeHtml(str) { - return str.replace(/&/g, '&').replace(//g, '>') -} diff --git a/src/pages/mcp.js b/src/pages/mcp.js deleted file mode 100644 index 6aeb5b6..0000000 --- a/src/pages/mcp.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * MCP 工具配置页面 - */ -import { api } from '../lib/tauri-api.js' -import { toast } from '../components/toast.js' -import { showModal } from '../components/modal.js' - -export async function render() { - const page = document.createElement('div') - page.className = 'page' - - page.innerHTML = ` - -
- - -
-
加载中...
- ` - - const state = { config: null } - await loadConfig(page, state) - - page.querySelector('#btn-save-mcp').onclick = async () => { - const btn = page.querySelector('#btn-save-mcp') - btn.disabled = true - btn.textContent = '保存中...' - try { - await saveConfig(state) - } finally { - btn.disabled = false - btn.textContent = '保存配置' - } - } - page.querySelector('#btn-add-mcp').onclick = () => addServer(page, state) - return page -} - -async function loadConfig(page, state) { - try { - state.config = await api.readMcpConfig() - renderServers(page, state) - } catch (e) { - toast('加载 MCP 配置失败: ' + e, 'error') - } -} - -function renderServers(page, state) { - const listEl = page.querySelector('#mcp-list') - const servers = state.config?.mcpServers || state.config || {} - const keys = Object.keys(servers) - - if (!keys.length) { - listEl.innerHTML = '
暂无 MCP Server 配置
' - return - } - - listEl.innerHTML = keys.map(key => { - const s = servers[key] - const type = s.url ? 'http' : 'stdio' - return ` -
-
- -
-
${key}
-
${type} · ${type === 'stdio' ? (s.command || '') : (s.url || '')}
-
-
-
- - -
-
- ` - }).join('') - - // 绑定事件 - listEl.querySelectorAll('[data-action]').forEach(btn => { - btn.onclick = () => { - const card = btn.closest('[data-server]') - const key = card.dataset.server - const action = btn.dataset.action - - if (action === 'delete') { - if (!confirm(`确定删除 MCP Server "${key}"?`)) return - if (state.config.mcpServers) delete state.config.mcpServers[key] - else delete state.config[key] - renderServers(page, state) - toast(`已删除 ${key}`, 'info') - } else if (action === 'edit') { - editServer(page, state, key) - } - } - }) -} - -function editServer(page, state, key) { - const servers = state.config?.mcpServers || state.config || {} - const s = servers[key] || {} - const json = JSON.stringify(s, null, 2) - - const listEl = page.querySelector('#mcp-list') - listEl.innerHTML = ` -
-
编辑: ${key}
-
- - -
-
- - -
-
- ` - - listEl.querySelector('#btn-apply-edit').onclick = () => { - try { - const parsed = JSON.parse(listEl.querySelector('#mcp-json').value) - if (state.config.mcpServers) state.config.mcpServers[key] = parsed - else state.config[key] = parsed - renderServers(page, state) - toast('已应用修改', 'success') - } catch (e) { - toast('JSON 格式错误: ' + e.message, 'error') - } - } - - listEl.querySelector('#btn-cancel-edit').onclick = () => renderServers(page, state) -} - -function addServer(page, state) { - showModal({ - title: '添加 MCP Server', - fields: [ - { name: 'name', label: 'Server 名称', placeholder: '如 exa, web-reader' }, - { name: 'command', label: '启动命令', placeholder: '如 npx, node' }, - ], - onConfirm: ({ name, command }) => { - if (!name) return - const target = state.config?.mcpServers || state.config - target[name] = { command: command || '', args: [], env: {} } - renderServers(page, state) - toast(`已添加 ${name}`, 'success') - }, - }) -} - -async function saveConfig(state) { - try { - await api.writeMcpConfig(state.config) - toast('MCP 配置已保存', 'success') - } catch (e) { - toast('保存失败: ' + e, 'error') - } -} - -function escapeHtml(str) { - return str.replace(/&/g, '&').replace(//g, '>') -} diff --git a/src/pages/memory.js b/src/pages/memory.js index 00b8e14..b4fc2d7 100644 --- a/src/pages/memory.js +++ b/src/pages/memory.js @@ -29,12 +29,16 @@ export async function render() { +
+ +
加载中...
选择文件查看
+
@@ -98,6 +102,12 @@ export async function render() { } } + // 单个下载 + page.querySelector('#btn-download').onclick = () => downloadCurrentFile(page, state) + + // 打包下载 + page.querySelector('#btn-export-zip').onclick = () => exportZip(state) + loadFiles(page, state) return page } @@ -142,6 +152,7 @@ async function loadFileContent(page, state) { const btnSave = page.querySelector('#btn-save-file') const btnPreview = page.querySelector('#btn-preview') const btnDel = page.querySelector('#btn-del-file') + const btnDl = page.querySelector('#btn-download') editor.disabled = true editor.value = '加载中...' @@ -160,6 +171,7 @@ async function loadFileContent(page, state) { btnSave.disabled = false btnPreview.disabled = false btnDel.disabled = false + btnDl.disabled = false } catch (e) { editor.value = '读取失败: ' + e toast('读取文件失败: ' + e, 'error') @@ -178,6 +190,7 @@ function resetEditor(page) { page.querySelector('#btn-preview').disabled = true page.querySelector('#btn-preview').textContent = '预览' page.querySelector('#btn-del-file').disabled = true + page.querySelector('#btn-download').disabled = true } async function saveFile(page, state) { @@ -228,3 +241,37 @@ function renderMarkdown(md) { .replace(/\n\n/g, '

') .replace(/\n/g, '
') } + +// ===== 下载功能 ===== + +function triggerDownload(filename, content) { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +async function downloadCurrentFile(page, state) { + if (!state.currentPath) return + try { + const content = page.querySelector('#file-editor').value + const filename = state.currentPath.split('/').pop() + triggerDownload(filename, content) + toast(`已下载 ${filename}`, 'success') + } catch (e) { + toast('下载失败: ' + e, 'error') + } +} + +async function exportZip(state) { + try { + const zipPath = await api.exportMemoryZip(state.category) + const label = CATEGORIES.find(c => c.key === state.category)?.label || state.category + toast(`已导出: ${label} → ${zipPath}`, 'success') + } catch (e) { + toast('打包下载失败: ' + e, 'error') + } +} diff --git a/src/pages/models.js b/src/pages/models.js index 5dc06c5..22e7588 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -1,5 +1,6 @@ /** * 模型配置页面 + * 模型增删改查 + 选择默认主模型应用(未选中自动成为 fallback) */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' @@ -12,50 +13,86 @@ export async function render() { page.innerHTML = `
- + +
+
加载中...
` const state = { config: null } await loadConfig(page, state) - page.querySelector('#btn-save-models').onclick = async () => { - const btn = page.querySelector('#btn-save-models') - btn.disabled = true - btn.textContent = '保存中...' - try { - await saveConfig(state) - } finally { - btn.disabled = false - btn.textContent = '保存配置' - } - } - page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state) - + // 事件委托绑定 + bindTopActions(page, state) return page } async function loadConfig(page, state) { try { state.config = await api.readOpenclawConfig() + renderDefaultBar(page, state) renderProviders(page, state) } catch (e) { toast('加载配置失败: ' + e, 'error') } } +// 获取当前默认主模型 +function getCurrentPrimary(config) { + return config?.agents?.defaults?.model?.primary || '' +} + +// 收集所有 provider/model-id 组合 +function collectAllModels(config) { + const result = [] + const providers = config?.models?.providers || {} + for (const [pk, pv] of Object.entries(providers)) { + for (const m of (pv.models || [])) { + const id = typeof m === 'string' ? m : m.id + if (id) result.push({ provider: pk, modelId: id, full: `${pk}/${id}` }) + } + } + return result +} + +// 渲染默认模型状态栏 +function renderDefaultBar(page, state) { + const bar = page.querySelector('#default-model-bar') + const primary = getCurrentPrimary(state.config) + const allModels = collectAllModels(state.config) + const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full) + + bar.innerHTML = ` +
+
当前应用配置
+
+
+ 主模型: + ${primary || '未配置'} +
+
+ Fallback: + ${fallbacks.length ? fallbacks.join(', ') : '无'} +
+
+
+ ` +} + +// 渲染 Provider 列表 function renderProviders(page, state) { const listEl = page.querySelector('#providers-list') const providers = state.config?.models?.providers || {} const keys = Object.keys(providers) + const primary = getCurrentPrimary(state.config) if (!keys.length) { - listEl.innerHTML = '
暂无 Provider 配置,点击上方按钮添加
' + listEl.innerHTML = '
暂无 Provider,点击上方按钮添加
' return } @@ -65,116 +102,251 @@ function renderProviders(page, state) { return `
- ${key} + ${key} ${p.api || p.apiType || ''} · ${models.length} 个模型
- + +
-
-
- - -
-
- - -
-
- ` }).join('') - // 绑定事件 + bindProviderEvents(page, state) +} + +// 渲染单个 Provider 下的模型卡片 +function renderModelCards(providerKey, models, primary) { + if (!models.length) { + return '
暂无模型
' + } + return models.map((m, i) => { + const id = typeof m === 'string' ? m : m.id + const name = m.name || id + const full = `${providerKey}/${id}` + const isPrimary = full === primary + const borderColor = isPrimary ? 'var(--success)' : 'var(--border-primary)' + const bgColor = isPrimary ? 'var(--success-muted)' : 'var(--bg-tertiary)' + return ` +
+
+
+ ${id} + ${isPrimary ? '主模型' : ''} + ${m.reasoning ? 'Reasoning' : ''} +
+
+ ${name !== id ? name + ' · ' : ''}${m.contextWindow ? (m.contextWindow / 1000) + 'K ctx' : ''}${m.cost?.input ? ' · $' + m.cost.input + '/$' + m.cost.output : ''} +
+
+
+ ${!isPrimary ? `` : ''} + + +
+
+ ` + }).join('') +} + +// 绑定 Provider 列表内的事件 +function bindProviderEvents(page, state) { + const listEl = page.querySelector('#providers-list') listEl.querySelectorAll('[data-action]').forEach(btn => { btn.onclick = () => { const section = btn.closest('[data-provider]') const providerKey = section.dataset.provider const action = btn.dataset.action - if (action === 'toggle') { - const models = section.querySelector('.provider-models') - models.style.display = models.style.display === 'none' ? 'block' : 'none' - } else if (action === 'delete-provider') { - if (!confirm(`确定删除 Provider "${providerKey}"?`)) return + if (action === 'delete-provider') { + if (!confirm(`确定删除 Provider "${providerKey}" 及其所有模型?`)) return delete state.config.models.providers[providerKey] renderProviders(page, state) + renderDefaultBar(page, state) toast(`已删除 ${providerKey}`, 'info') - } else if (action === 'delete-model') { - const idx = parseInt(btn.dataset.index) - state.config.models.providers[providerKey].models.splice(idx, 1) - renderProviders(page, state) } else if (action === 'add-model') { - showModal({ - title: '添加模型', - fields: [{ name: 'id', label: '模型 ID', placeholder: '如 claude-opus-4-6' }], - onConfirm: ({ id }) => { - if (id) { - state.config.models.providers[providerKey].models.push({ id }) - renderProviders(page, state) - } - }, - }) + addModel(page, state, providerKey) + } else if (action === 'edit-provider') { + editProvider(page, state, providerKey) + } else if (action === 'delete-model') { + const card = btn.closest('.model-card') + const idx = parseInt(card.dataset.index) + const models = state.config.models.providers[providerKey].models + models.splice(idx, 1) + renderProviders(page, state) + renderDefaultBar(page, state) + } else if (action === 'edit-model') { + const card = btn.closest('.model-card') + const idx = parseInt(card.dataset.index) + editModel(page, state, providerKey, idx) + } else if (action === 'set-primary') { + const card = btn.closest('.model-card') + const full = card.dataset.full + setPrimary(state, full) + renderProviders(page, state) + renderDefaultBar(page, state) + toast(`已设为主模型: ${full}`, 'success') } } }) - - // 输入框变更实时同步到 state - listEl.querySelectorAll('[data-field]').forEach(input => { - input.oninput = () => { - const providerKey = input.closest('[data-provider]').dataset.provider - state.config.models.providers[providerKey][input.dataset.field] = input.value - } - }) } +// 设置主模型(仅修改 state,不写入文件) +function setPrimary(state, full) { + if (!state.config.agents) state.config.agents = {} + if (!state.config.agents.defaults) state.config.agents.defaults = {} + if (!state.config.agents.defaults.model) state.config.agents.defaults.model = {} + state.config.agents.defaults.model.primary = full +} + +// 顶部按钮事件绑定 +function bindTopActions(page, state) { + page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state) + + page.querySelector('#btn-save-models').onclick = async () => { + const btn = page.querySelector('#btn-save-models') + btn.disabled = true + btn.textContent = '保存中...' + try { + await api.writeOpenclawConfig(state.config) + toast('模型配置已保存', 'success') + } catch (e) { + toast('保存失败: ' + e, 'error') + } finally { + btn.disabled = false + btn.textContent = '保存模型配置' + } + } + + page.querySelector('#btn-apply-default').onclick = async () => { + const btn = page.querySelector('#btn-apply-default') + const primary = getCurrentPrimary(state.config) + if (!primary) { + toast('请先选择一个主模型', 'warning') + return + } + btn.disabled = true + btn.textContent = '应用中...' + try { + applyDefaultModel(state) + await api.writeOpenclawConfig(state.config) + renderDefaultBar(page, state) + toast('默认模型已应用', 'success') + } catch (e) { + toast('应用失败: ' + e, 'error') + } finally { + btn.disabled = false + btn.textContent = '应用默认模型' + } + } +} + +// 应用默认模型:primary + 其余自动成为 fallback +function applyDefaultModel(state) { + const primary = getCurrentPrimary(state.config) + const allModels = collectAllModels(state.config) + const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full) + + const defaults = state.config.agents.defaults + defaults.model.primary = primary + defaults.model.fallbacks = fallbacks + + // 生成 models 映射(所有模型的空配置对象) + const modelsMap = {} + modelsMap[primary] = {} + for (const fb of fallbacks) modelsMap[fb] = {} + defaults.models = modelsMap +} + +// 添加 Provider function addProvider(page, state) { showModal({ title: '添加 Provider', fields: [ { name: 'key', label: 'Provider 名称', placeholder: '如 openai, newapi' }, { name: 'baseUrl', label: 'Base URL', placeholder: 'https://api.openai.com/v1' }, - { - name: 'apiType', label: 'API 类型', type: 'select', - options: [ - { value: 'openai', label: 'OpenAI' }, - { value: 'anthropic', label: 'Anthropic' }, - { value: 'google', label: 'Google' }, - ], - }, + { name: 'apiKey', label: 'API Key', placeholder: 'sk-...' }, ], - onConfirm: ({ key, baseUrl, apiType }) => { + onConfirm: ({ key, baseUrl, apiKey }) => { if (!key) return if (!state.config.models) state.config.models = { mode: 'replace', providers: {} } if (!state.config.models.providers) state.config.models.providers = {} - state.config.models.providers[key] = { baseUrl, apiType, models: [] } + state.config.models.providers[key] = { + baseUrl: baseUrl || '', + apiKey: apiKey || '', + api: 'openai-completions', + models: [], + } renderProviders(page, state) - toast(`已添加 ${key}`, 'success') + toast(`已添加 Provider: ${key}`, 'success') }, }) } -async function saveConfig(state) { - try { - await api.writeOpenclawConfig(state.config) - toast('配置已保存', 'success') - } catch (e) { - toast('保存失败: ' + e, 'error') - } +// 编辑 Provider 属性 +function editProvider(page, state, providerKey) { + const p = state.config.models.providers[providerKey] + showModal({ + title: `编辑 Provider: ${providerKey}`, + fields: [ + { name: 'baseUrl', label: 'Base URL', value: p.baseUrl || '' }, + { name: 'apiKey', label: 'API Key', value: p.apiKey || '' }, + { name: 'api', label: 'API 类型', value: p.api || 'openai-completions' }, + ], + onConfirm: ({ baseUrl, apiKey, api: apiType }) => { + p.baseUrl = baseUrl + p.apiKey = apiKey + p.api = apiType + renderProviders(page, state) + toast('Provider 已更新', 'success') + }, + }) +} + +// 添加模型 +function addModel(page, state, providerKey) { + showModal({ + title: `添加模型到 ${providerKey}`, + fields: [ + { name: 'id', label: '模型 ID', placeholder: '如 claude-opus-4-6' }, + { name: 'name', label: '显示名称', placeholder: '如 Claude Opus 4.6' }, + { name: 'contextWindow', label: 'Context Window', placeholder: '如 200000' }, + ], + onConfirm: ({ id, name, contextWindow }) => { + if (!id) return + const model = { id, name: name || id, reasoning: false, input: ['text', 'image'] } + if (contextWindow) model.contextWindow = parseInt(contextWindow) || 0 + state.config.models.providers[providerKey].models.push(model) + renderProviders(page, state) + renderDefaultBar(page, state) + toast(`已添加模型: ${id}`, 'success') + }, + }) +} + +// 编辑模型属性 +function editModel(page, state, providerKey, idx) { + const m = state.config.models.providers[providerKey].models[idx] + showModal({ + title: `编辑模型: ${m.id}`, + fields: [ + { name: 'id', label: '模型 ID', value: m.id || '' }, + { name: 'name', label: '显示名称', value: m.name || '' }, + { name: 'contextWindow', label: 'Context Window', value: String(m.contextWindow || '') }, + ], + onConfirm: (vals) => { + if (!vals.id) return + m.id = vals.id + m.name = vals.name || vals.id + if (vals.contextWindow) m.contextWindow = parseInt(vals.contextWindow) || 0 + renderProviders(page, state) + renderDefaultBar(page, state) + toast('模型已更新', 'success') + }, + }) } diff --git a/src/pages/services.js b/src/pages/services.js index 3d2438f..1cb7c7a 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -1,9 +1,12 @@ /** * 服务管理页面 + * 服务启停 + 更新检测 + 配置备份管理 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' +let _delegated = false + export async function render() { const page = document.createElement('div') page.className = 'page' @@ -11,64 +14,194 @@ export async function render() { page.innerHTML = ` +
加载中...
+
+
配置备份
+
+ +
+
加载中...
+
` - loadServices(page) + bindEvents(page) + loadAll(page) return page } -async function loadServices(page) { +async function loadAll(page) { + await Promise.all([ + loadVersion(page), + loadServices(page), + loadBackups(page), + ]) +} + +// ===== 版本检测 ===== + +async function loadVersion(page) { + const bar = page.querySelector('#version-bar') try { - const services = await api.getServicesStatus() - renderServices(page, services) + const info = await api.getVersionInfo() + const ver = info.current || '未知' + bar.innerHTML = ` +
+
+
+ 当前版本 +
+
${ver}
+
${info.update_available ? '有新版本可用' : '已是最新版本'}
+
+
+ ` } catch (e) { - toast('加载服务状态失败: ' + e, 'error') + bar.innerHTML = `
版本信息加载失败
` } } -function renderServices(page, services) { - const listEl = page.querySelector('#services-list') - listEl.innerHTML = services.map(s => ` +// ===== 服务列表 ===== + +async function loadServices(page) { + const container = page.querySelector('#services-list') + try { + const services = await api.getServicesStatus() + renderServices(container, services) + } catch (e) { + container.innerHTML = `
加载服务列表失败: ${e}
` + } +} + +function renderServices(container, services) { + if (!services || !services.length) { + container.innerHTML = '
暂无服务
' + return + } + container.innerHTML = services.map(s => `
${s.label}
-
${s.description}${s.pid ? ' · PID: ' + s.pid : ''}
+
${s.description || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}
${s.running - ? ` - ` - : `` + ? ` + ` + : `` }
`).join('') +} - // 绑定操作按钮 - listEl.querySelectorAll('[data-action]').forEach(btn => { - btn.onclick = async () => { - const card = btn.closest('.service-card') - const label = card.dataset.label - const action = btn.dataset.action - btn.disabled = true - btn.textContent = '执行中...' - try { - if (action === 'start') await api.startService(label) - else if (action === 'stop') await api.stopService(label) - else if (action === 'restart') await api.restartService(label) - toast(`${label} ${action} 成功`, 'success') - setTimeout(() => loadServices(page), 300) - } catch (e) { - toast(`操作失败: ${e}`, 'error') - btn.disabled = false - btn.textContent = action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启' +// ===== 备份管理 ===== + +async function loadBackups(page) { + const list = page.querySelector('#backup-list') + try { + const backups = await api.listBackups() + renderBackups(list, backups) + } catch (e) { + list.innerHTML = `
加载备份列表失败: ${e}
` + } +} + +function renderBackups(container, backups) { + if (!backups || !backups.length) { + container.innerHTML = '
暂无备份
' + return + } + container.innerHTML = backups.map(b => { + const date = b.created_at ? new Date(b.created_at * 1000).toLocaleString('zh-CN') : '未知' + const size = b.size ? (b.size / 1024).toFixed(1) + ' KB' : '' + return ` +
+
+
+
${b.name}
+
${date}${size ? ' · ' + size : ''}
+
+
+
+ + +
+
` + }).join('') +} + +// ===== 事件绑定(事件委托) ===== + +function bindEvents(page) { + if (_delegated) return + _delegated = true + + 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 'start': + case 'stop': + case 'restart': + await handleServiceAction(action, btn.dataset.label, page) + break + case 'create-backup': + await handleCreateBackup(page) + break + case 'restore-backup': + await handleRestoreBackup(btn.dataset.name, page) + break + case 'delete-backup': + await handleDeleteBackup(btn.dataset.name, page) + break } + } catch (e) { + toast(e.toString(), 'error') + } finally { + btn.disabled = false } }) } + +// ===== 服务操作 ===== + +const ACTION_LABELS = { start: '启动', stop: '停止', restart: '重启' } + +async function handleServiceAction(action, label, page) { + const fn = { start: api.startService, stop: api.stopService, restart: api.restartService }[action] + await fn(label) + toast(`${ACTION_LABELS[action]} ${label} 成功`, 'success') + await loadServices(page) +} + +// ===== 备份操作 ===== + +async function handleCreateBackup(page) { + const result = await api.createBackup() + toast(`备份已创建: ${result.name}`, 'success') + await loadBackups(page) +} + +async function handleRestoreBackup(name, page) { + if (!confirm(`确定要恢复备份 "${name}" 吗?\n当前配置将自动备份后再恢复。`)) return + await api.restoreBackup(name) + toast('配置已恢复', 'success') + await loadBackups(page) +} + +async function handleDeleteBackup(name, page) { + if (!confirm(`确定要删除备份 "${name}" 吗?此操作不可撤销。`)) return + await api.deleteBackup(name) + toast('备份已删除', 'success') + await loadBackups(page) +}