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:
晴天
2026-05-14 05:36:50 +08:00
parent 0d6c4614e4
commit 129d8c0ac1
8 changed files with 732 additions and 1 deletions

View File

@@ -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: 多 GatewayWeb 模式不支持本地进程管理)
hermes_multi_gateway_list() { return [] },
hermes_multi_gateway_add() { throw new Error('Web 模式不支持多 Gateway 管理(请使用桌面客户端)') },

View File

@@ -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).
// ============================================================================

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',