From e55968b19df73a7cfaada99751787f701556957e Mon Sep 17 00:00:00 2001 From: pufit Date: Tue, 7 Apr 2026 19:17:43 -0400 Subject: [PATCH 1/4] Add file management: upload files/images via web chat, download agent-produced files Backend: - New uploaded_files table (v022 migration) + FileStore DB mixin - POST /api/files/upload: multipart upload, stores on disk + DB index - GET /api/files/uploads/{id}: serve uploaded files for chat history - GET /api/files/download: serve workspace files with path traversal protection - WebSocket message handler extended to accept file_ids, loads files and passes them to engine.run(images=...) reusing the Telegram pipeline - engine.py stores image refs in user message blocks column for persistence - Session deletion cleans up uploaded files from disk and DB Frontend: - New ImageBlockData and FileBlockData types in MessageBlock union - ChatInput: paperclip file picker, drag-and-drop, clipboard paste (Ctrl+V) with preview strip showing upload progress - UserMessage: renders attached images as thumbnails, files as download cards - BlockRenderer: handles image and file block types in assistant messages - chatStore.sendMessage extended with fileIds and imageBlocks params - hydrateMessage: restores image/file blocks from DB for chat history - api/client.ts: uploadFiles method with multipart FormData - api/websocket.ts: sendMessage accepts optional fileIds --- nerve/agent/engine.py | 23 +- nerve/db/base.py | 2 + nerve/db/files.py | 59 ++++++ nerve/db/migrations/v022_uploaded_files.py | 22 ++ nerve/gateway/routes/__init__.py | 2 + nerve/gateway/routes/files.py | 158 ++++++++++++++ nerve/gateway/routes/sessions.py | 24 +++ nerve/gateway/server.py | 72 +++++++ web/src/api/client.ts | 27 +++ web/src/api/websocket.ts | 8 +- web/src/components/Chat/BlockRenderer.tsx | 25 +++ web/src/components/Chat/ChatInput.tsx | 234 ++++++++++++++++++++- web/src/components/Chat/UserMessage.tsx | 49 ++++- web/src/stores/chatStore.ts | 13 +- web/src/types/chat.ts | 16 +- web/src/utils/hydrateMessage.ts | 19 +- 16 files changed, 733 insertions(+), 20 deletions(-) create mode 100644 nerve/db/files.py create mode 100644 nerve/db/migrations/v022_uploaded_files.py create mode 100644 nerve/gateway/routes/files.py diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 2239d750..dad72462 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -1247,6 +1247,7 @@ async def run( model: str | None = None, internal: bool = False, images: list[dict[str, Any]] | None = None, + image_refs: list[dict[str, Any]] | None = None, ) -> str: """Run the agent for a user message and return the final text response. @@ -1256,6 +1257,8 @@ async def run( DB or shown in the UI. images: Optional list of image dicts with keys ``type``, ``media_type``, and ``data`` (base64-encoded). + image_refs: Optional metadata about uploaded files for persisting + in the user message blocks column (web uploads only). """ # Serialize runs per session — messages for the same session wait # in order instead of failing with "already running". @@ -1281,6 +1284,7 @@ async def run( return await self._run_inner( session_id, user_message, source, channel, model, internal=internal, images=images, + image_refs=image_refs, ) finally: self.sessions.mark_not_running(session_id) @@ -1301,6 +1305,7 @@ async def _run_inner( model: str | None, internal: bool = False, images: list[dict[str, Any]] | None = None, + image_refs: list[dict[str, Any]] | None = None, ) -> str: # Ensure session exists in DB await self.sessions.get_or_create(session_id, source=source) @@ -1330,10 +1335,14 @@ async def _run_inner( # Store user message in DB (note attached images for display) db_text = user_message if images: - suffix = f"\n[{len(images)} image(s) attached]" - db_text = (user_message + suffix) if user_message else suffix.strip() + # Count only image/pdf entries, not text_file entries + img_count = sum(1 for img in images if img.get("type") != "text_file") + if img_count: + suffix = f"\n[{img_count} image(s) attached]" + db_text = (user_message + suffix) if user_message else suffix.strip() await self.sessions.add_message( session_id, "user", db_text, channel=channel, + blocks=image_refs, ) full_response_text = "" @@ -1383,6 +1392,16 @@ async def _run_inner( if query_text: content_blocks.append({"type": "text", "text": query_text}) for img in images: + # Text files are inlined as text context blocks + if img.get("type") == "text_file": + fname = img.get("filename", "file") + content = img.get("content", "") + content_blocks.append({ + "type": "text", + "text": f"--- Attached: {fname} ---\n{content}", + }) + continue + # PDFs use "document" content block; images use "image" block_type = "document" if img["media_type"] == "application/pdf" else "image" diff --git a/nerve/db/base.py b/nerve/db/base.py index e69b3a34..ba94e40d 100644 --- a/nerve/db/base.py +++ b/nerve/db/base.py @@ -17,6 +17,7 @@ from nerve.db.audit import AuditStore from nerve.db.cron import CronStore +from nerve.db.files import FileStore from nerve.db.mcp import McpStore from nerve.db.messages import MessageStore from nerve.db.migrations.runner import discover_migrations, run_migrations @@ -47,6 +48,7 @@ class Database( McpStore, AuditStore, UsageStore, + FileStore, ): """Async SQLite database wrapper. diff --git a/nerve/db/files.py b/nerve/db/files.py new file mode 100644 index 00000000..66df7b53 --- /dev/null +++ b/nerve/db/files.py @@ -0,0 +1,59 @@ +"""Uploaded file data access methods.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + + +class FileStore: + """Mixin providing uploaded file CRUD operations.""" + + async def save_uploaded_file( + self, + file_id: str, + session_id: str, + filename: str, + media_type: str, + file_type: str, + file_size: int, + disk_path: str, + ) -> None: + now = datetime.now(timezone.utc).isoformat() + await self.db.execute( + """INSERT INTO uploaded_files (id, session_id, filename, media_type, file_type, file_size, disk_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (file_id, session_id, filename, media_type, file_type, file_size, disk_path, now), + ) + await self.db.commit() + + async def get_uploaded_file(self, file_id: str) -> dict | None: + async with self.db.execute( + "SELECT * FROM uploaded_files WHERE id = ?", (file_id,) + ) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + async def get_uploaded_files_by_ids(self, file_ids: list[str]) -> list[dict]: + if not file_ids: + return [] + placeholders = ",".join("?" for _ in file_ids) + async with self.db.execute( + f"SELECT * FROM uploaded_files WHERE id IN ({placeholders})", + file_ids, + ) as cursor: + return [dict(row) async for row in cursor] + + async def delete_uploaded_files(self, session_id: str) -> list[str]: + """Delete all uploaded file records for a session. Returns disk paths for cleanup.""" + async with self.db.execute( + "SELECT disk_path FROM uploaded_files WHERE session_id = ?", + (session_id,), + ) as cursor: + paths = [row[0] async for row in cursor] + await self.db.execute( + "DELETE FROM uploaded_files WHERE session_id = ?", + (session_id,), + ) + await self.db.commit() + return paths diff --git a/nerve/db/migrations/v022_uploaded_files.py b/nerve/db/migrations/v022_uploaded_files.py new file mode 100644 index 00000000..621c2ec3 --- /dev/null +++ b/nerve/db/migrations/v022_uploaded_files.py @@ -0,0 +1,22 @@ +"""v022: Add uploaded_files table for web chat file management.""" + +from __future__ import annotations + +import aiosqlite + + +async def up(db: aiosqlite.Connection) -> None: + await db.executescript(""" + CREATE TABLE IF NOT EXISTS uploaded_files ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + filename TEXT NOT NULL, + media_type TEXT NOT NULL, + file_type TEXT NOT NULL, + file_size INTEGER NOT NULL, + disk_path TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_uploaded_files_session + ON uploaded_files(session_id); + """) diff --git a/nerve/gateway/routes/__init__.py b/nerve/gateway/routes/__init__.py index 2a41781d..c4f36745 100644 --- a/nerve/gateway/routes/__init__.py +++ b/nerve/gateway/routes/__init__.py @@ -24,6 +24,7 @@ sources, notifications, houseofagents, + files, ) __all__ = [ @@ -49,4 +50,5 @@ def register_all_routes() -> APIRouter: router.include_router(sources.router) router.include_router(notifications.router) router.include_router(houseofagents.router) + router.include_router(files.router) return router diff --git a/nerve/gateway/routes/files.py b/nerve/gateway/routes/files.py new file mode 100644 index 00000000..c6121328 --- /dev/null +++ b/nerve/gateway/routes/files.py @@ -0,0 +1,158 @@ +"""File upload and download routes.""" + +from __future__ import annotations + +import base64 +import logging +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from nerve.config import get_config +from nerve.gateway.auth import require_auth +from nerve.gateway.routes._deps import get_deps + +logger = logging.getLogger(__name__) + +router = APIRouter() + +MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB per file +MAX_TOTAL_SIZE = 50 * 1024 * 1024 # 50 MB per request + +IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"} +PDF_TYPES = {"application/pdf"} + + +def _classify_file(media_type: str) -> str: + """Classify an uploaded file as image, pdf, or text.""" + if media_type in IMAGE_TYPES: + return "image" + if media_type in PDF_TYPES: + return "pdf" + return "text" + + +def _uploads_dir() -> Path: + """Return the uploads root directory.""" + config = get_config() + return config.workspace / ".uploads" + + +@router.post("/api/files/upload") +async def upload_files( + files: list[UploadFile] = File(...), + session_id: str = Form(...), + user: dict = Depends(require_auth), +): + """Upload one or more files, store on disk and track in DB.""" + deps = get_deps() + + # Validate session exists + session = await deps.db.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Read all files and validate sizes + file_data: list[tuple[UploadFile, bytes]] = [] + total_size = 0 + for f in files: + data = await f.read() + if len(data) > MAX_FILE_SIZE: + raise HTTPException( + status_code=413, + detail=f"File '{f.filename}' exceeds {MAX_FILE_SIZE // (1024*1024)}MB limit", + ) + total_size += len(data) + if total_size > MAX_TOTAL_SIZE: + raise HTTPException( + status_code=413, + detail=f"Total upload size exceeds {MAX_TOTAL_SIZE // (1024*1024)}MB limit", + ) + file_data.append((f, data)) + + # Store files on disk and in DB + upload_dir = _uploads_dir() / session_id + upload_dir.mkdir(parents=True, exist_ok=True) + + results = [] + for f, data in file_data: + file_id = uuid.uuid4().hex[:16] + filename = f.filename or "unnamed" + # Sanitize filename + filename = Path(filename).name # strip directory components + media_type = f.content_type or "application/octet-stream" + file_type = _classify_file(media_type) + + disk_path = upload_dir / f"{file_id}_{filename}" + disk_path.write_bytes(data) + + await deps.db.save_uploaded_file( + file_id=file_id, + session_id=session_id, + filename=filename, + media_type=media_type, + file_type=file_type, + file_size=len(data), + disk_path=str(disk_path), + ) + + results.append({ + "id": file_id, + "filename": filename, + "media_type": media_type, + "file_type": file_type, + "size": len(data), + }) + + return {"files": results} + + +@router.get("/api/files/uploads/{file_id}") +async def get_uploaded_file( + file_id: str, + user: dict = Depends(require_auth), +): + """Serve an uploaded file by its ID (for image display in chat history).""" + deps = get_deps() + record = await deps.db.get_uploaded_file(file_id) + if not record: + raise HTTPException(status_code=404, detail="File not found") + + disk_path = Path(record["disk_path"]) + if not disk_path.exists(): + raise HTTPException(status_code=404, detail="File not found on disk") + + return FileResponse( + path=str(disk_path), + media_type=record["media_type"], + filename=record["filename"], + ) + + +@router.get("/api/files/download") +async def download_file( + path: str, + user: dict = Depends(require_auth), +): + """Download a workspace file by absolute path.""" + config = get_config() + workspace_root = config.workspace.resolve() + + resolved = Path(path).resolve() + if not resolved.is_relative_to(workspace_root): + raise HTTPException(status_code=403, detail="Access denied: path outside workspace") + + if not resolved.exists() or not resolved.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + import mimetypes + media_type, _ = mimetypes.guess_type(str(resolved)) + + return FileResponse( + path=str(resolved), + media_type=media_type or "application/octet-stream", + filename=resolved.name, + headers={"Content-Disposition": f'attachment; filename="{resolved.name}"'}, + ) diff --git a/nerve/gateway/routes/sessions.py b/nerve/gateway/routes/sessions.py index a29a81da..c3df6532 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -20,6 +20,27 @@ router = APIRouter() +async def _cleanup_uploaded_files(db: object, session_id: str) -> None: + """Delete uploaded files from DB and disk for a deleted session.""" + try: + disk_paths = await db.delete_uploaded_files(session_id) # type: ignore[attr-defined] + for p in disk_paths: + try: + Path(p).unlink(missing_ok=True) + except Exception: + pass + # Try to remove session upload dir if empty + config = get_config() + session_dir = config.workspace / ".uploads" / session_id + if session_dir.exists(): + try: + session_dir.rmdir() + except OSError: + pass # Not empty — that's fine + except Exception as e: + logger.warning("Failed to cleanup uploaded files for session %s: %s", session_id, e) + + # --- Request/Response models --- class MessageRequest(BaseModel): @@ -141,6 +162,8 @@ async def delete_session(session_id: str, user: dict = Depends(require_auth)): messages = await db.get_messages(session_id, limit=10000) if connected_at else [] # Delete from DB immediately (fast) await db.delete_session(session_id) + # Cleanup uploaded files for this session + await _cleanup_uploaded_files(db, session_id) # Memorize in background from snapshot if messages and connected_at and engine._memory_bridge and engine._memory_bridge.available: async def _bg_memorize(): @@ -159,6 +182,7 @@ async def _bg_memorize(): asyncio.create_task(_bg_memorize()) else: await db.delete_session(session_id) + await _cleanup_uploaded_files(db, session_id) return {"deleted": True} diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index fef0f35a..f4b062c6 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -295,6 +295,7 @@ async def ws_broadcast(session_id: str, message: dict): # User sent a chat message user_text = data.get("content", "") session_id = data.get("session_id", active_session) + file_ids = data.get("file_ids", []) if session_id != active_session: # Switch sessions @@ -303,6 +304,14 @@ async def ws_broadcast(session_id: str, message: dict): await broadcaster.register(active_session, client_id, ws_broadcast) await router.switch_session("web:default", session_id) + # Load uploaded files if any + images = None + image_refs = None + if file_ids: + images, image_refs = await _load_uploaded_files( + _engine.db, file_ids, + ) + # Run agent in background, store task for stop support task = asyncio.create_task( _engine.run( @@ -310,6 +319,8 @@ async def ws_broadcast(session_id: str, message: dict): user_message=user_text, source="web", channel="web", + images=images or None, + image_refs=image_refs or None, ) ) _engine.register_task(session_id, task) @@ -435,6 +446,67 @@ async def spa_fallback(path: str): return app +async def _load_uploaded_files( + db: Database, file_ids: list[str], +) -> tuple[list[dict], list[dict]]: + """Load uploaded files from DB/disk into the engine image format. + + Returns: + (images, image_refs) where images is the list for engine.run(images=...) + and image_refs is metadata for storing in the user message blocks column. + """ + import base64 + + records = await db.get_uploaded_files_by_ids(file_ids) + images: list[dict] = [] + image_refs: list[dict] = [] + + for rec in records: + disk_path = Path(rec["disk_path"]) + if not disk_path.exists(): + logger.warning("Uploaded file not found on disk: %s", disk_path) + continue + + data = disk_path.read_bytes() + file_type = rec["file_type"] + media_type = rec["media_type"] + file_id = rec["id"] + filename = rec["filename"] + + if file_type in ("image", "pdf"): + b64 = base64.b64encode(data).decode("utf-8") + images.append({ + "type": "base64", + "media_type": media_type, + "data": b64, + }) + image_refs.append({ + "type": "image" if file_type == "image" else "file", + "url": f"/api/files/uploads/{file_id}", + "filename": filename, + "media_type": media_type, + }) + else: + # Text file — will be appended to user message by the engine + try: + text_content = data.decode("utf-8") + except UnicodeDecodeError: + text_content = f"[Binary file: {filename}, {len(data)} bytes]" + images.append({ + "type": "text_file", + "filename": filename, + "content": text_content, + }) + image_refs.append({ + "type": "file", + "url": f"/api/files/uploads/{file_id}", + "filename": filename, + "media_type": media_type, + }) + + return images, image_refs + + def run_server(config: NerveConfig | None = None) -> None: """Run the Nerve server with uvicorn.""" import uvicorn diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a22e3ba4..10623ab6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -306,4 +306,31 @@ export const api = { installHoaBinary: () => request<{ installed: boolean; path: string; version: string }>('/houseofagents/install', { method: 'POST' }), + // Files + uploadFiles: async (files: File[], sessionId: string): Promise<{ files: Array<{ id: string; filename: string; media_type: string; file_type: string; size: number }> }> => { + const formData = new FormData(); + formData.append('session_id', sessionId); + files.forEach(f => formData.append('files', f)); + + const headers: Record = {}; + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + + const res = await fetch(`${API_BASE}/files/upload`, { + method: 'POST', + headers, + body: formData, + }); + + if (res.status === 401) { + clearToken(); + window.location.reload(); + throw new Error('Unauthorized'); + } + if (!res.ok) { + const body = await res.text(); + throw new Error(`${res.status}: ${body}`); + } + return res.json(); + }, + }; diff --git a/web/src/api/websocket.ts b/web/src/api/websocket.ts index 9f2aa433..8c508732 100644 --- a/web/src/api/websocket.ts +++ b/web/src/api/websocket.ts @@ -89,8 +89,12 @@ export class NerveWebSocket { } } - sendMessage(content: string, sessionId: string) { - this.send({ type: 'message', content, session_id: sessionId }); + sendMessage(content: string, sessionId: string, fileIds?: string[]) { + const msg: Record = { type: 'message', content, session_id: sessionId }; + if (fileIds && fileIds.length > 0) { + msg.file_ids = fileIds; + } + this.send(msg); } switchSession(sessionId: string) { diff --git a/web/src/components/Chat/BlockRenderer.tsx b/web/src/components/Chat/BlockRenderer.tsx index d9b6e964..04eaec92 100644 --- a/web/src/components/Chat/BlockRenderer.tsx +++ b/web/src/components/Chat/BlockRenderer.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; +import { Download, FileText } from 'lucide-react'; import type { MessageBlock } from '../../types/chat'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolCallBlock } from './ToolCallBlock'; import { ToolCallGroupBlock } from './ToolCallGroupBlock'; import { MarkdownContent } from './MarkdownContent'; import { groupToolCalls } from '../../utils/groupToolCalls'; +import { getToken } from '../../api/client'; interface BlockRendererProps { blocks: MessageBlock[]; @@ -53,6 +55,29 @@ export function BlockRenderer({
{inner}
); } + case 'image': + return ( +
+ + {item.filename} + +
+ ); + case 'file': + return ( +
+ + + {item.filename} + {item.size != null && ({(item.size / 1024).toFixed(1)}KB)} + + +
+ ); default: return null; } diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index 44b75a1c..e726cb2e 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -1,7 +1,8 @@ -import { useState, useRef, useEffect, type KeyboardEvent } from 'react'; -import { Send, Square, X, Plus, Trash2, Sparkles, HelpCircle, StickyNote } from 'lucide-react'; +import { useState, useRef, useEffect, useCallback, type KeyboardEvent, type ClipboardEvent, type DragEvent } from 'react'; +import { Send, Square, X, Plus, Trash2, Sparkles, HelpCircle, StickyNote, Paperclip, FileText, Loader2 } from 'lucide-react'; import { useChatStore } from '../../stores/chatStore'; import type { QuoteAction, QuoteEntry } from '../../stores/chatStore'; +import { api } from '../../api/client'; const ACTION_CONFIG: Record = { add: { icon: Plus, label: 'Add', color: 'var(--theme-accent)', placeholder: 'Instructions...' }, @@ -14,20 +15,35 @@ const ACTION_CONFIG: Record(['add', 'question', 'note']); +interface AttachmentFile { + id: string; + file: File; + preview?: string; + uploading: boolean; + uploadedId?: string; + uploadedMeta?: { filename: string; media_type: string; file_type: string }; + error?: string; +} + export function ChatInput({ onSend, onStop, isStreaming, disabled }: { - onSend: (message: string) => void; + onSend: (message: string, fileIds?: string[], imageBlocks?: Array<{ url: string; filename: string; media_type: string }>) => void; onStop: () => void; isStreaming: boolean; disabled?: boolean; }) { const [input, setInput] = useState(''); + const [attachments, setAttachments] = useState([]); + const [isDragging, setIsDragging] = useState(false); const textareaRef = useRef(null); const lastInstructionRef = useRef(null); + const fileInputRef = useRef(null); + const dragCountRef = useRef(0); const quotes = useChatStore(s => s.quotes); const removeQuote = useChatStore(s => s.removeQuote); const updateQuoteInstruction = useChatStore(s => s.updateQuoteInstruction); const clearQuotes = useChatStore(s => s.clearQuotes); + const activeSession = useChatStore(s => s.activeSession); const [prevQuoteCount, setPrevQuoteCount] = useState(0); @@ -36,13 +52,63 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { if (quotes.length > prevQuoteCount && quotes.length > 0) { const last = quotes[quotes.length - 1]; if (FOCUS_ACTIONS.has(last.action)) { - // Focus the instruction input of the last quote setTimeout(() => lastInstructionRef.current?.focus(), 0); } } setPrevQuoteCount(quotes.length); }, [quotes.length, prevQuoteCount, quotes]); + // Cleanup object URLs on unmount + useEffect(() => { + return () => { + attachments.forEach(a => { if (a.preview) URL.revokeObjectURL(a.preview); }); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const addFiles = useCallback(async (files: File[]) => { + const newAttachments: AttachmentFile[] = files.map(file => ({ + id: crypto.randomUUID(), + file, + preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, + uploading: true, + })); + + setAttachments(prev => [...prev, ...newAttachments]); + + // Upload all files + try { + const result = await api.uploadFiles(files, activeSession); + setAttachments(prev => prev.map(a => { + const idx = newAttachments.findIndex(n => n.id === a.id); + if (idx >= 0 && result.files[idx]) { + const meta = result.files[idx]; + return { + ...a, + uploading: false, + uploadedId: meta.id, + uploadedMeta: { filename: meta.filename, media_type: meta.media_type, file_type: meta.file_type }, + }; + } + return a; + })); + } catch (err) { + setAttachments(prev => prev.map(a => { + if (newAttachments.some(n => n.id === a.id)) { + return { ...a, uploading: false, error: String(err) }; + } + return a; + })); + } + }, [activeSession]); + + const removeAttachment = useCallback((id: string) => { + setAttachments(prev => { + const removed = prev.find(a => a.id === id); + if (removed?.preview) URL.revokeObjectURL(removed.preview); + return prev.filter(a => a.id !== id); + }); + }, []); + const composeMessage = (): string => { const parts: string[] = []; const ACTION_LABELS: Record = { @@ -63,14 +129,29 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { return parts.join('\n\n'); }; - const canSend = !disabled && !isStreaming && (input.trim() || quotes.length > 0); + const allUploaded = attachments.length === 0 || attachments.every(a => !a.uploading); + const hasContent = input.trim() || quotes.length > 0 || attachments.some(a => a.uploadedId); + const canSend = !disabled && !isStreaming && hasContent && allUploaded; const handleSend = () => { const message = composeMessage(); - if (!message) return; - onSend(message); + if (!message && attachments.length === 0) return; + + const fileIds = attachments.filter(a => a.uploadedId).map(a => a.uploadedId!); + const imageBlocks = attachments + .filter(a => a.uploadedId && a.uploadedMeta?.file_type === 'image') + .map(a => ({ + url: `/api/files/uploads/${a.uploadedId}`, + filename: a.uploadedMeta!.filename, + media_type: a.uploadedMeta!.media_type, + })); + + onSend(message, fileIds.length > 0 ? fileIds : undefined, imageBlocks.length > 0 ? imageBlocks : undefined); setInput(''); clearQuotes(); + // Clean up previews + attachments.forEach(a => { if (a.preview) URL.revokeObjectURL(a.preview); }); + setAttachments([]); if (textareaRef.current) textareaRef.current.style.height = 'auto'; }; @@ -81,6 +162,60 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { } }; + const handlePaste = (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + + if (files.length > 0) { + e.preventDefault(); + addFiles(files); + } + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCountRef.current++; + if (e.dataTransfer.types.includes('Files')) { + setIsDragging(true); + } + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCountRef.current--; + if (dragCountRef.current === 0) { + setIsDragging(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCountRef.current = 0; + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + addFiles(files); + } + }; + const handleInput = () => { const el = textareaRef.current; if (el) { @@ -90,7 +225,20 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { }; return ( -
+
+ {/* 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 = ''; + }} + /> +