feat: add file browser and file download with multi-backend support (#142)

* feat: add file browser and file download with multi-backend support

Adds a built-in File Browser page and a File Download system to Hermes
Web UI, enabling users to browse, edit, preview, upload, and download
files from the workspace directly from the web dashboard.

File Browser (/hermes/files):
- New view FilesView.vue plus components under components/hermes/files/
  (FileTree, FileList, FileBreadcrumb, FileToolbar, FileContextMenu,
  FileEditor, FilePreview, FileRenameModal, FileUploadModal)
- New Pinia store stores/hermes/files.ts for directory tree, selection,
  and editing state
- New API module api/hermes/files.ts
- New server routes routes/hermes/files.ts with CRUD, rename, upload,
  and directory listing
- New service services/hermes/file-provider.ts with a pluggable
  provider architecture (local filesystem + multi-terminal backends)

File Download:
- New server route routes/hermes/download.ts and client API
  api/hermes/download.ts
- Integration in chat messages (MessageItem.vue, MarkdownRenderer.vue)
  to surface downloadable file references

Packaging:
- package.json: add a prepare script so the package can be installed
  directly from a git URL with dist/ built automatically

i18n: add files/download translations to en.ts and zh.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use clipboard fallback for non-secure HTTP contexts

navigator.clipboard is undefined on HTTP intranet deployments (only
available in secure contexts). The previous synchronous calls threw
silently and the success toast still fired, making 'copy' actions
appear broken.

- Add packages/client/src/utils/clipboard.ts with execCommand fallback
  via a hidden textarea
- Use the helper in FileContextMenu (copy file path), CodexLoginModal
  (copy user code), NousLoginModal (copy user code), ChatPanel (copy
  session id)
- Each call now awaits the result and shows success/failure based on
  the actual outcome

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
ww
2026-04-23 12:09:39 +08:00
committed by GitHub
parent 1f91b902da
commit 0cc31ee999
32 changed files with 2913 additions and 12 deletions
@@ -0,0 +1,114 @@
import Router from '@koa/router'
import { basename, extname } from 'path'
import {
createFileProvider,
localProvider,
isInUploadDir,
validatePath,
resolveHermesPath,
} from '../../services/hermes/file-provider'
export const downloadRoutes = new Router()
// MIME type mapping for common extensions
const MIME_MAP: Record<string, string> = {
'.txt': 'text/plain',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.md': 'text/markdown',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.py': 'text/x-python',
'.ts': 'text/typescript',
'.tsx': 'text/typescript',
'.rs': 'text/x-rust',
'.go': 'text/x-go',
'.java': 'text/x-java',
'.c': 'text/x-c',
'.cpp': 'text/x-c++',
'.h': 'text/x-c',
'.sh': 'text/x-shellscript',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.toml': 'text/toml',
'.log': 'text/plain',
}
function getMimeType(fileName: string): string {
const ext = extname(fileName).toLowerCase()
return MIME_MAP[ext] || 'application/octet-stream'
}
downloadRoutes.get('/api/hermes/download', async (ctx) => {
const filePath = ctx.query.path as string | undefined
const fileName = ctx.query.name as string | undefined
if (!filePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
// Validate the path first
// Support both absolute and relative paths
const validPath = filePath.startsWith('/') ? validatePath(filePath) : resolveHermesPath(filePath)
// Choose provider: always use local for upload directory files
let data: Buffer
if (isInUploadDir(validPath)) {
data = await localProvider.readFile(validPath)
} else {
const provider = await createFileProvider()
data = await provider.readFile(validPath)
}
// Determine filename and MIME type
const name = fileName || basename(validPath)
const mime = getMimeType(name)
// Set response headers
ctx.set('Content-Type', mime)
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`)
ctx.set('Content-Length', String(data.length))
ctx.set('Cache-Control', 'no-cache')
ctx.body = data
} catch (err: any) {
const code = err.code || 'unknown'
const statusMap: Record<string, number> = {
missing_path: 400,
invalid_path: 400,
not_found: 404,
ENOENT: 404,
file_too_large: 413,
unsupported_backend: 501,
backend_error: 502,
backend_timeout: 504,
}
ctx.status = statusMap[code] || 500
ctx.body = { error: err.message, code }
}
})
+283
View File
@@ -0,0 +1,283 @@
import Router from '@koa/router'
import {
createFileProvider,
resolveHermesPath,
isSensitivePath,
MAX_EDIT_SIZE,
} from '../../services/hermes/file-provider'
export const fileRoutes = new Router()
function handleError(ctx: any, err: any) {
const code = err.code || 'unknown'
const statusMap: Record<string, number> = {
missing_path: 400,
invalid_path: 400,
not_found: 404,
ENOENT: 404,
already_exists: 409,
permission_denied: 403,
file_too_large: 413,
not_a_directory: 400,
not_a_file: 400,
unsupported_backend: 501,
backend_error: 502,
backend_timeout: 504,
}
ctx.status = statusMap[code] || 500
ctx.body = { error: err.message, code }
}
// GET /api/hermes/files/list?path=
fileRoutes.get('/api/hermes/files/list', async (ctx) => {
const relativePath = (ctx.query.path as string) || ''
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const entries = await provider.listDir(absPath)
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name)
})
ctx.body = { entries, path: relativePath }
} catch (err: any) {
handleError(ctx, err)
}
})
// GET /api/hermes/files/stat?path=
fileRoutes.get('/api/hermes/files/stat', async (ctx) => {
const relativePath = ctx.query.path as string
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const info = await provider.stat(absPath)
ctx.body = info
} catch (err: any) {
handleError(ctx, err)
}
})
// GET /api/hermes/files/read?path=
fileRoutes.get('/api/hermes/files/read', async (ctx) => {
const relativePath = ctx.query.path as string
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
const data = await provider.readFile(absPath)
if (data.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: 'File too large to edit', code: 'file_too_large' }
return
}
ctx.body = { content: data.toString('utf-8'), path: relativePath, size: data.length }
} catch (err: any) {
handleError(ctx, err)
}
})
// PUT /api/hermes/files/write body: { path, content }
fileRoutes.put('/api/hermes/files/write', async (ctx) => {
const { path: relativePath, content } = ctx.request.body as { path?: string; content?: string }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
if (isSensitivePath(relativePath)) {
ctx.status = 403
ctx.body = { error: 'Cannot modify sensitive file', code: 'permission_denied' }
return
}
try {
const buf = Buffer.from(content || '', 'utf-8')
if (buf.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: 'Content too large', code: 'file_too_large' }
return
}
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
await provider.writeFile(absPath, buf)
ctx.body = { ok: true, path: relativePath }
} catch (err: any) {
handleError(ctx, err)
}
})
// DELETE /api/hermes/files/delete body: { path, recursive? }
fileRoutes.delete('/api/hermes/files/delete', async (ctx) => {
const { path: relativePath, recursive } = ctx.request.body as { path?: string; recursive?: boolean }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
if (isSensitivePath(relativePath)) {
ctx.status = 403
ctx.body = { error: 'Cannot delete sensitive file', code: 'permission_denied' }
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
if (recursive) {
await provider.deleteDir(absPath)
} else {
await provider.deleteFile(absPath)
}
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/rename body: { oldPath, newPath }
fileRoutes.post('/api/hermes/files/rename', async (ctx) => {
const { oldPath, newPath } = ctx.request.body as { oldPath?: string; newPath?: string }
if (!oldPath || !newPath) {
ctx.status = 400
ctx.body = { error: 'Missing oldPath or newPath', code: 'missing_path' }
return
}
if (isSensitivePath(oldPath)) {
ctx.status = 403
ctx.body = { error: 'Cannot rename sensitive file', code: 'permission_denied' }
return
}
try {
const absOld = resolveHermesPath(oldPath)
const absNew = resolveHermesPath(newPath)
const provider = await createFileProvider()
await provider.renameFile(absOld, absNew)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/mkdir body: { path }
fileRoutes.post('/api/hermes/files/mkdir', async (ctx) => {
const { path: relativePath } = ctx.request.body as { path?: string }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveHermesPath(relativePath)
const provider = await createFileProvider()
await provider.mkDir(absPath)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/copy body: { srcPath, destPath }
fileRoutes.post('/api/hermes/files/copy', async (ctx) => {
const { srcPath, destPath } = ctx.request.body as { srcPath?: string; destPath?: string }
if (!srcPath || !destPath) {
ctx.status = 400
ctx.body = { error: 'Missing srcPath or destPath', code: 'missing_path' }
return
}
try {
const absSrc = resolveHermesPath(srcPath)
const absDest = resolveHermesPath(destPath)
const provider = await createFileProvider()
await provider.copyFile(absSrc, absDest)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/upload?path= (multipart/form-data)
fileRoutes.post('/api/hermes/files/upload', async (ctx) => {
const targetDir = (ctx.query.path as string) || ''
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data', code: 'invalid_request' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary', code: 'invalid_request' }
return
}
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const raw = Buffer.concat(chunks)
const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const provider = await createFileProvider()
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
if (headerEnd === -1) continue
const headerBuf = part.subarray(0, headerEnd)
const header = headerBuf.toString('utf-8')
const data = part.subarray(headerEnd + 4, part.length - 2)
let filename = ''
const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i)
if (filenameStarMatch) {
filename = decodeURIComponent(filenameStarMatch[1])
} else {
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
filename = filenameMatch[1]
}
if (data.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: `File ${filename} too large`, code: 'file_too_large' }
return
}
const filePath = targetDir ? `${targetDir}/${filename}` : filename
if (isSensitivePath(filePath)) {
ctx.status = 403
ctx.body = { error: `Cannot overwrite sensitive file: ${filename}`, code: 'permission_denied' }
return
}
const absPath = resolveHermesPath(filePath)
await provider.writeFile(absPath, data)
results.push({ name: filename, path: filePath })
}
ctx.body = { files: results }
})
function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] {
const parts: Buffer[] = []
let start = 0
while (true) {
const idx = raw.indexOf(boundary, start)
if (idx === -1) break
if (start > 0) {
const partStart = start + 2
parts.push(raw.subarray(partStart, idx))
}
start = idx + boundary.length
}
return parts
}
+4
View File
@@ -20,6 +20,8 @@ import { codexAuthRoutes } from './hermes/codex-auth'
import { nousAuthRoutes } from './hermes/nous-auth'
import { gatewayRoutes } from './hermes/gateways'
import { weixinRoutes } from './hermes/weixin'
import { fileRoutes } from './hermes/files'
import { downloadRoutes } from './hermes/download'
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
/**
@@ -52,6 +54,8 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
app.use(nousAuthRoutes.routes())
app.use(gatewayRoutes.routes())
app.use(weixinRoutes.routes())
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
app.use(downloadRoutes.routes()) // Must be before proxy
app.use(proxyRoutes.routes())
// Proxy catch-all middleware (must be last)