-
+
通用版
.AppImage
-
+
Debian / Ubuntu
.deb
diff --git a/package-lock.json b/package-lock.json
index 4a522e2..fe265ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 3df0ee3..626dd3e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawpanel",
- "version": "0.13.1",
+ "version": "0.13.2",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",
diff --git a/scripts/dev-api.js b/scripts/dev-api.js
index f1a1884..61b7a9e 100644
--- a/scripts/dev-api.js
+++ b/scripts/dev-api.js
@@ -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'
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 77c0eef..43fce3e 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -351,7 +351,7 @@ dependencies = [
[[package]]
name = "clawpanel"
-version = "0.13.1"
+version = "0.13.2"
dependencies = [
"base64 0.22.1",
"chrono",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index fc16b2d..fe83b1f 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
-version = "0.13.1"
+version = "0.13.2"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]
diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs
index ec7d862..05dc7c0 100644
--- a/src-tauri/src/commands/hermes.rs
+++ b/src-tauri/src/commands/hermes.rs
@@ -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
{
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,
+ limit: Option,
+) -> Result {
+ 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 = Vec::new();
+ for line in output.lines() {
+ let t = line.trim();
+ if t.is_empty() {
+ continue;
+ }
+ if let Ok(obj) = serde_json::from_str::(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 {
+ 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::(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::>()
+ })
+ .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 {
+ 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 {
+ run_silent("hermes", &["sessions", "rename", &session_id, &title])?;
+ Ok("ok".into())
+}
+
+#[tauri::command]
+pub async fn hermes_logs_list() -> Result {
+ let logs_dir = hermes_home().join("logs");
+ if !logs_dir.exists() {
+ return Ok(serde_json::json!([]));
+ }
+ let mut files: Vec = 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,
+ level: Option,
+) -> Result {
+ 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 = 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,
+ level: Option,
+ message: Option,
+}
+
+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 {
+ let skills_dir = hermes_home().join("skills");
+ if !skills_dir.exists() {
+ return Ok(serde_json::json!([]));
+ }
+ let mut categories: Vec = 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 = 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 {
+ 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) -> Result {
+ 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, content: String) -> Result {
+ 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())
+}
diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs
index 68f8c0a..e2c320c 100644
--- a/src-tauri/src/commands/service.rs
+++ b/src-tauri/src/commands/service.rs
@@ -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);
}
}
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 66b8efe..e099777 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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| {
// 关闭窗口时最小化到托盘,不退出应用
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 7a42e7a..3b3bb20 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -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",
diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js
index 6bd6048..bfa9698 100644
--- a/src/engines/hermes/index.js
+++ b/src/engines/hermes/index.js
@@ -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') },
diff --git a/src/engines/hermes/pages/cron.js b/src/engines/hermes/pages/cron.js
index 89f9a98..b7bd419 100644
--- a/src/engines/hermes/pages/cron.js
+++ b/src/engines/hermes/pages/cron.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, '>')
+function esc(s) {
+ return String(s || '').replace(/&/g, '&').replace(//g, '>')
+}
+function escAttr(s) {
+ return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/ 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: ``,
+ play: ``,
+ pause: ``,
+ zap: ``,
+ edit: ``,
+ trash: ``,
+ refresh: ``,
+ back: ``,
}
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 = `
-