feat: 精简页面结构并增强核心功能

- 删除 MCP 配置、Agent 配置、部署 3 个页面,保留 6 个核心页面
- 重写模型配置页:Provider/模型 CRUD + 一键应用默认模型(自动生成 fallback)
- 增强服务管理页:版本检测 + 配置备份管理(创建/恢复/删除)
- 增强记忆文件页:单个文件下载 + 分类打包 zip 下载
- Rust 后端新增 5 个命令(4 个备份 + export_memory_zip)
- 更新路由和侧边栏,同步清理
This commit is contained in:
晴天
2026-02-27 00:16:45 +08:00
parent c2e3f738b5
commit 1b9a195d32
14 changed files with 693 additions and 555 deletions

View File

@@ -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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>',
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
models: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12"/></svg>',
agents: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>',
gateway: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>',
mcp: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>',
memory: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>',
deploy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
}
let _delegated = false

View File

@@ -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 }),
}

View File

@@ -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()

View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">Agent 配置</h1>
<p class="page-desc">配置默认模型、Fallback 链和记忆搜索</p>
</div>
<div id="agent-config">加载中...</div>
<div style="margin-top:16px">
<button class="btn btn-primary" id="btn-save-agent">保存配置</button>
</div>
`
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 = `
<div class="config-section">
<div class="config-section-title">主模型</div>
<div class="form-group">
<label class="form-label">Primary Model</label>
<input class="form-input" id="primary-model" value="${model.primary || ''}" placeholder="如 newapi-claude/claude-opus-4-6">
</div>
</div>
<div class="config-section">
<div class="config-section-title">Fallback 链</div>
<div id="fallback-list">
${(model.fallbacks || []).map((f, i) => `
<div class="fallback-item" style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<span style="color:var(--text-tertiary);font-size:var(--font-size-sm);min-width:20px">${i + 1}.</span>
<input class="form-input fallback-input" value="${f}" style="flex:1">
<button class="btn btn-sm btn-danger" data-action="remove-fallback" data-index="${i}">删除</button>
</div>
`).join('')}
</div>
<button class="btn btn-sm btn-secondary" id="btn-add-fallback">+ 添加 Fallback</button>
</div>
<div class="config-section">
<div class="config-section-title">并发控制</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label class="form-label">最大并发</label>
<input class="form-input" id="max-concurrent" type="number" value="${defaults.maxConcurrent || 4}" min="1" max="20">
</div>
<div class="form-group">
<label class="form-label">子 Agent 数</label>
<input class="form-input" id="max-subagents" type="number" value="${defaults.subagents || 2}" min="0" max="10">
</div>
</div>
</div>
`
// 删除 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')
}
}

View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">ClawApp 部署</h1>
<p class="page-desc">一键生成 ClawApp 客户端配置</p>
</div>
<div id="deploy-content">加载中...</div>
`
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 = '<div style="color:var(--text-tertiary)">加载失败</div>'
}
}
function renderDeployUI(page, el, envContent, gwUrl, token) {
el.innerHTML = `
<div class="config-section">
<div class="config-section-title">连接信息</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">Gateway URL</span></div>
<div class="stat-card-value" style="font-size:var(--font-size-sm);font-family:var(--font-mono)">${gwUrl}</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">认证状态</span></div>
<div class="stat-card-value">${token ? '已配置 Token' : '无认证'}</div>
</div>
</div>
</div>
<div class="config-section">
<div class="config-section-title">.env 文件预览</div>
<div class="log-viewer" style="max-height:200px;margin-bottom:12px">
${envContent.split('\n').map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')}
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="btn-copy-env">复制到剪贴板</button>
<button class="btn btn-secondary btn-sm" id="btn-write-env">写入 .env 文件</button>
</div>
</div>
<div class="config-section">
<div class="config-section-title">写入路径</div>
<div class="form-group">
<input class="form-input" id="env-path" value="~/Desktop/clawapp/.env" placeholder="输入 ClawApp 项目 .env 文件路径">
</div>
</div>
`
// 复制到剪贴板
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">MCP 工具</h1>
<p class="page-desc">管理 MCP Server 配置</p>
</div>
<div class="config-actions">
<button class="btn btn-primary btn-sm" id="btn-add-mcp">+ 添加 MCP Server</button>
<button class="btn btn-secondary btn-sm" id="btn-save-mcp">保存配置</button>
</div>
<div id="mcp-list">加载中...</div>
`
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 = '<div style="color:var(--text-tertiary);padding:20px">暂无 MCP Server 配置</div>'
return
}
listEl.innerHTML = keys.map(key => {
const s = servers[key]
const type = s.url ? 'http' : 'stdio'
return `
<div class="service-card" data-server="${key}">
<div class="service-info">
<span class="status-dot running"></span>
<div>
<div class="service-name">${key}</div>
<div class="service-desc">${type} · ${type === 'stdio' ? (s.command || '') : (s.url || '')}</div>
</div>
</div>
<div class="service-actions">
<button class="btn btn-sm btn-secondary" data-action="edit">编辑</button>
<button class="btn btn-sm btn-danger" data-action="delete">删除</button>
</div>
</div>
`
}).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 = `
<div class="config-section">
<div class="config-section-title">编辑: ${key}</div>
<div class="form-group">
<label class="form-label">JSON 配置</label>
<textarea class="form-input" id="mcp-json" rows="12" style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${escapeHtml(json)}</textarea>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="btn-apply-edit">应用</button>
<button class="btn btn-secondary btn-sm" id="btn-cancel-edit">取消</button>
</div>
</div>
`
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -29,12 +29,16 @@ export async function render() {
<button class="btn btn-sm btn-secondary" id="btn-new-file" style="flex:1">+ 新建</button>
<button class="btn btn-sm btn-danger" id="btn-del-file" disabled style="flex:1">删除</button>
</div>
<div style="padding:0 var(--space-sm) var(--space-sm)">
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">打包下载全部</button>
</div>
<div id="file-tree">加载中...</div>
</div>
<div class="memory-editor">
<div class="editor-toolbar">
<span id="current-file" style="font-size:var(--font-size-sm);color:var(--text-tertiary)">选择文件查看</span>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" id="btn-download" disabled>下载</button>
<button class="btn btn-sm btn-secondary" id="btn-preview" disabled>预览</button>
<button class="btn btn-sm btn-primary" id="btn-save-file" disabled>保存</button>
</div>
@@ -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, '<br><br>')
.replace(/\n/g, '<br>')
}
// ===== 下载功能 =====
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')
}
}

View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">模型配置</h1>
<p class="page-desc">管理 AI 模型 Provider 和模型列表</p>
<p class="page-desc">管理模型列表,选择默认主模型并一键应用</p>
</div>
<div class="config-actions">
<button class="btn btn-primary btn-sm" id="btn-add-provider">+ 添加 Provider</button>
<button class="btn btn-secondary btn-sm" id="btn-save-models">保存配置</button>
<button class="btn btn-secondary btn-sm" id="btn-save-models">保存模型配置</button>
<button class="btn btn-primary btn-sm" id="btn-apply-default">应用默认模型</button>
</div>
<div id="default-model-bar"></div>
<div id="providers-list">加载中...</div>
`
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 = `
<div class="config-section" style="margin-bottom:var(--space-lg)">
<div class="config-section-title">当前应用配置</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<div>
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">主模型:</span>
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm);color:${primary ? 'var(--success)' : 'var(--error)'}">${primary || '未配置'}</span>
</div>
<div>
<span style="font-size:var(--font-size-sm);color:var(--text-tertiary)">Fallback</span>
<span style="font-size:var(--font-size-sm);color:var(--text-secondary)">${fallbacks.length ? fallbacks.join(', ') : '无'}</span>
</div>
</div>
</div>
`
}
// 渲染 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 = '<div style="color:var(--text-tertiary);padding:20px">暂无 Provider 配置,点击上方按钮添加</div>'
listEl.innerHTML = '<div style="color:var(--text-tertiary);padding:20px">暂无 Provider点击上方按钮添加</div>'
return
}
@@ -65,116 +102,251 @@ function renderProviders(page, state) {
return `
<div class="config-section" data-provider="${key}">
<div class="config-section-title" style="display:flex;justify-content:space-between;align-items:center">
<span>${key}</span>
<span>${key} <span style="font-size:var(--font-size-xs);color:var(--text-tertiary);font-weight:400">${p.api || p.apiType || ''} · ${models.length} 个模型</span></span>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" data-action="toggle">展开/收起</button>
<button class="btn btn-sm btn-secondary" data-action="edit-provider">编辑</button>
<button class="btn btn-sm btn-secondary" data-action="add-model">+ 模型</button>
<button class="btn btn-sm btn-danger" data-action="delete-provider">删除</button>
</div>
</div>
<div class="provider-meta" style="margin-bottom:12px">
<div class="form-group" style="margin-bottom:8px">
<label class="form-label">Base URL</label>
<input class="form-input" data-field="baseUrl" value="${p.baseUrl || ''}">
</div>
<div class="form-group" style="margin-bottom:8px">
<label class="form-label">API 类型</label>
<select class="form-input" data-field="apiType">
<option value="openai" ${p.apiType === 'openai' ? 'selected' : ''}>OpenAI</option>
<option value="anthropic" ${p.apiType === 'anthropic' ? 'selected' : ''}>Anthropic</option>
<option value="google" ${p.apiType === 'google' ? 'selected' : ''}>Google</option>
</select>
</div>
</div>
<div class="provider-models" style="display:none">
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);margin-bottom:8px">
模型列表 (${models.length})
</div>
${models.map((m, i) => `
<div class="model-item" style="background:var(--bg-tertiary);padding:8px 12px;border-radius:var(--radius-sm);margin-bottom:6px;display:flex;justify-content:space-between;align-items:center">
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${m.id || m}</span>
<button class="btn btn-sm btn-danger" data-action="delete-model" data-index="${i}">删除</button>
</div>
`).join('')}
<button class="btn btn-sm btn-secondary" data-action="add-model" style="margin-top:4px">+ 添加模型</button>
<div class="provider-models">
${renderModelCards(key, models, primary)}
</div>
</div>
`
}).join('')
// 绑定事件
bindProviderEvents(page, state)
}
// 渲染单个 Provider 下的模型卡片
function renderModelCards(providerKey, models, primary) {
if (!models.length) {
return '<div style="color:var(--text-tertiary);font-size:var(--font-size-sm);padding:8px 0">暂无模型</div>'
}
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 `
<div class="model-card" data-index="${i}" data-full="${full}"
style="background:${bgColor};border:1px solid ${borderColor};padding:10px 14px;border-radius:var(--radius-md);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-family:var(--font-mono);font-size:var(--font-size-sm)">${id}</span>
${isPrimary ? '<span style="font-size:var(--font-size-xs);background:var(--success);color:var(--text-inverse);padding:1px 6px;border-radius:var(--radius-sm)">主模型</span>' : ''}
${m.reasoning ? '<span style="font-size:var(--font-size-xs);background:var(--accent-muted);color:var(--accent);padding:1px 6px;border-radius:var(--radius-sm)">Reasoning</span>' : ''}
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:2px">
${name !== id ? name + ' · ' : ''}${m.contextWindow ? (m.contextWindow / 1000) + 'K ctx' : ''}${m.cost?.input ? ' · $' + m.cost.input + '/$' + m.cost.output : ''}
</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0">
${!isPrimary ? `<button class="btn btn-sm btn-secondary" data-action="set-primary">设为主模型</button>` : ''}
<button class="btn btn-sm btn-secondary" data-action="edit-model">编辑</button>
<button class="btn btn-sm btn-danger" data-action="delete-model">删除</button>
</div>
</div>
`
}).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')
},
})
}

