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 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: purple
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ![IDE](agent_ide.html)
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 &nbsp;|&nbsp; 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
+ &nbsp;Β·&nbsp; {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