feat(chat): redesign attachments with ContentBlock format and file downloads (#397)

- Redesign attachment handling using Anthropic-style ContentBlock array format
  with discriminated unions (text, image, file types)
- Add frontend file download functionality supporting both ContentBlock
  and Markdown formats with authentication tokens
- Fix multi-process conflict causing SQLite database resets by eliminating
  redundant nodemon instances
- Update chat store to build ContentBlock arrays from attachments
- Improve image handling with base64 conversion for upstream API

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-02 15:39:01 +08:00
committed by GitHub
parent caa9162f28
commit 793003fcf6
14 changed files with 331 additions and 30 deletions
+57 -7
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, type RunEvent } from '@/api/hermes/chat'
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getApiKey } from '@/api/client'
import { defineStore } from 'pinia'
@@ -7,6 +7,9 @@ import { useAppStore } from './app'
import { useProfilesStore } from './profiles'
import { detectThinkingBoundary } from '@/utils/thinking-parser'
// Re-export ContentBlock for convenience
export type ContentBlock = ContentBlockImport
export interface Attachment {
id: string
name: string
@@ -74,6 +77,47 @@ async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; p
return data.files
}
async function buildContentBlocks(
content: string,
attachments?: Attachment[],
uploadedFiles?: { name: string; path: string }[]
): Promise<ContentBlock[]> {
const blocks: ContentBlock[] = []
// Add text block if content is not empty
if (content.trim()) {
blocks.push({ type: 'text', text: content.trim() })
}
// Add attachment blocks using uploaded file paths
if (attachments && attachments.length > 0 && uploadedFiles) {
for (let i = 0; i < uploadedFiles.length; i++) {
const uploaded = uploadedFiles[i]
const attachment = attachments[i]
// Check if it's an image
if (attachment?.type.startsWith('image/')) {
blocks.push({
type: 'image',
name: uploaded.name,
path: uploaded.path,
media_type: attachment.type,
})
} else {
// Other files
blocks.push({
type: 'file',
name: uploaded.name,
path: uploaded.path,
media_type: attachment?.type,
})
}
}
}
return blocks
}
function mapHermesMessages(msgs: HermesMessage[]): Message[] {
// Build lookups from assistant messages with tool_calls
const toolNameMap = new Map<string, string>()
@@ -608,11 +652,13 @@ export const useChatStore = defineStore('chat', () => {
try {
// Upload attachments and build input with file paths
let inputText = content.trim()
// Build input in Anthropic format
let input: string | ContentBlock[]
if (attachments && attachments.length > 0) {
// Has attachments: upload first, then build content blocks
const uploaded = await uploadFiles(attachments)
// Replace blob URLs with persistent download URLs on the user message
// Update attachment URLs on the user message for display
const token = getApiKey()
const urlMap = new Map(uploaded.map(f => {
const base = `/api/hermes/download?path=${encodeURIComponent(f.path)}&name=${encodeURIComponent(f.name)}`
@@ -626,14 +672,18 @@ export const useChatStore = defineStore('chat', () => {
return dl ? { ...a, url: dl } : a
})
}
const pathParts = uploaded.map(f => `[File: ${f.name}](${urlMap.get(f.name)})`)
inputText = inputText ? inputText + '\n\n' + pathParts.join('\n') : pathParts.join('\n')
// Build content blocks with uploaded file paths
input = await buildContentBlocks(content, attachments, uploaded)
} else {
// No attachments: use plain text format
input = content.trim()
}
const appStore = useAppStore()
const sessionModel = activeSession.value?.model || appStore.selectedModel
const runPayload = {
input: inputText,
input,
session_id: sid,
model: sessionModel || undefined,
}