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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user