Files
Hermes-ui/packages/client/src/api/hermes/files.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

102 lines
3.3 KiB
TypeScript

import { request, getApiKey, getBaseUrlValue } from '../client'
export interface FileEntry {
name: string
path: string
isDir: boolean
size: number
modTime: string
}
export interface FileStat {
name: string
path: string
isDir: boolean
size: number
modTime: string
permissions?: string
}
export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string }> {
const params = new URLSearchParams()
if (path) params.set('path', path)
const query = params.toString()
return request<{ entries: FileEntry[]; path: string }>(`/api/hermes/files/list${query ? `?${query}` : ''}`)
}
export async function statFile(path: string): Promise<FileStat> {
return request<FileStat>(`/api/hermes/files/stat?path=${encodeURIComponent(path)}`)
}
export async function readFile(path: string): Promise<{ content: string; path: string; size: number }> {
return request<{ content: string; path: string; size: number }>(`/api/hermes/files/read?path=${encodeURIComponent(path)}`)
}
export async function writeFile(path: string, content: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/write', {
method: 'PUT',
body: JSON.stringify({ path, content }),
})
}
export async function deleteFile(path: string, recursive: boolean = false): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/delete', {
method: 'DELETE',
body: JSON.stringify({ path, recursive }),
})
}
export async function renameFile(oldPath: string, newPath: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/rename', {
method: 'POST',
body: JSON.stringify({ oldPath, newPath }),
})
}
export async function mkDir(path: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/mkdir', {
method: 'POST',
body: JSON.stringify({ path }),
})
}
export async function copyFile(srcPath: string, destPath: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/copy', {
method: 'POST',
body: JSON.stringify({ srcPath, destPath }),
})
}
export async function uploadFiles(targetDir: string, files: File[]): Promise<{ name: string; path: string }[]> {
const base = getBaseUrlValue()
const formData = new FormData()
for (const file of files) {
formData.append('file', file)
}
const params = new URLSearchParams()
if (targetDir) params.set('path', targetDir)
const query = params.toString()
const url = `${base}/api/hermes/files/upload${query ? `?${query}` : ''}`
const headers: Record<string, string> = {}
const token = getApiKey()
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(url, { method: 'POST', headers, body: formData })
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(body.error || `Upload failed: ${res.status}`)
}
const data = await res.json()
return data.files
}
export function getFileDownloadUrl(relativePath: string, fileName?: string): string {
const base = getBaseUrlValue()
const params = new URLSearchParams({ path: relativePath })
if (fileName) params.set('name', fileName)
const token = getApiKey()
if (token) params.set('token', token)
return `${base}/api/hermes/download?${params.toString()}`
}