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

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