+ {/* Drag overlay */}
+ {isDragging && (
+
+ Drop files here
+
+ )}
+
{/* Quote cards */}
{quotes.length > 0 && (
@@ -109,15 +257,48 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: {
)}
+ {/* Attachment previews */}
+ {attachments.length > 0 && (
+
+
+ {attachments.map(a => (
+
removeAttachment(a.id)} />
+ ))}
+
+
+ )}
+
{/* Main input */}
+ {/* File attach button */}
+
+
{
+ const files = Array.from(e.target.files || []);
+ if (files.length > 0) addFiles(files);
+ e.target.value = '';
+ }}
+ />
+
diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts
index 9b82d884..c58a4f97 100644
--- a/web/src/stores/chatStore.ts
+++ b/web/src/stores/chatStore.ts
@@ -428,15 +428,22 @@ export const useChatStore = create
((set, get) => ({
set({ searchQuery: '', searchResults: null, searchLoading: false });
},
- sendMessage: (content: string) => {
+ sendMessage: (content: string, fileIds?: string[], imageBlocks?: Array<{ url: string; filename: string; media_type: string }>) => {
const session = get().activeSession;
+ const blocks: import('../types/chat').MessageBlock[] = [];
+ if (content) blocks.push({ type: 'text', content });
+ if (imageBlocks) {
+ for (const img of imageBlocks) {
+ blocks.push({ type: 'image', url: img.url, filename: img.filename, media_type: img.media_type });
+ }
+ }
set((state) => ({
- messages: [...state.messages, { role: 'user', blocks: [{ type: 'text', content }] }],
+ messages: [...state.messages, { role: 'user', blocks }],
streamingBlocks: [],
isStreaming: true,
agentStatus: { state: 'thinking' },
}));
- ws.sendMessage(content, session);
+ ws.sendMessage(content, session, fileIds);
},
stopSession: () => {
diff --git a/web/src/types/chat.ts b/web/src/types/chat.ts
index 605876bf..fd223423 100644
--- a/web/src/types/chat.ts
+++ b/web/src/types/chat.ts
@@ -20,7 +20,21 @@ export interface ToolCallBlockData {
hoaEvents?: Record[];
}
-export type MessageBlock = ThinkingBlockData | TextBlockData | ToolCallBlockData;
+export interface ImageBlockData {
+ type: 'image';
+ url: string;
+ filename: string;
+ media_type: string;
+}
+
+export interface FileBlockData {
+ type: 'file';
+ url: string;
+ filename: string;
+ size?: number;
+}
+
+export type MessageBlock = ThinkingBlockData | TextBlockData | ToolCallBlockData | ImageBlockData | FileBlockData;
export interface ChatMessage {
id?: number;
diff --git a/web/src/utils/hydrateMessage.ts b/web/src/utils/hydrateMessage.ts
index b5d1aa82..0714bbe3 100644
--- a/web/src/utils/hydrateMessage.ts
+++ b/web/src/utils/hydrateMessage.ts
@@ -2,10 +2,21 @@ import type { ChatMessage, MessageBlock } from '../types/chat';
export function hydrateMessage(raw: any): ChatMessage {
if (raw.role === 'user') {
+ const userBlocks: MessageBlock[] = [{ type: 'text', content: raw.content || '' }];
+ // Restore image/file blocks from DB (uploaded files)
+ if (raw.blocks && Array.isArray(raw.blocks)) {
+ for (const b of raw.blocks) {
+ if (b.type === 'image') {
+ userBlocks.push({ type: 'image', url: b.url || '', filename: b.filename || '', media_type: b.media_type || '' });
+ } else if (b.type === 'file') {
+ userBlocks.push({ type: 'file', url: b.url || '', filename: b.filename || '', size: b.size });
+ }
+ }
+ }
return {
id: raw.id,
role: 'user',
- blocks: [{ type: 'text', content: raw.content || '' }],
+ blocks: userBlocks,
channel: raw.channel,
created_at: raw.created_at,
};
@@ -28,6 +39,12 @@ export function hydrateMessage(raw: any): ChatMessage {
status: 'complete' as const,
};
}
+ if (b.type === 'image') {
+ return { type: 'image' as const, url: b.url || '', filename: b.filename || '', media_type: b.media_type || '' };
+ }
+ if (b.type === 'file') {
+ return { type: 'file' as const, url: b.url || '', filename: b.filename || '', size: b.size };
+ }
// Default: text
return { type: 'text' as const, content: b.content || '' };
});