feat: ClawPanel v0.1.0 项目骨架

- Tauri v2 + Vanilla JS + Vite 技术栈
- 9 个页面: 仪表盘/服务管理/日志/模型配置/Agent配置/Gateway/MCP工具/记忆文件/部署
- Rust 后端: 配置读写/服务管理(launchd)/日志读取/记忆文件管理
- 暗色主题 + 玻璃拟态 UI
- Mock 数据支持纯浏览器开发调试
This commit is contained in:
晴天
2026-02-26 22:34:55 +08:00
commit e26c4d9307
54 changed files with 13839 additions and 0 deletions

118
src/pages/agents.js Normal file
View File

@@ -0,0 +1,118 @@
/**
* 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 = () => saveConfig(page, state)
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)
model.fallbacks.splice(idx, 1)
renderConfig(page, state)
}
})
// 添加 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')
}
}

133
src/pages/dashboard.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* 仪表盘页面
*/
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">仪表盘</h1>
<p class="page-desc">OpenClaw 运行状态概览</p>
</div>
<div class="stat-cards" id="stat-cards">
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
<div class="stat-card loading-placeholder"></div>
</div>
<div class="quick-actions">
<button class="btn btn-secondary" id="btn-restart-gw">重启 Gateway</button>
<button class="btn btn-secondary" id="btn-check-update">检查更新</button>
</div>
<div class="config-section">
<div class="config-section-title">最近日志</div>
<div class="log-viewer" id="recent-logs" style="max-height:300px">加载中...</div>
</div>
`
// 异步加载数据
loadDashboardData(page)
return page
}
async function loadDashboardData(page) {
try {
const [services, version, logs] = await Promise.all([
api.getServicesStatus(),
api.getVersionInfo(),
api.readLogTail('gateway', 20),
])
renderStatCards(page, services, version)
renderLogs(page, logs)
bindActions(page)
} catch (e) {
toast('加载仪表盘数据失败: ' + e, 'error')
}
}
function renderStatCards(page, services, version) {
const cardsEl = page.querySelector('#stat-cards')
const gw = services.find(s => s.label.includes('gateway'))
const guardian = services.find(s => s.label.includes('guardian.watch'))
const watchdog = services.find(s => s.label.includes('watchdog'))
const runningCount = services.filter(s => s.running).length
cardsEl.innerHTML = `
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">Gateway</span>
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">
${gw?.pid ? 'PID: ' + gw.pid : ''}
</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">Guardian</span>
<span class="status-dot ${guardian?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${guardian?.running ? '运行中' : '已停止'}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">健康监控</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">Watchdog</span>
<span class="status-dot ${watchdog?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${watchdog?.running ? '运行中' : '已停止'}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">看门狗</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">版本</span>
</div>
<div class="stat-card-value">${version.current || '未知'}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">
服务 ${runningCount}/${services.length} 运行中
</div>
</div>
`
}
function renderLogs(page, logs) {
const logsEl = page.querySelector('#recent-logs')
if (!logs) { logsEl.textContent = '暂无日志'; return }
const lines = logs.trim().split('\n')
logsEl.innerHTML = lines.map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
logsEl.scrollTop = logsEl.scrollHeight
}
function bindActions(page) {
page.querySelector('#btn-restart-gw')?.addEventListener('click', async () => {
try {
await api.restartService('ai.openclaw.gateway')
toast('Gateway 已重启', 'success')
} catch (e) {
toast('重启失败: ' + e, 'error')
}
})
page.querySelector('#btn-check-update')?.addEventListener('click', async () => {
try {
const info = await api.getVersionInfo()
if (info.update_available) {
toast(`发现新版本: ${info.latest}`, 'info')
} else {
toast('已是最新版本', 'success')
}
} catch (e) {
toast('检查更新失败: ' + e, 'error')
}
})
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

127
src/pages/deploy.js Normal file
View File

@@ -0,0 +1,127 @@
/**
* 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="" 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;')
}

119
src/pages/gateway.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* Gateway 配置页面
*/
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">Gateway 配置</h1>
<p class="page-desc">配置 OpenClaw Gateway 端口、绑定和认证</p>
</div>
<div id="gateway-config">加载中...</div>
<div style="margin-top:16px">
<button class="btn btn-primary" id="btn-save-gw">保存配置</button>
</div>
`
const state = { config: null }
await loadConfig(page, state)
page.querySelector('#btn-save-gw').onclick = () => saveConfig(page, state)
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('#gateway-config')
const gw = state.config?.gateway || {}
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="form-group">
<label class="form-label">端口</label>
<input class="form-input" id="gw-port" type="number" value="${gw.port || 18789}" min="1024" max="65535">
</div>
<div class="form-group">
<label class="form-label">绑定模式</label>
<select class="form-input" id="gw-bind">
<option value="loopback" ${gw.bind === 'loopback' ? 'selected' : ''}>Loopback (仅本机)</option>
<option value="all" ${gw.bind === 'all' ? 'selected' : ''}>All (所有接口)</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">运行模式</label>
<select class="form-input" id="gw-mode">
<option value="local" ${gw.mode === 'local' ? 'selected' : ''}>Local</option>
<option value="remote" ${gw.mode === 'remote' ? 'selected' : ''}>Remote</option>
</select>
</div>
</div>
<div class="config-section">
<div class="config-section-title">认证</div>
<div class="form-group">
<label class="form-label">Auth Token</label>
<div style="display:flex;gap:8px">
<input class="form-input" id="gw-token" type="password" value="${gw.authToken || ''}" placeholder="留空则无认证" style="flex:1">
<button class="btn btn-sm btn-secondary" id="btn-toggle-token">显示</button>
</div>
</div>
</div>
<div class="config-section">
<div class="config-section-title">Tailscale</div>
<div class="form-group">
<label class="form-label">Tailscale 地址</label>
<input class="form-input" id="gw-tailscale" value="${gw.tailscale?.address || ''}" placeholder="如 100.x.x.x:18789">
</div>
</div>
`
// 切换密码可见
el.querySelector('#btn-toggle-token').onclick = () => {
const input = el.querySelector('#gw-token')
const btn = el.querySelector('#btn-toggle-token')
if (input.type === 'password') {
input.type = 'text'
btn.textContent = '隐藏'
} else {
input.type = 'password'
btn.textContent = '显示'
}
}
}
async function saveConfig(page, state) {
const port = parseInt(page.querySelector('#gw-port')?.value) || 18789
const bind = page.querySelector('#gw-bind')?.value || 'loopback'
const mode = page.querySelector('#gw-mode')?.value || 'local'
const authToken = page.querySelector('#gw-token')?.value || ''
const tailscaleAddr = page.querySelector('#gw-tailscale')?.value || ''
state.config.gateway = {
...state.config.gateway,
port, bind, mode, authToken,
tailscale: tailscaleAddr ? { address: tailscaleAddr } : undefined,
}
try {
await api.writeOpenclawConfig(state.config)
toast('Gateway 配置已保存', 'success')
} catch (e) {
toast('保存失败: ' + e, 'error')
}
}

110
src/pages/logs.js Normal file
View File

@@ -0,0 +1,110 @@
/**
* 日志查看页面
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
const LOG_TABS = [
{ key: 'gateway', label: 'Gateway' },
{ key: 'gateway-err', label: 'Gateway Err' },
{ key: 'guardian', label: 'Guardian' },
{ key: 'guardian-backup', label: 'Backup' },
{ key: 'config-audit', label: '审计日志' },
]
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">日志查看</h1>
<p class="page-desc">查看 OpenClaw 各服务日志</p>
</div>
<div class="tab-bar">
${LOG_TABS.map((t, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${t.key}">${t.label}</div>`).join('')}
</div>
<div class="log-toolbar">
<input type="text" class="form-input" id="log-search" placeholder="搜索日志..." style="max-width:300px">
<button class="btn btn-secondary btn-sm" id="btn-refresh">刷新</button>
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);color:var(--text-secondary)">
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
</label>
</div>
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)">加载中...</div>
`
let currentTab = 'gateway'
// Tab 切换
page.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
page.querySelectorAll('.tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
currentTab = tab.dataset.tab
loadLog(page, currentTab)
}
})
// 搜索
let searchTimer = null
page.querySelector('#log-search').addEventListener('input', (e) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
if (e.target.value.trim()) {
searchLog(page, currentTab, e.target.value.trim())
} else {
loadLog(page, currentTab)
}
}, 300)
})
// 刷新
page.querySelector('#btn-refresh').onclick = () => loadLog(page, currentTab)
loadLog(page, currentTab)
return page
}
async function loadLog(page, logName) {
const el = page.querySelector('#log-content')
el.innerHTML = '<div style="color:var(--text-tertiary)">加载中...</div>'
try {
const content = await api.readLogTail(logName, 200)
if (!content || !content.trim()) {
el.innerHTML = '<div style="color:var(--text-tertiary)">暂无日志</div>'
return
}
const lines = content.trim().split('\n')
el.innerHTML = lines.map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
if (page.querySelector('#log-autoscroll')?.checked) {
el.scrollTop = el.scrollHeight
}
} catch (e) {
toast('加载日志失败: ' + e, 'error')
}
}
async function searchLog(page, logName, query) {
const el = page.querySelector('#log-content')
el.innerHTML = '<div style="color:var(--text-tertiary)">搜索中...</div>'
try {
const results = await api.searchLog(logName, query)
if (!results || !results.length) {
el.innerHTML = '<div style="color:var(--text-tertiary)">未找到匹配结果</div>'
return
}
el.innerHTML = results.map(l => `<div class="log-line">${highlightMatch(escapeHtml(l), query)}</div>`).join('')
} catch (e) {
toast('搜索失败: ' + e, 'error')
}
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function highlightMatch(html, query) {
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return html.replace(new RegExp(escaped, 'gi'), m => `<mark>${m}</mark>`)
}

144
src/pages/mcp.js Normal file
View File

@@ -0,0 +1,144 @@
/**
* MCP 工具配置页面
*/
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">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 = () => saveConfig(state)
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 (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) {
const name = prompt('输入 MCP Server 名称:')
if (!name) return
const target = state.config?.mcpServers || state.config
target[name] = { 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;')
}

135
src/pages/memory.js Normal file
View File

@@ -0,0 +1,135 @@
/**
* 记忆文件管理页面
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
const CATEGORIES = [
{ key: 'memory', label: '工作记忆' },
{ key: 'archive', label: '记忆归档' },
{ key: 'core', label: '核心文件' },
]
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">记忆文件</h1>
<p class="page-desc">管理 OpenClaw 工作记忆和归档文件</p>
</div>
<div class="tab-bar">
${CATEGORIES.map((c, i) => `<div class="tab${i === 0 ? ' active' : ''}" data-tab="${c.key}">${c.label}</div>`).join('')}
</div>
<div class="memory-layout">
<div class="memory-sidebar" id="file-tree">加载中...</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-preview" disabled>预览</button>
<button class="btn btn-sm btn-primary" id="btn-save-file" disabled>保存</button>
</div>
</div>
<textarea class="editor-area" id="file-editor" placeholder="选择左侧文件进行编辑..." disabled></textarea>
</div>
</div>
`
const state = { category: 'memory', currentPath: null }
// Tab 切换
page.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
page.querySelectorAll('.tab').forEach(t => t.classList.remove('active'))
tab.classList.add('active')
state.category = tab.dataset.tab
state.currentPath = null
resetEditor(page)
loadFiles(page, state)
}
})
// 保存
page.querySelector('#btn-save-file').onclick = () => saveFile(page, state)
loadFiles(page, state)
return page
}
async function loadFiles(page, state) {
const tree = page.querySelector('#file-tree')
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">加载中...</div>'
try {
const files = await api.listMemoryFiles(state.category)
if (!files || !files.length) {
tree.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">暂无文件</div>'
return
}
renderFileTree(page, state, files)
} catch (e) {
toast('加载文件列表失败: ' + e, 'error')
}
}
function renderFileTree(page, state, files) {
const tree = page.querySelector('#file-tree')
tree.innerHTML = files.map(f => {
const name = f.split('/').pop()
const active = state.currentPath === f ? ' active' : ''
return `<div class="file-item${active}" data-path="${f}">${name}</div>`
}).join('')
tree.querySelectorAll('.file-item').forEach(item => {
item.onclick = () => {
state.currentPath = item.dataset.path
tree.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'))
item.classList.add('active')
loadFileContent(page, state)
}
})
}
async function loadFileContent(page, state) {
const editor = page.querySelector('#file-editor')
const label = page.querySelector('#current-file')
const btnSave = page.querySelector('#btn-save-file')
const btnPreview = page.querySelector('#btn-preview')
editor.disabled = true
editor.value = '加载中...'
label.textContent = state.currentPath
try {
const content = await api.readMemoryFile(state.currentPath)
editor.value = content || ''
editor.disabled = false
btnSave.disabled = false
btnPreview.disabled = false
} catch (e) {
editor.value = '读取失败: ' + e
toast('读取文件失败: ' + e, 'error')
}
}
function resetEditor(page) {
const editor = page.querySelector('#file-editor')
editor.value = ''
editor.disabled = true
page.querySelector('#current-file').textContent = '选择文件查看'
page.querySelector('#btn-save-file').disabled = true
page.querySelector('#btn-preview').disabled = true
}
async function saveFile(page, state) {
if (!state.currentPath) return
const content = page.querySelector('#file-editor').value
try {
await api.writeMemoryFile(state.currentPath, content)
toast('文件已保存', 'success')
} catch (e) {
toast('保存失败: ' + e, 'error')
}
}

