From 129d8c0ac1fb3f820457c45e2d47271659d78402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 14 May 2026 05:36:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(hermes):=20Batch=203=20=C2=A7L=20-=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=99=A8=EF=BC=88=E9=99=90?= =?UTF-8?q?=E5=AE=9A=E5=9C=A8=20~/.hermes=20=E5=AD=90=E6=A0=91=E5=86=85?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 校对: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 ✓ --- scripts/dev-api.js | 57 ++++++ src-tauri/src/commands/hermes.rs | 183 +++++++++++++++++ src-tauri/src/lib.rs | 3 + src/engines/hermes/index.js | 3 +- src/engines/hermes/pages/files.js | 305 ++++++++++++++++++++++++++++ src/engines/hermes/style/hermes.css | 165 +++++++++++++++ src/lib/tauri-api.js | 4 + src/locales/modules/engine.js | 13 ++ 8 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 src/engines/hermes/pages/files.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 19f0714..3f8eed8 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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 管理(请使用桌面客户端)') }, diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 4b44d6a..9322b0e 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -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 { + 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 { + 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 { + 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 { + 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). // ============================================================================ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 10d2af3..a6e4c2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index dee5545..49c805a 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -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') }, diff --git a/src/engines/hermes/pages/files.js b/src/engines/hermes/pages/files.js new file mode 100644 index 0000000..91117fe --- /dev/null +++ b/src/engines/hermes/pages/files.js @@ -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, '"') +} +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 = ` + +
+
+ ${renderBreadcrumb()} + ${renderDirContent()} +
+
+ ${renderFilePane()} +
+
+ ` + 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 ` +
+ ${crumbs.map((c, i) => ` + ${escHtml(c.label)} + ${i < crumbs.length - 1 ? '/' : ''} + `).join('')} +
+ ` + } + + function renderDirContent() { + if (dirLoading) { + return `
${escHtml(t('common.loading'))}…
` + } + if (dirError) { + return `
${escHtml(dirError)}
` + } + if (!entries.length) { + return `
${escHtml(t('engine.hermesFilesEmptyDir'))}
` + } + return ` +
+ ${currentDir ? `
📁..
` : ''} + ${entries.map(e => ` +
+ ${iconForKind(e.kind, e.name)} + ${escHtml(e.name)} + ${e.kind === 'file' ? escHtml(formatSize(e.size)) : ''} +
+ `).join('')} +
+ ` + } + + function renderFilePane() { + if (!selectedRel) { + return `
${escHtml(t('engine.hermesFilesSelectFile'))}
` + } + if (fileLoading) { + return `
${escHtml(t('common.loading'))}…
` + } + if (fileError) { + return ` +
+
${escHtml(selectedRel)}
+
+
${escHtml(fileError)}
+ ` + } + if (!fileData) return '' + + return ` +
+
${escHtml(selectedRel)}
+
+ ${escHtml(formatSize(fileData.size))} + ${fileData.text != null ? ` + ` : ''} +
+
+ ${fileData.text != null + ? `` + : fileData.binary_b64 + ? renderBinaryPreview(fileData) + : `
${escHtml(t('engine.hermesFilesUnreadable'))}
`} + ` + } + + 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 `
` + } + return `
${escHtml(t('engine.hermesFilesBinary'))} · ${escHtml(formatSize(d.size))}
` + } + + 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 +} diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index c681d52..c861c03 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -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; diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 8502095..17c7304 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -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), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 8ac6abe..35def57 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',