View File

@@ -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 = `
<div class="page-header">
<h1 class="page-title">服务管理</h1>
<p class="page-desc">管理 OpenClaw 相关的 launchd 服务</p>
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
</div>
<div id="version-bar"></div>
<div id="services-list">加载中...</div>
<div class="config-section" id="backup-section">
<div class="config-section-title">配置备份</div>
<div id="backup-actions" style="margin-bottom:var(--space-md)">
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
</div>
<div id="backup-list">加载中...</div>
</div>
`
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 = `
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">当前版本</span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${info.update_available ? '有新版本可用' : '已是最新版本'}</div>
</div>
</div>
`
} catch (e) {
toast('加载服务状态失败: ' + e, 'error')
bar.innerHTML = `<div class="stat-card" style="margin-bottom:var(--space-lg)"><div class="stat-card-label">版本信息加载失败</div></div>`
}
}
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 = `<div style="color:var(--error)">加载服务列表失败: ${e}</div>`
}
}
function renderServices(container, services) {
if (!services || !services.length) {
container.innerHTML = '<div style="color:var(--text-tertiary)">暂无服务</div>'
return
}
container.innerHTML = services.map(s => `
<div class="service-card" data-label="${s.label}">
<div class="service-info">
<span class="status-dot ${s.running ? 'running' : 'stopped'}"></span>
<div>
<div class="service-name">${s.label}</div>
<div class="service-desc">${s.description}${s.pid ? ' · PID: ' + s.pid : ''}</div>
<div class="service-desc">${s.description || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
</div>
</div>
<div class="service-actions">
${s.running
? `<button class="btn btn-sm btn-secondary" data-action="stop">停止</button>
<button class="btn btn-sm btn-primary" data-action="restart">重启</button>`
: `<button class="btn btn-sm btn-primary" data-action="start">启动</button>`
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${s.label}">重启</button>
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${s.label}">停止</button>`
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${s.label}">启动</button>`
}
</div>
</div>
`).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 = `<div style="color:var(--error)">加载备份列表失败: ${e}</div>`
}
}
function renderBackups(container, backups) {
if (!backups || !backups.length) {
container.innerHTML = '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">暂无备份</div>'
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 `
<div class="service-card" data-backup="${b.name}">
<div class="service-info">
<div>
<div class="service-name">${b.name}</div>
<div class="service-desc">${date}${size ? ' · ' + size : ''}</div>
</div>
</div>
<div class="service-actions">
<button class="btn btn-primary btn-sm" data-action="restore-backup" data-name="${b.name}">恢复</button>
<button class="btn btn-danger btn-sm" data-action="delete-backup" data-name="${b.name}">删除</button>
</div>
</div>`
}).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)
}