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
@@ -6,6 +6,7 @@ import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getSourceLabel } from '@/shared/session-display'
import { copyToClipboard } from '@/utils/clipboard'
import ChatInput from './ChatInput.vue'
import ConversationMonitorPane from './ConversationMonitorPane.vue'
import MessageList from './MessageList.vue'
@@ -177,11 +178,12 @@ function handleNewChat() {
chatStore.newChat()
}
function copySessionId(id?: string) {
async function copySessionId(id?: string) {
const sessionId = id || chatStore.activeSessionId
if (sessionId) {
navigator.clipboard.writeText(sessionId)
message.success(t('common.copied'))
const ok = await copyToClipboard(sessionId)
if (ok) message.success(t('common.copied'))
else message.error(t('common.copied') + ' ✗')
}
}
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
import { downloadFile } from '@/api/hermes/download'
const props = defineProps<{ content: string }>()
const { t } = useI18n()
const message = useMessage()
const md: MarkdownIt = new MarkdownIt({
html: false,
@@ -20,6 +23,33 @@ const renderedHtml = computed(() => md.render(props.content))
function handleMarkdownClick(event: MouseEvent): void {
void handleCodeBlockCopyClick(event)
// Handle file path link clicks for download
const target = event.target as HTMLElement
const link = target.closest('a') as HTMLAnchorElement | null
if (!link) return
const href = link.getAttribute('href')
if (!href) return
// Let http(s) links behave normally
if (href.startsWith('http://') || href.startsWith('https://')) {
link.target = '_blank'
link.rel = 'noopener noreferrer'
return
}
// File path links: intercept and download
if (href.startsWith('/')) {
event.preventDefault()
event.stopPropagation()
const linkText = link.textContent || ''
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
message.info(t('download.downloading'))
downloadFile(href, fileName || undefined).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
}
}
</script>
@@ -2,6 +2,8 @@
import type { Message } from "@/stores/hermes/chat";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useMessage } from "naive-ui";
import { downloadFile } from "@/api/hermes/download";
import MarkdownRenderer from "./MarkdownRenderer.vue";
import {
copyTextToClipboard,
@@ -13,6 +15,7 @@ const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000;
const props = defineProps<{ message: Message; highlight?: boolean }>();
const { t } = useI18n();
const toast = useMessage();
const isSystem = computed(() => props.message.role === "system");
const toolExpanded = ref(false);
@@ -32,6 +35,39 @@ function formatSize(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
/**
* Extract the upload file path from message content for a given attachment.
* Upload format in content: [File: name.txt](/tmp/hermes-uploads/abc123.txt)
*/
function getFilePathFromContent(attName: string): string | null {
const content = props.message.content || "";
const regex = /\[File:\s*([^\]]+)\]\(([^)]+)\)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
if (match[1].trim() === attName.trim()) return match[2];
}
return null;
}
function handleAttachmentDownload(att: { name: string; url: string; type: string }) {
const filePath = getFilePathFromContent(att.name);
if (filePath) {
toast.info(t("download.downloading"));
downloadFile(filePath, att.name).catch((err: Error) => {
toast.error(err.message || t("download.downloadFailed"));
});
return;
}
if (att.url && att.url.startsWith("blob:")) {
const a = document.createElement("a");
a.href = att.url;
a.download = att.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}
type ToolPayload = {
full: string;
display: string;
@@ -214,7 +250,7 @@ const renderedToolResult = computed(() => {
/>
</template>
<template v-else>
<div class="msg-attachment-file">
<div class="msg-attachment-file" @click="handleAttachmentDownload(att)" style="cursor: pointer;" :title="t('download.downloadFile')">
<svg
width="16"
height="16"
@@ -230,6 +266,11 @@ const renderedToolResult = computed(() => {
</svg>
<span class="att-name">{{ att.name }}</span>
<span class="att-size">{{ formatSize(att.size) }}</span>
<svg class="att-download-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</div>
</template>
</div>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const filesStore = useFilesStore()
function handleClick(index: number) {
if (index < 0) {
filesStore.navigateTo('')
} else {
const path = filesStore.pathSegments.slice(0, index + 1).join('/')
filesStore.navigateTo(path)
}
}
</script>
<template>
<div class="file-breadcrumb">
<NBreadcrumb>
<NBreadcrumbItem @click="handleClick(-1)">
{{ t('files.breadcrumbRoot') }}
</NBreadcrumbItem>
<NBreadcrumbItem
v-for="(segment, index) in filesStore.pathSegments"
:key="index"
@click="handleClick(index)"
>
{{ segment }}
</NBreadcrumbItem>
</NBreadcrumb>
</div>
</template>
<style scoped lang="scss">
.file-breadcrumb {
padding: 0 16px;
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { NDropdown, useMessage, useDialog } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores/hermes/files'
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const message = useMessage()
const dialog = useDialog()
const filesStore = useFilesStore()
const showMenu = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const targetEntry = ref<FileEntry | null>(null)
const emit = defineEmits<{
(e: 'rename', entry: FileEntry): void
}>()
function show(e: MouseEvent, entry: FileEntry) {
targetEntry.value = entry
menuX.value = e.clientX
menuY.value = e.clientY
showMenu.value = false
nextTick(() => {
showMenu.value = true
})
}
function getOptions() {
const entry = targetEntry.value
if (!entry) return []
const options: any[] = []
if (entry.isDir) {
options.push({ label: t('files.open'), key: 'open' })
} else {
if (isTextFile(entry.name)) {
options.push({ label: t('files.edit'), key: 'edit' })
}
if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
options.push({ label: t('files.preview'), key: 'preview' })
}
options.push({ label: t('files.download'), key: 'download' })
}
options.push({ type: 'divider', key: 'd1' })
options.push({ label: t('files.copyPath'), key: 'copyPath' })
options.push({ label: t('files.rename'), key: 'rename' })
options.push({ type: 'divider', key: 'd2' })
options.push({ label: t('files.delete'), key: 'delete' })
return options
}
async function handleSelect(key: string) {
showMenu.value = false
const entry = targetEntry.value
if (!entry) return
switch (key) {
case 'open':
filesStore.navigateTo(entry.path)
break
case 'edit':
try { await filesStore.openEditor(entry.path) } catch { message.error(t('files.backendError')) }
break
case 'preview':
try { await filesStore.openPreview(entry) } catch { message.error(t('files.backendError')) }
break
case 'download':
try { await downloadFile(entry.path, entry.name) } catch (err: any) { message.error(err.message) }
break
case 'copyPath': {
const ok = await copyToClipboard(entry.path)
if (ok) {
message.success(t('files.pathCopied'))
} else {
message.error(t('files.pathCopied') + ' ✗')
}
break
}
case 'rename':
emit('rename', entry)
break
case 'delete':
dialog.warning({
title: t('files.delete'),
content: entry.isDir ? t('files.confirmDeleteDir', { name: entry.name }) : t('files.confirmDelete', { name: entry.name }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await filesStore.deleteEntry(entry)
message.success(t('files.deleted'))
} catch {
message.error(t('files.deleteFailed'))
}
},
})
break
}
}
function handleClickOutside() {
showMenu.value = false
}
defineExpose({ show })
</script>
<template>
<NDropdown
:show="showMenu"
:x="menuX"
:y="menuY"
:options="getOptions()"
placement="bottom-start"
trigger="manual"
@select="handleSelect"
@clickoutside="handleClickOutside"
/>
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { NButton, NSpace, useMessage, useDialog } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import * as monaco from 'monaco-editor'
// Configure Monaco workers using import.meta.url
;(self as any).MonacoEnvironment = {
getWorker(_: any, _label: string) {
return new Worker(
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
{ type: 'module' }
)
},
}
const { t } = useI18n()
const message = useMessage()
const dialogApi = useDialog()
const filesStore = useFilesStore()
const editorContainer = ref<HTMLElement | null>(null)
let editor: monaco.editor.IStandaloneCodeEditor | null = null
const saving = ref(false)
onMounted(() => {
if (!editorContainer.value || !filesStore.editingFile) return
editor = monaco.editor.create(editorContainer.value, {
value: filesStore.editingFile.content,
language: filesStore.editingFile.language,
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
})
editor.onDidChangeModelContent(() => {
if (filesStore.editingFile) {
filesStore.editingFile.content = editor!.getValue()
}
})
// Ctrl/Cmd + S to save
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave()
})
})
onBeforeUnmount(() => {
editor?.dispose()
editor = null
})
async function handleSave() {
saving.value = true
try {
await filesStore.saveEditor()
message.success(t('files.saved'))
} catch {
message.error(t('files.saveFailed'))
} finally {
saving.value = false
}
}
function handleClose() {
if (filesStore.hasUnsavedChanges) {
dialogApi.warning({
title: t('files.unsavedChanges'),
positiveText: t('common.ok'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
filesStore.closeEditor()
},
})
} else {
filesStore.closeEditor()
}
}
</script>
<template>
<div class="file-editor">
<div class="editor-header">
<span class="editor-filename">{{ filesStore.editingFile?.path }}</span>
<NSpace>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">
{{ t('files.saveFile') }}
</NButton>
<NButton size="small" @click="handleClose">
{{ t('files.closeEditor') }}
</NButton>
</NSpace>
</div>
<div ref="editorContainer" class="editor-container" />
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-editor {
display: flex;
flex-direction: column;
height: 100%;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid $border-color;
background-color: $bg-card;
}
.editor-filename {
font-size: 13px;
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-container {
flex: 1;
min-height: 0;
}
</style>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore, isImageFile, isMarkdownFile, isTextFile } from '@/stores/hermes/files'
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const emit = defineEmits<{
(e: 'contextmenu-entry', event: MouseEvent, entry: FileEntry): void
}>()
function formatSize(bytes: number): string {
if (bytes === 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatDate(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString()
}
function getFileIcon(entry: FileEntry): string {
if (entry.isDir) return '📁'
const ext = entry.name.split('.').pop()?.toLowerCase() || ''
const iconMap: Record<string, string> = {
yaml: '⚙️', yml: '⚙️', json: '📋', toml: '⚙️',
md: '📝', txt: '📄', log: '📄',
py: '🐍', js: '📜', ts: '📜', vue: '💚',
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
zip: '📦', gz: '📦', tar: '📦',
sh: '⚡', bash: '⚡',
}
return iconMap[ext] || '📄'
}
function handleDoubleClick(entry: FileEntry) {
if (entry.isDir) {
filesStore.navigateTo(entry.path)
} else if (isTextFile(entry.name)) {
filesStore.openEditor(entry.path)
} else if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
filesStore.openPreview(entry)
}
}
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
e.preventDefault()
emit('contextmenu-entry', e, entry)
}
async function handleDownload(entry: FileEntry) {
try {
await downloadFile(entry.path, entry.name)
} catch (err: any) {
message.error(err.message || t('files.backendError'))
}
}
</script>
<template>
<div class="file-list">
<NSpin :show="filesStore.loading">
<NEmpty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
<div v-else class="file-list-items">
<div class="file-list-header">
<div class="file-name sort-header" @click="filesStore.setSort('name')">
{{ t('files.name') }}
<span v-if="filesStore.sortBy === 'name'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-size sort-header" @click="filesStore.setSort('size')">
{{ t('files.size') }}
<span v-if="filesStore.sortBy === 'size'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-date sort-header" @click="filesStore.setSort('modTime')">
{{ t('files.modified') }}
<span v-if="filesStore.sortBy === 'modTime'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-actions-placeholder" />
</div>
<div
v-for="entry in filesStore.sortedEntries"
:key="entry.path"
class="file-list-row"
@dblclick="handleDoubleClick(entry)"
@contextmenu="handleContextMenu($event, entry)"
>
<div class="file-name">
<span class="file-icon">{{ getFileIcon(entry) }}</span>
<span>{{ entry.name }}</span>
</div>
<div class="file-size">{{ entry.isDir ? '—' : formatSize(entry.size) }}</div>
<div class="file-date">{{ formatDate(entry.modTime) }}</div>
<div class="file-actions">
<NButton v-if="isTextFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')"></NButton>
<NButton v-if="!entry.isDir" size="tiny" quaternary @click.stop="handleDownload(entry)" :title="t('files.download')"></NButton>
</div>
</div>
</div>
</NSpin>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-list {
padding: 8px 16px;
}
.file-list-header {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 16px;
font-size: 12px;
font-weight: 500;
color: $text-muted;
border-bottom: 1px solid $border-light;
margin-bottom: 4px;
user-select: none;
}
.sort-header {
cursor: pointer;
&:hover {
color: $text-primary;
}
}
.sort-indicator {
margin-left: 2px;
font-size: 11px;
}
.file-actions-placeholder {
width: 60px;
flex-shrink: 0;
}
.file-list-row {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: $radius-sm;
cursor: pointer;
gap: 16px;
font-size: 13px;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
.file-actions {
opacity: 1;
}
}
}
.file-name {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-icon {
flex-shrink: 0;
}
.file-size {
width: 80px;
text-align: right;
color: $text-secondary;
flex-shrink: 0;
}
.file-date {
width: 160px;
color: $text-secondary;
flex-shrink: 0;
}
.file-actions {
opacity: 0;
transition: opacity $transition-fast;
display: flex;
gap: 4px;
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.file-size, .file-date {
display: none;
}
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import { getFileDownloadUrl } from '@/api/hermes/files'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
const { t } = useI18n()
const filesStore = useFilesStore()
function getImageUrl(): string {
if (!filesStore.previewFile) return ''
return getFileDownloadUrl(filesStore.previewFile.path)
}
</script>
<template>
<div class="file-preview" v-if="filesStore.previewFile">
<div class="preview-header">
<span class="preview-filename">{{ filesStore.previewFile.path }}</span>
<NButton size="small" @click="filesStore.closePreview()">
{{ t('common.cancel') }}
</NButton>
</div>
<div class="preview-content">
<img
v-if="filesStore.previewFile.type === 'image'"
:src="getImageUrl()"
class="preview-image"
:alt="filesStore.previewFile.path"
/>
<div v-else-if="filesStore.previewFile.type === 'markdown'" class="preview-markdown">
<MarkdownRenderer :content="filesStore.previewFile.content || ''" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-preview {
display: flex;
flex-direction: column;
height: 100%;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid $border-color;
}
.preview-filename {
font-size: 13px;
color: $text-secondary;
}
.preview-content {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
justify-content: center;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-markdown {
max-width: 800px;
width: 100%;
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NModal, NInput, NButton, NSpace, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import type { FileEntry } from '@/api/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const props = defineProps<{
show: boolean
mode: 'newFile' | 'newFolder' | 'rename'
entry?: FileEntry | null
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
}>()
const inputValue = ref('')
const submitting = ref(false)
watch(() => props.show, (val) => {
if (val) {
if (props.mode === 'rename' && props.entry) {
inputValue.value = props.entry.name
} else {
inputValue.value = ''
}
}
})
const title = computed(() => {
switch (props.mode) {
case 'newFile': return t('files.newFile')
case 'newFolder': return t('files.newFolder')
case 'rename': return t('files.rename')
}
})
const placeholder = computed(() => {
switch (props.mode) {
case 'newFile': return t('files.newFileName')
case 'newFolder': return t('files.newFolderName')
case 'rename': return t('files.renameTo')
}
})
async function handleSubmit() {
if (!inputValue.value.trim()) return
submitting.value = true
try {
switch (props.mode) {
case 'newFile':
await filesStore.createFile(inputValue.value.trim())
message.success(t('files.created'))
break
case 'newFolder':
await filesStore.createDir(inputValue.value.trim())
message.success(t('files.created'))
break
case 'rename':
if (props.entry) {
await filesStore.renameEntry(props.entry, inputValue.value.trim())
message.success(t('files.renamed'))
}
break
}
emit('update:show', false)
} catch (err: any) {
const msg = props.mode === 'rename' ? t('files.renameFailed') : t('files.createFailed')
message.error(err.message || msg)
} finally {
submitting.value = false
}
}
</script>
<template>
<NModal :show="props.show" preset="dialog" :title="title" @update:show="emit('update:show', false)" style="width: 400px;">
<NInput
v-model:value="inputValue"
:placeholder="placeholder"
@keydown.enter="handleSubmit"
autofocus
/>
<template #action>
<NSpace>
<NButton @click="emit('update:show', false)">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="submitting" :disabled="!inputValue.trim()" @click="handleSubmit">
{{ t('common.ok') }}
</NButton>
</NSpace>
</template>
</NModal>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { NButton, NSpace, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const emit = defineEmits<{
(e: 'showNewFile'): void
(e: 'showNewFolder'): void
(e: 'showUpload'): void
}>()
async function handleRefresh() {
try {
await filesStore.fetchEntries()
} catch {
message.error(t('files.backendError'))
}
}
</script>
<template>
<div class="file-toolbar">
<NSpace>
<NButton size="small" @click="emit('showNewFile')">
{{ t('files.newFile') }}
</NButton>
<NButton size="small" @click="emit('showNewFolder')">
{{ t('files.newFolder') }}
</NButton>
<NButton size="small" @click="emit('showUpload')">
{{ t('files.upload') }}
</NButton>
<NButton size="small" @click="handleRefresh">
{{ t('files.refresh') }}
</NButton>
</NSpace>
</div>
</template>
<style scoped lang="scss">
.file-toolbar {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NTree } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import * as filesApi from '@/api/hermes/files'
import type { TreeOption } from 'naive-ui'
const { t } = useI18n()
const filesStore = useFilesStore()
const treeData = ref<TreeOption[]>([])
const selectedKeys = ref<string[]>([])
async function loadChildren(path: string): Promise<TreeOption[]> {
try {
const result = await filesApi.listFiles(path)
return result.entries
.filter(e => e.isDir)
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => ({
key: e.path,
label: e.name,
isLeaf: false,
}))
} catch {
return []
}
}
async function handleLoad(node: TreeOption): Promise<void> {
node.children = await loadChildren(node.key as string)
}
function handleSelect(keys: string[]) {
if (keys.length > 0) {
selectedKeys.value = keys
filesStore.navigateTo(keys[0])
}
}
function handleRootClick() {
selectedKeys.value = []
filesStore.navigateTo('')
}
onMounted(async () => {
treeData.value = await loadChildren('')
})
</script>
<template>
<div class="file-tree">
<div class="tree-header" @click="handleRootClick">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>{{ t('files.breadcrumbRoot') }}</span>
</div>
<NTree
:data="treeData"
:selected-keys="selectedKeys"
:on-load="handleLoad"
expand-on-click
block-line
@update:selected-keys="handleSelect"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-tree {
padding: 8px;
}
.tree-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: $radius-sm;
font-size: 13px;
font-weight: 500;
color: $text-primary;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
}
}
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NButton, NUpload, NSpace, useMessage } from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ (e: 'update:show', value: boolean): void }>()
const uploading = ref(false)
const fileList = ref<File[]>([])
function handleFileChange(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
fileList.value = data.fileList
.map((f: UploadFileInfo) => f.file)
.filter((f): f is File => f != null)
}
async function handleUpload() {
if (fileList.value.length === 0) return
uploading.value = true
try {
await filesStore.uploadFiles(fileList.value)
message.success(t('files.uploadSuccess', { count: fileList.value.length }))
fileList.value = []
emit('update:show', false)
} catch (err: any) {
message.error(err.message || t('files.uploadFailed'))
} finally {
uploading.value = false
}
}
function handleClose() {
fileList.value = []
emit('update:show', false)
}
</script>
<template>
<NModal :show="props.show" preset="dialog" :title="t('files.upload')" @update:show="handleClose" style="width: 500px;">
<NUpload
multiple
directory-dnd
:default-upload="false"
@change="handleFileChange"
>
<div class="upload-dragger">
<p>{{ t('files.dragDropHint') }}</p>
</div>
</NUpload>
<template #action>
<NSpace>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleUpload">
{{ t('files.upload') }} ({{ fileList.length }})
</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped>
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
cursor: pointer;
}
.upload-dragger p {
margin-top: 12px;
opacity: 0.6;
font-size: 14px;
}
</style>
@@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue'
import { NModal, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startCodexLogin, pollCodexLogin } from '@/api/hermes/codex-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
@@ -85,9 +86,10 @@ function handleClose() {
setTimeout(() => emit('close'), 200)
}
function copyCode() {
navigator.clipboard.writeText(userCode.value)
message.success(t('models.codexCopyCode'))
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.codexCopyCode'))
else message.error(t('models.codexCopyCode') + ' ✗')
}
function openLink() {
@@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startNousLogin, pollNousLogin } from '@/api/hermes/nous-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
@@ -87,9 +88,10 @@ function handleClose() {
setTimeout(() => emit('close'), 200)
}
function copyCode() {
navigator.clipboard.writeText(userCode.value)
message.success(t('models.nousCopyCode'))
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.nousCopyCode'))
else message.error(t('models.nousCopyCode') + ' ✗')
}
function openLink() {
@@ -190,6 +190,12 @@ function openChangelog() {
</svg>
<span>{{ t("sidebar.terminal") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.files' }" @click="handleNav('hermes.files')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span>{{ t("sidebar.files") }}</span>
</button>
</div>
</div>