mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-29 01:38:26 +08:00
chore: release v0.13.2
This commit is contained in:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -5,6 +5,32 @@
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [0.13.2] - 2026-04-13
|
||||
|
||||
### 新功能 (Features)
|
||||
|
||||
- **Hermes 日志查看器** — 新增 Agent 日志页面,支持文件列表、级别过滤、关键词搜索,左右分栏布局实时浏览日志
|
||||
- **Hermes Skills 浏览器** — 新增 Skills 页面,左侧分类列表 + 右侧 Markdown 详情预览
|
||||
- **Hermes 记忆编辑器** — 新增记忆文件页面,支持查看和编辑 Agent 笔记(MEMORY.md)与用户画像(USER.md)
|
||||
- **Hermes 定时任务可视化** — 定时任务页面全新重写:统计卡片、快捷预设按钮(每5分钟/每小时/每天9:00等)、cron 表达式实时人性化预览、SVG 图标操作按钮
|
||||
|
||||
### 改进 (Improvements)
|
||||
|
||||
- **仪表盘模型配置折叠** — 模型配置区域默认折叠,点击可展开/收起,节省仪表盘空间
|
||||
- **定时任务执行反馈** — "立即执行"按钮点击后显示✓/✕状态闪烁,明确告知执行结果
|
||||
- **Hermes CLI 命令完整对接** — Rust 后端新增 sessions/logs/skills/memory 全套 CLI 命令,Web 后端同步实现
|
||||
- **API 代理认证注入** — `hermes_api_proxy` 自动从 .env 注入 API_SERVER_KEY,解决 Cron 等页面 "Invalid API key" 错误
|
||||
|
||||
### 修复 (Fixes)
|
||||
|
||||
- **定时任务显示 [object Object]** — 正确解析 Gateway 返回的 schedule 对象(`{kind,expr,display}`),提取 cron 表达式显示
|
||||
- **定时任务保存 'str' has no attribute 'get'** — 保存时发送 `{kind:'cron', expr:...}` 对象格式,匹配 Gateway 期望的结构
|
||||
- **ClawPanel 误杀外部 Gateway** — `cleanup_zombie_gateway_processes` 不再杀死外部启动的健康 Gateway 进程,改为采纳其 PID
|
||||
- **croniter 依赖缺失** — `uv tool install` 添加 `--with croniter`,确保定时任务功能开箱即用
|
||||
- **API 代理错误提取** — 修复嵌套错误对象(`{error:{message:...}}`)的提取逻辑
|
||||
|
||||
---
|
||||
|
||||
## [0.13.1] - 2026-04-13
|
||||
|
||||
### 新功能 (Features)
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"description": "支持 OpenClaw 和 Hermes Agent 双引擎的多 AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。内置晴辰助手支持工具调用,晴辰云 AI 接口一键接入。支持仪表盘监控、多模型配置、Hermes Agent 对话、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。支持 11 种语言。",
|
||||
"url": "https://claw.qt.cool/",
|
||||
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
|
||||
"softwareVersion": "0.13.0",
|
||||
"softwareVersion": "0.13.2",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "晴辰云 QingchenCloud",
|
||||
@@ -1155,7 +1155,7 @@
|
||||
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
|
||||
<div class="container-sm" style="position:relative;z-index:10">
|
||||
<div class="section-header">
|
||||
<div class="reveal download-version"><span class="pulse"></span> <span id="dl-badge" data-i18n="dl.badge">v0.12.0 最新版</span></div>
|
||||
<div class="reveal download-version"><span class="pulse"></span> <span id="dl-badge" data-i18n="dl.badge">v0.13.2 最新版</span></div>
|
||||
<h2 class="reveal section-title" data-i18n="dl.title"><span class="gradient-text">下载安装</span></h2>
|
||||
<p class="reveal section-desc" data-i18n="dl.desc">选择你的操作系统,一键下载安装</p>
|
||||
</div>
|
||||
@@ -1165,11 +1165,11 @@
|
||||
<h3>macOS</h3>
|
||||
<p class="dl-desc" data-i18n="dl.mac.d">支持 Apple Silicon 和 Intel 芯片</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.12.0_aarch64.dmg" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.13.2_aarch64.dmg" target="_blank" rel="noopener">
|
||||
Apple Silicon (M1/M2/M3/M4)
|
||||
<span class="dl-format">.dmg</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.12.0_x64.dmg" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.13.2_x64.dmg" target="_blank" rel="noopener">
|
||||
<span data-i18n="dl.mac.intel">Intel 芯片</span>
|
||||
<span class="dl-format">.dmg</span>
|
||||
</a>
|
||||
@@ -1187,15 +1187,15 @@
|
||||
<h3>Windows</h3>
|
||||
<p class="dl-desc" data-i18n="dl.win.d">支持 Windows 10 及以上版本</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.12.0_x64-setup.exe" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.13.2_x64-setup.exe" target="_blank" rel="noopener">
|
||||
<span data-i18n="dl.win.exe">安装程序</span>
|
||||
<span class="dl-format">.exe</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.12.0_x64-setup-full.exe" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.13.2_x64-setup-full.exe" target="_blank" rel="noopener">
|
||||
<span data-i18n="dl.win.full">完整包(含 WebView2)</span>
|
||||
<span class="dl-format">.exe</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.12.0_x64_en-US.msi" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.13.2_x64_en-US.msi" target="_blank" rel="noopener">
|
||||
<span data-i18n="dl.win.msi">MSI 安装包</span>
|
||||
<span class="dl-format">.msi</span>
|
||||
</a>
|
||||
@@ -1206,11 +1206,11 @@
|
||||
<h3>Linux</h3>
|
||||
<p class="dl-desc" data-i18n="dl.linux.d">支持主流 Linux 发行版</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.12.0_amd64.AppImage" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.13.2_amd64.AppImage" target="_blank" rel="noopener">
|
||||
<span data-i18n="dl.linux.ai">通用版</span>
|
||||
<span class="dl-format">.AppImage</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.12.0_amd64.deb" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.13.2_amd64.deb" target="_blank" rel="noopener">
|
||||
Debian / Ubuntu
|
||||
<span class="dl-format">.deb</span>
|
||||
</a>
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "clawpanel",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clawpanel",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.2",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawpanel",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"private": true,
|
||||
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
|
||||
"type": "module",
|
||||
|
||||
@@ -6555,7 +6555,13 @@ const handlers = {
|
||||
const opts = { method: method || 'GET', headers: { 'User-Agent': 'ClawPanel-Web' } }
|
||||
const timeout = (reqPath.includes('/chat/completions') || reqPath.includes('/responses')) ? 120000 : 30000
|
||||
opts.signal = AbortSignal.timeout(timeout)
|
||||
if (body && (method === 'POST' || method === 'PATCH')) {
|
||||
// Auto-inject API_SERVER_KEY from .env if available
|
||||
try {
|
||||
const envContent = fs.readFileSync(path.join(hermesHome(), '.env'), 'utf8')
|
||||
const m = envContent.match(/^API_SERVER_KEY=(.+)$/m)
|
||||
if (m) opts.headers['Authorization'] = `Bearer ${m[1].trim()}`
|
||||
} catch {}
|
||||
if (body && (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE')) {
|
||||
opts.body = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
opts.headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
@@ -6565,7 +6571,7 @@ const handlers = {
|
||||
const resp = await globalThis.fetch(url, opts)
|
||||
const text = await resp.text()
|
||||
let json; try { json = JSON.parse(text) } catch { json = { raw: text } }
|
||||
if (resp.status >= 400) throw new Error(json?.error || text)
|
||||
if (resp.status >= 400) throw new Error(json?.error?.message || json?.error || text)
|
||||
return json
|
||||
},
|
||||
|
||||
@@ -6705,6 +6711,199 @@ const handlers = {
|
||||
return `Gateway URL 已设置: ${hermesGatewayUrl()}`
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Hermes Sessions / Logs / Skills / Memory
|
||||
// =========================================================================
|
||||
|
||||
hermes_sessions_list({ source, limit } = {}) {
|
||||
const args = ['sessions', 'export', '-']
|
||||
if (source) args.push('--source', source)
|
||||
const r = runHermesSilent('hermes', args)
|
||||
if (!r.ok) return []
|
||||
const sessions = []
|
||||
for (const line of r.stdout.split('\n')) {
|
||||
const t = line.trim()
|
||||
if (!t) continue
|
||||
try {
|
||||
const obj = JSON.parse(t)
|
||||
sessions.push({
|
||||
id: obj.session_id || obj.id || '',
|
||||
title: obj.title || obj.name || '',
|
||||
source: obj.source || '',
|
||||
model: obj.model || '',
|
||||
created_at: obj.created_at || obj.createdAt || '',
|
||||
updated_at: obj.updated_at || obj.updatedAt || '',
|
||||
message_count: obj.message_count || (obj.messages ? obj.messages.length : 0),
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
sessions.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
|
||||
if (limit && limit > 0) return sessions.slice(0, limit)
|
||||
return sessions
|
||||
},
|
||||
|
||||
hermes_session_detail({ sessionId } = {}) {
|
||||
if (!sessionId) throw new Error('sessionId is required')
|
||||
const r = runHermesSilent('hermes', ['sessions', 'export', '-'])
|
||||
if (!r.ok) throw new Error('Failed to read sessions')
|
||||
for (const line of r.stdout.split('\n')) {
|
||||
const t = line.trim()
|
||||
if (!t) continue
|
||||
try {
|
||||
const obj = JSON.parse(t)
|
||||
if ((obj.session_id || obj.id) === sessionId) {
|
||||
return {
|
||||
id: obj.session_id || obj.id,
|
||||
title: obj.title || obj.name || '',
|
||||
source: obj.source || '',
|
||||
model: obj.model || '',
|
||||
created_at: obj.created_at || '',
|
||||
messages: (obj.messages || []).map(m => ({
|
||||
role: m.role || '',
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content || ''),
|
||||
timestamp: m.timestamp || m.created_at || '',
|
||||
})),
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error('Session not found')
|
||||
},
|
||||
|
||||
hermes_session_delete({ sessionId } = {}) {
|
||||
if (!sessionId) throw new Error('sessionId is required')
|
||||
const r = runHermesSilent('hermes', ['sessions', 'delete', sessionId, '--yes'])
|
||||
if (!r.ok) throw new Error(`Failed to delete session: ${r.stderr || 'unknown error'}`)
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
hermes_session_rename({ sessionId, title } = {}) {
|
||||
if (!sessionId || !title) throw new Error('sessionId and title are required')
|
||||
const r = runHermesSilent('hermes', ['sessions', 'rename', sessionId, title])
|
||||
if (!r.ok) throw new Error(`Failed to rename session: ${r.stderr || 'unknown error'}`)
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
hermes_logs_list() {
|
||||
const r = runHermesSilent('hermes', ['logs', 'list'])
|
||||
if (!r.ok) {
|
||||
// Fallback: read log files from ~/.hermes/logs/
|
||||
const logsDir = path.join(hermesHome(), 'logs')
|
||||
if (!fs.existsSync(logsDir)) return []
|
||||
try {
|
||||
return fs.readdirSync(logsDir)
|
||||
.filter(f => f.endsWith('.log') || f.endsWith('.txt'))
|
||||
.map(f => {
|
||||
const stat = fs.statSync(path.join(logsDir, f))
|
||||
return { name: f, size: stat.size, modified: stat.mtime.toISOString() }
|
||||
})
|
||||
.sort((a, b) => b.modified.localeCompare(a.modified))
|
||||
} catch { return [] }
|
||||
}
|
||||
// Parse CLI output
|
||||
const files = []
|
||||
for (const line of r.stdout.split('\n')) {
|
||||
const t = line.trim()
|
||||
if (!t || t.startsWith('─') || t.startsWith('Name') || t.startsWith('=')) continue
|
||||
const parts = t.split(/\s{2,}/)
|
||||
if (parts.length >= 1) files.push({ name: parts[0], size: parts[1] || '', modified: parts[2] || '' })
|
||||
}
|
||||
return files
|
||||
},
|
||||
|
||||
hermes_logs_read({ name, lines = 200, level } = {}) {
|
||||
if (!name) throw new Error('log file name is required')
|
||||
const args = ['logs', name, '-n', String(lines)]
|
||||
if (level) args.push('--level', level)
|
||||
const r = runHermesSilent('hermes', args)
|
||||
if (!r.ok) {
|
||||
// Fallback: direct file read
|
||||
const logPath = path.join(hermesHome(), 'logs', name)
|
||||
if (!fs.existsSync(logPath)) throw new Error(`Log file not found: ${name}`)
|
||||
const content = fs.readFileSync(logPath, 'utf8')
|
||||
const allLines = content.split('\n')
|
||||
const tail = allLines.slice(-lines)
|
||||
return tail.map(line => {
|
||||
const m = line.match(/^(\S+\s+\S+)\s+(\w+)\s+(.*)/)
|
||||
return m ? { timestamp: m[1], level: m[2], message: m[3], raw: line } : { raw: line }
|
||||
}).filter(e => e.raw.trim())
|
||||
}
|
||||
return r.stdout.split('\n').filter(l => l.trim()).map(line => {
|
||||
const m = line.match(/^(\S+\s+\S+)\s+(\w+)\s+(.*)/)
|
||||
return m ? { timestamp: m[1], level: m[2], message: m[3], raw: line } : { raw: line }
|
||||
})
|
||||
},
|
||||
|
||||
hermes_skills_list() {
|
||||
const skillsDir = path.join(hermesHome(), 'skills')
|
||||
if (!fs.existsSync(skillsDir)) return []
|
||||
const categories = []
|
||||
try {
|
||||
const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const catDir = path.join(skillsDir, entry.name)
|
||||
const skills = []
|
||||
for (const file of fs.readdirSync(catDir)) {
|
||||
if (!file.endsWith('.md')) continue
|
||||
const filePath = path.join(catDir, file)
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const nameMatch = content.match(/^#\s+(.+)/m)
|
||||
const descMatch = content.match(/^(?:##\s+)?(?:Description|描述)[:\s]*(.+)/mi) || content.match(/^[^#\n].{10,}/m)
|
||||
skills.push({
|
||||
file: file,
|
||||
name: nameMatch ? nameMatch[1].trim() : file.replace('.md', ''),
|
||||
description: descMatch ? descMatch[1].trim().slice(0, 200) : '',
|
||||
path: filePath,
|
||||
})
|
||||
}
|
||||
if (skills.length > 0) {
|
||||
categories.push({ category: entry.name, skills })
|
||||
}
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
// Top-level skill
|
||||
const filePath = path.join(skillsDir, entry.name)
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const nameMatch = content.match(/^#\s+(.+)/m)
|
||||
categories.push({
|
||||
category: '_root',
|
||||
skills: [{ file: entry.name, name: nameMatch ? nameMatch[1].trim() : entry.name.replace('.md', ''), description: '', path: filePath }]
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return categories
|
||||
},
|
||||
|
||||
hermes_skill_detail({ filePath } = {}) {
|
||||
if (!filePath) throw new Error('filePath is required')
|
||||
// Security: ensure path is within hermes skills dir
|
||||
const skillsDir = path.join(hermesHome(), 'skills')
|
||||
const resolved = path.resolve(filePath)
|
||||
if (!resolved.startsWith(skillsDir)) throw new Error('Access denied')
|
||||
if (!fs.existsSync(resolved)) throw new Error('Skill file not found')
|
||||
return fs.readFileSync(resolved, 'utf8')
|
||||
},
|
||||
|
||||
hermes_memory_read({ type = 'memory' } = {}) {
|
||||
const home = hermesHome()
|
||||
const fileName = type === 'user' ? 'USER.md' : 'MEMORY.md'
|
||||
const filePath = path.join(home, 'memories', fileName)
|
||||
if (!fs.existsSync(filePath)) return ''
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
},
|
||||
|
||||
hermes_memory_write({ type = 'memory', content } = {}) {
|
||||
if (content == null) throw new Error('content is required')
|
||||
const home = hermesHome()
|
||||
const memDir = path.join(home, 'memories')
|
||||
fs.mkdirSync(memDir, { recursive: true })
|
||||
const fileName = type === 'user' ? 'USER.md' : 'MEMORY.md'
|
||||
const filePath = path.join(memDir, fileName)
|
||||
fs.writeFileSync(filePath, content, 'utf8')
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
async update_hermes() {
|
||||
const uvPath = path.join(uvBinDir(), isWindows ? 'uv.exe' : 'uv')
|
||||
const uv = fs.existsSync(uvPath) ? uvPath : 'uv'
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -351,7 +351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.13.1"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.13.1"
|
||||
version = "0.13.2"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -946,7 +946,7 @@ async fn install_via_uv_tool(
|
||||
};
|
||||
|
||||
let mut cmd = tokio::process::Command::new(uv_path);
|
||||
cmd.args(["tool", "install", "--force", &pkg, "--python", "3.11"]);
|
||||
cmd.args(["tool", "install", "--force", &pkg, "--python", "3.11", "--with", "croniter"]);
|
||||
|
||||
// 配置 PyPI 镜像(extras 的依赖仍从 PyPI 下载)
|
||||
if let Some(mirror) = pypi_mirror_url() {
|
||||
@@ -2143,6 +2143,22 @@ pub async fn hermes_api_proxy(
|
||||
) -> Result<Value, String> {
|
||||
let url = format!("{}{path}", hermes_gateway_url());
|
||||
|
||||
// 读取 API_SERVER_KEY
|
||||
let api_key = {
|
||||
let env_path = hermes_home().join(".env");
|
||||
let mut key = String::new();
|
||||
if let Ok(content) = std::fs::read_to_string(&env_path) {
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(val) = line.strip_prefix("API_SERVER_KEY=") {
|
||||
key = val.trim().to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
key
|
||||
};
|
||||
|
||||
let timeout = if path.contains("/chat/completions") || path.contains("/responses") {
|
||||
std::time::Duration::from_secs(120)
|
||||
} else {
|
||||
@@ -2167,10 +2183,28 @@ pub async fn hermes_api_proxy(
|
||||
}
|
||||
r
|
||||
}
|
||||
"DELETE" => client.delete(&url),
|
||||
"PUT" => {
|
||||
let mut r = client.put(&url);
|
||||
if let Some(b) = &body {
|
||||
r = r.header("Content-Type", "application/json").body(b.clone());
|
||||
}
|
||||
r
|
||||
}
|
||||
"DELETE" => {
|
||||
let mut r = client.delete(&url);
|
||||
if let Some(b) = &body {
|
||||
r = r.header("Content-Type", "application/json").body(b.clone());
|
||||
}
|
||||
r
|
||||
}
|
||||
_ => return Err(format!("不支持的方法: {method}")),
|
||||
};
|
||||
|
||||
// 注入 API_SERVER_KEY 认证
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
|
||||
// 注入自定义 headers(如 X-Hermes-Session-Id)
|
||||
if let Some(Value::Object(map)) = &headers {
|
||||
for (k, v) in map {
|
||||
@@ -2190,12 +2224,16 @@ pub async fn hermes_api_proxy(
|
||||
});
|
||||
|
||||
if status >= 400 {
|
||||
// 返回错误信息
|
||||
// 提取错误信息:支持 {"error": "msg"} 和 {"error": {"message": "msg"}} 两种格式
|
||||
let err_msg = json_val
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&text);
|
||||
return Err(format!("{err_msg}"));
|
||||
.and_then(|v| {
|
||||
v.as_str().map(String::from).or_else(|| {
|
||||
v.get("message").and_then(|m| m.as_str()).map(String::from)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| text.clone());
|
||||
return Err(err_msg);
|
||||
}
|
||||
|
||||
Ok(json_val)
|
||||
@@ -2369,3 +2407,398 @@ pub async fn hermes_agent_run(
|
||||
}));
|
||||
Ok(run_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hermes Sessions / Logs / Skills / Memory — 文件系统 + CLI 命令
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_sessions_list(
|
||||
source: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Value, String> {
|
||||
let mut args = vec!["sessions", "export", "-"];
|
||||
let source_owned;
|
||||
if let Some(s) = &source {
|
||||
source_owned = s.clone();
|
||||
args.push("--source");
|
||||
args.push(&source_owned);
|
||||
}
|
||||
let output = match run_silent("hermes", &args) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Ok(serde_json::json!([])),
|
||||
};
|
||||
let mut sessions: Vec<Value> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(obj) = serde_json::from_str::<Value>(t) {
|
||||
sessions.push(serde_json::json!({
|
||||
"id": obj.get("session_id").or(obj.get("id")).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"title": obj.get("title").or(obj.get("name")).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"source": obj.get("source").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"model": obj.get("model").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"created_at": obj.get("created_at").or(obj.get("createdAt")).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"updated_at": obj.get("updated_at").or(obj.get("updatedAt")).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"message_count": obj.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
}
|
||||
sessions.sort_by(|a, b| {
|
||||
let ca = a["created_at"].as_str().unwrap_or("");
|
||||
let cb = b["created_at"].as_str().unwrap_or("");
|
||||
cb.cmp(ca)
|
||||
});
|
||||
if let Some(lim) = limit {
|
||||
if lim > 0 {
|
||||
sessions.truncate(lim);
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(sessions))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_session_detail(session_id: String) -> Result<Value, String> {
|
||||
let output = run_silent("hermes", &["sessions", "export", "-"])
|
||||
.map_err(|e| format!("Failed to read sessions: {e}"))?;
|
||||
for line in output.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(obj) = serde_json::from_str::<Value>(t) {
|
||||
let id = obj
|
||||
.get("session_id")
|
||||
.or(obj.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if id == session_id {
|
||||
let messages = obj
|
||||
.get("messages")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"role": m.get("role").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"content": m.get("content").map(|c| {
|
||||
if let Some(s) = c.as_str() { s.to_string() }
|
||||
else { c.to_string() }
|
||||
}).unwrap_or_default(),
|
||||
"timestamp": m.get("timestamp").or(m.get("created_at")).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
return Ok(serde_json::json!({
|
||||
"id": id,
|
||||
"title": obj.get("title").or(obj.get("name")).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"source": obj.get("source").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"model": obj.get("model").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"created_at": obj.get("created_at").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"messages": messages,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("Session not found".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_session_delete(session_id: String) -> Result<String, String> {
|
||||
run_silent("hermes", &["sessions", "delete", &session_id, "--yes"])?;
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_session_rename(session_id: String, title: String) -> Result<String, String> {
|
||||
run_silent("hermes", &["sessions", "rename", &session_id, &title])?;
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_logs_list() -> Result<Value, String> {
|
||||
let logs_dir = hermes_home().join("logs");
|
||||
if !logs_dir.exists() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
let mut files: Vec<Value> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&logs_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".log") && !name.ends_with(".txt") && !name.ends_with(".jsonl") {
|
||||
continue;
|
||||
}
|
||||
let (size, modified) = if let Ok(meta) = entry.metadata() {
|
||||
let sz = meta.len();
|
||||
let mt = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| {
|
||||
t.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| {
|
||||
let secs = d.as_secs() as i64;
|
||||
// Simple ISO-ish format
|
||||
let dt = chrono_simple(secs);
|
||||
dt
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(sz, mt)
|
||||
} else {
|
||||
(0, String::new())
|
||||
};
|
||||
files.push(serde_json::json!({
|
||||
"name": name,
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
}));
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| {
|
||||
let ma = a["modified"].as_str().unwrap_or("");
|
||||
let mb = b["modified"].as_str().unwrap_or("");
|
||||
mb.cmp(ma)
|
||||
});
|
||||
Ok(Value::Array(files))
|
||||
}
|
||||
|
||||
/// Simple timestamp formatter (no chrono crate dependency)
|
||||
fn chrono_simple(epoch_secs: i64) -> String {
|
||||
// Use system time formatting via std
|
||||
let d = std::time::UNIX_EPOCH + std::time::Duration::from_secs(epoch_secs as u64);
|
||||
// Format as ISO string via debug (rough but functional)
|
||||
format!("{d:?}")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_logs_read(
|
||||
name: String,
|
||||
lines: Option<usize>,
|
||||
level: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let max_lines = lines.unwrap_or(200);
|
||||
let log_path = hermes_home().join("logs").join(&name);
|
||||
if !log_path.exists() {
|
||||
return Err(format!("Log file not found: {name}"));
|
||||
}
|
||||
// Security: ensure path is within logs dir
|
||||
let logs_dir = hermes_home().join("logs");
|
||||
let canonical = log_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Path error: {e}"))?;
|
||||
let canonical_dir = logs_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Path error: {e}"))?;
|
||||
if !canonical.starts_with(&canonical_dir) {
|
||||
return Err("Access denied".into());
|
||||
}
|
||||
|
||||
let content =
|
||||
std::fs::read_to_string(&canonical).map_err(|e| format!("Failed to read log: {e}"))?;
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let start = if all_lines.len() > max_lines {
|
||||
all_lines.len() - max_lines
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let tail = &all_lines[start..];
|
||||
|
||||
let level_upper = level.as_deref().unwrap_or("").to_uppercase();
|
||||
let mut entries: Vec<Value> = Vec::new();
|
||||
// Regex-like manual parsing: "TIMESTAMP LEVEL MESSAGE"
|
||||
for line in tail {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Try to parse structured log: "2024-01-01 12:00:00 INFO message..."
|
||||
let parsed = parse_log_line(t);
|
||||
if !level_upper.is_empty() && level_upper != "ALL" {
|
||||
if let Some(ref lvl) = parsed.level {
|
||||
if lvl.to_uppercase() != level_upper {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue; // skip raw lines when filtering by level
|
||||
}
|
||||
}
|
||||
entries.push(match (parsed.timestamp, parsed.level, parsed.message) {
|
||||
(Some(ts), Some(lvl), Some(msg)) => serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"level": lvl,
|
||||
"message": msg,
|
||||
"raw": t,
|
||||
}),
|
||||
_ => serde_json::json!({ "raw": t }),
|
||||
});
|
||||
}
|
||||
Ok(Value::Array(entries))
|
||||
}
|
||||
|
||||
struct ParsedLogLine {
|
||||
timestamp: Option<String>,
|
||||
level: Option<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_log_line(line: &str) -> ParsedLogLine {
|
||||
// Pattern: "YYYY-MM-DD HH:MM:SS LEVEL rest..." or "HH:MM:SS LEVEL rest..."
|
||||
let parts: Vec<&str> = line.splitn(4, char::is_whitespace).collect();
|
||||
if parts.len() >= 3 {
|
||||
// Check if first two parts look like a timestamp
|
||||
let maybe_date = parts[0];
|
||||
let maybe_time = parts[1];
|
||||
if (maybe_date.len() == 10 && maybe_date.contains('-'))
|
||||
&& (maybe_time.len() >= 8 && maybe_time.contains(':'))
|
||||
{
|
||||
let ts = format!("{maybe_date} {maybe_time}");
|
||||
let lvl = parts[2].to_string();
|
||||
let msg = if parts.len() > 3 { parts[3].to_string() } else { String::new() };
|
||||
return ParsedLogLine {
|
||||
timestamp: Some(ts),
|
||||
level: Some(lvl),
|
||||
message: Some(msg),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fallback: check if first part is time-like
|
||||
if parts.len() >= 2 && parts[0].contains(':') && parts[0].len() >= 8 {
|
||||
let ts = parts[0].to_string();
|
||||
let lvl = parts[1].to_string();
|
||||
let msg = parts[2..].join(" ");
|
||||
return ParsedLogLine {
|
||||
timestamp: Some(ts),
|
||||
level: Some(lvl),
|
||||
message: Some(msg),
|
||||
};
|
||||
}
|
||||
ParsedLogLine {
|
||||
timestamp: None,
|
||||
level: None,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_skills_list() -> Result<Value, String> {
|
||||
let skills_dir = hermes_home().join("skills");
|
||||
if !skills_dir.exists() {
|
||||
return Ok(serde_json::json!([]));
|
||||
}
|
||||
let mut categories: Vec<Value> = Vec::new();
|
||||
let entries =
|
||||
std::fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills dir: {e}"))?;
|
||||
for entry in entries.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if ft.is_dir() {
|
||||
let cat_dir = skills_dir.join(&name);
|
||||
let mut skills: Vec<Value> = Vec::new();
|
||||
if let Ok(files) = std::fs::read_dir(&cat_dir) {
|
||||
for f in files.flatten() {
|
||||
let fname = f.file_name().to_string_lossy().to_string();
|
||||
if !fname.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
let fpath = cat_dir.join(&fname);
|
||||
let content = std::fs::read_to_string(&fpath).unwrap_or_default();
|
||||
let skill_name = content
|
||||
.lines()
|
||||
.find(|l| l.starts_with("# "))
|
||||
.map(|l| l[2..].trim().to_string())
|
||||
.unwrap_or_else(|| fname.trim_end_matches(".md").to_string());
|
||||
let description = content
|
||||
.lines()
|
||||
.find(|l| {
|
||||
!l.starts_with('#') && !l.trim().is_empty() && l.trim().len() > 10
|
||||
})
|
||||
.map(|l| {
|
||||
let s = l.trim();
|
||||
if s.len() > 200 {
|
||||
format!("{}...", &s[..200])
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
skills.push(serde_json::json!({
|
||||
"file": fname,
|
||||
"name": skill_name,
|
||||
"description": description,
|
||||
"path": fpath.to_string_lossy(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
if !skills.is_empty() {
|
||||
categories.push(serde_json::json!({
|
||||
"category": name,
|
||||
"skills": skills,
|
||||
}));
|
||||
}
|
||||
} else if name.ends_with(".md") {
|
||||
let fpath = skills_dir.join(&name);
|
||||
let content = std::fs::read_to_string(&fpath).unwrap_or_default();
|
||||
let skill_name = content
|
||||
.lines()
|
||||
.find(|l| l.starts_with("# "))
|
||||
.map(|l| l[2..].trim().to_string())
|
||||
.unwrap_or_else(|| name.trim_end_matches(".md").to_string());
|
||||
categories.push(serde_json::json!({
|
||||
"category": "_root",
|
||||
"skills": [{
|
||||
"file": name,
|
||||
"name": skill_name,
|
||||
"description": "",
|
||||
"path": fpath.to_string_lossy(),
|
||||
}],
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(categories))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_skill_detail(file_path: String) -> Result<String, String> {
|
||||
let skills_dir = hermes_home().join("skills");
|
||||
let resolved = PathBuf::from(&file_path);
|
||||
let canonical = resolved
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Path error: {e}"))?;
|
||||
let canonical_dir = skills_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Path error: {e}"))?;
|
||||
if !canonical.starts_with(&canonical_dir) {
|
||||
return Err("Access denied".into());
|
||||
}
|
||||
std::fs::read_to_string(&canonical).map_err(|e| format!("Failed to read skill: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_memory_read(r#type: Option<String>) -> Result<String, String> {
|
||||
let kind = r#type.as_deref().unwrap_or("memory");
|
||||
let file_name = if kind == "user" { "USER.md" } else { "MEMORY.md" };
|
||||
let file_path = hermes_home().join("memories").join(file_name);
|
||||
if !file_path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
std::fs::read_to_string(&file_path).map_err(|e| format!("Failed to read memory: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_memory_write(r#type: Option<String>, content: String) -> Result<String, String> {
|
||||
let kind = r#type.as_deref().unwrap_or("memory");
|
||||
let mem_dir = hermes_home().join("memories");
|
||||
std::fs::create_dir_all(&mem_dir).map_err(|e| format!("Failed to create dir: {e}"))?;
|
||||
let file_name = if kind == "user" { "USER.md" } else { "MEMORY.md" };
|
||||
let file_path = mem_dir.join(file_name);
|
||||
std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write memory: {e}"))?;
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
@@ -1245,11 +1245,12 @@ mod platform {
|
||||
));
|
||||
kill_process_tree(pid);
|
||||
} else if Some(pid) != our_pid {
|
||||
// /health 有响应但不是我们启动的 → 旧进程残留
|
||||
// /health 有响应但不是当前实例启动的 → 采纳为已知进程,不杀
|
||||
super::guardian_log(&format!(
|
||||
"清理残留 Gateway 进程 (PID {pid}):非当前实例"
|
||||
"检测到外部启动的 Gateway 进程 (PID {pid}):/health 正常响应,已采纳"
|
||||
));
|
||||
kill_process_tree(pid);
|
||||
let mut known = LAST_KNOWN_GATEWAY_PID.lock().unwrap();
|
||||
*known = Some(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,16 @@ pub fn run() {
|
||||
hermes::hermes_set_gateway_url,
|
||||
hermes::update_hermes,
|
||||
hermes::uninstall_hermes,
|
||||
hermes::hermes_sessions_list,
|
||||
hermes::hermes_session_detail,
|
||||
hermes::hermes_session_delete,
|
||||
hermes::hermes_session_rename,
|
||||
hermes::hermes_logs_list,
|
||||
hermes::hermes_logs_read,
|
||||
hermes::hermes_skills_list,
|
||||
hermes::hermes_skill_detail,
|
||||
hermes::hermes_memory_read,
|
||||
hermes::hermes_memory_write,
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
// 关闭窗口时最小化到托盘,不退出应用
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "ClawPanel",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.2",
|
||||
"identifier": "ai.openclaw.clawpanel",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -71,17 +71,24 @@ export default {
|
||||
}]
|
||||
}
|
||||
// 就绪后显示完整菜单
|
||||
// 仅展示已开发的页面,stub 页面暂时隐藏
|
||||
return [{
|
||||
section: t('sidebar.sectionMonitor'),
|
||||
items: [
|
||||
{ route: '/h/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
|
||||
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
|
||||
{ route: '/h/chat', label: t('sidebar.chat'), icon: 'chat' },
|
||||
{ route: '/h/logs', label: t('sidebar.logs'), icon: 'logs' },
|
||||
]
|
||||
}, {
|
||||
section: t('sidebar.sectionManage'),
|
||||
items: [
|
||||
{ route: '/h/skills', label: t('sidebar.skills'), icon: 'skills' },
|
||||
{ route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' },
|
||||
{ route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' },
|
||||
]
|
||||
}, {
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
|
||||
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
|
||||
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
|
||||
]
|
||||
@@ -90,15 +97,17 @@ export default {
|
||||
|
||||
getRoutes() {
|
||||
return [
|
||||
// Hermes 专属页面(/h/ 前缀)— Phase 2 实现
|
||||
// Hermes 专属页面(/h/ 前缀)
|
||||
{ path: '/h/setup', loader: () => import('./pages/setup.js') },
|
||||
{ path: '/h/dashboard', loader: () => import('./pages/dashboard.js') },
|
||||
{ path: '/h/chat', loader: () => import('./pages/chat.js') },
|
||||
{ path: '/h/logs', loader: () => import('./pages/logs.js') },
|
||||
{ path: '/h/skills', loader: () => import('./pages/skills.js') },
|
||||
{ path: '/h/memory', loader: () => import('./pages/memory.js') },
|
||||
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
|
||||
{ path: '/h/services', loader: () => import('./pages/services.js') },
|
||||
{ path: '/h/config', loader: () => import('./pages/config.js') },
|
||||
{ path: '/h/channels', loader: () => import('./pages/channels.js') },
|
||||
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
|
||||
{ path: '/h/skills', loader: () => import('./pages/skills.js') },
|
||||
// 共用页面(引擎无关)
|
||||
{ path: '/assistant', loader: () => import('../../pages/assistant.js') },
|
||||
{ path: '/settings', loader: () => import('../../pages/settings.js') },
|
||||
|
||||
@@ -5,8 +5,65 @@
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
}
|
||||
|
||||
// ── Schedule 解析工具 ──
|
||||
|
||||
function extractCronExpr(schedule) {
|
||||
if (!schedule) return ''
|
||||
if (typeof schedule === 'string') return schedule
|
||||
return schedule.expr || schedule.display || schedule.cron || schedule.value || ''
|
||||
}
|
||||
|
||||
function CRON_SHORTCUTS() {
|
||||
return [
|
||||
{ expr: '*/5 * * * *', text: t('engine.cronEvery5min') },
|
||||
{ expr: '*/15 * * * *', text: t('engine.cronEvery15min') },
|
||||
{ expr: '0 * * * *', text: t('engine.cronHourly') },
|
||||
{ expr: '0 9 * * *', text: t('engine.cronDaily9') },
|
||||
{ expr: '0 18 * * *', text: t('engine.cronDaily18') },
|
||||
{ expr: '0 9 * * 1', text: t('engine.cronMonday9') },
|
||||
{ expr: '0 9 1 * *', text: t('engine.cronMonthly1') },
|
||||
]
|
||||
}
|
||||
|
||||
function describeCron(raw) {
|
||||
const expr = typeof raw === 'string' ? raw : extractCronExpr(raw)
|
||||
if (!expr) return ''
|
||||
const hit = CRON_SHORTCUTS().find(s => s.expr === expr)
|
||||
if (hit) return hit.text
|
||||
const parts = expr.split(' ')
|
||||
if (parts.length !== 5) return expr
|
||||
const [min, hr, dom, , dow] = parts
|
||||
if (min === '*' && hr === '*') return t('engine.cronEveryMinute')
|
||||
if (min.startsWith('*/')) return t('engine.cronEveryNMin').replace('{n}', min.slice(2))
|
||||
if (hr === '*' && min === '0') return t('engine.cronHourlyOnTheHour')
|
||||
if (dow !== '*' && dom === '*') {
|
||||
const days = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const d = parseInt(dow)
|
||||
return `每周${isNaN(d) ? dow : (days[d] || dow)} ${hr}:${min.padStart(2, '0')}`
|
||||
}
|
||||
if (dom !== '*') return `每月${dom}日 ${hr}:${min.padStart(2, '0')}`
|
||||
if (hr !== '*') return `每天 ${hr}:${min.padStart(2, '0')}`
|
||||
return expr
|
||||
}
|
||||
|
||||
// ── SVG Icons ──
|
||||
|
||||
const ICONS = {
|
||||
clock: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
||||
play: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
|
||||
pause: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`,
|
||||
zap: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
|
||||
edit: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>`,
|
||||
refresh: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>`,
|
||||
back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>`,
|
||||
}
|
||||
|
||||
export function render() {
|
||||
@@ -14,22 +71,19 @@ export function render() {
|
||||
el.className = 'page'
|
||||
|
||||
let jobs = []
|
||||
let gwPort = 8642
|
||||
let gwOnline = false
|
||||
let loading = true
|
||||
let editingJob = null // null = list view, {} = create/edit form
|
||||
let editingJob = null
|
||||
let busy = false
|
||||
let errorMsg = ''
|
||||
|
||||
async function gw(path, opts = {}) {
|
||||
const method = (opts.method || 'GET').toUpperCase()
|
||||
return await api.hermesApiProxy(method, path, opts.body || null)
|
||||
return await api.hermesApiProxy((opts.method || 'GET').toUpperCase(), path, opts.body || null)
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const info = await api.checkHermes()
|
||||
gwPort = info?.gatewayPort || 8642
|
||||
gwOnline = !!info?.gatewayRunning
|
||||
} catch (_) {}
|
||||
if (gwOnline) await loadJobs()
|
||||
@@ -48,43 +102,94 @@ export function render() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 主渲染 ──
|
||||
|
||||
function draw() {
|
||||
if (editingJob) { drawForm(); return }
|
||||
const total = jobs.length
|
||||
const active = jobs.filter(j => !j.paused).length
|
||||
const paused = total - active
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
||||
<h1 style="margin:0">${t('engine.hermesCronTitle')}</h1>
|
||||
<button class="btn btn-primary btn-sm hm-cron-create" ${!gwOnline ? 'disabled' : ''}>${t('engine.cronCreate')}</button>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-sm btn-secondary hm-cron-refresh" title="Refresh" style="padding:4px 10px">${ICONS.refresh}</button>
|
||||
<button class="btn btn-primary btn-sm hm-cron-create" ${!gwOnline ? 'disabled' : ''}>${t('engine.cronCreate')}</button>
|
||||
</div>
|
||||
</div>
|
||||
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px">${escHtml(errorMsg)}</div>` : ''}
|
||||
${!gwOnline ? `<div class="card"><div class="card-body" style="padding:24px;text-align:center;color:var(--text-tertiary)">${t('engine.chatGatewayOffline')}</div></div>` : ''}
|
||||
${gwOnline && jobs.length === 0 && !loading ? `<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">${t('engine.cronNoJobs')}</div></div>` : ''}
|
||||
${gwOnline && jobs.length > 0 ? renderJobList() : ''}
|
||||
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px;padding:8px 12px;background:var(--error-muted, #fee2e2);border-radius:6px">${esc(errorMsg)}</div>` : ''}
|
||||
${!gwOnline ? `
|
||||
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
|
||||
<div style="margin-bottom:8px">${ICONS.clock.replace('width="14"', 'width="32"').replace('height="14"', 'height="32"')}</div>
|
||||
${t('engine.chatGatewayOffline')}
|
||||
</div></div>
|
||||
` : ''}
|
||||
${gwOnline && !loading ? `
|
||||
<!-- 统计卡片 -->
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
|
||||
<div class="card"><div class="card-body" style="padding:12px 16px">
|
||||
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronTotal')}</div>
|
||||
<div style="font-size:20px;font-weight:700">${total}</div>
|
||||
</div></div>
|
||||
<div class="card"><div class="card-body" style="padding:12px 16px">
|
||||
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronRunning')}</div>
|
||||
<div style="font-size:20px;font-weight:700;color:var(--success,#22c55e)">${active}</div>
|
||||
</div></div>
|
||||
<div class="card"><div class="card-body" style="padding:12px 16px">
|
||||
<div style="font-size:11px;color:var(--text-tertiary);margin-bottom:4px">${t('engine.cronPaused')}</div>
|
||||
<div style="font-size:20px;font-weight:700;color:var(--text-tertiary)">${paused}</div>
|
||||
</div></div>
|
||||
</div>
|
||||
${total === 0 ? `
|
||||
<div class="card"><div class="card-body" style="padding:40px;text-align:center">
|
||||
<div style="margin-bottom:8px;color:var(--text-tertiary)">${ICONS.clock.replace('width="14"', 'width="40"').replace('height="14"', 'height="40"')}</div>
|
||||
<div style="font-size:15px;color:var(--text-secondary);margin-bottom:6px">${t('engine.cronNoJobs')}</div>
|
||||
<div style="font-size:12px;color:var(--text-tertiary)">${t('engine.cronNoJobsHint')}</div>
|
||||
</div></div>
|
||||
` : renderJobList()}
|
||||
` : ''}
|
||||
${loading ? `
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
|
||||
${[1,2,3].map(() => '<div class="card"><div class="card-body" style="padding:12px 16px"><div class="skeleton-line" style="width:60%;height:12px;margin-bottom:8px"></div><div class="skeleton-line" style="width:40%;height:20px"></div></div></div>').join('')}
|
||||
</div>
|
||||
${[1,2].map(() => '<div class="card" style="margin-bottom:12px"><div class="card-body" style="padding:16px"><div class="skeleton-line" style="width:50%;height:14px;margin-bottom:8px"></div><div class="skeleton-line" style="width:70%;height:12px"></div></div></div>').join('')}
|
||||
` : ''}
|
||||
`
|
||||
bindList()
|
||||
}
|
||||
|
||||
function renderJobList() {
|
||||
return `<div style="display:flex;flex-direction:column;gap:12px">${jobs.map(j => `
|
||||
<div class="card hm-cron-item" data-id="${escHtml(j.id || j.name)}">
|
||||
<div class="card-body" style="padding:14px 16px">
|
||||
return `<div style="display:flex;flex-direction:column;gap:10px">${jobs.map(j => {
|
||||
const expr = extractCronExpr(j.schedule)
|
||||
const desc = describeCron(j.schedule)
|
||||
const id = esc(j.id || j.name)
|
||||
return `
|
||||
<div class="card hm-cron-item" data-id="${id}" style="transition:opacity .2s;${j.paused ? 'opacity:0.65' : ''}">
|
||||
<div class="card-body" style="padding:14px 18px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<div style="font-weight:600;font-size:14px">${escHtml(j.name)}</div>
|
||||
<div style="font-size:12px;color:var(--text-tertiary);margin-top:2px;font-family:var(--font-mono,monospace)">${escHtml(j.schedule || '')}</div>
|
||||
${j.prompt ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:4px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(j.prompt)}</div>` : ''}
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<span style="font-weight:600;font-size:14px">${esc(j.name)}</span>
|
||||
<span style="font-size:10px;padding:2px 8px;border-radius:10px;font-weight:500;background:${j.paused ? 'var(--bg-tertiary)' : 'rgba(34,197,94,0.1)'};color:${j.paused ? 'var(--text-tertiary)' : 'var(--success,#22c55e)'}">${j.paused ? t('engine.cronPaused') : t('engine.cronActive')}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-tertiary);margin-bottom:2px">
|
||||
${ICONS.clock}
|
||||
<span>${esc(desc)}</span>
|
||||
<code style="font-size:11px;padding:1px 6px;background:var(--bg-tertiary);border-radius:4px;color:var(--text-secondary)">${esc(expr)}</code>
|
||||
</div>
|
||||
${j.prompt ? `<div style="font-size:12px;color:var(--text-secondary);margin-top:4px;max-width:500px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.prompt)}</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:${j.paused ? 'var(--bg-tertiary)' : 'rgba(34,197,94,0.1)'};color:${j.paused ? 'var(--text-tertiary)' : 'var(--success, #22c55e)'}">${j.paused ? t('engine.cronPaused') : t('engine.cronActive')}</span>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-toggle" data-id="${escHtml(j.id || j.name)}" data-paused="${j.paused ? '1' : '0'}" title="${j.paused ? 'Resume' : 'Pause'}" style="padding:4px 10px;font-size:12px">${j.paused ? '▶' : '⏸'}</button>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-run" data-id="${escHtml(j.id || j.name)}" title="${t('engine.cronRunNow')}" style="padding:4px 10px;font-size:12px">⚡</button>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-edit" data-id="${escHtml(j.id || j.name)}" style="padding:4px 10px;font-size:12px">✎</button>
|
||||
<button class="btn btn-sm hm-cron-del" data-id="${escHtml(j.id || j.name)}" style="padding:4px 10px;font-size:12px;color:var(--error)">✕</button>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<button class="btn btn-sm btn-secondary hm-cron-toggle" data-id="${id}" data-paused="${j.paused ? '1' : '0'}" title="${j.paused ? 'Resume' : 'Pause'}" style="padding:5px 8px">${j.paused ? ICONS.play : ICONS.pause}</button>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-run" data-id="${id}" title="${t('engine.cronRunNow')}" style="padding:5px 8px">${ICONS.zap}</button>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-edit" data-id="${id}" title="${t('engine.cronEdit')}" style="padding:5px 8px">${ICONS.edit}</button>
|
||||
<button class="btn btn-sm btn-secondary hm-cron-del" data-id="${id}" title="${t('engine.cronDelete')}" style="padding:5px 8px;color:var(--error)">${ICONS.trash}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}</div>`
|
||||
</div>`
|
||||
}).join('')}</div>`
|
||||
}
|
||||
|
||||
function bindList() {
|
||||
@@ -92,73 +197,144 @@ export function render() {
|
||||
editingJob = { name: '', schedule: '0 9 * * *', prompt: '' }
|
||||
draw()
|
||||
})
|
||||
el.querySelector('.hm-cron-refresh')?.addEventListener('click', async () => {
|
||||
loading = true; draw()
|
||||
await loadJobs()
|
||||
loading = false; draw()
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id
|
||||
const paused = btn.dataset.paused === '1'
|
||||
btn.disabled = true
|
||||
try { await gw(`/api/jobs/${encodeURIComponent(id)}/${paused ? 'resume' : 'pause'}`, { method: 'POST' }) } catch (_) {}
|
||||
await loadJobs(); draw()
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-run').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}/run`, { method: 'POST' }) } catch (_) {}
|
||||
btn.disabled = true
|
||||
try {
|
||||
await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}/run`, { method: 'POST' })
|
||||
// 成功反馈:短暂闪烁绿色
|
||||
btn.style.color = 'var(--success,#22c55e)'
|
||||
btn.innerHTML = '✓'
|
||||
setTimeout(() => { btn.innerHTML = ICONS.zap; btn.style.color = ''; btn.disabled = false }, 1500)
|
||||
} catch (_) {
|
||||
btn.style.color = 'var(--error)'
|
||||
btn.innerHTML = '✕'
|
||||
setTimeout(() => { btn.innerHTML = ICONS.zap; btn.style.color = ''; btn.disabled = false }, 1500)
|
||||
}
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-edit').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const job = jobs.find(j => (j.id || j.name) === btn.dataset.id)
|
||||
if (job) { editingJob = { ...job, _editing: true }; draw() }
|
||||
if (job) { editingJob = { ...job, schedule: extractCronExpr(job.schedule), _editing: true }; draw() }
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-cron-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('engine.cronDelete') + '?')) return
|
||||
const job = jobs.find(j => (j.id || j.name) === btn.dataset.id)
|
||||
const msg = t('engine.cronConfirmDelete').replace('{name}', job?.name || btn.dataset.id)
|
||||
if (!confirm(msg)) return
|
||||
btn.disabled = true
|
||||
try { await gw(`/api/jobs/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' }) } catch (_) {}
|
||||
await loadJobs(); draw()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── 创建/编辑表单 ──
|
||||
|
||||
function drawForm() {
|
||||
const isEdit = !!editingJob._editing
|
||||
const id = editingJob.id || editingJob.name
|
||||
const initSchedule = editingJob.schedule || '0 9 * * *'
|
||||
|
||||
const shortcutsHtml = CRON_SHORTCUTS().map(s => {
|
||||
const selected = s.expr === initSchedule
|
||||
return `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-secondary'} hm-cron-shortcut" data-expr="${escAttr(s.expr)}" style="font-size:11px;padding:3px 10px">${s.text}</button>`
|
||||
}).join('')
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1 style="margin:0">${isEdit ? escHtml(editingJob.name) : t('engine.cronCreate')}</h1></div>
|
||||
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px">${escHtml(errorMsg)}</div>` : ''}
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
|
||||
<button class="btn btn-sm btn-secondary hm-cron-back" style="padding:5px 8px">${ICONS.back}</button>
|
||||
<h1 style="margin:0">${isEdit ? t('engine.cronEdit') + ' — ' + esc(editingJob.name) : t('engine.cronCreate')}</h1>
|
||||
</div>
|
||||
${errorMsg ? `<div style="color:var(--error);font-size:13px;margin-bottom:12px;padding:8px 12px;background:var(--error-muted, #fee2e2);border-radius:6px">${esc(errorMsg)}</div>` : ''}
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:20px;display:flex;flex-direction:column;gap:14px">
|
||||
<div class="card-body" style="padding:24px;display:flex;flex-direction:column;gap:18px">
|
||||
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronName')}</label>
|
||||
<input class="input" id="hm-cron-name" value="${escHtml(editingJob.name)}" style="width:100%" ${isEdit ? 'disabled' : ''}>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronName')}</label>
|
||||
<input class="input" id="hm-cron-name" value="${escAttr(editingJob.name)}" placeholder="${t('engine.cronName')}" style="width:100%" ${isEdit ? 'disabled' : ''}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronSchedule')} <span style="font-weight:400;color:var(--text-tertiary)">(cron)</span></label>
|
||||
<input class="input" id="hm-cron-schedule" value="${escHtml(editingJob.schedule || '')}" placeholder="0 9 * * *" style="width:100%">
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronSchedule')}</label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">${shortcutsHtml}</div>
|
||||
<input class="input" id="hm-cron-schedule" value="${escAttr(initSchedule)}" placeholder="0 9 * * *" style="width:100%;font-family:var(--font-mono,monospace)">
|
||||
<div id="hm-cron-preview" style="font-size:12px;color:var(--text-tertiary);margin-top:6px;display:flex;align-items:center;gap:6px">
|
||||
${ICONS.clock} <span>${describeCron(initSchedule)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px">${t('engine.cronPrompt')}</label>
|
||||
<textarea class="input" id="hm-cron-prompt" rows="4" style="width:100%;resize:vertical;font-size:14px">${escHtml(editingJob.prompt || '')}</textarea>
|
||||
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">${t('engine.cronPrompt')}</label>
|
||||
<textarea class="input" id="hm-cron-prompt" rows="4" style="width:100%;resize:vertical;font-size:13px;line-height:1.5" placeholder="${t('engine.cronPrompt')}">${esc(editingJob.prompt || '')}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:4px">
|
||||
<button class="btn btn-primary btn-sm hm-cron-save" ${busy ? 'disabled' : ''}>${t('engine.cronSave')}</button>
|
||||
<button class="btn btn-primary btn-sm hm-cron-save" ${busy ? 'disabled' : ''}>${busy ? t('engine.cronSaving') : t('engine.cronSave')}</button>
|
||||
<button class="btn btn-secondary btn-sm hm-cron-cancel">${t('engine.cronCancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
bindForm(isEdit, id)
|
||||
}
|
||||
|
||||
function bindForm(isEdit, id) {
|
||||
el.querySelector('.hm-cron-back')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
|
||||
el.querySelector('.hm-cron-cancel')?.addEventListener('click', () => { editingJob = null; errorMsg = ''; draw() })
|
||||
|
||||
// 快捷预设
|
||||
el.querySelectorAll('.hm-cron-shortcut').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
el.querySelectorAll('.hm-cron-shortcut').forEach(b => { b.classList.remove('btn-primary'); b.classList.add('btn-secondary') })
|
||||
btn.classList.remove('btn-secondary'); btn.classList.add('btn-primary')
|
||||
const input = el.querySelector('#hm-cron-schedule')
|
||||
input.value = btn.dataset.expr
|
||||
updatePreview(btn.dataset.expr)
|
||||
})
|
||||
})
|
||||
|
||||
// 实时预览
|
||||
const schedInput = el.querySelector('#hm-cron-schedule')
|
||||
schedInput?.addEventListener('input', () => {
|
||||
const val = schedInput.value.trim()
|
||||
updatePreview(val)
|
||||
el.querySelectorAll('.hm-cron-shortcut').forEach(b => {
|
||||
b.classList.remove('btn-primary'); b.classList.add('btn-secondary')
|
||||
if (b.dataset.expr === val) { b.classList.remove('btn-secondary'); b.classList.add('btn-primary') }
|
||||
})
|
||||
})
|
||||
|
||||
// 保存
|
||||
el.querySelector('.hm-cron-save')?.addEventListener('click', async () => {
|
||||
const name = el.querySelector('#hm-cron-name')?.value?.trim()
|
||||
const schedule = el.querySelector('#hm-cron-schedule')?.value?.trim()
|
||||
const prompt = el.querySelector('#hm-cron-prompt')?.value?.trim()
|
||||
if (!name || !schedule) { errorMsg = 'Name and schedule are required'; draw(); return }
|
||||
busy = true; errorMsg = ''
|
||||
if (!name) { errorMsg = t('engine.cronNameRequired'); draw(); return }
|
||||
if (!schedule) { errorMsg = t('engine.cronScheduleRequired'); draw(); return }
|
||||
if (!prompt) { errorMsg = t('engine.cronPromptRequired'); draw(); return }
|
||||
busy = true; errorMsg = ''; draw()
|
||||
try {
|
||||
if (isEdit) {
|
||||
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ schedule, prompt }) })
|
||||
await gw(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ schedule: { kind: 'cron', expr: schedule }, prompt }) })
|
||||
} else {
|
||||
await gw('/api/jobs', { method: 'POST', body: JSON.stringify({ name, schedule, prompt }) })
|
||||
await gw('/api/jobs', { method: 'POST', body: JSON.stringify({ name, schedule: { kind: 'cron', expr: schedule }, prompt }) })
|
||||
}
|
||||
editingJob = null
|
||||
await loadJobs()
|
||||
@@ -169,6 +345,11 @@ export function render() {
|
||||
})
|
||||
}
|
||||
|
||||
function updatePreview(expr) {
|
||||
const previewEl = el.querySelector('#hm-cron-preview span')
|
||||
if (previewEl) previewEl.textContent = describeCron(expr) || expr
|
||||
}
|
||||
|
||||
init()
|
||||
return el
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export function render() {
|
||||
let connectMode = 'local' // local | wsl2 | docker | custom
|
||||
let customGwUrl = '' // 自定义 Gateway URL
|
||||
let connectMsg = '' // 连接区消息
|
||||
let modelConfigCollapsed = true // 模型配置默认折叠
|
||||
|
||||
// 表单状态(跨 draw 保持,不被覆盖)
|
||||
let formBaseUrl = ''
|
||||
@@ -178,36 +179,41 @@ export function render() {
|
||||
|
||||
<!-- 模型配置区 -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div class="card-body" style="padding:20px">
|
||||
<h3 style="margin:0 0 12px;font-size:15px">${t('engine.dashModelConfig')}</h3>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
|
||||
${HERMES_PROVIDERS.map(p =>
|
||||
`<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.key}" data-url="${esc(p.baseUrl)}" data-api="${p.api || 'openai-completions'}" style="font-size:11px;padding:2px 8px;${activePreset?.key === p.key ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.label}</button>`
|
||||
).join('')}
|
||||
<div class="card-body" style="padding:0">
|
||||
<div class="hm-cfg-toggle" style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;cursor:pointer;user-select:none">
|
||||
<h3 style="margin:0;font-size:15px">${t('engine.dashModelConfig')}</h3>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="transition:transform .2s;transform:rotate(${modelConfigCollapsed ? '0' : '180'}deg);opacity:0.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
API Base URL
|
||||
<input type="text" id="hm-cfg-baseurl" class="input" value="${esc(formBaseUrl)}" placeholder="https://gpt.qt.cool/v1" style="font-size:13px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
API Key
|
||||
<input type="password" id="hm-cfg-apikey" class="input" value="${esc(formApiKey)}" placeholder="sk-..." style="font-size:13px">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px">
|
||||
<label style="flex:1;display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
${t('engine.configModel')}
|
||||
<div style="position:relative">
|
||||
<input type="text" id="hm-cfg-model" class="input" value="${esc(formModel)}" placeholder="QC-B01" style="font-size:13px">
|
||||
${dropdownHtml}
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-sm btn-secondary hm-fetch-models" style="white-space:nowrap;flex-shrink:0" ${fetchBusy ? 'disabled' : ''}>${fetchBusy ? t('engine.configFetching') : t('engine.configFetchModels')}</button>
|
||||
</div>
|
||||
<div id="hm-cfg-msg" style="font-size:12px;min-height:16px;margin-bottom:8px">${cfgMsg}</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
|
||||
<div style="${modelConfigCollapsed ? 'display:none' : 'padding:0 20px 20px'}">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
|
||||
${HERMES_PROVIDERS.map(p =>
|
||||
`<button class="btn btn-sm btn-secondary hm-preset-btn" data-key="${p.key}" data-url="${esc(p.baseUrl)}" data-api="${p.api || 'openai-completions'}" style="font-size:11px;padding:2px 8px;${activePreset?.key === p.key ? 'opacity:1;font-weight:600' : 'opacity:0.6'}">${p.label}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
API Base URL
|
||||
<input type="text" id="hm-cfg-baseurl" class="input" value="${esc(formBaseUrl)}" placeholder="https://gpt.qt.cool/v1" style="font-size:13px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
API Key
|
||||
<input type="password" id="hm-cfg-apikey" class="input" value="${esc(formApiKey)}" placeholder="sk-..." style="font-size:13px">
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px">
|
||||
<label style="flex:1;display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--text-secondary)">
|
||||
${t('engine.configModel')}
|
||||
<div style="position:relative">
|
||||
<input type="text" id="hm-cfg-model" class="input" value="${esc(formModel)}" placeholder="QC-B01" style="font-size:13px">
|
||||
${dropdownHtml}
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-sm btn-secondary hm-fetch-models" style="white-space:nowrap;flex-shrink:0" ${fetchBusy ? 'disabled' : ''}>${fetchBusy ? t('engine.configFetching') : t('engine.configFetchModels')}</button>
|
||||
</div>
|
||||
<div id="hm-cfg-msg" style="font-size:12px;min-height:16px;margin-bottom:8px">${cfgMsg}</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary btn-sm hm-save-model" ${modelBusy ? 'disabled' : ''}>${modelBusy ? '...' : t('engine.configSaveBtn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,6 +309,12 @@ export function render() {
|
||||
|
||||
function bind() {
|
||||
el.querySelector('.hm-dash-refresh')?.addEventListener('click', refresh)
|
||||
// 模型配置折叠/展开
|
||||
el.querySelector('.hm-cfg-toggle')?.addEventListener('click', () => {
|
||||
syncFormFromDom()
|
||||
modelConfigCollapsed = !modelConfigCollapsed
|
||||
draw()
|
||||
})
|
||||
// Gateway actions
|
||||
el.querySelector('.hm-dash-start')?.addEventListener('click', async () => {
|
||||
actionBusy = true; draw()
|
||||
|
||||
156
src/engines/hermes/pages/logs.js
Normal file
156
src/engines/hermes/pages/logs.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Hermes Agent 日志查看器
|
||||
* 支持按文件/级别/关键字过滤,实时查看 Agent 运行日志
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') }
|
||||
|
||||
const LOG_LEVELS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
const LEVEL_CLASS = { DEBUG: 'debug', INFO: 'info', WARNING: 'warn', WARN: 'warn', ERROR: 'error', CRITICAL: 'error', FATAL: 'error' }
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'hermes-logs-page'
|
||||
|
||||
let logFiles = []
|
||||
let activeFile = ''
|
||||
let entries = []
|
||||
let loading = false
|
||||
let levelFilter = 'ALL'
|
||||
let searchQuery = ''
|
||||
let lineLimit = 200
|
||||
let autoScroll = true
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
logFiles = await api.hermesLogsList()
|
||||
if (logFiles.length && !activeFile) activeFile = logFiles[0].name
|
||||
} catch (e) {
|
||||
console.error('Failed to load log files:', e)
|
||||
logFiles = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
if (!activeFile) { entries = []; draw(); return }
|
||||
loading = true
|
||||
draw()
|
||||
try {
|
||||
entries = await api.hermesLogsRead(activeFile, lineLimit, levelFilter !== 'ALL' ? levelFilter : null)
|
||||
} catch (e) {
|
||||
entries = [{ raw: `⚠️ ${t('engine.logsLoadFailed')}: ${e.message || e}` }]
|
||||
}
|
||||
loading = false
|
||||
draw()
|
||||
}
|
||||
|
||||
function filteredEntries() {
|
||||
if (!searchQuery) return entries
|
||||
const q = searchQuery.toLowerCase()
|
||||
return entries.filter(e => (e.raw || e.message || '').toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (typeof bytes === 'string') return bytes
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const filtered = filteredEntries()
|
||||
el.innerHTML = `
|
||||
<div class="hm-logs-header">
|
||||
<span class="hm-logs-header-title">${t('engine.hermesLogsTitle')}</span>
|
||||
<div class="hm-logs-header-actions">
|
||||
<select id="hm-logs-level" class="hm-logs-select">
|
||||
${LOG_LEVELS.map(l => `<option value="${l}" ${l === levelFilter ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
<select id="hm-logs-lines" class="hm-logs-select">
|
||||
${[100, 200, 500, 1000].map(n => `<option value="${n}" ${n === lineLimit ? 'selected' : ''}>${n} ${t('engine.logsLines')}</option>`).join('')}
|
||||
</select>
|
||||
<input type="text" id="hm-logs-search" class="hm-logs-search" placeholder="${t('engine.logsSearch')}" value="${escHtml(searchQuery)}">
|
||||
<button class="btn btn-sm" id="hm-logs-refresh">${t('engine.logsRefresh')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-logs-layout">
|
||||
<div class="hm-logs-sidebar">
|
||||
<div class="hm-logs-sidebar-title">${t('engine.logsFiles')}</div>
|
||||
${logFiles.length === 0 ? `<div class="hm-logs-empty">${t('engine.logsNoFiles')}</div>` : ''}
|
||||
${logFiles.map(f => `
|
||||
<div class="hm-logs-file-item ${f.name === activeFile ? 'active' : ''}" data-file="${escHtml(f.name)}">
|
||||
<span class="hm-logs-file-name">${escHtml(f.name)}</span>
|
||||
<span class="hm-logs-file-size">${formatSize(f.size)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="hm-logs-main">
|
||||
<div class="hm-logs-toolbar">
|
||||
<div class="hm-logs-count">${filtered.length} ${t('engine.logsEntries')}</div>
|
||||
</div>
|
||||
<div class="hm-logs-content" id="hm-logs-content">
|
||||
${loading ? `<div class="hm-logs-loading">${t('engine.logsLoading')}</div>` : ''}
|
||||
${!loading && filtered.length === 0 ? `<div class="hm-logs-empty-content">${t('engine.logsEmpty')}</div>` : ''}
|
||||
${!loading ? filtered.map(e => renderEntry(e)).join('') : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
if (autoScroll && !loading) {
|
||||
const content = el.querySelector('#hm-logs-content')
|
||||
if (content) content.scrollTop = content.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntry(e) {
|
||||
const lvl = (e.level || '').toUpperCase()
|
||||
const cls = LEVEL_CLASS[lvl] || ''
|
||||
if (e.timestamp) {
|
||||
const time = e.timestamp.replace(/^.*?(\d{2}:\d{2}:\d{2}).*$/, '$1') || e.timestamp
|
||||
return `<div class="hm-log-entry ${cls}">
|
||||
<span class="hm-log-time">${escHtml(time)}</span>
|
||||
<span class="hm-log-level ${cls}">${escHtml(lvl || '-')}</span>
|
||||
<span class="hm-log-msg">${escHtml(e.message || '')}</span>
|
||||
</div>`
|
||||
}
|
||||
return `<div class="hm-log-entry raw"><span class="hm-log-msg">${escHtml(e.raw || '')}</span></div>`
|
||||
}
|
||||
|
||||
function bind() {
|
||||
el.querySelector('#hm-logs-refresh')?.addEventListener('click', () => loadEntries())
|
||||
|
||||
el.querySelectorAll('.hm-logs-file-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
activeFile = item.dataset.file
|
||||
loadEntries()
|
||||
})
|
||||
})
|
||||
|
||||
el.querySelector('#hm-logs-level')?.addEventListener('change', (e) => {
|
||||
levelFilter = e.target.value
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
el.querySelector('#hm-logs-lines')?.addEventListener('change', (e) => {
|
||||
lineLimit = parseInt(e.target.value) || 200
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
el.querySelector('#hm-logs-search')?.addEventListener('input', (e) => {
|
||||
searchQuery = e.target.value
|
||||
draw()
|
||||
})
|
||||
}
|
||||
|
||||
// Init
|
||||
async function init() {
|
||||
await loadFiles()
|
||||
await loadEntries()
|
||||
}
|
||||
init()
|
||||
|
||||
return el
|
||||
}
|
||||
147
src/engines/hermes/pages/memory.js
Normal file
147
src/engines/hermes/pages/memory.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Hermes Agent 记忆编辑器
|
||||
* 读写 ~/.hermes/memories/MEMORY.md 和 USER.md
|
||||
* 支持 Markdown 预览和编辑模式切换
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') }
|
||||
|
||||
function mdToHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'hermes-memory-page'
|
||||
|
||||
let memoryContent = ''
|
||||
let userContent = ''
|
||||
let editingSection = null // null | 'memory' | 'user'
|
||||
let editBuffer = ''
|
||||
let loading = true
|
||||
let saving = false
|
||||
|
||||
async function loadAll() {
|
||||
loading = true
|
||||
draw()
|
||||
try {
|
||||
const [mem, usr] = await Promise.all([
|
||||
api.hermesMemoryRead('memory'),
|
||||
api.hermesMemoryRead('user'),
|
||||
])
|
||||
memoryContent = mem || ''
|
||||
userContent = usr || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to load memory:', e)
|
||||
}
|
||||
loading = false
|
||||
draw()
|
||||
}
|
||||
|
||||
function startEdit(section) {
|
||||
editingSection = section
|
||||
editBuffer = section === 'memory' ? memoryContent : userContent
|
||||
draw()
|
||||
el.querySelector('#hm-memory-textarea')?.focus()
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
const original = editingSection === 'memory' ? memoryContent : userContent
|
||||
if (editBuffer !== original && !confirm(t('engine.memoryUnsaved'))) return
|
||||
editingSection = null
|
||||
editBuffer = ''
|
||||
draw()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editingSection) return
|
||||
saving = true
|
||||
draw()
|
||||
try {
|
||||
await api.hermesMemoryWrite(editingSection, editBuffer)
|
||||
if (editingSection === 'memory') memoryContent = editBuffer
|
||||
else userContent = editBuffer
|
||||
editingSection = null
|
||||
editBuffer = ''
|
||||
} catch (e) {
|
||||
alert(`${t('engine.memorySaveFailed')}: ${e.message || e}`)
|
||||
}
|
||||
saving = false
|
||||
draw()
|
||||
}
|
||||
|
||||
function renderSection(type, title, iconSvg, content) {
|
||||
const isEditing = editingSection === type
|
||||
return `<div class="hm-memory-section">
|
||||
<div class="hm-memory-section-header">
|
||||
<div class="hm-memory-section-title-row">
|
||||
<span class="hm-memory-section-icon">${iconSvg}</span>
|
||||
<span class="hm-memory-section-title">${title}</span>
|
||||
</div>
|
||||
${!isEditing ? `<button class="btn btn-sm btn-secondary hm-memory-edit-btn" data-section="${type}">${t('engine.memoryEdit')}</button>` : ''}
|
||||
</div>
|
||||
${isEditing ? `
|
||||
<div class="hm-memory-edit-wrap">
|
||||
<textarea class="hm-memory-editor" id="hm-memory-textarea" placeholder="${t('engine.memoryPlaceholder')}">${escHtml(editBuffer)}</textarea>
|
||||
<div class="hm-memory-edit-actions">
|
||||
<button class="btn btn-sm" id="hm-memory-cancel">${t('engine.memoryCancel')}</button>
|
||||
<button class="btn btn-sm btn-primary" id="hm-memory-save" ${saving ? 'disabled' : ''}>${saving ? t('engine.memorySaving') : t('engine.memorySave')}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="hm-memory-section-body markdown-body">
|
||||
${content.trim() ? mdToHtml(content) : `<div class="hm-memory-empty">${t('engine.memoryEmpty')}</div>`}
|
||||
</div>
|
||||
`}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const notesIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>'
|
||||
const userIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="hm-memory-header">
|
||||
<span class="hm-memory-header-title">${t('engine.hermesMemoryTitle')}</span>
|
||||
<button class="btn btn-sm" id="hm-memory-refresh">${t('engine.logsRefresh')}</button>
|
||||
</div>
|
||||
<div class="hm-memory-content">
|
||||
${loading ? `<div class="hm-memory-loading">${t('engine.memoryLoading')}</div>` : `
|
||||
<div class="hm-memory-sections">
|
||||
${renderSection('memory', t('engine.memoryNotes'), notesIcon, memoryContent)}
|
||||
${renderSection('user', t('engine.memoryProfile'), userIcon, userContent)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
}
|
||||
|
||||
function bind() {
|
||||
el.querySelector('#hm-memory-refresh')?.addEventListener('click', () => loadAll())
|
||||
el.querySelectorAll('.hm-memory-edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => startEdit(btn.dataset.section))
|
||||
})
|
||||
el.querySelector('#hm-memory-cancel')?.addEventListener('click', () => cancelEdit())
|
||||
el.querySelector('#hm-memory-save')?.addEventListener('click', () => save())
|
||||
el.querySelector('#hm-memory-textarea')?.addEventListener('input', (e) => {
|
||||
editBuffer = e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
loadAll()
|
||||
return el
|
||||
}
|
||||
@@ -1,16 +1,145 @@
|
||||
/**
|
||||
* Hermes Agent Skills 管理
|
||||
* Hermes Agent Skills 浏览器
|
||||
* 从 ~/.hermes/skills/ 读取技能文件,按分类展示,支持搜索和详情查看
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
|
||||
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') }
|
||||
|
||||
function mdToHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
||||
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h1>${t('engine.hermesSkillsTitle')}</h1></div>
|
||||
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
|
||||
${t('engine.comingSoonPhase2')}
|
||||
</div></div>
|
||||
`
|
||||
el.className = 'hermes-skills-page'
|
||||
|
||||
let categories = []
|
||||
let loading = true
|
||||
let searchQuery = ''
|
||||
let activeSkill = null // { category, file, name, path }
|
||||
let skillContent = ''
|
||||
let loadingDetail = false
|
||||
|
||||
async function loadSkills() {
|
||||
loading = true
|
||||
draw()
|
||||
try {
|
||||
categories = await api.hermesSkillsList()
|
||||
} catch (e) {
|
||||
console.error('Failed to load skills:', e)
|
||||
categories = []
|
||||
}
|
||||
loading = false
|
||||
draw()
|
||||
}
|
||||
|
||||
async function loadDetail(skill) {
|
||||
activeSkill = skill
|
||||
loadingDetail = true
|
||||
draw()
|
||||
try {
|
||||
skillContent = await api.hermesSkillDetail(skill.path)
|
||||
} catch (e) {
|
||||
skillContent = `⚠️ ${e.message || e}`
|
||||
}
|
||||
loadingDetail = false
|
||||
draw()
|
||||
}
|
||||
|
||||
function filteredCategories() {
|
||||
if (!searchQuery) return categories
|
||||
const q = searchQuery.toLowerCase()
|
||||
return categories.map(cat => ({
|
||||
...cat,
|
||||
skills: cat.skills.filter(s =>
|
||||
s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q)
|
||||
)
|
||||
})).filter(cat => cat.skills.length > 0)
|
||||
}
|
||||
|
||||
function totalSkillCount() {
|
||||
return categories.reduce((sum, cat) => sum + cat.skills.length, 0)
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const filtered = filteredCategories()
|
||||
el.innerHTML = `
|
||||
<div class="hm-skills-header">
|
||||
<span class="hm-skills-header-title">${t('engine.hermesSkillsTitle')}</span>
|
||||
<div class="hm-skills-header-right">
|
||||
<input type="text" id="hm-skills-search" class="hm-skills-header-search" placeholder="${t('engine.skillsSearch')}" value="${escHtml(searchQuery)}">
|
||||
<span class="hm-skills-count">${totalSkillCount()} ${t('engine.skillsTotal')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-skills-layout">
|
||||
<div class="hm-skills-list-panel">
|
||||
<div class="hm-skills-list-scroll">
|
||||
${loading ? `<div class="hm-skills-loading">${t('engine.skillsLoading')}</div>` : ''}
|
||||
${!loading && filtered.length === 0 ? `<div class="hm-skills-empty">${t('engine.skillsEmpty')}</div>` : ''}
|
||||
${!loading ? filtered.map(cat => `
|
||||
<div class="hm-skills-category">
|
||||
<div class="hm-skills-cat-header">
|
||||
<span class="hm-skills-cat-name">${escHtml(cat.category === '_root' ? t('engine.skillsUncategorized') : cat.category)}</span>
|
||||
<span class="hm-skills-cat-count">${cat.skills.length}</span>
|
||||
</div>
|
||||
${cat.skills.map(s => `
|
||||
<div class="hm-skills-item ${activeSkill?.path === s.path ? 'active' : ''}" data-path="${escHtml(s.path)}">
|
||||
<div class="hm-skills-item-name">${escHtml(s.name)}</div>
|
||||
${s.description ? `<div class="hm-skills-item-desc">${escHtml(s.description)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('') : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-skills-detail-panel">
|
||||
${!activeSkill ? `<div class="hm-skills-detail-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
<span>${t('engine.skillsSelectHint')}</span>
|
||||
</div>` : ''}
|
||||
${activeSkill && loadingDetail ? `<div class="hm-skills-detail-loading">${t('engine.skillsLoading')}</div>` : ''}
|
||||
${activeSkill && !loadingDetail ? `
|
||||
<div class="hm-skills-detail-header">
|
||||
<h2>${escHtml(activeSkill.name)}</h2>
|
||||
<span class="hm-skills-detail-file">${escHtml(activeSkill.file)}</span>
|
||||
</div>
|
||||
<div class="hm-skills-detail-content markdown-body">${mdToHtml(skillContent)}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
}
|
||||
|
||||
function bind() {
|
||||
el.querySelector('#hm-skills-search')?.addEventListener('input', (e) => {
|
||||
searchQuery = e.target.value
|
||||
draw()
|
||||
})
|
||||
el.querySelectorAll('.hm-skills-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const skillPath = item.dataset.path
|
||||
// Find the skill object
|
||||
for (const cat of categories) {
|
||||
const s = cat.skills.find(s => s.path === skillPath)
|
||||
if (s) { loadDetail(s); return }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
loadSkills()
|
||||
return el
|
||||
}
|
||||
|
||||
@@ -390,4 +390,16 @@ export const api = {
|
||||
hermesSetGatewayUrl: (url) => invoke('hermes_set_gateway_url', { url: url || null }),
|
||||
updateHermes: () => invoke('update_hermes'),
|
||||
uninstallHermes: (cleanConfig = false) => invoke('uninstall_hermes', { cleanConfig }),
|
||||
|
||||
// Hermes Sessions / Logs / Skills / Memory
|
||||
hermesSessionsList: (source, limit) => invoke('hermes_sessions_list', { source: source || null, limit: limit || null }),
|
||||
hermesSessionDetail: (sessionId) => invoke('hermes_session_detail', { sessionId }),
|
||||
hermesSessionDelete: (sessionId) => invoke('hermes_session_delete', { sessionId }),
|
||||
hermesSessionRename: (sessionId, title) => invoke('hermes_session_rename', { sessionId, title }),
|
||||
hermesLogsList: () => invoke('hermes_logs_list'),
|
||||
hermesLogsRead: (name, lines, level) => invoke('hermes_logs_read', { name, lines: lines || 200, level: level || null }),
|
||||
hermesSkillsList: () => invoke('hermes_skills_list'),
|
||||
hermesSkillDetail: (filePath) => invoke('hermes_skill_detail', { filePath }),
|
||||
hermesMemoryRead: (type) => invoke('hermes_memory_read', { type: type || 'memory' }),
|
||||
hermesMemoryWrite: (type, content) => invoke('hermes_memory_write', { type: type || 'memory', content }),
|
||||
}
|
||||
|
||||
@@ -163,10 +163,62 @@ export default {
|
||||
cronDelete: _('删除', 'Delete', '刪除'),
|
||||
cronSave: _('保存', 'Save', '儲存'),
|
||||
cronCancel: _('取消', 'Cancel', '取消'),
|
||||
cronEdit: _('编辑', 'Edit', '編輯'),
|
||||
cronTotal: _('总任务', 'Total', '總任務'),
|
||||
cronRunning: _('运行中', 'Running', '運行中'),
|
||||
cronNoJobsHint: _('点击"创建任务"添加你的第一个定时任务', 'Click "Create Job" to add your first cron job', '點擊「建立任務」添加你的第一個定時任務'),
|
||||
cronEvery5min: _('每5分钟', 'Every 5 min', '每5分鐘'),
|
||||
cronEvery15min: _('每15分钟', 'Every 15 min', '每15分鐘'),
|
||||
cronHourly: _('每小时', 'Hourly', '每小時'),
|
||||
cronDaily9: _('每天 9:00', 'Daily 9:00', '每天 9:00'),
|
||||
cronDaily18: _('每天 18:00', 'Daily 18:00', '每天 18:00'),
|
||||
cronMonday9: _('每周一 9:00', 'Monday 9:00', '每週一 9:00'),
|
||||
cronMonthly1: _('每月1日 9:00', '1st of Month 9:00', '每月1日 9:00'),
|
||||
cronEveryMinute: _('每分钟', 'Every minute', '每分鐘'),
|
||||
cronEveryNMin: _('每 {n} 分钟', 'Every {n} minutes', '每 {n} 分鐘'),
|
||||
cronHourlyOnTheHour: _('每小时整点', 'Every hour on the hour', '每小時整點'),
|
||||
cronCustomExpr: _('自定义 cron 表达式', 'Custom cron expression', '自訂 cron 表達式'),
|
||||
cronPreview: _('预览', 'Preview', '預覽'),
|
||||
cronConfirmDelete: _('确定要删除任务「{name}」吗?', 'Delete job "{name}"?', '確定要刪除任務「{name}」嗎?'),
|
||||
cronSaving: _('保存中...', 'Saving...', '儲存中...'),
|
||||
cronNameRequired: _('请输入任务名称', 'Job name is required', '請輸入任務名稱'),
|
||||
cronPromptRequired: _('请输入 AI 指令', 'AI prompt is required', '請輸入 AI 指令'),
|
||||
cronScheduleRequired: _('请选择执行周期', 'Schedule is required', '請選擇執行週期'),
|
||||
// 日志页面
|
||||
hermesLogsTitle: _('Agent 日志', 'Agent Logs', 'Agent 日誌'),
|
||||
logsRefresh: _('刷新', 'Refresh', '重新整理'),
|
||||
logsFiles: _('日志文件', 'Log Files', '日誌檔案'),
|
||||
logsNoFiles: _('暂无日志文件', 'No log files', '暫無日誌檔案'),
|
||||
logsLines: _('行', 'lines', '行'),
|
||||
logsSearch: _('搜索日志...', 'Search logs...', '搜尋日誌...'),
|
||||
logsEntries: _('条记录', 'entries', '條記錄'),
|
||||
logsLoading: _('加载中...', 'Loading...', '載入中...'),
|
||||
logsEmpty: _('暂无日志', 'No logs', '暫無日誌'),
|
||||
logsLoadFailed: _('加载失败', 'Load failed', '載入失敗'),
|
||||
// Skills 页面
|
||||
hermesSkillsTitle: _('Agent Skills', 'Agent Skills', 'Agent Skills'),
|
||||
skillsTotal: _('个技能', 'skills', '個技能'),
|
||||
skillsSearch: _('搜索技能...', 'Search skills...', '搜尋技能...'),
|
||||
skillsLoading: _('加载中...', 'Loading...', '載入中...'),
|
||||
skillsEmpty: _('暂无技能', 'No skills found', '暫無技能'),
|
||||
skillsUncategorized: _('未分类', 'Uncategorized', '未分類'),
|
||||
skillsSelectHint: _('选择一个技能查看详情', 'Select a skill to view details', '選擇一個技能查看詳情'),
|
||||
// Memory 页面
|
||||
hermesMemoryTitle: _('Agent 记忆', 'Agent Memory', 'Agent 記憶'),
|
||||
memoryNotes: _('笔记', 'Notes', '筆記'),
|
||||
memoryProfile: _('用户画像', 'User Profile', '用戶畫像'),
|
||||
memoryEdit: _('编辑', 'Edit', '編輯'),
|
||||
memoryCancel: _('取消', 'Cancel', '取消'),
|
||||
memorySave: _('保存', 'Save', '儲存'),
|
||||
memorySaving: _('保存中...', 'Saving...', '儲存中...'),
|
||||
memorySaveFailed: _('保存失败', 'Save failed', '儲存失敗'),
|
||||
memoryLoading: _('加载中...', 'Loading...', '載入中...'),
|
||||
memoryEmpty: _('暂无内容', 'No content', '暫無內容'),
|
||||
memoryPlaceholder: _('使用 Markdown 格式编写...', 'Write in Markdown format...', '使用 Markdown 格式編寫...'),
|
||||
memoryUnsaved: _('有未保存的更改,确定离开?', 'Unsaved changes. Leave anyway?', '有未儲存的變更,確定離開?'),
|
||||
// 其它页面
|
||||
hermesServicesTitle: _('Hermes 服务', 'Hermes Services', 'Hermes 服務'),
|
||||
hermesConfigTitle: _('Hermes 配置', 'Hermes Config', 'Hermes 配置'),
|
||||
hermesChannelsTitle: _('Hermes 渠道', 'Hermes Channels', 'Hermes 頻道'),
|
||||
hermesSkillsTitle: _('Hermes Skills', 'Hermes Skills', 'Hermes Skills'),
|
||||
comingSoonPhase2: _('即将在 Phase 2 中推出', 'Coming in Phase 2', '即將在 Phase 2 中推出'),
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export default {
|
||||
sectionMonitor: _('监控', 'Monitor', '監控', 'モニター', '모니터링', 'Giám sát', 'Monitoreo', 'Monitoramento', 'Мониторинг', 'Surveillance', 'Überwachung'),
|
||||
sectionConfig: _('配置', 'Config', '設定', '設定', '설정', 'Cấu hình', 'Configuración', 'Configuração', 'Настройки', 'Configuration', 'Konfiguration'),
|
||||
sectionData: _('数据', 'Data', '資料', 'データ', '데이터', 'Dữ liệu', 'Datos', 'Dados', 'Данные', 'Données', 'Daten'),
|
||||
sectionManage: _('管理', 'Manage', '管理', '管理', '관리', 'Quản lý', 'Gestión', 'Gestão', 'Управление', 'Gestion', 'Verwaltung'),
|
||||
sectionExtension: _('扩展', 'Extensions', '擴充', '拡張', '확장', 'Mở rộng', 'Extensiones', 'Extensões', 'Расширения', '', 'Erweiterungen'),
|
||||
dashboard: _('仪表盘', 'Dashboard', '儀表盤', 'ダッシュボード', '대시보드', 'Bảng điều khiển', 'Panel', 'Painel', 'Панель', 'Tableau de bord'),
|
||||
assistant: _('晴辰助手', 'Assistant', '', 'アシスタント', '어시스턴트', 'Trợ lý', 'Asistente', 'Assistente', 'Ассистент', '', 'Assistent'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { showConfirm } from '../components/modal.js'
|
||||
import { t, getLang, setLang, getAvailableLangs, onLangChange } from '../lib/i18n.js'
|
||||
import { isMacPlatform } from '../lib/app-state.js'
|
||||
import { renderSidebar } from '../components/sidebar.js'
|
||||
import { getActiveEngineId } from '../lib/engine-manager.js'
|
||||
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
@@ -64,6 +65,8 @@ export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
const isHermes = getActiveEngineId() === 'hermes'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">${t('settings.title')}</h1>
|
||||
@@ -80,7 +83,7 @@ export async function render() {
|
||||
<div id="model-proxy-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="registry-section">
|
||||
${isHermes ? '' : `<div class="config-section" id="registry-section">
|
||||
<div class="config-section-title">${t('settings.npmRegistry')}</div>
|
||||
<div id="registry-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
@@ -108,7 +111,7 @@ export async function render() {
|
||||
<div class="config-section" id="cli-binding-section">
|
||||
<div class="config-section-title">${t('settings.openclawCli')}</div>
|
||||
<div id="cli-binding-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
</div>`}
|
||||
|
||||
<div class="config-section" id="language-section">
|
||||
<div class="config-section-title">${t('settings.language')}</div>
|
||||
@@ -128,8 +131,11 @@ export async function render() {
|
||||
}
|
||||
|
||||
async function loadAll(page) {
|
||||
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadGitPath(page), loadCliBinding(page)]
|
||||
tasks.push(loadRegistry(page))
|
||||
const isHermes = getActiveEngineId() === 'hermes'
|
||||
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)]
|
||||
if (!isHermes) {
|
||||
tasks.push(loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadGitPath(page), loadCliBinding(page), loadRegistry(page))
|
||||
}
|
||||
if (window.__TAURI_INTERNALS__) tasks.push(loadAutostart(page))
|
||||
await Promise.all(tasks)
|
||||
loadLanguageSwitcher(page)
|
||||
|
||||
@@ -2483,4 +2483,229 @@
|
||||
}
|
||||
.hermes-spin {
|
||||
animation: hermes-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* === Hermes Logs Page === */
|
||||
.hermes-logs-page { height: 100%; display: flex; flex-direction: column; }
|
||||
.hm-logs-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 20px; border-bottom: 1px solid var(--border-primary);
|
||||
flex-shrink: 0; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.hm-logs-header-title { font-size: 16px; font-weight: 600; color: var(--text-primary); white-space: nowrap; }
|
||||
.hm-logs-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.hm-logs-layout {
|
||||
display: flex; flex: 1; min-height: 0; gap: 0;
|
||||
}
|
||||
.hm-logs-sidebar {
|
||||
width: 200px; flex-shrink: 0; border-right: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary); overflow-y: auto;
|
||||
}
|
||||
.hm-logs-sidebar-title {
|
||||
padding: 12px 14px 8px; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-tertiary);
|
||||
}
|
||||
.hm-logs-file-item {
|
||||
padding: 8px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 2px;
|
||||
font-size: 13px; color: var(--text-secondary); transition: background 0.15s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.hm-logs-file-item:hover { background: var(--bg-tertiary); }
|
||||
.hm-logs-file-item.active {
|
||||
background: var(--accent-subtle, rgba(99,102,241,0.06)); color: var(--accent);
|
||||
font-weight: 500; border-left-color: var(--accent);
|
||||
}
|
||||
.hm-logs-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hm-logs-file-size { font-size: 11px; color: var(--text-tertiary); }
|
||||
.hm-logs-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.hm-logs-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 14px; border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary); gap: 8px; flex-shrink: 0;
|
||||
}
|
||||
.hm-logs-filters { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.hm-logs-select {
|
||||
padding: 4px 8px; border: 1px solid var(--border-primary); border-radius: var(--radius-sm, 6px);
|
||||
background: var(--bg-primary); color: var(--text-primary); font-size: 12px; cursor: pointer;
|
||||
outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.hm-logs-select:focus { border-color: var(--accent); }
|
||||
.hm-logs-search {
|
||||
padding: 4px 10px; border: 1px solid var(--border-primary); border-radius: var(--radius-sm, 6px);
|
||||
background: var(--bg-primary); color: var(--text-primary); font-size: 12px;
|
||||
flex: 1; max-width: 200px; outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.hm-logs-search:focus { border-color: var(--accent); }
|
||||
.hm-logs-search::placeholder { color: var(--text-tertiary); }
|
||||
.hm-logs-count { font-size: 11px; color: var(--text-tertiary); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.hm-logs-content {
|
||||
flex: 1; overflow-y: auto; padding: 4px 0;
|
||||
font-family: var(--font-mono, 'Consolas', monospace); font-size: 12px; line-height: 1.7;
|
||||
}
|
||||
.hm-logs-loading, .hm-logs-empty-content, .hm-logs-empty {
|
||||
padding: 32px; text-align: center; color: var(--text-tertiary); font-size: 13px;
|
||||
}
|
||||
.hm-log-entry {
|
||||
display: flex; align-items: center; gap: 8px; padding: 2px 20px;
|
||||
border-left: 2px solid transparent; transition: background 0.1s;
|
||||
}
|
||||
.hm-log-entry:hover { background: rgba(0,0,0,0.02); }
|
||||
.hm-log-entry.error { border-left-color: var(--error, #ef4444); }
|
||||
.hm-log-entry.warn { border-left-color: var(--warning, #f59e0b); }
|
||||
.hm-log-time { color: var(--text-tertiary); flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||
.hm-log-level {
|
||||
flex-shrink: 0; font-weight: 600; text-transform: uppercase; font-size: 10px;
|
||||
padding: 0 5px; border-radius: 3px; min-width: 44px; text-align: center;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.hm-log-level.info { background: rgba(99,102,241,0.08); color: var(--accent, #6366f1); }
|
||||
.hm-log-level.debug { background: rgba(0,0,0,0.04); color: var(--text-tertiary); }
|
||||
.hm-log-level.warn { background: rgba(245,158,11,0.1); color: #d97706; }
|
||||
.hm-log-level.error { background: rgba(239,68,68,0.1); color: var(--error, #ef4444); }
|
||||
.hm-log-msg { color: var(--text-secondary); white-space: pre-wrap; word-break: break-all; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hm-log-entry.error .hm-log-msg { color: var(--error, #ef4444); }
|
||||
.hm-log-entry.warn .hm-log-msg { color: #d97706; }
|
||||
.hm-log-entry.raw .hm-log-msg { color: var(--text-tertiary); }
|
||||
|
||||
/* === Hermes Skills Page === */
|
||||
.hermes-skills-page { height: 100%; display: flex; flex-direction: column; }
|
||||
.hm-skills-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 20px; border-bottom: 1px solid var(--border-primary);
|
||||
flex-shrink: 0; gap: 12px;
|
||||
}
|
||||
.hm-skills-header-title { font-size: 16px; font-weight: 600; color: var(--text-primary); flex-shrink: 0; }
|
||||
.hm-skills-header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.hm-skills-header-search {
|
||||
width: 200px; padding: 5px 10px; border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm, 6px); background: var(--bg-primary); color: var(--text-primary);
|
||||
font-size: 13px; outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.hm-skills-header-search:focus { border-color: var(--accent); }
|
||||
.hm-skills-header-search::placeholder { color: var(--text-tertiary); }
|
||||
.hm-skills-count { font-size: 12px; color: var(--text-tertiary); white-space: nowrap; }
|
||||
.hm-skills-layout {
|
||||
display: flex; flex: 1; min-height: 0; gap: 0;
|
||||
}
|
||||
.hm-skills-list-panel {
|
||||
width: 280px; flex-shrink: 0; border-right: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary); display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.hm-skills-list-scroll { flex: 1; overflow-y: auto; }
|
||||
.hm-skills-loading, .hm-skills-empty {
|
||||
padding: 32px; text-align: center; color: var(--text-tertiary); font-size: 13px;
|
||||
}
|
||||
.hm-skills-category { margin-bottom: 4px; }
|
||||
.hm-skills-cat-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px 4px; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-tertiary);
|
||||
}
|
||||
.hm-skills-cat-count {
|
||||
background: var(--bg-tertiary); color: var(--text-tertiary);
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 10px;
|
||||
}
|
||||
.hm-skills-item {
|
||||
padding: 8px 14px; cursor: pointer; transition: background 0.12s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.hm-skills-item:hover { background: var(--bg-tertiary); }
|
||||
.hm-skills-item.active {
|
||||
background: var(--accent-subtle, rgba(99,102,241,0.06));
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.hm-skills-item-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.hm-skills-item.active .hm-skills-item-name { color: var(--accent); }
|
||||
.hm-skills-item-desc {
|
||||
font-size: 12px; color: var(--text-tertiary); margin-top: 2px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.hm-skills-detail-panel {
|
||||
flex: 1; overflow-y: auto; padding: 0; display: flex; flex-direction: column; min-width: 0;
|
||||
}
|
||||
.hm-skills-detail-empty, .hm-skills-detail-loading {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; color: var(--text-tertiary); font-size: 13px;
|
||||
}
|
||||
.hm-skills-detail-empty svg { opacity: 0.2; }
|
||||
.hm-skills-detail-header {
|
||||
padding: 16px 24px; border-bottom: 1px solid var(--border-primary);
|
||||
display: flex; align-items: baseline; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
.hm-skills-detail-header h2 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.hm-skills-detail-file { font-size: 12px; color: var(--text-tertiary); }
|
||||
.hm-skills-detail-content {
|
||||
padding: 20px 24px; font-size: 14px; line-height: 1.7; color: var(--text-primary);
|
||||
}
|
||||
.hm-skills-detail-content pre {
|
||||
background: var(--bg-secondary); border: 1px solid var(--border-primary);
|
||||
border-radius: 8px; padding: 12px 16px; overflow-x: auto; font-size: 13px; margin: 8px 0;
|
||||
}
|
||||
.hm-skills-detail-content code {
|
||||
font-family: var(--font-mono, 'Consolas', monospace); font-size: 0.9em;
|
||||
}
|
||||
.hm-skills-detail-content code:not(pre code) {
|
||||
background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px;
|
||||
}
|
||||
.hm-skills-detail-content h2 { font-size: 18px; margin: 16px 0 8px; }
|
||||
.hm-skills-detail-content h3 { font-size: 15px; margin: 14px 0 6px; }
|
||||
.hm-skills-detail-content h4 { font-size: 14px; margin: 12px 0 4px; }
|
||||
|
||||
/* === Hermes Memory Page === */
|
||||
.hermes-memory-page { height: 100%; display: flex; flex-direction: column; }
|
||||
.hm-memory-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 20px; border-bottom: 1px solid var(--border-primary);
|
||||
flex-shrink: 0; gap: 12px;
|
||||
}
|
||||
.hm-memory-header-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||||
.hm-memory-content { flex: 1; overflow: hidden; padding: 16px 20px; display: flex; flex-direction: column; }
|
||||
.hm-memory-loading {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-tertiary); font-size: 13px;
|
||||
}
|
||||
.hm-memory-sections { display: flex; gap: 16px; flex: 1; min-height: 0; }
|
||||
.hm-memory-section {
|
||||
flex: 1; min-height: 0; border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md, 10px); overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.hm-memory-section-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 16px; background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary); flex-shrink: 0;
|
||||
}
|
||||
.hm-memory-section-title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.hm-memory-section-icon { color: var(--text-secondary); display: flex; }
|
||||
.hm-memory-section-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||
.hm-memory-section-body {
|
||||
flex: 1; overflow-y: auto; padding: 16px; min-height: 0;
|
||||
font-size: 14px; line-height: 1.7; color: var(--text-primary);
|
||||
}
|
||||
.hm-memory-section-body pre {
|
||||
background: var(--bg-secondary); border: 1px solid var(--border-primary);
|
||||
border-radius: 8px; padding: 12px 16px; overflow-x: auto; font-size: 13px; margin: 8px 0;
|
||||
}
|
||||
.hm-memory-section-body code {
|
||||
font-family: var(--font-mono, 'Consolas', monospace); font-size: 0.9em;
|
||||
}
|
||||
.hm-memory-section-body code:not(pre code) {
|
||||
background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px;
|
||||
}
|
||||
.hm-memory-empty {
|
||||
color: var(--text-tertiary); font-style: italic; font-size: 13px; padding: 12px 0;
|
||||
}
|
||||
.hm-memory-editor {
|
||||
flex: 1; width: 100%; min-height: 0; padding: 12px;
|
||||
border: 1px solid var(--border-primary); border-radius: var(--radius-sm, 6px);
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
font-family: var(--font-mono, 'Consolas', monospace); font-size: 13px; line-height: 1.6;
|
||||
resize: none; outline: none; box-sizing: border-box;
|
||||
}
|
||||
.hm-memory-editor:focus { border-color: var(--accent); }
|
||||
.hm-memory-edit-wrap {
|
||||
flex: 1; display: flex; flex-direction: column; padding: 12px 16px; min-height: 0;
|
||||
}
|
||||
.hm-memory-edit-actions {
|
||||
display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; flex-shrink: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user