diff --git a/packages/core/src/services/message-types.ts b/packages/core/src/services/message-types.ts index 93198b9a..165e7b06 100644 --- a/packages/core/src/services/message-types.ts +++ b/packages/core/src/services/message-types.ts @@ -23,6 +23,7 @@ export interface NormalizedAttachment { readonly mimeType?: string; readonly filename?: string; readonly size?: number; + readonly path?: string; } export interface NormalizedToolCall { diff --git a/packages/gateway/src/config/defaults.ts b/packages/gateway/src/config/defaults.ts index c53922e3..3d3c48f5 100644 --- a/packages/gateway/src/config/defaults.ts +++ b/packages/gateway/src/config/defaults.ts @@ -318,8 +318,8 @@ export const HSTS_MAX_AGE_PRELOAD = 63_072_000; /** HSTS max-age without preload (1 year, seconds) */ export const HSTS_MAX_AGE = 31_536_000; -/** Default HTTP request body size limit (bytes) — 1 MB */ -export const DEFAULT_BODY_LIMIT_BYTES = 1_048_576; +/** Default HTTP request body size limit (bytes) — 10 MB */ +export const DEFAULT_BODY_LIMIT_BYTES = 10 * 1024 * 1024; /** HTTP 413 Payload Too Large status code */ export const HTTP_PAYLOAD_TOO_LARGE = 413; diff --git a/packages/gateway/src/db/repositories/chat.ts b/packages/gateway/src/db/repositories/chat.ts index b7452b39..3df79b40 100644 --- a/packages/gateway/src/db/repositories/chat.ts +++ b/packages/gateway/src/db/repositories/chat.ts @@ -409,7 +409,17 @@ export class ChatRepository extends BaseRepository { input.isError || false, input.inputTokens || null, input.outputTokens || null, - input.attachments?.length ? JSON.stringify(input.attachments) : null, + input.attachments?.length + ? JSON.stringify( + input.attachments.map((a) => ({ + type: a.type, + mimeType: a.mimeType, + filename: a.filename, + size: a.size, + path: a.path, + })) + ) + : null, now, ] ); diff --git a/packages/gateway/src/middleware/auth.ts b/packages/gateway/src/middleware/auth.ts index 6310afeb..3d578c66 100644 --- a/packages/gateway/src/middleware/auth.ts +++ b/packages/gateway/src/middleware/auth.ts @@ -50,13 +50,14 @@ export function createAuthMiddleware(config: AuthConfig) { return next(); } - const authHeader = c.req.header('Authorization'); + const authHeader = c.req.header('Authorization'); + const queryToken = c.req.query('token'); - if (config.type === 'api-key') { - // Check for API key in header - const apiKey = authHeader?.startsWith('Bearer ') - ? authHeader.slice(7) - : c.req.header('X-API-Key'); + if (config.type === 'api-key') { + // Check for API key in header or query + const apiKey = authHeader?.startsWith('Bearer ') + ? authHeader.slice(7) + : c.req.header('X-API-Key') || queryToken; if (!apiKey) { return apiError(c, { code: ERROR_CODES.UNAUTHORIZED, message: 'API key required' }, 401); @@ -81,11 +82,11 @@ export function createAuthMiddleware(config: AuthConfig) { ); } - if (!authHeader?.startsWith('Bearer ')) { + if (!authHeader?.startsWith('Bearer ') && !queryToken) { return apiError(c, { code: ERROR_CODES.UNAUTHORIZED, message: 'JWT token required' }, 401); } - const token = authHeader.slice(7); + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : queryToken!; try { const payload = await validateJWT(token, config.jwtSecret); diff --git a/packages/gateway/src/middleware/ui-session.ts b/packages/gateway/src/middleware/ui-session.ts index 3b5e1e25..1338cf5f 100644 --- a/packages/gateway/src/middleware/ui-session.ts +++ b/packages/gateway/src/middleware/ui-session.ts @@ -28,8 +28,8 @@ export const uiSessionMiddleware = createMiddleware(async (c, next) => { return next(); } - // 2. Check for session token - const token = c.req.header('X-Session-Token'); + // 2. Check for session token (header or query param for images) + const token = c.req.header('X-Session-Token') || c.req.query('token'); if (token && validateSession(token)) { c.set('sessionAuthenticated', true); c.set('userId', 'default'); diff --git a/packages/gateway/src/middleware/validation.ts b/packages/gateway/src/middleware/validation.ts index 2b728718..0df036a4 100644 --- a/packages/gateway/src/middleware/validation.ts +++ b/packages/gateway/src/middleware/validation.ts @@ -52,9 +52,11 @@ export const chatMessageSchema = z.object({ .array( z.object({ type: z.enum(['image', 'file']), - data: z.string().max(20_000_000), + data: z.string().max(20_000_000).optional(), + path: z.string().max(2000).optional(), mimeType: z.string().max(100), filename: z.string().max(255).optional(), + size: z.number().int().min(0).optional(), }) ) .max(5) diff --git a/packages/gateway/src/routes/chat-history.ts b/packages/gateway/src/routes/chat-history.ts index 52fbfe6d..a32103ed 100644 --- a/packages/gateway/src/routes/chat-history.ts +++ b/packages/gateway/src/routes/chat-history.ts @@ -44,6 +44,21 @@ import type { ChannelIncomingMessage } from '@ownpilot/core'; const log = getLog('ChatHistory'); +function normalizeTrace(trace: any): any { + if (!trace) return trace; + return { + ...trace, + modelCalls: trace.modelCalls || [], + autonomyChecks: trace.autonomyChecks || [], + dbOperations: trace.dbOperations || { reads: 0, writes: 0 }, + memoryOps: trace.memoryOps || { adds: 0, recalls: 0 }, + triggersFired: trace.triggersFired || [], + errors: trace.errors || [], + events: trace.events || [], + toolCalls: trace.toolCalls || [], + }; +} + export const chatHistoryRoutes = new Hono(); // ===================================================== @@ -253,8 +268,9 @@ chatHistoryRoutes.get('/history/:id', async (c) => { content: msg.role === 'assistant' ? stripInternalTags(msg.content) : msg.content, provider: msg.provider, model: msg.model, + attachments: msg.attachments, toolCalls: msg.toolCalls, - trace: msg.trace, + trace: normalizeTrace(msg.trace), isError: msg.isError, createdAt: msg.createdAt.toISOString(), })), @@ -315,11 +331,12 @@ chatHistoryRoutes.get('/history/:id/unified', async (c) => { provider: msg.provider, model: msg.model, toolCalls: msg.toolCalls, - trace: msg.trace, + trace: normalizeTrace(msg.trace), isError: msg.isError, createdAt: msg.createdAt.toISOString(), source: 'web' as const, direction: msg.role === 'user' ? 'inbound' : 'outbound', + attachments: msg.attachments ?? undefined, })), }); } @@ -356,6 +373,7 @@ chatHistoryRoutes.get('/history/:id/unified', async (c) => { direction: 'inbound' | 'outbound'; senderName?: string; senderId?: string; + attachments?: any[]; }; const unified: UnifiedMessage[] = []; @@ -371,6 +389,7 @@ chatHistoryRoutes.get('/history/:id/unified', async (c) => { direction: cm.direction, senderName: cm.senderName, senderId: cm.senderId, + attachments: cm.attachments ?? undefined, }); } @@ -400,11 +419,12 @@ chatHistoryRoutes.get('/history/:id/unified', async (c) => { provider: msg.provider, model: msg.model, toolCalls: msg.toolCalls, - trace: msg.trace, + trace: normalizeTrace(msg.trace), isError: msg.isError, createdAt: msg.createdAt.toISOString(), source: 'ai', direction: msg.role === 'user' ? 'inbound' : 'outbound', + attachments: msg.attachments ?? undefined, }); } } diff --git a/packages/gateway/src/routes/chat-legacy-send.ts b/packages/gateway/src/routes/chat-legacy-send.ts index 8b58877f..cd9f6639 100644 --- a/packages/gateway/src/routes/chat-legacy-send.ts +++ b/packages/gateway/src/routes/chat-legacy-send.ts @@ -419,6 +419,7 @@ export async function handleLegacySend(params: LegacySendParams): Promise; + attachments?: Array<{ + type: 'image' | 'file'; + data?: string; + path?: string; + mimeType: string; + filename?: string; + size?: number; + }>; thinking?: { type: 'enabled' | 'adaptive'; budgetTokens?: number; @@ -640,6 +648,9 @@ export async function processStreamingViaBus( contextWindowOverride, }); + // Hydrate attachment data from disk for the LLM if only path is provided + hydrateAttachments(userId, body.attachments); + // Normalize into NormalizedMessage const normalized: NormalizedMessage = { id: crypto.randomUUID(), @@ -650,8 +661,10 @@ export async function processStreamingViaBus( attachments: body.attachments.map((a) => ({ type: a.type as 'image' | 'file', data: a.data, + path: a.path, mimeType: a.mimeType, filename: a.filename, + size: a.size, })), }), metadata: { @@ -722,6 +735,7 @@ export async function processStreamingViaBus( toolCalls, finishReason: result.response.metadata.finishReason as string | undefined, historyLength: body.historyLength, + attachments: body.attachments, }); // Post-processing middleware skips web UI memory extraction. diff --git a/packages/gateway/src/routes/chat.ts b/packages/gateway/src/routes/chat.ts index 16c72c12..7c54761a 100644 --- a/packages/gateway/src/routes/chat.ts +++ b/packages/gateway/src/routes/chat.ts @@ -13,6 +13,10 @@ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; +import { writeFileSync, mkdirSync, existsSync, createReadStream } from 'node:fs'; +import { join, extname } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { getDataDirectoryInfo } from '../paths/index.js'; import type { ChatRequest } from '../types/index.js'; import { apiResponse, @@ -23,6 +27,7 @@ import { getErrorMessage, parseJsonBody, truncate, + hydrateAttachments, } from './helpers.js'; import { wsGateway } from '../ws/server.js'; import { @@ -108,6 +113,135 @@ chatRoutes.route('/', chatHistoryRoutes); import { chatFetchUrlRoutes } from './chat-fetch-url.js'; chatRoutes.route('/', chatFetchUrlRoutes); +// ============================================================================= +// Attachments +// ============================================================================= + +/** + * POST /chat/upload-attachment - Upload a file for chat + */ +chatRoutes.post('/upload-attachment', async (c) => { + const userId = getUserId(c); + + try { + const body = await c.req.parseBody(); + const file = body['file']; + + if (!file || typeof file === 'string') { + return apiError(c, { code: ERROR_CODES.VALIDATION_ERROR, message: 'file field is required' }, 400); + } + + const uploadedFile = file as File; + const originalName = uploadedFile.name || 'unknown'; + const ext = extname(originalName).toLowerCase(); + + // Limit size to 10MB + if (uploadedFile.size > 10 * 1024 * 1024) { + return apiError(c, { code: ERROR_CODES.VALIDATION_ERROR, message: 'File too large. Maximum: 10MB' }, 400); + } + + const dataInfo = getDataDirectoryInfo(); + const attachmentsDir = join(dataInfo.root, 'attachments', userId); + + if (!existsSync(attachmentsDir)) { + mkdirSync(attachmentsDir, { recursive: true }); + } + + // Generate unique filename + const baseName = originalName.slice(0, -ext.length || undefined).replace(/[^a-zA-Z0-9-]/g, '_'); + const suffix = randomBytes(4).toString('hex'); + const filename = `${baseName}-${suffix}${ext}`; + + const destPath = join(attachmentsDir, filename); + const fileBuffer = Buffer.from(await uploadedFile.arrayBuffer()); + writeFileSync(destPath, fileBuffer); + + const relativePath = `attachments/${filename}`; + + return apiResponse(c, { + url: `/api/v1/chat/${relativePath}`, + path: relativePath, + filename: originalName, + mimeType: uploadedFile.type, + size: uploadedFile.size, + }, 201); + } catch (error) { + return apiError(c, { + code: ERROR_CODES.CREATE_FAILED, + message: getErrorMessage(error, 'Failed to upload attachment'), + }, 500); + } +}); + +/** + * Secure attachment serving route. + * Serves files only from the authenticated user's attachment directory. + * Prevents directory traversal and ensures strict isolation. + */ +chatRoutes.get('/attachments/:path{.+}', async (c) => { + const path = c.req.param('path'); + const userId = getUserId(c); + + // Security check: prevent directory traversal + if (path.includes('..') || path.startsWith('/') || path.includes('\\')) { + return apiError(c, { code: ERROR_CODES.FORBIDDEN, message: 'Invalid path' }, 403); + } + + const dataInfo = getDataDirectoryInfo(); + // Files are stored in: attachments/:userId/:filename + // The route param 'path' corresponds to the filename (or nested subpath if allowed) + const filePath = join(dataInfo.root, 'attachments', userId, path); + + if (!existsSync(filePath)) { + return notFoundError(c, 'Attachment', path); + } + + try { + const mimeType = getMimeType(path); + const stream = createReadStream(filePath); + + return c.body(stream as any, 200, { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Disposition': `inline; filename="${path}"`, + }); + } catch (error) { + return apiError( + c, + { code: ERROR_CODES.INTERNAL_ERROR, message: `Failed to serve attachment: ${getErrorMessage(error)}` }, + 500 + ); + } +}); + +function getMimeType(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const types: Record = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + 'pdf': 'application/pdf', + 'txt': 'text/plain', + 'md': 'text/markdown', + 'json': 'application/json', + 'csv': 'text/csv', + 'xml': 'application/xml', + 'zip': 'application/zip', + 'js': 'application/javascript', + 'ts': 'application/x-typescript', + 'css': 'text/css', + 'html': 'text/html', + 'mp3': 'audio/mpeg', + 'wav': 'audio/wav', + 'mp4': 'video/mp4', + }; + return types[ext] || 'application/octet-stream'; +} + + /** * Process a non-streaming chat message through the MessageBus pipeline. */ @@ -135,11 +269,13 @@ async function processNonStreamingViaBus( content: chatMessage, ...(body.attachments?.length && { attachments: body.attachments.map( - (a: { type: string; data: string; mimeType: string; filename?: string }) => ({ + (a: { type: string; data?: string; path?: string; mimeType: string; filename?: string; size?: number }) => ({ type: a.type as 'image' | 'file', data: a.data, + path: a.path, mimeType: a.mimeType, filename: a.filename, + size: a.size, }) ), }), @@ -181,6 +317,9 @@ chatRoutes.post('/', async (c) => { workspaceId?: string; }; + // Hydrate attachment data from disk for the LLM if only path is provided + hydrateAttachments(getUserId(c), body.attachments); + // Resolve provider and model: explicit request body > per-process routing > global default let provider: string; let model: string; @@ -300,9 +439,23 @@ chatRoutes.post('/', async (c) => { { restoredFromDb: true, restoredAt: new Date().toISOString() } ); // Replay messages from DB into agent memory + const chatUserId = getUserId(c); for (const msg of dbData.messages) { if (msg.role === 'user') { - agent.getMemory().addUserMessage(dbData.conversation.id, msg.content); + if (msg.attachments?.length) { + hydrateAttachments(chatUserId, msg.attachments as any); + const content = [ + { type: 'text', text: msg.content }, + ...msg.attachments.map((a) => ({ + type: 'image' as const, + data: (a as any).data || '', + mediaType: (a.mimeType || 'image/png') as any, + })), + ]; + agent.getMemory().addUserMessage(dbData.conversation.id, content as any); + } else { + agent.getMemory().addUserMessage(dbData.conversation.id, msg.content); + } } else if (msg.role === 'assistant') { agent.getMemory().addAssistantMessage(dbData.conversation.id, msg.content); } @@ -442,6 +595,15 @@ chatRoutes.post('/', async (c) => { conversationId: earlyConv.id, role: 'user', content: chatMessage, + ...(body.attachments?.length && { + attachments: body.attachments.map(a => ({ + type: a.type, + mimeType: a.mimeType, + filename: a.filename, + size: a.size, + path: a.path + })) + }), }); wsGateway.broadcast('chat:history:updated', { conversationId: earlyConv.id, diff --git a/packages/gateway/src/routes/helpers.ts b/packages/gateway/src/routes/helpers.ts index 5554fb3b..b6aa13ca 100644 --- a/packages/gateway/src/routes/helpers.ts +++ b/packages/gateway/src/routes/helpers.ts @@ -5,10 +5,13 @@ */ import { createHash, timingSafeEqual } from 'node:crypto'; +import { join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; import type { Context } from 'hono'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; import type { ApiResponse } from '../types/index.js'; import { ERROR_CODES, type ErrorCode } from './error-codes.js'; +import { getDataDirectoryInfo } from '../paths/index.js'; // Re-export error codes for convenience export { ERROR_CODES, type ErrorCode }; @@ -408,3 +411,39 @@ export async function parseJsonBodySafe(c: Context): Promise +): void { + if (!attachments?.length) return; + + const dataInfo = getDataDirectoryInfo(); + for (const att of attachments) { + if (!att.data && att.path) { + // Extract filename from path (e.g., 'attachments/filename.png') + const filename = att.path.replace('attachments/', '').replace(/\\/g, '/').split('/').pop(); + if (filename && !filename.includes('..')) { + const filePath = join(dataInfo.root, 'attachments', userId, filename); + if (existsSync(filePath)) { + try { + const buffer = readFileSync(filePath); + att.data = buffer.toString('base64'); + } catch (e) { + // Log but don't crash + console.error(`[Hydration] Failed to read ${filePath}:`, e); + } + } + } + } + } +} diff --git a/packages/gateway/src/services/conversation-service.ts b/packages/gateway/src/services/conversation-service.ts index d4f97dae..be459bcd 100644 --- a/packages/gateway/src/services/conversation-service.ts +++ b/packages/gateway/src/services/conversation-service.ts @@ -149,7 +149,15 @@ export class ConversationService { content: params.userMessage, provider: params.provider, model: params.model, - ...(params.attachments?.length && { attachments: params.attachments }), + ...(params.attachments?.length && { + attachments: params.attachments.map(a => ({ + type: a.type, + mimeType: a.mimeType, + filename: a.filename, + size: a.size, + path: a.path + })) + }), }); } @@ -235,6 +243,11 @@ export class ConversationService { }, ] : [], + autonomyChecks: [], + dbOperations: { reads: 0, writes: 0 }, + memoryOps: { adds: 0, recalls: 0 }, + triggersFired: [], + errors: [], mcpToolEvents, events: mcpToolEvents.map((event) => ({ type: event.type, diff --git a/packages/gateway/src/services/middleware/persistence.ts b/packages/gateway/src/services/middleware/persistence.ts index f234893e..c3433eee 100644 --- a/packages/gateway/src/services/middleware/persistence.ts +++ b/packages/gateway/src/services/middleware/persistence.ts @@ -83,11 +83,12 @@ export function createPersistenceMiddleware(): MessageMiddleware { (a): a is typeof a & { type: 'image' | 'file' } => a.type === 'image' || a.type === 'file' ) - .map((a) => ({ + .map((a: any) => ({ type: a.type, mimeType: a.mimeType, filename: a.filename, size: a.size, + path: a.path, })), }), }); diff --git a/packages/gateway/src/types/index.ts b/packages/gateway/src/types/index.ts index e9c40432..4062a62f 100644 --- a/packages/gateway/src/types/index.ts +++ b/packages/gateway/src/types/index.ts @@ -57,9 +57,11 @@ export interface ChatRequest { /** File/image attachments (base64 encoded) */ attachments?: Array<{ type: 'image' | 'file'; - data: string; + data?: string; + path?: string; mimeType: string; filename?: string; + size?: number; }>; /** Page context for system prompt enrichment */ pageContext?: { diff --git a/packages/ui/src/api/types/channels.ts b/packages/ui/src/api/types/channels.ts index fade2360..4fedb39f 100644 --- a/packages/ui/src/api/types/channels.ts +++ b/packages/ui/src/api/types/channels.ts @@ -78,6 +78,7 @@ export interface HistoryMessage { trace?: Record; isError?: boolean; createdAt: string; + attachments?: any[]; } /** Unified message — used in channel conversations to merge AI + channel data. */ @@ -95,6 +96,7 @@ export interface UnifiedMessage { direction: 'inbound' | 'outbound'; senderName?: string; senderId?: string; + attachments?: any[]; } /** Channel info attached to unified conversation response. */ diff --git a/packages/ui/src/components/ChatInput.tsx b/packages/ui/src/components/ChatInput.tsx index bf530be5..0de7e4ca 100644 --- a/packages/ui/src/components/ChatInput.tsx +++ b/packages/ui/src/components/ChatInput.tsx @@ -7,17 +7,18 @@ import { type KeyboardEvent, type FormEvent, } from 'react'; -import { Send, StopCircle, X, Image } from './icons'; +import { Send, StopCircle, X, Upload } from './icons'; import { ToolPicker, type ResourceAttachment, type ResourceType } from './ToolPicker'; import { VoiceButton } from './VoiceButton'; import type { MessageAttachment } from '../types'; +import { STORAGE_KEYS } from '../constants/storage-keys'; -/** File attachment being previewed (with base64 data) */ -interface ImagePreview { +interface AttachmentPreview { file: File; - data: string; // base64 + path: string; // server path mimeType: string; - previewUrl: string; // object URL for thumbnail + type: 'image' | 'file'; + previewUrl?: string; // object URL for thumbnail (images only) } interface ChatInputProps { @@ -116,7 +117,8 @@ export const ChatInput = forwardRef(function Ch ) { const [value, setValue] = useState(''); const [attachments, setAttachments] = useState([]); - const [imagePreviews, setImagePreviews] = useState([]); + const [filePreviews, setFilePreviews] = useState([]); + const [isUploading, setIsUploading] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -140,12 +142,14 @@ export const ChatInput = forwardRef(function Ch } }, [value]); - // Cleanup object URLs on unmount — use ref to access latest imagePreviews - const imagePreviewsRef = useRef(imagePreviews); - imagePreviewsRef.current = imagePreviews; + // Cleanup object URLs on unmount — use ref to access latest filePreviews + const filePreviewsRef = useRef(filePreviews); + filePreviewsRef.current = filePreviews; useEffect(() => { return () => { - for (const p of imagePreviewsRef.current) URL.revokeObjectURL(p.previewUrl); + for (const p of filePreviewsRef.current) { + if (p.previewUrl) URL.revokeObjectURL(p.previewUrl); + } }; }, []); @@ -157,52 +161,65 @@ export const ChatInput = forwardRef(function Ch const files = e.target.files; if (!files) return; - const newPreviews: ImagePreview[] = []; + setIsUploading(true); + const newPreviews: AttachmentPreview[] = []; for (const file of Array.from(files)) { - if (!file.type.startsWith('image/')) continue; - if (imagePreviews.length + newPreviews.length >= 5) break; // max 5 - - const base64 = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - // Strip "data:image/xxx;base64," prefix - resolve(result.split(',')[1] ?? ''); - }; - reader.readAsDataURL(file); - }); - - newPreviews.push({ - file, - data: base64, - mimeType: file.type, - previewUrl: URL.createObjectURL(file), - }); + if (filePreviews.length + newPreviews.length >= 5) break; // max 5 + + try { + const formData = new FormData(); + formData.append('file', file); + const token = localStorage.getItem(STORAGE_KEYS.SESSION_TOKEN); + const res = await fetch('/api/v1/chat/upload-attachment', { + method: 'POST', + headers: { + 'X-Session-Token': token || '', + }, + body: formData, + }); + + if (!res.ok) throw new Error('Upload failed'); + const resData = await res.json(); + + const isImage = file.type.startsWith('image/'); + newPreviews.push({ + file, + path: resData.data?.path || '', + mimeType: file.type, + type: isImage ? 'image' : 'file', + previewUrl: isImage ? URL.createObjectURL(file) : undefined, + }); + } catch (err) { + console.error('Failed to upload file:', err); + } } - setImagePreviews((prev) => [...prev, ...newPreviews]); + setFilePreviews((prev) => [...prev, ...newPreviews]); + setIsUploading(false); // Reset input so the same file can be re-selected e.target.value = ''; setTimeout(() => textareaRef.current?.focus(), 0); }; - const removeImage = (index: number) => { - setImagePreviews((prev) => { - URL.revokeObjectURL(prev[index]!.previewUrl); + const removeFile = (index: number) => { + setFilePreviews((prev) => { + const p = prev[index]; + if (p?.previewUrl) URL.revokeObjectURL(p.previewUrl); return prev.filter((_, i) => i !== index); }); }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); - const hasContent = value.trim() || imagePreviews.length > 0; + const hasContent = value.trim() || filePreviews.length > 0; if (hasContent && !isLoading) { // Prompt attachments prepend their text before the user's message const promptPrefixes = attachments .filter((a) => a.type === 'prompt' && a.promptText) .map((a) => a.promptText!) .join('\n\n'); - const rawUserText = value.trim() || (imagePreviews.length > 0 ? 'Analyze this image.' : ''); + const hasImage = filePreviews.some(p => p.type === 'image'); + const rawUserText = value.trim() || (hasImage ? 'Analyze this image.' : 'Analyze these files.'); const userText = promptPrefixes ? `${promptPrefixes}\n\n${rawUserText}` : rawUserText; const contextBlock = buildContextBlock(attachments); const finalMessage = userText + contextBlock; @@ -212,24 +229,24 @@ export const ChatInput = forwardRef(function Ch .filter((a) => a.type === 'tool' || a.type === 'custom-tool') .map((a) => a.name); - // Convert image previews to MessageAttachment[] - const imageAttachments: MessageAttachment[] = imagePreviews.map((p) => ({ - type: 'image' as const, - data: p.data, + // Convert file previews to MessageAttachment[] + const msgAttachments: MessageAttachment[] = filePreviews.map((p) => ({ + type: p.type, + path: p.path, mimeType: p.mimeType, filename: p.file.name, + size: p.file.size, + previewUrl: p.previewUrl, })); onSend( finalMessage, directToolNames.length > 0 ? directToolNames : undefined, - imageAttachments.length > 0 ? imageAttachments : undefined + msgAttachments.length > 0 ? msgAttachments : undefined ); setValue(''); setAttachments([]); - // Cleanup previews - for (const p of imagePreviews) URL.revokeObjectURL(p.previewUrl); - setImagePreviews([]); + setFilePreviews([]); } }; @@ -262,7 +279,6 @@ export const ChatInput = forwardRef(function Ch (function Ch />
- {/* Image previews */} - {imagePreviews.length > 0 && ( -
- {imagePreviews.map((preview, index) => ( + {/* Attachment previews */} + {filePreviews.length > 0 && ( +
+ {filePreviews.map((preview, index) => (
- {preview.file.name} + {preview.type === 'image' ? ( +
+ {preview.file.name} +
+ ) : ( +
+ {preview.file.name} +
+ )} {/* Voice Input Button */} @@ -352,8 +380,8 @@ export const ChatInput = forwardRef(function Ch onChange={(e) => setValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={ - imagePreviews.length > 0 - ? 'Describe what you want to know about the image...' + filePreviews.length > 0 + ? 'Ask what you want to know about the attachments...' : attachments.length > 0 ? 'Ask about the attached context...' : placeholder @@ -380,7 +408,7 @@ export const ChatInput = forwardRef(function Ch ) : (
@@ -330,7 +326,7 @@ export function Sidebar({ isMobile, isOpen, onClose, onSearchOpen, onCustomizeTo
- {!collapsed.recents && <>
- recents.setSearch(e.target.value)} - placeholder="Search\u2026" - data-testid="sidebar-recents-search" - className="w-full px-2 py-1 text-xs rounded border border-border dark:border-dark-border bg-bg-primary dark:bg-dark-bg-primary text-text-primary dark:text-dark-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary" - /> -
- {recents.availablePlatforms.size > 0 && ( -
- {(['all', 'web', ...(recents.availablePlatforms.has('whatsapp') ? ['whatsapp'] : []), ...(recents.availablePlatforms.has('telegram') ? ['telegram'] : [])] as SourceFilter[]).map((tab) => ( - - ))} -
- )} - {recents.isLoading && recents.conversations.length === 0 ? ( -
- {[...Array(4)].map((_, i) => ( -
- ))} + {!collapsed.recents && <>
+
+ + recents.setSearch(e.target.value)} + placeholder="Search..." + data-testid="sidebar-recents-search" + className="w-full pl-8 pr-3 py-1.5 text-[13px] rounded-md bg-black/5 dark:bg-white/5 border border-transparent text-text-primary dark:text-dark-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary/50 focus:bg-transparent dark:focus:bg-transparent focus:ring-1 focus:ring-primary/50 transition-all" + />
- ) : recents.conversations.length === 0 ? ( -
- {recents.search ? 'No results' : 'No conversations yet'} -
- ) : ( - <> - {/* Optimistic entries: one per active session, shows immediately +
+ {recents.availablePlatforms.size > 0 && ( +
+ {(['all', 'web', ...(recents.availablePlatforms.has('whatsapp') ? ['whatsapp'] : []), ...(recents.availablePlatforms.has('telegram') ? ['telegram'] : [])] as SourceFilter[]).map((tab) => ( + + ))} +
+ )} + {recents.isLoading && recents.conversations.length === 0 ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ) : recents.conversations.length === 0 ? ( +
+ {recents.search ? 'No results' : 'No conversations yet'} +
+ ) : ( + <> + {/* Optimistic entries: one per active session, shows immediately when user sends a message. Each persists in sidebar until the DB row arrives via WS chat:history:updated. */} - {optimisticEntries.map((entry) => { - const isActive = activeConversationId === entry.id; - return ( -
-
handleRecentClick(entry.id)} - className={`group relative flex items-center gap-1.5 px-2 py-1.5 mx-1 my-0.5 rounded-md cursor-pointer transition-colors ${ - isActive ? 'bg-primary/10 text-primary' : 'hover:bg-bg-tertiary dark:hover:bg-dark-bg-tertiary text-text-secondary dark:text-dark-text-secondary' - }`} - > - - {entry.title} -
-
- ); - })} - {recents.groups.map((group) => ( -
-

{group.label}

- {group.items.map((conv) => { - const isActiveConv = activeConversationId === conv.id; - const isEditing = recents.editingId === conv.id; - const title = getConvTitle(conv); - const isChannel = conv.source === 'channel'; - const isTelegram = conv.channelPlatform === 'telegram'; + {optimisticEntries.map((entry) => { + const isActive = activeConversationId === entry.id; return ( -
handleRecentClick(conv.id)} - className={`group relative flex items-center gap-1.5 px-2 py-1.5 mx-1 my-0.5 rounded-md cursor-pointer transition-colors ${ - isActiveConv ? 'bg-primary/10 text-primary' : 'hover:bg-bg-tertiary dark:hover:bg-dark-bg-tertiary text-text-secondary dark:text-dark-text-secondary' - }`} - > - {isChannel && isTelegram ? : isChannel && conv.channelPlatform === 'whatsapp' ? : isChannel ? : } - {isEditing ? ( - recents.setEditTitle(e.target.value)} - onBlur={() => handleCommitEdit(conv.id)} - onKeyDown={(e) => { if (e.key === 'Enter') handleCommitEdit(conv.id); if (e.key === 'Escape') recents.cancelEdit(); }} - onClick={(e) => e.stopPropagation()} - className="flex-1 min-w-0 text-xs bg-bg-primary dark:bg-dark-bg-primary border border-primary rounded px-1 py-0.5 outline-none" - autoFocus - /> - ) : ( - {title} - )} - {!isEditing && ( -
- - -
- )} +
+
handleRecentClick(entry.id)} + className={`group relative flex items-center gap-2 pl-7 pr-2 py-1.5 mx-2 my-0.5 rounded-md cursor-pointer transition-colors ${isActive ? 'bg-primary/10 text-primary dark:bg-primary/20 font-medium' : 'hover:bg-bg-tertiary dark:hover:bg-dark-bg-tertiary text-text-secondary dark:text-dark-text-secondary hover:text-text-primary dark:hover:text-dark-text-primary text-[13px]' + }`} + > + + {entry.title} +
); })} -
- ))} - - )} - {recents.total > recents.conversations.length && ( -

+{recents.total - recents.conversations.length} older

- )} - - All conversations → - + {recents.groups.map((group) => ( +
+

{group.label}

+ {group.items.map((conv) => { + const isActiveConv = activeConversationId === conv.id; + const isEditing = recents.editingId === conv.id; + const title = getConvTitle(conv); + const isChannel = conv.source === 'channel'; + const isTelegram = conv.channelPlatform === 'telegram'; + return ( +
handleRecentClick(conv.id)} + className={`group relative flex items-center gap-2 pl-7 pr-2 py-1.5 mx-2 my-0.5 rounded-md cursor-pointer transition-colors ${isActiveConv ? 'bg-primary/10 text-primary dark:bg-primary/20 font-medium' : 'hover:bg-bg-tertiary dark:hover:bg-dark-bg-tertiary text-text-secondary dark:text-dark-text-secondary hover:text-text-primary dark:hover:text-dark-text-primary text-[13px]' + }`} + > + {isChannel && isTelegram ? : isChannel && conv.channelPlatform === 'whatsapp' ? : isChannel ? : } + {isEditing ? ( + recents.setEditTitle(e.target.value)} + onBlur={() => handleCommitEdit(conv.id)} + onKeyDown={(e) => { if (e.key === 'Enter') handleCommitEdit(conv.id); if (e.key === 'Escape') recents.cancelEdit(); }} + onClick={(e) => e.stopPropagation()} + className="flex-1 min-w-0 text-xs bg-bg-primary dark:bg-dark-bg-primary border border-primary rounded px-1 py-0.5 outline-none" + autoFocus + /> + ) : ( + {title} + )} + {!isEditing && ( +
+ + +
+ )} +
+ ); + })} +
+ ))} + + )} + {recents.total > recents.conversations.length && ( +

+{recents.total - recents.conversations.length} older

+ )} + + All conversations → + }
diff --git a/packages/ui/src/components/sidebar/SidebarDataSection.tsx b/packages/ui/src/components/sidebar/SidebarDataSection.tsx index 304eb04e..c98fc1e2 100644 --- a/packages/ui/src/components/sidebar/SidebarDataSection.tsx +++ b/packages/ui/src/components/sidebar/SidebarDataSection.tsx @@ -43,7 +43,7 @@ export function SidebarDataSection({ @@ -84,12 +84,12 @@ export function SidebarDataSection({ ) : items.length === 0 ? (
No {label.toLowerCase()}
) : ( -
+
{items.map((item) => (