From d18b40c76e1325754c442ce0028c6f7e7af3a1b3 Mon Sep 17 00:00:00 2001 From: Stanislaw <56738034+anvme@users.noreply.github.com> Date: Wed, 21 Jan 2026 03:14:08 +0100 Subject: [PATCH] Added image processing support to all providers --- backend/app/models/request.py | 52 +++- backend/app/providers/base.py | 26 +- backend/app/providers/claude.py | 19 +- backend/app/providers/gemini.py | 23 +- backend/app/providers/openrouter.py | 11 +- backend/app/routes/chat.py | 25 +- backend/app/services/pipeline.py | 52 +++- backend/app/utils/message_helpers.py | 274 ++++++++++++++++++ backend/tests/test_message_helpers.py | 96 ++++++ frontend/src/components/ChatInput.astro | 130 +++++++-- frontend/src/components/Navbar.astro | 2 +- .../src/components/admin/AdminNavbar.astro | 8 +- frontend/src/layouts/AdminLayout.astro | 10 + frontend/src/layouts/BaseLayout.astro | 12 +- frontend/src/scripts/attachments.ts | 226 +++++++++++---- frontend/src/scripts/chat.ts | 133 ++++++++- frontend/src/scripts/image.ts | 47 ++- frontend/src/scripts/provider-set-selector.ts | 9 +- frontend/src/scripts/storage.ts | 21 +- frontend/src/scripts/types/external.d.ts | 41 ++- nginx/templates/http.conf.template | 3 + nginx/templates/https.conf.template | 3 + 22 files changed, 1068 insertions(+), 155 deletions(-) create mode 100644 backend/app/utils/message_helpers.py create mode 100644 backend/tests/test_message_helpers.py diff --git a/backend/app/models/request.py b/backend/app/models/request.py index 1cfce7c..8b183c5 100644 --- a/backend/app/models/request.py +++ b/backend/app/models/request.py @@ -1,10 +1,56 @@ -from pydantic import BaseModel, Field -from typing import List, Optional +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional, Literal, Union + + +class TextContent(BaseModel): + """Text content part of a multimodal message""" + type: Literal["text"] = "text" + text: str + + +class ImageSource(BaseModel): + """Image source with base64 data""" + type: Literal["base64"] = "base64" + media_type: str # e.g., "image/jpeg", "image/png" + data: str # Base64-encoded image data (without data URL prefix) + + +class ImageContent(BaseModel): + """Image content part of a multimodal message""" + type: Literal["image"] = "image" + source: ImageSource class Message(BaseModel): + """Message with either text-only (string) or multimodal (array) content""" role: str = Field(..., pattern="^(user|assistant)$") - content: str + content: Union[str, List[Union[TextContent, ImageContent]]] + + model_config = ConfigDict( + # Allow both formats for backward compatibility + json_schema_extra={ + "examples": [ + { + "role": "user", + "content": "What is the capital of France?" + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "iVBORw0KGgoAAAANSUhEUgAA..." + } + } + ] + } + ] + } + ) class ChatRequest(BaseModel): diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py index 389735d..274bd23 100644 --- a/backend/app/providers/base.py +++ b/backend/app/providers/base.py @@ -57,6 +57,21 @@ class BaseProvider(ABC): """ return 3.0 if self.is_free_tier else 1.0 + @property + def supports_vision(self) -> bool: + """Whether this provider supports image inputs (vision capabilities).""" + # Override in providers that don't support vision + return True + + def _prepare_message_content(self, message: dict) -> dict: + """ + Prepare message content for this provider. + Override in subclasses for provider-specific formatting. + + Default: pass through as-is (works for Claude format) + """ + return message + @abstractmethod async def stream_chat( self, messages: list[dict], system_prompt: Optional[str] = None @@ -147,12 +162,19 @@ class OpenAIFormatProvider(BaseProvider): async def stream_chat( self, messages: list[dict], system_prompt: Optional[str] = None ) -> AsyncIterator[StreamChunk]: - """Stream chat completion using OpenAI API format.""" + """Stream chat completion using OpenAI API format with vision support.""" try: + # Import here to avoid circular dependency + from app.utils.message_helpers import format_for_openai + formatted_messages = [] if system_prompt: formatted_messages.append({"role": "system", "content": system_prompt}) - formatted_messages.extend(messages) + + # Format messages for OpenAI (converts images to image_url format) + for msg in messages: + formatted_msg = format_for_openai(msg) + formatted_messages.append(formatted_msg) payload = { "model": self.model, diff --git a/backend/app/providers/claude.py b/backend/app/providers/claude.py index ef81ee1..6e19796 100644 --- a/backend/app/providers/claude.py +++ b/backend/app/providers/claude.py @@ -32,11 +32,28 @@ class ClaudeProvider(BaseProvider): async def stream_chat( self, messages: list[dict], system_prompt: Optional[str] = None ) -> AsyncIterator[StreamChunk]: + """Stream chat responses from Claude API with vision support.""" try: + # Prepare messages (Claude accepts our universal format directly) + # Content can be string or array of content blocks + prepared_messages = [] + for msg in messages: + # Ensure content is in correct format + content = msg.get("content") + if isinstance(content, str): + # Text-only message + prepared_messages.append({"role": msg["role"], "content": content}) + elif isinstance(content, list): + # Multimodal message (text + images) + prepared_messages.append({"role": msg["role"], "content": content}) + else: + # Fallback + prepared_messages.append({"role": msg["role"], "content": str(content)}) + payload = { "model": self.model, "max_tokens": 4096, - "messages": messages, + "messages": prepared_messages, "stream": True, } if system_prompt: diff --git a/backend/app/providers/gemini.py b/backend/app/providers/gemini.py index 80120c7..114d3db 100644 --- a/backend/app/providers/gemini.py +++ b/backend/app/providers/gemini.py @@ -27,19 +27,36 @@ class GeminiProvider(BaseProvider): async def stream_chat( self, messages: list[dict], system_prompt: Optional[str] = None ) -> AsyncIterator[StreamChunk]: + """Stream chat responses from Gemini API with vision support.""" try: + # Import here to avoid circular dependency + from app.utils.message_helpers import format_for_gemini + # Convert messages to Gemini format contents = [] for msg in messages: + # Get Gemini-formatted message (with parts array) + formatted = format_for_gemini(msg) + + # Remap role: "assistant" -> "model" role = "user" if msg["role"] == "user" else "model" - contents.append({"role": role, "parts": [{"text": msg["content"]}]}) + + contents.append({ + "role": role, + "parts": formatted["parts"] + }) payload = { "contents": contents, - "generationConfig": {"maxOutputTokens": 4096}, + "generationConfig": { + "maxOutputTokens": 4096, + }, } + if system_prompt: - payload["systemInstruction"] = {"parts": [{"text": system_prompt}]} + payload["systemInstruction"] = { + "parts": [{"text": system_prompt}] + } url = f"/models/{self.model}:streamGenerateContent?key={self.api_key}&alt=sse" diff --git a/backend/app/providers/openrouter.py b/backend/app/providers/openrouter.py index adf029f..d3cc40e 100644 --- a/backend/app/providers/openrouter.py +++ b/backend/app/providers/openrouter.py @@ -47,12 +47,19 @@ class OpenRouterProvider(BaseProvider): async def stream_chat( self, messages: list[dict], system_prompt: Optional[str] = None ): - """Stream chat completion using OpenRouter API (OpenAI-compatible format).""" + """Stream chat completion using OpenRouter API (OpenAI-compatible format) with vision support.""" try: + # Import here to avoid circular dependency + from app.utils.message_helpers import format_for_openai + formatted_messages = [] if system_prompt: formatted_messages.append({"role": "system", "content": system_prompt}) - formatted_messages.extend(messages) + + # Format messages for OpenAI (converts images to image_url format) + for msg in messages: + formatted_msg = format_for_openai(msg) + formatted_messages.append(formatted_msg) payload = { "model": self.model, diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index fdaccbe..efbb47d 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -91,7 +91,16 @@ async def chat( question = "" for msg in reversed(request.messages): if msg.role == "user": - question = msg.content + # Extract text from content (handle both string and multimodal) + if isinstance(msg.content, str): + question = msg.content + elif isinstance(msg.content, list): + # Multimodal: extract text parts + text_parts = [] + for block in msg.content: + if isinstance(block, dict) and block.get("type") == "text": + text_parts.append(block.get("text", "")) + question = " ".join(text_parts).strip() or "(image)" break if not question: @@ -100,8 +109,18 @@ async def chat( # Create pipeline orchestrator orchestrator = PipelineOrchestrator(providers=providers) - # Convert messages to dict format - messages = [{"role": m.role, "content": m.content} for m in request.messages] + # Convert messages to dict format (properly serialize Pydantic models) + messages = [] + for m in request.messages: + # If content is a list of Pydantic models, convert to dicts + if isinstance(m.content, list): + content = [ + item.model_dump() if hasattr(item, 'model_dump') else item + for item in m.content + ] + else: + content = m.content + messages.append({"role": m.role, "content": content}) return StreamingResponse( orchestrator.run_pipeline(messages, question), diff --git a/backend/app/services/pipeline.py b/backend/app/services/pipeline.py index bd9b01e..afeb2ce 100644 --- a/backend/app/services/pipeline.py +++ b/backend/app/services/pipeline.py @@ -39,6 +39,7 @@ from app.services.prompts import ( from app.config import settings from app.utils.sse import format_pipeline_sse from app.utils.normalize import normalize_string_list, normalize_to_string, repair_llm_json +from app.utils.message_helpers import extract_text_only, has_images logger = logging.getLogger(__name__) @@ -97,6 +98,9 @@ class PipelineOrchestrator: """ Run the complete 4-step pipeline with SSE streaming. + IMPORTANT: Images are only sent in Step 1. + Steps 2-4 use text-only message history. + Yields SSE events for frontend to display progress. """ # Initialize pipeline state @@ -109,14 +113,39 @@ class PipelineOrchestrator: self.state.provider_to_label[provider.name] = label # ========================================================================= - # STEP 1: Initial Responses + # STEP 1: Initial Responses (with images if present) # ========================================================================= yield format_pipeline_sse("step_start", "system", {"step": 1, "name": "Initial Responses"}) self.state.current_step = 1 + # Check if any messages have images + has_any_images = any(has_images(msg) for msg in messages) + + if has_any_images: + # Filter to only vision-capable providers + vision_providers = [p for p in self.providers if p.supports_vision] + if not vision_providers: + yield format_pipeline_sse("error", "system", { + "message": "No vision-capable providers available for image analysis" + }) + return + logger.info(f"Image(s) detected. Using {len(vision_providers)} vision-capable providers: {[p.name for p in vision_providers]}") + # Temporarily use only vision providers for Step 1 + original_providers = self.providers + original_providers_by_name = self.providers_by_name.copy() + self.providers = vision_providers + # Update provider lookup dictionary + self.providers_by_name = {p.name: p for p in vision_providers} + + # Step 1: Use original messages (WITH images) async for event in self._run_step1(messages): yield event + # Restore original provider list if we filtered + if has_any_images: + self.providers = original_providers + self.providers_by_name = original_providers_by_name + yield format_pipeline_sse( "step_complete", "system", @@ -134,12 +163,21 @@ class PipelineOrchestrator: return # ========================================================================= - # STEP 2: MoA Refinement + # CONVERT TO TEXT-ONLY FOR STEPS 2-4 + # ========================================================================= + # This is CRITICAL: remove images to avoid redundant API calls + text_only_messages = [ + extract_text_only(msg) if msg.get("role") == "user" else msg + for msg in messages + ] + + # ========================================================================= + # STEP 2: MoA Refinement (text-only) # ========================================================================= yield format_pipeline_sse("step_start", "system", {"step": 2, "name": "MoA Refinement"}) self.state.current_step = 2 - async for event in self._run_step2(messages): + async for event in self._run_step2(text_only_messages): yield event yield format_pipeline_sse( @@ -158,12 +196,12 @@ class PipelineOrchestrator: return # ========================================================================= - # STEP 3: Peer Evaluation + # STEP 3: Peer Evaluation (text-only) # ========================================================================= yield format_pipeline_sse("step_start", "system", {"step": 3, "name": "Peer Evaluation"}) self.state.current_step = 3 - async for event in self._run_step3(messages): + async for event in self._run_step3(text_only_messages): yield event yield format_pipeline_sse( @@ -173,12 +211,12 @@ class PipelineOrchestrator: ) # ========================================================================= - # STEP 4: KEA Synthesis + # STEP 4: KEA Synthesis (text-only) # ========================================================================= yield format_pipeline_sse("step_start", "system", {"step": 4, "name": "KEA Synthesis"}) self.state.current_step = 4 - async for event in self._run_step4(messages): + async for event in self._run_step4(text_only_messages): yield event yield format_pipeline_sse( diff --git a/backend/app/utils/message_helpers.py b/backend/app/utils/message_helpers.py new file mode 100644 index 0000000..4e40706 --- /dev/null +++ b/backend/app/utils/message_helpers.py @@ -0,0 +1,274 @@ +"""Message format conversion utilities for multi-provider image support.""" + +from typing import Any +import re + + +def has_images(message: dict[str, Any]) -> bool: + """ + Check if a message contains images. + + Args: + message: Message dict with 'content' field + + Returns: + True if message contains image content blocks + """ + content = message.get("content") + if isinstance(content, str): + return False + if isinstance(content, list): + return any( + isinstance(item, dict) and item.get("type") == "image" + for item in content + ) + return False + + +def extract_text_only(message: dict[str, Any]) -> dict[str, Any]: + """ + Strip images from message, return text-only version. + + Args: + message: Message dict with 'content' field + + Returns: + New message dict with only text content + + Examples: + >>> msg = {"role": "user", "content": [{"type": "text", "text": "Hi"}, {"type": "image", ...}]} + >>> extract_text_only(msg) + {"role": "user", "content": "Hi"} + + >>> msg = {"role": "user", "content": "Plain text"} + >>> extract_text_only(msg) + {"role": "user", "content": "Plain text"} + """ + content = message.get("content") + + # Already text-only + if isinstance(content, str): + return message.copy() + + # Extract text parts from multimodal content + if isinstance(content, list): + text_parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + + # Join all text parts with newlines + text_content = "\n".join(text_parts).strip() + + return { + "role": message.get("role"), + "content": text_content or "(image)" # Fallback if only images + } + + return message.copy() + + +def format_for_claude(message: dict[str, Any]) -> dict[str, Any]: + """ + Convert message to Claude's format. + + Claude format for images: + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "iVBORw0KGgoAAAANSUhEUgAA..." # No data URL prefix + } + } + ] + } + + Args: + message: Universal format message + + Returns: + Claude-formatted message + """ + content = message.get("content") + + # Text-only: pass through + if isinstance(content, str): + return message.copy() + + # Multimodal: already in Claude format (our universal format matches Claude) + if isinstance(content, list): + return message.copy() + + return message.copy() + + +def format_for_openai(message: dict[str, Any]) -> dict[str, Any]: + """ + Convert message to OpenAI format (also used by Mistral, Grok, OpenRouter). + + OpenAI format for images: + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAA..." + } + } + ] + } + + Args: + message: Universal format message + + Returns: + OpenAI-formatted message + """ + content = message.get("content") + + # Text-only: pass through + if isinstance(content, str): + return message.copy() + + # Multimodal: convert to OpenAI format + if isinstance(content, list): + openai_content = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + openai_content.append({ + "type": "text", + "text": item.get("text", "") + }) + elif item.get("type") == "image": + # Convert from Claude format to OpenAI format + source = item.get("source", {}) + media_type = source.get("media_type", "image/jpeg") + data = source.get("data", "") + + # Reconstruct data URL + data_url = f"data:{media_type};base64,{data}" + + openai_content.append({ + "type": "image_url", + "image_url": { + "url": data_url + } + }) + + return { + "role": message.get("role"), + "content": openai_content + } + + return message.copy() + + +def format_for_gemini(message: dict[str, Any]) -> dict[str, Any]: + """ + Convert message to Gemini format. + + Gemini format for images: + { + "role": "user", # Note: will be converted to "user"/"model" by provider + "parts": [ + {"text": "What's in this image?"}, + { + "inline_data": { + "mime_type": "image/jpeg", + "data": "iVBORw0KGgoAAAANSUhEUgAA..." # No data URL prefix + } + } + ] + } + + Args: + message: Universal format message + + Returns: + Gemini-formatted message (intermediate format, provider will finalize) + """ + content = message.get("content") + + # Text-only: return as single text part + if isinstance(content, str): + return { + "role": message.get("role"), + "parts": [{"text": content}] + } + + # Multimodal: convert to Gemini parts format + if isinstance(content, list): + gemini_parts = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + gemini_parts.append({ + "text": item.get("text", "") + }) + elif item.get("type") == "image": + # Convert from Claude format to Gemini format + source = item.get("source", {}) + gemini_parts.append({ + "inline_data": { + "mime_type": source.get("media_type", "image/jpeg"), + "data": source.get("data", "") + } + }) + + return { + "role": message.get("role"), + "parts": gemini_parts + } + + return message.copy() + + +def get_mime_type_from_data_url(data_url: str) -> str: + """ + Extract MIME type from data URL. + + Args: + data_url: Base64 data URL (e.g., "data:image/jpeg;base64,...") + + Returns: + MIME type string (e.g., "image/jpeg") + + Examples: + >>> get_mime_type_from_data_url("data:image/png;base64,iVBORw0...") + "image/png" + >>> get_mime_type_from_data_url("invalid") + "image/jpeg" # Default fallback + """ + match = re.match(r'data:([^;]+);base64,', data_url) + if match: + return match.group(1) + return "image/jpeg" # Default fallback + + +def split_data_url(data_url: str) -> tuple[str, str]: + """ + Split data URL into MIME type and base64 data. + + Args: + data_url: Base64 data URL + + Returns: + Tuple of (mime_type, base64_data) + + Examples: + >>> split_data_url("data:image/png;base64,iVBORw0...") + ("image/png", "iVBORw0...") + """ + parts = data_url.split(',', 1) + if len(parts) == 2: + mime_type = get_mime_type_from_data_url(data_url) + return mime_type, parts[1] + return "image/jpeg", data_url # Fallback diff --git a/backend/tests/test_message_helpers.py b/backend/tests/test_message_helpers.py new file mode 100644 index 0000000..d3d8c8c --- /dev/null +++ b/backend/tests/test_message_helpers.py @@ -0,0 +1,96 @@ +"""Tests for message helper utilities.""" + +import pytest +from app.utils.message_helpers import ( + has_images, + extract_text_only, + format_for_claude, + format_for_openai, + format_for_gemini, + get_mime_type_from_data_url, + split_data_url, +) + + +def test_has_images_text_only(): + msg = {"role": "user", "content": "Hello"} + assert has_images(msg) == False + + +def test_has_images_with_image(): + msg = { + "role": "user", + "content": [ + {"type": "text", "text": "Hi"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "abc"}} + ] + } + assert has_images(msg) == True + + +def test_extract_text_only_from_string(): + msg = {"role": "user", "content": "Hello"} + result = extract_text_only(msg) + assert result["content"] == "Hello" + + +def test_extract_text_only_from_multimodal(): + msg = { + "role": "user", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "image", "source": {}}, + {"type": "text", "text": "World"} + ] + } + result = extract_text_only(msg) + assert result["content"] == "Hello\nWorld" + + +def test_format_for_openai(): + msg = { + "role": "user", + "content": [ + {"type": "text", "text": "What's this?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0" + } + } + ] + } + result = format_for_openai(msg) + assert result["content"][0]["type"] == "text" + assert result["content"][1]["type"] == "image_url" + assert result["content"][1]["image_url"]["url"] == "data:image/png;base64,iVBORw0" + + +def test_format_for_gemini(): + msg = { + "role": "user", + "content": [ + {"type": "text", "text": "What's this?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "abc123" + } + } + ] + } + result = format_for_gemini(msg) + assert "parts" in result + assert result["parts"][0]["text"] == "What's this?" + assert result["parts"][1]["inline_data"]["mime_type"] == "image/jpeg" + assert result["parts"][1]["inline_data"]["data"] == "abc123" + + +def test_split_data_url(): + mime, data = split_data_url("data:image/png;base64,iVBORw0KGgo") + assert mime == "image/png" + assert data == "iVBORw0KGgo" diff --git a/frontend/src/components/ChatInput.astro b/frontend/src/components/ChatInput.astro index fc37a5a..b2caebd 100644 --- a/frontend/src/components/ChatInput.astro +++ b/frontend/src/components/ChatInput.astro @@ -8,18 +8,21 @@ const { t } = Astro.props;