147
src/pages/models.js Normal file
View File

@@ -0,0 +1,147 @@
/**
* 模型配置页面
*/
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">模型配置</h1>
<p class="page-desc">管理 AI 模型 Provider 和模型列表</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>
</div>
<div id="providers-list">加载中...</div>
`
const state = { config: null }
await loadConfig(page, state)
page.querySelector('#btn-save-models').onclick = () => saveConfig(state)
page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state)
return page
}
async function loadConfig(page, state) {
try {
state.config = await api.readOpenclawConfig()
renderProviders(page, state)
} catch (e) {
toast('加载配置失败: ' + e, 'error')
}
}
function renderProviders(page, state) {
const listEl = page.querySelector('#providers-list')
const providers = state.config?.models?.providers || {}
const keys = Object.keys(providers)
if (!keys.length) {
listEl.innerHTML = '<div style="color:var(--text-tertiary);padding:20px">暂无 Provider 配置,点击上方按钮添加</div>'
return
}
listEl.innerHTML = keys.map(key => {
const p = providers[key]
const models = p.models || []
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>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-secondary" data-action="toggle">展开/收起</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>
</div>
`
}).join('')
// 绑定事件
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') {
delete state.config.models.providers[providerKey]
renderProviders(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') {
const id = prompt('输入模型 ID:')
if (id) {
state.config.models.providers[providerKey].models.push({ id })
renderProviders(page, state)
}
}
}
})
// 输入框变更同步到 state
listEl.querySelectorAll('[data-field]').forEach(input => {
input.onchange = () => {
const providerKey = input.closest('[data-provider]').dataset.provider
state.config.models.providers[providerKey][input.dataset.field] = input.value
}
})
}
function addProvider(page, state) {
const key = prompt('输入 Provider 名称 (如 openai, newapi):')
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: 'openai', models: [] }
renderProviders(page, state)
toast(`已添加 ${key}`, 'success')
}
async function saveConfig(state) {
try {
await api.writeOpenclawConfig(state.config)
toast('配置已保存', 'success')
} catch (e) {
toast('保存失败: ' + e, 'error')
}
}

74
src/pages/services.js Normal file
View File

@@ -0,0 +1,74 @@
/**
* 服务管理页面
*/
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">服务管理</h1>
<p class="page-desc">管理 OpenClaw 相关的 launchd 服务</p>
</div>
<div id="services-list">加载中...</div>
`
loadServices(page)
return page
}
async function loadServices(page) {
try {
const services = await api.getServicesStatus()
renderServices(page, services)
} catch (e) {
toast('加载服务状态失败: ' + e, 'error')
}
}
function renderServices(page, services) {
const listEl = page.querySelector('#services-list')
listEl.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>
</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>`
}
</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')
loadServices(page)
} catch (e) {
toast(`操作失败: ${e}`, 'error')
btn.disabled = false
btn.textContent = action === 'start' ? '启动' : action === 'stop' ? '停止' : '重启'
}
}
})
}