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/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/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..0cc208d0 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-cli==0.8.22 \ 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..15fcb6ba 100644 --- a/src/assistant/services/llm_service.py +++ b/src/assistant/services/llm_service.py @@ -1,15 +1,25 @@ +import asyncio +import logging from typing import List + 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.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. @@ -24,6 +34,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 +75,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 +106,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.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}) + 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 +171,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/frontend/static/frontend/src/components/assistant/ChatPanel.tsx b/src/frontend/static/frontend/src/components/assistant/ChatPanel.tsx index 6699eaf6..f72316d7 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' @@ -35,28 +37,66 @@ 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 props - Component props. + * @param props.onClose - Callback invoked when the user closes the panel. + * @returns The rendered chat panel element. */ -const ChatPanel = ({ onClose }: ChatPanelProps) => { +const ChatPanel = (props: ChatPanelProps) => { + const { onClose } = props 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) + const [isFullscreen, setIsFullscreen] = useState(false) + const [helpOpen, setHelpOpen] = useState(false) + const helpRef = useRef(null) + + 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.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 + }) + } /** 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 ? 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) : 520 - return Math.max(MIN_H, Math.min(window.innerHeight - 140, saved)) + 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)) }) /** Drag state kept in a ref so mouse-move handlers don't trigger re-renders. */ @@ -68,17 +108,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)) } @@ -102,14 +139,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] } /** @@ -128,7 +165,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) { @@ -143,7 +180,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}/` @@ -155,7 +192,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)) @@ -175,16 +212,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)) } @@ -192,23 +230,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 } @@ -251,10 +290,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 +
+
+ + Multiomix Assistant + {activeConvId && conversations.find(c => c.id === activeConvId)?.title && ( + <> + · + + {conversations.find(c => c.id === activeConvId)?.title} + + + )}
- +
+
+ + {helpOpen && ( +
+

Multiomix Assistant

+

+ AI assistant integrated into Multiomix that lets you explore your data + and access external bioinformatics databases conversationally. +

+

Tips for better results

+
    +
  • Use exact names for genes, experiments, or files
  • +
  • The assistant remembers context within the conversation
  • +
  • You can ask it to expand, rephrase, or explain in more detail
  • +
  • If something fails, try rephrasing the question with more context
  • +
+

Keyboard shortcuts

+
    +
  • Enter — send message
  • +
  • Shift + Enter — new line
  • +
+
+ )} +
+
+ + +
+
{/* 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..54f56ac4 100644 --- a/src/frontend/static/frontend/src/components/assistant/ChatWidget.tsx +++ b/src/frontend/static/frontend/src/components/assistant/ChatWidget.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { Button, Icon } from 'semantic-ui-react' +import { Button } from 'semantic-ui-react' import { ChatPanel } from './ChatPanel' import '../../css/chat-widget.css' @@ -8,13 +8,14 @@ const STORAGE_KEY = 'multiomix_chat_open' /** * Floating Action Button that toggles the ChatPanel open or closed. * Open state is persisted in localStorage so it survives page reloads. + * @returns The rendered FAB and, when open, the ChatPanel. */ const ChatWidget = () => { const [isOpen, setIsOpen] = useState(() => localStorage.getItem(STORAGE_KEY) === '1') /** * Sets the open/closed state and persists it to localStorage. - * @param next + * @param next - The desired open state. */ const toggle = (next: boolean) => { localStorage.setItem(STORAGE_KEY, next ? '1' : '0') @@ -25,15 +26,15 @@ const ChatWidget = () => { <> {isOpen && toggle(false)} />} -
+
diff --git a/src/frontend/static/frontend/src/components/assistant/ConversationList.tsx b/src/frontend/static/frontend/src/components/assistant/ConversationList.tsx index 82b92c0a..83345105 100644 --- a/src/frontend/static/frontend/src/components/assistant/ConversationList.tsx +++ b/src/frontend/static/frontend/src/components/assistant/ConversationList.tsx @@ -14,7 +14,8 @@ interface ConversationListProps { /** * Formats an ISO timestamp as "HH:MM" for today, or "DD Mon" otherwise. - * @param iso + * @param iso - ISO 8601 timestamp string. + * @returns Human-readable time or date string. */ function formatConvDate (iso: string): string { const d = new Date(iso) @@ -29,17 +30,19 @@ function formatConvDate (iso: string): string { /** * Sidebar list of past conversations. Each item shows the title (or - * "Sin título") and the last-updated timestamp. Supports inline + * "Untitled") and the last-updated timestamp. Supports inline * renaming on double-click and deletion via the trash icon. - * @param root0 - * @param root0.conversations - * @param root0.activeId - * @param root0.onSelect - * @param root0.onNew - * @param root0.onDelete - * @param root0.onRename + * @param props - Component props. + * @param props.conversations - List of conversation summaries to display. + * @param props.activeId - ID of the currently active conversation, or null. + * @param props.onSelect - Callback invoked when a conversation is selected. + * @param props.onNew - Callback invoked when the user starts a new conversation. + * @param props.onDelete - Callback invoked when a conversation is deleted. + * @param props.onRename - Callback invoked when a conversation is renamed. + * @returns The rendered conversation list sidebar. */ -const ConversationList = ({ conversations, activeId, onSelect, onNew, onDelete, onRename }: ConversationListProps) => { +const ConversationList = (props: ConversationListProps) => { + const { conversations, activeId, onSelect, onNew, onDelete, onRename } = props const [editingId, setEditingId] = useState>(null) const [editTitle, setEditTitle] = useState('') const inputRef = useRef(null) @@ -47,8 +50,8 @@ const ConversationList = ({ conversations, activeId, onSelect, onNew, onDelete, /** * Enters inline edit mode for the conversation title; stops event * propagation. - * @param conv - * @param e + * @param conv - The conversation being edited. + * @param e - The mouse event that triggered the edit. */ const startEdit = (conv: ConversationSummary, e: React.MouseEvent) => { e.stopPropagation() @@ -59,7 +62,7 @@ const ConversationList = ({ conversations, activeId, onSelect, onNew, onDelete, /** * Submits the trimmed title via `onRename` and exits edit mode. - * @param id + * @param id - ID of the conversation being renamed. */ const commitEdit = (id: number) => { onRename(id, editTitle.trim()) @@ -68,6 +71,7 @@ const ConversationList = ({ conversations, activeId, onSelect, onNew, onDelete, /** * Exits edit mode without saving changes. + * @returns void */ const cancelEdit = () => setEditingId(null) @@ -78,7 +82,7 @@ const ConversationList = ({ conversations, activeId, onSelect, onNew, onDelete,
)}
diff --git a/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx b/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx index e8ca5dd1..433854b4 100644 --- a/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx +++ b/src/frontend/static/frontend/src/components/assistant/MessageThread.tsx @@ -11,16 +11,70 @@ 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: 'Your data', + items: [ + 'Correlation experiments', + 'Biomarkers and feature selection', + 'Uploaded files', + 'Statistical validation', + ], + }, + { + icon: 'lab' as const, + name: 'Bioinformatics', + items: [ + 'Genes, miRNAs and CpG sites', + 'cBioPortal (CGDS) datasets', + 'STRING interaction networks', + 'Statistical methods', + ], + }, + { + icon: 'book' as const, + name: 'Medical literature', + items: [ + '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?', - '¿Qué archivos tengo subidos?', - 'Buscar información sobre el gen TP53', + '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?', ] /** * Formats an ISO timestamp as "HH:MM" for today, or "DD Mon HH:MM" otherwise. - * @param iso + * @param iso - ISO 8601 timestamp string, or undefined. + * @returns Human-readable time or date+time string, or empty string if undefined. */ function formatMsgTime (iso: string | undefined): string { if (!iso) { return '' } @@ -39,12 +93,14 @@ function formatMsgTime (iso: string | undefined): string { * support (including a click-to-enlarge image lightbox), a copy-to- * clipboard button per message, a typing indicator while the assistant * is responding, and a textarea input for sending new messages. - * @param root0 - * @param root0.messages - * @param root0.isLoading - * @param root0.onSend + * @param props - Component props. + * @param props.messages - Ordered list of chat messages to display. + * @param props.isLoading - Whether the assistant is currently generating a reply. + * @param props.onSend - Callback invoked with the message text when the user submits. + * @returns The rendered message thread. */ -const MessageThread = ({ messages, isLoading, onSend }: MessageThreadProps) => { +const MessageThread = (props: MessageThreadProps) => { + const { messages, isLoading, onSend } = props const [input, setInput] = useState('') const [lightboxSrc, setLightboxSrc] = useState>(null) const [copiedKey, setCopiedKey] = useState>(null) @@ -81,7 +137,7 @@ const MessageThread = ({ messages, isLoading, onSend }: MessageThreadProps) => { /** * Submits on Enter; allows Shift+Enter for line breaks. - * @param e + * @param e - Keyboard event from the textarea. */ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -93,8 +149,8 @@ const MessageThread = ({ messages, isLoading, onSend }: MessageThreadProps) => { /** * Copies content to the clipboard and briefly flips the button to a * "copied" state. - * @param key - * @param content + * @param key - Unique key identifying which message's button to flip. + * @param content - Text content to write to the clipboard. */ const handleCopy = (key: string, content: string) => { navigator.clipboard.writeText(content).then(() => { @@ -136,9 +192,22 @@ const MessageThread = ({ messages, isLoading, onSend }: MessageThreadProps) => {
{messages.length === 0 && !isLoading && (
-

- Preguntame sobre tus experimentos, genes o biomarkers. -

+

How can I help you?

+
+ {TOOL_CATEGORIES.map(cat => ( +
+
+ + {cat.name} +
+
    + {cat.items.map(item => ( +
  • {item}
  • + ))} +
+
+ ))} +
{SUGGESTED_PROMPTS.map(prompt => (