+
+
+
+
+
+
+
+
+
{{ att.name }}
+
{{ formatSize(att.size) }}
+
+
+
+
@@ -123,6 +154,53 @@ const timeStr = computed(() => {
word-break: break-word;
}
+.msg-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.msg-attachment {
+ border-radius: $radius-sm;
+ overflow: hidden;
+ background-color: rgba(0, 0, 0, 0.04);
+ border: 1px solid $border-light;
+
+ &.image {
+ max-width: 200px;
+ }
+}
+
+.msg-attachment-thumb {
+ display: block;
+ max-width: 200px;
+ max-height: 160px;
+ object-fit: contain;
+}
+
+.msg-attachment-file {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: $text-secondary;
+
+ .att-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 160px;
+ }
+
+ .att-size {
+ color: $text-muted;
+ font-size: 11px;
+ flex-shrink: 0;
+ }
+}
+
.message-time {
font-size: 11px;
color: $text-muted;
diff --git a/src/stores/chat.ts b/src/stores/chat.ts
index 067ecb1..de41038 100644
--- a/src/stores/chat.ts
+++ b/src/stores/chat.ts
@@ -3,6 +3,15 @@ import { ref } from 'vue'
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
import { useAppStore } from './app'
+export interface Attachment {
+ id: string
+ name: string
+ type: string
+ size: number
+ url: string
+ file?: File
+}
+
export interface Message {
id: string
role: 'user' | 'assistant' | 'system' | 'tool'
@@ -12,6 +21,7 @@ export interface Message {
toolPreview?: string
toolStatus?: 'running' | 'done' | 'error'
isStreaming?: boolean
+ attachments?: Attachment[]
}
interface Session {
@@ -26,6 +36,18 @@ function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
+async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
+ if (attachments.length === 0) return []
+ const formData = new FormData()
+ for (const att of attachments) {
+ if (att.file) formData.append('file', att.file, att.name)
+ }
+ const res = await fetch('/__upload', { method: 'POST', body: formData })
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
+ const data = await res.json() as { files: { name: string; path: string }[] }
+ return data.files
+}
+
const SESSIONS_KEY = 'hermes_chat_sessions'
const ACTIVE_SESSION_KEY = 'hermes_active_session'
@@ -97,15 +119,25 @@ export const useChatStore = defineStore('chat', () => {
}
}
+ function stripNonSerializable(msgs: Message[]): Message[] {
+ return msgs.map(m => ({
+ ...m,
+ attachments: m.attachments?.map(a => ({ ...a, file: undefined, url: '' })),
+ }))
+ }
+
function persistMessages() {
if (!activeSession.value || !appStore.sessionPersistence) return
- activeSession.value.messages = [...messages.value]
+ activeSession.value.messages = stripNonSerializable(messages.value)
activeSession.value.updatedAt = Date.now()
if (activeSession.value.title === 'New Chat') {
const firstUser = messages.value.find(m => m.role === 'user')
if (firstUser) {
- activeSession.value.title = firstUser.content.slice(0, 40) + (firstUser.content.length > 40 ? '...' : '')
+ const title = firstUser.attachments?.length
+ ? firstUser.attachments.map(a => a.name).join(', ')
+ : firstUser.content
+ activeSession.value.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
}
}
@@ -125,8 +157,8 @@ export const useChatStore = defineStore('chat', () => {
}
}
- async function sendMessage(content: string) {
- if (!content.trim() || isStreaming.value) return
+ async function sendMessage(content: string, attachments?: Attachment[]) {
+ if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
if (!activeSession.value) {
const session = createSession()
@@ -138,6 +170,7 @@ export const useChatStore = defineStore('chat', () => {
role: 'user',
content: content.trim(),
timestamp: Date.now(),
+ attachments: attachments && attachments.length > 0 ? attachments : undefined,
}
addMessage(userMsg)
persistMessages()
@@ -150,8 +183,16 @@ export const useChatStore = defineStore('chat', () => {
.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
+ // Upload attachments and build input with file paths
+ let inputText = content.trim()
+ if (attachments && attachments.length > 0) {
+ const uploaded = await uploadFiles(attachments)
+ const pathParts = uploaded.map(f => `[File: ${f.name}](${f.path})`)
+ inputText = inputText ? inputText + '\n\n' + pathParts.join('\n') : pathParts.join('\n')
+ }
+
const run = await startRun({
- input: content.trim(),
+ input: inputText,
conversation_history: history,
session_id: activeSession.value?.id,
})
diff --git a/vite.config.ts b/vite.config.ts
index 5bbc814..a31c42e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,10 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import type { ProxyOptions } from 'vite'
import { resolve } from 'path'
+import type { IncomingMessage, ServerResponse } from 'http'
+import { mkdir, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { randomBytes } from 'crypto'
function createProxyConfig(): ProxyOptions {
return {
@@ -21,8 +25,74 @@ function createProxyConfig(): ProxyOptions {
}
}
+const UPLOAD_DIR = resolve(tmpdir(), 'hermes-uploads')
+
+async function handleUpload(req: IncomingMessage, res: ServerResponse) {
+ if (req.method !== 'POST') {
+ res.writeHead(405, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: 'Method not allowed' }))
+ return
+ }
+
+ const contentType = req.headers['content-type'] || ''
+ if (!contentType.startsWith('multipart/form-data')) {
+ res.writeHead(400, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: 'Expected multipart/form-data' }))
+ return
+ }
+
+ try {
+ await mkdir(UPLOAD_DIR, { recursive: true })
+ const chunks: Buffer[] = []
+ for await (const chunk of req) chunks.push(chunk)
+ const body = Buffer.concat(chunks).toString()
+
+ const boundary = '--' + contentType.split('boundary=')[1]
+ const parts = body.split(boundary).slice(1, -1)
+
+ const results: { name: string; path: string }[] = []
+ for (const part of parts) {
+ const headerEnd = part.indexOf('\r\n\r\n')
+ if (headerEnd === -1) continue
+ const header = part.substring(0, headerEnd)
+ const data = part.substring(headerEnd + 4, part.length - 2)
+
+ const filenameMatch = header.match(/filename="([^"]+)"/)
+ if (!filenameMatch) continue
+
+ const filename = filenameMatch[1]
+ const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
+ const savedName = randomBytes(8).toString('hex') + ext
+ const savedPath = resolve(UPLOAD_DIR, savedName)
+
+ await writeFile(savedPath, Buffer.from(data, 'binary'))
+ results.push({ name: filename, path: savedPath })
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ files: results }))
+ } catch (err: any) {
+ res.writeHead(500, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: err.message }))
+ }
+}
+
export default defineConfig({
- plugins: [vue()],
+ plugins: [
+ vue(),
+ {
+ name: 'upload-middleware',
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ if (req.url?.startsWith('/__upload')) {
+ handleUpload(req as any, res as any)
+ } else {
+ next()
+ }
+ })
+ },
+ },
+ ],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),