From 73073715501a72a2331d7de77c2e37959c9e590e Mon Sep 17 00:00:00 2001 From: Hernan Date: Thu, 28 May 2026 19:28:05 -0300 Subject: [PATCH 01/10] Add fullscreen mode and controls to chat panel Introduce a fullscreen toggle for the ChatPanel with an isFullscreen state and toggleFullscreen handler (restores default dimensions to 700x520 and persists them). Update panel layout/styles to occupy the full viewport in fullscreen and hide resize handles while fullscreen. Add header window controls (maximize/restore and close) and improve ChatWidget FAB behavior: smaller when panel open, updated titles (Spanish) and icon markup. Refresh CSS: FAB sizing/centering/transitions, enhanced resize-handle visuals, header and control button styles, and corner grip indicator. --- .../src/components/assistant/ChatPanel.tsx | 90 ++++++++++---- .../src/components/assistant/ChatWidget.tsx | 6 +- .../static/frontend/src/css/chat-widget.css | 111 +++++++++++++++++- 3 files changed, 178 insertions(+), 29 deletions(-) diff --git a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx index 6699eaf6..942ad212 100644 --- a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx +++ b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx @@ -46,6 +46,20 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { }) const [messages, setMessages] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isFullscreen, setIsFullscreen] = useState(false) + + const toggleFullscreen = () => { + setIsFullscreen(prev => { + if (prev) { + // Exiting fullscreen → restore default dimensions + setPanelW(700) + setPanelH(520) + localStorage.setItem(WIDTH_KEY, '700') + localStorage.setItem(HEIGHT_KEY, '520') + } + return !prev + }) + } /** Panel dimensions; both values are persisted to localStorage. */ const [panelW, setPanelW] = useState(() => { @@ -251,10 +265,25 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { }) } - return ( -
{ overflow: 'hidden', backgroundColor: '#fff', border: '1px solid rgba(34,36,38,.15)', - }} - > - {/* Resize handles */} -
startDrag(e, 'w')} - /> -
startDrag(e, 'h')} - /> -
startDrag(e, 'both')} - /> + } + + return ( +
+ {/* Resize handles — hidden in fullscreen */} + {!isFullscreen && ( + <> +
startDrag(e, 'w')} + /> +
startDrag(e, 'h')} + /> +
startDrag(e, 'both')} + /> + + )} {/* Header */} -
+
Multiomix Assistant
- +
+ + +
{/* Body */} diff --git a/src/frontend/static/frontend/src/components/assistant/ChatWidget.tsx b/src/frontend/static/frontend/src/components/assistant/ChatWidget.tsx index c93b1952..06b01d1e 100644 --- a/src/frontend/static/frontend/src/components/assistant/ChatWidget.tsx +++ b/src/frontend/static/frontend/src/components/assistant/ChatWidget.tsx @@ -25,15 +25,15 @@ const ChatWidget = () => { <> {isOpen && toggle(false)} />} -
+
diff --git a/src/frontend/static/frontend/src/css/chat-widget.css b/src/frontend/static/frontend/src/css/chat-widget.css index e4ecd972..fd4bc376 100644 --- a/src/frontend/static/frontend/src/css/chat-widget.css +++ b/src/frontend/static/frontend/src/css/chat-widget.css @@ -7,20 +7,49 @@ z-index: 9000; } +/* When panel is open: smaller button, drops below panel z-index + (fullscreen panel at 8999 will cover it automatically) */ +.chat-widget-fab--open { + z-index: 8998; +} + .chat-widget-fab .ui.circular.button { width: 56px; height: 56px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); - transition: box-shadow 0.2s; + transition: width 0.2s, height 0.2s, box-shadow 0.2s, opacity 0.2s; + /* Flex for proper icon centering */ + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.chat-widget-fab--open .ui.circular.button { + width: 38px; + height: 38px; + opacity: 0.75; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); +} + +.chat-widget-fab .ui.circular.button i.icon { + margin: 0 !important; + line-height: 1 !important; + height: auto !important; + font-size: 1.1em; +} + +.chat-widget-fab--open .ui.circular.button i.icon { + font-size: 0.85em; } .chat-widget-fab .ui.circular.button:hover { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + opacity: 1; } /* ── Chat Panel ──────────────────────────────────────────────── */ -/* Resize handles — invisible by default, highlighted on hover */ +/* Resize handles — subtly visible by default, highlighted on hover */ .chat-resize-handle { position: absolute; z-index: 10; @@ -34,6 +63,7 @@ width: 5px; cursor: ew-resize; border-radius: 0 3px 3px 0; + background: rgba(33, 133, 208, 0.13); } .chat-resize-top { @@ -43,19 +73,88 @@ height: 5px; cursor: ns-resize; border-radius: 0 0 3px 3px; + background: rgba(33, 133, 208, 0.13); } +/* Corner handle with dotted grip indicator */ .chat-resize-corner { top: 0; left: 0; - width: 14px; - height: 14px; + width: 18px; + height: 18px; cursor: nwse-resize; - border-radius: 0 0 4px 0; + border-radius: 0 0 5px 0; + background-color: rgba(33, 133, 208, 0.13); + background-image: + radial-gradient(circle, rgba(33, 133, 208, 0.6) 1.2px, transparent 1.2px), + radial-gradient(circle, rgba(33, 133, 208, 0.4) 1.2px, transparent 1.2px), + radial-gradient(circle, rgba(33, 133, 208, 0.4) 1.2px, transparent 1.2px), + radial-gradient(circle, rgba(33, 133, 208, 0.25) 1.2px, transparent 1.2px); + background-size: 100% 100%; + background-position: 4px 4px, 10px 4px, 4px 10px, 10px 10px; + background-repeat: no-repeat; } .chat-resize-handle:hover { - background: rgba(33, 133, 208, 0.2); + background-color: rgba(33, 133, 208, 0.28); +} + +/* ── Panel header ─────────────────────────────────────────────── */ + +.chat-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid rgba(34, 36, 38, 0.15); + flex-shrink: 0; + background-color: #fff; + user-select: none; +} + +/* ── Window control buttons ───────────────────────────────────── */ + +.chat-window-controls { + display: flex; + align-items: center; + gap: 7px; +} + +.chat-ctrl-btn { + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: transform 0.12s, filter 0.12s; +} + +.chat-ctrl-btn i.icon { + margin: 0 !important; + font-size: 0.55em !important; + color: rgba(0, 0, 0, 0) !important; + transition: color 0.12s; +} + +.chat-ctrl-btn:hover i.icon { + color: rgba(0, 0, 0, 0.55) !important; +} + +.chat-ctrl-btn:hover { + transform: scale(1.1); + filter: brightness(0.92); +} + +.chat-ctrl-maximize { + background: #28c840; +} + +.chat-ctrl-close { + background: #ff5f57; } /* ── Lightbox ────────────────────────────────────────────────── */ From b2c1ed4018520421a3c7b80c023f0e6cbe143ff3 Mon Sep 17 00:00:00 2001 From: Hernan Date: Fri, 29 May 2026 11:34:33 -0300 Subject: [PATCH 02/10] Add BioMCP support; upgrade LangChain to 1.x Integrates external MCP (BioMCP) tools and upgrades the LangChain stack to 1.x. Adds config/mcp_servers.json and a new mcp_loader to read/validate MCP server entries (with PATH/transport checks and graceful warnings). Refactors llm_service: switches to async agent execution (asyncio.run), uses create_agent()/ainvoke with langchain 1.x + langchain-mcp-adapters, loads MCP tools at runtime, and falls back to internal tools if MCP is unavailable. Updates docs (AI_ASSISTANT.md) and docker-compose hints to document MCP usage. Updates Python dependencies and settings: bumps langchain/openai libraries, adds langchain-mcp-adapters and biomcp-python, updates ASSISTANT_MCP_CONFIG_PATH handling in settings. --- AI_ASSISTANT.md | 167 +++++++++++++++++++----- config/mcp_servers.json | 13 ++ config/requirements.txt | 18 +-- docker-compose_dist.yml | 5 + src/assistant/services/llm_service.py | 76 ++++++++--- src/assistant/services/mcp_loader.py | 65 +++++++++ src/multiomics_intermediate/settings.py | 3 + 7 files changed, 290 insertions(+), 57 deletions(-) create mode 100644 config/mcp_servers.json create mode 100644 src/assistant/services/mcp_loader.py diff --git a/AI_ASSISTANT.md b/AI_ASSISTANT.md index c8f030b8..c84ede71 100644 --- a/AI_ASSISTANT.md +++ b/AI_ASSISTANT.md @@ -19,13 +19,15 @@ Technical documentation for the AI assistant integrated into the Multiomix platf - [Biological information (BioAPI / Modulector)](#biological-information-bioapi--modulector) - [STRING database](#string-database) - [Curated knowledge base](#curated-knowledge-base) + - [External MCP tools (BioMCP)](#external-mcp-tools-biomcp) 5. [Frontend — React widget](#5-frontend--react-widget) 6. [Full query flow](#6-full-query-flow) 7. [Environment variables and configuration](#7-environment-variables-and-configuration) 8. [How to change the LLM model](#8-how-to-change-the-llm-model) 9. [How to add a new tool](#9-how-to-add-a-new-tool) -10. [How to add curated documentation](#10-how-to-add-curated-documentation) -11. [Dependencies](#11-dependencies) +10. [How to add an MCP server](#10-how-to-add-an-mcp-server) +11. [How to add curated documentation](#11-how-to-add-curated-documentation) +12. [Dependencies](#12-dependencies) --- @@ -36,6 +38,8 @@ The assistant is an **LLM agent with tool-calling** embedded in Multiomix as a f - Their own experiments, results, and biomarkers - Biological information about genes, miRNAs, and drugs - Protein interaction networks and functional enrichment (STRING) +- Biomedical literature: PubMed papers, preprints (bioRxiv/medRxiv), clinical trials (ClinicalTrials.gov) +- Genomic variants and gene/drug/disease annotations from curated databases - Platform documentation and general concepts The assistant **never fabricates data**. It always retrieves real information via tools connected to the Multiomix database and external APIs. @@ -63,27 +67,38 @@ The assistant **never fabricates data**. It always retrieves real information vi │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ -│ llm_service.run_chat() │ +│ llm_service.run_chat() [sync] │ │ │ │ 1. EmbeddingService.embed(user_message) ← HuggingFace local │ │ 2. build_chat_history() ← PostgreSQL + pgvector │ │ ├─ 15 most recent messages (current conversation) │ │ └─ 5 semantically similar messages (ALL user conversations — │ │ cross-chat memory) │ -│ 3. AgentExecutor.invoke() ← LangChain + OpenAI │ -│ └─ create_tool_calling_agent(llm, tools, prompt) │ -│ 4. Persist user msg + assistant reply with embeddings │ +│ 3. load_mcp_config() ← config/mcp_servers.json│ +│ 4. asyncio.run(_async_agent()) ← LangChain 1.x + OpenAI │ +│ ├─ MultiServerMCPClient(mcp_config) ← MCP subprocess (stdio) │ +│ │ └─ client.get_tools() ← BioMCP tools │ +│ ├─ all_tools = internal (23) + mcp │ +│ └─ create_agent(llm, all_tools, prompt=SYSTEM_PROMPT) │ +│ 5. Persist user msg + assistant reply with embeddings │ └──────────┬──────────────────────────────────────────────────────────┘ │ tool calls ▼ ┌─────────────────────────────────────────────────────────────────────┐ -│ TOOLS (23 tools) │ +│ TOOLS │ │ │ +│ Internal (23): │ │ ├─ PostgreSQL (Django ORM) → experiments, biomarkers, files │ │ ├─ MongoDB → experiment results │ │ ├─ Modulector API → miRNA-gene interactions │ │ ├─ BioAPI → gene annotations, drugs │ │ └─ STRING REST API → protein interactions, enrichment │ +│ │ +│ External MCP (BioMCP, optional): │ +│ ├─ PubMed / bioRxiv / medRxiv → biomedical papers │ +│ ├─ ClinicalTrials.gov → clinical trials │ +│ ├─ NCI / OncoKB → variants, oncology annotations │ +│ └─ cBioPortal → genomic studies │ └─────────────────────────────────────────────────────────────────────┘ │ embeddings ▼ @@ -194,20 +209,30 @@ vector = embedding_service.embed("TP53 expression in breast cancer") Location: `src/assistant/services/llm_service.py` -The agent uses the standard LangChain **ReAct with tool-calling** pattern: +The agent uses the **langchain 1.x** API backed by LangGraph: ```python -agent = create_tool_calling_agent(llm, tools, prompt) -executor = AgentExecutor(agent=agent, tools=tools, max_iterations=5) +agent = create_agent(llm, all_tools, prompt=SYSTEM_PROMPT) +result = await agent.ainvoke({"messages": messages}) +``` + +`run_chat()` is a **sync** function (called from a standard WSGI/DRF view). Because `MultiServerMCPClient` requires an async context, the agent execution is isolated in `_async_agent()` and run via `asyncio.run()`. Sync DB operations (embed, save messages) stay outside the event loop. + +``` +run_chat() [sync] + ├─ DB ops (embed, build history, save user message) + ├─ load_mcp_config() + └─ asyncio.run(_async_agent(...)) + ├─ MultiServerMCPClient ← spawns MCP subprocesses + ├─ all_tools = internal + mcp + ├─ create_agent(llm, all_tools, prompt=SYSTEM_PROMPT) + └─ agent.ainvoke({"messages": [*history, HumanMessage]}) + └─ DB ops (embed, save assistant reply) ``` -The prompt has 4 sections: -1. **System prompt** — scope, restrictions, markdown formatting instructions -2. **Chat history** — previous messages (recent + semantic) -3. **Human message** — current user query -4. **Agent scratchpad** — internal space for the agent to reason and invoke tools +**Graceful fallback**: if any MCP server fails to start or crashes, a `WARNING` is logged and the agent continues with the 23 internal tools only — the assistant remains fully functional. -**System prompt restrictions**: the agent only answers questions about bioinformatics, the Multiomix platform, and the user's own data. Out-of-scope questions receive a fixed rejection message. +**System prompt restrictions**: the agent only answers questions about bioinformatics, the Multiomix platform, the user's own data, and biomedical literature. Out-of-scope questions receive a fixed rejection message. **`get_llm()` function**: the model swap point. See section [8](#8-how-to-change-the-llm-model). @@ -293,6 +318,25 @@ Integration with the public [STRING REST API](https://string-db.org/help/api/) ( |------|-------------| | `search_curated_knowledge(query)` | Semantic search over `CuratedDocument`. Returns the top 5 documents by cosine similarity. Useful for answering questions about how the platform works | +### External MCP tools (BioMCP) + +Provided by the `biomcp-python` package via the MCP protocol (stdio subprocess). Available when `ASSISTANT_MCP_CONFIG_PATH` is set and `biomcp` is installed. + +| Category | Examples | +|----------|---------| +| PubMed / bioRxiv / medRxiv | Search papers by gene, disease, method, author | +| ClinicalTrials.gov | Search clinical trials by condition or intervention | +| NCI / OncoKB | Variant annotations, oncogenicity classifications | +| cBioPortal | Studies, mutations, copy number alterations | + +**Example usage in the chat:** + +> *"Find recent PubMed papers about DESeq2 differential expression in breast cancer"* + +> *"Are there clinical trials for BRCA1 mutations in ovarian cancer?"* + +The tools are discovered automatically from the running MCP server — no code changes are needed when BioMCP adds new tools in a future release. + --- ## 5. Frontend — React widget @@ -373,12 +417,19 @@ ChatPanel.sendMessage() │ ├─ 3. Message.objects.create(role=user, embedding=...) │ - ├─ 4. AgentExecutor.invoke(input, chat_history) - │ ├─ LLM decides which tools to call - │ ├─ Executes tools (DB, external APIs) - │ └─ LLM generates final response in markdown + ├─ 4. load_mcp_config() ← reads mcp_servers.json + │ + ├─ 5. asyncio.run(_async_agent(...)) + │ ├─ MultiServerMCPClient → spawns BioMCP subprocess + │ ├─ all_tools = 23 internal + MCP tools + │ ├─ create_agent(llm, all_tools, system_prompt) + │ ├─ agent.ainvoke({messages: [history + user_msg]}) + │ │ ├─ LLM decides which tools to call + │ │ ├─ Executes tools (DB, external APIs, MCP) + │ │ └─ LLM generates final response in markdown + │ └─ [fallback to 23 internal tools if MCP fails] │ - └─ 5. Message.objects.create(role=assistant, embedding=...) + └─ 6. Message.objects.create(role=assistant, embedding=...) │ ▼ Response { conversation_id, reply } @@ -412,6 +463,7 @@ Optional variables: |----------|-------------| | `HF_TOKEN` | HuggingFace token (only required if the model needs authentication) | | `HF_CACHE_DIR` | Cache directory for HuggingFace models | +| `ASSISTANT_MCP_CONFIG_PATH` | Absolute path to `mcp_servers.json`. If not set or the file is missing, MCP tools are disabled and the assistant uses only the 23 internal tools | --- @@ -484,7 +536,58 @@ def get_my_new_tool(param: str) -> str: --- -## 10. How to add curated documentation +## 10. How to add an MCP server + +Adding a new MCP server requires **only editing `config/mcp_servers.json`** — no code changes needed. + +```json +{ + "version": "1.0", + "servers": { + "biomcp": { + "enabled": true, + "transport": "stdio", + "command": "biomcp", + "args": ["run"] + }, + "my-new-server": { + "enabled": true, + "description": "Optional description for humans", + "transport": "stdio", + "command": "my-mcp-command", + "args": ["--flag", "value"], + "env": { + "MY_API_KEY": "secret" + } + } + } +} +``` + +**Schema reference:** + +| Field | Required | Description | +|-------|----------|-------------| +| `enabled` | No (default `true`) | Set to `false` to disable without removing the entry | +| `transport` | Yes | `"stdio"`, `"http"`, or `"sse"` | +| `command` | Yes (stdio) | Executable name or path. Must be in `PATH` | +| `args` | No | List of arguments passed to the command | +| `env` | No | Extra environment variables injected into the subprocess | +| `url` | Yes (http/sse) | Server URL for HTTP or SSE transports | + +**The new server's tools are discovered automatically** at every request — the LLM receives their descriptions and decides when to call them. + +**When to also update the system prompt:** if the new server covers a domain not listed in `## Your scope` (e.g. pharmacogenomics), add a bullet point to `SYSTEM_PROMPT` in `llm_service.py` so the agent knows it is allowed to answer questions in that domain. + +**Validation rules** (applied by `mcp_loader.py` at load time): +- File missing or invalid JSON → warning logged, MCP disabled (internal tools still work) +- `enabled: false` → silently skipped +- Unknown transport → warning + skip +- `command` not found in PATH (stdio) → warning + skip + +--- + +## 11. How to add curated documentation `CuratedDocument` entries allow adding platform-specific knowledge that the agent can query semantically. @@ -499,20 +602,24 @@ def get_my_new_tool(param: str) -> str: --- -## 11. Dependencies +## 12. Dependencies ### Backend (`config/requirements.txt`) ``` -langchain>=0.3.0 -langchain-openai>=0.2.0 -langchain-community>=0.3.0 -langchain-huggingface>=0.1.0 -pgvector>=0.3.6 -sentence-transformers>=3.0.0 -openai>=1.50.0 +langchain==1.3.2 +langchain-openai==1.2.2 +langchain-community==0.4.2 +langchain-huggingface==1.2.2 +langchain-mcp-adapters==0.2.2 +pgvector==0.4.2 +sentence-transformers==3.0.0 +openai==2.38.0 +biomcp-python==0.7.3 ``` +**Note on langchain 1.x:** the entire langchain stack was upgraded from `0.3.x` to `1.x` because `langchain-mcp-adapters 0.2.x` requires `langchain-core>=1.4.0`, which is incompatible with the `0.3.x` series. As part of this upgrade, the agent API changed: `AgentExecutor` + `create_tool_calling_agent` were replaced by the single `create_agent()` function backed by LangGraph. + ### Frontend (`package.json`) ``` diff --git a/config/mcp_servers.json b/config/mcp_servers.json new file mode 100644 index 00000000..e79f2593 --- /dev/null +++ b/config/mcp_servers.json @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "servers": { + "biomcp": { + "enabled": true, + "description": "Biomedical research: PubMed papers, bioRxiv, ClinicalTrials.gov, NCI, variants, OncoKB", + "transport": "stdio", + "command": "biomcp", + "args": ["run", "--mode", "stdio"], + "env": {} + } + } +} diff --git a/config/requirements.txt b/config/requirements.txt index b6615cfc..eb6870b6 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -17,11 +17,11 @@ lifelines==0.27.8 mypy-extensions==1.0.0 mypy==1.11.1 pandas==2.2.2 -psutil==6.0.0 +psutil==7.2.2 psycopg2-binary==2.9.9 pymongo==4.6.3 redis==5.0.3 -requests==2.31.0 +requests==2.34.2 scikit-learn==1.3.2 scikit-survival==0.22.2 scipy==1.13.0 @@ -30,10 +30,12 @@ xlrd==2.0.1 openpyxl==3.1.5 rpy2==3.6.1 urllib3==2.5.0 -langchain==0.3.0 -langchain-openai==0.2.0 -langchain-community==0.3.0 -langchain-huggingface==0.1.0 -pgvector==0.3.6 +langchain==1.3.2 +langchain-openai==1.2.2 +langchain-community==0.4.2 +langchain-huggingface==1.2.2 +langchain-mcp-adapters==0.2.2 +pgvector==0.4.2 sentence-transformers==3.0.0 -openai==1.50.0 \ No newline at end of file +openai==2.38.0 +biomcp-python==0.7.3 \ No newline at end of file diff --git a/docker-compose_dist.yml b/docker-compose_dist.yml index b8aa0abe..d8a4c810 100644 --- a/docker-compose_dist.yml +++ b/docker-compose_dist.yml @@ -293,10 +293,15 @@ services: # For logging (by default LOG_FILE_PATH is /logs) # LOG_FILE_PATH: /path/to/folder/log + + # MCP external tools (optional). Uncomment both lines below and install + # biomcp-python inside the container image to enable biomedical literature tools. + # ASSISTANT_MCP_CONFIG_PATH: '/config/mcp_servers.json' volumes: - static_data:/src/static - media_data:/src/media - logs_data:/logs + # - ./config/mcp_servers.json:/config/mcp_servers.json:ro depends_on: - db - mongo diff --git a/src/assistant/services/llm_service.py b/src/assistant/services/llm_service.py index 3a2486f8..857634b3 100644 --- a/src/assistant/services/llm_service.py +++ b/src/assistant/services/llm_service.py @@ -1,15 +1,23 @@ +import asyncio +import logging from typing import List + +# Uncomment to see every tool call the agent makes in the server console: +# from langchain_core.globals import set_debug; set_debug(True) + from django.conf import settings from assistant.models import Message from assistant.services.embedding_service import embedding_service +from assistant.services.mcp_loader import load_mcp_config from assistant.services.tools import make_tools -from langchain.agents import create_tool_calling_agent, AgentExecutor -from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder -from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage +from langchain.agents import create_agent +from langchain_core.messages import BaseMessage, HumanMessage, AIMessage from langchain_openai import ChatOpenAI from pgvector.django import CosineDistance +logger = logging.getLogger(__name__) + SYSTEM_PROMPT = """You are Multiomix Assistant, a specialized AI integrated into the Multiomix platform — a cloud-based bioinformatics tool for inferring cancer genomic and epigenomic events associated with gene expression modulation. @@ -24,6 +32,8 @@ - Gene and molecule information (genes, miRNAs, CpG sites) - Statistical methods used in the platform (DESeq2, limma, Cox regression, SVM, Random Forest, etc.) - Protein-protein interaction networks and functional enrichment from STRING database +- Biomedical literature: PubMed papers, preprints (bioRxiv/medRxiv), clinical trials (ClinicalTrials.gov) +- Genomic variants and gene/drug/disease annotations from curated databases (when MCP tools are available) ## Hard restrictions @@ -63,7 +73,7 @@ def build_chat_history(conversation, user_id: int, query_embedding: List[float]) - Recent messages: last N from the CURRENT conversation (immediate context). - Semantic messages: most similar messages from ANY conversation of the user (cross-chat long-term memory), excluding messages already in the recent set. - Returns LangChain message objects. SystemMessage is added separately in the prompt. + Returns LangChain message objects. """ # Recent messages from the current conversation @@ -94,6 +104,46 @@ def build_chat_history(conversation, user_id: int, query_embedding: List[float]) return lc_messages +def _extract_reply(result: dict) -> str: + """Extract text reply from the agent result dict.""" + messages = result.get('messages', []) + if not messages: + return '' + last = messages[-1] + content = last.content if hasattr(last, 'content') else '' + return content if isinstance(content, str) else str(content) + + +async def _async_agent(user_message: str, chat_history: List[BaseMessage], user_id: int, mcp_config: dict) -> str: + """ + Async agent execution. Loads MCP tools if mcp_config is provided, then invokes + the LangChain agent. Falls back gracefully to internal tools if MCP fails. + """ + from langchain_mcp_adapters.client import MultiServerMCPClient + + internal_tools = make_tools(user_id) + llm = get_llm() + + # In langchain 1.x the full message list (history + current turn) is the input. + messages: List[BaseMessage] = list(chat_history) + [HumanMessage(content=user_message)] + + if mcp_config: + try: + client = MultiServerMCPClient(mcp_config) + mcp_tools = await client.get_tools() + logger.info('MCP tools loaded: %s', [t.name for t in mcp_tools]) + all_tools = internal_tools + mcp_tools + agent = create_agent(llm, all_tools, system_prompt=SYSTEM_PROMPT) + result = await agent.ainvoke({'messages': messages}) + return _extract_reply(result) + except Exception as exc: + logger.warning('MCP tools unavailable (%s), falling back to internal tools', exc) + + agent = create_agent(llm, internal_tools, system_prompt=SYSTEM_PROMPT) + result = await agent.ainvoke({'messages': messages}) + return _extract_reply(result) + + def run_chat(conversation, user_message: str, user_id: int) -> str: """ Run one chat turn: embed the user message, build context, invoke agent, return reply. @@ -119,22 +169,10 @@ def run_chat(conversation, user_message: str, user_id: int) -> str: conversation.title = user_message[:100] conversation.save(update_fields=['title']) - # Build agent prompt — standard LangChain tool-calling pattern - prompt = ChatPromptTemplate.from_messages([ - ('system', SYSTEM_PROMPT), - MessagesPlaceholder(variable_name='chat_history'), - ('human', '{input}'), - MessagesPlaceholder(variable_name='agent_scratchpad'), - ]) - - tools = make_tools(user_id) - llm = get_llm() - - agent = create_tool_calling_agent(llm, tools, prompt) - executor = AgentExecutor(agent=agent, tools=tools, verbose=False, max_iterations=5) + mcp_config = load_mcp_config() - result = executor.invoke({'input': user_message, 'chat_history': chat_history}) - reply = result.get('output', '') + # Async agent execution (MCP adapters require an event loop) + reply = asyncio.run(_async_agent(user_message, chat_history, user_id, mcp_config)) # Embed and save assistant reply reply_embedding = embedding_service.embed(reply) diff --git a/src/assistant/services/mcp_loader.py b/src/assistant/services/mcp_loader.py new file mode 100644 index 00000000..5f754f5f --- /dev/null +++ b/src/assistant/services/mcp_loader.py @@ -0,0 +1,65 @@ +import json +import logging +import os +import shutil + +from django.conf import settings + +logger = logging.getLogger(__name__) +VALID_TRANSPORTS = {'stdio', 'http', 'sse'} + + +def load_mcp_config() -> dict: + """ + Reads, validates and returns the MCP server config as a dict compatible with + MultiServerMCPClient. + Returns {} if config is absent, malformed, or has no enabled servers. + """ + config_path = getattr(settings, 'ASSISTANT_MCP_CONFIG_PATH', None) + if not config_path: + return {} + if not os.path.exists(config_path): + logger.warning('MCP config not found at: %s', config_path) + return {} + try: + with open(config_path) as f: + raw = json.load(f) + except (json.JSONDecodeError, IOError) as exc: + logger.error('Failed to read MCP config: %s', exc) + return {} + + servers = raw.get('servers', {}) + if not isinstance(servers, dict): + logger.error("MCP config 'servers' must be a dict") + return {} + + result = {} + for name, cfg in servers.items(): + if not cfg.get('enabled', True): + continue + transport = cfg.get('transport', '') + if transport not in VALID_TRANSPORTS: + logger.warning("MCP server '%s': invalid transport '%s', skipping", name, transport) + continue + if 'command' not in cfg: + logger.warning("MCP server '%s': missing 'command', skipping", name) + continue + if transport == 'stdio' and not shutil.which(cfg['command']): + logger.warning( + "MCP server '%s': command '%s' not found in PATH, skipping", + name, + cfg['command'], + ) + continue + + entry: dict = {'transport': transport, 'command': cfg['command']} + if 'args' in cfg: + entry['args'] = cfg['args'] + if cfg.get('env'): + entry['env'] = cfg['env'] + if transport in ('http', 'sse') and 'url' in cfg: + entry['url'] = cfg['url'] + result[name] = entry + logger.info("MCP server '%s' loaded (transport=%s)", name, transport) + + return result diff --git a/src/multiomics_intermediate/settings.py b/src/multiomics_intermediate/settings.py index d0e6256f..52f39191 100644 --- a/src/multiomics_intermediate/settings.py +++ b/src/multiomics_intermediate/settings.py @@ -352,6 +352,9 @@ ASSISTANT_TOKENIZERS_PARALLELISM: str = os.getenv('TOKENIZERS_PARALLELISM', 'false') # Apply at boot time so the HuggingFace tokenizers library picks it up before any import. os.environ.setdefault('TOKENIZERS_PARALLELISM', ASSISTANT_TOKENIZERS_PARALLELISM) +# Path to external MCP servers config JSON. Set via ASSISTANT_MCP_CONFIG_PATH env var. +# If not set or file not found, MCP tools are disabled gracefully. +ASSISTANT_MCP_CONFIG_PATH: Optional[str] = os.getenv('ASSISTANT_MCP_CONFIG_PATH') or None # Feature Selection settings From c7123a045bb76eb2e1b718244652a08cd920b3e8 Mon Sep 17 00:00:00 2001 From: Hernan Date: Tue, 2 Jun 2026 03:49:14 -0300 Subject: [PATCH 03/10] Improve chat panel sizing and UI/empty state Adjust chat panel sizing to be responsive: compute MIN/MAX dimensions from window size, update default restore sizes, and persist new dimensions to localStorage. Enhance header with a robot icon and show active conversation title; improve conversation list layout and hover/delete behavior. Replace simple empty hint with a capability grid and richer suggested prompts, and tweak CSS for resize handles, sidebar width, spacing, and visual polish to improve overall UX. --- .../src/components/assistant/ChatPanel.tsx | 45 +++-- .../components/assistant/MessageThread.tsx | 56 +++++- .../static/frontend/src/css/chat-widget.css | 188 +++++++++++++----- 3 files changed, 215 insertions(+), 74 deletions(-) diff --git a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx index 942ad212..5fd8992a 100644 --- a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx +++ b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx @@ -14,8 +14,10 @@ const ACTIVE_CONV_KEY = 'multiomix_chat_conv_id' const WIDTH_KEY = 'multiomix_chat_width' const HEIGHT_KEY = 'multiomix_chat_height' -const MIN_W = 380 -const MIN_H = 300 +const MIN_W = Math.max(640, Math.round(window.innerWidth * 0.42)) +const MIN_H = Math.max(420, Math.round(window.innerHeight * 0.48)) +const MAX_W = Math.min(1000, window.innerWidth - 40) +const MAX_H = window.innerHeight - 80 interface DragState { dir: 'w' | 'h' | 'both' @@ -52,10 +54,12 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { setIsFullscreen(prev => { if (prev) { // Exiting fullscreen → restore default dimensions - setPanelW(700) - setPanelH(520) - localStorage.setItem(WIDTH_KEY, '700') - localStorage.setItem(HEIGHT_KEY, '520') + const w = Math.min(780, Math.round(window.innerWidth * 0.55)) + const h = Math.min(600, Math.round(window.innerHeight * 0.65)) + setPanelW(w) + setPanelH(h) + localStorage.setItem(WIDTH_KEY, String(w)) + localStorage.setItem(HEIGHT_KEY, String(h)) } return !prev }) @@ -64,13 +68,13 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { /** Panel dimensions; both values are persisted to localStorage. */ const [panelW, setPanelW] = useState(() => { const s = localStorage.getItem(WIDTH_KEY) - const saved = s ? parseInt(s, 10) : 700 - return Math.max(MIN_W, Math.min(window.innerWidth - 60, saved)) + const saved = s ? parseInt(s, 10) : Math.min(900, Math.round(window.innerWidth * 0.62)) + return Math.max(MIN_W, Math.min(MAX_W, saved)) }) const [panelH, setPanelH] = useState(() => { const s = localStorage.getItem(HEIGHT_KEY) - const saved = s ? parseInt(s, 10) : 520 - return Math.max(MIN_H, Math.min(window.innerHeight - 140, saved)) + const saved = s ? parseInt(s, 10) : Math.min(600, Math.round(window.innerHeight * 0.65)) + return Math.max(MIN_H, Math.min(MAX_H, saved)) }) /** Drag state kept in a ref so mouse-move handlers don't trigger re-renders. */ @@ -82,17 +86,14 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { if (!d) { return } - const maxW = Math.min(1100, window.innerWidth - 60) - const maxH = Math.min(900, window.innerHeight - 140) - if (d.dir === 'w' || d.dir === 'both') { - const w = Math.max(MIN_W, Math.min(maxW, d.startW - (e.clientX - d.startX))) + const w = Math.max(MIN_W, Math.min(MAX_W, d.startW - (e.clientX - d.startX))) setPanelW(w) localStorage.setItem(WIDTH_KEY, String(w)) } if (d.dir === 'h' || d.dir === 'both') { - const h = Math.max(MIN_H, Math.min(maxH, d.startH - (e.clientY - d.startY))) + const h = Math.max(MIN_H, Math.min(MAX_H, d.startH - (e.clientY - d.startY))) setPanelH(h) localStorage.setItem(HEIGHT_KEY, String(h)) } @@ -319,9 +320,17 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { {/* Header */}
-
- - Multiomix Assistant +
+ + Multiomix Assistant + {activeConvId && conversations.find(c => c.id === activeConvId)?.title && ( + <> + · + + {conversations.find(c => c.id === activeConvId)?.title} + + + )}
-
+
+
+ + {helpOpen && ( +
+

Multiomix Assistant

+

+ Asistente de IA integrado en Multiomix que te permite explorar tus datos + y acceder a bases de datos bioinformáticas externas de forma conversacional. +

+

Herramientas disponibles

+
    +
  • Tus datos — experimentos, biomarkers, archivos y validaciones
  • +
  • Bioinformática — genes, miRNAs, CpG, cBioPortal y redes STRING
  • +
  • Literatura — PubMed, bioRxiv y ClinicalTrials.gov
  • +
  • Genómica clínica — variantes y anotaciones en OncoKB
  • +
+
+ )} +
+
+
{/* Body */} diff --git a/src/frontend/static/frontend/src/css/chat-widget.css b/src/frontend/static/frontend/src/css/chat-widget.css index e378d4ca..ebf11710 100644 --- a/src/frontend/static/frontend/src/css/chat-widget.css +++ b/src/frontend/static/frontend/src/css/chat-widget.css @@ -98,6 +98,70 @@ user-select: none; } +/* ── Help button & dropdown ───────────────────────────────────── */ + +.chat-help-btn { + background: none; + border: none; + padding: 2px 3px; + cursor: pointer; + color: #bbb; + display: flex; + align-items: center; + border-radius: 4px; + transition: color 0.15s, background 0.15s; + font-size: 1em; +} + +.chat-help-btn:hover { + color: #2185d0; + background: rgba(33, 133, 208, 0.08); +} + +.chat-help-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 290px; + background: #fff; + border: 1px solid rgba(34, 36, 38, 0.15); + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.13); + padding: 14px 16px; + z-index: 10000; +} + +.chat-help-title { + font-weight: 700; + font-size: 0.95em; + color: #222; + margin: 0 0 6px; +} + +.chat-help-desc { + font-size: 0.83em; + color: #555; + line-height: 1.55; + margin: 0 0 12px; +} + +.chat-help-section { + font-size: 0.72em; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #999; + margin: 0 0 6px; +} + +.chat-help-list { + margin: 0; + padding-left: 16px; + font-size: 0.83em; + color: #444; + line-height: 1.7; +} + /* ── Window control buttons ───────────────────────────────────── */ .chat-window-controls { @@ -242,7 +306,7 @@ .chat-conversation-list .conv-item { flex-shrink: 0; - /*comentado se ve mas bonito, sino se hace muy ancho cada celda*/ + /*Commented out, it looks nicer; otherwise, each cell becomes too wide.*/ /*padding: 11px 16px 15px;*/ cursor: pointer; border-bottom: 1px solid rgba(34, 36, 38, 0.09); From 73ca13c94a5ed75241577a2c8207229e0bb013df Mon Sep 17 00:00:00 2001 From: Hernan Date: Tue, 2 Jun 2026 04:10:52 -0300 Subject: [PATCH 05/10] Improve chat help text and adjust layout Update chat assistant help content and tweak widget styling. In ChatPanel.tsx the help section heading and list were replaced with concise Spanish tips for better results and a new keyboard shortcuts subsection was added; minor reformatting of the control buttons block only changes indentation. In chat-widget.css added width/display/flex centering to the affected element and introduced a left padding tweak for list cells to refine spacing and alignment. --- .../src/components/assistant/ChatPanel.tsx | 45 ++++++++++--------- .../static/frontend/src/css/chat-widget.css | 5 +++ 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx index 3b090904..12a5c9f7 100644 --- a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx +++ b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx @@ -361,32 +361,37 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { Asistente de IA integrado en Multiomix que te permite explorar tus datos y acceder a bases de datos bioinformáticas externas de forma conversacional.

-

Herramientas disponibles

+

Tips para mejores resultados

    -
  • Tus datos — experimentos, biomarkers, archivos y validaciones
  • -
  • Bioinformática — genes, miRNAs, CpG, cBioPortal y redes STRING
  • -
  • Literatura — PubMed, bioRxiv y ClinicalTrials.gov
  • -
  • Genómica clínica — variantes y anotaciones en OncoKB
  • +
  • Mencioná nombres exactos de genes, experimentos o archivos
  • +
  • El asistente recuerda el contexto dentro de la conversación
  • +
  • Podés pedirle que amplíe, reformule o explique con más detalle
  • +
  • Si algo falla, intentá reformular la pregunta con más contexto
  • +
+

Atajos de teclado

+
    +
  • Enter — enviar mensaje
  • +
  • Shift + Enter — nueva línea
)}
- - -
+ + +
diff --git a/src/frontend/static/frontend/src/css/chat-widget.css b/src/frontend/static/frontend/src/css/chat-widget.css index ebf11710..07cc369c 100644 --- a/src/frontend/static/frontend/src/css/chat-widget.css +++ b/src/frontend/static/frontend/src/css/chat-widget.css @@ -35,6 +35,10 @@ margin: 0 !important; line-height: 1 !important; height: auto !important; + width: auto !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; font-size: 1.1em; } @@ -308,6 +312,7 @@ flex-shrink: 0; /*Commented out, it looks nicer; otherwise, each cell becomes too wide.*/ /*padding: 11px 16px 15px;*/ + padding-left: 14px; cursor: pointer; border-bottom: 1px solid rgba(34, 36, 38, 0.09); font-size: 13.5px; From 496b637a6d610ac8eca11733ad18907b7c69ccd0 Mon Sep 17 00:00:00 2001 From: Hernan Date: Tue, 2 Jun 2026 04:22:19 -0300 Subject: [PATCH 06/10] Add MCP tools docs and update biomcp dep Add a new "AI Assistant MCP Tools" section to DEPLOYING.md describing Model Context Protocol (MCP) servers, the default BioMCP config (config/mcp_servers.json), how to enable MCP support in docker-compose (ASSISTANT_MCP_CONFIG_PATH + volume mount), requirements for stdio servers, how to add custom MCP servers, supported transport types (stdio, http, sse), and an example. Also replace the biomcp dependency in config/requirements.txt from biomcp-python==0.7.3 to biomcp-cli==0.8.22 so the documented runtime command matches the installed package. --- DEPLOYING.md | 82 +++++++++++++++++++++++++++++++++++++++++ config/requirements.txt | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/DEPLOYING.md b/DEPLOYING.md index 6126d0d0..29cbcde0 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -231,6 +231,86 @@ Then the following environment variables must be configured: - `AWS_EMR_SHARED_FOLDER` +## AI Assistant MCP Tools + +The AI assistant supports [Model Context Protocol (MCP)][mcp-spec] servers, which extend the assistant's capabilities by giving it access to external tools (e.g. biomedical literature databases, clinical trials registries, genomic variant databases, and more). + +MCP servers are declared in `config/mcp_servers.json`. By default that file ships with **[BioMCP][biomcp]** already configured and enabled: + +```json +{ + "version": "1.0", + "servers": { + "biomcp": { + "enabled": true, + "description": "Biomedical research: PubMed papers, bioRxiv, ClinicalTrials.gov, NCI, variants, OncoKB", + "transport": "stdio", + "command": "biomcp", + "args": ["run", "--mode", "stdio"], + "env": {} + } + } +} +``` + +### Activating MCP support + +MCP tools are **disabled by default**. To enable them you need to have the AI assistant enabled first (i.e. `OPENAI_API_KEY` and the related parameters set) and then: + +1. Open your `docker-compose.yml` (copied from `docker-compose_dist.yml`). +2. In the `backend` service, uncomment the environment variable: + ```yaml + ASSISTANT_MCP_CONFIG_PATH: '/config/mcp_servers.json' + ``` +3. In the same service, uncomment the volume mount so the config file is available inside the container: + ```yaml + - ./config/mcp_servers.json:/config/mcp_servers.json:ro + ``` +4. Make sure any MCP server declared with `transport: stdio` has its `command` installed and available in `PATH` inside the container. For BioMCP that means `biomcp-python` must be installed in the container image. +5. Redo the deployment with Docker. + +### Adding custom MCP servers + +You can extend the assistant with any MCP-compatible server by adding entries to `config/mcp_servers.json`. The loader supports three transport types: + +| Transport | Required fields | Optional fields | +|-----------|----------------|-----------------| +| `stdio` | `command` | `args`, `env` | +| `http` | `command`, `url` | `args`, `env` | +| `sse` | `command`, `url` | `args`, `env` | + +Set `"enabled": false` on any entry to disable it without removing it from the file. + +Example — adding a custom HTTP MCP server alongside BioMCP: + +```json +{ + "version": "1.0", + "servers": { + "biomcp": { + "enabled": true, + "transport": "stdio", + "command": "biomcp", + "args": ["run", "--mode", "stdio"], + "env": {} + }, + "my-custom-mcp": { + "enabled": true, + "description": "Custom internal tool server", + "transport": "http", + "command": "my-mcp-server", + "url": "http://my-mcp-host:8080/mcp", + "env": { + "MY_API_KEY": "secret" + } + } + } +} +``` + +After modifying the file, restart the backend service for the changes to take effect. No rebuild is required since the file is mounted as a read-only volume. + + ## Execution of tasks with Celery Multiomix uses [Celery][celery] to distribute the computational load of its most expensive tasks (such as correlation analysis, Biomarkers Feature Selection, static validations, Machine Learning model training, etc.). This requires the user to have a messaging broker, such as RabbitMQ or Redis, installed and configured. In this project, Redis is used and a worker is deployed for each of the execution queues serving a different type of task. The Docker configuration is left ready to run in Docker Compose or Docker Swarm and K8S. @@ -299,6 +379,8 @@ To import a `media` folder backup inside a new environment you must (from the ro 2. Run the script `./tools/import_media.sh`. +[mcp-spec]: https://modelcontextprotocol.io/ +[biomcp]: https://github.com/genomics-geek/biomcp [docker-swarm]: https://docs.docker.com/engine/swarm/ [modulector]: https://github.com/omics-datascience/modulector [bioapi]: https://github.com/omics-datascience/BioAPI diff --git a/config/requirements.txt b/config/requirements.txt index eb6870b6..0cc208d0 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -38,4 +38,4 @@ langchain-mcp-adapters==0.2.2 pgvector==0.4.2 sentence-transformers==3.0.0 openai==2.38.0 -biomcp-python==0.7.3 \ No newline at end of file +biomcp-cli==0.8.22 \ No newline at end of file From 878a6a58da0fa21685474d75ec41e31c9ec493e9 Mon Sep 17 00:00:00 2001 From: Hernan Date: Thu, 4 Jun 2026 20:57:14 -0300 Subject: [PATCH 07/10] Enable LangChain debug; English UI & minor fixes Python: import and enable langchain_core.set_debug only when the root logger is at DEBUG level, and lower MCP tools log from INFO to DEBUG. Frontend: translate assistant UI copy from Spanish to English across ChatPanel, ChatWidget, ConversationList and MessageThread. Add JSDoc/comments, introduce TOOL_CATEGORIES and SUGGESTED_PROMPTS for the empty-state, and improve behavior/robustness: use Number.parseInt for persisted sizes/ids, clamp restored panel dimensions to MIN/MAX when toggling fullscreen, map cursor style via an object, and extract small state-update helpers for conversation removal/renaming. Misc UI text, placeholders and tooltip refinements included. --- src/assistant/services/llm_service.py | 10 ++- .../src/components/assistant/ChatPanel.tsx | 74 +++++++++++-------- .../src/components/assistant/ChatWidget.tsx | 2 +- .../components/assistant/ConversationList.tsx | 10 +-- .../components/assistant/MessageThread.tsx | 68 ++++++++++------- 5 files changed, 97 insertions(+), 67 deletions(-) diff --git a/src/assistant/services/llm_service.py b/src/assistant/services/llm_service.py index 857634b3..15fcb6ba 100644 --- a/src/assistant/services/llm_service.py +++ b/src/assistant/services/llm_service.py @@ -2,9 +2,6 @@ import logging from typing import List -# Uncomment to see every tool call the agent makes in the server console: -# from langchain_core.globals import set_debug; set_debug(True) - from django.conf import settings from assistant.models import Message @@ -12,12 +9,17 @@ from assistant.services.mcp_loader import load_mcp_config from assistant.services.tools import make_tools from langchain.agents import create_agent +from langchain_core.globals import set_debug from langchain_core.messages import BaseMessage, HumanMessage, AIMessage from langchain_openai import ChatOpenAI from pgvector.django import CosineDistance logger = logging.getLogger(__name__) +# Enable LangChain debug output only when the root logger is at DEBUG level +if logging.getLogger().isEnabledFor(logging.DEBUG): + set_debug(True) + SYSTEM_PROMPT = """You are Multiomix Assistant, a specialized AI integrated into the Multiomix platform — a cloud-based bioinformatics tool for inferring cancer genomic and epigenomic events associated with gene expression modulation. @@ -131,7 +133,7 @@ async def _async_agent(user_message: str, chat_history: List[BaseMessage], user_ try: client = MultiServerMCPClient(mcp_config) mcp_tools = await client.get_tools() - logger.info('MCP tools loaded: %s', [t.name for t in mcp_tools]) + logger.debug('MCP tools loaded: %s', [t.name for t in mcp_tools]) all_tools = internal_tools + mcp_tools agent = create_agent(llm, all_tools, system_prompt=SYSTEM_PROMPT) result = await agent.ainvoke({'messages': messages}) diff --git a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx index 12a5c9f7..8de27fb9 100644 --- a/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx +++ b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx @@ -37,14 +37,15 @@ interface ChatPanelProps { * the active message thread (MessageThread). Panel dimensions are * persisted in localStorage and can be adjusted by dragging the * left/top/corner resize handles. - * @param root0 - * @param root0.onClose + * @param root0 - Component props. + * @param root0.onClose - Callback invoked when the user closes the panel. + * @returns The rendered chat panel element. */ const ChatPanel = ({ onClose }: ChatPanelProps) => { const [conversations, setConversations] = useState([]) const [activeConvId, setActiveConvId] = useState>(() => { const stored = localStorage.getItem(ACTIVE_CONV_KEY) - return stored ? parseInt(stored, 10) : null + return stored ? Number.parseInt(stored, 10) : null }) const [messages, setMessages] = useState([]) const [isLoading, setIsLoading] = useState(false) @@ -54,26 +55,33 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { useEffect(() => { if (!helpOpen) { return } + const onClickOutside = (e: MouseEvent) => { if (helpRef.current && !helpRef.current.contains(e.target as Node)) { setHelpOpen(false) } } + document.addEventListener('mousedown', onClickOutside) return () => document.removeEventListener('mousedown', onClickOutside) }, [helpOpen]) + /** + * Toggles fullscreen mode. When exiting fullscreen, restores the panel + * to proportional default dimensions clamped by MIN/MAX bounds. + */ const toggleFullscreen = () => { setIsFullscreen(prev => { if (prev) { // Exiting fullscreen → restore default dimensions - const w = Math.min(780, Math.round(window.innerWidth * 0.55)) - const h = Math.min(600, Math.round(window.innerHeight * 0.65)) + const w = Math.max(MIN_W, Math.min(MAX_W, Math.round(window.innerWidth * 0.55))) + const h = Math.max(MIN_H, Math.min(MAX_H, Math.round(window.innerHeight * 0.65))) setPanelW(w) setPanelH(h) localStorage.setItem(WIDTH_KEY, String(w)) localStorage.setItem(HEIGHT_KEY, String(h)) } + return !prev }) } @@ -81,12 +89,12 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { /** Panel dimensions; both values are persisted to localStorage. */ const [panelW, setPanelW] = useState(() => { const s = localStorage.getItem(WIDTH_KEY) - const saved = s ? parseInt(s, 10) : Math.min(900, Math.round(window.innerWidth * 0.62)) + const saved = s ? Number.parseInt(s, 10) : Math.min(900, Math.round(window.innerWidth * 0.62)) return Math.max(MIN_W, Math.min(MAX_W, saved)) }) const [panelH, setPanelH] = useState(() => { const s = localStorage.getItem(HEIGHT_KEY) - const saved = s ? parseInt(s, 10) : Math.min(600, Math.round(window.innerHeight * 0.65)) + const saved = s ? Number.parseInt(s, 10) : Math.min(600, Math.round(window.innerHeight * 0.65)) return Math.max(MIN_H, Math.min(MAX_H, saved)) }) @@ -130,14 +138,14 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { /** * Initiates a resize drag in the given direction; stores initial cursor * and panel dimensions. - * @param e - * @param dir + * @param e - The mouse event that triggered the drag. + * @param dir - Resize axis: 'w' (horizontal), 'h' (vertical), or 'both' (corner). */ const startDrag = (e: React.MouseEvent, dir: 'w' | 'h' | 'both') => { e.preventDefault() dragRef.current = { dir, startX: e.clientX, startY: e.clientY, startW: panelW, startH: panelH } document.body.style.userSelect = 'none' - document.body.style.cursor = dir === 'w' ? 'ew-resize' : dir === 'h' ? 'ns-resize' : 'nwse-resize' + document.body.style.cursor = { w: 'ew-resize', h: 'ns-resize', both: 'nwse-resize' }[dir] } /** @@ -156,7 +164,7 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { const storedId = localStorage.getItem(ACTIVE_CONV_KEY) if (storedId) { - const id = parseInt(storedId, 10) + const id = Number.parseInt(storedId, 10) const exists = data.some(c => c.id === id) if (exists) { @@ -171,7 +179,7 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { /** * Fetches all messages for the conversation with the given ID. - * @param id + * @param id - ID of the conversation whose messages should be loaded. */ const loadConvMessages = (id: number) => { const convUrl = `${urlAssistantConversations}${id}/` @@ -183,7 +191,7 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { /** * Persists the selected conversation in localStorage, updates state, * and loads its messages. - * @param id + * @param id - ID of the conversation to activate. */ const selectConversation = (id: number) => { localStorage.setItem(ACTIVE_CONV_KEY, String(id)) @@ -203,16 +211,17 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { /** * DELETEs the conversation on the server, falls back to a new * conversation if it was active. - * @param id + * @param id - ID of the conversation to delete. */ const deleteConversation = (id: number) => { const convUrl = `${urlAssistantConversations}${id}/` + const removeConv = (prev: ConversationSummary[]) => prev.filter(c => c.id !== id) ky.delete(convUrl, { headers: getDjangoHeader() }).then(() => { if (activeConvId === id) { startNewConversation() } - setConversations(prev => prev.filter(c => c.id !== id)) + setConversations(removeConv) loadConversations() }).catch(err => console.error('Error deleting conversation', err)) } @@ -220,23 +229,24 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => { /** * PATCHes the conversation title on the server and updates local * state optimistically. - * @param id - * @param title + * @param id - ID of the conversation to rename. + * @param title - New title; empty string resets it to null on the server. */ const renameConversation = (id: number, title: string) => { const convUrl = `${urlAssistantConversations}${id}/` + const applyTitle = (prev: ConversationSummary[]) => prev.map(c => c.id === id ? { ...c, title: title || null } : c) ky.patch(convUrl, { headers: getDjangoHeader(), json: { title: title || null }, }).then(() => { - setConversations(prev => prev.map(c => c.id === id ? { ...c, title: title || null } : c)) + setConversations(applyTitle) }).catch(err => console.error('Error renaming conversation', err)) } /** * Appends the user message optimistically, POSTs to the chat API, * then appends the assistant reply or an error message. - * @param text + * @param text - The message text entered by the user. */ const sendMessage = (text: string) => { const userMsg: ChatMessage = { role: 'user', content: text } @@ -349,7 +359,7 @@ const ChatPanel = ({ onClose }: ChatPanelProps) => {
)}
diff --git a/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx b/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx index a3e39321..4c8392ea 100644 --- a/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx +++ b/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx @@ -11,46 +11,64 @@ interface MessageThreadProps { onSend: (text: string) => void } +/** + * Capability groups shown in the empty-state welcome screen. + * Each entry represents a thematic area the assistant can help with, + * derived from the tool set defined in `assistant/services/tools.py` + * (internal Multiomix tools) and the MCP servers configured via + * `assistant/services/mcp_loader.py` (BioMCP, cBioPortal, etc.). + * + * Displayed as a three-column grid of cards, each card showing an icon, + * a category name, and a bullet list of example capabilities. + * Update this list whenever a new tool category is added or removed. + */ const TOOL_CATEGORIES = [ { icon: 'database' as const, - name: 'Tus datos', + name: 'Your data', items: [ - 'Experimentos de correlación', - 'Biomarkers y feature selection', - 'Archivos subidos', - 'Validación estadística', + 'Correlation experiments', + 'Biomarkers and feature selection', + 'Uploaded files', + 'Statistical validation', ], }, { icon: 'lab' as const, - name: 'Bioinformática', + name: 'Bioinformatics', items: [ - 'Genes, miRNAs y sitios CpG', - 'Datasets cBioPortal (CGDS)', - 'Redes de interacción STRING', - 'Métodos estadísticos', + 'Genes, miRNAs and CpG sites', + 'cBioPortal (CGDS) datasets', + 'STRING interaction networks', + 'Statistical methods', ], }, { icon: 'book' as const, - name: 'Literatura médica', + name: 'Medical literature', items: [ - 'Papers en PubMed / bioRxiv', - 'Ensayos clínicos (ClinicalTrials)', - 'Variantes genómicas (OncoKB)', - 'Anotaciones gen / fármaco', + 'Papers on PubMed / bioRxiv', + 'Clinical trials (ClinicalTrials)', + 'Genomic variants (OncoKB)', + 'Gene / drug annotations', ], }, ] +/** + * One-click example prompts shown below the capability cards in the + * empty-state screen. Clicking a chip fires `onSend` directly with the + * prompt text, letting the user try the assistant without typing. + * These are purely illustrative — edit freely to reflect the most + * useful or frequently requested queries. + */ const SUGGESTED_PROMPTS = [ - 'Mostrar mis experimentos recientes', - '¿Cuáles son mis biomarkers?', - 'Buscar papers sobre DESeq2 en PubMed', - 'Ensayos clínicos para BRCA1', - 'Información sobre el gen TP53', - '¿Qué archivos tengo subidos?', + 'Show my recent experiments', + 'What are my biomarkers?', + 'Search PubMed papers about DESeq2', + 'Clinical trials for BRCA1', + 'Information about the TP53 gene', + 'What files have I uploaded?', ] /** @@ -171,7 +189,7 @@ const MessageThread = ({ messages, isLoading, onSend }: MessageThreadProps) => {
{messages.length === 0 && !isLoading && (
-

¿En qué puedo ayudarte?

+

How can I help you?

{TOOL_CATEGORIES.map(cat => (
@@ -212,7 +230,7 @@ const MessageThread = ({ messages, isLoading, onSend }: MessageThreadProps) => {