mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-06 00:00:06 +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:
@@ -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