From a36509ae95bd19b37eec760cda28b6f3e0598c8b Mon Sep 17 00:00:00 2001 From: xprilion Date: Thu, 30 Apr 2026 00:50:16 +0530 Subject: [PATCH 1/2] MCP updates, image viewer, terminal integration --- README.md | 4 +- backend/openmlr/app.py | 2 + backend/openmlr/dependencies.py | 16 + backend/openmlr/models.py | 1 + backend/openmlr/routes/agent.py | 30 + backend/openmlr/routes/mcp.py | 63 +++ backend/openmlr/routes/projects.py | 32 +- backend/openmlr/services/session_manager.py | 11 +- backend/openmlr/tasks/agent_tasks.py | 39 +- backend/openmlr/tools/mcp.py | 122 +++- backend/pyproject.toml | 3 + backend/tests/test_session_manager.py | 2 +- frontend/src/App.tsx | 158 +++++- frontend/src/__tests__/FileTree.test.tsx | 14 - frontend/src/__tests__/RightPanel.test.tsx | 171 ++---- frontend/src/__tests__/Sidebar.test.tsx | 44 -- frontend/src/__tests__/api.test.ts | 5 +- frontend/src/api.ts | 14 +- frontend/src/components/FileTree.tsx | 36 +- frontend/src/components/ImageViewer.tsx | 137 +++++ frontend/src/components/RightPanel.tsx | 168 ++++-- frontend/src/components/Sidebar.tsx | 160 +++--- frontend/src/components/Terminal.tsx | 17 +- .../src/components/settings/McpSettings.tsx | 525 +++++++++++------- frontend/src/index.css | 12 + frontend/src/types.ts | 9 + site/docs/configuration.md | 87 ++- site/docs/index.md | 4 +- 28 files changed, 1243 insertions(+), 643 deletions(-) create mode 100644 backend/openmlr/routes/mcp.py create mode 100644 frontend/src/components/ImageViewer.tsx diff --git a/README.md b/README.md index ac0b2ca..e6817e6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## Features - **Projects & Workspaces** — Persistent workspaces with knowledge graphs, file trees, and cross-conversation memory. Research accumulates across chats. -- **Interactive terminal** — Built-in terminal connected to the project workspace. Run commands directly alongside AI-driven research. +- **Interactive terminal** — Built-in terminal tab connected to the project workspace. Run commands directly alongside AI-driven research. - **Plan + Execute modes** — Plan mode gathers context; Execute mode does the work. Toggle with `Cmd+M`. - **Paper research** — OpenAlex, Semantic Scholar, arXiv, CrossRef, Papers With Code. Reads full papers, crawls citation graphs. - **Paper writing** — Section-by-section drafting with auto-save. Export to Markdown/LaTeX. @@ -28,7 +28,7 @@ - **Background jobs** — Celery + Redis. Close the browser, come back later. - **Multi-provider LLMs** — OpenAI, Anthropic, OpenRouter, plus local models (Ollama, LM Studio). Add custom providers with OpenAI SDK, Anthropic SDK, OpenRouter, or LiteLLM compatibility. - **Model picker** — Browse models grouped by provider with logos, sorted by release date. Recently used models at the top. Fetches live from [models.dev](https://models.dev). -- **MCP servers** — Connect external tools via the Model Context Protocol. +- **MCP servers** — Connect remote HTTP/HTTPS MCP servers with custom authentication (Bearer, API key, headers). - **Onboarding flow** — Guided setup when no LLM provider is configured. ## Quick Start diff --git a/backend/openmlr/app.py b/backend/openmlr/app.py index 3d485f6..4b05060 100644 --- a/backend/openmlr/app.py +++ b/backend/openmlr/app.py @@ -84,6 +84,7 @@ async def lifespan(app: FastAPI): from .routes.compute import router as compute_router from .routes.health import router as health_router from .routes.keys import router as keys_router +from .routes.mcp import router as mcp_router from .routes.projects import router as projects_router from .routes.settings import router as settings_router from .routes.terminal import router as terminal_router @@ -94,6 +95,7 @@ async def lifespan(app: FastAPI): app.include_router(health_router) app.include_router(keys_router) app.include_router(compute_router) +app.include_router(mcp_router) app.include_router(projects_router) app.include_router(terminal_router) diff --git a/backend/openmlr/dependencies.py b/backend/openmlr/dependencies.py index a584066..7ca4811 100644 --- a/backend/openmlr/dependencies.py +++ b/backend/openmlr/dependencies.py @@ -57,3 +57,19 @@ async def get_current_user( ) return user + + +async def get_current_user_optional( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User | None: + """Like get_current_user but returns None instead of raising.""" + if credentials is None: + return None + payload = decode_access_token(credentials.credentials) + if payload is None: + return None + result = await db.execute( + select(User).where(User.id == int(payload["sub"]), User.is_active == True) + ) + return result.scalar_one_or_none() diff --git a/backend/openmlr/models.py b/backend/openmlr/models.py index 7a2f9d2..35c8870 100644 --- a/backend/openmlr/models.py +++ b/backend/openmlr/models.py @@ -75,6 +75,7 @@ class MessageSend(BaseModel): mode: Literal["plan", "execute"] | None = ( None # per-message mode; only plan or execute accepted ) + request_id: str | None = None # client-generated idempotency key class ApprovalRequest(BaseModel): diff --git a/backend/openmlr/routes/agent.py b/backend/openmlr/routes/agent.py index ec23863..e364d02 100644 --- a/backend/openmlr/routes/agent.py +++ b/backend/openmlr/routes/agent.py @@ -360,6 +360,32 @@ async def clear_conversation_compute( # ── Messaging ──────────────────────────────────────────── +# In-memory set of recently seen request IDs (with TTL-based eviction) +_recent_request_ids: dict[str, float] = {} +_REQUEST_ID_TTL = 30.0 # seconds + + +def _check_and_record_request_id(request_id: str | None) -> bool: + """Return True if this request_id is a duplicate. Records new IDs.""" + import time + + if not request_id: + return False # No idempotency key — allow through + + now = time.monotonic() + + # Evict expired entries (keep the dict small) + expired = [k for k, t in _recent_request_ids.items() if now - t > _REQUEST_ID_TTL] + for k in expired: + _recent_request_ids.pop(k, None) + + if request_id in _recent_request_ids: + return True # Duplicate + + _recent_request_ids[request_id] = now + return False + + @router.post("/message") async def send_message( body: MessageSend, @@ -369,6 +395,10 @@ async def send_message( ): from ..services.job_manager import USE_BACKGROUND_JOBS, get_job_manager + # Reject duplicate requests (idempotency guard) + if _check_and_record_request_id(body.request_id): + return {"ok": True, "duplicate": True} + sm = _sm(request) event_bus = _bus(request) job_manager = get_job_manager() diff --git a/backend/openmlr/routes/mcp.py b/backend/openmlr/routes/mcp.py new file mode 100644 index 0000000..0ce4d10 --- /dev/null +++ b/backend/openmlr/routes/mcp.py @@ -0,0 +1,63 @@ +"""MCP server management routes — test connections and get status.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from ..db import operations as ops +from ..db.engine import get_db +from ..db.models import User +from ..dependencies import get_current_user +from ..tools.mcp import test_mcp_connection + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/api/mcp", tags=["mcp"]) + + +class TestRequest(BaseModel): + url: str + headers: dict[str, str] | None = None + params: dict[str, str] | None = None + + +@router.post("/test") +async def test_connection( + body: TestRequest, + user: User = Depends(get_current_user), +): + """Test an MCP server connection without saving it.""" + if not body.url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Only http/https URLs are supported") + + result = await test_mcp_connection( + url=body.url, + headers=body.headers, + params=body.params, + ) + return result + + +@router.get("/status") +async def get_status( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get configured MCP servers with their enabled/disabled state.""" + user_settings = await ops.get_all_settings(db, user.id, category="mcp") + mcp_settings = user_settings.get("mcp", {}) + servers_config = mcp_settings.get("servers", {}) + + servers = [] + for name, config in servers_config.items(): + servers.append( + { + "name": name, + "url": config.get("url", ""), + "enabled": config.get("enabled", True), + "connected": False, # Will be updated via SSE in real-time + } + ) + + return {"servers": servers} diff --git a/backend/openmlr/routes/projects.py b/backend/openmlr/routes/projects.py index 5cbc25f..c62346b 100644 --- a/backend/openmlr/routes/projects.py +++ b/backend/openmlr/routes/projects.py @@ -17,14 +17,16 @@ import uuid as uuid_mod from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile +from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from ..auth.security import decode_access_token from ..db import operations as ops from ..db.engine import get_db from ..db.models import User -from ..dependencies import get_current_user +from ..dependencies import get_current_user, get_current_user_optional router = APIRouter(prefix="/api/projects", tags=["projects"]) @@ -434,14 +436,36 @@ async def list_files( return {"path": path, "entries": entries} +async def _get_user_from_token_param(token: str | None, db: AsyncSession) -> User | None: + """Resolve a user from a query-string token (for img/binary loads).""" + if not token: + return None + payload = decode_access_token(token) + if not payload: + return None + result = await db.execute( + select(User).where(User.id == int(payload["sub"]), User.is_active == True) + ) + return result.scalar_one_or_none() + + @router.get("/{project_uuid}/files/{file_path:path}") async def read_file( project_uuid: str, file_path: str, - user: User = Depends(get_current_user), + token: str | None = Query(None), + user: User | None = Depends(get_current_user_optional), db: AsyncSession = Depends(get_db), ): - """Read a file from the project workspace.""" + """Read a file from the project workspace. + + Supports auth via Bearer header or ?token= query param (for tags). + """ + # Fall back to token query param (for image tags that can't set headers) + if user is None and token: + user = await _get_user_from_token_param(token, db) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") project = await ops.get_project_by_uuid(db, project_uuid, user.id) if not project or not project.workspace_path: raise HTTPException(status_code=404, detail="Project not found") diff --git a/backend/openmlr/services/session_manager.py b/backend/openmlr/services/session_manager.py index e3eb967..ccdc255 100644 --- a/backend/openmlr/services/session_manager.py +++ b/backend/openmlr/services/session_manager.py @@ -45,7 +45,7 @@ def __init__(self, event_bus: EventBus, default_config: AgentConfig): self.event_bus = event_bus self.default_config = default_config self.current_conversation_id: int | None = None - self._is_processing: bool = False + self._processing: set[int] = set() # per-conversation processing locks self._message_queues: dict[int, list[str]] = {} def get_session(self, conversation_id: int) -> ActiveSession | None: @@ -314,14 +314,15 @@ async def process_message( message: str, mode: str = None, ) -> None: - """Queue and process a user message.""" + """Queue and process a user message (per-conversation locking).""" queue = self._message_queues.setdefault(conversation_id, []) queue.append((message, mode)) - if self._is_processing: + # Per-conversation lock: if this conversation is already processing, just queue + if conversation_id in self._processing: return - self._is_processing = True + self._processing.add(conversation_id) await self.event_bus.broadcast( AgentEvent(event_type="status", data={"status": "thinking..."}) ) @@ -340,7 +341,7 @@ async def process_message( AgentEvent(event_type="error", data={"error": str(e)}) ) finally: - self._is_processing = False + self._processing.discard(conversation_id) await self.event_bus.broadcast( AgentEvent(event_type="status", data={"status": "ready"}) ) diff --git a/backend/openmlr/tasks/agent_tasks.py b/backend/openmlr/tasks/agent_tasks.py index c0abe86..277925f 100644 --- a/backend/openmlr/tasks/agent_tasks.py +++ b/backend/openmlr/tasks/agent_tasks.py @@ -12,7 +12,7 @@ logger = logging.getLogger("openmlr.tasks") -@celery_app.task(bind=True, name="openmlr.tasks.agent_tasks.process_agent_message") +@celery_app.task(bind=True, name="openmlr.tasks.agent_tasks.process_agent_message", acks_late=False) def process_agent_message( self, job_id: str, @@ -82,19 +82,34 @@ async def _async_process_message( # Get worker-specific session factory to avoid event loop conflicts worker_session = get_worker_session() - # Update job status to running + # Idempotency guard: check if this job was already started (Celery redelivery). + # If the worker crashed and Celery redelivers the task, the user message was + # already persisted on the first attempt — we must not save it again. async with worker_session() as db: + job = await ops.get_agent_job(db, job_id) + if job and job.status in ("completed", "failed"): + # Job already finished on a previous attempt — nothing to do + logger.warning( + f"Job {job_id}: redelivery detected (status={job.status}), skipping entirely" + ) + return + is_redelivery = job is not None and job.status == "running" + if is_redelivery: + logger.warning( + f"Job {job_id}: redelivery detected (status=running), skipping user message save" + ) + + # Update job status to running await ops.update_job_status(db, job_id, "running", worker_id=worker_id) # Load existing messages for context messages = await ops.get_messages(db, conversation_id) existing_messages = [{"role": m.role, "content": m.content} for m in messages] - # Increment user message count - await ops.increment_user_message_count(db, conversation_id) - - # Add user message to database - await ops.add_message(db, conversation_id, "user", message) + if not is_redelivery: + # First execution: persist the user message + await ops.increment_user_message_count(db, conversation_id) + await ops.add_message(db, conversation_id, "user", message) # Broadcast that we're processing await publish_event( @@ -140,8 +155,14 @@ async def _async_process_message( username="user", ) - # Load existing messages into context - for msg in existing_messages: + # Load existing messages into context. + # On redelivery, existing_messages already includes the user message we saved + # on the first attempt. Exclude the trailing user message so run_agent_turn + # can add it without duplication (it always adds the user message to context). + history = existing_messages + if is_redelivery and history and history[-1].get("role") == "user": + history = history[:-1] + for msg in history: session.context_manager.add_message(msg) # Wire event broadcasting to Redis pub/sub diff --git a/backend/openmlr/tools/mcp.py b/backend/openmlr/tools/mcp.py index ebb3048..87bd888 100644 --- a/backend/openmlr/tools/mcp.py +++ b/backend/openmlr/tools/mcp.py @@ -1,4 +1,8 @@ -"""MCP (Model Context Protocol) server integration.""" +"""MCP (Model Context Protocol) server integration. + +Only HTTP/HTTPS MCP servers are supported. Each server config can include +custom authentication via headers or query parameters. +""" import logging import os @@ -32,15 +36,50 @@ def process_mcp_config(config: dict) -> dict: return processed +def _build_url_with_params(url: str, params: dict[str, str] | None) -> str: + """Append query parameters to a URL.""" + if not params: + return url + from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + + parsed = urlparse(url) + existing = parse_qs(parsed.query, keep_blank_values=True) + for k, v in params.items(): + existing[k] = [v] + new_query = urlencode({k: v[0] for k, v in existing.items()}) + return urlunparse(parsed._replace(query=new_query)) + + +def _create_mcp_client(url: str, headers: dict | None = None): + """Create a fastmcp Client with optional custom headers on the transport. + + Uses StreamableHttpTransport by default (POST-based, newer standard). + Falls back to SSETransport for URLs ending in /sse (legacy convention). + """ + from fastmcp import Client as MCPClient + from fastmcp.client.transports.http import StreamableHttpTransport + from fastmcp.client.transports.sse import SSETransport + + if headers: + # Pick transport based on URL convention (same logic as fastmcp's infer_transport) + if url.rstrip("/").endswith("/sse"): + transport = SSETransport(url=url, headers=headers) + else: + transport = StreamableHttpTransport(url=url, headers=headers) + return MCPClient(transport) + return MCPClient(url) + + class MCPManager: """ Manages MCP server connections for a session. - Supports connecting to multiple servers and registering their tools. + Only HTTP/HTTPS MCP servers are supported. """ def __init__(self): self._clients: dict[str, object] = {} # server_name -> client self._connected: set[str] = set() + self._configs: dict[str, dict] = {} # server_name -> processed config async def connect_servers( self, @@ -59,7 +98,7 @@ async def connect_servers( total_registered = 0 try: - from fastmcp import Client as MCPClient + from fastmcp import Client as _MCPClient # noqa: F401 — validates import except ImportError: log.warning("fastmcp not installed — MCP servers will not be available") return 0 @@ -67,6 +106,7 @@ async def connect_servers( for server_name, server_config in mcp_configs.items(): # Skip disabled servers if not server_config.get("enabled", True): + self._configs[server_name] = process_mcp_config(server_config) continue # Skip already connected servers @@ -74,25 +114,31 @@ async def connect_servers( continue config = process_mcp_config(server_config) - transport = config.get("transport", "http") + self._configs[server_name] = config url = config.get("url", "") - command = config.get("command", "") + + if not url: + log.warning(f"MCP server {server_name}: missing URL") + continue + + # Validate URL scheme + if not url.startswith(("http://", "https://")): + log.warning( + f"MCP server {server_name}: only http/https URLs are supported, got {url[:20]}" + ) + continue try: - if transport == "http" and url: - client = MCPClient(url) - elif transport == "stdio" and command: - args = config.get("args", []) - env = config.get("env", {}) - # Merge environment variables - full_env = {**os.environ, **env} if env else None - client = MCPClient(command, args=args, env=full_env) - else: - log.warning(f"MCP server {server_name}: invalid config (transport={transport})") - continue + # Build URL with query params if configured + params = config.get("params") + final_url = _build_url_with_params(url, params) + + # Build headers dict if configured + headers = config.get("headers") or None + + client = _create_mcp_client(final_url, headers) # Connect and register tools - # Note: We keep the client connection open for the session await client.__aenter__() count = await tool_router.register_mcp_tools( client, @@ -121,12 +167,54 @@ async def disconnect_all(self): self._clients.clear() self._connected.clear() + self._configs.clear() @property def connected_servers(self) -> set[str]: """Return names of connected MCP servers.""" return self._connected.copy() + def get_server_statuses(self) -> list[dict]: + """Return status info for all known servers.""" + statuses = [] + for name, config in self._configs.items(): + statuses.append( + { + "name": name, + "url": config.get("url", ""), + "enabled": config.get("enabled", True), + "connected": name in self._connected, + } + ) + return statuses + + +async def test_mcp_connection( + url: str, headers: dict | None = None, params: dict | None = None +) -> dict: + """ + Test an MCP server connection. + Returns {"ok": True, "tools": int} on success or {"ok": False, "error": str} on failure. + """ + try: + from fastmcp import Client as _MCPClient # noqa: F401 — validates import + except ImportError: + return {"ok": False, "error": "fastmcp not installed on server"} + + final_url = _build_url_with_params(url, params) + + try: + client = _create_mcp_client(final_url, headers) + await client.__aenter__() + try: + tools = await client.list_tools() + tool_count = len(tools) if tools else 0 + return {"ok": True, "tools": tool_count} + finally: + await client.__aexit__(None, None, None) + except Exception as e: + return {"ok": False, "error": str(e)} + # Legacy function for backward compatibility async def connect_mcp_servers( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 59f5673..d6fbdfb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -45,6 +45,9 @@ dependencies = [ # Background jobs "celery>=5.4.0", "redis>=5.0.0", + + # MCP (Model Context Protocol) + "fastmcp>=2.0.0", ] [project.optional-dependencies] diff --git a/backend/tests/test_session_manager.py b/backend/tests/test_session_manager.py index c9a4386..70120a8 100644 --- a/backend/tests/test_session_manager.py +++ b/backend/tests/test_session_manager.py @@ -32,7 +32,7 @@ def session_manager(event_bus, config): class TestSessionManager: async def test_initial_state(self, session_manager): assert session_manager.current_conversation_id is None - assert session_manager.is_processing is False + assert len(session_manager._processing) == 0 assert session_manager.get_current_session() is None async def test_get_session_nonexistent(self, session_manager): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c51401..9e96a06 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,12 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { Routes, Route, Navigate, useNavigate, useParams } from 'react-router-dom'; -import { Copy, Check } from 'lucide-react'; +import { Copy, Check, Menu, PanelRightOpen } from 'lucide-react'; import { ComputeSelector } from './components/ComputeSelector'; import { ProjectSelector } from './components/ProjectSelector'; import { useSSE } from './hooks/useSSE'; import { useJobStatus } from './hooks/useJobStatus'; import { api } from './api'; -import type { AgentEvent, Message, Conversation, User, QuestionsPayload, PlanTask, Resource, ContextUsage, SearchBudget, Project, TodoApprovalPayload, OpenFile } from './types'; +import type { AgentEvent, Message, Conversation, User, QuestionsPayload, PlanTask, Resource, ContextUsage, SearchBudget, Project, TodoApprovalPayload, OpenFile, McpServerStatus } from './types'; import { MessageList } from './components/MessageList'; import { InputArea, type Mode } from './components/InputArea'; import { Sidebar } from './components/Sidebar'; @@ -29,6 +29,7 @@ import { McpSettings } from './components/settings/McpSettings'; import { ComputeSettings } from './components/settings/ComputeSettings'; import { WritingSettings } from './components/settings/WritingSettings'; import { EditorPanel } from './components/EditorPanel'; +import { ImageViewer } from './components/ImageViewer'; let msgId = 0; const nextId = () => `msg-${++msgId}`; @@ -41,7 +42,14 @@ function findLastIndex(arr: T[], predicate: (item: T) => boolean): number { return -1; } type ConvStatus = 'idle' | 'processing' | 'waiting_approval' | 'waiting_input'; -type MainTab = 'agent' | 'editor'; +type MainTab = 'agent' | 'editor' | 'terminal' | 'image'; + +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']); + +function isImageFile(path: string): boolean { + const ext = '.' + (path.split('.').pop()?.toLowerCase() || ''); + return IMAGE_EXTENSIONS.has(ext); +} /** Map file extensions to Monaco language IDs. */ function detectLanguage(path: string): string { @@ -128,13 +136,16 @@ function ChatUI({ const [activeProject, setActiveProject] = useState(null); const [showProjectModal, setShowProjectModal] = useState(false); const [showManageProjects, setShowManageProjects] = useState(false); - const [terminalOpen, setTerminalOpen] = useState(false); const [terminalConnected, setTerminalConnected] = useState(false); const [todoApprovalPayload, setTodoApprovalPayload] = useState(null); const [fileTreeRefreshKey, setFileTreeRefreshKey] = useState(0); const [mainTab, setMainTab] = useState('agent'); const [openFiles, setOpenFiles] = useState([]); const [activeFilePath, setActiveFilePath] = useState(null); + const [imageTab, setImageTab] = useState<{ path: string; url: string } | null>(null); + const [mcpServers, setMcpServers] = useState([]); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [mobileRightOpen, setMobileRightOpen] = useState(false); // Refs to always have current values in SSE callback (avoids stale closure) const currentConvUuidRef = useRef(currentConvUuid); @@ -146,6 +157,10 @@ function ChatUI({ const switchSeqRef = useRef(0); // Timer ref so pending reload timeouts can be cleared on conversation switch const reloadTimerRef = useRef | null>(null); + // Guard against concurrent/duplicate message sends + const sendingRef = useRef(false); + // Debounce timer for reloadConversationMessages + const reloadDebounceRef = useRef | null>(null); // ── Derived per-conversation processing state ───────── const currentStatus = currentConvUuid ? (convStatuses[currentConvUuid] || 'idle') : 'idle'; @@ -188,6 +203,15 @@ function ChatUI({ } }, []); + const loadMcpServers = useCallback(async () => { + try { + const data = await api.getMcpStatus(); + setMcpServers(data.servers || []); + } catch { + setMcpServers([]); + } + }, []); + const loadActiveCompute = useCallback(async (uuid: string) => { try { const data = await api.getConversationCompute(uuid); @@ -203,6 +227,7 @@ function ChatUI({ const [, projData] = await Promise.all([ loadComputeNodes(), api.listProjects().catch(() => ({ projects: [] })), + loadMcpServers(), ]); const allProjects: Project[] = projData.projects || []; setProjects(allProjects); @@ -375,8 +400,8 @@ function ChatUI({ } catch { /* */ } }, [currentConvUuid, loadActiveCompute]); - // Helper to reload messages from DB for a given conversation - const reloadConversationMessages = useCallback(async (uuid: string) => { + // Helper to reload messages from DB for a given conversation (raw, no debounce) + const _doReloadMessages = useCallback(async (uuid: string) => { try { const data = await api.getConversation(uuid); // Guard: only apply if this is still the active conversation @@ -398,6 +423,15 @@ function ChatUI({ } catch { /* ignore */ } }, []); + // Debounced version: collapses rapid successive reloads into one (300ms window) + const reloadConversationMessages = useCallback((uuid: string) => { + if (reloadDebounceRef.current) clearTimeout(reloadDebounceRef.current); + reloadDebounceRef.current = setTimeout(() => { + reloadDebounceRef.current = null; + _doReloadMessages(uuid); + }, 300); + }, [_doReloadMessages]); + // ── Auto-compact when context usage > 90% ───────────── const lastCompactRef = useRef(0); useEffect(() => { @@ -565,6 +599,8 @@ function ChatUI({ case 'todo_approval_required': setTodoApprovalPayload(data as TodoApprovalPayload); setCurrentConvStatus('waiting_approval'); break; case 'turn_complete': setApprovalEvent(null); setTodoApprovalPayload(null); + // Cancel any pending job_complete reload — SSE events already updated state + if (reloadTimerRef.current) { clearTimeout(reloadTimerRef.current); reloadTimerRef.current = null; } setMessages((prev) => { const c = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); const last = c[c.length - 1]; @@ -642,22 +678,37 @@ function ChatUI({ }, [currentConvUuid, jobProcessing, connected]); const sendMessage = useCallback(async (text: string, mode: string) => { + // Prevent concurrent/duplicate sends + if (sendingRef.current) return; + sendingRef.current = true; + setInputText(''); setMessages((prev) => [...prev, { id: nextId(), role: 'user', content: text, metadata: { tool: mode } }]); setCurrentConvStatus('processing'); - try { await api.sendMessage(text, mode); } catch (err: any) { + try { + await api.sendMessage(text, mode); + } catch (err: any) { setCurrentConvStatus('idle'); setMessages((prev) => [...prev, { id: nextId(), role: 'error', content: `Failed to send: ${err.message}` }]); + } finally { + sendingRef.current = false; } }, [setCurrentConvStatus]); const handleFileOpen = useCallback((path: string, content: string) => { + // Images open in a dedicated Image tab + if (isImageFile(path) && activeProject?.uuid) { + const url = api.fileUrl(activeProject.uuid, path); + setImageTab({ path, url }); + setMainTab('image'); + return; + } setOpenFiles((prev) => { if (prev.some((f) => f.path === path)) return prev; return [...prev, { path, content, language: detectLanguage(path) }]; }); setActiveFilePath(path); setMainTab('editor'); - }, []); + }, [activeProject]); const handleCloseFile = useCallback((path: string) => { setOpenFiles((prev) => { @@ -688,8 +739,16 @@ function ChatUI({ {/* Header */}
+ {/* Mobile sidebar toggle */} + OpenMLR - OpenMLR + OpenMLR + {/* Mobile right panel toggle */} +
@@ -718,19 +785,17 @@ function ChatUI({ setTerminalOpen((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} />
- {/* Agent / Editor tab bar */} + {/* Agent / Editor / Terminal tab bar */}
+ + {/* Closable Image tab */} + {imageTab && ( + + )}
{/* Agent tab */} @@ -819,20 +917,28 @@ function ChatUI({ onCloseFile={handleCloseFile} /> )} + + {/* Terminal tab */} + {mainTab === 'terminal' && ( + + )} + + {/* Image tab */} + {mainTab === 'image' && imageTab && ( + + )}
{/* RightPanel is fixed position, doesn't affect flex layout */} - setRightPanelOpen((v) => !v)} onViewReport={(r) => setViewingReport(r)} onFileOpen={handleFileOpen} onSearchBudgetChange={(newMax) => setSearchBudget((prev) => prev ? { ...prev, max: newMax } : prev)} /> + setRightPanelOpen((v) => !v)} onMobileClose={() => setMobileRightOpen(false)} onViewReport={(r) => setViewingReport(r)} onFileOpen={handleFileOpen} onSearchBudgetChange={(newMax) => setSearchBudget((prev) => prev ? { ...prev, max: newMax } : prev)} /> - - {/* Terminal panel */} - setTerminalOpen((v) => !v)} - onConnectionChange={setTerminalConnected} - rightOffset={rightPanelOpen ? 288 : 48} - /> {viewingReport && setViewingReport(null)} />} {showProjectModal && setShowProjectModal(false)} onCreate={async (p) => { diff --git a/frontend/src/__tests__/FileTree.test.tsx b/frontend/src/__tests__/FileTree.test.tsx index c32f370..01998db 100644 --- a/frontend/src/__tests__/FileTree.test.tsx +++ b/frontend/src/__tests__/FileTree.test.tsx @@ -35,13 +35,6 @@ describe('FileTree', () => { expect(screen.getByText('Loading files...')).toBeInTheDocument(); }); - it('renders refresh button', async () => { - render(); - await waitFor(() => { - expect(screen.getByTitle('Refresh')).toBeInTheDocument(); - }); - }); - it('shows "No files yet" when directory is empty', async () => { const { api } = await import('../api'); vi.mocked(api.listFiles).mockResolvedValueOnce({ entries: [] }); @@ -121,11 +114,4 @@ describe('FileTree', () => { expect(onFileSelect).not.toHaveBeenCalled(); }); - it('renders Files header', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Files')).toBeInTheDocument(); - }); - }); }); diff --git a/frontend/src/__tests__/RightPanel.test.tsx b/frontend/src/__tests__/RightPanel.test.tsx index 14b3803..40020c8 100644 --- a/frontend/src/__tests__/RightPanel.test.tsx +++ b/frontend/src/__tests__/RightPanel.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { RightPanel } from '../components/RightPanel'; -import type { PlanTask, Resource, ContextUsage, SearchBudget } from '../types'; +import type { PlanTask, Resource, ContextUsage, SearchBudget, McpServerStatus } from '../types'; vi.mock('../api', () => ({ api: { @@ -24,19 +24,23 @@ describe('RightPanel', () => { const mockContext: ContextUsage = { used: 50000, max: 200000, ratio: 0.25 }; const mockSearchBudget: SearchBudget = { used: 5, max: 25 }; + const mockMcpServers: McpServerStatus[] = [ + { name: 'test-server', url: 'https://mcp.example.com', enabled: true, connected: true }, + ]; + + const baseProps = { + resources: [] as Resource[], + contextUsage: null as ContextUsage | null, + searchBudget: null as SearchBudget | null, + mcpServers: [] as McpServerStatus[], + projectUuid: null as string | null, + onToggle: vi.fn(), + onViewReport: vi.fn(), + }; it('renders collapsed rail with expand button when not visible', () => { render( - + ); expect(screen.getByTitle('Expand panel')).toBeInTheDocument(); expect(screen.getByTitle('Todos')).toBeInTheDocument(); @@ -44,16 +48,7 @@ describe('RightPanel', () => { it('renders tasks when visible', () => { render( - + ); expect(screen.getByText('Read papers')).toBeInTheDocument(); expect(screen.getByText('Implement model')).toBeInTheDocument(); @@ -62,49 +57,21 @@ describe('RightPanel', () => { it('shows task completion count badge', () => { render( - + ); - // CollapsiblePanel renders badge with "done/total" expect(screen.getByText('1/3')).toBeInTheDocument(); }); it('shows "No tasks yet" when empty', () => { render( - + ); expect(screen.getByText('No tasks yet')).toBeInTheDocument(); }); it('renders context gauge with data', () => { render( - + ); expect(screen.getByText(/50k/)).toBeInTheDocument(); expect(screen.getByText(/200k/)).toBeInTheDocument(); @@ -112,101 +79,48 @@ describe('RightPanel', () => { it('renders context gauge placeholder when null', () => { render( - + ); expect(screen.getByText('Context: --')).toBeInTheDocument(); }); it('renders search budget gauge', () => { render( - + ); expect(screen.getByText(/Searches:/)).toBeInTheDocument(); }); it('renders default search budget when null', () => { render( - + ); expect(screen.getByText('Searches: 0 / 25')).toBeInTheDocument(); }); it('does not render resources section (resources are now in FileTree)', () => { render( - + ); - // Resources section was removed — resources now appear as files in the workspace expect(screen.queryByText('No resources yet')).not.toBeInTheDocument(); }); it('does not render paper export buttons (papers are now in FileTree)', () => { render( ); - // Paper export buttons were removed — papers are now files in workspace expect(screen.queryByText('.md')).not.toBeInTheDocument(); expect(screen.queryByText('.tex')).not.toBeInTheDocument(); }); it('shows task count badge on collapsed rail', () => { render( - + ); const todosButton = screen.getByTitle('Todos'); const badge = todosButton.querySelector('span'); @@ -215,17 +129,30 @@ describe('RightPanel', () => { it('has search budget settings button', () => { render( - + ); expect(screen.getByTitle('Change search budget')).toBeInTheDocument(); }); + + it('renders MCP servers section when servers are configured', () => { + render( + + ); + expect(screen.getByText('MCP Servers')).toBeInTheDocument(); + expect(screen.getByText('test-server')).toBeInTheDocument(); + }); + + it('shows MCP server count badge on collapsed rail', () => { + render( + + ); + expect(screen.getByTitle('MCP Servers')).toBeInTheDocument(); + }); + + it('does not render MCP section when no servers configured', () => { + render( + + ); + expect(screen.queryByText('MCP Servers')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/Sidebar.test.tsx b/frontend/src/__tests__/Sidebar.test.tsx index 690e90b..7f86fd6 100644 --- a/frontend/src/__tests__/Sidebar.test.tsx +++ b/frontend/src/__tests__/Sidebar.test.tsx @@ -47,13 +47,9 @@ const defaultProps = { currentUuid: null as string | null, user: mockUser, convStatuses: {} as Record, - terminalOpen: false, - terminalConnected: false, - terminalSessionCount: 0, onSwitch: vi.fn(), onNew: vi.fn(), onDelete: vi.fn(), - onTerminalToggle: vi.fn(), }; describe('Sidebar', () => { @@ -149,44 +145,4 @@ describe('Sidebar', () => { expect(onNew).toHaveBeenCalled(); }); - it('renders terminal button with Closed status when terminal is not open', () => { - render( - - - - ); - expect(screen.getByText('Terminal')).toBeInTheDocument(); - expect(screen.getByText('Closed')).toBeInTheDocument(); - }); - - it('renders terminal button with Connected status when terminal is open and connected', () => { - render( - - - - ); - expect(screen.getByText('Terminal')).toBeInTheDocument(); - expect(screen.getByText('Connected')).toBeInTheDocument(); - expect(screen.getByText('1')).toBeInTheDocument(); - }); - - it('renders terminal button with Disconnected when terminal is open but not connected', () => { - render( - - - - ); - expect(screen.getByText('Disconnected')).toBeInTheDocument(); - }); - - it('calls onTerminalToggle when terminal button clicked', () => { - const onTerminalToggle = vi.fn(); - render( - - - - ); - fireEvent.click(screen.getByText('Terminal')); - expect(onTerminalToggle).toHaveBeenCalled(); - }); }); diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index 15400b8..381adc5 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -53,7 +53,10 @@ describe('api.sendMessage', () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe('/api/message'); expect(opts.method).toBe('POST'); - expect(JSON.parse(opts.body)).toEqual({ message: 'Hello', mode: 'general' }); + const body = JSON.parse(opts.body); + expect(body.message).toBe('Hello'); + expect(body.mode).toBe('general'); + expect(body.request_id).toBeDefined(); }); }); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7fd7afb..cf9a6ba 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -70,7 +70,8 @@ export const api = { getMe: () => get('/api/auth/me'), // Messages - sendMessage: (message: string, mode?: string) => post('/api/message', { message, mode }), + sendMessage: (message: string, mode?: string) => + post('/api/message', { message, mode, request_id: crypto.randomUUID() }), submitAnswers: (answers: Record) => post('/api/answers', { answers }), interrupt: () => post('/api/interrupt', {}), sendApproval: (approvals: Record) => post('/api/approval', { approvals }), @@ -136,6 +137,12 @@ export const api = { get(`/api/projects/${projectUuid}/files${path ? `?path=${encodeURIComponent(path)}` : ''}`), readFile: (projectUuid: string, filePath: string) => get(`/api/projects/${projectUuid}/files/${encodeURIComponent(filePath)}`), + /** Build an authenticated URL for directly loading a binary file (e.g. images). */ + fileUrl: (projectUuid: string, filePath: string): string => { + const token = getToken(); + const base = `/api/projects/${projectUuid}/files/${encodeURIComponent(filePath)}`; + return token ? `${base}?token=${token}` : base; + }, writeFile: (projectUuid: string, filePath: string, content: string) => put(`/api/projects/${projectUuid}/files/${encodeURIComponent(filePath)}`, { content }), deleteFile: (projectUuid: string, filePath: string) => @@ -152,4 +159,9 @@ export const api = { post('/api/compute/test', { type, config }), probeComputeNode: (id: number) => post(`/api/compute/nodes/${id}/probe`, {}), setDefaultComputeNode: (id: number) => post(`/api/compute/nodes/${id}/set-default`, {}), + + // MCP Servers + getMcpStatus: () => get('/api/mcp/status'), + testMcpServer: (url: string, headers?: Record, params?: Record) => + post('/api/mcp/test', { url, headers: headers || null, params: params || null }), }; diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx index 7b6aa93..7114ad1 100644 --- a/frontend/src/components/FileTree.tsx +++ b/frontend/src/components/FileTree.tsx @@ -29,6 +29,8 @@ interface TreeNode extends FileNode { expanded?: boolean; } +const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico']); + const FILE_ICONS: Record = { '.py': , '.js': , @@ -240,6 +242,12 @@ export function FileTree({ projectUuid, refreshKey, onFileSelect }: Props) { }, [loadDirectory]); const handleSelect = useCallback(async (path: string) => { + const ext = '.' + (path.split('.').pop()?.toLowerCase() || ''); + if (IMAGE_EXTS.has(ext)) { + // For image files, pass through with empty content — the parent handles the URL + onFileSelect?.(path, ''); + return; + } try { const data = await api.readFile(projectUuid, path); if (data.content !== undefined) { @@ -250,13 +258,15 @@ export function FileTree({ projectUuid, refreshKey, onFileSelect }: Props) { } }, [projectUuid, onFileSelect]); - const handleRefresh = useCallback(async () => { - setLoading(true); - setError(null); - const entries = await loadDirectory(''); - setNodes(entries); - setLoading(false); - }, [loadDirectory]); + // Auto-refresh every 10 seconds + useEffect(() => { + const interval = setInterval(() => { + loadDirectory('').then((entries) => { + setNodes((prev) => mergeWithPreviousState(entries, prev)); + }); + }, 10000); + return () => clearInterval(interval); + }, [loadDirectory, mergeWithPreviousState]); if (loading) { return ( @@ -278,18 +288,6 @@ export function FileTree({ projectUuid, refreshKey, onFileSelect }: Props) { return (
- {/* Header */} -
- Files - -
- {/* Tree */}
{nodes.length === 0 ? ( diff --git a/frontend/src/components/ImageViewer.tsx b/frontend/src/components/ImageViewer.tsx new file mode 100644 index 0000000..9054dcc --- /dev/null +++ b/frontend/src/components/ImageViewer.tsx @@ -0,0 +1,137 @@ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { ZoomIn, ZoomOut, RotateCw, Maximize2, Minimize2 } from 'lucide-react'; + +interface Props { + readonly src: string; + readonly filename: string; +} + +export function ImageViewer({ src, filename }: Props) { + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + const [dragging, setDragging] = useState(false); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null); + const [fitMode, setFitMode] = useState(true); + const containerRef = useRef(null); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + setZoom((z) => Math.min(Math.max(z + (e.deltaY > 0 ? -0.1 : 0.1), 0.1), 10)); + setFitMode(false); + }, []); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; + setDragging(true); + setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y }); + }, [offset]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!dragging) return; + setOffset({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); + }, [dragging, dragStart]); + + const handleMouseUp = useCallback(() => { + setDragging(false); + }, []); + + const resetView = useCallback(() => { + setZoom(1); + setRotation(0); + setOffset({ x: 0, y: 0 }); + setFitMode(true); + }, []); + + const handleLoad = useCallback((e: React.SyntheticEvent) => { + const img = e.currentTarget; + setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }); + }, []); + + // Reset view when src changes + useEffect(() => { + resetView(); + }, [src, resetView]); + + return ( +
+ {/* Toolbar */} +
+
+ {filename} + {naturalSize && ( + + {naturalSize.w} x {naturalSize.h} + + )} +
+
+ + {Math.round(zoom * 100)}% + + + + +
+
+ + {/* Image canvas */} +
+ {filename} +
+
+ ); +} diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 3b6849c..263b0d4 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -10,21 +10,25 @@ import { Settings, PanelRightClose, PanelRightOpen, + Plug, } from 'lucide-react'; import { api } from '../api'; import { FileTree } from './FileTree'; import { CollapsiblePanel } from './CollapsiblePanel'; -import type { PlanTask, Resource, ContextUsage, SearchBudget } from '../types'; +import type { PlanTask, Resource, ContextUsage, SearchBudget, McpServerStatus } from '../types'; interface Props { readonly tasks: readonly PlanTask[]; readonly resources: readonly Resource[]; readonly contextUsage: ContextUsage | null; readonly searchBudget: SearchBudget | null; + readonly mcpServers: readonly McpServerStatus[]; readonly visible: boolean; + readonly mobileOpen?: boolean; readonly projectUuid: string | null; readonly fileTreeRefreshKey?: number; onToggle: () => void; + onMobileClose?: () => void; onViewReport: (resource: Resource) => void; onFileOpen?: (path: string, content: string) => void; onSearchBudgetChange?: (newMax: number) => void; @@ -104,7 +108,7 @@ function SearchBudgetDialog({ currentMax, onSave, onClose }: { currentMax: numbe ); } -export function RightPanel({ tasks, resources: _resources, contextUsage, searchBudget, visible, projectUuid, fileTreeRefreshKey, onToggle, onViewReport: _onViewReport, onFileOpen, onSearchBudgetChange }: Props) { +export function RightPanel({ tasks, resources: _resources, contextUsage, searchBudget, mcpServers, visible, mobileOpen, projectUuid, fileTreeRefreshKey, onToggle, onMobileClose, onViewReport: _onViewReport, onFileOpen, onSearchBudgetChange }: Props) { const [showBudgetDialog, setShowBudgetDialog] = useState(false); const done = tasks.filter((t) => t.status === 'completed').length; @@ -114,49 +118,9 @@ export function RightPanel({ tasks, resources: _resources, contextUsage, searchB const budgetMax = searchBudget?.max ?? 25; const budgetPct = budgetMax > 0 ? Math.round((budgetUsed / budgetMax) * 100) : 0; - // Collapsed state: show narrow icon rail - if (!visible) { - return ( - - ); - } - - // Expanded state — no tabs, everything stacked - return ( -