mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes): Batch 3 §L - 文件管理器(限定在 ~/.hermes 子树内)
校对:Hermes 没有 file HTTP API,必须自建。
## Rust 后端:3 个安全 fs 命令(~180 行)
### 安全策略
- 所有路径必须在 hermes_home() 子树内(防 path traversal)
- canonicalize 后用 starts_with 验证
- 拒绝绝对路径跳出 + .. 跳出
- 5 MB 文件大小上限
- 单次列目录最多 2000 条
### 命令
- hermes_fs_list(path) → { path, entries: [{name, kind, size, modified}] }
· 隐藏文件默认不显示(.env 除外)
· 排序:目录在前,文件在后,按名字
- hermes_fs_read(path) → { path, size, text?, binary_b64? }
· UTF-8 文本 → text
· 二进制 → 内置 base64 编码(不引新依赖)
- hermes_fs_write(path, content) → { path, size }
· 父目录必须存在
· 5 MB 上限
## 前端 /h/files 页面(~270 行)
### UI 布局
- 左侧:面包屑 + 目录树(点目录进入 / 点文件选中)
- 右侧:编辑器(文本)/ 预览(图片)/ 元信息(其他二进制)
### 文件树
- 文件图标按扩展名(📁/🔗/🖼️/📝/⚙️/📄)
- 文件大小显示(B/KB/MB)
- ".." 返回上级
- 选中态高亮
### 编辑器
- textarea 编辑,monospace 字体
- "保存 *" 状态标记 dirty
- 切目录/文件前 showConfirm 「丢弃未保存修改?」
- 保存成功 toast
### 二进制预览
- 图片 (png/jpg/gif/webp/svg) → 用 data: URL 显示
- 其他 → 「二进制文件(不可编辑)」+ 文件大小
## sidebar
- 「管理」section 加文件管理器入口(folder icon)
- /h/files 路由注册(含意外的 routes 顺序修复)
### dev-api.js
- Web 模式走 Node fs.readdirSync/readFileSync/writeFileSync
- 同样的安全策略:根目录 hermes_home() = process.env.HERMES_HOME 或 ~/.hermes
- realpath 验证 + 5 MB 限制
### CSS(~165 行)
- .hm-files-layout: grid 双栏(响应式 → 单栏)
- .hm-files-tree: 左侧树,breadcrumb + entry 列表
- .hm-files-pane: 右侧编辑器/预览
- .hm-files-editor: 全宽 textarea,monospace
- 响应式:768px 以下单栏
### i18n
- 12 个新键 × 3 语言(hermesFiles*)
## 修复
- src/engines/hermes/index.js 末尾多余 `}` 删除(之前 edit 留下)
## 累计
- Rust ~180 行 + 前端 ~270 行 + CSS ~165 行 + dev-api ~55 行
- 12 个 i18n × 3 语言
- cargo check ✓ + npm build ✓
This commit is contained in:
@@ -7225,6 +7225,63 @@ const handlers = {
|
||||
return await resp.json().catch(() => ({ ok: true }))
|
||||
},
|
||||
|
||||
// Batch 3 §L: 文件管理器(Web 模式走 Node fs,限定 hermes_home 子树)
|
||||
_hermesHome() {
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
if (process.env.HERMES_HOME) return process.env.HERMES_HOME
|
||||
return path.join(os.homedir(), '.hermes')
|
||||
},
|
||||
_validateFsPath(rel) {
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const root = handlers._hermesHome()
|
||||
const target = rel ? path.resolve(root, rel) : root
|
||||
const canonRoot = fs.realpathSync.native?.(root) || root
|
||||
if (!target.startsWith(canonRoot)) throw new Error(`路径不能跳出 ${root}`)
|
||||
return target
|
||||
},
|
||||
async hermes_fs_list({ path: p = '' } = {}) {
|
||||
const fs = require('node:fs')
|
||||
const target = handlers._validateFsPath(p)
|
||||
if (!fs.existsSync(target)) throw new Error(`目录不存在: ${target}`)
|
||||
const stat = fs.statSync(target)
|
||||
if (!stat.isDirectory()) throw new Error(`不是目录: ${target}`)
|
||||
let entries = fs.readdirSync(target, { withFileTypes: true }).filter(e => !e.name.startsWith('.') || e.name === '.env')
|
||||
entries = entries.slice(0, 2000).map(e => {
|
||||
const sub = require('node:path').join(target, e.name)
|
||||
const m = fs.statSync(sub)
|
||||
return {
|
||||
name: e.name,
|
||||
kind: e.isDirectory() ? 'dir' : e.isSymbolicLink() ? 'symlink' : 'file',
|
||||
size: e.isFile() ? m.size : null,
|
||||
modified: Math.floor(m.mtimeMs / 1000),
|
||||
}
|
||||
})
|
||||
entries.sort((a, b) => a.kind !== b.kind ? (a.kind === 'dir' ? -1 : 1) : a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||
return { path: target, entries }
|
||||
},
|
||||
async hermes_fs_read({ path: p } = {}) {
|
||||
const fs = require('node:fs')
|
||||
const target = handlers._validateFsPath(p)
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) throw new Error(`不是文件: ${target}`)
|
||||
const stat = fs.statSync(target)
|
||||
if (stat.size > 5 * 1024 * 1024) throw new Error(`文件过大 (${stat.size} bytes)`)
|
||||
const buf = fs.readFileSync(target)
|
||||
let text = null, binary_b64 = null
|
||||
try { text = buf.toString('utf8'); if (text.includes('\u0000')) { text = null; binary_b64 = buf.toString('base64') } }
|
||||
catch { binary_b64 = buf.toString('base64') }
|
||||
return { path: target, size: stat.size, text, binary_b64 }
|
||||
},
|
||||
async hermes_fs_write({ path: p, content } = {}) {
|
||||
const fs = require('node:fs')
|
||||
const target = handlers._validateFsPath(p)
|
||||
if (Buffer.byteLength(content || '', 'utf8') > 5 * 1024 * 1024) throw new Error('内容过大')
|
||||
fs.writeFileSync(target, content || '', 'utf8')
|
||||
return { path: target, size: fs.statSync(target).size }
|
||||
},
|
||||
|
||||
// Batch 2 §G: 多 Gateway(Web 模式不支持本地进程管理)
|
||||
hermes_multi_gateway_list() { return [] },
|
||||
hermes_multi_gateway_add() { throw new Error('Web 模式不支持多 Gateway 管理(请使用桌面客户端)') },
|
||||
|
||||
@@ -6431,6 +6431,189 @@ pub async fn hermes_multi_gateway_stop(
|
||||
Ok(serde_json::json!({ "stopped": true, "was_running": true, "pid": pid }))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Batch 3 §L: 文件管理器(基础 fs 命令)
|
||||
//
|
||||
// 限制:所有路径必须在 hermes_home() (~/.hermes) 子树内(防 path traversal)。
|
||||
// 提供:list / read / write 三个基础命令,前端组合成文件管理器 UI。
|
||||
// ============================================================================
|
||||
|
||||
const FS_MAX_READ_BYTES: u64 = 5 * 1024 * 1024; // 5 MB
|
||||
const FS_MAX_LIST_ENTRIES: usize = 2000; // 单次最多返回 2000 条
|
||||
|
||||
/// 验证路径在 hermes_home 子树内(防 path traversal)。
|
||||
/// 返回安全的绝对路径,或 Err。
|
||||
fn validate_hermes_fs_path(rel_path: &str) -> Result<PathBuf, String> {
|
||||
let root = hermes_home();
|
||||
// 空 = 根目录
|
||||
let target = if rel_path.is_empty() {
|
||||
root.clone()
|
||||
} else {
|
||||
// 拒绝绝对路径输入(必须相对于 hermes_home)
|
||||
let p = std::path::Path::new(rel_path);
|
||||
if p.is_absolute() {
|
||||
// 允许绝对路径,但必须以 root 开头(用 starts_with 检查)
|
||||
let canonical_root = root.canonicalize().unwrap_or(root.clone());
|
||||
let canonical_target = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
|
||||
if !canonical_target.starts_with(&canonical_root) {
|
||||
return Err(format!(
|
||||
"路径必须在 {} 子树内",
|
||||
root.to_string_lossy()
|
||||
));
|
||||
}
|
||||
canonical_target
|
||||
} else {
|
||||
// 相对路径:拼到 root 下,再 canonicalize 防 ..
|
||||
let joined = root.join(p);
|
||||
// 父目录必须存在才能 canonicalize;对不存在的新文件 fallback 到 joined
|
||||
let canon = joined.canonicalize().unwrap_or(joined.clone());
|
||||
let canonical_root = root.canonicalize().unwrap_or(root.clone());
|
||||
if !canon.starts_with(&canonical_root) {
|
||||
return Err(format!(
|
||||
"路径不能跳出 {} 目录",
|
||||
root.to_string_lossy()
|
||||
));
|
||||
}
|
||||
canon
|
||||
}
|
||||
};
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_fs_list(path: String) -> Result<Value, String> {
|
||||
let target = validate_hermes_fs_path(&path)?;
|
||||
if !target.exists() {
|
||||
return Err(format!("目录不存在: {}", target.to_string_lossy()));
|
||||
}
|
||||
if !target.is_dir() {
|
||||
return Err(format!("不是目录: {}", target.to_string_lossy()));
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let read_dir = std::fs::read_dir(&target).map_err(|e| format!("读取目录失败: {e}"))?;
|
||||
for entry in read_dir.flatten().take(FS_MAX_LIST_ENTRIES) {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') && name != ".env" && name != ".hermes" {
|
||||
continue; // 隐藏文件默认不显示(.env 除外因为 Hermes 用它)
|
||||
}
|
||||
let ft = match entry.file_type() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let meta = entry.metadata().ok();
|
||||
let size = meta.as_ref().and_then(|m| if m.is_file() { Some(m.len()) } else { None });
|
||||
let modified = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs())
|
||||
});
|
||||
entries.push(serde_json::json!({
|
||||
"name": name,
|
||||
"kind": if ft.is_dir() { "dir" } else if ft.is_symlink() { "symlink" } else { "file" },
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
}));
|
||||
}
|
||||
// 目录在前,文件在后,每组按名字排序
|
||||
entries.sort_by(|a, b| {
|
||||
let ak = a.get("kind").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let bk = b.get("kind").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if ak != bk {
|
||||
return if ak == "dir" { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater };
|
||||
}
|
||||
let an = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let bn = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
an.to_lowercase().cmp(&bn.to_lowercase())
|
||||
});
|
||||
Ok(serde_json::json!({
|
||||
"path": target.to_string_lossy(),
|
||||
"entries": entries,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_fs_read(path: String) -> Result<Value, String> {
|
||||
let target = validate_hermes_fs_path(&path)?;
|
||||
if !target.exists() {
|
||||
return Err(format!("文件不存在: {}", target.to_string_lossy()));
|
||||
}
|
||||
if !target.is_file() {
|
||||
return Err(format!("不是文件: {}", target.to_string_lossy()));
|
||||
}
|
||||
let meta = target.metadata().map_err(|e| format!("读元数据失败: {e}"))?;
|
||||
if meta.len() > FS_MAX_READ_BYTES {
|
||||
return Err(format!(
|
||||
"文件过大({} bytes),最大 {} bytes",
|
||||
meta.len(),
|
||||
FS_MAX_READ_BYTES
|
||||
));
|
||||
}
|
||||
let content = std::fs::read(&target).map_err(|e| format!("读取失败: {e}"))?;
|
||||
// 尝试当作 UTF-8 文本;失败 → 二进制(用 base64)
|
||||
let (text_content, binary_b64) = match std::str::from_utf8(&content) {
|
||||
Ok(s) => (Some(s.to_string()), None),
|
||||
Err(_) => {
|
||||
// 简单的非文本判定(包含 null byte 即认为是二进制)
|
||||
(None, Some(base64_encode(&content)))
|
||||
}
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"path": target.to_string_lossy(),
|
||||
"size": meta.len(),
|
||||
"text": text_content,
|
||||
"binary_b64": binary_b64,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 简单的 base64 编码(不引新依赖)
|
||||
fn base64_encode(bytes: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
|
||||
let mut i = 0;
|
||||
while i + 3 <= bytes.len() {
|
||||
let n = (u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8) | u32::from(bytes[i + 2]);
|
||||
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[(n & 0x3F) as usize] as char);
|
||||
i += 3;
|
||||
}
|
||||
let rem = bytes.len() - i;
|
||||
if rem == 1 {
|
||||
let n = u32::from(bytes[i]) << 16;
|
||||
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
out.push('=');
|
||||
out.push('=');
|
||||
} else if rem == 2 {
|
||||
let n = (u32::from(bytes[i]) << 16) | (u32::from(bytes[i + 1]) << 8);
|
||||
out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
|
||||
out.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
|
||||
out.push('=');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hermes_fs_write(path: String, content: String) -> Result<Value, String> {
|
||||
let target = validate_hermes_fs_path(&path)?;
|
||||
// 父目录必须存在
|
||||
if let Some(parent) = target.parent() {
|
||||
if !parent.exists() {
|
||||
return Err(format!("父目录不存在: {}", parent.to_string_lossy()));
|
||||
}
|
||||
}
|
||||
// 写入大小限制(防止巨型文件意外写入)
|
||||
if content.len() as u64 > FS_MAX_READ_BYTES {
|
||||
return Err(format!("内容过大({} bytes),最大 {} bytes", content.len(), FS_MAX_READ_BYTES));
|
||||
}
|
||||
std::fs::write(&target, content.as_bytes()).map_err(|e| format!("写入失败: {e}"))?;
|
||||
let meta = target.metadata().ok();
|
||||
Ok(serde_json::json!({
|
||||
"path": target.to_string_lossy(),
|
||||
"size": meta.map(|m| m.len()).unwrap_or(0),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests for the pure YAML helpers (no filesystem I/O).
|
||||
// ============================================================================
|
||||
|
||||
@@ -249,6 +249,9 @@ pub fn run() {
|
||||
hermes::hermes_multi_gateway_remove,
|
||||
hermes::hermes_multi_gateway_start,
|
||||
hermes::hermes_multi_gateway_stop,
|
||||
hermes::hermes_fs_list,
|
||||
hermes::hermes_fs_read,
|
||||
hermes::hermes_fs_write,
|
||||
hermes::hermes_read_config,
|
||||
hermes::hermes_read_config_full,
|
||||
hermes::hermes_lazy_deps_features,
|
||||
|
||||
@@ -109,7 +109,8 @@ export default {
|
||||
// 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/oauth', loader: () => import('./pages/oauth.js') },
|
||||
{ path: '/h/files', loader: () => import('./pages/files.js') },
|
||||
{ path: '/h/sessions', loader: () => import('./pages/sessions.js') },
|
||||
{ path: '/h/logs', loader: () => import('./pages/logs.js') },
|
||||
{ path: '/h/usage', loader: () => import('./pages/usage.js') },
|
||||
|
||||
305
src/engines/hermes/pages/files.js
Normal file
305
src/engines/hermes/pages/files.js
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Hermes 文件管理器(Batch 3 §L)
|
||||
*
|
||||
* 自建(Hermes 没有 file HTTP API),走 Tauri fs 命令:
|
||||
* - hermesFsList(path) → { path, entries: [{name, kind, size, modified}] }
|
||||
* - hermesFsRead(path) → { path, size, text?, binary_b64? }
|
||||
* - hermesFsWrite(path, content) → { path, size }
|
||||
*
|
||||
* 限制:所有路径必须在 hermes_home (~/.hermes) 子树内(Rust 验证)。
|
||||
*
|
||||
* UI:左侧面包屑 + 文件树,右侧编辑器(文本)/ 预览(二进制)。
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
import { toast } from '../../../components/toast.js'
|
||||
import { showConfirm } from '../../../components/modal.js'
|
||||
import { humanizeError } from '../../../lib/humanize-error.js'
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
function escAttr(s) { return escHtml(s) }
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes == null) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatTime(secs) {
|
||||
if (!secs) return ''
|
||||
const d = new Date(secs * 1000)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
function iconForKind(kind, name) {
|
||||
if (kind === 'dir') return '📁'
|
||||
if (kind === 'symlink') return '🔗'
|
||||
const ext = (name.split('.').pop() || '').toLowerCase()
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return '🖼️'
|
||||
if (['md', 'txt'].includes(ext)) return '📝'
|
||||
if (['json', 'yaml', 'yml', 'toml'].includes(ext)) return '⚙️'
|
||||
if (['py', 'js', 'ts', 'rs', 'go'].includes(ext)) return '📄'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
el.dataset.engine = 'hermes'
|
||||
|
||||
// 路径用相对路径(相对 hermes_home),空串 = 根
|
||||
let currentDir = ''
|
||||
let entries = []
|
||||
let dirLoading = true
|
||||
let dirError = ''
|
||||
|
||||
// 选中文件状态
|
||||
let selectedRel = null // 选中文件相对路径
|
||||
let fileData = null // { path, size, text, binary_b64 }
|
||||
let fileLoading = false
|
||||
let fileError = ''
|
||||
let editorBuf = '' // 编辑器当前内容
|
||||
let editorDirty = false
|
||||
|
||||
function draw() {
|
||||
el.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">${escHtml(t('engine.hermesFilesTitle'))}</h1>
|
||||
<p class="page-desc">${escHtml(t('engine.hermesFilesDesc'))}</p>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="hm-fs-refresh">${escHtml(t('hermesLazyDeps.refresh'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-files-layout">
|
||||
<div class="hm-files-tree">
|
||||
${renderBreadcrumb()}
|
||||
${renderDirContent()}
|
||||
</div>
|
||||
<div class="hm-files-pane">
|
||||
${renderFilePane()}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
}
|
||||
|
||||
function renderBreadcrumb() {
|
||||
const parts = currentDir ? currentDir.split(/[\\/]/).filter(Boolean) : []
|
||||
let acc = ''
|
||||
const crumbs = [{ rel: '', label: '~/.hermes' }]
|
||||
for (const p of parts) {
|
||||
acc = acc ? `${acc}/${p}` : p
|
||||
crumbs.push({ rel: acc, label: p })
|
||||
}
|
||||
return `
|
||||
<div class="hm-files-breadcrumb">
|
||||
${crumbs.map((c, i) => `
|
||||
<a href="#" data-cd="${escAttr(c.rel)}" class="hm-files-crumb">${escHtml(c.label)}</a>
|
||||
${i < crumbs.length - 1 ? '<span class="hm-files-crumb-sep">/</span>' : ''}
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderDirContent() {
|
||||
if (dirLoading) {
|
||||
return `<div style="padding:20px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>`
|
||||
}
|
||||
if (dirError) {
|
||||
return `<div style="padding:16px;color:var(--error)">${escHtml(dirError)}</div>`
|
||||
}
|
||||
if (!entries.length) {
|
||||
return `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('engine.hermesFilesEmptyDir'))}</div>`
|
||||
}
|
||||
return `
|
||||
<div class="hm-files-list">
|
||||
${currentDir ? `<div class="hm-files-entry hm-files-entry--up" data-cd="${escAttr(parentDir(currentDir))}"><span class="hm-files-icon">📁</span><span class="hm-files-name">..</span></div>` : ''}
|
||||
${entries.map(e => `
|
||||
<div class="hm-files-entry ${e.kind === 'dir' ? 'is-dir' : 'is-file'} ${selectedRel === joinRel(currentDir, e.name) ? 'is-selected' : ''}"
|
||||
data-kind="${escAttr(e.kind)}"
|
||||
data-name="${escAttr(e.name)}">
|
||||
<span class="hm-files-icon">${iconForKind(e.kind, e.name)}</span>
|
||||
<span class="hm-files-name" title="${escAttr(e.name)}">${escHtml(e.name)}</span>
|
||||
<span class="hm-files-meta">${e.kind === 'file' ? escHtml(formatSize(e.size)) : ''}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderFilePane() {
|
||||
if (!selectedRel) {
|
||||
return `<div class="hm-files-pane-empty">${escHtml(t('engine.hermesFilesSelectFile'))}</div>`
|
||||
}
|
||||
if (fileLoading) {
|
||||
return `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>`
|
||||
}
|
||||
if (fileError) {
|
||||
return `
|
||||
<div class="hm-files-pane-header">
|
||||
<div class="hm-files-pane-title">${escHtml(selectedRel)}</div>
|
||||
</div>
|
||||
<div style="padding:16px;color:var(--error)">${escHtml(fileError)}</div>
|
||||
`
|
||||
}
|
||||
if (!fileData) return ''
|
||||
|
||||
return `
|
||||
<div class="hm-files-pane-header">
|
||||
<div class="hm-files-pane-title" title="${escAttr(fileData.path)}">${escHtml(selectedRel)}</div>
|
||||
<div class="hm-files-pane-actions">
|
||||
<span class="hm-files-pane-size">${escHtml(formatSize(fileData.size))}</span>
|
||||
${fileData.text != null ? `
|
||||
<button class="btn btn-primary btn-sm" id="hm-fs-save" ${!editorDirty ? 'disabled' : ''}>
|
||||
${escHtml(editorDirty ? t('engine.hermesFilesSaveDirty') : t('engine.hermesFilesSave'))}
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${fileData.text != null
|
||||
? `<textarea class="hm-files-editor" id="hm-fs-editor" spellcheck="false">${escHtml(editorBuf)}</textarea>`
|
||||
: fileData.binary_b64
|
||||
? renderBinaryPreview(fileData)
|
||||
: `<div style="padding:32px;color:var(--text-tertiary);text-align:center">${escHtml(t('engine.hermesFilesUnreadable'))}</div>`}
|
||||
`
|
||||
}
|
||||
|
||||
function renderBinaryPreview(d) {
|
||||
const ext = (selectedRel.split('.').pop() || '').toLowerCase()
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
|
||||
const mime = ext === 'jpg' ? 'image/jpeg' : `image/${ext === 'svg' ? 'svg+xml' : ext}`
|
||||
return `<div class="hm-files-binary-preview"><img src="data:${mime};base64,${d.binary_b64}" alt=""></div>`
|
||||
}
|
||||
return `<div class="hm-files-binary-meta">${escHtml(t('engine.hermesFilesBinary'))} · ${escHtml(formatSize(d.size))}</div>`
|
||||
}
|
||||
|
||||
function joinRel(a, b) {
|
||||
return a ? `${a}/${b}` : b
|
||||
}
|
||||
|
||||
function parentDir(rel) {
|
||||
if (!rel) return ''
|
||||
const idx = Math.max(rel.lastIndexOf('/'), rel.lastIndexOf('\\'))
|
||||
return idx > 0 ? rel.slice(0, idx) : ''
|
||||
}
|
||||
|
||||
function bind() {
|
||||
el.querySelector('#hm-fs-refresh')?.addEventListener('click', () => loadDir(currentDir))
|
||||
el.querySelectorAll('[data-cd]').forEach(node => {
|
||||
node.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
loadDir(node.dataset.cd)
|
||||
})
|
||||
})
|
||||
el.querySelectorAll('.hm-files-entry').forEach(node => {
|
||||
const kind = node.dataset.kind
|
||||
const name = node.dataset.name
|
||||
if (!kind || !name) return // 跳过 ".." 已绑定的
|
||||
node.addEventListener('click', () => {
|
||||
if (kind === 'dir') {
|
||||
loadDir(joinRel(currentDir, name))
|
||||
} else {
|
||||
loadFile(joinRel(currentDir, name))
|
||||
}
|
||||
})
|
||||
})
|
||||
const editor = el.querySelector('#hm-fs-editor')
|
||||
editor?.addEventListener('input', () => {
|
||||
editorBuf = editor.value
|
||||
const wasDirty = editorDirty
|
||||
editorDirty = (editorBuf !== (fileData?.text || ''))
|
||||
if (wasDirty !== editorDirty) {
|
||||
// 只更新 save 按钮,避免重绘 textarea 失焦
|
||||
const btn = el.querySelector('#hm-fs-save')
|
||||
if (btn) {
|
||||
btn.disabled = !editorDirty
|
||||
btn.textContent = editorDirty ? t('engine.hermesFilesSaveDirty') : t('engine.hermesFilesSave')
|
||||
}
|
||||
}
|
||||
})
|
||||
el.querySelector('#hm-fs-save')?.addEventListener('click', onSave)
|
||||
}
|
||||
|
||||
async function loadDir(rel) {
|
||||
if (editorDirty) {
|
||||
const ok = await showConfirm({
|
||||
message: t('engine.hermesFilesUnsavedConfirm'),
|
||||
confirmText: t('engine.hermesFilesDiscardChanges'),
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
currentDir = rel
|
||||
dirLoading = true
|
||||
dirError = ''
|
||||
selectedRel = null
|
||||
fileData = null
|
||||
editorBuf = ''
|
||||
editorDirty = false
|
||||
draw()
|
||||
try {
|
||||
const data = await api.hermesFsList(rel)
|
||||
entries = data?.entries || []
|
||||
} catch (e) {
|
||||
dirError = String(e?.message || e)
|
||||
entries = []
|
||||
} finally {
|
||||
dirLoading = false
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile(rel) {
|
||||
if (editorDirty) {
|
||||
const ok = await showConfirm({
|
||||
message: t('engine.hermesFilesUnsavedConfirm'),
|
||||
confirmText: t('engine.hermesFilesDiscardChanges'),
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
selectedRel = rel
|
||||
fileLoading = true
|
||||
fileError = ''
|
||||
fileData = null
|
||||
editorBuf = ''
|
||||
editorDirty = false
|
||||
draw()
|
||||
try {
|
||||
const data = await api.hermesFsRead(rel)
|
||||
fileData = data
|
||||
editorBuf = data?.text || ''
|
||||
} catch (e) {
|
||||
fileError = String(e?.message || e)
|
||||
} finally {
|
||||
fileLoading = false
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!selectedRel || !editorDirty) return
|
||||
try {
|
||||
await api.hermesFsWrite(selectedRel, editorBuf)
|
||||
toast(t('engine.hermesFilesSaved'), 'success')
|
||||
// 同步内存状态(不重读,避免失焦)
|
||||
if (fileData) fileData.text = editorBuf
|
||||
editorDirty = false
|
||||
const btn = el.querySelector('#hm-fs-save')
|
||||
if (btn) {
|
||||
btn.disabled = true
|
||||
btn.textContent = t('engine.hermesFilesSave')
|
||||
}
|
||||
} catch (e) {
|
||||
toast(humanizeError(e, t('engine.hermesFilesSaveFailed')), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
draw()
|
||||
loadDir('')
|
||||
return el
|
||||
}
|
||||
@@ -5311,6 +5311,171 @@ body[data-active-engine="hermes"][data-theme="dark"] {
|
||||
color: var(--hm-accent);
|
||||
}
|
||||
|
||||
/* ---- Batch 3 §L: 文件管理器 ---- */
|
||||
[data-engine="hermes"] .hm-files-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 16px;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 480px;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-tree {
|
||||
background: var(--hm-surface-0);
|
||||
border: 1px solid var(--hm-border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--hm-border);
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-crumb {
|
||||
color: var(--hm-accent);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-crumb:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-crumb-sep {
|
||||
color: var(--hm-text-tertiary);
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-entry:hover {
|
||||
background: var(--hm-surface-1);
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-entry.is-selected {
|
||||
background: var(--hm-surface-2);
|
||||
color: var(--hm-accent);
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-entry.is-dir .hm-files-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-icon {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-meta {
|
||||
font-size: 10px;
|
||||
color: var(--hm-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-pane {
|
||||
background: var(--hm-surface-0);
|
||||
border: 1px solid var(--hm-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-pane-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--hm-text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--hm-border);
|
||||
gap: 12px;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-pane-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-pane-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-pane-size {
|
||||
font-size: 11px;
|
||||
color: var(--hm-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-editor {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: none;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: var(--hm-text-primary);
|
||||
width: 100%;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-binary-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-binary-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-binary-meta {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--hm-text-tertiary);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
[data-engine="hermes"] .hm-files-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
[data-engine="hermes"] .hm-files-tree {
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-engine="hermes"] .hm-chat-live-tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -494,6 +494,10 @@ export const api = {
|
||||
hermesMultiGatewayRemove: (name) => invoke('hermes_multi_gateway_remove', { name }),
|
||||
hermesMultiGatewayStart: (name) => invoke('hermes_multi_gateway_start', { name }),
|
||||
hermesMultiGatewayStop: (name) => invoke('hermes_multi_gateway_stop', { name }),
|
||||
// Batch 3 §L: 文件管理器(限定在 hermes_home 子树内)
|
||||
hermesFsList: (path = '') => invoke('hermes_fs_list', { path }),
|
||||
hermesFsRead: (path) => invoke('hermes_fs_read', { path }),
|
||||
hermesFsWrite: (path, content) => invoke('hermes_fs_write', { path, content }),
|
||||
hermesReadConfig: () => invoke('hermes_read_config'),
|
||||
hermesReadConfigFull: () => invoke('hermes_read_config_full'),
|
||||
hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000),
|
||||
|
||||
@@ -587,6 +587,19 @@ export default {
|
||||
hermesGatewayRemoveConfirm: _('确认从面板删除 Gateway "{name}"?(不会影响该 Profile 配置)', 'Remove Gateway "{name}" from panel? (Does not affect profile config)', '確認從面板刪除 Gateway "{name}"?(不會影響該 Profile 設定)'),
|
||||
hermesGatewayRemoved: _('Gateway "{name}" 已删除', 'Gateway "{name}" removed', 'Gateway "{name}" 已刪除'),
|
||||
hermesGatewayRemoveFailed: _('删除 Gateway 失败', 'Remove gateway failed', '刪除 Gateway 失敗'),
|
||||
// Batch 3 §L: 文件管理器
|
||||
hermesFilesTitle: _('文件管理器', 'Files', '檔案管理器'),
|
||||
hermesFilesDesc: _('浏览和编辑 ~/.hermes 目录下的配置、记忆、日志等文件(限制 5MB 以内)', 'Browse and edit files under ~/.hermes (config, memory, logs — max 5MB)', '瀏覽和編輯 ~/.hermes 目錄下的設定、記憶、日誌等檔案'),
|
||||
hermesFilesEmptyDir: _('(空目录)', '(empty directory)', '(空目錄)'),
|
||||
hermesFilesSelectFile: _('从左侧选一个文件查看 / 编辑', 'Select a file from the left to view / edit', '從左側選一個檔案查看 / 編輯'),
|
||||
hermesFilesSave: _('保存', 'Save', '儲存'),
|
||||
hermesFilesSaveDirty: _('保存 *', 'Save *', '儲存 *'),
|
||||
hermesFilesSaved: _('已保存', 'Saved', '已儲存'),
|
||||
hermesFilesSaveFailed: _('保存失败', 'Save failed', '儲存失敗'),
|
||||
hermesFilesBinary: _('二进制文件(不可编辑)', 'Binary file (not editable)', '二進位檔案(不可編輯)'),
|
||||
hermesFilesUnreadable: _('文件无法读取', 'File unreadable', '檔案無法讀取'),
|
||||
hermesFilesUnsavedConfirm: _('当前文件有未保存的修改,确认丢弃?', 'Current file has unsaved changes. Discard?', '目前檔案有未儲存的修改,確認丟棄?'),
|
||||
hermesFilesDiscardChanges: _('丢弃', 'Discard', '丟棄'),
|
||||
// Web 模式(远程浏览器)下流式聊天暂不可用
|
||||
chatWebModeStreamingUnsupported: _(
|
||||
'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',
|
||||
|
||||
Reference in New Issue
Block a user