chore: release v0.13.2

This commit is contained in:
晴天
2026-04-13 15:10:20 +08:00
parent fc75426aef
commit 55e8365cab
22 changed files with 1716 additions and 117 deletions

View File

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

View File

@@ -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
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "clawpanel",
"version": "0.13.1",
"version": "0.13.2",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",

View File

@@ -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
View File

@@ -351,7 +351,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.13.1"
version = "0.13.2"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.13.1"
version = "0.13.2"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

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

View File

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

View File

@@ -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| {
// 关闭窗口时最小化到托盘,不退出应用

View File

@@ -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",

View File

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

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function escAttr(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}
// ── 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
}

View File

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

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
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
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function mdToHtml(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function mdToHtml(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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
}

View File

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

View File

@@ -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 中推出'),
}

View File

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

View File

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

View File

@@ -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;
}