diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index a9ea890..33434c7 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -5,6 +5,7 @@ import os import re import time import uuid +from urllib.parse import quote, unquote from quart import Blueprint, jsonify, make_response, request from quart_jwt_extended import get_jwt_identity @@ -16,9 +17,19 @@ from blueprints.users.decorators import jwt_access_required from metrics import count_result, observe_response, time_response from utils.image_process import image_content_block from utils.strip_markdown import extract_agent_text +from utils.file_extract import ( + FILE_TYPES, + FileExtractionError, + attachment_block, + extract_text, + file_extension, +) +from utils.image_upload import ALLOWED_TYPES as IMAGE_TYPES from utils.image_upload import ImageValidationError, process_image from utils.s3_client import generate_presigned_url as s3_presigned_url +from utils.s3_client import get_file as s3_get_file from utils.s3_client import get_image as s3_get_image +from utils.s3_client import upload_file as s3_upload_file from utils.s3_client import upload_image as s3_upload_image from .logic import ( @@ -203,6 +214,87 @@ async def upload_image(): return jsonify({"image_key": key}) +def _file_key_belongs_to(file_key: str, conversation_id) -> bool: + """Only allow attachments stored under this conversation's prefix.""" + return ( + isinstance(file_key, str) + and ".." not in file_key + and file_key.startswith(f"conversations/{conversation_id}/files/") + ) + + +async def _load_attached_file(file_key: str) -> tuple[str, str]: + """Fetch an attached file from S3 and return (original name, extracted text).""" + data, metadata = await s3_get_file(file_key) + name = unquote(metadata.get("filename", "")) or file_key.rsplit("/", 1)[-1] + ext = file_extension(file_key, None) or "" + # PyMuPDF is CPU-bound; keep it off the event loop so other streams don't stall. + return name, await asyncio.to_thread(extract_text, data, ext) + + +@conversation_blueprint.post("/upload") +@jwt_access_required +async def upload_attachment(): + current_user_uuid = get_jwt_identity() + files = await request.files + form = await request.form + file = files.get("file") + conversation_id = form.get("conversation_id") + + if not file or not conversation_id: + return jsonify({"error": "file and conversation_id are required"}), 400 + + conversation = await _get_owned_conversation(conversation_id, current_user_uuid) + if conversation is None: + return jsonify({"error": "conversation not found"}), 404 + + file_bytes = file.read() + content_type = (file.content_type or "").split(";")[0].strip().lower() + name = os.path.basename(file.filename or "") or "attachment" + + if content_type in IMAGE_TYPES: + try: + processed_bytes, output_content_type = process_image( + file_bytes, content_type + ) + except Exception as e: + return jsonify({"error": f"Could not process image: {e}"}), 400 + ext = output_content_type.split("/")[-1] + if ext == "jpeg": + ext = "jpg" + key = f"conversations/{conversation.id}/{uuid.uuid4()}.{ext}" + await s3_upload_image(processed_bytes, key, output_content_type) + return jsonify({"kind": "image", "image_key": key}) + + ext = file_extension(name, content_type) + if ext is None: + return ( + jsonify( + { + "error": f"Unsupported file type: {content_type or name}. " + "Allowed: images, PDF, TXT, MD, CSV" + } + ), + 415, + ) + + try: + text = await asyncio.to_thread(extract_text, file_bytes, ext) + except FileExtractionError as e: + return jsonify({"error": str(e)}), 400 + if not text.strip(): + message = ( + "This PDF has no text layer (scanned?), so Simba can't read it yet." + if ext == "pdf" + else "This file is empty, so there's nothing for Simba to read." + ) + return jsonify({"error": message}), 422 + + key = f"conversations/{conversation.id}/files/{uuid.uuid4()}.{ext}" + await s3_upload_file(file_bytes, key, FILE_TYPES[ext], {"filename": quote(name)}) + return jsonify({"kind": "file", "file_key": key, "name": name, "chars": len(text)}) + + @conversation_blueprint.get("/image/") @jwt_access_required async def serve_image(image_key: str): @@ -219,12 +311,30 @@ async def stream_query(): query_text = data.get("query") conversation_id = data.get("conversation_id") image_key = data.get("image_key") + file_key = data.get("file_key") conversation = await _get_owned_conversation(conversation_id, current_user_uuid) if conversation is None: return jsonify({"error": "conversation not found"}), 404 + + # An attached file is extracted and appended to the agent's user turn; the + # saved message only records its name. + saved_text = query_text or "" + if file_key: + if not _file_key_belongs_to(file_key, conversation.id): + return jsonify({"error": "invalid file_key"}), 400 + try: + file_name, file_text = await _load_attached_file(file_key) + block = attachment_block(file_name, file_text) + except Exception as e: + logging.error(f"Failed to load file {file_key}: {e}") + file_name = file_key.rsplit("/", 1)[-1] + block = "[A file was attached but could not be loaded.]" + query_text = f"{query_text or 'The user attached this file.'}\n\n{block}" + saved_text = f"{saved_text}\n\n📎 {file_name}".strip() + await add_message_to_conversation( conversation=conversation, - message=query_text or "", + message=saved_text, speaker="user", user=user, image_key=image_key, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 79d5e4a..8c10adc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -146,6 +146,8 @@ pub fn run() { .title("Simba") .inner_size(1200.0, 820.0) .min_inner_size(420.0, 520.0) + // Let HTML5 drag-and-drop reach the webview (file drop in chat). + .disable_drag_drop_handler() .build()?; build_tray(app.handle())?; register_hotkey(app.handle())?; diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 60b4ca6..d47cacb 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -51,6 +51,12 @@ interface Conversation { user_id?: string; } +export type UploadResult = + | { kind: "image"; image_key: string } + | { kind: "file"; file_key: string; name: string; chars: number }; + +export class UploadError extends Error {} + interface QueryResponse { response: string; } @@ -182,16 +188,16 @@ class ConversationService { return await response.json(); } - async uploadImage( + async uploadAttachment( file: File, conversationId: string, - ): Promise<{ image_key: string }> { + ): Promise { const formData = new FormData(); formData.append("file", file); formData.append("conversation_id", conversationId); const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/upload-image`, + `${this.conversationBaseUrl}/upload`, { method: "POST", body: formData, @@ -200,8 +206,8 @@ class ConversationService { ); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to upload image"); + const data = await response.json().catch(() => ({})); + throw new UploadError(data.error || "Failed to upload file"); } return await response.json(); @@ -224,11 +230,15 @@ class ConversationService { onEvent: SSEEventCallback, signal?: AbortSignal, imageKey?: string, + fileKey?: string, ): Promise { const body: Record = { query, conversation_id }; if (imageKey) { body.image_key = imageKey; } + if (fileKey) { + body.file_key = fileKey; + } const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/stream-query`, diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 6248a53..05add45 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -6,6 +6,7 @@ import { AnswerBubble } from "./AnswerBubble"; import { ActivityTimeline } from "./ActivityTimeline"; import { ThinkingBubble } from "./ThinkingBubble"; import { MessageInput } from "./MessageInput"; +import { DropOverlay } from "./DropOverlay"; import { SidebarContent } from "./SidebarContent"; import { AdminPanel } from "./AdminPanel"; import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel"; @@ -84,8 +85,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { streaming, thinking, messagesLoading, - pendingImage, - setPendingImage, + pendingAttachment, + attachFile, + clearAttachment, + attachmentError, + clearAttachmentError, sendMessage, stopGeneration, error, @@ -141,9 +145,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { [deleteConversation], ); - const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]); - const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]); - const handleLogout = () => { localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); @@ -223,7 +224,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { {showAdminPanel && setShowAdminPanel(false)} />} {showScheduler && setShowScheduler(false)} />} -
+
+
Simba @@ -302,9 +304,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { onToggleSimbaMode={handleToggleSimbaMode} isLoading={isLoading} onStop={stopGeneration} - pendingImage={pendingImage} - onImageSelect={handleImageSelect} - onClearImage={handleClearImage} + pendingAttachment={pendingAttachment} + onAttach={attachFile} + onClearAttachment={clearAttachment} + attachmentError={attachmentError} + onDismissAttachmentError={clearAttachmentError} autoFocus />
@@ -431,9 +435,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { onToggleSimbaMode={handleToggleSimbaMode} isLoading={isLoading} onStop={stopGeneration} - pendingImage={pendingImage} - onImageSelect={handleImageSelect} - onClearImage={handleClearImage} + pendingAttachment={pendingAttachment} + onAttach={attachFile} + onClearAttachment={clearAttachment} + attachmentError={attachmentError} + onDismissAttachmentError={clearAttachmentError} />
diff --git a/raggr-frontend/src/components/DropOverlay.tsx b/raggr-frontend/src/components/DropOverlay.tsx new file mode 100644 index 0000000..3b22971 --- /dev/null +++ b/raggr-frontend/src/components/DropOverlay.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { Paperclip } from "lucide-react"; + +const hasFiles = (e: DragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + +type DropOverlayProps = { + onDrop: (file: File) => void; +}; + +/** + * Listens for file drags anywhere in the window and covers its positioned + * parent (the chat column, beside the artifact panel) with a "Drop to attach" + * target. Below lg the panel is a full-screen sheet, so it covers the window + * and sits above the sheet. Drags without files (text, links) + * are ignored so they keep their normal behaviour. + */ +export const DropOverlay = ({ onDrop }: DropOverlayProps) => { + const [active, setActive] = useState(false); + // dragenter/dragleave fire for every element crossed; count to know when + // the drag has really left the window. + const depthRef = useRef(0); + const onDropRef = useRef(onDrop); + onDropRef.current = onDrop; + + useEffect(() => { + const handleEnter = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + depthRef.current += 1; + setActive(true); + }; + const handleOver = (e: DragEvent) => { + if (!hasFiles(e)) return; + // Required for the drop event to fire (and to stop the browser + // navigating to the file). + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + }; + const handleLeave = (e: DragEvent) => { + if (!hasFiles(e)) return; + depthRef.current = Math.max(0, depthRef.current - 1); + if (depthRef.current === 0) setActive(false); + }; + const handleDrop = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + depthRef.current = 0; + setActive(false); + const file = e.dataTransfer?.files?.[0]; + if (file) onDropRef.current(file); + }; + + window.addEventListener("dragenter", handleEnter); + window.addEventListener("dragover", handleOver); + window.addEventListener("dragleave", handleLeave); + window.addEventListener("drop", handleDrop); + return () => { + window.removeEventListener("dragenter", handleEnter); + window.removeEventListener("dragover", handleOver); + window.removeEventListener("dragleave", handleLeave); + window.removeEventListener("drop", handleDrop); + }; + }, []); + + if (!active) return null; + + return ( +
+
+
+ +
+

+ Drop to attach +

+

Images, PDF, TXT, MD or CSV · up to 10 MB

+
+
+ ); +}; diff --git a/raggr-frontend/src/components/MessageInput.tsx b/raggr-frontend/src/components/MessageInput.tsx index 67883e2..2dd2f43 100644 --- a/raggr-frontend/src/components/MessageInput.tsx +++ b/raggr-frontend/src/components/MessageInput.tsx @@ -1,6 +1,12 @@ import React, { useEffect, useMemo, useRef } from "react"; -import { ArrowUp, ImagePlus, Square, X } from "lucide-react"; +import { ArrowUp, FileSpreadsheet, FileText, Paperclip, Square, X } from "lucide-react"; import { cn } from "../lib/utils"; +import { + ATTACHMENT_ACCEPT, + attachmentKind, + fileExtension, + formatBytes, +} from "../lib/attachments"; import { Textarea } from "./ui/textarea"; type MessageInputProps = { @@ -12,9 +18,11 @@ type MessageInputProps = { query: string; isLoading: boolean; onStop: () => void; - pendingImage: File | null; - onImageSelect: (file: File) => void; - onClearImage: () => void; + pendingAttachment: File | null; + onAttach: (file: File) => void; + onClearAttachment: () => void; + attachmentError: string | null; + onDismissAttachmentError: () => void; autoFocus?: boolean; }; @@ -28,15 +36,23 @@ export const MessageInput = React.memo( onToggleSimbaMode, isLoading, onStop, - pendingImage, - onImageSelect, - onClearImage, + pendingAttachment, + onAttach, + onClearAttachment, + attachmentError, + onDismissAttachmentError, autoFocus, }: MessageInputProps) => { const fileInputRef = useRef(null); const textareaRef = useRef(null); - // Create blob URL once per file, revoke on cleanup + const pendingImage = + pendingAttachment && attachmentKind(pendingAttachment) === "image" + ? pendingAttachment + : null; + const pendingFile = pendingAttachment && !pendingImage ? pendingAttachment : null; + + // Create blob URL once per image, revoke on cleanup const previewUrl = useMemo( () => (pendingImage ? URL.createObjectURL(pendingImage) : null), [pendingImage], @@ -59,7 +75,7 @@ export const MessageInput = React.memo( const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - onImageSelect(file); + onAttach(file); } // Reset so the same file can be re-selected e.target.value = ""; @@ -69,19 +85,21 @@ export const MessageInput = React.memo( if (isLoading) return; const items = e.clipboardData?.items; if (!items) return; - for (const item of items) { + for (const item of Array.from(items)) { if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) { e.preventDefault(); - onImageSelect(file); + onAttach(file); return; } } } }; - const canSend = !isLoading && Boolean(query.trim() || pendingImage); + const canSend = !isLoading && Boolean(query.trim() || pendingAttachment); + const FileIcon = + pendingFile && fileExtension(pendingFile.name) === "csv" ? FileSpreadsheet : FileText; return (
)} + {/* File chip */} + {pendingFile && ( +
+
+
+ +
+
+
+ {pendingFile.name} +
+
+ {fileExtension(pendingFile.name).toUpperCase() || "File"} ·{" "} + {formatBytes(pendingFile.size)} +
+
+ +
+
+ )} + + {attachmentError && ( +
+
+ {attachmentError} + +
+
+ )} + {/* Textarea */}