hemantn Claude Sonnet 4.6 commited on
Commit Β·
037ba33
1
Parent(s): bdb1950
Deploy CpptrajGPT to HuggingFace Spaces
Browse files- Flask + multi-user session isolation (per-session runner/agent/state)
- AI agent: Claude, OpenAI (Responses API), Gemini (native SDK)
- cpptraj editor, Python editor, 3D trajectory viewer
- RAG over cpptraj manual (pre-built cache JSON)
- Dockerfile: conda-forge cpptraj, port 7860, non-root user
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .env.example +2 -0
- .gitignore +26 -0
- Dockerfile +38 -0
- README.md +164 -4
- agent_ide.html +0 -0
- app.py +1483 -0
- core/__init__.py +0 -0
- core/agent.py +502 -0
- core/knowledge_base.py +458 -0
- core/llm_backends.py +468 -0
- core/runner.py +180 -0
- cpptraj_manual_cache.json +0 -0
- requirements.txt +10 -0
- server.py +486 -0
- test_data/README.txt +25 -0
.env.example
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ANTHROPIC_API_KEY=your_api_key_here
|
| 2 |
+
CPPTRAJ_PATH=cpptraj
|
.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
*.env
|
| 4 |
+
|
| 5 |
+
# Python
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.py[cod]
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
*.egg-info/
|
| 11 |
+
dist/
|
| 12 |
+
build/
|
| 13 |
+
.venv/
|
| 14 |
+
venv/
|
| 15 |
+
env/
|
| 16 |
+
|
| 17 |
+
# Large files
|
| 18 |
+
CpptrajManual.pdf
|
| 19 |
+
Test/
|
| 20 |
+
|
| 21 |
+
# Sensitive
|
| 22 |
+
resume.txt
|
| 23 |
+
|
| 24 |
+
# OS
|
| 25 |
+
.DS_Store
|
| 26 |
+
*.swp
|
Dockerfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM continuumio/miniconda3:latest
|
| 2 |
+
|
| 3 |
+
# Install system dependencies
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
build-essential \
|
| 6 |
+
curl \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
# Install cpptraj from conda-forge
|
| 10 |
+
RUN conda install -y -c conda-forge cpptraj && conda clean -afy
|
| 11 |
+
|
| 12 |
+
# Set working directory
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
|
| 15 |
+
# Copy requirements first for layer caching
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
|
| 18 |
+
# Install Python dependencies
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# Copy application code
|
| 22 |
+
COPY . .
|
| 23 |
+
|
| 24 |
+
# HuggingFace Spaces runs as non-root user 1000
|
| 25 |
+
RUN useradd -m -u 1000 user && chown -R user:user /app
|
| 26 |
+
USER user
|
| 27 |
+
|
| 28 |
+
# Create temp dir that the app can write to
|
| 29 |
+
RUN mkdir -p /tmp/cpptraj_sessions
|
| 30 |
+
|
| 31 |
+
# Expose port 7860 (required by HuggingFace Spaces)
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
ENV PORT=7860
|
| 35 |
+
ENV CPPTRAJ_PATH=/opt/conda/bin/cpptraj
|
| 36 |
+
ENV FLASK_SECRET_KEY=cpptrajgpt-hf-spaces-secret
|
| 37 |
+
|
| 38 |
+
CMD ["python", "server.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,170 @@
|
|
| 1 |
---
|
| 2 |
title: CpptrajGPT
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: CpptrajGPT
|
| 3 |
+
emoji: π§¬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# CpptrajGPT
|
| 12 |
+
|
| 13 |
+
An AI-powered IDE for molecular dynamics trajectory analysis using **cpptraj** and large language models with RAG (Retrieval-Augmented Generation).
|
| 14 |
+
|
| 15 |
+

|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## Features
|
| 20 |
+
|
| 21 |
+
- **IDE-style interface** β three-panel layout (command reference | script editor + AI chat | file manager)
|
| 22 |
+
- **AI Agent + RAG** β prompt the AI for any analysis, it writes and runs the cpptraj script automatically
|
| 23 |
+
- **Multi-provider AI** β Claude, OpenAI, Gemini (cloud) or Ollama/qwen2.5-coder (local, free)
|
| 24 |
+
- **Script Editor** β write/edit cpptraj scripts with syntax hints and one-click execution
|
| 25 |
+
- **Script Builder** β GUI builder for common analyses (RMSD, RMSF, clustering, PCA, etc.)
|
| 26 |
+
- **Results Viewer** β interactive Plotly plots of output data files
|
| 27 |
+
- **3D Viewer** β molecular structure visualization (NGL)
|
| 28 |
+
- **Command Reference** β searchable cpptraj documentation with examples
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Quick Start
|
| 33 |
+
|
| 34 |
+
### 1. Clone and install dependencies
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
git clone https://github.com/nagarh/CpptrajGPT.git
|
| 38 |
+
cd CpptrajGPT
|
| 39 |
+
pip install -r requirements.txt
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### 2. Install cpptraj
|
| 43 |
+
|
| 44 |
+
cpptraj must be installed and available on your PATH.
|
| 45 |
+
|
| 46 |
+
**Via conda (recommended):**
|
| 47 |
+
```bash
|
| 48 |
+
conda install -c conda-forge ambertools
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
**From source:**
|
| 52 |
+
```bash
|
| 53 |
+
git clone https://github.com/Amber-MD/cpptraj.git
|
| 54 |
+
cd cpptraj && ./configure gnu && make -j4 install
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
### 3. Choose your AI backend
|
| 58 |
+
|
| 59 |
+
#### Option A β Local AI (Free, No API key, GPU or CPU)
|
| 60 |
+
|
| 61 |
+
Best for researchers who want full privacy and no cost.
|
| 62 |
+
|
| 63 |
+
**Step 1: Install Ollama**
|
| 64 |
+
```bash
|
| 65 |
+
# Linux / macOS
|
| 66 |
+
curl -fsSL https://ollama.com/install.sh | sh
|
| 67 |
+
|
| 68 |
+
# Windows: download installer from https://ollama.com/download
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
**Step 2: Pull the model**
|
| 72 |
+
```bash
|
| 73 |
+
ollama pull qwen2.5-coder:7b
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
> **Requirements:** ~8GB RAM. Works on CPU (slower) or GPU (faster). Ollama automatically detects and uses GPU if available, otherwise falls back to CPU.
|
| 77 |
+
|
| 78 |
+
**Step 3: Start Ollama** (if not already running)
|
| 79 |
+
```bash
|
| 80 |
+
ollama serve
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
> Ollama runs a local server at **`http://localhost:11434`** (fixed default port).
|
| 84 |
+
> CpptrajGPT automatically pings this URL to verify Ollama is running and the model is available.
|
| 85 |
+
> You will see a live status indicator in the β Settings modal:
|
| 86 |
+
> - β
Green β Ollama running and `qwen2.5-coder:7b` detected
|
| 87 |
+
> - β οΈ Yellow β Ollama running but model not pulled yet (run `ollama pull qwen2.5-coder:7b`)
|
| 88 |
+
> - β Red β Ollama not running (run `ollama serve`)
|
| 89 |
+
|
| 90 |
+
#### Option B β Cloud AI (API key required)
|
| 91 |
+
|
| 92 |
+
| Provider | Where to get key | Notes |
|
| 93 |
+
|----------|-----------------|-------|
|
| 94 |
+
| **Anthropic (Claude)** | [console.anthropic.com](https://console.anthropic.com) | Best quality |
|
| 95 |
+
| **OpenAI (GPT-4o)** | [platform.openai.com](https://platform.openai.com) | Widely used |
|
| 96 |
+
| **Google (Gemini)** | [aistudio.google.com](https://aistudio.google.com) | Free tier available |
|
| 97 |
+
|
| 98 |
+
No setup needed β just enter your API key in the IDE Settings (β icon).
|
| 99 |
+
|
| 100 |
+
> β οΈ **Privacy Note (HuggingFace Space):** Your API key is used only for the duration of your browser session and is never logged or stored on disk. However, for sensitive work or large trajectories, we recommend **running locally** (see Quick Start above).
|
| 101 |
+
|
| 102 |
+
### 4. Run the IDE
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
python server.py
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Open your browser at **http://localhost:8502**
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Setting up AI in the IDE
|
| 113 |
+
|
| 114 |
+
1. Click the **β Settings** button in the top-right of the IDE
|
| 115 |
+
2. Select your provider (Claude / OpenAI / Gemini / Ollama)
|
| 116 |
+
3. Enter your API key (not needed for Ollama)
|
| 117 |
+
4. Select a model and click **Save**
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## Architecture
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
CpptrajGPT/
|
| 125 |
+
βββ server.py # Flask backend (API endpoints)
|
| 126 |
+
βββ agent_ide.html # Frontend IDE (HTML/CSS/JS)
|
| 127 |
+
βββ core/
|
| 128 |
+
β βββ agent.py # AI agent with tool use
|
| 129 |
+
β βββ knowledge_base.py # cpptraj docs + TF-IDF RAG retrieval
|
| 130 |
+
β βββ llm_backends.py # Claude / OpenAI / Gemini / Ollama backends
|
| 131 |
+
β βββ runner.py # cpptraj subprocess execution
|
| 132 |
+
βββ test_data/ # Sample topology and trajectory for testing
|
| 133 |
+
βββ requirements.txt
|
| 134 |
+
βββ .env.example
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
### How AI + RAG works
|
| 138 |
+
|
| 139 |
+
1. User provides a prompt describing the desired analysis
|
| 140 |
+
2. TF-IDF retrieval finds the most relevant cpptraj documentation chunks
|
| 141 |
+
3. Retrieved docs are injected as context into the AI prompt
|
| 142 |
+
4. AI writes a cpptraj script using its tools (`run_cpptraj_script`, `read_output_file`)
|
| 143 |
+
5. Script is executed and results returned to the AI
|
| 144 |
+
6. AI interprets and summarizes the results
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## Supported Analyses
|
| 149 |
+
|
| 150 |
+
CpptrajGPT supports all analyses available in CPPTRAJ. Common examples include:
|
| 151 |
+
|
| 152 |
+
| Category | Analyses |
|
| 153 |
+
|----------|----------|
|
| 154 |
+
| **Structural** | RMSD, RMSF, radius of gyration, SASA, distance, angle, dihedral |
|
| 155 |
+
| **Dynamics** | Diffusion/MSD, atomic fluctuation (B-factors), covariance matrix |
|
| 156 |
+
| **Clustering** | HierAgglo, K-means, DBSCAN |
|
| 157 |
+
| **Dimensionality reduction** | PCA (matrix + projection) |
|
| 158 |
+
| **Interactions** | Hydrogen bonds, native contacts (Q-value), water shell |
|
| 159 |
+
| **Secondary structure** | DSSP per-residue and per-frame |
|
| 160 |
+
| **Density & maps** | Volumetric density maps, density profiles |
|
| 161 |
+
| **Specialized** | Ring pucker, multidihedral (Ο/Ο/Ο), trajectory imaging, stripping |
|
| 162 |
+
|
| 163 |
+
Any analysis not listed above can still be requested via prompt.
|
| 164 |
+
|
| 165 |
+
## Supported File Formats
|
| 166 |
+
|
| 167 |
+
| Type | Formats |
|
| 168 |
+
|------|---------|
|
| 169 |
+
| Topology | `.prmtop` `.parm7` `.psf` `.pdb` `.gro` `.mol2` |
|
| 170 |
+
| Trajectory | `.nc` `.ncdf` `.dcd` `.xtc` `.trr` `.crd` `.mdcrd` |
|
agent_ide.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app.py
ADDED
|
@@ -0,0 +1,1483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CPPTRAJ Agent β IDE-style Streamlit UI
|
| 3 |
+
Matches the aesthetic of agent_ide.html:
|
| 4 |
+
- Dark GitHub-style theme (#0d1117 bg)
|
| 5 |
+
- JetBrains Mono + Syne fonts
|
| 6 |
+
- Three-panel layout: Command Ref | Editor + Terminal/AI | Files + Detail
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import base64 as _b64
|
| 10 |
+
import os
|
| 11 |
+
import tempfile
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import plotly.express as px
|
| 16 |
+
import streamlit as st
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
from core.agent import TrajectoryAgent
|
| 22 |
+
from core.knowledge_base import CPPTrajKnowledgeBase, CPPTRAJ_COMMANDS, SCRIPT_TEMPLATES
|
| 23 |
+
from core.runner import CPPTrajRunner
|
| 24 |
+
|
| 25 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
# PAGE CONFIG
|
| 27 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
|
| 29 |
+
st.set_page_config(
|
| 30 |
+
page_title="cpptraj IDE",
|
| 31 |
+
page_icon="⬑",
|
| 32 |
+
layout="wide",
|
| 33 |
+
initial_sidebar_state="collapsed",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
# GLOBAL CSS β match agent_ide.html exactly
|
| 38 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
_CSS = """
|
| 41 |
+
/* ββ Variables ββββββββββββββββββββββββββββββββββββββββ */
|
| 42 |
+
:root {
|
| 43 |
+
--bg: #0d1117;
|
| 44 |
+
--surface: #161b22;
|
| 45 |
+
--surface2: #21262d;
|
| 46 |
+
--surface3: #30363d;
|
| 47 |
+
--border: #30363d;
|
| 48 |
+
--accent: #58a6ff;
|
| 49 |
+
--accent2: #3fb950;
|
| 50 |
+
--accent3: #f78166;
|
| 51 |
+
--accent4: #e3b341;
|
| 52 |
+
--text: #e6edf3;
|
| 53 |
+
--muted: #8b949e;
|
| 54 |
+
--dim: #484f58;
|
| 55 |
+
--keyword: #ff7b72;
|
| 56 |
+
--option: #79c0ff;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* ββ Base βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 60 |
+
html, body, [data-testid="stAppViewContainer"], .stApp {
|
| 61 |
+
background: var(--bg) !important;
|
| 62 |
+
font-family: 'Syne', sans-serif !important;
|
| 63 |
+
color: var(--text) !important;
|
| 64 |
+
}
|
| 65 |
+
[data-testid="stHeader"] { display: none !important; }
|
| 66 |
+
[data-testid="stDecoration"] { display: none !important; }
|
| 67 |
+
[data-testid="stToolbar"] { display: none !important; }
|
| 68 |
+
.block-container {
|
| 69 |
+
padding: 0 !important;
|
| 70 |
+
max-width: 100% !important;
|
| 71 |
+
}
|
| 72 |
+
footer { display: none !important; }
|
| 73 |
+
#MainMenu { display: none !important; }
|
| 74 |
+
|
| 75 |
+
/* ββ Sidebar hide βββββββββββββββββββββββββββββββββββββ */
|
| 76 |
+
[data-testid="stSidebar"] { display: none !important; }
|
| 77 |
+
|
| 78 |
+
/* ββ Custom Header ββββββββββββββββββββββββββββββββββββ */
|
| 79 |
+
.ide-header {
|
| 80 |
+
background: var(--surface);
|
| 81 |
+
border-bottom: 1px solid var(--border);
|
| 82 |
+
padding: 0 20px;
|
| 83 |
+
height: 52px;
|
| 84 |
+
display: flex;
|
| 85 |
+
align-items: center;
|
| 86 |
+
gap: 16px;
|
| 87 |
+
position: sticky;
|
| 88 |
+
top: 0;
|
| 89 |
+
z-index: 999;
|
| 90 |
+
}
|
| 91 |
+
.ide-logo {
|
| 92 |
+
font-size: 18px;
|
| 93 |
+
font-weight: 800;
|
| 94 |
+
font-family: 'Syne', sans-serif;
|
| 95 |
+
letter-spacing: -0.5px;
|
| 96 |
+
display: flex;
|
| 97 |
+
align-items: center;
|
| 98 |
+
gap: 8px;
|
| 99 |
+
white-space: nowrap;
|
| 100 |
+
}
|
| 101 |
+
.ide-logo-icon { color: var(--accent); font-family: 'JetBrains Mono', monospace; font-size: 22px; }
|
| 102 |
+
.ide-logo b { color: var(--accent); }
|
| 103 |
+
.ide-search {
|
| 104 |
+
flex: 1;
|
| 105 |
+
max-width: 360px;
|
| 106 |
+
position: relative;
|
| 107 |
+
}
|
| 108 |
+
.ide-search input {
|
| 109 |
+
width: 100%;
|
| 110 |
+
background: var(--bg);
|
| 111 |
+
border: 1px solid var(--border);
|
| 112 |
+
border-radius: 8px;
|
| 113 |
+
padding: 7px 12px 7px 34px;
|
| 114 |
+
color: var(--text);
|
| 115 |
+
font-family: 'JetBrains Mono', monospace;
|
| 116 |
+
font-size: 13px;
|
| 117 |
+
outline: none;
|
| 118 |
+
}
|
| 119 |
+
.ide-search input:focus { border-color: var(--accent); }
|
| 120 |
+
.ide-search-icon {
|
| 121 |
+
position: absolute;
|
| 122 |
+
left: 10px;
|
| 123 |
+
top: 50%;
|
| 124 |
+
transform: translateY(-50%);
|
| 125 |
+
color: var(--muted);
|
| 126 |
+
font-size: 14px;
|
| 127 |
+
}
|
| 128 |
+
.ide-status-row {
|
| 129 |
+
margin-left: auto;
|
| 130 |
+
display: flex;
|
| 131 |
+
align-items: center;
|
| 132 |
+
gap: 14px;
|
| 133 |
+
font-family: 'JetBrains Mono', monospace;
|
| 134 |
+
font-size: 11px;
|
| 135 |
+
color: var(--muted);
|
| 136 |
+
}
|
| 137 |
+
.ide-status-dot {
|
| 138 |
+
width: 7px; height: 7px; border-radius: 50%;
|
| 139 |
+
background: var(--accent2);
|
| 140 |
+
display: inline-block; margin-right: 5px;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* ββ Panel wrapper ββββββββββββββββββββββββββββββββββββ */
|
| 144 |
+
.ide-panels {
|
| 145 |
+
display: flex;
|
| 146 |
+
height: calc(100vh - 52px);
|
| 147 |
+
overflow: hidden;
|
| 148 |
+
}
|
| 149 |
+
.panel-left {
|
| 150 |
+
width: 290px;
|
| 151 |
+
min-width: 290px;
|
| 152 |
+
background: var(--surface);
|
| 153 |
+
border-right: 1px solid var(--border);
|
| 154 |
+
display: flex;
|
| 155 |
+
flex-direction: column;
|
| 156 |
+
overflow: hidden;
|
| 157 |
+
}
|
| 158 |
+
.panel-center {
|
| 159 |
+
flex: 1;
|
| 160 |
+
display: flex;
|
| 161 |
+
flex-direction: column;
|
| 162 |
+
overflow: hidden;
|
| 163 |
+
background: var(--bg);
|
| 164 |
+
}
|
| 165 |
+
.panel-right {
|
| 166 |
+
width: 330px;
|
| 167 |
+
min-width: 330px;
|
| 168 |
+
background: var(--surface);
|
| 169 |
+
border-left: 1px solid var(--border);
|
| 170 |
+
display: flex;
|
| 171 |
+
flex-direction: column;
|
| 172 |
+
overflow: hidden;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* ββ Panel headers ββββββββββββββββββββββββββββββββββββ */
|
| 176 |
+
.panel-hdr {
|
| 177 |
+
padding: 9px 14px;
|
| 178 |
+
font-size: 10px;
|
| 179 |
+
font-weight: 700;
|
| 180 |
+
letter-spacing: 0.1em;
|
| 181 |
+
text-transform: uppercase;
|
| 182 |
+
color: var(--muted);
|
| 183 |
+
border-bottom: 1px solid var(--border);
|
| 184 |
+
display: flex;
|
| 185 |
+
align-items: center;
|
| 186 |
+
justify-content: space-between;
|
| 187 |
+
flex-shrink: 0;
|
| 188 |
+
background: var(--surface);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* ββ Filter tabs ββββββββββββββββββββββββββββββββββββββ */
|
| 192 |
+
.filter-bar {
|
| 193 |
+
display: flex;
|
| 194 |
+
gap: 4px;
|
| 195 |
+
padding: 8px 10px;
|
| 196 |
+
border-bottom: 1px solid var(--border);
|
| 197 |
+
flex-wrap: wrap;
|
| 198 |
+
flex-shrink: 0;
|
| 199 |
+
}
|
| 200 |
+
.ftab {
|
| 201 |
+
padding: 3px 10px;
|
| 202 |
+
border-radius: 20px;
|
| 203 |
+
font-size: 11px;
|
| 204 |
+
font-weight: 600;
|
| 205 |
+
cursor: pointer;
|
| 206 |
+
border: 1px solid var(--border);
|
| 207 |
+
color: var(--muted);
|
| 208 |
+
background: transparent;
|
| 209 |
+
transition: all 0.15s;
|
| 210 |
+
}
|
| 211 |
+
.ftab:hover { border-color: var(--accent); color: var(--accent); }
|
| 212 |
+
.ftab.active { background: var(--accent); border-color: var(--accent); color: #000; }
|
| 213 |
+
|
| 214 |
+
/* ββ Cmd list βββββββββββββββββββββββββββββββββββββββββ */
|
| 215 |
+
.cmd-list {
|
| 216 |
+
overflow-y: auto;
|
| 217 |
+
flex: 1;
|
| 218 |
+
padding: 4px;
|
| 219 |
+
}
|
| 220 |
+
.cmd-list::-webkit-scrollbar { width: 3px; }
|
| 221 |
+
.cmd-list::-webkit-scrollbar-thumb { background: var(--surface3); border-radius: 2px; }
|
| 222 |
+
|
| 223 |
+
.cmd-item {
|
| 224 |
+
padding: 8px 10px;
|
| 225 |
+
border-radius: 6px;
|
| 226 |
+
cursor: pointer;
|
| 227 |
+
margin-bottom: 1px;
|
| 228 |
+
transition: background 0.1s;
|
| 229 |
+
border-left: 2px solid transparent;
|
| 230 |
+
}
|
| 231 |
+
.cmd-item:hover { background: var(--surface2); }
|
| 232 |
+
.cmd-item.sel { background: var(--surface2); border-left-color: var(--accent); }
|
| 233 |
+
.cmd-name {
|
| 234 |
+
font-family: 'JetBrains Mono', monospace;
|
| 235 |
+
font-size: 13px;
|
| 236 |
+
font-weight: 600;
|
| 237 |
+
color: var(--keyword);
|
| 238 |
+
}
|
| 239 |
+
.cmd-sub {
|
| 240 |
+
font-size: 11px;
|
| 241 |
+
color: var(--muted);
|
| 242 |
+
margin-top: 2px;
|
| 243 |
+
line-height: 1.3;
|
| 244 |
+
}
|
| 245 |
+
.badge {
|
| 246 |
+
display: inline-block;
|
| 247 |
+
font-size: 9px;
|
| 248 |
+
padding: 1px 6px;
|
| 249 |
+
border-radius: 3px;
|
| 250 |
+
font-weight: 700;
|
| 251 |
+
margin-left: 6px;
|
| 252 |
+
vertical-align: middle;
|
| 253 |
+
font-family: 'Syne', sans-serif;
|
| 254 |
+
}
|
| 255 |
+
.b-analysis { background: rgba(88,166,255,.15); color: var(--accent); }
|
| 256 |
+
.b-action { background: rgba(63,185,80,.15); color: var(--accent2); }
|
| 257 |
+
.b-input { background: rgba(227,179,65,.15); color: var(--accent4); }
|
| 258 |
+
.b-output { background: rgba(247,129,102,.15);color: var(--accent3); }
|
| 259 |
+
.b-mask { background: rgba(121,192,255,.15);color: var(--option); }
|
| 260 |
+
|
| 261 |
+
/* ββ Editor toolbar βββββββββββββββββββββββββββββββββββ */
|
| 262 |
+
.editor-bar {
|
| 263 |
+
background: var(--surface);
|
| 264 |
+
border-bottom: 1px solid var(--border);
|
| 265 |
+
padding: 6px 14px;
|
| 266 |
+
display: flex;
|
| 267 |
+
align-items: center;
|
| 268 |
+
gap: 8px;
|
| 269 |
+
flex-shrink: 0;
|
| 270 |
+
}
|
| 271 |
+
.file-tab {
|
| 272 |
+
padding: 4px 12px;
|
| 273 |
+
border-radius: 4px;
|
| 274 |
+
font-family: 'JetBrains Mono', monospace;
|
| 275 |
+
font-size: 12px;
|
| 276 |
+
color: var(--text);
|
| 277 |
+
background: var(--surface2);
|
| 278 |
+
border: 1px solid var(--border);
|
| 279 |
+
display: flex;
|
| 280 |
+
align-items: center;
|
| 281 |
+
gap: 6px;
|
| 282 |
+
}
|
| 283 |
+
.file-tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent4); }
|
| 284 |
+
|
| 285 |
+
/* ββ Mode switcher (Editor / AI Agent / Builder / Results) ββ */
|
| 286 |
+
.mode-bar {
|
| 287 |
+
display: flex;
|
| 288 |
+
background: var(--bg);
|
| 289 |
+
border-bottom: 1px solid var(--border);
|
| 290 |
+
flex-shrink: 0;
|
| 291 |
+
}
|
| 292 |
+
.mode-btn {
|
| 293 |
+
padding: 8px 18px;
|
| 294 |
+
font-family: 'Syne', sans-serif;
|
| 295 |
+
font-size: 12px;
|
| 296 |
+
font-weight: 600;
|
| 297 |
+
color: var(--muted);
|
| 298 |
+
cursor: pointer;
|
| 299 |
+
border: none;
|
| 300 |
+
background: transparent;
|
| 301 |
+
border-bottom: 2px solid transparent;
|
| 302 |
+
transition: all 0.15s;
|
| 303 |
+
}
|
| 304 |
+
.mode-btn:hover { color: var(--text); }
|
| 305 |
+
.mode-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
| 306 |
+
|
| 307 |
+
/* ββ Terminal βββββββββββββββββββββββββββββββββββββββββ */
|
| 308 |
+
.terminal {
|
| 309 |
+
background: #010409;
|
| 310 |
+
border-top: 1px solid var(--border);
|
| 311 |
+
font-family: 'JetBrains Mono', monospace;
|
| 312 |
+
font-size: 12px;
|
| 313 |
+
line-height: 1.7;
|
| 314 |
+
overflow-y: auto;
|
| 315 |
+
flex-shrink: 0;
|
| 316 |
+
}
|
| 317 |
+
.terminal::-webkit-scrollbar { width: 3px; }
|
| 318 |
+
.terminal::-webkit-scrollbar-thumb { background: var(--surface3); border-radius: 2px; }
|
| 319 |
+
.t-hdr {
|
| 320 |
+
background: var(--surface);
|
| 321 |
+
border-bottom: 1px solid var(--border);
|
| 322 |
+
padding: 5px 14px;
|
| 323 |
+
font-size: 10px;
|
| 324 |
+
font-weight: 700;
|
| 325 |
+
letter-spacing: 0.1em;
|
| 326 |
+
text-transform: uppercase;
|
| 327 |
+
color: var(--muted);
|
| 328 |
+
display: flex;
|
| 329 |
+
align-items: center;
|
| 330 |
+
justify-content: space-between;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* ββ Run bar βββββββοΏ½οΏ½ββββββββββββββββββββββββββββββββββ */
|
| 334 |
+
.run-bar {
|
| 335 |
+
background: var(--surface);
|
| 336 |
+
border-top: 1px solid var(--border);
|
| 337 |
+
padding: 7px 14px;
|
| 338 |
+
display: flex;
|
| 339 |
+
align-items: center;
|
| 340 |
+
gap: 10px;
|
| 341 |
+
flex-shrink: 0;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/* ββ Buttons ββββββββββββββββββββββββββββββββββββββββββ */
|
| 345 |
+
.ibtn {
|
| 346 |
+
padding: 6px 14px;
|
| 347 |
+
border-radius: 6px;
|
| 348 |
+
font-family: 'Syne', sans-serif;
|
| 349 |
+
font-weight: 600;
|
| 350 |
+
font-size: 12px;
|
| 351 |
+
cursor: pointer;
|
| 352 |
+
border: none;
|
| 353 |
+
transition: all 0.15s;
|
| 354 |
+
white-space: nowrap;
|
| 355 |
+
}
|
| 356 |
+
.ibtn-green { background: var(--accent2); color: #000; }
|
| 357 |
+
.ibtn-green:hover { background: #56d364; }
|
| 358 |
+
.ibtn-blue { background: var(--accent); color: #000; }
|
| 359 |
+
.ibtn-blue:hover { background: #79b8ff; }
|
| 360 |
+
.ibtn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
| 361 |
+
.ibtn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
| 362 |
+
.ibtn-sm { padding: 4px 10px; font-size: 11px; }
|
| 363 |
+
|
| 364 |
+
/* ββ Upload zone ββββββββββββββββββββββββββββββββββββββ */
|
| 365 |
+
.upload-zone {
|
| 366 |
+
margin: 10px;
|
| 367 |
+
border: 2px dashed var(--border);
|
| 368 |
+
border-radius: 8px;
|
| 369 |
+
padding: 14px;
|
| 370 |
+
text-align: center;
|
| 371 |
+
cursor: pointer;
|
| 372 |
+
transition: all 0.2s;
|
| 373 |
+
}
|
| 374 |
+
.upload-zone:hover { border-color: var(--accent); background: rgba(88,166,255,.05); }
|
| 375 |
+
|
| 376 |
+
/* ββ File items βββββββββββββββββββββββββββββββββββββββ */
|
| 377 |
+
.fitem {
|
| 378 |
+
display: flex;
|
| 379 |
+
align-items: center;
|
| 380 |
+
gap: 8px;
|
| 381 |
+
padding: 6px 8px;
|
| 382 |
+
border-radius: 6px;
|
| 383 |
+
background: var(--surface2);
|
| 384 |
+
margin: 4px 10px;
|
| 385 |
+
font-size: 12px;
|
| 386 |
+
}
|
| 387 |
+
.fitem-name {
|
| 388 |
+
font-family: 'JetBrains Mono', monospace;
|
| 389 |
+
font-size: 11px;
|
| 390 |
+
color: var(--text);
|
| 391 |
+
flex: 1;
|
| 392 |
+
overflow: hidden;
|
| 393 |
+
text-overflow: ellipsis;
|
| 394 |
+
white-space: nowrap;
|
| 395 |
+
}
|
| 396 |
+
.fitem-meta { font-size: 10px; color: var(--muted); }
|
| 397 |
+
.fitem-type {
|
| 398 |
+
font-size: 9px;
|
| 399 |
+
padding: 1px 5px;
|
| 400 |
+
border-radius: 3px;
|
| 401 |
+
background: rgba(88,166,255,.15);
|
| 402 |
+
color: var(--accent);
|
| 403 |
+
font-weight: 700;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
/* ββ Command detail βββββββββββββββββββββββββββββββββββ */
|
| 407 |
+
.detail-box { padding: 14px; overflow-y: auto; flex: 1; }
|
| 408 |
+
.detail-box::-webkit-scrollbar { width: 3px; }
|
| 409 |
+
.detail-box::-webkit-scrollbar-thumb { background: var(--surface3); border-radius: 2px; }
|
| 410 |
+
.detail-cmd { font-family: 'JetBrains Mono', monospace; font-size: 18px; font-weight: 700; color: var(--keyword); }
|
| 411 |
+
.detail-cat { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; margin: 4px 0 10px; }
|
| 412 |
+
.detail-desc { font-size: 12px; line-height: 1.6; color: var(--text); margin-bottom: 12px; }
|
| 413 |
+
.sect-title {
|
| 414 |
+
font-size: 9px;
|
| 415 |
+
font-weight: 700;
|
| 416 |
+
letter-spacing: .1em;
|
| 417 |
+
text-transform: uppercase;
|
| 418 |
+
color: var(--muted);
|
| 419 |
+
padding-bottom: 4px;
|
| 420 |
+
border-bottom: 1px solid var(--border);
|
| 421 |
+
margin-bottom: 8px;
|
| 422 |
+
}
|
| 423 |
+
.syntax-box {
|
| 424 |
+
background: var(--bg);
|
| 425 |
+
border: 1px solid var(--border);
|
| 426 |
+
border-radius: 6px;
|
| 427 |
+
padding: 10px 12px;
|
| 428 |
+
font-family: 'JetBrains Mono', monospace;
|
| 429 |
+
font-size: 12px;
|
| 430 |
+
line-height: 1.6;
|
| 431 |
+
color: var(--text);
|
| 432 |
+
position: relative;
|
| 433 |
+
word-break: break-all;
|
| 434 |
+
}
|
| 435 |
+
.example-box {
|
| 436 |
+
background: var(--bg);
|
| 437 |
+
border: 1px solid var(--border);
|
| 438 |
+
border-left: 3px solid var(--accent2);
|
| 439 |
+
border-radius: 4px;
|
| 440 |
+
padding: 8px 10px;
|
| 441 |
+
font-family: 'JetBrains Mono', monospace;
|
| 442 |
+
font-size: 11px;
|
| 443 |
+
color: var(--text);
|
| 444 |
+
line-height: 1.7;
|
| 445 |
+
white-space: pre;
|
| 446 |
+
overflow-x: auto;
|
| 447 |
+
}
|
| 448 |
+
.opt-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: flex-start; }
|
| 449 |
+
.opt-key { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--option); min-width: 80px; flex-shrink: 0; }
|
| 450 |
+
.opt-val { font-size: 11px; color: var(--muted); line-height: 1.4; }
|
| 451 |
+
.insert-btn {
|
| 452 |
+
width: 100%;
|
| 453 |
+
margin-top: 6px;
|
| 454 |
+
padding: 6px;
|
| 455 |
+
border-radius: 5px;
|
| 456 |
+
background: rgba(88,166,255,.1);
|
| 457 |
+
border: 1px solid rgba(88,166,255,.3);
|
| 458 |
+
color: var(--accent);
|
| 459 |
+
font-family: 'Syne', sans-serif;
|
| 460 |
+
font-weight: 600;
|
| 461 |
+
font-size: 11px;
|
| 462 |
+
cursor: pointer;
|
| 463 |
+
transition: all 0.15s;
|
| 464 |
+
text-align: center;
|
| 465 |
+
}
|
| 466 |
+
.insert-btn:hover { background: rgba(88,166,255,.2); }
|
| 467 |
+
|
| 468 |
+
/* ββ Chat βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 469 |
+
.chat-area {
|
| 470 |
+
flex: 1;
|
| 471 |
+
overflow-y: auto;
|
| 472 |
+
padding: 14px;
|
| 473 |
+
display: flex;
|
| 474 |
+
flex-direction: column;
|
| 475 |
+
gap: 14px;
|
| 476 |
+
}
|
| 477 |
+
.chat-area::-webkit-scrollbar { width: 3px; }
|
| 478 |
+
.chat-area::-webkit-scrollbar-thumb { background: var(--surface3); border-radius: 2px; }
|
| 479 |
+
.chat-msg { display: flex; gap: 10px; }
|
| 480 |
+
.chat-msg.user { flex-direction: row-reverse; }
|
| 481 |
+
.avatar {
|
| 482 |
+
width: 28px; height: 28px; border-radius: 50%;
|
| 483 |
+
display: flex; align-items: center; justify-content: center;
|
| 484 |
+
font-size: 12px; font-weight: 700; flex-shrink: 0;
|
| 485 |
+
}
|
| 486 |
+
.avatar-user { background: var(--accent); color: #000; }
|
| 487 |
+
.avatar-ai { background: var(--accent2); color: #000; }
|
| 488 |
+
.bubble {
|
| 489 |
+
max-width: 85%;
|
| 490 |
+
padding: 8px 12px;
|
| 491 |
+
border-radius: 8px;
|
| 492 |
+
font-size: 13px;
|
| 493 |
+
line-height: 1.5;
|
| 494 |
+
}
|
| 495 |
+
.bubble-user { background: rgba(88,166,255,.15); border: 1px solid rgba(88,166,255,.3); color: var(--text); }
|
| 496 |
+
.bubble-ai { background: var(--surface2); border: 1px solid var(--border); color: var(--text); }
|
| 497 |
+
.chat-input-bar {
|
| 498 |
+
background: var(--surface);
|
| 499 |
+
border-top: 1px solid var(--border);
|
| 500 |
+
padding: 10px 14px;
|
| 501 |
+
display: flex;
|
| 502 |
+
gap: 8px;
|
| 503 |
+
flex-shrink: 0;
|
| 504 |
+
}
|
| 505 |
+
.chat-input {
|
| 506 |
+
flex: 1;
|
| 507 |
+
background: var(--bg);
|
| 508 |
+
border: 1px solid var(--border);
|
| 509 |
+
border-radius: 8px;
|
| 510 |
+
padding: 8px 12px;
|
| 511 |
+
color: var(--text);
|
| 512 |
+
font-family: 'Syne', sans-serif;
|
| 513 |
+
font-size: 13px;
|
| 514 |
+
outline: none;
|
| 515 |
+
resize: none;
|
| 516 |
+
}
|
| 517 |
+
.chat-input:focus { border-color: var(--accent); }
|
| 518 |
+
|
| 519 |
+
/* ββ Quick prompt chips βββββββββββββββββββββββββββββββ */
|
| 520 |
+
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
| 521 |
+
.chip {
|
| 522 |
+
padding: 4px 10px;
|
| 523 |
+
border-radius: 20px;
|
| 524 |
+
font-size: 11px;
|
| 525 |
+
font-weight: 600;
|
| 526 |
+
cursor: pointer;
|
| 527 |
+
border: 1px solid var(--border);
|
| 528 |
+
color: var(--muted);
|
| 529 |
+
background: transparent;
|
| 530 |
+
transition: all 0.15s;
|
| 531 |
+
font-family: 'Syne', sans-serif;
|
| 532 |
+
}
|
| 533 |
+
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
| 534 |
+
|
| 535 |
+
/* ββ Tool call accordion ββββββββββββββββββββββββββββββ */
|
| 536 |
+
.tool-call {
|
| 537 |
+
margin-top: 6px;
|
| 538 |
+
background: var(--bg);
|
| 539 |
+
border: 1px solid var(--border);
|
| 540 |
+
border-radius: 6px;
|
| 541 |
+
overflow: hidden;
|
| 542 |
+
font-size: 11px;
|
| 543 |
+
}
|
| 544 |
+
.tool-call-hdr {
|
| 545 |
+
padding: 5px 10px;
|
| 546 |
+
background: var(--surface3);
|
| 547 |
+
color: var(--muted);
|
| 548 |
+
font-family: 'JetBrains Mono', monospace;
|
| 549 |
+
cursor: pointer;
|
| 550 |
+
display: flex;
|
| 551 |
+
align-items: center;
|
| 552 |
+
gap: 6px;
|
| 553 |
+
}
|
| 554 |
+
.tool-call-body {
|
| 555 |
+
padding: 8px 10px;
|
| 556 |
+
font-family: 'JetBrains Mono', monospace;
|
| 557 |
+
font-size: 11px;
|
| 558 |
+
line-height: 1.6;
|
| 559 |
+
color: var(--text);
|
| 560 |
+
max-height: 200px;
|
| 561 |
+
overflow-y: auto;
|
| 562 |
+
white-space: pre-wrap;
|
| 563 |
+
}
|
| 564 |
+
.t-success { color: var(--accent2); }
|
| 565 |
+
.t-warn { color: var(--accent4); }
|
| 566 |
+
.t-error { color: var(--accent3); }
|
| 567 |
+
.t-info { color: var(--muted); }
|
| 568 |
+
.t-data { color: var(--accent); }
|
| 569 |
+
.t-prompt { color: var(--accent2); }
|
| 570 |
+
|
| 571 |
+
/* ββ Streamlit widget overrides βββββββββββββββββββββββ */
|
| 572 |
+
div[data-testid="stTextInput"] label,
|
| 573 |
+
div[data-testid="stTextArea"] label,
|
| 574 |
+
div[data-testid="stSelectbox"] label,
|
| 575 |
+
div[data-testid="stCheckbox"] label,
|
| 576 |
+
.stRadio label { color: var(--muted) !important; font-size: 11px !important; font-family: 'Syne', sans-serif !important; }
|
| 577 |
+
|
| 578 |
+
div[data-testid="stTextInput"] input,
|
| 579 |
+
div[data-testid="stNumberInput"] input {
|
| 580 |
+
background: var(--bg) !important;
|
| 581 |
+
border: 1px solid var(--border) !important;
|
| 582 |
+
border-radius: 6px !important;
|
| 583 |
+
color: var(--text) !important;
|
| 584 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 585 |
+
font-size: 13px !important;
|
| 586 |
+
}
|
| 587 |
+
div[data-testid="stTextInput"] input:focus,
|
| 588 |
+
div[data-testid="stNumberInput"] input:focus {
|
| 589 |
+
border-color: var(--accent) !important;
|
| 590 |
+
box-shadow: none !important;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
div[data-testid="stTextArea"] textarea {
|
| 594 |
+
background: var(--bg) !important;
|
| 595 |
+
border: 1px solid var(--border) !important;
|
| 596 |
+
color: var(--text) !important;
|
| 597 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 598 |
+
font-size: 13px !important;
|
| 599 |
+
line-height: 22px !important;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.stButton > button {
|
| 603 |
+
background: var(--surface2) !important;
|
| 604 |
+
border: 1px solid var(--border) !important;
|
| 605 |
+
color: var(--text) !important;
|
| 606 |
+
font-family: 'Syne', sans-serif !important;
|
| 607 |
+
font-weight: 600 !important;
|
| 608 |
+
border-radius: 6px !important;
|
| 609 |
+
transition: all 0.15s !important;
|
| 610 |
+
}
|
| 611 |
+
.stButton > button:hover {
|
| 612 |
+
border-color: var(--accent) !important;
|
| 613 |
+
color: var(--accent) !important;
|
| 614 |
+
}
|
| 615 |
+
.stButton > button[kind="primary"] {
|
| 616 |
+
background: var(--accent2) !important;
|
| 617 |
+
border-color: var(--accent2) !important;
|
| 618 |
+
color: #000 !important;
|
| 619 |
+
}
|
| 620 |
+
.stButton > button[kind="primary"]:hover {
|
| 621 |
+
background: #56d364 !important;
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
div[data-testid="stSelectbox"] > div > div {
|
| 625 |
+
background: var(--bg) !important;
|
| 626 |
+
border: 1px solid var(--border) !important;
|
| 627 |
+
color: var(--text) !important;
|
| 628 |
+
font-family: 'JetBrains Mono', monospace !important;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.stExpander {
|
| 632 |
+
background: var(--surface2) !important;
|
| 633 |
+
border: 1px solid var(--border) !important;
|
| 634 |
+
border-radius: 6px !important;
|
| 635 |
+
}
|
| 636 |
+
.stExpander header { color: var(--text) !important; font-family: 'JetBrains Mono', monospace !important; font-size: 13px !important; }
|
| 637 |
+
.stExpander div[data-testid="stExpanderDetails"] { background: var(--bg) !important; }
|
| 638 |
+
|
| 639 |
+
.stAlert { border-radius: 6px !important; font-family: 'Syne', sans-serif !important; }
|
| 640 |
+
|
| 641 |
+
div[data-testid="stChatMessage"] {
|
| 642 |
+
background: var(--surface2) !important;
|
| 643 |
+
border: 1px solid var(--border) !important;
|
| 644 |
+
border-radius: 8px !important;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
div[data-testid="stChatInputContainer"] textarea {
|
| 648 |
+
background: var(--bg) !important;
|
| 649 |
+
border: 1px solid var(--border) !important;
|
| 650 |
+
color: var(--text) !important;
|
| 651 |
+
font-family: 'Syne', sans-serif !important;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
[data-testid="column"] { padding: 0 !important; }
|
| 655 |
+
|
| 656 |
+
/* ββ Dataframe ββββββββββββββββββββββββββββββββββββββββ */
|
| 657 |
+
.stDataFrame { border: 1px solid var(--border) !important; border-radius: 6px !important; }
|
| 658 |
+
|
| 659 |
+
/* ββ Code block βββββββββββββββββββββββββββββββββββββββ */
|
| 660 |
+
.stCode { background: var(--bg) !important; border: 1px solid var(--border) !important; }
|
| 661 |
+
code { font-family: 'JetBrains Mono', monospace !important; color: var(--text) !important; }
|
| 662 |
+
|
| 663 |
+
/* ββ Plotly chart βββββββββββββββββββββββββββββββββββββ */
|
| 664 |
+
.js-plotly-plot { border: 1px solid var(--border) !important; border-radius: 6px !important; }
|
| 665 |
+
|
| 666 |
+
/* ββ Scroll bars global βββββββββββββββββββββββββββββββ */
|
| 667 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 668 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 669 |
+
::-webkit-scrollbar-thumb { background: var(--surface3); border-radius: 2px; }
|
| 670 |
+
|
| 671 |
+
/* ββ Remove streamlit container gaps ββββββββββββββββββ */
|
| 672 |
+
.element-container { margin: 0 !important; }
|
| 673 |
+
div[data-testid="stVerticalBlock"] > div { gap: 0 !important; }
|
| 674 |
+
"""
|
| 675 |
+
_b64css = _b64.b64encode(_CSS.encode()).decode()
|
| 676 |
+
st.markdown(
|
| 677 |
+
'<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700'
|
| 678 |
+
'&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">'
|
| 679 |
+
f'<link rel="stylesheet" href="data:text/css;base64,{_b64css}">',
|
| 680 |
+
unsafe_allow_html=True,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 684 |
+
# SESSION STATE
|
| 685 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 686 |
+
|
| 687 |
+
def _init():
|
| 688 |
+
defaults = {
|
| 689 |
+
"parm_path": None,
|
| 690 |
+
"traj_paths": [],
|
| 691 |
+
"work_dir": tempfile.mkdtemp(prefix="cpptraj_"),
|
| 692 |
+
"chat_history": [],
|
| 693 |
+
"script": (
|
| 694 |
+
"# cpptraj analysis script\n"
|
| 695 |
+
"parm topology.prmtop\n"
|
| 696 |
+
"trajin trajectory.nc\n\n"
|
| 697 |
+
"autoimage\n"
|
| 698 |
+
"center !:WAT origin\n\n"
|
| 699 |
+
"rmsd backbone @CA,C,N,O first out rmsd.dat\n\n"
|
| 700 |
+
"go\n"
|
| 701 |
+
),
|
| 702 |
+
"last_result": None,
|
| 703 |
+
"api_key": os.environ.get("ANTHROPIC_API_KEY", ""),
|
| 704 |
+
"runner": None,
|
| 705 |
+
"agent": None,
|
| 706 |
+
"center_mode": "editor", # editor | agent | builder | results
|
| 707 |
+
"sel_cmd": None, # selected command key in left panel
|
| 708 |
+
"cmd_filter": "all",
|
| 709 |
+
"doc_search": "",
|
| 710 |
+
}
|
| 711 |
+
for k, v in defaults.items():
|
| 712 |
+
if k not in st.session_state:
|
| 713 |
+
st.session_state[k] = v
|
| 714 |
+
|
| 715 |
+
_init()
|
| 716 |
+
|
| 717 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 718 |
+
# RESOURCE HELPERS
|
| 719 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 720 |
+
|
| 721 |
+
@st.cache_resource
|
| 722 |
+
def get_kb() -> CPPTrajKnowledgeBase:
|
| 723 |
+
return CPPTrajKnowledgeBase()
|
| 724 |
+
|
| 725 |
+
def get_runner() -> CPPTrajRunner:
|
| 726 |
+
if st.session_state.runner is None:
|
| 727 |
+
st.session_state.runner = CPPTrajRunner(work_dir=st.session_state.work_dir)
|
| 728 |
+
return st.session_state.runner
|
| 729 |
+
|
| 730 |
+
def get_agent() -> TrajectoryAgent:
|
| 731 |
+
if st.session_state.agent is None:
|
| 732 |
+
st.session_state.agent = TrajectoryAgent(
|
| 733 |
+
runner=get_runner(), kb=get_kb(),
|
| 734 |
+
api_key=st.session_state.api_key,
|
| 735 |
+
)
|
| 736 |
+
return st.session_state.agent
|
| 737 |
+
|
| 738 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 739 |
+
# HELPER: PLOT
|
| 740 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 741 |
+
|
| 742 |
+
def _col_names(fname: str, ncols: int) -> list:
|
| 743 |
+
maps = {
|
| 744 |
+
"rmsd": ["Frame"] + ["RMSD_Γ
"] * (ncols - 1),
|
| 745 |
+
"rmsf": ["Residue"] + ["RMSF_Γ
"] * (ncols - 1),
|
| 746 |
+
"rg": ["Frame", "Rg_Γ
", "Rg_max_Γ
"],
|
| 747 |
+
"radgyr": ["Frame", "Rg_Γ
", "Rg_max_Γ
"],
|
| 748 |
+
"hbond": ["Frame", "N_HBonds"],
|
| 749 |
+
"distance": ["Frame", "Dist_Γ
"],
|
| 750 |
+
"angle": ["Frame", "Angle_Β°"],
|
| 751 |
+
"dihedral": ["Frame", "Dihedral_Β°"],
|
| 752 |
+
"msd": ["Time_ps", "MSD_Γ
Β²", "Dx", "Dy", "Dz"],
|
| 753 |
+
"density": ["Pos_Γ
", "Density"],
|
| 754 |
+
"surf": ["Frame", "SASA_Γ
Β²"],
|
| 755 |
+
"sasa": ["Frame", "SASA_Γ
Β²"],
|
| 756 |
+
"cluster": ["Frame", "Cluster_ID"],
|
| 757 |
+
"pca_proj": ["Frame"] + [f"PC{i}" for i in range(1, ncols)],
|
| 758 |
+
"watershell": ["Frame", "Shell1", "Shell2"],
|
| 759 |
+
"nativecontacts": ["Frame", "Q_native"],
|
| 760 |
+
}
|
| 761 |
+
for k, cols in maps.items():
|
| 762 |
+
if k in fname:
|
| 763 |
+
r = list(cols[:ncols])
|
| 764 |
+
while len(r) < ncols:
|
| 765 |
+
r.append(f"col{len(r)}")
|
| 766 |
+
return r
|
| 767 |
+
return [f"col{i}" for i in range(ncols)]
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def plot_file(fp: Path, content: str, key_pfx: str = ""):
|
| 771 |
+
from io import StringIO
|
| 772 |
+
rows = [l for l in content.splitlines()
|
| 773 |
+
if l.strip() and not l.strip().startswith(("#","@","$","%"))]
|
| 774 |
+
if not rows:
|
| 775 |
+
st.info("File is empty or comment-only.")
|
| 776 |
+
return
|
| 777 |
+
try:
|
| 778 |
+
df = pd.read_csv(StringIO("\n".join(rows)), sep=r"\s+",
|
| 779 |
+
header=None, on_bad_lines="skip")
|
| 780 |
+
if df.empty or df.shape[1] < 2:
|
| 781 |
+
st.info("Need β₯2 numeric columns to plot.")
|
| 782 |
+
return
|
| 783 |
+
names = _col_names(fp.stem.lower(), df.shape[1])
|
| 784 |
+
df.columns = names[:df.shape[1]]
|
| 785 |
+
df = df.apply(pd.to_numeric, errors="coerce").dropna()
|
| 786 |
+
if df.empty:
|
| 787 |
+
return
|
| 788 |
+
x_col = df.columns[0]
|
| 789 |
+
y_cols = list(df.columns[1:])
|
| 790 |
+
ptype = st.radio("Plot type", ["Line","Scatter","Histogram","Box"],
|
| 791 |
+
horizontal=True, key=f"pt_{key_pfx}_{fp.name}")
|
| 792 |
+
sel_y = st.multiselect("Y-axis", y_cols,
|
| 793 |
+
default=y_cols[:min(3,len(y_cols))],
|
| 794 |
+
key=f"py_{key_pfx}_{fp.name}")
|
| 795 |
+
if not sel_y:
|
| 796 |
+
return
|
| 797 |
+
if ptype == "Line":
|
| 798 |
+
fig = px.line(df, x=x_col, y=sel_y, title=fp.stem,
|
| 799 |
+
template="plotly_dark")
|
| 800 |
+
elif ptype == "Scatter":
|
| 801 |
+
cols = sel_y if len(sel_y) >= 2 else [x_col] + sel_y
|
| 802 |
+
fig = px.scatter(df, x=cols[0], y=cols[1], title=fp.stem,
|
| 803 |
+
template="plotly_dark", opacity=0.6)
|
| 804 |
+
elif ptype == "Histogram":
|
| 805 |
+
fig = px.histogram(df, x=sel_y[0], nbins=60, title=fp.stem,
|
| 806 |
+
template="plotly_dark")
|
| 807 |
+
else:
|
| 808 |
+
fig = px.box(df, y=sel_y, title=fp.stem, template="plotly_dark")
|
| 809 |
+
fig.update_layout(
|
| 810 |
+
height=360,
|
| 811 |
+
paper_bgcolor="#0d1117",
|
| 812 |
+
plot_bgcolor="#0d1117",
|
| 813 |
+
font_color="#e6edf3",
|
| 814 |
+
xaxis=dict(gridcolor="#30363d"),
|
| 815 |
+
yaxis=dict(gridcolor="#30363d"),
|
| 816 |
+
)
|
| 817 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 818 |
+
with st.expander("Statistics"):
|
| 819 |
+
st.dataframe(df[sel_y].describe(), use_container_width=True)
|
| 820 |
+
except Exception as e:
|
| 821 |
+
st.caption(f"Could not auto-plot: {e}")
|
| 822 |
+
|
| 823 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 824 |
+
# HEADER
|
| 825 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 826 |
+
|
| 827 |
+
runner = get_runner()
|
| 828 |
+
cpptraj_ok = runner.is_cpptraj_available()
|
| 829 |
+
parm_ok = st.session_state.parm_path is not None
|
| 830 |
+
traj_ok = len(st.session_state.traj_paths) > 0
|
| 831 |
+
|
| 832 |
+
st.markdown(f"""
|
| 833 |
+
<div class="ide-header">
|
| 834 |
+
<div class="ide-logo">
|
| 835 |
+
<span class="ide-logo-icon">⬑</span>
|
| 836 |
+
cpptraj <b>IDE</b>
|
| 837 |
+
</div>
|
| 838 |
+
<div class="ide-status-row">
|
| 839 |
+
<span><span class="ide-status-dot" style="background:{'var(--accent2)' if cpptraj_ok else 'var(--accent3)'}"></span>
|
| 840 |
+
cpptraj {'found' if cpptraj_ok else 'not found'}</span>
|
| 841 |
+
<span><span class="ide-status-dot" style="background:{'var(--accent2)' if parm_ok else 'var(--dim)'}"></span>
|
| 842 |
+
topology {'loaded' if parm_ok else 'none'}</span>
|
| 843 |
+
<span><span class="ide-status-dot" style="background:{'var(--accent2)' if traj_ok else 'var(--dim)'}"></span>
|
| 844 |
+
trajectory {'loaded' if traj_ok else 'none'}</span>
|
| 845 |
+
</div>
|
| 846 |
+
</div>
|
| 847 |
+
""", unsafe_allow_html=True)
|
| 848 |
+
|
| 849 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 850 |
+
# THREE-PANEL LAYOUT
|
| 851 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 852 |
+
|
| 853 |
+
left, center, right = st.columns([1.1, 2.4, 1.2], gap="small")
|
| 854 |
+
|
| 855 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 856 |
+
# LEFT PANEL β Command Reference
|
| 857 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 858 |
+
|
| 859 |
+
with left:
|
| 860 |
+
kb = get_kb()
|
| 861 |
+
|
| 862 |
+
st.markdown("""
|
| 863 |
+
<div class="panel-hdr">
|
| 864 |
+
Command Reference
|
| 865 |
+
</div>
|
| 866 |
+
""", unsafe_allow_html=True)
|
| 867 |
+
|
| 868 |
+
search = st.text_input(
|
| 869 |
+
"search", placeholder="Search commandsβ¦",
|
| 870 |
+
label_visibility="collapsed",
|
| 871 |
+
key="left_search",
|
| 872 |
+
)
|
| 873 |
+
|
| 874 |
+
# Filter tabs
|
| 875 |
+
cat_options = ["all"] + kb.get_categories()
|
| 876 |
+
cat_cols = st.columns(len(cat_options))
|
| 877 |
+
for i, cat in enumerate(cat_options):
|
| 878 |
+
with cat_cols[i]:
|
| 879 |
+
label = cat.replace("Analysis","Analysis").replace("Manipulation","Action")[:6]
|
| 880 |
+
active = st.session_state.cmd_filter == cat
|
| 881 |
+
if st.button(
|
| 882 |
+
label,
|
| 883 |
+
key=f"ftab_{cat}",
|
| 884 |
+
type="primary" if active else "secondary",
|
| 885 |
+
use_container_width=True,
|
| 886 |
+
):
|
| 887 |
+
st.session_state.cmd_filter = cat
|
| 888 |
+
st.rerun()
|
| 889 |
+
|
| 890 |
+
# Build filtered list
|
| 891 |
+
if search:
|
| 892 |
+
results = kb.retrieve(search, top_k=15)
|
| 893 |
+
filtered = {r["key"]: r["doc"] for r in results}
|
| 894 |
+
elif st.session_state.cmd_filter != "all":
|
| 895 |
+
filtered = kb.get_by_category(st.session_state.cmd_filter)
|
| 896 |
+
else:
|
| 897 |
+
filtered = kb.get_all_commands()
|
| 898 |
+
|
| 899 |
+
cat_badge = {
|
| 900 |
+
"Analysis": "b-analysis",
|
| 901 |
+
"Setup": "b-input",
|
| 902 |
+
"Output": "b-output",
|
| 903 |
+
"Manipulation": "b-action",
|
| 904 |
+
"Mask Reference": "b-mask",
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
with st.container(height=520, border=False):
|
| 908 |
+
for cmd_key, doc in filtered.items():
|
| 909 |
+
badge_cls = cat_badge.get(doc["category"], "b-action")
|
| 910 |
+
is_sel = st.session_state.sel_cmd == cmd_key
|
| 911 |
+
bg = "background:var(--surface2);border-left:2px solid var(--accent);" if is_sel else ""
|
| 912 |
+
st.markdown(f"""
|
| 913 |
+
<div class="cmd-item {'sel' if is_sel else ''}" style="{bg}">
|
| 914 |
+
<div class="cmd-name">
|
| 915 |
+
{cmd_key}
|
| 916 |
+
<span class="badge {badge_cls}">{doc['category'][:5]}</span>
|
| 917 |
+
</div>
|
| 918 |
+
<div class="cmd-sub">{doc['description'][:60]}β¦</div>
|
| 919 |
+
</div>
|
| 920 |
+
""", unsafe_allow_html=True)
|
| 921 |
+
if st.button(f"Select {cmd_key}", key=f"sel_{cmd_key}",
|
| 922 |
+
use_container_width=True):
|
| 923 |
+
st.session_state.sel_cmd = cmd_key
|
| 924 |
+
st.rerun()
|
| 925 |
+
|
| 926 |
+
# ββ Script Templates ββ
|
| 927 |
+
st.markdown('<div class="panel-hdr" style="margin-top:4px">Script Templates</div>',
|
| 928 |
+
unsafe_allow_html=True)
|
| 929 |
+
for tk, tmpl in SCRIPT_TEMPLATES.items():
|
| 930 |
+
if st.button(tmpl["title"], key=f"tmpl_{tk}", use_container_width=True):
|
| 931 |
+
st.session_state.script = tmpl["script"]
|
| 932 |
+
st.session_state.center_mode = "editor"
|
| 933 |
+
st.rerun()
|
| 934 |
+
|
| 935 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 936 |
+
# CENTER PANEL β Editor / AI Agent / Builder / Results
|
| 937 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 938 |
+
|
| 939 |
+
with center:
|
| 940 |
+
# ββ Mode bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 941 |
+
st.markdown('<div class="panel-hdr">Workspace</div>', unsafe_allow_html=True)
|
| 942 |
+
|
| 943 |
+
mode_cols = st.columns(4)
|
| 944 |
+
modes = [("editor", "β¨ Editor"), ("agent", "β¦ AI Agent"),
|
| 945 |
+
("builder", "β Builder"), ("results", "π Results")]
|
| 946 |
+
for i, (mkey, mlabel) in enumerate(modes):
|
| 947 |
+
with mode_cols[i]:
|
| 948 |
+
active = st.session_state.center_mode == mkey
|
| 949 |
+
if st.button(mlabel, key=f"mode_{mkey}", type="primary" if active else "secondary",
|
| 950 |
+
use_container_width=True):
|
| 951 |
+
st.session_state.center_mode = mkey
|
| 952 |
+
st.rerun()
|
| 953 |
+
|
| 954 |
+
# ββ EDITOR mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 955 |
+
if st.session_state.center_mode == "editor":
|
| 956 |
+
st.markdown("""
|
| 957 |
+
<div class="editor-bar">
|
| 958 |
+
<div class="file-tab">
|
| 959 |
+
<span class="file-tab-dot"></span>
|
| 960 |
+
analysis.cpptraj
|
| 961 |
+
</div>
|
| 962 |
+
</div>
|
| 963 |
+
""", unsafe_allow_html=True)
|
| 964 |
+
|
| 965 |
+
script_val = st.text_area(
|
| 966 |
+
"script_editor",
|
| 967 |
+
value=st.session_state.script,
|
| 968 |
+
height=400,
|
| 969 |
+
key="main_script_area",
|
| 970 |
+
label_visibility="collapsed",
|
| 971 |
+
)
|
| 972 |
+
if script_val is not None:
|
| 973 |
+
st.session_state.script = script_val
|
| 974 |
+
|
| 975 |
+
# Run bar
|
| 976 |
+
rb1, rb2, rb3, rb4 = st.columns([1, 1, 1, 2])
|
| 977 |
+
with rb1:
|
| 978 |
+
run_btn = st.button("βΆ Run", type="primary", use_container_width=True, key="run_editor")
|
| 979 |
+
with rb2:
|
| 980 |
+
if st.button("Clear", use_container_width=True, key="clear_editor"):
|
| 981 |
+
st.session_state.script = "parm topology.prmtop\ntrajin trajectory.nc\n\nautoimage\n\n\ngo\n"
|
| 982 |
+
st.rerun()
|
| 983 |
+
with rb3:
|
| 984 |
+
st.download_button("β¬ Save", data=st.session_state.script,
|
| 985 |
+
file_name="analysis.cpptraj", mime="text/plain",
|
| 986 |
+
use_container_width=True, key="dl_script")
|
| 987 |
+
with rb4:
|
| 988 |
+
lines = st.session_state.script.count("\n") + 1
|
| 989 |
+
st.markdown(f"""
|
| 990 |
+
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;
|
| 991 |
+
color:var(--muted);padding:8px 4px;text-align:right">
|
| 992 |
+
{lines} lines | CPPTRAJ SCRIPT
|
| 993 |
+
</div>""", unsafe_allow_html=True)
|
| 994 |
+
|
| 995 |
+
if run_btn:
|
| 996 |
+
if not runner.is_cpptraj_available():
|
| 997 |
+
st.error("cpptraj not found on PATH. Install it or set CPPTRAJ_PATH.")
|
| 998 |
+
else:
|
| 999 |
+
script_to_run = st.session_state.script
|
| 1000 |
+
if st.session_state.parm_path or st.session_state.traj_paths:
|
| 1001 |
+
script_to_run = runner.inject_paths_into_script(
|
| 1002 |
+
script_to_run,
|
| 1003 |
+
Path(st.session_state.parm_path) if st.session_state.parm_path else None,
|
| 1004 |
+
[Path(p) for p in st.session_state.traj_paths],
|
| 1005 |
+
)
|
| 1006 |
+
with st.spinner("Running cpptrajβ¦"):
|
| 1007 |
+
result = runner.run_script(script_to_run)
|
| 1008 |
+
st.session_state.last_result = result
|
| 1009 |
+
|
| 1010 |
+
# Terminal output
|
| 1011 |
+
result = st.session_state.last_result
|
| 1012 |
+
if result:
|
| 1013 |
+
status_color = "var(--accent2)" if result["success"] else "var(--accent3)"
|
| 1014 |
+
status_text = "DONE" if result["success"] else "ERROR"
|
| 1015 |
+
st.markdown(f"""
|
| 1016 |
+
<div class="t-hdr">
|
| 1017 |
+
Output Terminal
|
| 1018 |
+
<span style="color:{status_color}">β {status_text}
|
| 1019 |
+
Β· {result['elapsed']:.1f}s</span>
|
| 1020 |
+
</div>
|
| 1021 |
+
""", unsafe_allow_html=True)
|
| 1022 |
+
|
| 1023 |
+
with st.container(height=180, border=False):
|
| 1024 |
+
if result["stdout"]:
|
| 1025 |
+
# Colorize terminal output
|
| 1026 |
+
lines_out = []
|
| 1027 |
+
for l in result["stdout"].splitlines()[:120]:
|
| 1028 |
+
if "Error" in l or "ERROR" in l:
|
| 1029 |
+
cls = "t-error"
|
| 1030 |
+
elif "Warning" in l or "WARNING" in l:
|
| 1031 |
+
cls = "t-warn"
|
| 1032 |
+
elif l.strip().startswith("CPPTRAJ") or "frames" in l.lower():
|
| 1033 |
+
cls = "t-data"
|
| 1034 |
+
elif l.strip().startswith("#"):
|
| 1035 |
+
cls = "t-info"
|
| 1036 |
+
else:
|
| 1037 |
+
cls = "t-success"
|
| 1038 |
+
lines_out.append(f'<span class="t-line {cls}">{l}</span>')
|
| 1039 |
+
st.markdown(
|
| 1040 |
+
f'<div class="terminal" style="height:160px;padding:10px 14px">'
|
| 1041 |
+
+ "\n".join(lines_out)
|
| 1042 |
+
+ "</div>",
|
| 1043 |
+
unsafe_allow_html=True,
|
| 1044 |
+
)
|
| 1045 |
+
|
| 1046 |
+
if result["stderr"]:
|
| 1047 |
+
with st.expander("stderr", expanded=not result["success"]):
|
| 1048 |
+
st.code(result["stderr"][:2000], language="text")
|
| 1049 |
+
|
| 1050 |
+
out_files = result.get("output_files", [])
|
| 1051 |
+
if out_files:
|
| 1052 |
+
st.markdown(
|
| 1053 |
+
f'<div style="font-size:11px;color:var(--accent);font-family:JetBrains Mono,monospace;padding:4px 0">'
|
| 1054 |
+
f'Output files: {", ".join(f.name for f in out_files)}'
|
| 1055 |
+
f'</div>',
|
| 1056 |
+
unsafe_allow_html=True,
|
| 1057 |
+
)
|
| 1058 |
+
else:
|
| 1059 |
+
st.markdown("""
|
| 1060 |
+
<div class="terminal" style="height:80px">
|
| 1061 |
+
<div class="empty-terminal" style="color:var(--muted);font-size:12px;
|
| 1062 |
+
padding:18px;text-align:center;font-family:JetBrains Mono,monospace">
|
| 1063 |
+
cpptraj IDE ready β write a script and click βΆ Run
|
| 1064 |
+
</div>
|
| 1065 |
+
</div>
|
| 1066 |
+
""", unsafe_allow_html=True)
|
| 1067 |
+
|
| 1068 |
+
# ββ AI AGENT mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1069 |
+
elif st.session_state.center_mode == "agent":
|
| 1070 |
+
|
| 1071 |
+
if not st.session_state.api_key:
|
| 1072 |
+
st.markdown("""
|
| 1073 |
+
<div style="padding:16px;background:rgba(247,129,102,.1);
|
| 1074 |
+
border:1px solid var(--accent3);border-radius:8px;
|
| 1075 |
+
font-size:13px;color:var(--accent3);margin:10px 0">
|
| 1076 |
+
β Anthropic API key required. Enter it in the right panel.
|
| 1077 |
+
</div>""", unsafe_allow_html=True)
|
| 1078 |
+
|
| 1079 |
+
# Quick prompt chips
|
| 1080 |
+
quick = [
|
| 1081 |
+
"RMSD of backbone vs first frame",
|
| 1082 |
+
"Full analysis: RMSD + RMSF + Rg + H-bonds",
|
| 1083 |
+
"Cluster trajectory into 5 structures",
|
| 1084 |
+
"Hydrogen bonds proteinβligand",
|
| 1085 |
+
"SASA over time",
|
| 1086 |
+
"PCA β first 3 modes",
|
| 1087 |
+
]
|
| 1088 |
+
chips_html = "".join(
|
| 1089 |
+
f'<button class="chip" onclick="void(0)" id="chip_{i}">{q}</button>'
|
| 1090 |
+
for i, q in enumerate(quick)
|
| 1091 |
+
)
|
| 1092 |
+
st.markdown(f'<div class="chip-row">{chips_html}</div>', unsafe_allow_html=True)
|
| 1093 |
+
|
| 1094 |
+
qcols = st.columns(3)
|
| 1095 |
+
for i, qp in enumerate(quick):
|
| 1096 |
+
with qcols[i % 3]:
|
| 1097 |
+
if st.button(qp, key=f"qp_{i}", use_container_width=True):
|
| 1098 |
+
st.session_state["_pending"] = qp
|
| 1099 |
+
st.rerun()
|
| 1100 |
+
|
| 1101 |
+
pending = st.session_state.get("_pending")
|
| 1102 |
+
if pending:
|
| 1103 |
+
del st.session_state["_pending"]
|
| 1104 |
+
|
| 1105 |
+
# Chat history
|
| 1106 |
+
with st.container(height=390, border=False):
|
| 1107 |
+
if not st.session_state.chat_history:
|
| 1108 |
+
st.markdown("""
|
| 1109 |
+
<div style="text-align:center;padding:40px 20px;color:var(--muted)">
|
| 1110 |
+
<div style="font-size:36px;margin-bottom:12px">β¦</div>
|
| 1111 |
+
<div style="font-size:13px">Describe your analysis in plain English.<br>
|
| 1112 |
+
The agent will write and run the cpptraj script for you.</div>
|
| 1113 |
+
</div>""", unsafe_allow_html=True)
|
| 1114 |
+
|
| 1115 |
+
for msg in st.session_state.chat_history:
|
| 1116 |
+
role = msg["role"]
|
| 1117 |
+
content = msg["content"]
|
| 1118 |
+
if role == "user" and "## User Request\n" in content:
|
| 1119 |
+
content = content.split("## User Request\n", 1)[1]
|
| 1120 |
+
|
| 1121 |
+
with st.chat_message(role):
|
| 1122 |
+
st.markdown(content)
|
| 1123 |
+
for tc in msg.get("tool_calls", []):
|
| 1124 |
+
with st.expander(f"β `{tc['tool']}`", expanded=False):
|
| 1125 |
+
if "script" in tc["input"]:
|
| 1126 |
+
st.code(tc["input"]["script"], language="bash")
|
| 1127 |
+
else:
|
| 1128 |
+
st.json(tc["input"])
|
| 1129 |
+
st.caption("Result:")
|
| 1130 |
+
st.code(tc["result"][:2000], language="text")
|
| 1131 |
+
|
| 1132 |
+
# Clear
|
| 1133 |
+
if st.button("Clear conversation", key="clear_chat"):
|
| 1134 |
+
st.session_state.chat_history = []
|
| 1135 |
+
if st.session_state.agent:
|
| 1136 |
+
st.session_state.agent.reset_conversation()
|
| 1137 |
+
st.rerun()
|
| 1138 |
+
|
| 1139 |
+
# Chat input
|
| 1140 |
+
user_input = st.chat_input("Ask the AI agentβ¦", key="agent_chat_input")
|
| 1141 |
+
if pending:
|
| 1142 |
+
user_input = pending
|
| 1143 |
+
|
| 1144 |
+
if user_input and st.session_state.api_key:
|
| 1145 |
+
with st.chat_message("user"):
|
| 1146 |
+
st.markdown(user_input)
|
| 1147 |
+
st.session_state.chat_history.append({"role": "user", "content": user_input})
|
| 1148 |
+
|
| 1149 |
+
with st.chat_message("assistant"):
|
| 1150 |
+
with st.spinner("Thinkingβ¦"):
|
| 1151 |
+
try:
|
| 1152 |
+
agent = get_agent()
|
| 1153 |
+
resp, tool_log = agent.chat(user_input)
|
| 1154 |
+
except Exception as e:
|
| 1155 |
+
resp = f"Error: {e}"
|
| 1156 |
+
tool_log = []
|
| 1157 |
+
st.markdown(resp)
|
| 1158 |
+
for tc in tool_log:
|
| 1159 |
+
with st.expander(f"β `{tc['tool']}`", expanded=False):
|
| 1160 |
+
if "script" in tc["input"]:
|
| 1161 |
+
st.code(tc["input"]["script"], language="bash")
|
| 1162 |
+
else:
|
| 1163 |
+
st.json(tc["input"])
|
| 1164 |
+
st.caption("Result:")
|
| 1165 |
+
st.code(tc["result"][:2000], language="text")
|
| 1166 |
+
|
| 1167 |
+
st.session_state.chat_history.append({
|
| 1168 |
+
"role": "assistant", "content": resp, "tool_calls": tool_log
|
| 1169 |
+
})
|
| 1170 |
+
st.rerun()
|
| 1171 |
+
|
| 1172 |
+
# ββ BUILDER mode βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1173 |
+
elif st.session_state.center_mode == "builder":
|
| 1174 |
+
st.markdown('<div class="panel-hdr">Script Builder β GUI Configuration</div>',
|
| 1175 |
+
unsafe_allow_html=True)
|
| 1176 |
+
|
| 1177 |
+
parm_name = Path(st.session_state.parm_path).name if st.session_state.parm_path else "topology.prmtop"
|
| 1178 |
+
traj_name = Path(st.session_state.traj_paths[0]).name if st.session_state.traj_paths else "trajectory.nc"
|
| 1179 |
+
|
| 1180 |
+
with st.container(height=600, border=False):
|
| 1181 |
+
c1, c2 = st.columns(2)
|
| 1182 |
+
with c1:
|
| 1183 |
+
pf = st.text_input("Topology file", value=parm_name, key="b_pf")
|
| 1184 |
+
tf = st.text_input("Trajectory file", value=traj_name, key="b_tf")
|
| 1185 |
+
ai = st.checkbox("autoimage", value=True, key="b_ai")
|
| 1186 |
+
cen = st.checkbox("center !:WAT origin", value=True, key="b_cen")
|
| 1187 |
+
with c2:
|
| 1188 |
+
st.markdown('<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Atom mask quick-insert</div>', unsafe_allow_html=True)
|
| 1189 |
+
masks = ["@CA", "@CA,C,N,O", "!:WAT", ":1-100@CA", ":LIG<:5.0"]
|
| 1190 |
+
for m in masks:
|
| 1191 |
+
st.code(m, language="text")
|
| 1192 |
+
|
| 1193 |
+
st.divider()
|
| 1194 |
+
ra, rb = st.columns(2)
|
| 1195 |
+
with ra:
|
| 1196 |
+
do_rmsd = st.checkbox("RMSD", value=True, key="b_rmsd")
|
| 1197 |
+
rmsd_mask = st.text_input("RMSD mask", "@CA,C,N,O", key="b_rmask")
|
| 1198 |
+
rmsd_ref = st.selectbox("Ref", ["first","ref file"], key="b_rref")
|
| 1199 |
+
rmsd_out = st.text_input("Output", "rmsd.dat", key="b_rout")
|
| 1200 |
+
do_perres = st.checkbox("Per-residue RMSD", key="b_perres")
|
| 1201 |
+
|
| 1202 |
+
do_rmsf = st.checkbox("RMSF", key="b_rmsf")
|
| 1203 |
+
rmsf_mask= st.text_input("RMSF mask", "@CA", key="b_rmsfm")
|
| 1204 |
+
rmsf_out = st.text_input("Output", "rmsf.dat", key="b_rmsfo")
|
| 1205 |
+
|
| 1206 |
+
do_rg = st.checkbox("Radius of Gyration", key="b_rg")
|
| 1207 |
+
rg_mask= st.text_input("Rg mask", "!:WAT", key="b_rgm")
|
| 1208 |
+
rg_out = st.text_input("Output", "rg.dat", key="b_rgo")
|
| 1209 |
+
|
| 1210 |
+
with rb:
|
| 1211 |
+
do_hb = st.checkbox("Hydrogen Bonds", key="b_hb")
|
| 1212 |
+
hb_mask = st.text_input("H-bond mask", "!:WAT", key="b_hbm")
|
| 1213 |
+
hb_dist = st.slider("Dist cutoff Γ
", 2.5, 4.5, 3.5, 0.1, key="b_hbd")
|
| 1214 |
+
hb_ang = st.slider("Angle cutoff Β°", 100, 170, 135, 5, key="b_hba")
|
| 1215 |
+
hb_out = st.text_input("Output", "hbond.dat", key="b_hbo")
|
| 1216 |
+
|
| 1217 |
+
do_ss = st.checkbox("Secondary Structure", key="b_ss")
|
| 1218 |
+
ss_out= st.text_input("Output", "secstruct.dat", key="b_sso")
|
| 1219 |
+
|
| 1220 |
+
do_cl = st.checkbox("Clustering", key="b_cl")
|
| 1221 |
+
cl_mask= st.text_input("Cluster mask", "@CA", key="b_clm")
|
| 1222 |
+
cl_algo= st.selectbox("Algorithm", ["hieragglo","kmeans","dbscan"], key="b_cla")
|
| 1223 |
+
cl_eps = st.number_input("Epsilon Γ
/ K", value=2.0, step=0.5, key="b_cle")
|
| 1224 |
+
cl_k = st.number_input("K clusters", 5, min_value=2, key="b_clk")
|
| 1225 |
+
|
| 1226 |
+
do_dist = st.checkbox("Distance", key="b_dist")
|
| 1227 |
+
d_m1 = st.text_input("Mask 1", ":1@CA", key="b_dm1")
|
| 1228 |
+
d_m2 = st.text_input("Mask 2", ":100@CA", key="b_dm2")
|
| 1229 |
+
d_out= st.text_input("Output", "distance.dat", key="b_do")
|
| 1230 |
+
|
| 1231 |
+
# Generate script
|
| 1232 |
+
lines = [f"parm {pf}", f"trajin {tf}", ""]
|
| 1233 |
+
if ai: lines.append("autoimage")
|
| 1234 |
+
if cen: lines.append("center !:WAT origin")
|
| 1235 |
+
if ai or cen: lines.append("")
|
| 1236 |
+
if do_rmsd:
|
| 1237 |
+
rs = "first" if rmsd_ref == "first" else "ref native.pdb"
|
| 1238 |
+
pr = " perres perresout perres_rmsd.dat" if do_perres else ""
|
| 1239 |
+
lines += [f"rmsd backbone {rmsd_mask} {rs} out {rmsd_out}{pr}", ""]
|
| 1240 |
+
if do_rmsf:
|
| 1241 |
+
lines += [f"atomicfluct rmsf {rmsf_mask} byres out {rmsf_out}", ""]
|
| 1242 |
+
if do_rg:
|
| 1243 |
+
lines += [f"radgyr rg {rg_mask} mass out {rg_out}", ""]
|
| 1244 |
+
if do_hb:
|
| 1245 |
+
lines += [f"hbond hbonds {hb_mask} dist {hb_dist:.1f} angle {hb_ang} out {hb_out} avgout hbond_avg.dat", ""]
|
| 1246 |
+
if do_ss:
|
| 1247 |
+
lines += [f"secstruct ss out {ss_out} sumout secstruct_sum.dat", ""]
|
| 1248 |
+
if do_cl:
|
| 1249 |
+
if cl_algo == "hieragglo": algo = f"hieragglo epsilon {cl_eps}"
|
| 1250 |
+
elif cl_algo == "kmeans": algo = f"kmeans clusters {int(cl_k)}"
|
| 1251 |
+
else: algo = f"dbscan minpoints 5 epsilon {cl_eps}"
|
| 1252 |
+
lines += [f"cluster clusters {cl_mask} {algo} sieve 10 out cluster_assign.dat summary cluster_sum.dat repout cluster_rep repfmt pdb", ""]
|
| 1253 |
+
if do_dist:
|
| 1254 |
+
lines += [f"distance dist {d_m1} {d_m2} out {d_out}", ""]
|
| 1255 |
+
lines.append("go")
|
| 1256 |
+
built = "\n".join(lines)
|
| 1257 |
+
|
| 1258 |
+
st.markdown('<div class="panel-hdr">Generated Script</div>', unsafe_allow_html=True)
|
| 1259 |
+
st.code(built, language="bash")
|
| 1260 |
+
|
| 1261 |
+
bb1, bb2 = st.columns(2)
|
| 1262 |
+
with bb1:
|
| 1263 |
+
if st.button("Load into Editor", use_container_width=True):
|
| 1264 |
+
st.session_state.script = built
|
| 1265 |
+
st.session_state.center_mode = "editor"
|
| 1266 |
+
st.rerun()
|
| 1267 |
+
with bb2:
|
| 1268 |
+
if st.button("βΆ Run Now", type="primary", use_container_width=True):
|
| 1269 |
+
if runner.is_cpptraj_available():
|
| 1270 |
+
with st.spinner("Runningβ¦"):
|
| 1271 |
+
result = runner.run_script(built)
|
| 1272 |
+
st.session_state.last_result = result
|
| 1273 |
+
msg = f"β Done in {result['elapsed']:.1f}s" if result["success"] else "β Error"
|
| 1274 |
+
(st.success if result["success"] else st.error)(msg)
|
| 1275 |
+
else:
|
| 1276 |
+
st.error("cpptraj not found.")
|
| 1277 |
+
|
| 1278 |
+
# ββ RESULTS mode βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1279 |
+
elif st.session_state.center_mode == "results":
|
| 1280 |
+
st.markdown('<div class="panel-hdr">Results Viewer</div>', unsafe_allow_html=True)
|
| 1281 |
+
|
| 1282 |
+
out_files = runner.list_output_files()
|
| 1283 |
+
if st.button("β³ Refresh", key="res_refresh"):
|
| 1284 |
+
st.rerun()
|
| 1285 |
+
|
| 1286 |
+
if not out_files:
|
| 1287 |
+
st.markdown("""
|
| 1288 |
+
<div style="text-align:center;padding:60px 20px;color:var(--muted)">
|
| 1289 |
+
<div style="font-size:32px;margin-bottom:10px">π</div>
|
| 1290 |
+
<div style="font-size:13px">No output files yet.<br>Run an analysis to see results here.</div>
|
| 1291 |
+
</div>""", unsafe_allow_html=True)
|
| 1292 |
+
else:
|
| 1293 |
+
# File pills
|
| 1294 |
+
pills = "".join(
|
| 1295 |
+
f'<span class="fitem-type" style="margin:2px;padding:4px 10px;font-size:11px">{f.name}</span>'
|
| 1296 |
+
for f in out_files
|
| 1297 |
+
)
|
| 1298 |
+
st.markdown(f'<div style="padding:8px 0;display:flex;flex-wrap:wrap;gap:4px">{pills}</div>',
|
| 1299 |
+
unsafe_allow_html=True)
|
| 1300 |
+
|
| 1301 |
+
sel_f = st.selectbox(
|
| 1302 |
+
"Select file to view/plot",
|
| 1303 |
+
[f.name for f in out_files],
|
| 1304 |
+
key="res_sel",
|
| 1305 |
+
label_visibility="collapsed",
|
| 1306 |
+
)
|
| 1307 |
+
if sel_f:
|
| 1308 |
+
fp = runner.work_dir / sel_f
|
| 1309 |
+
content = fp.read_text(errors="replace")
|
| 1310 |
+
|
| 1311 |
+
t_raw, t_plot = st.tabs(["Raw Data", "Interactive Plot"])
|
| 1312 |
+
with t_raw:
|
| 1313 |
+
st.code(content[:4000], language="text")
|
| 1314 |
+
st.download_button("β¬ Download", data=content,
|
| 1315 |
+
file_name=sel_f, key="res_dl")
|
| 1316 |
+
with t_plot:
|
| 1317 |
+
plot_file(fp, content, key_pfx="results")
|
| 1318 |
+
|
| 1319 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1320 |
+
# RIGHT PANEL β Files + Command Detail
|
| 1321 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1322 |
+
|
| 1323 |
+
with right:
|
| 1324 |
+
# ββ API key ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1325 |
+
st.markdown('<div class="panel-hdr">API Key</div>', unsafe_allow_html=True)
|
| 1326 |
+
|
| 1327 |
+
api_in = st.text_input(
|
| 1328 |
+
"api_key", type="password",
|
| 1329 |
+
placeholder="sk-ant-api03-β¦",
|
| 1330 |
+
value=st.session_state.api_key,
|
| 1331 |
+
label_visibility="collapsed",
|
| 1332 |
+
key="api_key_input",
|
| 1333 |
+
)
|
| 1334 |
+
if api_in != st.session_state.api_key:
|
| 1335 |
+
st.session_state.api_key = api_in
|
| 1336 |
+
st.session_state.agent = None
|
| 1337 |
+
|
| 1338 |
+
# ββ File Upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1339 |
+
st.markdown('<div class="panel-hdr" style="margin-top:6px">Project Files</div>',
|
| 1340 |
+
unsafe_allow_html=True)
|
| 1341 |
+
|
| 1342 |
+
parm_up = st.file_uploader(
|
| 1343 |
+
"Topology (.prmtop .psf .gro)",
|
| 1344 |
+
type=["prmtop","parm7","psf","pdb","gro","mol2"],
|
| 1345 |
+
key="parm_up",
|
| 1346 |
+
label_visibility="visible",
|
| 1347 |
+
)
|
| 1348 |
+
if parm_up:
|
| 1349 |
+
saved = runner.save_uploaded_file(parm_up)
|
| 1350 |
+
st.session_state.parm_path = str(saved)
|
| 1351 |
+
get_agent().set_files(saved, [Path(p) for p in st.session_state.traj_paths])
|
| 1352 |
+
st.success(f"Saved: {saved.name}")
|
| 1353 |
+
|
| 1354 |
+
traj_up = st.file_uploader(
|
| 1355 |
+
"Trajectory (.nc .dcd .xtc .trr)",
|
| 1356 |
+
type=["nc","ncdf","dcd","xtc","trr","crd","mdcrd"],
|
| 1357 |
+
accept_multiple_files=True,
|
| 1358 |
+
key="traj_up",
|
| 1359 |
+
label_visibility="visible",
|
| 1360 |
+
)
|
| 1361 |
+
if traj_up:
|
| 1362 |
+
saved_trajs = []
|
| 1363 |
+
for f in traj_up:
|
| 1364 |
+
s = runner.save_uploaded_file(f)
|
| 1365 |
+
saved_trajs.append(str(s))
|
| 1366 |
+
st.session_state.traj_paths = saved_trajs
|
| 1367 |
+
pf = Path(st.session_state.parm_path) if st.session_state.parm_path else None
|
| 1368 |
+
get_agent().set_files(pf, [Path(p) for p in saved_trajs])
|
| 1369 |
+
st.success(f"{len(saved_trajs)} trajectory file(s) loaded")
|
| 1370 |
+
|
| 1371 |
+
# Show loaded files
|
| 1372 |
+
if st.session_state.parm_path or st.session_state.traj_paths:
|
| 1373 |
+
all_files = []
|
| 1374 |
+
if st.session_state.parm_path:
|
| 1375 |
+
p = Path(st.session_state.parm_path)
|
| 1376 |
+
size = p.stat().st_size / 1024
|
| 1377 |
+
all_files.append((p.name, p.suffix[1:].upper(), f"{size:.0f} KB", "π§¬"))
|
| 1378 |
+
for tp in st.session_state.traj_paths:
|
| 1379 |
+
p = Path(tp)
|
| 1380 |
+
size = p.stat().st_size / 1024
|
| 1381 |
+
all_files.append((p.name, p.suffix[1:].upper(), f"{size:.0f} KB", "ποΈ"))
|
| 1382 |
+
|
| 1383 |
+
fhtml = ""
|
| 1384 |
+
for name, ext, sz, icon in all_files:
|
| 1385 |
+
fhtml += f"""
|
| 1386 |
+
<div class="fitem">
|
| 1387 |
+
<span style="font-size:16px">{icon}</span>
|
| 1388 |
+
<div style="flex:1;overflow:hidden">
|
| 1389 |
+
<div class="fitem-name">{name}</div>
|
| 1390 |
+
<div class="fitem-meta">{ext} Β· {sz}</div>
|
| 1391 |
+
</div>
|
| 1392 |
+
</div>"""
|
| 1393 |
+
st.markdown(fhtml, unsafe_allow_html=True)
|
| 1394 |
+
|
| 1395 |
+
# ββ Command Detail βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1396 |
+
st.markdown('<div class="panel-hdr" style="margin-top:6px">Command Detail</div>',
|
| 1397 |
+
unsafe_allow_html=True)
|
| 1398 |
+
|
| 1399 |
+
sel_key = st.session_state.sel_cmd
|
| 1400 |
+
if not sel_key:
|
| 1401 |
+
st.markdown("""
|
| 1402 |
+
<div style="text-align:center;padding:30px 20px;color:var(--muted)">
|
| 1403 |
+
<div style="font-size:32px;margin-bottom:8px">⬑</div>
|
| 1404 |
+
<div style="font-size:12px">Click a command in the left panel<br>to see its full documentation</div>
|
| 1405 |
+
</div>""", unsafe_allow_html=True)
|
| 1406 |
+
else:
|
| 1407 |
+
doc = kb.get_command(sel_key)
|
| 1408 |
+
if doc:
|
| 1409 |
+
with st.container(height=480, border=False):
|
| 1410 |
+
st.markdown(f"""
|
| 1411 |
+
<div class="detail-box" style="height:100%">
|
| 1412 |
+
<div class="detail-cmd">{sel_key}</div>
|
| 1413 |
+
<div class="detail-cat">{doc['category']} command</div>
|
| 1414 |
+
<div class="detail-desc">{doc['description']}</div>
|
| 1415 |
+
|
| 1416 |
+
<div class="sect-title">Syntax</div>
|
| 1417 |
+
<div class="syntax-box">{doc.get('syntax','N/A')}</div>
|
| 1418 |
+
""", unsafe_allow_html=True)
|
| 1419 |
+
|
| 1420 |
+
if doc.get("parameters"):
|
| 1421 |
+
st.markdown('<div class="sect-title" style="margin-top:10px">Options</div>',
|
| 1422 |
+
unsafe_allow_html=True)
|
| 1423 |
+
for p in doc["parameters"]:
|
| 1424 |
+
req = "(required)" if p.get("req") else ""
|
| 1425 |
+
st.markdown(
|
| 1426 |
+
f'<div class="opt-row">'
|
| 1427 |
+
f'<div class="opt-key">{p["name"]}</div>'
|
| 1428 |
+
f'<div class="opt-val">{p["desc"]} '
|
| 1429 |
+
f'<span style="color:var(--accent4);font-size:9px">{req}</span></div>'
|
| 1430 |
+
f'</div>',
|
| 1431 |
+
unsafe_allow_html=True,
|
| 1432 |
+
)
|
| 1433 |
+
|
| 1434 |
+
if doc.get("examples"):
|
| 1435 |
+
st.markdown('<div class="sect-title" style="margin-top:10px">Examples</div>',
|
| 1436 |
+
unsafe_allow_html=True)
|
| 1437 |
+
for ex in doc["examples"][:2]:
|
| 1438 |
+
st.markdown(f'<div class="example-box">{ex}</div>',
|
| 1439 |
+
unsafe_allow_html=True)
|
| 1440 |
+
|
| 1441 |
+
if doc.get("notes"):
|
| 1442 |
+
st.markdown(
|
| 1443 |
+
f'<div style="margin-top:10px;font-size:11px;color:var(--muted);'
|
| 1444 |
+
f'line-height:1.5;padding:8px;background:rgba(88,166,255,.05);'
|
| 1445 |
+
f'border:1px solid rgba(88,166,255,.2);border-radius:6px">'
|
| 1446 |
+
f'π‘ {doc["notes"]}</div>',
|
| 1447 |
+
unsafe_allow_html=True,
|
| 1448 |
+
)
|
| 1449 |
+
|
| 1450 |
+
# Insert into editor button
|
| 1451 |
+
if doc.get("examples"):
|
| 1452 |
+
if st.button("β Insert example into Editor",
|
| 1453 |
+
key=f"ins_{sel_key}", use_container_width=True):
|
| 1454 |
+
ex = doc["examples"][0]
|
| 1455 |
+
st.session_state.script = (
|
| 1456 |
+
f"parm topology.prmtop\ntrajin trajectory.nc\n\nautoimage\n\n"
|
| 1457 |
+
f"# {doc['title']}\n{ex}\n\ngo\n"
|
| 1458 |
+
)
|
| 1459 |
+
st.session_state.center_mode = "editor"
|
| 1460 |
+
st.rerun()
|
| 1461 |
+
|
| 1462 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 1463 |
+
|
| 1464 |
+
# ββ Mask cheat sheet βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1465 |
+
with st.expander("Atom Mask Cheat Sheet"):
|
| 1466 |
+
masks_ref = [
|
| 1467 |
+
(":1", "Residue 1"),
|
| 1468 |
+
(":1-100", "Residues 1β100"),
|
| 1469 |
+
(":ALA", "All alanines"),
|
| 1470 |
+
("@CA", "All CΞ± atoms"),
|
| 1471 |
+
("@CA,C,N,O", "Backbone atoms"),
|
| 1472 |
+
("!:WAT", "Exclude water"),
|
| 1473 |
+
(":1-50&@CA", "CΞ± of res 1β50"),
|
| 1474 |
+
(":LIG<:5.0", "Within 5Γ
of LIG"),
|
| 1475 |
+
("@/C", "All carbons"),
|
| 1476 |
+
]
|
| 1477 |
+
html = "".join(
|
| 1478 |
+
f'<div style="display:flex;gap:8px;padding:3px 0;font-size:11px">'
|
| 1479 |
+
f'<span style="font-family:JetBrains Mono,monospace;color:var(--option);min-width:90px">{m}</span>'
|
| 1480 |
+
f'<span style="color:var(--muted)">{d}</span></div>'
|
| 1481 |
+
for m, d in masks_ref
|
| 1482 |
+
)
|
| 1483 |
+
st.markdown(html, unsafe_allow_html=True)
|
core/__init__.py
ADDED
|
File without changes
|
core/agent.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI trajectory analysis agent β Claude, OpenAI, Gemini.
|
| 3 |
+
All three providers support reliable tool use / function calling.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from .knowledge_base import CPPTrajKnowledgeBase
|
| 12 |
+
from .llm_backends import LLMBackend, create_backend
|
| 13 |
+
from .runner import CPPTrajRunner
|
| 14 |
+
|
| 15 |
+
TOOLS = [
|
| 16 |
+
{
|
| 17 |
+
"name": "run_cpptraj_script",
|
| 18 |
+
"description": (
|
| 19 |
+
"Write and execute a cpptraj script to analyze the trajectory. "
|
| 20 |
+
"Always include parm, trajin, analysis commands, and 'go'. "
|
| 21 |
+
"Returns stdout, stderr, and output files generated."
|
| 22 |
+
),
|
| 23 |
+
"input_schema": {
|
| 24 |
+
"type": "object",
|
| 25 |
+
"properties": {
|
| 26 |
+
"script": {"type": "string", "description": "Complete cpptraj script"},
|
| 27 |
+
"description": {"type": "string", "description": "What this script does"},
|
| 28 |
+
},
|
| 29 |
+
"required": ["script", "description"],
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"name": "read_output_file",
|
| 34 |
+
"description": "Read the content of an output file produced by a previous cpptraj run.",
|
| 35 |
+
"input_schema": {
|
| 36 |
+
"type": "object",
|
| 37 |
+
"properties": {
|
| 38 |
+
"filename": {"type": "string", "description": "Output file name (e.g. rmsd.dat)"},
|
| 39 |
+
},
|
| 40 |
+
"required": ["filename"],
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"name": "list_output_files",
|
| 45 |
+
"description": "List all output files in the working directory.",
|
| 46 |
+
"input_schema": {"type": "object", "properties": {}},
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"name": "run_python_script",
|
| 50 |
+
"description": (
|
| 51 |
+
"Write and execute a Python script for post-processing, plotting, or statistical "
|
| 52 |
+
"analysis of cpptraj output files. Use matplotlib to save plots as PNG. "
|
| 53 |
+
"All output files (PNG, CSV, etc.) are saved to the working directory. "
|
| 54 |
+
"Returns stdout, stderr, and any new files created."
|
| 55 |
+
),
|
| 56 |
+
"input_schema": {
|
| 57 |
+
"type": "object",
|
| 58 |
+
"properties": {
|
| 59 |
+
"script": {"type": "string", "description": "Complete Python script to execute"},
|
| 60 |
+
"description": {"type": "string", "description": "What this script does"},
|
| 61 |
+
},
|
| 62 |
+
"required": ["script", "description"],
|
| 63 |
+
},
|
| 64 |
+
},
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
SYSTEM_PROMPT = """\
|
| 68 |
+
You are an expert computational biophysicist specializing in MD simulation analysis.
|
| 69 |
+
|
| 70 |
+
RULES: Always call tools directly. Never explain commands or tell users to run them manually.
|
| 71 |
+
- Be concise. After running any script (cpptraj or Python), give a 1-2 sentence summary of results only. Do not explain what commands do unless explicitly asked. Never repeat the script back to the user.
|
| 72 |
+
- cpptraj task β run_cpptraj_script | plotting/stats β run_python_script | list files β list_output_files
|
| 73 |
+
- After cpptraj finishes: read the output file, report the key numbers in 1-2 sentences, then STOP. Never continue to Python automatically.
|
| 74 |
+
- run_python_script is ONLY allowed when the user message contains words like: plot, graph, chart, visualize, histogram, heatmap, statistics, stats, analyze further. "calculate", "compute", "find", "show me" do NOT trigger Python.
|
| 75 |
+
- WRONG: user says "calculate RMSD" β you run cpptraj then auto-run Python to plot it. STOP after cpptraj.
|
| 76 |
+
- RIGHT: user says "calculate RMSD" β run cpptraj, read output, report numbers. Done.
|
| 77 |
+
|
| 78 |
+
cpptraj syntax (spaces, NOT colons): `parm file.prmtop` not `parm: file.prmtop`. Always end with `go`.
|
| 79 |
+
- Frame count: parm + trajin + go (stdout shows count).
|
| 80 |
+
- ALWAYS strip :WAT before autoimage and before any RMSD/distance/secstruct analysis. Order: strip β autoimage β analysis. Without stripping water first, autoimage anchors to water molecules causing artificially huge RMSD (20-40 Γ
).
|
| 81 |
+
- Output: `out rmsd.dat`. References: `first`, `refindex -1`. Masks: `@CA,C,N,O` `@CA` `:1-100` `!:WAT`
|
| 82 |
+
|
| 83 |
+
## Python Environment
|
| 84 |
+
Available packages: pandas, numpy, matplotlib, scikit-learn, scipy. NOT available: MDAnalysis, parmed, pytraj, openmm.
|
| 85 |
+
NEVER use `delim_whitespace=True` (deprecated in pandas 2.x) β always use `sep=r'\s+'`.
|
| 86 |
+
|
| 87 |
+
Python: `plt.savefig('f.png', dpi=150, bbox_inches='tight')` then `plt.close()`. Never plt.show().
|
| 88 |
+
Read .dat files with pandas: `pd.read_csv('f.dat', sep=r'\\s+', comment='#')`. Print key stats to stdout.
|
| 89 |
+
|
| 90 |
+
## Residue Classification (critical β never misclassify)
|
| 91 |
+
Protein residues (NOT ligands): ALA ARG ASN ASP CYS CYX GLN GLU GLY HIS HIE HID HIP ILE LEU LYS MET PHE PRO SER THR TRP TYR VAL
|
| 92 |
+
Capping groups (NOT ligands β part of the protein): ACE (N-terminal acetyl cap) NME (C-terminal methylamide cap) NHE NH2
|
| 93 |
+
Water/solvent (NOT ligands): WAT HOH TIP3 TIP4
|
| 94 |
+
Ions (NOT ligands): Na+ Cl- K+ MG CA ZN NA CL Mg2+ Ca2+
|
| 95 |
+
Ligand = any residue that is NONE of the above.
|
| 96 |
+
|
| 97 |
+
## Efficient Ligand Identification (use this approach, do it in ONE script)
|
| 98 |
+
Run a single Python script using parmed or direct prmtop parsing to list unique residue names, then filter:
|
| 99 |
+
```python
|
| 100 |
+
import subprocess, re
|
| 101 |
+
result = subprocess.run(['cpptraj', '-p', 'PRMTOP', '--resmask', '*'], capture_output=True, text=True)
|
| 102 |
+
```
|
| 103 |
+
OR run cpptraj with `resinfo *` and parse stdout β do this ONCE, not in a loop.
|
| 104 |
+
Standard approach:
|
| 105 |
+
```
|
| 106 |
+
parm protein.prmtop
|
| 107 |
+
resinfo *
|
| 108 |
+
go
|
| 109 |
+
```
|
| 110 |
+
Then parse the output with run_python_script to filter non-ligand residues. Do not repeat the script if you get results β read what cpptraj printed.
|
| 111 |
+
"""
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class TrajectoryAgent:
|
| 115 |
+
def __init__(self, runner: CPPTrajRunner, kb: CPPTrajKnowledgeBase,
|
| 116 |
+
provider: str = "", api_key: str = "", model: str = "", base_url: str = ""):
|
| 117 |
+
self.runner = runner
|
| 118 |
+
self.kb = kb
|
| 119 |
+
self.conversation_history: list[dict] = []
|
| 120 |
+
self.parm_file: Path | None = None
|
| 121 |
+
self.traj_files: list[Path] = []
|
| 122 |
+
self._topology_info: dict = {}
|
| 123 |
+
|
| 124 |
+
provider = provider or os.environ.get("LLM_PROVIDER", "claude")
|
| 125 |
+
model = model or os.environ.get("LLM_MODEL", "")
|
| 126 |
+
base_url = base_url or os.environ.get("LLM_BASE_URL", "")
|
| 127 |
+
# api_key intentionally not read from environment β must come from IDE settings
|
| 128 |
+
|
| 129 |
+
self._backend: LLMBackend = create_backend(provider, api_key, model, base_url)
|
| 130 |
+
|
| 131 |
+
def reconfigure(self, provider: str, api_key: str, model: str, base_url: str = ""):
|
| 132 |
+
self._backend = create_backend(provider, api_key, model, base_url)
|
| 133 |
+
self.conversation_history = []
|
| 134 |
+
|
| 135 |
+
_PROTEIN_RES = {
|
| 136 |
+
"ALA","ARG","ASN","ASP","CYS","CYX","GLN","GLU","GLY",
|
| 137 |
+
"HIS","HIE","HID","HIP","ILE","LEU","LYS","MET","PHE",
|
| 138 |
+
"PRO","SER","THR","TRP","TYR","VAL",
|
| 139 |
+
"ACE","NME","NHE","NH2", # caps
|
| 140 |
+
}
|
| 141 |
+
_ION_RES = {
|
| 142 |
+
"NA","CL","K","MG","CA","ZN","NA+","CL-","K+",
|
| 143 |
+
"Na+","Cl-","Mg2+","Ca2+",
|
| 144 |
+
"SOD","CLA","POT","CAL", # CHARMM names
|
| 145 |
+
"LI","RB","CS","F","BR","I",
|
| 146 |
+
}
|
| 147 |
+
_WATER_RES = {"WAT","HOH","TIP3","TIP4","SPC","SPCE"}
|
| 148 |
+
# Combined set for ligand detection
|
| 149 |
+
_KNOWN_NON_LIGAND = _PROTEIN_RES | _ION_RES | _WATER_RES
|
| 150 |
+
|
| 151 |
+
def set_files(self, parm_file: Path | None, traj_files: list[Path]):
|
| 152 |
+
self.parm_file = parm_file
|
| 153 |
+
self.traj_files = traj_files
|
| 154 |
+
self._topology_info: dict = {}
|
| 155 |
+
if parm_file and parm_file.exists():
|
| 156 |
+
self._scan_topology(parm_file)
|
| 157 |
+
|
| 158 |
+
def _scan_topology(self, parm_file: Path):
|
| 159 |
+
"""Run resinfo once on upload and cache ligand/residue info."""
|
| 160 |
+
import re
|
| 161 |
+
script = f"parm {parm_file}\nresinfo *\ngo"
|
| 162 |
+
res = self.runner.run_script(script)
|
| 163 |
+
stdout = res.get("stdout", "")
|
| 164 |
+
ligands, n_protein, n_water, n_ions = [], 0, 0, 0
|
| 165 |
+
for line in stdout.splitlines():
|
| 166 |
+
m = re.match(r'\s*(\d+)\s+(\S+)\s+\d+\s+\d+\s+(\d+)\s+', line)
|
| 167 |
+
if not m:
|
| 168 |
+
continue
|
| 169 |
+
resid, resname, natoms = int(m.group(1)), m.group(2), int(m.group(3))
|
| 170 |
+
rname_up = resname.upper()
|
| 171 |
+
if rname_up in {r.upper() for r in self._WATER_RES}:
|
| 172 |
+
n_water += 1
|
| 173 |
+
elif rname_up in {r.upper() for r in self._ION_RES}:
|
| 174 |
+
n_ions += 1
|
| 175 |
+
elif rname_up in {r.upper() for r in self._PROTEIN_RES}:
|
| 176 |
+
n_protein += 1
|
| 177 |
+
else:
|
| 178 |
+
ligands.append({"resid": resid, "name": resname, "natoms": natoms})
|
| 179 |
+
self._topology_info = {
|
| 180 |
+
"n_protein_res": n_protein,
|
| 181 |
+
"n_water": n_water,
|
| 182 |
+
"n_ions": n_ions,
|
| 183 |
+
"ligands": ligands,
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
def reset_conversation(self):
|
| 187 |
+
self.conversation_history = []
|
| 188 |
+
|
| 189 |
+
@property
|
| 190 |
+
def provider(self): return self._backend.provider
|
| 191 |
+
|
| 192 |
+
@property
|
| 193 |
+
def model(self): return self._backend.model
|
| 194 |
+
|
| 195 |
+
# Queries that don't need cpptraj documentation context
|
| 196 |
+
_SKIP_RAG = ("how many frames", "frame count", "list file", "list output",
|
| 197 |
+
"plot ", "show plot", "what files", "delete", "reset")
|
| 198 |
+
|
| 199 |
+
# Aliases: user terms β cpptraj command names
|
| 200 |
+
_CMD_ALIASES = {
|
| 201 |
+
"rg": "radgyr", "radius of gyration": "radgyr", "radgyr": "radgyr",
|
| 202 |
+
"rmsf": "atomicfluct", "bfactor": "atomicfluct", "b-factor": "atomicfluct",
|
| 203 |
+
"rmsd": "rmsd", "hbond": "hbond", "hydrogen bond": "hbond",
|
| 204 |
+
"secondary structure": "secstruct", "dssp": "secstruct",
|
| 205 |
+
"cluster": "cluster", "clustering": "cluster",
|
| 206 |
+
"contact map": "nativecontacts", "native contact": "nativecontacts",
|
| 207 |
+
"pca": "matrix", "principal component": "pca",
|
| 208 |
+
"dihedral": "dihedral", "phi psi": "dihedral",
|
| 209 |
+
"distance": "distance", "angle": "angle",
|
| 210 |
+
"sasa": "surf", "surface area": "surf",
|
| 211 |
+
"diffusion": "diffusion", "msd": "diffusion",
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
def _build_user_message_with_rag(self, query: str) -> str:
|
| 215 |
+
fc = self._build_file_context()
|
| 216 |
+
q = query.lower()
|
| 217 |
+
if any(kw in q for kw in self._SKIP_RAG):
|
| 218 |
+
return f"{fc}\n\n## User Request\n{query}"
|
| 219 |
+
|
| 220 |
+
# Inject exact syntax for any recognised command aliases
|
| 221 |
+
exact_lines = []
|
| 222 |
+
for alias, cmd_key in self._CMD_ALIASES.items():
|
| 223 |
+
if alias in q:
|
| 224 |
+
cmd = self.kb.get_command(cmd_key)
|
| 225 |
+
if cmd:
|
| 226 |
+
exact_lines.append(f" {cmd['title']}: {cmd['syntax']}")
|
| 227 |
+
exact_block = ""
|
| 228 |
+
if exact_lines:
|
| 229 |
+
exact_block = "## Relevant cpptraj syntax\n" + "\n".join(exact_lines) + "\n\n"
|
| 230 |
+
|
| 231 |
+
rag = self.kb.get_context_for_llm(query, top_k=3)
|
| 232 |
+
return f"{fc}\n\n{exact_block}{rag}\n\n## User Request\n{query}"
|
| 233 |
+
|
| 234 |
+
def _trim_history(self, history: list) -> list:
|
| 235 |
+
"""Keep the last few turns, always cutting at a real user-text boundary.
|
| 236 |
+
|
| 237 |
+
Must never start the window on a tool-result wrapper (Claude list content,
|
| 238 |
+
OpenAI _multi, or Gemini _fn_responses) β that produces orphaned results
|
| 239 |
+
the API rejects with a 400.
|
| 240 |
+
"""
|
| 241 |
+
if len(history) <= 8:
|
| 242 |
+
return history
|
| 243 |
+
|
| 244 |
+
# Identify indices of genuine user-text messages (not tool-result wrappers)
|
| 245 |
+
real_user_idx = []
|
| 246 |
+
for i, msg in enumerate(history):
|
| 247 |
+
if msg["role"] != "user":
|
| 248 |
+
continue
|
| 249 |
+
# Exclude Gemini function-response turns
|
| 250 |
+
if "_fn_responses" in msg:
|
| 251 |
+
continue
|
| 252 |
+
content = msg.get("content", "")
|
| 253 |
+
if isinstance(content, str) and content.strip():
|
| 254 |
+
real_user_idx.append(i)
|
| 255 |
+
elif isinstance(content, list):
|
| 256 |
+
# A real user turn has at least one non-tool_result block
|
| 257 |
+
if any(not (isinstance(b, dict) and b.get("type") == "tool_result")
|
| 258 |
+
for b in content):
|
| 259 |
+
real_user_idx.append(i)
|
| 260 |
+
|
| 261 |
+
# Keep the last 3 real turns; if fewer exist, return the full history
|
| 262 |
+
if len(real_user_idx) <= 3:
|
| 263 |
+
return history
|
| 264 |
+
return history[real_user_idx[-4]:]
|
| 265 |
+
|
| 266 |
+
@staticmethod
|
| 267 |
+
def _compress_result(result: str) -> str:
|
| 268 |
+
"""Trim tool result stored in history to save tokens."""
|
| 269 |
+
if len(result) <= 200:
|
| 270 |
+
return result
|
| 271 |
+
lines = result.splitlines()
|
| 272 |
+
head = "\n".join(lines[:8])
|
| 273 |
+
return f"{head}\n⦠[{len(lines)} lines total, truncated]"
|
| 274 |
+
|
| 275 |
+
def _safe_trim(self, history: list) -> list:
|
| 276 |
+
"""Emergency trim if total history exceeds ~120k chars (~30k tokens)."""
|
| 277 |
+
total = sum(len(str(m.get("content", ""))) for m in history)
|
| 278 |
+
if total <= 120_000:
|
| 279 |
+
return history
|
| 280 |
+
# Keep only last 2 real user turns
|
| 281 |
+
real_user_idx = []
|
| 282 |
+
for i, msg in enumerate(history):
|
| 283 |
+
if msg["role"] != "user":
|
| 284 |
+
continue
|
| 285 |
+
content = msg.get("content", "")
|
| 286 |
+
if isinstance(content, str):
|
| 287 |
+
real_user_idx.append(i)
|
| 288 |
+
elif isinstance(content, list):
|
| 289 |
+
if any(not (isinstance(b, dict) and b.get("type") == "tool_result")
|
| 290 |
+
for b in content):
|
| 291 |
+
real_user_idx.append(i)
|
| 292 |
+
if len(real_user_idx) >= 2:
|
| 293 |
+
return history[real_user_idx[-2]:]
|
| 294 |
+
return history[-4:] # fallback: last 4 messages
|
| 295 |
+
|
| 296 |
+
def _build_file_context(self) -> str:
|
| 297 |
+
parts = ["## Available Files"]
|
| 298 |
+
parts.append(f"- Topology: `{self.parm_file.name}`" if self.parm_file
|
| 299 |
+
else "- Topology: *not uploaded yet*")
|
| 300 |
+
if self.traj_files:
|
| 301 |
+
for tf in self.traj_files: parts.append(f"- Trajectory: `{tf.name}`")
|
| 302 |
+
else:
|
| 303 |
+
parts.append("- Trajectory: *not uploaded yet*")
|
| 304 |
+
|
| 305 |
+
info = getattr(self, "_topology_info", {})
|
| 306 |
+
if info:
|
| 307 |
+
parts.append(f"\n## Topology Composition")
|
| 308 |
+
parts.append(f"- Protein residues: {info['n_protein_res']}")
|
| 309 |
+
if info.get('n_ions'):
|
| 310 |
+
parts.append(f"- Ions: {info['n_ions']} residues")
|
| 311 |
+
parts.append(f"- Water molecules: {info['n_water']}")
|
| 312 |
+
ligs = info.get("ligands", [])
|
| 313 |
+
if ligs:
|
| 314 |
+
parts.append(f"- Ligands ({len(ligs)} molecule{'s' if len(ligs)>1 else ''}):")
|
| 315 |
+
for lig in ligs:
|
| 316 |
+
parts.append(f" β’ {lig['name']} β residue :{lig['resid']} β {lig['natoms']} atoms")
|
| 317 |
+
parts.append(f" β protein mask: :1-{ligs[0]['resid']-1} ligand mask: :{lig['resid']}")
|
| 318 |
+
else:
|
| 319 |
+
parts.append("- Ligands: none detected")
|
| 320 |
+
|
| 321 |
+
existing = self.runner.list_output_files()
|
| 322 |
+
if existing:
|
| 323 |
+
parts.append("\n## Existing Output Files")
|
| 324 |
+
for f in existing: parts.append(f" - {f.name}")
|
| 325 |
+
return "\n".join(parts)
|
| 326 |
+
|
| 327 |
+
def _execute_tool(self, name: str, inp: dict) -> str:
|
| 328 |
+
if name == "run_cpptraj_script":
|
| 329 |
+
script = inp.get("script", "")
|
| 330 |
+
if not script:
|
| 331 |
+
return "Error: model did not provide a script."
|
| 332 |
+
if self.parm_file or self.traj_files:
|
| 333 |
+
script = self.runner.inject_paths_into_script(script, self.parm_file, self.traj_files)
|
| 334 |
+
res = self.runner.run_script(script)
|
| 335 |
+
out = [f"Success: {res['success']}", f"Elapsed: {res['elapsed']:.1f}s"]
|
| 336 |
+
if res["stdout"]: out.append(f"\nSTDOUT:\n{res['stdout'][:1500]}")
|
| 337 |
+
if res["stderr"]: out.append(f"\nSTDERR:\n{res['stderr'][:800]}")
|
| 338 |
+
if res["output_files"]:
|
| 339 |
+
out.append("Output files:")
|
| 340 |
+
for f in res["output_files"]: out.append(f" - {f.name}")
|
| 341 |
+
return "\n".join(out)
|
| 342 |
+
|
| 343 |
+
if name == "read_output_file":
|
| 344 |
+
path = self.runner.work_dir / inp["filename"]
|
| 345 |
+
if not path.exists():
|
| 346 |
+
avail = [f.name for f in self.runner.list_output_files()]
|
| 347 |
+
return f"File '{inp['filename']}' not found. Available: {avail}"
|
| 348 |
+
content = self.runner.read_file(path)
|
| 349 |
+
lines = content.splitlines()
|
| 350 |
+
if len(lines) > 40:
|
| 351 |
+
return "\n".join(lines[:40]) + f"\n\n[{len(lines)} lines total β first 40 shown]"
|
| 352 |
+
return content
|
| 353 |
+
|
| 354 |
+
if name == "list_output_files":
|
| 355 |
+
files = self.runner.list_output_files()
|
| 356 |
+
if not files: return "No output files yet."
|
| 357 |
+
return "Output files:\n" + "\n".join(
|
| 358 |
+
f" - {f.name} ({f.stat().st_size} bytes)" for f in files)
|
| 359 |
+
|
| 360 |
+
if name == "run_python_script":
|
| 361 |
+
script = inp.get("script", "")
|
| 362 |
+
if not script:
|
| 363 |
+
return "Error: model did not provide a script."
|
| 364 |
+
work_dir = self.runner.work_dir
|
| 365 |
+
before = set(work_dir.iterdir())
|
| 366 |
+
try:
|
| 367 |
+
proc = subprocess.run(
|
| 368 |
+
[sys.executable, "-c", script],
|
| 369 |
+
capture_output=True, text=True, timeout=60,
|
| 370 |
+
cwd=str(work_dir),
|
| 371 |
+
)
|
| 372 |
+
after = set(work_dir.iterdir())
|
| 373 |
+
new_files = sorted(after - before, key=lambda f: f.name)
|
| 374 |
+
out = [f"Success: {proc.returncode == 0}"]
|
| 375 |
+
if proc.stdout: out.append(f"\nSTDOUT:\n{proc.stdout[:1500]}")
|
| 376 |
+
if proc.stderr: out.append(f"\nSTDERR:\n{proc.stderr[:800]}")
|
| 377 |
+
if new_files:
|
| 378 |
+
out.append("New files created:")
|
| 379 |
+
for f in new_files: out.append(f" - {f.name} ({f.stat().st_size} bytes)")
|
| 380 |
+
return "\n".join(out)
|
| 381 |
+
except subprocess.TimeoutExpired:
|
| 382 |
+
return "Error: Python script timed out after 60 seconds."
|
| 383 |
+
except Exception as e:
|
| 384 |
+
return f"Error running Python script: {e}"
|
| 385 |
+
|
| 386 |
+
return f"Unknown tool: {name}"
|
| 387 |
+
|
| 388 |
+
def _sanitize_history(self):
|
| 389 |
+
while self.conversation_history:
|
| 390 |
+
last = self.conversation_history[-1]
|
| 391 |
+
role = last["role"]
|
| 392 |
+
# Remove orphaned assistant/model messages with unresolved tool calls
|
| 393 |
+
if role not in ("assistant", "model"):
|
| 394 |
+
break
|
| 395 |
+
content = last.get("content") or []
|
| 396 |
+
has_unresolved = (
|
| 397 |
+
any(isinstance(b, dict) and b.get("type") == "tool_use" for b in content)
|
| 398 |
+
if isinstance(content, list)
|
| 399 |
+
else bool(last.get("tool_calls") or last.get("_fn_calls"))
|
| 400 |
+
)
|
| 401 |
+
if has_unresolved:
|
| 402 |
+
self.conversation_history.pop()
|
| 403 |
+
else:
|
| 404 |
+
break
|
| 405 |
+
|
| 406 |
+
def chat_stream(self, user_query: str):
|
| 407 |
+
"""Generator yielding SSE-style dicts for streaming chat."""
|
| 408 |
+
self._sanitize_history()
|
| 409 |
+
self.conversation_history.append({
|
| 410 |
+
"role": "user",
|
| 411 |
+
"content": self._build_user_message_with_rag(user_query),
|
| 412 |
+
})
|
| 413 |
+
|
| 414 |
+
backend = self._backend
|
| 415 |
+
|
| 416 |
+
while True:
|
| 417 |
+
text_acc = []
|
| 418 |
+
tool_calls = []
|
| 419 |
+
stop_reason = "end_turn"
|
| 420 |
+
|
| 421 |
+
for event_type, data in backend.stream_chat(
|
| 422 |
+
self._safe_trim(self._trim_history(self.conversation_history)), TOOLS, SYSTEM_PROMPT):
|
| 423 |
+
if event_type == "text":
|
| 424 |
+
text_acc.append(data)
|
| 425 |
+
yield {"type": "text", "chunk": data}
|
| 426 |
+
elif event_type == "tool_calls":
|
| 427 |
+
tool_calls = data
|
| 428 |
+
elif event_type == "stop_reason":
|
| 429 |
+
stop_reason = data
|
| 430 |
+
|
| 431 |
+
full_text = "".join(text_acc)
|
| 432 |
+
|
| 433 |
+
self.conversation_history.append(
|
| 434 |
+
backend.make_assistant_message(full_text, tool_calls))
|
| 435 |
+
|
| 436 |
+
if stop_reason not in ("tool_use", "tool_calls") or not tool_calls:
|
| 437 |
+
yield {"type": "done"}
|
| 438 |
+
break
|
| 439 |
+
|
| 440 |
+
# Execute tools and stream results
|
| 441 |
+
results = []
|
| 442 |
+
for tc in tool_calls:
|
| 443 |
+
yield {"type": "tool_start", "tool": tc["name"],
|
| 444 |
+
"description": tc["input"].get("description", tc["name"])}
|
| 445 |
+
try:
|
| 446 |
+
result = self._execute_tool(tc["name"], tc["input"])
|
| 447 |
+
except Exception as e:
|
| 448 |
+
result = f"Error: {e}"
|
| 449 |
+
yield {"type": "tool_done", "tool": tc["name"],
|
| 450 |
+
"input": tc["input"], "result": result}
|
| 451 |
+
results.append(self._compress_result(result)) # compress for history
|
| 452 |
+
|
| 453 |
+
# Always add tool results to history to avoid orphaned function_calls
|
| 454 |
+
tool_result_msg = backend.make_tool_result_message(tool_calls, results)
|
| 455 |
+
if "_multi" in tool_result_msg:
|
| 456 |
+
self.conversation_history.extend(tool_result_msg["_multi"])
|
| 457 |
+
else:
|
| 458 |
+
self.conversation_history.append(tool_result_msg)
|
| 459 |
+
|
| 460 |
+
def chat(self, user_query: str) -> tuple[str, list[dict]]:
|
| 461 |
+
self._sanitize_history()
|
| 462 |
+
self.conversation_history.append({
|
| 463 |
+
"role": "user",
|
| 464 |
+
"content": self._build_user_message_with_rag(user_query),
|
| 465 |
+
})
|
| 466 |
+
|
| 467 |
+
tool_calls_log = []
|
| 468 |
+
final_text = ""
|
| 469 |
+
backend = self._backend
|
| 470 |
+
|
| 471 |
+
while True:
|
| 472 |
+
try:
|
| 473 |
+
text, tool_calls, has_tool_use = backend.chat(
|
| 474 |
+
self._safe_trim(self._trim_history(self.conversation_history)), TOOLS, SYSTEM_PROMPT)
|
| 475 |
+
except Exception as e:
|
| 476 |
+
if "tool_use" in str(e) or "tool_result" in str(e):
|
| 477 |
+
last = self.conversation_history[-1]
|
| 478 |
+
self.conversation_history = [last]
|
| 479 |
+
text, tool_calls, has_tool_use = backend.chat(
|
| 480 |
+
self._safe_trim(self._trim_history(self.conversation_history)), TOOLS, SYSTEM_PROMPT)
|
| 481 |
+
else:
|
| 482 |
+
raise
|
| 483 |
+
|
| 484 |
+
self.conversation_history.append(backend.make_assistant_message(text, tool_calls))
|
| 485 |
+
|
| 486 |
+
if not has_tool_use or not tool_calls:
|
| 487 |
+
final_text = text
|
| 488 |
+
break
|
| 489 |
+
|
| 490 |
+
results = []
|
| 491 |
+
for tc in tool_calls:
|
| 492 |
+
result = self._execute_tool(tc["name"], tc["input"])
|
| 493 |
+
tool_calls_log.append({"tool": tc["name"], "input": tc["input"], "result": result})
|
| 494 |
+
results.append(self._compress_result(result)) # compress for history
|
| 495 |
+
|
| 496 |
+
tool_result_msg = backend.make_tool_result_message(tool_calls, results)
|
| 497 |
+
if "_multi" in tool_result_msg:
|
| 498 |
+
self.conversation_history.extend(tool_result_msg["_multi"])
|
| 499 |
+
else:
|
| 500 |
+
self.conversation_history.append(tool_result_msg)
|
| 501 |
+
|
| 502 |
+
return final_text, tool_calls_log
|
core/knowledge_base.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cpptraj RAG knowledge base β built from the real CpptrajManual.pdf.
|
| 3 |
+
|
| 4 |
+
Pipeline:
|
| 5 |
+
1. Extract text from PDF with pdfplumber (cached to cpptraj_manual_cache.json)
|
| 6 |
+
2. Split into per-command chunks using section-header heuristics
|
| 7 |
+
3. TF-IDF index (scikit-learn) for fast semantic-ish retrieval
|
| 8 |
+
4. Thin structured command registry for the left-panel UI (unchanged look)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 17 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 18 |
+
|
| 19 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
# PATHS
|
| 21 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
|
| 23 |
+
_HERE = Path(__file__).parent.parent # CPPTRAJ_Agent/
|
| 24 |
+
PDF_PATH = _HERE / "CpptrajManual.pdf"
|
| 25 |
+
CACHE_PATH = _HERE / "cpptraj_manual_cache.json"
|
| 26 |
+
|
| 27 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
# CPPTRAJ COMMANDS
|
| 29 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
|
| 31 |
+
CPPTRAJ_COMMANDS = {
|
| 32 |
+
# ββ Setup / Input ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
"parm": {"category": "Setup", "title": "Load Topology (parm)", "description": "Load a topology/parameter file (.prmtop, .psf, .gro, .pdb). Must be the first command.", "syntax": "parm <filename> [<tag>] [nobondsearch]"},
|
| 34 |
+
"trajin": {"category": "Setup", "title": "Load Trajectory (trajin)", "description": "Load trajectory file(s). Multiple trajin statements concatenate frames. Use start/stop/offset to sub-sample.", "syntax": "trajin <filename> [start] [stop|last] [offset]"},
|
| 35 |
+
"reference": {"category": "Setup", "title": "Load Reference (reference)", "description": "Load a reference structure used by rmsd, align, nativecontacts.", "syntax": "reference <filename> [<frame>] [<tag>]"},
|
| 36 |
+
"activeref": {"category": "Setup", "title": "Set Active Reference (activeref)", "description": "Set the active reference structure by tag.", "syntax": "activeref <tag>"},
|
| 37 |
+
"createcrd": {"category": "Setup", "title": "Create COORDS Set (createcrd)", "description": "Create an empty COORDS data set for in-memory trajectory storage.", "syntax": "createcrd <name>"},
|
| 38 |
+
"createreservoir": {"category": "Setup", "title": "Create Reservoir (createreservoir)", "description": "Create structure reservoir for REST simulation.", "syntax": "createreservoir <name> <filename> [<fmt>] [<mask>] [ene <set>] [temp <T>]"},
|
| 39 |
+
"createset": {"category": "Setup", "title": "Create Data Set (createset)", "description": "Create a new data set with specified values.", "syntax": "createset name <name> type <type> [values <v1>,<v2>,...] [<range>]"},
|
| 40 |
+
"loadcrd": {"category": "Setup", "title": "Load COORDS (loadcrd)", "description": "Load trajectory into a named COORDS data set for later use.", "syntax": "loadcrd <filename> [<fmt>] [<mask>] name <setname>"},
|
| 41 |
+
"loadtraj": {"category": "Setup", "title": "Load Trajectory (loadtraj)", "description": "Load trajectory (alias for trajin inside scripts).", "syntax": "loadtraj <filename> [<fmt>] [<mask>]"},
|
| 42 |
+
"readdata": {"category": "Setup", "title": "Read Data (readdata)", "description": "Read data from file into data sets for analysis.", "syntax": "readdata <filename> [as <fmt>] [name <name>] [index <col>]"},
|
| 43 |
+
"go": {"category": "Setup", "title": "Execute (go)", "description": "Execute all queued commands. Required at end of every script.", "syntax": "go"},
|
| 44 |
+
# ββ Output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
"trajout": {"category": "Output", "title": "Write Trajectory (trajout)", "description": "Write processed trajectory to a new file. Format auto-detected from extension.", "syntax": "trajout <filename> [format] [nobox]"},
|
| 46 |
+
"outtraj": {"category": "Output", "title": "Write Frames On-the-fly (outtraj)", "description": "Write frames to trajectory file during processing.", "syntax": "outtraj <filename> [<fmt>] [<mask>] [nobox] [onlyframes <range>]"},
|
| 47 |
+
"crdout": {"category": "Output", "title": "Write COORDS Set (crdout)", "description": "Write a COORDS data set to a trajectory file.", "syntax": "crdout <crdset> <filename> [<fmt>] [<mask>]"},
|
| 48 |
+
"parmwrite": {"category": "Output", "title": "Write Topology (parmwrite)", "description": "Write topology to file in specified format.", "syntax": "parmwrite out <filename> [<fmt>] [<topology tag>]"},
|
| 49 |
+
"datafile": {"category": "Output", "title": "Data File Options (datafile)", "description": "Set output options for a data file.", "syntax": "datafile <filename> [<options>]"},
|
| 50 |
+
"datafilter": {"category": "Output", "title": "Filter Data (datafilter)", "description": "Filter data sets by criteria and write to file.", "syntax": "datafilter <dataset> min <min> max <max> [out <file>]"},
|
| 51 |
+
"dataset": {"category": "Output", "title": "Data Set Operations (dataset)", "description": "Perform operations on data sets: legend, makexy, etc.", "syntax": "dataset {legend <legend> <set> | makexy <X> <Y> name <out> | ...}"},
|
| 52 |
+
"flatten": {"category": "Output", "title": "Flatten Data (flatten)", "description": "Flatten multi-dimensional data sets to 1D.", "syntax": "flatten <dataset> [out <file>]"},
|
| 53 |
+
"precision": {"category": "Output", "title": "Output Precision (precision)", "description": "Set output precision for data files.", "syntax": "precision <file> <width> [<digits>]"},
|
| 54 |
+
"selectds": {"category": "Output", "title": "Select Data Sets (selectds)", "description": "Select data sets matching a string pattern.", "syntax": "selectds <selection>"},
|
| 55 |
+
# ββ Manipulation / Actions βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
"autoimage": {"category": "Manipulation", "title": "Fix PBC Imaging (autoimage)", "description": "Re-image molecules across periodic boundaries back into the primary unit cell. Always strip :WAT first.", "syntax": "autoimage [familiar] [byres|bymol] [anchor <mask>]"},
|
| 57 |
+
"center": {"category": "Manipulation", "title": "Center System (center)", "description": "Translate coordinates so that specified atoms are at the origin or box center.", "syntax": "center [<mask>] [origin] [mass]"},
|
| 58 |
+
"strip": {"category": "Manipulation", "title": "Strip Atoms (strip)", "description": "Remove atoms/residues/molecules from the trajectory.", "syntax": "strip <mask>"},
|
| 59 |
+
"align": {"category": "Manipulation", "title": "Align Trajectory (align)", "description": "Rotate and translate frames to least-squares-fit selected atoms to a reference. Modifies coordinates.", "syntax": "align [<mask>] [ref <tag>|reference|first] [mass]"},
|
| 60 |
+
"image": {"category": "Manipulation", "title": "Image Molecules (image)", "description": "Image molecules into primary unit cell. Use autoimage for automatic imaging.", "syntax": "image [familiar] [bymol|byres|byatom] [<mask>] [origin] [center]"},
|
| 61 |
+
"unwrap": {"category": "Manipulation", "title": "Unwrap Trajectory (unwrap)", "description": "Unwrap trajectory to remove periodic boundary jumps.", "syntax": "unwrap [<mask>] [center] [bymol|byres]"},
|
| 62 |
+
"unstrip": {"category": "Manipulation", "title": "Restore Stripped Atoms (unstrip)", "description": "Restore previously stripped atoms back to the system.", "syntax": "unstrip"},
|
| 63 |
+
"translate": {"category": "Manipulation", "title": "Translate Coordinates (translate)", "description": "Translate coordinates by a vector.", "syntax": "translate [<mask>] [x <dx>] [y <dy>] [z <dz>]"},
|
| 64 |
+
"rotate": {"category": "Manipulation", "title": "Rotate Coordinates (rotate)", "description": "Rotate coordinates around an axis.", "syntax": "rotate [<mask>] {axis <x,y,z> degrees <d> | x|y|z <deg>}"},
|
| 65 |
+
"scale": {"category": "Manipulation", "title": "Scale Coordinates (scale)", "description": "Scale coordinates by a factor along x/y/z.", "syntax": "scale [<mask>] [x <fx>] [y <fy>] [z <fz>]"},
|
| 66 |
+
"box": {"category": "Manipulation", "title": "Set Box Dimensions (box)", "description": "Set or modify unit cell box dimensions.", "syntax": "box [x <x>] [y <y>] [z <z>] [alpha <a>] [beta <b>] [gamma <g>] [nobox]"},
|
| 67 |
+
"closest": {"category": "Manipulation", "title": "Keep Closest Solvent (closest)", "description": "Keep N closest solvent molecules to solute, remove the rest.", "syntax": "closest <N> <solvent_mask> [noimage] [first|oxygen] [name <name>]"},
|
| 68 |
+
"addatom": {"category": "Manipulation", "title": "Add Atom (addatom)", "description": "Add atoms to the topology.", "syntax": "addatom {bond <mask> | nobond} <name> <type> <charge> <mass> [<coords>]"},
|
| 69 |
+
"atommap": {"category": "Manipulation", "title": "Map Atoms (atommap)", "description": "Map atoms between two structures/topologies.", "syntax": "atommap <ref> <target> [mapout <file>] [maponly]"},
|
| 70 |
+
"catcrd": {"category": "Manipulation", "title": "Concatenate COORDS (catcrd)", "description": "Concatenate multiple COORDS data sets.", "syntax": "catcrd [crdset <set1>] [crdset <set2>] ... name <output>"},
|
| 71 |
+
"change": {"category": "Manipulation", "title": "Change Topology Properties (change)", "description": "Change topology atom/residue names, types, or other properties.", "syntax": "change {resname from <old> to <new> | atomname from <old> to <new> | ...}"},
|
| 72 |
+
"charge": {"category": "Manipulation", "title": "Print Total Charge (charge)", "description": "Print total charge for atom selection.", "syntax": "charge [<mask>]"},
|
| 73 |
+
"checkchirality": {"category": "Manipulation", "title": "Check Chirality (checkchirality)", "description": "Check chirality of chiral centers.", "syntax": "checkchirality [<mask>] [out <file>]"},
|
| 74 |
+
"combinecrd": {"category": "Manipulation", "title": "Combine COORDS (combinecrd)", "description": "Combine two COORDS sets into one.", "syntax": "combinecrd <crdset1> <crdset2> name <output>"},
|
| 75 |
+
"comparetop": {"category": "Manipulation", "title": "Compare Topologies (comparetop)", "description": "Compare two topology files.", "syntax": "comparetop [parm1 <tag>] [parm2 <tag>]"},
|
| 76 |
+
"crdaction": {"category": "Manipulation", "title": "Apply Action to COORDS (crdaction)", "description": "Apply an action to a COORDS data set.", "syntax": "crdaction <crdset> <action> [<action_args>]"},
|
| 77 |
+
"crdfluct": {"category": "Manipulation", "title": "COORDS Fluctuations (crdfluct)", "description": "Calculate fluctuations of a COORDS data set.", "syntax": "crdfluct <crdset> [<mask>] [out <file>] [byres] [bfactor]"},
|
| 78 |
+
"crdtransform": {"category": "Manipulation", "title": "Transform COORDS (crdtransform)", "description": "Apply coordinate transformation to a COORDS set.", "syntax": "crdtransform <crdset> [<xform_args>]"},
|
| 79 |
+
"dihedralscan": {"category": "Manipulation", "title": "Dihedral Scan (dihedralscan)", "description": "Scan dihedral angles to generate conformations.", "syntax": "dihedralscan [<mask>] [rseed <seed>] [out <file>] [outtraj <file>]"},
|
| 80 |
+
"emin": {"category": "Manipulation", "title": "Energy Minimization (emin)", "description": "Energy minimization using internal force field.", "syntax": "emin [<mask>] [nstep <N>] [out <file>] [step <step>]"},
|
| 81 |
+
"fiximagedbonds": {"category": "Manipulation", "title": "Fix Imaged Bonds (fiximagedbonds)", "description": "Fix broken bonds across periodic boundaries.", "syntax": "fiximagedbonds [<mask>]"},
|
| 82 |
+
"fixatomorder": {"category": "Manipulation", "title": "Fix Atom Order (fixatomorder)", "description": "Reorder atoms to match topology.", "syntax": "fixatomorder [<mask>] [outprefix <prefix>]"},
|
| 83 |
+
"graft": {"category": "Manipulation", "title": "Graft Coordinates (graft)", "description": "Graft coordinates from one structure onto another.", "syntax": "graft [src <mask>] [tgt <mask>] [srcframe <N>] [mass]"},
|
| 84 |
+
"hmassrepartition": {"category": "Manipulation", "title": "H-mass Repartition (hmassrepartition)", "description": "Hydrogen mass repartitioning for longer MD timesteps.", "syntax": "hmassrepartition [<mask>] [factor <f>]"},
|
| 85 |
+
"lessplit": {"category": "Manipulation", "title": "Split LES Trajectory (lessplit)", "description": "Split LES trajectory into individual replicas.", "syntax": "lessplit [out <prefix>] [<fmt>]"},
|
| 86 |
+
"makestructure": {"category": "Manipulation", "title": "Build Structure (makestructure)", "description": "Build structure using idealized geometry.", "syntax": "makestructure <sstype>:<res_range>[,...] [out <prefix>]"},
|
| 87 |
+
"minimage": {"category": "Manipulation", "title": "Minimum Image (minimage)", "description": "Apply minimum image convention for periodic distance.", "syntax": "minimage [<name>] <mask1> <mask2> [out <file>]"},
|
| 88 |
+
"molinfo": {"category": "Manipulation", "title": "Molecule Info (molinfo)", "description": "Print molecular information for atom mask.", "syntax": "molinfo [<mask>] [<topology tag>]"},
|
| 89 |
+
"parmbox": {"category": "Manipulation", "title": "Set Topology Box (parmbox)", "description": "Set periodic box dimensions in topology.", "syntax": "parmbox {x <x> y <y> z <z> [alpha <a> beta <b> gamma <g>] | nobox}"},
|
| 90 |
+
"parminfo": {"category": "Manipulation", "title": "Topology Info (parminfo)", "description": "Print topology information summary.", "syntax": "parminfo [<mask>] [<topology tag>]"},
|
| 91 |
+
"parmstrip": {"category": "Manipulation", "title": "Strip Topology (parmstrip)", "description": "Strip atoms from topology file permanently.", "syntax": "parmstrip <mask> [<topology tag>]"},
|
| 92 |
+
"permutedihedrals": {"category": "Manipulation", "title": "Permute Dihedrals (permutedihedrals)", "description": "Randomly permute dihedral angles.", "syntax": "permutedihedrals [<mask>] [rseed <seed>] [out <file>]"},
|
| 93 |
+
"prepareforleap": {"category": "Manipulation", "title": "Prepare for LEaP (prepareforleap)", "description": "Prepare structure for LEaP (add missing atoms, fix naming).", "syntax": "prepareforleap [<mask>] [out <file>] [pdbout <file>]"},
|
| 94 |
+
"randomizeions": {"category": "Manipulation", "title": "Randomize Ions (randomizeions)", "description": "Randomly swap ions with solvent molecules.", "syntax": "randomizeions <ion_mask> [by <solvent_mask>] [around <solute_mask>] [min <d>] [rseed <s>]"},
|
| 95 |
+
"remap": {"category": "Manipulation", "title": "Remap Atom Order (remap)", "description": "Remap atom ordering to match a reference.", "syntax": "remap [<mask>] <reference>"},
|
| 96 |
+
"replicatecell": {"category": "Manipulation", "title": "Replicate Unit Cell (replicatecell)", "description": "Replicate the unit cell in 3D.", "syntax": "replicatecell [<mask>] [out <prefix>] {all | dir X Y Z}"},
|
| 97 |
+
"resinfo": {"category": "Manipulation", "title": "Residue Info (resinfo)", "description": "Print residue information: resid, resname, atom count, etc.", "syntax": "resinfo [<mask>] [<topology tag>]"},
|
| 98 |
+
"rotatedihedral": {"category": "Manipulation", "title": "Rotate Dihedral (rotatedihedral)", "description": "Rotate a specific dihedral angle to a target value.", "syntax": "rotatedihedral [<mask>] res <r> type <phi|psi|chi1...> val <degrees>"},
|
| 99 |
+
"scale": {"category": "Manipulation", "title": "Scale Coordinates (scale)", "description": "Scale coordinates by a factor along x/y/z.", "syntax": "scale [<mask>] [x <fx>] [y <fy>] [z <fz>]"},
|
| 100 |
+
"select": {"category": "Manipulation", "title": "Select Atoms (select)", "description": "Select atoms by mask and print information.", "syntax": "select <mask>"},
|
| 101 |
+
"sequence": {"category": "Manipulation", "title": "Print Sequence (sequence)", "description": "Print amino acid or nucleic acid sequence.", "syntax": "sequence [<mask>] [<topology tag>]"},
|
| 102 |
+
"setvelocity": {"category": "Manipulation", "title": "Set Velocities (setvelocity)", "description": "Assign velocities from Maxwell-Boltzmann distribution.", "syntax": "setvelocity [<mask>] [temp <T>] [rseed <seed>]"},
|
| 103 |
+
"solvent": {"category": "Manipulation", "title": "Define Solvent (solvent)", "description": "Define solvent molecules in topology.", "syntax": "solvent [<mask>] [<topology tag>]"},
|
| 104 |
+
"splitcoords": {"category": "Manipulation", "title": "Split COORDS (splitcoords)", "description": "Split COORDS set into separate sets by frame.", "syntax": "splitcoords <crdset> [<range>] name <prefix>"},
|
| 105 |
+
"updateparameters": {"category": "Manipulation", "title": "Update Parameters (updateparameters)", "description": "Update force field parameters in topology.", "syntax": "updateparameters {<bond_args>|<angle_args>|<dih_args>}"},
|
| 106 |
+
"bondparminfo": {"category": "Manipulation", "title": "Bond Parameter Info (bondparminfo)", "description": "Print bond parameter information.", "syntax": "bondparminfo [<mask>] [<topology tag>]"},
|
| 107 |
+
# ββ Analysis βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 108 |
+
"rmsd": {"category": "Analysis", "title": "RMSD (rmsd)", "description": "Calculate frame-by-frame RMSD of atoms relative to a reference. Use @CA,C,N,O for backbone. Most common MD analysis.", "syntax": "rmsd [<name>] [<mask>] [ref <tag>|first|reference] [out <file>] [nofit] [mass] [perres]"},
|
| 109 |
+
"atomicfluct": {"category": "Analysis", "title": "RMSF (atomicfluct)", "description": "Per-atom or per-residue root mean square fluctuation (B-factors). Use byres for per-residue.", "syntax": "atomicfluct [<name>] [<mask>] [out <file>] [byres] [byatom] [bfactor]"},
|
| 110 |
+
"radgyr": {"category": "Analysis", "title": "Radius of Gyration (radgyr)", "description": "Calculate radius of gyration β measures compactness. Always use mass keyword.", "syntax": "radgyr [<name>] [<mask>] [out <file>] [mass] [tensor]"},
|
| 111 |
+
"hbond": {"category": "Analysis", "title": "Hydrogen Bonds (hbond)", "description": "Detect and track hydrogen bonds. Default: dist β€ 3.5 Γ
, angle β₯ 135Β°. Use avgout for statistics.", "syntax": "hbond [<name>] [<mask>] [out <file>] [avgout <file>] [dist <A>] [angle <deg>] [series]"},
|
| 112 |
+
"secstruct": {"category": "Analysis", "title": "Secondary Structure (secstruct)", "description": "Assign secondary structure using DSSP algorithm. H=helix, E=strand, T=turn, C=coil.", "syntax": "secstruct [<name>] [<mask>] [out <file>] [sumout <file>]"},
|
| 113 |
+
"dssp": {"category": "Analysis", "title": "DSSP Secondary Structure (dssp)", "description": "DSSP secondary structure assignment β alias for secstruct.", "syntax": "dssp [<name>] [<mask>] [out <file>] [sumout <file>]"},
|
| 114 |
+
"cluster": {"category": "Analysis", "title": "Clustering (cluster)", "description": "Cluster trajectory frames by structural similarity. Use sieve for large trajectories.", "syntax": "cluster [<name>] [<mask>] [hieragglo|kmeans|dbscan] [epsilon <val>] [clusters <N>] [out <file>] [summary <file>] [repout <prefix>] [repfmt pdb]"},
|
| 115 |
+
"distance": {"category": "Analysis", "title": "Distance (distance)", "description": "Calculate distance between two atom masks (center-of-mass by default).", "syntax": "distance [<name>] <mask1> <mask2> [out <file>] [noimage] [geom]"},
|
| 116 |
+
"angle": {"category": "Analysis", "title": "Angle (angle)", "description": "Calculate angle between three atoms or groups. mask2 is the vertex.", "syntax": "angle [<name>] <mask1> <mask2> <mask3> [out <file>]"},
|
| 117 |
+
"dihedral": {"category": "Analysis", "title": "Dihedral (dihedral)", "description": "Calculate dihedral (torsion) angle from four atoms. Output in β180 to +180 degrees.", "syntax": "dihedral [<name>] <mask1> <mask2> <mask3> <mask4> [out <file>]"},
|
| 118 |
+
"multidihedral": {"category": "Analysis", "title": "Backbone Dihedrals (multidihedral)", "description": "Calculate phi, psi, omega, chi1-chi4 for all or selected residues.", "syntax": "multidihedral [phi] [psi] [omega] [chin] [<mask>] [out <file>]"},
|
| 119 |
+
"phipsi": {"category": "Analysis", "title": "Phi/Psi Ramachandran (phipsi)", "description": "Calculate Ramachandran phi/psi angles for residues.", "syntax": "phipsi [<mask>] [out <file>] [name <name>] [resrange <range>]"},
|
| 120 |
+
"surf": {"category": "Analysis", "title": "SASA (surf)", "description": "Calculate solvent-accessible surface area using LCPO algorithm. 1.4 Γ
probe.", "syntax": "surf [<name>] [<mask>] [out <file>] [solvradius <val>]"},
|
| 121 |
+
"molsurf": {"category": "Analysis", "title": "MSMS SASA (molsurf)", "description": "MSMS/molsurf solvent accessible surface area.", "syntax": "molsurf [<name>] [<mask>] [out <file>] [probe <r>]"},
|
| 122 |
+
"nativecontacts": {"category": "Analysis", "title": "Native Contacts (nativecontacts)", "description": "Calculate fraction of native contacts (Q-value) relative to a reference structure.", "syntax": "nativecontacts [<name>] [<mask>] [ref <tag>|reference] [out <file>] [distance <cutoff>]"},
|
| 123 |
+
"contacts": {"category": "Analysis", "title": "Contacts (contacts)", "description": "Calculate number of contacts. Legacy command β prefer nativecontacts.", "syntax": "contacts [first|reference|ref <ref>] [byresidue] [out <file>] [<mask>]"},
|
| 124 |
+
"density": {"category": "Analysis", "title": "Density Profile (density)", "description": "Calculate number or mass density along an axis. Useful for membrane systems.", "syntax": "density [<name>] [<mask>] [out <file>] [x|y|z] [delta <dx>] [number|mass|electron]"},
|
| 125 |
+
"diffusion": {"category": "Analysis", "title": "Diffusion / MSD (diffusion)", "description": "Calculate mean square displacement and diffusion coefficient. D = slope of MSD / 6.", "syntax": "diffusion [<name>] [<mask>] [out <file>] [time <dt>] [diffout <file>]"},
|
| 126 |
+
"stfcdiffusion": {"category": "Analysis", "title": "STFC Diffusion (stfcdiffusion)", "description": "Diffusion using STFC method for charged particles.", "syntax": "stfcdiffusion [<mask>] [out <file>] [time <dt>] [x|y|z|xy|xz|yz|xyz]"},
|
| 127 |
+
"calcdiffusion": {"category": "Analysis", "title": "Calc Diffusion Coefficient (calcdiffusion)", "description": "Calculate diffusion coefficient from MSD data set.", "syntax": "calcdiffusion <msd_set> [out <file>] [time <ts>]"},
|
| 128 |
+
"watershell": {"category": "Analysis", "title": "Water Shell (watershell)", "description": "Count water molecules in first and second solvation shells around a solute.", "syntax": "watershell [<name>] <mask> [out <file>] [lower <A>] [upper <A>]"},
|
| 129 |
+
"radial": {"category": "Analysis", "title": "Radial Distribution Function (radial)", "description": "Calculate radial distribution function (RDF) g(r).", "syntax": "radial [out <file>] <spacing> <maximum> <solvent_mask> [<solute_mask>] [noimage]"},
|
| 130 |
+
"volmap": {"category": "Analysis", "title": "Volumetric Map (volmap)", "description": "Generate 3D volumetric density map (.dx file, viewable in VMD).", "syntax": "volmap <filename> [<mask>] [size <dx> <dy> <dz>] [center <mask>]"},
|
| 131 |
+
"grid": {"category": "Analysis", "title": "3D Density Grid (grid)", "description": "Calculate 3D density grid.", "syntax": "grid <filename> <dx> <dy> <dz> [origin] [<mask>] [box]"},
|
| 132 |
+
"pucker": {"category": "Analysis", "title": "Ring Pucker (pucker)", "description": "Calculate Cremer-Pople ring pucker parameters for sugars/nucleic acids.", "syntax": "pucker [<name>] <m1> <m2> <m3> <m4> <m5> [<m6>] [out <file>] [amplitude] [theta]"},
|
| 133 |
+
"multipucker": {"category": "Analysis", "title": "Multi Ring Pucker (multipucker)", "description": "Calculate ring pucker for multiple residues.", "syntax": "multipucker [<mask>] [out <file>] [amplitude] [theta]"},
|
| 134 |
+
"matrix": {"category": "Analysis", "title": "Covariance Matrix (matrix)", "description": "Build covariance or correlation matrix β first step for PCA.", "syntax": "matrix covar [<name>] [<mask>] [out <file>]"},
|
| 135 |
+
"diagmatrix": {"category": "Analysis", "title": "Diagonalize Matrix (diagmatrix)", "description": "Diagonalize a matrix to get eigenvalues and eigenvectors.", "syntax": "diagmatrix <matrixset> [out <evecfile>] [vecs <N>] [reduce] [mass <mask>]"},
|
| 136 |
+
"projection": {"category": "Analysis", "title": "PCA Projection (projection)", "description": "Project trajectory onto eigenvectors from matrix/analyze modes for PCA.", "syntax": "projection [<name>] evecvecs <data> [<mask>] [out <file>] [beg <n>] [end <n>]"},
|
| 137 |
+
"modes": {"category": "Analysis", "title": "Normal Modes (modes)", "description": "Analyze normal modes from diagonalized matrix: fluct, displ, corr, eigenval, trajout.", "syntax": "modes {fluct|displ|corr|eigenval|trajout} name <modesname> [beg <b>] [end <e>] [out <file>]"},
|
| 138 |
+
"tica": {"category": "Analysis", "title": "TICA (tica)", "description": "Time-lagged independent component analysis.", "syntax": "tica {crdset <COORDS>|data <sets>} [lag <lag>] [nvecs <N>] [out <file>]"},
|
| 139 |
+
"atomiccorr": {"category": "Analysis", "title": "Atomic Correlation (atomiccorr)", "description": "Atomic correlation matrix between atom displacements.", "syntax": "atomiccorr [out <file>] [cut <cut>] [<mask>] [datasave <set>]"},
|
| 140 |
+
"rms2d": {"category": "Analysis", "title": "Pairwise RMSD Matrix (rms2d)", "description": "Pairwise RMSD matrix between all frame pairs.", "syntax": "rms2d [<name>] [<mask>] [out <file>] [mass] [nofit] [reftraj <traj>]"},
|
| 141 |
+
"rmsavgcorr": {"category": "Analysis", "title": "RMSD Running Average Correlation (rmsavgcorr)", "description": "Correlation of running-average RMSD vs window size.", "syntax": "rmsavgcorr [<mask>] [out <file>] [mass]"},
|
| 142 |
+
"symmrmsd": {"category": "Analysis", "title": "Symmetric RMSD (symmrmsd)", "description": "RMSD with symmetry correction for equivalent atoms.", "syntax": "symmrmsd [<name>] [<mask>] [ref <ref>|first] [out <file>] [remap]"},
|
| 143 |
+
"dihedralrms": {"category": "Analysis", "title": "Dihedral RMSD (dihedralrms)", "description": "RMSD of dihedral angles between frames.", "syntax": "dihedralrms [<mask>] [out <file>] [mass] [nofit]"},
|
| 144 |
+
"clusterdihedral": {"category": "Analysis", "title": "Dihedral Clustering (clusterdihedral)", "description": "Cluster by dihedral angles.", "syntax": "clusterdihedral [<mask>] [out <file>] [clusterout <prefix>] [...dihedrals...]"},
|
| 145 |
+
"average": {"category": "Analysis", "title": "Average Structure (average)", "description": "Compute average structure over trajectory frames.", "syntax": "average [<name>] <filename> [<fmt>] [<mask>] [start <s>] [stop <e>] [offset <o>]"},
|
| 146 |
+
"avgcoord": {"category": "Analysis", "title": "Average Coordinates (avgcoord)", "description": "Average coordinates for each atom over trajectory.", "syntax": "avgcoord [<name>] [<mask>] [out <file>]"},
|
| 147 |
+
"avgbox": {"category": "Analysis", "title": "Average Box (avgbox)", "description": "Compute average box dimensions over trajectory.", "syntax": "avgbox [<name>] [out <file>]"},
|
| 148 |
+
"bounds": {"category": "Analysis", "title": "Bounding Box (bounds)", "description": "Calculate bounding box around atoms.", "syntax": "bounds [<name>] [<mask>] [out <file>] [dx <dx>] [offset <offset>]"},
|
| 149 |
+
"principal": {"category": "Analysis", "title": "Principal Axes (principal)", "description": "Calculate principal axes and moments of inertia.", "syntax": "principal [<name>] [<mask>] [out <file>] [dorotation] [mass]"},
|
| 150 |
+
"dipole": {"category": "Analysis", "title": "Dipole Moment (dipole)", "description": "Calculate dipole moment of selection.", "syntax": "dipole [<name>] [<mask>] [out <file>] [<grid_options>]"},
|
| 151 |
+
"volume": {"category": "Analysis", "title": "Unit Cell Volume (volume)", "description": "Calculate unit cell volume over trajectory.", "syntax": "volume [<name>] [out <file>]"},
|
| 152 |
+
"temperature": {"category": "Analysis", "title": "Temperature (temperature)", "description": "Calculate instantaneous temperature from velocities.", "syntax": "temperature [<name>] [<mask>] [out <file>] [frame]"},
|
| 153 |
+
"energy": {"category": "Analysis", "title": "Energy (energy)", "description": "Calculate energy using internal force field (bond, angle, dihedral, VdW, electrostatic).", "syntax": "energy [<mask>] [out <file>] [bond] [angle] [dih] [vdw] [elec]"},
|
| 154 |
+
"esander": {"category": "Analysis", "title": "Energy via Sander (esander)", "description": "Calculate energy using sander AMBER engine.", "syntax": "esander [<mask>] [out <file>] [igb <igb>] [cut <cut>]"},
|
| 155 |
+
"enedecomp": {"category": "Analysis", "title": "Energy Decomposition (enedecomp)", "description": "Energy decomposition per residue.", "syntax": "enedecomp [<mask>] [out <file>] [cut <cut>]"},
|
| 156 |
+
"pairwise": {"category": "Analysis", "title": "Pairwise Energy (pairwise)", "description": "Pairwise energy decomposition between residues.", "syntax": "pairwise [<mask>] [out <file>] [cut <cut>] [cuteelec <c>] [cutevdw <c>]"},
|
| 157 |
+
"lie": {"category": "Analysis", "title": "Linear Interaction Energy (lie)", "description": "Linear interaction energy calculation.", "syntax": "lie <mask1> [<mask2>] [out <file>] [elec <scale>] [vdw <scale>]"},
|
| 158 |
+
"ti": {"category": "Analysis", "title": "Thermodynamic Integration (ti)", "description": "Thermodynamic integration (TI) free energy calculation.", "syntax": "ti <dset0> [<dset1>...] {nq <n>|xvals <x>} [out <file>] [name <name>]"},
|
| 159 |
+
"spam": {"category": "Analysis", "title": "SPAM (spam)", "description": "Solvation parameters from analysis of MD.", "syntax": "spam <site_file> [out <file>] [name <name>] [DG <dg>]"},
|
| 160 |
+
"nastruct": {"category": "Analysis", "title": "Nucleic Acid Structure (nastruct)", "description": "Nucleic acid structure parameters: base pairs, helical parameters.", "syntax": "nastruct [<name>] [resrange <range>] [naout <suffix>] [sscalc] [noheader]"},
|
| 161 |
+
"jcoupling": {"category": "Analysis", "title": "J-coupling (jcoupling)", "description": "Calculate J-coupling constants from dihedral angles using Karplus equation.", "syntax": "jcoupling [<mask>] [kfile <karplus_file>] [out <file>]"},
|
| 162 |
+
"ired": {"category": "Analysis", "title": "iRED NMR (ired)", "description": "iRED analysis of NMR order parameters.", "syntax": "ired [relax freq <MHz>] [order <o>] [orderparamfile <f>] [tstep <t>] [tcorr <t>] [out <f>]"},
|
| 163 |
+
"rotdif": {"category": "Analysis", "title": "Rotational Diffusion (rotdif)", "description": "Rotational diffusion analysis from NMR relaxation.", "syntax": "rotdif [out <file>] [rvecin <file>] [rseed <seed>] [nvecs <N>]"},
|
| 164 |
+
"timecorr": {"category": "Analysis", "title": "Time Correlation (timecorr)", "description": "Time correlation function of vectors.", "syntax": "timecorr vec1 <set> [vec2 <set>] [out <file>] [tstep <t>] [tcorr <t>]"},
|
| 165 |
+
"vector": {"category": "Analysis", "title": "Vector (vector)", "description": "Calculate a vector between two masks over time.", "syntax": "vector [<name>] <mask1> <mask2> [out <file>] [ired]"},
|
| 166 |
+
"multivector": {"category": "Analysis", "title": "Multi-vector (multivector)", "description": "Calculate vectors for multiple residue pairs.", "syntax": "multivector [<mask>] [out <file>] [ired]"},
|
| 167 |
+
"vectormath": {"category": "Analysis", "title": "Vector Math (vectormath)", "description": "Math operations on vector data sets: dot product, cross product, etc.", "syntax": "vectormath vec1 <set> [vec2 <set>] {dotproduct|crossproduct|...} [out <file>]"},
|
| 168 |
+
"velocityautocorr": {"category": "Analysis", "title": "Velocity Autocorrelation (velocityautocorr)", "description": "Velocity autocorrelation function (VACF).", "syntax": "velocityautocorr [<mask>] [out <file>] [tstep <t>] [maxlag <m>] [norm]"},
|
| 169 |
+
"lipidorder": {"category": "Analysis", "title": "Lipid Order Parameters (lipidorder)", "description": "Calculate lipid tail order parameters (Scd) for membrane systems.", "syntax": "lipidorder [<mask>] [out <file>] [scd] [unsat]"},
|
| 170 |
+
"lipidscd": {"category": "Analysis", "title": "Lipid Scd (lipidscd)", "description": "Lipid Scd order parameter calculation.", "syntax": "lipidscd [<mask>] [out <file>]"},
|
| 171 |
+
"areapermol": {"category": "Analysis", "title": "Area per Molecule (areapermol)", "description": "Calculate area per molecule for lipid bilayers.", "syntax": "areapermol [<name>] [out <file>] [<mask>] [frame]"},
|
| 172 |
+
"mindist": {"category": "Analysis", "title": "Min/Max Distance (mindist)", "description": "Minimum and maximum distance between two masks.", "syntax": "mindist [<name>] <mask1> <mask2> [out <file>] [noimage]"},
|
| 173 |
+
"pairdist": {"category": "Analysis", "title": "Pairwise Distance (pairdist)", "description": "Pairwise distance histogram between all atom pairs.", "syntax": "pairdist [<name>] [<mask>] [out <file>] [delta <dx>] [max <max>]"},
|
| 174 |
+
"hausdorff": {"category": "Analysis", "title": "Hausdorff Distance (hausdorff)", "description": "Calculate Hausdorff distance between two masks.", "syntax": "hausdorff [<name>] <mask1> <mask2> [out <file>]"},
|
| 175 |
+
"tordiff": {"category": "Analysis", "title": "Torsion Difference (tordiff)", "description": "Torsion angle difference between two structures.", "syntax": "tordiff [<mask>] [out <file>] [ref <ref>]"},
|
| 176 |
+
"autocorr": {"category": "Analysis", "title": "Autocorrelation (autocorr)", "description": "Autocorrelation function of a data set.", "syntax": "autocorr <dataset> [out <file>] [lagmax <max>] [norm] [direct]"},
|
| 177 |
+
"crosscorr": {"category": "Analysis", "title": "Cross-correlation (crosscorr)", "description": "Cross-correlation between two data sets.", "syntax": "crosscorr <set1> <set2> [out <file>] [lagmax <max>] [norm] [direct]"},
|
| 178 |
+
"lifetime": {"category": "Analysis", "title": "Lifetime Analysis (lifetime)", "description": "Lifetime analysis of hydrogen bonds or contacts.", "syntax": "lifetime <dataset> [out <file>] [window <w>] [cut <cut>] [name <name>]"},
|
| 179 |
+
"runningavg": {"category": "Analysis", "title": "Running Average (runningavg)", "description": "Running average (sliding window) of a data set.", "syntax": "runningavg <dataset> [out <file>] [window <w>]"},
|
| 180 |
+
"integrate": {"category": "Analysis", "title": "Integrate (integrate)", "description": "Integrate a data set using the trapezoidal rule.", "syntax": "integrate <dataset> [out <file>]"},
|
| 181 |
+
"slope": {"category": "Analysis", "title": "Slope / Linear Fit (slope)", "description": "Calculate slope of a data set by linear fit.", "syntax": "slope <dataset> [out <file>]"},
|
| 182 |
+
"regress": {"category": "Analysis", "title": "Linear Regression (regress)", "description": "Linear regression of a data set.", "syntax": "regress <dataset> [out <file>] [results <file>]"},
|
| 183 |
+
"curvefit": {"category": "Analysis", "title": "Curve Fitting (curvefit)", "description": "Fit data to a functional form.", "syntax": "curvefit <function> <dataset> [out <file>] [results <file>] [nofit]"},
|
| 184 |
+
"kde": {"category": "Analysis", "title": "KDE (kde)", "description": "Kernel density estimation of a data set.", "syntax": "kde <dataset> [out <file>] [bandwidth <bw>] [bins <N>]"},
|
| 185 |
+
"fft": {"category": "Analysis", "title": "FFT (fft)", "description": "Fast Fourier Transform of a data set.", "syntax": "fft <dataset> [out <file>] [dt <timestep>] [fftout <file>]"},
|
| 186 |
+
"wavelet": {"category": "Analysis", "title": "Wavelet Analysis (wavelet)", "description": "Wavelet analysis of trajectory data.", "syntax": "wavelet [<mask>] [out <file>] [type <wavelet>] [nb <N>]"},
|
| 187 |
+
"filter": {"category": "Analysis", "title": "Filter Frames (filter)", "description": "Filter frames based on dataset value criteria.", "syntax": "filter <dataset> min <min> max <max>"},
|
| 188 |
+
"divergence": {"category": "Analysis", "title": "KL Divergence (divergence)", "description": "Calculate KL divergence between two distributions.", "syntax": "divergence <set1> <set2> [out <file>]"},
|
| 189 |
+
"lowestcurve": {"category": "Analysis", "title": "Lowest Free Energy Curve (lowestcurve)", "description": "Compute lowest free energy curve from 2D data.", "syntax": "lowestcurve <dataset> [out <file>] [step <s>]"},
|
| 190 |
+
"meltcurve": {"category": "Analysis", "title": "Melting Curve (meltcurve)", "description": "Generate melting curve from temperature-dependent data.", "syntax": "meltcurve <dataset> [out <file>] [norm]"},
|
| 191 |
+
"multicurve": {"category": "Analysis", "title": "Multi-curve Fit (multicurve)", "description": "Fit multiple exponential curves to data.", "syntax": "multicurve [<dataset>] [out <file>] [nexp <N>]"},
|
| 192 |
+
"multihist": {"category": "Analysis", "title": "Multi-histogram (multihist)", "description": "Histogram multiple data sets simultaneously.", "syntax": "multihist <set1> [<set2>...] [out <file>] [bins <N>]"},
|
| 193 |
+
"calcstate": {"category": "Analysis", "title": "Calculate State (calcstate)", "description": "Calculate state of system using HMM or thresholds.", "syntax": "calcstate [name <name>] [out <file>] <state_args>"},
|
| 194 |
+
"checkoverlap": {"category": "Analysis", "title": "Check Overlaps (checkoverlap)", "description": "Check for bad atomic overlaps/clashes.", "syntax": "check [<mask>] [cut <cut>] [noimage] [out <file>]"},
|
| 195 |
+
"cphstats": {"category": "Analysis", "title": "Constant-pH Stats (cphstats)", "description": "Analyze constant-pH simulation statistics.", "syntax": "cphstats <cpin> {<cpout> [<cpout2> ...]} [out <file>] [deprot <file>]"},
|
| 196 |
+
"remlog": {"category": "Analysis", "title": "REMD Log Analysis (remlog)", "description": "Analyze replica exchange log files.", "syntax": "remlog <remlogfile> [out <file>] [nstlim <N>] [temp0 <T>]"},
|
| 197 |
+
# ββ Reference ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
+
"mask_syntax": {"category": "Reference", "title": "Atom Mask Syntax", "description": "cpptraj atom selection: :resnum @atomname :resname ! & | < >. Examples: :1-100 @CA !:WAT :LIG<:5.0", "syntax": ":1-100 @CA !:WAT :LIG<:5.0 @CA,C,N,O"},
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
SCRIPT_TEMPLATES = {
|
| 202 |
+
"basic_rmsd": {
|
| 203 |
+
"title": "RMSD + RMSF + Rg",
|
| 204 |
+
"description": "Backbone RMSD, per-residue RMSF, radius of gyration",
|
| 205 |
+
"script": "parm topology.prmtop\ntrajin trajectory.nc\n\nautoimage\ncenter !:WAT origin\n\nrmsd backbone @CA,C,N,O first out rmsd.dat\natomicfluct rmsf @CA byres out rmsf.dat\nradgyr rg !:WAT mass out rg.dat\n\ngo\n",
|
| 206 |
+
},
|
| 207 |
+
"full_protein": {
|
| 208 |
+
"title": "Full Protein Analysis",
|
| 209 |
+
"description": "RMSD, RMSF, Rg, H-bonds, secondary structure",
|
| 210 |
+
"script": "parm topology.prmtop\ntrajin trajectory.nc\n\nautoimage\ncenter !:WAT origin\n\nrmsd bb_rmsd @CA,C,N,O first out rmsd.dat\natomicfluct rmsf @CA byres out rmsf.dat\nradgyr rg !:WAT mass out rg.dat\nhbond hbonds !:WAT out hbond.dat avgout hbond_avg.dat\nsecstruct ss out secstruct.dat sumout secstruct_sum.dat\n\ngo\n",
|
| 211 |
+
},
|
| 212 |
+
"clustering": {
|
| 213 |
+
"title": "Trajectory Clustering",
|
| 214 |
+
"description": "Hierarchical clustering + representative structures",
|
| 215 |
+
"script": "parm topology.prmtop\ntrajin trajectory.nc\n\nautoimage\nstrip :WAT,Na+,Cl-\n\ncluster clusters @CA hieragglo epsilon 2.0 sieve 10 out cluster_assign.dat summary cluster_sum.dat info cluster_info.dat repout cluster_rep repfmt pdb\n\ngo\n",
|
| 216 |
+
},
|
| 217 |
+
"pca": {
|
| 218 |
+
"title": "PCA",
|
| 219 |
+
"description": "Covariance matrix + projection onto first 3 modes",
|
| 220 |
+
"script": "parm topology.prmtop\ntrajin trajectory.nc\n\nautoimage\nalign @CA reference\n\nmatrix covar pca_mat @CA out covar.dat\nanalyze modes eigenvalues evectors pca_mat out pca_modes.dat\nprojection pca_proj evecvecs pca_mat @CA out pca_proj.dat beg 1 end 3\n\ngo\n",
|
| 221 |
+
},
|
| 222 |
+
"strip_solvent": {
|
| 223 |
+
"title": "Strip Solvent & Save",
|
| 224 |
+
"description": "Remove water/ions, write protein-only trajectory",
|
| 225 |
+
"script": "parm topology.prmtop\ntrajin trajectory.nc\n\nautoimage\nstrip :WAT,Na+,Cl-\n\ntrajout protein_traj.nc\n\ngo\n",
|
| 226 |
+
},
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 230 |
+
# PDF TEXT EXTRACTION + CHUNKING
|
| 231 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
+
|
| 233 |
+
# Section header patterns in the manual, e.g. "11.1 rmsd", "8.3 hbond"
|
| 234 |
+
_SECTION_RE = re.compile(
|
| 235 |
+
r'^(\d+\.\d+(?:\.\d+)?)\s+([a-zA-Z][a-zA-Z0-9_\-|/]{1,30})\s*$',
|
| 236 |
+
re.MULTILINE,
|
| 237 |
+
)
|
| 238 |
+
# Chapter headers like "8 General Commands", "11 Action Commands"
|
| 239 |
+
_CHAPTER_RE = re.compile(r'^(\d+)\s+([A-Z][A-Za-z ]{3,50})\s*$', re.MULTILINE)
|
| 240 |
+
|
| 241 |
+
_MIN_CHUNK_CHARS = 100
|
| 242 |
+
_MAX_CHUNK_CHARS = 6000
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _extract_pdf_text() -> list[dict]:
|
| 246 |
+
"""
|
| 247 |
+
Extract text from CpptrajManual.pdf and return a list of page dicts:
|
| 248 |
+
[{"page": int, "text": str}, ...]
|
| 249 |
+
Results are cached in cpptraj_manual_cache.json.
|
| 250 |
+
"""
|
| 251 |
+
if CACHE_PATH.exists():
|
| 252 |
+
with open(CACHE_PATH, encoding="utf-8") as f:
|
| 253 |
+
return json.load(f)
|
| 254 |
+
|
| 255 |
+
try:
|
| 256 |
+
import pdfplumber
|
| 257 |
+
except ImportError:
|
| 258 |
+
raise ImportError("pdfplumber is required to parse the manual: pip install pdfplumber")
|
| 259 |
+
|
| 260 |
+
print("[RAG] Extracting text from CpptrajManual.pdf (one-time, ~10 s)β¦")
|
| 261 |
+
pages = []
|
| 262 |
+
with pdfplumber.open(PDF_PATH) as pdf:
|
| 263 |
+
for i, page in enumerate(pdf.pages):
|
| 264 |
+
text = page.extract_text() or ""
|
| 265 |
+
pages.append({"page": i + 1, "text": text})
|
| 266 |
+
|
| 267 |
+
with open(CACHE_PATH, "w", encoding="utf-8") as f:
|
| 268 |
+
json.dump(pages, f, ensure_ascii=False)
|
| 269 |
+
|
| 270 |
+
print(f"[RAG] Extracted {len(pages)} pages, cached to {CACHE_PATH.name}")
|
| 271 |
+
return pages
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _chunk_manual(pages: list[dict]) -> list[dict]:
|
| 275 |
+
"""
|
| 276 |
+
Split the full manual text into semantic chunks.
|
| 277 |
+
|
| 278 |
+
Strategy:
|
| 279 |
+
- Detect section headers (e.g. "11.1 rmsd") as chunk boundaries.
|
| 280 |
+
- Each chunk = one command section (header + body until next header).
|
| 281 |
+
- Also add whole-page chunks for pages that don't fit the pattern.
|
| 282 |
+
- Merge tiny chunks with the previous one.
|
| 283 |
+
"""
|
| 284 |
+
# Concatenate all pages preserving page breaks
|
| 285 |
+
full_text = ""
|
| 286 |
+
page_offsets = [] # (start_char, page_num)
|
| 287 |
+
for p in pages:
|
| 288 |
+
page_offsets.append((len(full_text), p["page"]))
|
| 289 |
+
full_text += p["text"] + "\n\n"
|
| 290 |
+
|
| 291 |
+
def char_to_page(pos: int) -> int:
|
| 292 |
+
pg = 1
|
| 293 |
+
for start, pnum in page_offsets:
|
| 294 |
+
if start > pos:
|
| 295 |
+
break
|
| 296 |
+
pg = pnum
|
| 297 |
+
return pg
|
| 298 |
+
|
| 299 |
+
# Find all section boundaries
|
| 300 |
+
boundaries = []
|
| 301 |
+
for m in _SECTION_RE.finditer(full_text):
|
| 302 |
+
boundaries.append((m.start(), m.group(0).strip(), m.group(2).lower()))
|
| 303 |
+
# Also add chapter boundaries
|
| 304 |
+
for m in _CHAPTER_RE.finditer(full_text):
|
| 305 |
+
boundaries.append((m.start(), m.group(0).strip(), m.group(2).lower()))
|
| 306 |
+
|
| 307 |
+
boundaries.sort(key=lambda x: x[0])
|
| 308 |
+
|
| 309 |
+
chunks = []
|
| 310 |
+
for i, (pos, header, cmd_name) in enumerate(boundaries):
|
| 311 |
+
end = boundaries[i + 1][0] if i + 1 < len(boundaries) else len(full_text)
|
| 312 |
+
text = full_text[pos:end].strip()
|
| 313 |
+
|
| 314 |
+
if len(text) < _MIN_CHUNK_CHARS:
|
| 315 |
+
continue
|
| 316 |
+
|
| 317 |
+
# Trim very long chunks (take first MAX_CHUNK_CHARS)
|
| 318 |
+
if len(text) > _MAX_CHUNK_CHARS:
|
| 319 |
+
text = text[:_MAX_CHUNK_CHARS] + "\n⦠[truncated]"
|
| 320 |
+
|
| 321 |
+
chunks.append({
|
| 322 |
+
"id": f"manual_sec_{i}",
|
| 323 |
+
"header": header,
|
| 324 |
+
"cmd_name": cmd_name,
|
| 325 |
+
"text": text,
|
| 326 |
+
"page": char_to_page(pos),
|
| 327 |
+
"source": "CpptrajManual.pdf",
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
# Fallback: if very few chunks found, fall back to page-level chunking
|
| 331 |
+
if len(chunks) < 20:
|
| 332 |
+
print("[RAG] Section detection found few chunks β falling back to page-level chunking")
|
| 333 |
+
chunks = []
|
| 334 |
+
for p in pages:
|
| 335 |
+
if len(p["text"]) < _MIN_CHUNK_CHARS:
|
| 336 |
+
continue
|
| 337 |
+
chunks.append({
|
| 338 |
+
"id": f"page_{p['page']}",
|
| 339 |
+
"header": f"Page {p['page']}",
|
| 340 |
+
"cmd_name": "",
|
| 341 |
+
"text": p["text"][:_MAX_CHUNK_CHARS],
|
| 342 |
+
"page": p["page"],
|
| 343 |
+
"source": "CpptrajManual.pdf",
|
| 344 |
+
})
|
| 345 |
+
|
| 346 |
+
return chunks
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
+
# KNOWLEDGE BASE CLASS
|
| 351 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 352 |
+
|
| 353 |
+
class CPPTrajKnowledgeBase:
|
| 354 |
+
"""
|
| 355 |
+
RAG over the real CpptrajManual.pdf using TF-IDF retrieval.
|
| 356 |
+
Falls back gracefully if the PDF is not found.
|
| 357 |
+
"""
|
| 358 |
+
|
| 359 |
+
def __init__(self):
|
| 360 |
+
self._chunks: list[dict] = []
|
| 361 |
+
self._texts: list[str] = []
|
| 362 |
+
self.vectorizer: TfidfVectorizer | None = None
|
| 363 |
+
self.tfidf_matrix = None
|
| 364 |
+
self._pdf_available = False
|
| 365 |
+
|
| 366 |
+
self._load()
|
| 367 |
+
|
| 368 |
+
def _load(self):
|
| 369 |
+
if not PDF_PATH.exists():
|
| 370 |
+
print(f"[RAG] Warning: {PDF_PATH} not found β using built-in command docs only.")
|
| 371 |
+
self._build_fallback_index()
|
| 372 |
+
return
|
| 373 |
+
|
| 374 |
+
try:
|
| 375 |
+
pages = _extract_pdf_text()
|
| 376 |
+
chunks = _chunk_manual(pages)
|
| 377 |
+
if not chunks:
|
| 378 |
+
self._build_fallback_index()
|
| 379 |
+
return
|
| 380 |
+
|
| 381 |
+
self._chunks = chunks
|
| 382 |
+
self._texts = [c["text"] for c in chunks]
|
| 383 |
+
self._pdf_available = True
|
| 384 |
+
print(f"[RAG] Loaded {len(chunks)} chunks from manual (pages 1β{pages[-1]['page']})")
|
| 385 |
+
except Exception as e:
|
| 386 |
+
print(f"[RAG] PDF load error: {e} β using built-in docs.")
|
| 387 |
+
self._build_fallback_index()
|
| 388 |
+
return
|
| 389 |
+
|
| 390 |
+
self._build_tfidf()
|
| 391 |
+
|
| 392 |
+
def _build_fallback_index(self):
|
| 393 |
+
"""Build a minimal TF-IDF index from the built-in CPPTRAJ_COMMANDS."""
|
| 394 |
+
for k, doc in CPPTRAJ_COMMANDS.items():
|
| 395 |
+
text = f"{doc['title']} {doc['description']} {doc['syntax']} {k}"
|
| 396 |
+
self._chunks.append({"id": k, "header": doc["title"], "cmd_name": k,
|
| 397 |
+
"text": text, "page": 0, "source": "built-in"})
|
| 398 |
+
self._texts.append(text)
|
| 399 |
+
self._build_tfidf()
|
| 400 |
+
|
| 401 |
+
def _build_tfidf(self):
|
| 402 |
+
self.vectorizer = TfidfVectorizer(
|
| 403 |
+
ngram_range=(1, 2),
|
| 404 |
+
stop_words="english",
|
| 405 |
+
min_df=1,
|
| 406 |
+
max_features=50_000,
|
| 407 |
+
)
|
| 408 |
+
self.tfidf_matrix = self.vectorizer.fit_transform(self._texts)
|
| 409 |
+
|
| 410 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
+
|
| 412 |
+
def retrieve(self, query: str, top_k: int = 6) -> list[dict]:
|
| 413 |
+
"""Return top-k most relevant chunks for a query."""
|
| 414 |
+
if self.vectorizer is None:
|
| 415 |
+
return []
|
| 416 |
+
q_vec = self.vectorizer.transform([query])
|
| 417 |
+
scores = cosine_similarity(q_vec, self.tfidf_matrix).flatten()
|
| 418 |
+
top_idx = np.argsort(scores)[::-1][:top_k]
|
| 419 |
+
return [
|
| 420 |
+
{"chunk": self._chunks[i], "score": float(scores[i])}
|
| 421 |
+
for i in top_idx if scores[i] > 0.0
|
| 422 |
+
]
|
| 423 |
+
|
| 424 |
+
def get_context_for_llm(self, query: str, top_k: int = 6) -> str:
|
| 425 |
+
"""
|
| 426 |
+
Return a formatted block of the most relevant manual sections
|
| 427 |
+
to inject as context into Claude's prompt.
|
| 428 |
+
"""
|
| 429 |
+
results = self.retrieve(query, top_k=top_k)
|
| 430 |
+
if not results:
|
| 431 |
+
return "No relevant documentation found."
|
| 432 |
+
|
| 433 |
+
lines = [
|
| 434 |
+
"=== CPPTRAJ MANUAL β RELEVANT SECTIONS ===",
|
| 435 |
+
f"(Extracted from CpptrajManual.pdf, {len(self._chunks)} total sections indexed)\n",
|
| 436 |
+
]
|
| 437 |
+
for r in results:
|
| 438 |
+
c = r["chunk"]
|
| 439 |
+
pg = f"p.{c['page']}" if c["page"] else c["source"]
|
| 440 |
+
lines.append(f"--- {c['header']} [{pg} relevance:{r['score']:.2f}] ---")
|
| 441 |
+
lines.append(c["text"])
|
| 442 |
+
lines.append("")
|
| 443 |
+
|
| 444 |
+
return "\n".join(lines)
|
| 445 |
+
|
| 446 |
+
def get_all_commands(self) -> dict: return CPPTRAJ_COMMANDS
|
| 447 |
+
def get_command(self, key) -> dict | None: return CPPTRAJ_COMMANDS.get(key)
|
| 448 |
+
def get_categories(self) -> list: return sorted(set(d["category"] for d in CPPTRAJ_COMMANDS.values()))
|
| 449 |
+
def get_by_category(self, cat)-> dict: return {k: v for k, v in CPPTRAJ_COMMANDS.items() if v["category"] == cat}
|
| 450 |
+
def get_script_templates(self)-> dict: return SCRIPT_TEMPLATES
|
| 451 |
+
|
| 452 |
+
@property
|
| 453 |
+
def pdf_available(self) -> bool:
|
| 454 |
+
return self._pdf_available
|
| 455 |
+
|
| 456 |
+
@property
|
| 457 |
+
def n_chunks(self) -> int:
|
| 458 |
+
return len(self._chunks)
|
core/llm_backends.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unified LLM backend β Claude, OpenAI, Gemini.
|
| 3 |
+
All three support reliable function calling / tool use.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
from abc import ABC, abstractmethod
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _extract_text_tool_calls(text: str) -> list[dict]:
|
| 15 |
+
"""Fallback: parse tool calls that a model printed as JSON text instead of using native calling."""
|
| 16 |
+
calls = []
|
| 17 |
+
# Match {"name": "...", "arguments": {...}} or {"name": "...", "input": {...}}
|
| 18 |
+
pattern = r'\{[\s\S]*?"name"\s*:\s*"([^"]+)"[\s\S]*?\}'
|
| 19 |
+
for m in re.finditer(pattern, text):
|
| 20 |
+
try:
|
| 21 |
+
obj = json.loads(m.group(0))
|
| 22 |
+
name = obj.get("name")
|
| 23 |
+
inp = obj.get("arguments") or obj.get("input") or obj.get("parameters") or {}
|
| 24 |
+
if name and isinstance(inp, dict):
|
| 25 |
+
calls.append({"id": f"text_{len(calls)}", "name": name, "input": inp})
|
| 26 |
+
except Exception:
|
| 27 |
+
continue
|
| 28 |
+
return calls
|
| 29 |
+
|
| 30 |
+
PROVIDER_DEFAULTS = {
|
| 31 |
+
"claude": {
|
| 32 |
+
"default_model": "claude-haiku-4-5-20251001",
|
| 33 |
+
"label": "Anthropic Claude",
|
| 34 |
+
"models": ["claude-haiku-4-5-20251001", "claude-sonnet-4-6", "claude-opus-4-6"],
|
| 35 |
+
},
|
| 36 |
+
"openai": {
|
| 37 |
+
"default_model": "gpt-4o-mini",
|
| 38 |
+
"label": "OpenAI",
|
| 39 |
+
"models": ["gpt-4o-mini"],
|
| 40 |
+
},
|
| 41 |
+
"gemini": {
|
| 42 |
+
"default_model": "gemini-2.5-flash",
|
| 43 |
+
"label": "Google Gemini",
|
| 44 |
+
"models": ["gemini-2.5-flash"],
|
| 45 |
+
},
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class LLMBackend(ABC):
|
| 50 |
+
@abstractmethod
|
| 51 |
+
def chat(self, messages, tools, system) -> tuple[str, list[dict], bool]: ...
|
| 52 |
+
|
| 53 |
+
@abstractmethod
|
| 54 |
+
def stream_chat(self, messages, tools, system):
|
| 55 |
+
"""Yields ('text', chunk), then ('tool_calls', list), then ('stop_reason', str)."""
|
| 56 |
+
...
|
| 57 |
+
|
| 58 |
+
@abstractmethod
|
| 59 |
+
def make_assistant_message(self, text: str, tool_calls: list[dict]) -> dict: ...
|
| 60 |
+
|
| 61 |
+
@abstractmethod
|
| 62 |
+
def make_tool_result_message(self, tool_calls: list[dict], results: list[str]) -> dict: ...
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
@abstractmethod
|
| 66 |
+
def provider(self) -> str: ...
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
@abstractmethod
|
| 70 |
+
def model(self) -> str: ...
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class ClaudeBackend(LLMBackend):
|
| 74 |
+
def __init__(self, api_key: str, model: str = "claude-haiku-4-5-20251001"):
|
| 75 |
+
import anthropic
|
| 76 |
+
self._model = model
|
| 77 |
+
# Pass api_key explicitly; use a dummy if empty to prevent SDK env-var fallback
|
| 78 |
+
self._client = anthropic.Anthropic(api_key=api_key or "no-key")
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def provider(self): return "claude"
|
| 82 |
+
@property
|
| 83 |
+
def model(self): return self._model
|
| 84 |
+
|
| 85 |
+
def chat(self, messages, tools, system):
|
| 86 |
+
response = self._client.messages.create(
|
| 87 |
+
model=self._model, max_tokens=4096,
|
| 88 |
+
system=system, tools=self._claude_tools(tools), messages=messages,
|
| 89 |
+
)
|
| 90 |
+
text_parts, tool_calls = [], []
|
| 91 |
+
for block in response.content:
|
| 92 |
+
if block.type == "text":
|
| 93 |
+
text_parts.append(block.text)
|
| 94 |
+
elif block.type == "tool_use":
|
| 95 |
+
tool_calls.append({"id": block.id, "name": block.name, "input": block.input})
|
| 96 |
+
return "\n".join(text_parts), tool_calls, response.stop_reason == "tool_use"
|
| 97 |
+
|
| 98 |
+
def _claude_tools(self, tools):
|
| 99 |
+
out = []
|
| 100 |
+
for t in tools:
|
| 101 |
+
if "input_schema" in t:
|
| 102 |
+
out.append(t)
|
| 103 |
+
else:
|
| 104 |
+
fn = t.get("function", t)
|
| 105 |
+
out.append({"name": fn["name"], "description": fn.get("description", ""),
|
| 106 |
+
"input_schema": fn.get("parameters", {"type": "object", "properties": {}})})
|
| 107 |
+
return out
|
| 108 |
+
|
| 109 |
+
def stream_chat(self, messages, tools, system):
|
| 110 |
+
claude_tools = self._claude_tools(tools)
|
| 111 |
+
with self._client.messages.stream(
|
| 112 |
+
model=self._model, max_tokens=4096,
|
| 113 |
+
system=system, tools=claude_tools, messages=messages,
|
| 114 |
+
) as stream:
|
| 115 |
+
for text in stream.text_stream:
|
| 116 |
+
yield ("text", text)
|
| 117 |
+
final = stream.get_final_message()
|
| 118 |
+
tool_calls = [
|
| 119 |
+
{"id": b.id, "name": b.name, "input": b.input}
|
| 120 |
+
for b in final.content if b.type == "tool_use"
|
| 121 |
+
]
|
| 122 |
+
yield ("tool_calls", tool_calls)
|
| 123 |
+
yield ("stop_reason", final.stop_reason)
|
| 124 |
+
|
| 125 |
+
def make_assistant_message(self, text, tool_calls):
|
| 126 |
+
content = []
|
| 127 |
+
if text: content.append({"type": "text", "text": text})
|
| 128 |
+
for tc in tool_calls:
|
| 129 |
+
content.append({"type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc["input"]})
|
| 130 |
+
return {"role": "assistant", "content": content}
|
| 131 |
+
|
| 132 |
+
def make_tool_result_message(self, tool_calls, results):
|
| 133 |
+
return {"role": "user", "content": [
|
| 134 |
+
{"type": "tool_result", "tool_use_id": tc["id"], "content": r}
|
| 135 |
+
for tc, r in zip(tool_calls, results)
|
| 136 |
+
]}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class OpenAICompatBackend(LLMBackend):
|
| 140 |
+
def __init__(self, api_key: str, model: str, base_url: str, provider_name: str):
|
| 141 |
+
from openai import OpenAI
|
| 142 |
+
self._provider = provider_name
|
| 143 |
+
self._model = model
|
| 144 |
+
self._client = OpenAI(api_key=api_key or "no-key", base_url=base_url)
|
| 145 |
+
|
| 146 |
+
@property
|
| 147 |
+
def provider(self): return self._provider
|
| 148 |
+
@property
|
| 149 |
+
def model(self): return self._model
|
| 150 |
+
|
| 151 |
+
def _oai_tools(self, tools):
|
| 152 |
+
out = []
|
| 153 |
+
for t in tools:
|
| 154 |
+
if "function" in t:
|
| 155 |
+
out.append(t)
|
| 156 |
+
else:
|
| 157 |
+
out.append({"type": "function", "function": {
|
| 158 |
+
"name": t["name"],
|
| 159 |
+
"description": t.get("description", ""),
|
| 160 |
+
"parameters": t.get("input_schema", {"type": "object", "properties": {}}),
|
| 161 |
+
}})
|
| 162 |
+
return out
|
| 163 |
+
|
| 164 |
+
def stream_chat(self, messages, tools, system):
|
| 165 |
+
oai_tools = self._oai_tools(tools)
|
| 166 |
+
full_messages = [{"role": "system", "content": system}] + messages
|
| 167 |
+
kwargs: dict[str, Any] = dict(model=self._model, messages=full_messages, max_tokens=4096, stream=True)
|
| 168 |
+
if oai_tools:
|
| 169 |
+
kwargs["tools"] = oai_tools
|
| 170 |
+
response = self._client.chat.completions.create(**kwargs)
|
| 171 |
+
tc_acc: dict[int, dict] = {}
|
| 172 |
+
text_chunks: list[str] = []
|
| 173 |
+
finish_reason = "stop"
|
| 174 |
+
for chunk in response:
|
| 175 |
+
choice = chunk.choices[0]
|
| 176 |
+
finish_reason = choice.finish_reason or finish_reason
|
| 177 |
+
if choice.delta.content:
|
| 178 |
+
text_chunks.append(choice.delta.content)
|
| 179 |
+
yield ("text", choice.delta.content)
|
| 180 |
+
if choice.delta.tool_calls:
|
| 181 |
+
for tc in choice.delta.tool_calls:
|
| 182 |
+
idx = tc.index
|
| 183 |
+
if idx not in tc_acc:
|
| 184 |
+
tc_acc[idx] = {"id": "", "name": "", "arguments": ""}
|
| 185 |
+
if tc.id: tc_acc[idx]["id"] = tc.id
|
| 186 |
+
if tc.function and tc.function.name: tc_acc[idx]["name"] = tc.function.name
|
| 187 |
+
if tc.function and tc.function.arguments: tc_acc[idx]["arguments"] += tc.function.arguments
|
| 188 |
+
tool_calls = []
|
| 189 |
+
for idx in sorted(tc_acc):
|
| 190 |
+
tc = tc_acc[idx]
|
| 191 |
+
try: inp = json.loads(tc["arguments"])
|
| 192 |
+
except Exception: inp = {}
|
| 193 |
+
tool_calls.append({"id": tc["id"], "name": tc["name"], "input": inp})
|
| 194 |
+
|
| 195 |
+
yield ("tool_calls", tool_calls)
|
| 196 |
+
yield ("stop_reason", "tool_calls" if finish_reason == "tool_calls" else "end_turn")
|
| 197 |
+
|
| 198 |
+
def chat(self, messages, tools, system):
|
| 199 |
+
oai_tools = self._oai_tools(tools)
|
| 200 |
+
full_messages = [{"role": "system", "content": system}] + messages
|
| 201 |
+
kwargs: dict[str, Any] = dict(model=self._model, messages=full_messages, max_tokens=4096)
|
| 202 |
+
if oai_tools:
|
| 203 |
+
kwargs["tools"] = oai_tools
|
| 204 |
+
response = self._client.chat.completions.create(**kwargs)
|
| 205 |
+
choice = response.choices[0]
|
| 206 |
+
msg = choice.message
|
| 207 |
+
text = msg.content or ""
|
| 208 |
+
tool_calls = []
|
| 209 |
+
if msg.tool_calls:
|
| 210 |
+
for tc in msg.tool_calls:
|
| 211 |
+
try:
|
| 212 |
+
inp = json.loads(tc.function.arguments)
|
| 213 |
+
except Exception:
|
| 214 |
+
inp = {}
|
| 215 |
+
tool_calls.append({"id": tc.id, "name": tc.function.name, "input": inp})
|
| 216 |
+
|
| 217 |
+
# Fallback: model printed tool calls as text instead of using native calling
|
| 218 |
+
if not tool_calls and text:
|
| 219 |
+
tool_calls = _extract_text_tool_calls(text)
|
| 220 |
+
|
| 221 |
+
return text, tool_calls, choice.finish_reason == "tool_calls" or bool(tool_calls)
|
| 222 |
+
|
| 223 |
+
def make_assistant_message(self, text, tool_calls):
|
| 224 |
+
msg: dict[str, Any] = {"role": "assistant", "content": text or ""}
|
| 225 |
+
if tool_calls:
|
| 226 |
+
msg["tool_calls"] = [
|
| 227 |
+
{"id": tc["id"], "type": "function",
|
| 228 |
+
"function": {"name": tc["name"], "arguments": json.dumps(tc["input"])}}
|
| 229 |
+
for tc in tool_calls
|
| 230 |
+
]
|
| 231 |
+
return msg
|
| 232 |
+
|
| 233 |
+
def make_tool_result_message(self, tool_calls, results):
|
| 234 |
+
return {"_multi": [
|
| 235 |
+
{"role": "tool", "tool_call_id": tc["id"], "content": r}
|
| 236 |
+
for tc, r in zip(tool_calls, results)
|
| 237 |
+
]}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class OpenAIResponsesBackend(LLMBackend):
|
| 241 |
+
"""
|
| 242 |
+
Uses the OpenAI Responses API (client.responses.create).
|
| 243 |
+
Required for accounts that don't have Chat Completions access for newer models.
|
| 244 |
+
"""
|
| 245 |
+
|
| 246 |
+
def __init__(self, api_key: str, model: str, provider_name: str = "openai"):
|
| 247 |
+
from openai import OpenAI
|
| 248 |
+
self._provider = provider_name
|
| 249 |
+
self._model = model
|
| 250 |
+
self._client = OpenAI(api_key=api_key or "no-key")
|
| 251 |
+
|
| 252 |
+
@property
|
| 253 |
+
def provider(self): return self._provider
|
| 254 |
+
@property
|
| 255 |
+
def model(self): return self._model
|
| 256 |
+
|
| 257 |
+
def _resp_tools(self, tools):
|
| 258 |
+
out = []
|
| 259 |
+
for t in tools:
|
| 260 |
+
if "function" in t:
|
| 261 |
+
fn = t["function"]
|
| 262 |
+
out.append({"type": "function", "name": fn["name"],
|
| 263 |
+
"description": fn.get("description", ""),
|
| 264 |
+
"parameters": fn.get("parameters", {"type": "object", "properties": {}})})
|
| 265 |
+
else:
|
| 266 |
+
out.append({"type": "function", "name": t["name"],
|
| 267 |
+
"description": t.get("description", ""),
|
| 268 |
+
"parameters": t.get("input_schema", {"type": "object", "properties": {}})})
|
| 269 |
+
return out
|
| 270 |
+
|
| 271 |
+
def _to_input(self, messages: list) -> list:
|
| 272 |
+
"""Convert internal chat history to Responses API input items."""
|
| 273 |
+
items = []
|
| 274 |
+
for msg in messages:
|
| 275 |
+
role = msg.get("role", "")
|
| 276 |
+
|
| 277 |
+
# Tool results stored as _multi
|
| 278 |
+
if "_multi" in msg:
|
| 279 |
+
for tm in msg["_multi"]:
|
| 280 |
+
items.append({"type": "function_call_output",
|
| 281 |
+
"call_id": tm["tool_call_id"],
|
| 282 |
+
"output": tm["content"]})
|
| 283 |
+
continue
|
| 284 |
+
|
| 285 |
+
# Assistant message (may have tool_calls)
|
| 286 |
+
if role == "assistant":
|
| 287 |
+
content = msg.get("content") or ""
|
| 288 |
+
if content:
|
| 289 |
+
items.append({"role": "assistant", "content": content})
|
| 290 |
+
for tc in msg.get("tool_calls", []):
|
| 291 |
+
items.append({"type": "function_call",
|
| 292 |
+
"call_id": tc["id"],
|
| 293 |
+
"name": tc["function"]["name"],
|
| 294 |
+
"arguments": tc["function"]["arguments"]})
|
| 295 |
+
continue
|
| 296 |
+
|
| 297 |
+
# Plain user/tool messages
|
| 298 |
+
if role == "user":
|
| 299 |
+
items.append({"role": "user", "content": msg.get("content") or ""})
|
| 300 |
+
elif role == "tool":
|
| 301 |
+
items.append({"type": "function_call_output",
|
| 302 |
+
"call_id": msg.get("tool_call_id", ""),
|
| 303 |
+
"output": msg.get("content") or ""})
|
| 304 |
+
return items
|
| 305 |
+
|
| 306 |
+
def chat(self, messages, tools, system):
|
| 307 |
+
resp_tools = self._resp_tools(tools)
|
| 308 |
+
input_items = self._to_input(messages)
|
| 309 |
+
kwargs: dict[str, Any] = dict(model=self._model, input=input_items, instructions=system)
|
| 310 |
+
if resp_tools:
|
| 311 |
+
kwargs["tools"] = resp_tools
|
| 312 |
+
|
| 313 |
+
response = self._client.responses.create(**kwargs)
|
| 314 |
+
|
| 315 |
+
text_parts: list[str] = []
|
| 316 |
+
tool_calls: list[dict] = []
|
| 317 |
+
for item in response.output:
|
| 318 |
+
item_type = getattr(item, "type", "")
|
| 319 |
+
if item_type == "message":
|
| 320 |
+
for block in getattr(item, "content", []):
|
| 321 |
+
if getattr(block, "type", "") == "output_text":
|
| 322 |
+
text_parts.append(block.text)
|
| 323 |
+
elif item_type == "function_call":
|
| 324 |
+
try:
|
| 325 |
+
inp = json.loads(item.arguments)
|
| 326 |
+
except Exception:
|
| 327 |
+
inp = {}
|
| 328 |
+
tool_calls.append({"id": item.call_id, "name": item.name, "input": inp})
|
| 329 |
+
|
| 330 |
+
return "\n".join(text_parts), tool_calls, bool(tool_calls)
|
| 331 |
+
|
| 332 |
+
def stream_chat(self, messages, tools, system):
|
| 333 |
+
# Use non-streaming chat for reliable tool call extraction.
|
| 334 |
+
# Complex streaming accumulation of function call arguments is error-prone.
|
| 335 |
+
text, tool_calls, _ = self.chat(messages, tools, system)
|
| 336 |
+
if text:
|
| 337 |
+
yield ("text", text)
|
| 338 |
+
yield ("tool_calls", tool_calls)
|
| 339 |
+
yield ("stop_reason", "tool_calls" if tool_calls else "end_turn")
|
| 340 |
+
|
| 341 |
+
def make_assistant_message(self, text, tool_calls):
|
| 342 |
+
msg: dict[str, Any] = {"role": "assistant", "content": text or ""}
|
| 343 |
+
if tool_calls:
|
| 344 |
+
msg["tool_calls"] = [
|
| 345 |
+
{"id": tc["id"], "type": "function",
|
| 346 |
+
"function": {"name": tc["name"], "arguments": json.dumps(tc["input"])}}
|
| 347 |
+
for tc in tool_calls
|
| 348 |
+
]
|
| 349 |
+
return msg
|
| 350 |
+
|
| 351 |
+
def make_tool_result_message(self, tool_calls, results):
|
| 352 |
+
return {"_multi": [
|
| 353 |
+
{"role": "tool", "tool_call_id": tc["id"], "content": r}
|
| 354 |
+
for tc, r in zip(tool_calls, results)
|
| 355 |
+
]}
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
class GeminiNativeBackend(LLMBackend):
|
| 359 |
+
"""Native Google Generative AI backend β works with any AI Studio key."""
|
| 360 |
+
|
| 361 |
+
def __init__(self, api_key: str, model: str = "gemini-2.5-flash"):
|
| 362 |
+
import google.generativeai as genai
|
| 363 |
+
self._genai = genai
|
| 364 |
+
self._model_name = model
|
| 365 |
+
genai.configure(api_key=api_key or "no-key")
|
| 366 |
+
|
| 367 |
+
@property
|
| 368 |
+
def provider(self): return "gemini"
|
| 369 |
+
@property
|
| 370 |
+
def model(self): return self._model_name
|
| 371 |
+
|
| 372 |
+
def _gemini_tools(self, tools):
|
| 373 |
+
protos = self._genai.protos
|
| 374 |
+
declarations = []
|
| 375 |
+
for t in tools:
|
| 376 |
+
if "function" in t:
|
| 377 |
+
fn, params = t["function"], t["function"].get("parameters", {})
|
| 378 |
+
else:
|
| 379 |
+
fn, params = t, t.get("input_schema", {})
|
| 380 |
+
props = {}
|
| 381 |
+
for pname, pschema in params.get("properties", {}).items():
|
| 382 |
+
ptype = pschema.get("type", "string").upper()
|
| 383 |
+
gemini_type = getattr(protos.Type, ptype, protos.Type.STRING)
|
| 384 |
+
props[pname] = protos.Schema(type=gemini_type,
|
| 385 |
+
description=pschema.get("description", ""))
|
| 386 |
+
declarations.append(protos.FunctionDeclaration(
|
| 387 |
+
name=fn["name"] if "function" in t else t["name"],
|
| 388 |
+
description=fn.get("description", ""),
|
| 389 |
+
parameters=protos.Schema(type=protos.Type.OBJECT,
|
| 390 |
+
properties=props,
|
| 391 |
+
required=params.get("required", [])),
|
| 392 |
+
))
|
| 393 |
+
return [protos.Tool(function_declarations=declarations)]
|
| 394 |
+
|
| 395 |
+
def _to_contents(self, messages):
|
| 396 |
+
protos = self._genai.protos
|
| 397 |
+
contents = []
|
| 398 |
+
for msg in messages:
|
| 399 |
+
role = msg.get("role", "")
|
| 400 |
+
if "_fn_responses" in msg:
|
| 401 |
+
parts = [protos.Part(function_response=protos.FunctionResponse(
|
| 402 |
+
name=fr["name"], response={"result": fr["response"]}))
|
| 403 |
+
for fr in msg["_fn_responses"]]
|
| 404 |
+
contents.append(protos.Content(role="user", parts=parts))
|
| 405 |
+
elif role == "user":
|
| 406 |
+
contents.append(protos.Content(role="user",
|
| 407 |
+
parts=[protos.Part(text=msg.get("content") or "")]))
|
| 408 |
+
elif role in ("assistant", "model"):
|
| 409 |
+
parts = []
|
| 410 |
+
if msg.get("content"):
|
| 411 |
+
parts.append(protos.Part(text=msg["content"]))
|
| 412 |
+
for fc in msg.get("_fn_calls", []):
|
| 413 |
+
parts.append(protos.Part(function_call=protos.FunctionCall(
|
| 414 |
+
name=fc["name"], args=fc["args"])))
|
| 415 |
+
if parts:
|
| 416 |
+
contents.append(protos.Content(role="model", parts=parts))
|
| 417 |
+
return contents
|
| 418 |
+
|
| 419 |
+
def chat(self, messages, tools, system):
|
| 420 |
+
model = self._genai.GenerativeModel(
|
| 421 |
+
self._model_name,
|
| 422 |
+
tools=self._gemini_tools(tools) if tools else None,
|
| 423 |
+
system_instruction=system,
|
| 424 |
+
)
|
| 425 |
+
contents = self._to_contents(messages)
|
| 426 |
+
response = model.generate_content(contents)
|
| 427 |
+
text_parts, tool_calls = [], []
|
| 428 |
+
for part in response.parts:
|
| 429 |
+
if hasattr(part, "text") and part.text:
|
| 430 |
+
text_parts.append(part.text)
|
| 431 |
+
elif hasattr(part, "function_call") and part.function_call.name:
|
| 432 |
+
fc = part.function_call
|
| 433 |
+
tool_calls.append({"id": fc.name, "name": fc.name, "input": dict(fc.args)})
|
| 434 |
+
return "\n".join(text_parts), tool_calls, bool(tool_calls)
|
| 435 |
+
|
| 436 |
+
def stream_chat(self, messages, tools, system):
|
| 437 |
+
text, tool_calls, _ = self.chat(messages, tools, system)
|
| 438 |
+
if text:
|
| 439 |
+
yield ("text", text)
|
| 440 |
+
yield ("tool_calls", tool_calls)
|
| 441 |
+
yield ("stop_reason", "tool_calls" if tool_calls else "end_turn")
|
| 442 |
+
|
| 443 |
+
def make_assistant_message(self, text, tool_calls):
|
| 444 |
+
msg: dict[str, Any] = {"role": "model", "content": text or ""}
|
| 445 |
+
if tool_calls:
|
| 446 |
+
msg["_fn_calls"] = [{"name": tc["name"], "args": tc["input"]} for tc in tool_calls]
|
| 447 |
+
return msg
|
| 448 |
+
|
| 449 |
+
def make_tool_result_message(self, tool_calls, results):
|
| 450 |
+
return {"role": "user", "_fn_responses": [
|
| 451 |
+
{"name": tc["name"], "response": r}
|
| 452 |
+
for tc, r in zip(tool_calls, results)
|
| 453 |
+
]}
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def create_backend(provider: str, api_key: str = "", model: str = "", base_url: str = "") -> LLMBackend:
|
| 457 |
+
provider = provider.lower().strip()
|
| 458 |
+
defaults = PROVIDER_DEFAULTS.get(provider, PROVIDER_DEFAULTS["openai"])
|
| 459 |
+
model = model or defaults["default_model"]
|
| 460 |
+
base_url = base_url or defaults.get("base_url", "")
|
| 461 |
+
|
| 462 |
+
if provider == "claude":
|
| 463 |
+
return ClaudeBackend(api_key=api_key, model=model)
|
| 464 |
+
if provider == "openai":
|
| 465 |
+
return OpenAIResponsesBackend(api_key=api_key, model=model)
|
| 466 |
+
if provider == "gemini":
|
| 467 |
+
return GeminiNativeBackend(api_key=api_key, model=model)
|
| 468 |
+
return OpenAICompatBackend(api_key=api_key, model=model, base_url=base_url, provider_name=provider)
|
core/runner.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cpptraj script execution and result management.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import subprocess
|
| 8 |
+
import tempfile
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CPPTrajRunner:
|
| 14 |
+
"""Manages temp files and executes cpptraj scripts."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, work_dir: str | None = None, cpptraj_bin: str = "cpptraj"):
|
| 17 |
+
self.cpptraj_bin = cpptraj_bin or os.environ.get("CPPTRAJ_PATH", "cpptraj")
|
| 18 |
+
self.work_dir = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="cpptraj_"))
|
| 19 |
+
self.work_dir.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
self.output_files: list[Path] = []
|
| 21 |
+
self._uploaded_names: set[str] = set()
|
| 22 |
+
|
| 23 |
+
# ββ File management ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
|
| 25 |
+
def save_uploaded_file(self, uploaded_file, name: str | None = None) -> Path:
|
| 26 |
+
"""Save a Flask FileStorage (or any file-like with .filename/.read()) to the work directory."""
|
| 27 |
+
fname = name or uploaded_file.filename
|
| 28 |
+
dest = self.work_dir / fname
|
| 29 |
+
uploaded_file.save(dest)
|
| 30 |
+
self._uploaded_names.add(fname)
|
| 31 |
+
return dest
|
| 32 |
+
|
| 33 |
+
def list_output_files(self) -> list[Path]:
|
| 34 |
+
"""Return all files in the work directory (excluding topology/trajectory inputs)."""
|
| 35 |
+
skip_exts = {".prmtop", ".parm7", ".psf", ".nc", ".ncdf", ".dcd",
|
| 36 |
+
".trr", ".xtc", ".crd", ".mdcrd", ".rst7"}
|
| 37 |
+
return sorted(
|
| 38 |
+
p for p in self.work_dir.iterdir()
|
| 39 |
+
if p.is_file()
|
| 40 |
+
and p.suffix.lower() not in skip_exts
|
| 41 |
+
and p.name not in self._uploaded_names
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def read_file(self, path: Path) -> str:
|
| 46 |
+
"""Read a text file, returning its contents."""
|
| 47 |
+
try:
|
| 48 |
+
return path.read_text(errors="replace")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
return f"Error reading file: {e}"
|
| 51 |
+
|
| 52 |
+
# ββ Script execution βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
|
| 54 |
+
def is_cpptraj_available(self) -> bool:
|
| 55 |
+
return shutil.which(self.cpptraj_bin) is not None
|
| 56 |
+
|
| 57 |
+
def run_script(
|
| 58 |
+
self,
|
| 59 |
+
script: str,
|
| 60 |
+
parm_file: Path | None = None,
|
| 61 |
+
timeout: int = 300,
|
| 62 |
+
) -> dict:
|
| 63 |
+
"""
|
| 64 |
+
Execute a cpptraj script.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
{
|
| 68 |
+
"success": bool,
|
| 69 |
+
"stdout": str,
|
| 70 |
+
"stderr": str,
|
| 71 |
+
"output_files": [Path, ...],
|
| 72 |
+
"elapsed": float,
|
| 73 |
+
}
|
| 74 |
+
"""
|
| 75 |
+
# Ensure script ends with 'go' so cpptraj actually executes the analysis
|
| 76 |
+
stripped = script.strip()
|
| 77 |
+
last_line = stripped.splitlines()[-1].strip().lower() if stripped else ""
|
| 78 |
+
if last_line not in ("go", "run", "quit"):
|
| 79 |
+
script = stripped + "\ngo\n"
|
| 80 |
+
|
| 81 |
+
# Write the script to a temp file
|
| 82 |
+
script_path = self.work_dir / f"script_{int(time.time())}.cpptraj"
|
| 83 |
+
script_path.write_text(script, encoding='utf-8')
|
| 84 |
+
|
| 85 |
+
# Build command
|
| 86 |
+
cmd = [self.cpptraj_bin]
|
| 87 |
+
if parm_file:
|
| 88 |
+
cmd += ["-p", str(parm_file)]
|
| 89 |
+
cmd += ["-i", str(script_path)]
|
| 90 |
+
|
| 91 |
+
t0 = time.time()
|
| 92 |
+
try:
|
| 93 |
+
result = subprocess.run(
|
| 94 |
+
cmd,
|
| 95 |
+
capture_output=True,
|
| 96 |
+
text=True,
|
| 97 |
+
timeout=timeout,
|
| 98 |
+
cwd=str(self.work_dir),
|
| 99 |
+
)
|
| 100 |
+
elapsed = time.time() - t0
|
| 101 |
+
success = result.returncode == 0
|
| 102 |
+
return {
|
| 103 |
+
"success": success,
|
| 104 |
+
"stdout": result.stdout,
|
| 105 |
+
"stderr": result.stderr,
|
| 106 |
+
"output_files": self.list_output_files(),
|
| 107 |
+
"elapsed": elapsed,
|
| 108 |
+
"script_path": str(script_path),
|
| 109 |
+
}
|
| 110 |
+
except subprocess.TimeoutExpired:
|
| 111 |
+
return {
|
| 112 |
+
"success": False,
|
| 113 |
+
"stdout": "",
|
| 114 |
+
"stderr": f"cpptraj timed out after {timeout}s.",
|
| 115 |
+
"output_files": [],
|
| 116 |
+
"elapsed": timeout,
|
| 117 |
+
"script_path": str(script_path),
|
| 118 |
+
}
|
| 119 |
+
except FileNotFoundError:
|
| 120 |
+
return {
|
| 121 |
+
"success": False,
|
| 122 |
+
"stdout": "",
|
| 123 |
+
"stderr": (
|
| 124 |
+
f"cpptraj binary not found at '{self.cpptraj_bin}'. "
|
| 125 |
+
"Please install cpptraj and ensure it is on your PATH, "
|
| 126 |
+
"or set the CPPTRAJ_PATH environment variable."
|
| 127 |
+
),
|
| 128 |
+
"output_files": [],
|
| 129 |
+
"elapsed": 0.0,
|
| 130 |
+
"script_path": str(script_path),
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
def inject_paths_into_script(
|
| 134 |
+
self,
|
| 135 |
+
script: str,
|
| 136 |
+
parm_path: Path | None,
|
| 137 |
+
traj_paths: list[Path],
|
| 138 |
+
) -> str:
|
| 139 |
+
"""
|
| 140 |
+
Replace placeholder filenames in the script with actual uploaded file paths.
|
| 141 |
+
Inserts parm and trajin lines at the top if they contain placeholder names.
|
| 142 |
+
"""
|
| 143 |
+
lines = script.splitlines()
|
| 144 |
+
patched = []
|
| 145 |
+
parm_injected = False
|
| 146 |
+
traj_injected = False
|
| 147 |
+
|
| 148 |
+
for line in lines:
|
| 149 |
+
stripped = line.strip()
|
| 150 |
+
|
| 151 |
+
# Replace parm placeholders
|
| 152 |
+
if stripped.startswith("parm ") and parm_path:
|
| 153 |
+
parts = stripped.split()
|
| 154 |
+
parts[1] = str(parm_path)
|
| 155 |
+
patched.append(" ".join(parts))
|
| 156 |
+
parm_injected = True
|
| 157 |
+
continue
|
| 158 |
+
|
| 159 |
+
# Replace trajin placeholders
|
| 160 |
+
if stripped.startswith("trajin ") and traj_paths:
|
| 161 |
+
patched.append(line) # keep original if user wrote it
|
| 162 |
+
traj_injected = True
|
| 163 |
+
continue
|
| 164 |
+
|
| 165 |
+
patched.append(line)
|
| 166 |
+
|
| 167 |
+
# If the script has no parm/trajin, prepend them
|
| 168 |
+
header = []
|
| 169 |
+
if not parm_injected and parm_path:
|
| 170 |
+
header.append(f"parm {parm_path}")
|
| 171 |
+
if not traj_injected and traj_paths:
|
| 172 |
+
for tp in traj_paths:
|
| 173 |
+
header.append(f"trajin {tp}")
|
| 174 |
+
|
| 175 |
+
return "\n".join(header + patched)
|
| 176 |
+
|
| 177 |
+
def cleanup(self):
|
| 178 |
+
"""Remove the working directory."""
|
| 179 |
+
if self.work_dir.exists():
|
| 180 |
+
shutil.rmtree(self.work_dir)
|
cpptraj_manual_cache.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
anthropic>=0.40.0
|
| 2 |
+
openai>=1.0.0
|
| 3 |
+
google-generativeai>=0.8.0
|
| 4 |
+
pandas>=2.0.0
|
| 5 |
+
numpy>=1.24.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
scikit-learn>=1.3.0
|
| 8 |
+
flask>=3.0.0
|
| 9 |
+
flask-cors>=4.0.0
|
| 10 |
+
pdfplumber>=0.10.0
|
server.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Flask backend for the cpptraj IDE HTML frontend.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
GET / β serve agent_ide.html
|
| 6 |
+
POST /api/upload β save topology/trajectory file
|
| 7 |
+
POST /api/run β execute cpptraj script
|
| 8 |
+
POST /api/chat β AI agent message
|
| 9 |
+
POST /api/chat/reset β reset agent conversation
|
| 10 |
+
GET /api/files β list output data files
|
| 11 |
+
GET /api/file/<name> β read an output file
|
| 12 |
+
GET /api/status β system status
|
| 13 |
+
POST /api/set_provider β configure LLM provider/model/key
|
| 14 |
+
GET /api/providers β list available providers and defaults
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import tempfile
|
| 20 |
+
import threading
|
| 21 |
+
import time
|
| 22 |
+
import traceback
|
| 23 |
+
import uuid
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
from flask import Flask, Response, jsonify, request, send_from_directory, session
|
| 27 |
+
from flask_cors import CORS
|
| 28 |
+
|
| 29 |
+
from core.knowledge_base import CPPTrajKnowledgeBase
|
| 30 |
+
from core.runner import CPPTrajRunner
|
| 31 |
+
from core.agent import TrajectoryAgent
|
| 32 |
+
from core.llm_backends import PROVIDER_DEFAULTS
|
| 33 |
+
|
| 34 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
# App setup
|
| 36 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
app = Flask(__name__, static_folder=".")
|
| 39 |
+
app.secret_key = os.environ.get("FLASK_SECRET_KEY", os.urandom(32))
|
| 40 |
+
CORS(app, supports_credentials=True)
|
| 41 |
+
|
| 42 |
+
_CPPTRAJ_BIN = os.environ.get(
|
| 43 |
+
"CPPTRAJ_PATH",
|
| 44 |
+
"/opt/conda/bin/cpptraj",
|
| 45 |
+
)
|
| 46 |
+
# Fallback to local conda env if running locally
|
| 47 |
+
if not Path(_CPPTRAJ_BIN).exists():
|
| 48 |
+
_CPPTRAJ_BIN = os.environ.get(
|
| 49 |
+
"CPPTRAJ_PATH",
|
| 50 |
+
"/home/hn533621/.conda/envs/cpptraj_env/bin/cpptraj",
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Shared read-only knowledge base (safe to share across sessions)
|
| 54 |
+
kb = CPPTrajKnowledgeBase()
|
| 55 |
+
|
| 56 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
# Per-session state
|
| 58 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
|
| 60 |
+
_SESSIONS: dict[str, dict] = {}
|
| 61 |
+
_SESSIONS_LOCK = threading.Lock()
|
| 62 |
+
_SESSION_TTL = 2 * 60 * 60 # 2 hours
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _make_session_state() -> dict:
|
| 66 |
+
work_dir = Path(tempfile.mkdtemp(prefix="cpptraj_ide_"))
|
| 67 |
+
return {
|
| 68 |
+
"runner": CPPTrajRunner(work_dir=work_dir, cpptraj_bin=_CPPTRAJ_BIN),
|
| 69 |
+
"parm_file": None,
|
| 70 |
+
"traj_files": [],
|
| 71 |
+
"agent": None,
|
| 72 |
+
"llm_config": {
|
| 73 |
+
"provider": "claude",
|
| 74 |
+
"api_key": "",
|
| 75 |
+
"model": "",
|
| 76 |
+
"base_url": "",
|
| 77 |
+
},
|
| 78 |
+
"stop_event": threading.Event(),
|
| 79 |
+
"last_active": time.time(),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _cleanup_expired_sessions():
|
| 84 |
+
"""Remove sessions that have been inactive for > TTL."""
|
| 85 |
+
now = time.time()
|
| 86 |
+
with _SESSIONS_LOCK:
|
| 87 |
+
expired = [sid for sid, sd in _SESSIONS.items()
|
| 88 |
+
if now - sd["last_active"] > _SESSION_TTL]
|
| 89 |
+
for sid in expired:
|
| 90 |
+
try:
|
| 91 |
+
_SESSIONS[sid]["runner"].cleanup()
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
del _SESSIONS[sid]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def get_sd() -> dict:
|
| 98 |
+
"""Get or create per-session state dict."""
|
| 99 |
+
# Lazy cleanup (cheap check)
|
| 100 |
+
if len(_SESSIONS) > 50:
|
| 101 |
+
_cleanup_expired_sessions()
|
| 102 |
+
|
| 103 |
+
sid = session.get("sid")
|
| 104 |
+
if not sid or sid not in _SESSIONS:
|
| 105 |
+
sid = str(uuid.uuid4())
|
| 106 |
+
session["sid"] = sid
|
| 107 |
+
with _SESSIONS_LOCK:
|
| 108 |
+
_SESSIONS[sid] = _make_session_state()
|
| 109 |
+
else:
|
| 110 |
+
with _SESSIONS_LOCK:
|
| 111 |
+
_SESSIONS[sid]["last_active"] = time.time()
|
| 112 |
+
return _SESSIONS[sid]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def get_agent(sd: dict) -> TrajectoryAgent | None:
|
| 116 |
+
cfg = sd["llm_config"]
|
| 117 |
+
if not cfg.get("api_key") and cfg["provider"] != "ollama":
|
| 118 |
+
return None
|
| 119 |
+
if sd["agent"] is None:
|
| 120 |
+
sd["agent"] = TrajectoryAgent(runner=sd["runner"], kb=kb, **cfg)
|
| 121 |
+
sd["agent"].set_files(sd["parm_file"], sd["traj_files"])
|
| 122 |
+
return sd["agent"]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
# Routes
|
| 127 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
|
| 129 |
+
@app.route("/")
|
| 130 |
+
def index():
|
| 131 |
+
return send_from_directory(".", "agent_ide.html")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@app.route("/api/upload", methods=["POST"])
|
| 135 |
+
def upload():
|
| 136 |
+
sd = get_sd()
|
| 137 |
+
|
| 138 |
+
if "file" not in request.files:
|
| 139 |
+
return jsonify({"error": "No file in request"}), 400
|
| 140 |
+
|
| 141 |
+
f = request.files["file"]
|
| 142 |
+
saved = sd["runner"].save_uploaded_file(f)
|
| 143 |
+
|
| 144 |
+
# Classify by extension; PDB files are inspected for multiple MODEL records
|
| 145 |
+
ext = saved.suffix.lower()
|
| 146 |
+
if ext in {".nc", ".ncdf", ".dcd", ".xtc", ".trr", ".crd", ".mdcrd", ".rst7"}:
|
| 147 |
+
if saved not in sd["traj_files"]:
|
| 148 |
+
sd["traj_files"].append(saved)
|
| 149 |
+
kind = "trajectory"
|
| 150 |
+
elif ext in {".prmtop", ".parm7", ".psf", ".gro", ".mol2"}:
|
| 151 |
+
sd["parm_file"] = saved
|
| 152 |
+
kind = "topology"
|
| 153 |
+
elif ext == ".pdb":
|
| 154 |
+
# Multi-MODEL PDB β trajectory; single-model PDB β topology
|
| 155 |
+
try:
|
| 156 |
+
head = saved.read_bytes(65536).decode("utf-8", errors="ignore")
|
| 157 |
+
model_count = head.count("\nMODEL ")
|
| 158 |
+
except Exception:
|
| 159 |
+
model_count = 0
|
| 160 |
+
if model_count > 1:
|
| 161 |
+
if saved not in sd["traj_files"]:
|
| 162 |
+
sd["traj_files"].append(saved)
|
| 163 |
+
kind = "trajectory"
|
| 164 |
+
else:
|
| 165 |
+
sd["parm_file"] = saved
|
| 166 |
+
kind = "topology"
|
| 167 |
+
else:
|
| 168 |
+
kind = "other"
|
| 169 |
+
|
| 170 |
+
# Update agent context
|
| 171 |
+
ag = get_agent(sd)
|
| 172 |
+
if ag:
|
| 173 |
+
ag.set_files(sd["parm_file"], sd["traj_files"])
|
| 174 |
+
|
| 175 |
+
return jsonify({
|
| 176 |
+
"name": saved.name,
|
| 177 |
+
"size": saved.stat().st_size,
|
| 178 |
+
"kind": kind,
|
| 179 |
+
"ext": ext[1:].upper(),
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@app.route("/api/run_python", methods=["POST"])
|
| 184 |
+
def run_python():
|
| 185 |
+
import subprocess, sys as _sys
|
| 186 |
+
sd = get_sd()
|
| 187 |
+
data = request.get_json(silent=True) or {}
|
| 188 |
+
script = data.get("script", "").strip()
|
| 189 |
+
if not script:
|
| 190 |
+
return jsonify({"error": "Empty script"}), 400
|
| 191 |
+
t0 = time.time()
|
| 192 |
+
try:
|
| 193 |
+
proc = subprocess.run(
|
| 194 |
+
[_sys.executable, "-c", script],
|
| 195 |
+
capture_output=True, text=True, timeout=120,
|
| 196 |
+
cwd=str(sd["runner"].work_dir),
|
| 197 |
+
)
|
| 198 |
+
elapsed = round(time.time() - t0, 2)
|
| 199 |
+
return jsonify({
|
| 200 |
+
"success": proc.returncode == 0,
|
| 201 |
+
"stdout": proc.stdout[:8000],
|
| 202 |
+
"stderr": proc.stderr[:3000],
|
| 203 |
+
"elapsed": elapsed,
|
| 204 |
+
"output_files": [f.name for f in sd["runner"].list_output_files()],
|
| 205 |
+
})
|
| 206 |
+
except subprocess.TimeoutExpired:
|
| 207 |
+
return jsonify({"success": False, "stdout": "", "stderr": "Timed out after 120s.", "elapsed": 120})
|
| 208 |
+
except Exception as e:
|
| 209 |
+
return jsonify({"success": False, "stdout": "", "stderr": str(e), "elapsed": 0})
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@app.route("/api/run", methods=["POST"])
|
| 213 |
+
def run_script():
|
| 214 |
+
sd = get_sd()
|
| 215 |
+
data = request.get_json(silent=True) or {}
|
| 216 |
+
script = data.get("script", "").strip()
|
| 217 |
+
|
| 218 |
+
if not script:
|
| 219 |
+
return jsonify({"error": "Empty script"}), 400
|
| 220 |
+
|
| 221 |
+
# Inject real file paths where placeholders exist
|
| 222 |
+
if sd["parm_file"] or sd["traj_files"]:
|
| 223 |
+
script = sd["runner"].inject_paths_into_script(script, sd["parm_file"], sd["traj_files"])
|
| 224 |
+
|
| 225 |
+
result = sd["runner"].run_script(script)
|
| 226 |
+
|
| 227 |
+
return jsonify({
|
| 228 |
+
"success": result["success"],
|
| 229 |
+
"stdout": result["stdout"],
|
| 230 |
+
"stderr": result["stderr"],
|
| 231 |
+
"elapsed": round(result["elapsed"], 2),
|
| 232 |
+
"output_files": [f.name for f in result.get("output_files", [])],
|
| 233 |
+
})
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@app.route("/api/chat", methods=["POST"])
|
| 237 |
+
def chat():
|
| 238 |
+
sd = get_sd()
|
| 239 |
+
data = request.get_json(silent=True) or {}
|
| 240 |
+
message = data.get("message", "").strip()
|
| 241 |
+
|
| 242 |
+
if not message:
|
| 243 |
+
return jsonify({"error": "Empty message"}), 400
|
| 244 |
+
|
| 245 |
+
ag = get_agent(sd)
|
| 246 |
+
if ag is None:
|
| 247 |
+
return jsonify({"error": "No LLM configured. Click β Settings to choose a provider and enter your API key."}), 400
|
| 248 |
+
|
| 249 |
+
try:
|
| 250 |
+
response, tool_calls = ag.chat(message)
|
| 251 |
+
return jsonify({
|
| 252 |
+
"response": response,
|
| 253 |
+
"tool_calls": [
|
| 254 |
+
{
|
| 255 |
+
"tool": tc["tool"],
|
| 256 |
+
"script": tc["input"].get("script", ""),
|
| 257 |
+
"input": {k: v for k, v in tc["input"].items() if k != "script"},
|
| 258 |
+
"result": tc["result"][:3000],
|
| 259 |
+
}
|
| 260 |
+
for tc in tool_calls
|
| 261 |
+
],
|
| 262 |
+
})
|
| 263 |
+
except Exception as e:
|
| 264 |
+
return jsonify({"error": str(e)}), 500
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@app.route("/api/chat/stream", methods=["POST"])
|
| 268 |
+
def chat_stream():
|
| 269 |
+
sd = get_sd()
|
| 270 |
+
data = request.get_json(silent=True) or {}
|
| 271 |
+
message = data.get("message", "").strip()
|
| 272 |
+
if not message:
|
| 273 |
+
return jsonify({"error": "Empty message"}), 400
|
| 274 |
+
ag = get_agent(sd)
|
| 275 |
+
if ag is None:
|
| 276 |
+
return jsonify({"error": "No LLM configured. Click β Settings to choose a provider and enter your API key."}), 400
|
| 277 |
+
|
| 278 |
+
stop_event = sd["stop_event"]
|
| 279 |
+
stop_event.clear()
|
| 280 |
+
|
| 281 |
+
def generate():
|
| 282 |
+
try:
|
| 283 |
+
for event in ag.chat_stream(message):
|
| 284 |
+
if stop_event.is_set():
|
| 285 |
+
yield ("data: " + json.dumps({"type": "stopped"}, ensure_ascii=False) + "\n\n").encode("utf-8")
|
| 286 |
+
return
|
| 287 |
+
yield ("data: " + json.dumps(event, ensure_ascii=False) + "\n\n").encode("utf-8")
|
| 288 |
+
except Exception as e:
|
| 289 |
+
tb = traceback.format_exc()
|
| 290 |
+
print(f"[chat/stream ERROR]\n{tb}", flush=True)
|
| 291 |
+
err = json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False)
|
| 292 |
+
yield ("data: " + err + "\n\n").encode("utf-8")
|
| 293 |
+
|
| 294 |
+
return Response(generate(), content_type="text/event-stream; charset=utf-8",
|
| 295 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@app.route("/api/chat/stop", methods=["POST"])
|
| 299 |
+
def chat_stop():
|
| 300 |
+
sd = get_sd()
|
| 301 |
+
sd["stop_event"].set()
|
| 302 |
+
return jsonify({"ok": True})
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@app.route("/api/chat/reset", methods=["POST"])
|
| 306 |
+
def chat_reset():
|
| 307 |
+
sd = get_sd()
|
| 308 |
+
if sd["agent"]:
|
| 309 |
+
sd["agent"].reset_conversation()
|
| 310 |
+
return jsonify({"ok": True})
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@app.route("/api/reset_all", methods=["POST"])
|
| 314 |
+
def reset_all():
|
| 315 |
+
"""Reset everything: chat history, uploaded files, output files."""
|
| 316 |
+
import shutil
|
| 317 |
+
sd = get_sd()
|
| 318 |
+
|
| 319 |
+
# Reset agent/chat history
|
| 320 |
+
if sd["agent"]:
|
| 321 |
+
sd["agent"].reset_conversation()
|
| 322 |
+
|
| 323 |
+
# Clear uploaded file references
|
| 324 |
+
sd["parm_file"] = None
|
| 325 |
+
sd["traj_files"] = []
|
| 326 |
+
|
| 327 |
+
# Delete all files in work dir and recreate it
|
| 328 |
+
runner = sd["runner"]
|
| 329 |
+
if runner.work_dir.exists():
|
| 330 |
+
shutil.rmtree(runner.work_dir)
|
| 331 |
+
runner.work_dir.mkdir(parents=True, exist_ok=True)
|
| 332 |
+
runner.output_files = []
|
| 333 |
+
runner._uploaded_names = set()
|
| 334 |
+
|
| 335 |
+
return jsonify({"ok": True})
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
@app.route("/api/files")
|
| 339 |
+
def list_files():
|
| 340 |
+
sd = get_sd()
|
| 341 |
+
files = sd["runner"].list_output_files()
|
| 342 |
+
return jsonify([
|
| 343 |
+
{
|
| 344 |
+
"name": f.name,
|
| 345 |
+
"size": f.stat().st_size,
|
| 346 |
+
"ext": f.suffix[1:].upper(),
|
| 347 |
+
}
|
| 348 |
+
for f in files
|
| 349 |
+
])
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
@app.route("/api/file/<path:name>")
|
| 353 |
+
def get_file(name):
|
| 354 |
+
sd = get_sd()
|
| 355 |
+
fp = sd["runner"].work_dir / name
|
| 356 |
+
if not fp.exists():
|
| 357 |
+
return jsonify({"error": "Not found"}), 404
|
| 358 |
+
from flask import send_file
|
| 359 |
+
suffix = fp.suffix.lower()
|
| 360 |
+
mime_map = {".png": "image/png", ".jpg": "image/jpeg",
|
| 361 |
+
".jpeg": "image/jpeg", ".svg": "image/svg+xml",
|
| 362 |
+
".dcd": "application/octet-stream",
|
| 363 |
+
".pdb": "chemical/x-pdb"}
|
| 364 |
+
mime = mime_map.get(suffix)
|
| 365 |
+
if mime:
|
| 366 |
+
return send_file(fp, mimetype=mime, as_attachment=False)
|
| 367 |
+
return send_file(fp, as_attachment=False)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
@app.route("/api/status")
|
| 371 |
+
def status():
|
| 372 |
+
sd = get_sd()
|
| 373 |
+
ag = sd["agent"]
|
| 374 |
+
cfg = sd["llm_config"]
|
| 375 |
+
return jsonify({
|
| 376 |
+
"cpptraj": sd["runner"].is_cpptraj_available(),
|
| 377 |
+
"parm": sd["parm_file"].name if sd["parm_file"] else None,
|
| 378 |
+
"trajs": [f.name for f in sd["traj_files"]],
|
| 379 |
+
"api_key": bool(cfg.get("api_key")) or cfg["provider"] == "ollama",
|
| 380 |
+
"provider": cfg["provider"],
|
| 381 |
+
"model": (ag.model if ag else None) or cfg.get("model", ""),
|
| 382 |
+
"work_dir": str(sd["runner"].work_dir),
|
| 383 |
+
})
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@app.route("/api/prepare_viewer", methods=["POST"])
|
| 387 |
+
def prepare_viewer():
|
| 388 |
+
"""Convert topology+trajectory to a multi-MODEL PDB for 3Dmol.js viewer."""
|
| 389 |
+
sd = get_sd()
|
| 390 |
+
if not sd["parm_file"] or not sd["traj_files"]:
|
| 391 |
+
return jsonify({"error": "Upload topology and trajectory first."}), 400
|
| 392 |
+
|
| 393 |
+
data = request.get_json(silent=True) or {}
|
| 394 |
+
first_frame = int(data.get("first_frame") or 1)
|
| 395 |
+
last_frame = data.get("last_frame")
|
| 396 |
+
frame_range = f" {first_frame} {int(last_frame)}" if last_frame else (f" {first_frame}" if first_frame > 1 else "")
|
| 397 |
+
|
| 398 |
+
runner = sd["runner"]
|
| 399 |
+
parm_file = sd["parm_file"]
|
| 400 |
+
traj_files = sd["traj_files"]
|
| 401 |
+
out_pdb = runner.work_dir / "viewer_traj.pdb"
|
| 402 |
+
script = f"""parm {parm_file}
|
| 403 |
+
trajin {traj_files[0]}{frame_range}
|
| 404 |
+
strip :WAT,HOH,TIP3,Na+,Cl-,NA,CL
|
| 405 |
+
autoimage
|
| 406 |
+
trajout {out_pdb} pdb
|
| 407 |
+
go"""
|
| 408 |
+
result = runner.run_script(script)
|
| 409 |
+
|
| 410 |
+
if not out_pdb.exists() or out_pdb.stat().st_size == 0:
|
| 411 |
+
script2 = f"""parm {parm_file}
|
| 412 |
+
trajin {traj_files[0]}{frame_range}
|
| 413 |
+
autoimage
|
| 414 |
+
trajout {out_pdb} pdb
|
| 415 |
+
go"""
|
| 416 |
+
result = runner.run_script(script2)
|
| 417 |
+
|
| 418 |
+
if out_pdb.exists() and out_pdb.stat().st_size > 0:
|
| 419 |
+
text = out_pdb.read_text(errors="ignore")
|
| 420 |
+
frames = max(text.count("MODEL "), 1)
|
| 421 |
+
return jsonify({"ok": True, "filename": "viewer_traj.pdb", "frames": frames})
|
| 422 |
+
|
| 423 |
+
err = result.get("stderr") or result.get("stdout") or "cpptraj conversion failed."
|
| 424 |
+
return jsonify({"ok": False, "error": err[:500]}), 500
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
@app.route("/api/prepare_viewer_pdb", methods=["POST"])
|
| 428 |
+
def prepare_viewer_pdb():
|
| 429 |
+
"""Use an already-uploaded PDB trajectory directly (no conversion needed)."""
|
| 430 |
+
sd = get_sd()
|
| 431 |
+
pdb_traj = next((f for f in sd["traj_files"] if f.suffix.lower() == ".pdb"), None)
|
| 432 |
+
if pdb_traj:
|
| 433 |
+
return jsonify({"filename": pdb_traj.name})
|
| 434 |
+
return jsonify({"error": "No PDB trajectory found."}), 404
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
@app.route("/api/test/<path:name>")
|
| 438 |
+
def get_test_file(name):
|
| 439 |
+
"""Serve a file from the test_data/ directory so the browser can load it."""
|
| 440 |
+
test_dir = Path(__file__).parent / "test_data"
|
| 441 |
+
fp = test_dir / name
|
| 442 |
+
if not fp.exists() or not fp.is_file():
|
| 443 |
+
return jsonify({"error": "Test file not found"}), 404
|
| 444 |
+
content = fp.read_bytes()
|
| 445 |
+
return content, 200, {
|
| 446 |
+
"Content-Type": "application/octet-stream",
|
| 447 |
+
"Content-Disposition": f'attachment; filename="{name}"',
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@app.route("/api/set_provider", methods=["POST"])
|
| 452 |
+
def set_provider():
|
| 453 |
+
sd = get_sd()
|
| 454 |
+
data = request.get_json(silent=True) or {}
|
| 455 |
+
provider = data.get("provider", "").strip()
|
| 456 |
+
api_key = data.get("api_key", "").strip()
|
| 457 |
+
model = data.get("model", "").strip()
|
| 458 |
+
base_url = data.get("base_url", "").strip()
|
| 459 |
+
|
| 460 |
+
# Strip any non-ASCII characters that would break HTTP header encoding
|
| 461 |
+
api_key_clean = api_key.encode("ascii", errors="ignore").decode("ascii")
|
| 462 |
+
if api_key_clean != api_key:
|
| 463 |
+
return jsonify({"error": "API key contains invalid characters. Please paste it again β it may have picked up extra symbols."}), 400
|
| 464 |
+
api_key = api_key_clean
|
| 465 |
+
|
| 466 |
+
if not provider:
|
| 467 |
+
return jsonify({"error": "provider is required"}), 400
|
| 468 |
+
|
| 469 |
+
sd["llm_config"] = {"provider": provider, "api_key": api_key,
|
| 470 |
+
"model": model, "base_url": base_url}
|
| 471 |
+
sd["agent"] = None # force rebuild
|
| 472 |
+
return jsonify({"ok": True, "provider": provider,
|
| 473 |
+
"model": model or PROVIDER_DEFAULTS.get(provider, {}).get("default_model", "")})
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
@app.route("/api/providers")
|
| 477 |
+
def list_providers():
|
| 478 |
+
return jsonify(PROVIDER_DEFAULTS)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 482 |
+
|
| 483 |
+
if __name__ == "__main__":
|
| 484 |
+
port = int(os.environ.get("PORT", 8502))
|
| 485 |
+
print(f"\n cpptraj IDE running at http://localhost:{port}\n")
|
| 486 |
+
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)
|
test_data/README.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Test files for cpptraj IDE
|
| 2 |
+
==========================
|
| 3 |
+
System : 10-residue polyalanine alpha-helix
|
| 4 |
+
Atoms : 50 atoms (10 residues x 5 backbone atoms: N, CA, C, O, CB)
|
| 5 |
+
Frames : 200
|
| 6 |
+
|
| 7 |
+
Files
|
| 8 |
+
-----
|
| 9 |
+
test_topology.pdb β single-frame PDB, use as topology (parm)
|
| 10 |
+
test_trajectory.pdb β multi-MODEL PDB, use as trajectory (trajin)
|
| 11 |
+
|
| 12 |
+
Example cpptraj script
|
| 13 |
+
----------------------
|
| 14 |
+
parm test_topology.pdb
|
| 15 |
+
trajin test_trajectory.pdb
|
| 16 |
+
|
| 17 |
+
autoimage
|
| 18 |
+
|
| 19 |
+
rmsd backbone @CA,C,N,O first out rmsd.dat
|
| 20 |
+
atomicfluct rmsf @CA byres out rmsf.dat
|
| 21 |
+
radgyr rg @CA mass out rg.dat
|
| 22 |
+
hbond hbonds out hbond.dat avgout hbond_avg.dat
|
| 23 |
+
secstruct ss out secstruct.dat sumout secstruct_sum.dat
|
| 24 |
+
|
| 25 |
+
go
|