Files
Hermes-ui/packages/server/src/routes/hermes/download.ts
T
ww 0cc31ee999 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>
2026-04-23 12:09:39 +08:00

115 lines
3.4 KiB
TypeScript

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 }
}
})