Merge pull request #34 from abhigyanpatwari/leidens_algorithm_and_processmap

Leidens algorithm and processmap
This commit is contained in:
Abhigyan Patwari 2026-01-29 00:11:19 +05:30 • committed by GitHub
commit baa300bea3
18 changed files with 2623 additions and 73 deletions

3
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: abhigyanpatwari

584
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,584 @@
# PyBaMM Architecture - End-to-End Analysis
## Executive Summary
**PyBaMM** (Python Battery Mathematical Modelling) is a comprehensive, open-source framework for modeling and simulating battery behavior. The project contains **973 files**, **4,342 functions**, and **735 classes** organized in a layered architecture optimized for modularity, extensibility, and scientific computation.
---
## 🏗️ High-Level Architecture Layers
```
┌─────────────────────────────────────────────────────────────┐
│ USER INTERFACE & EXAMPLES │
│ (Jupyter Notebooks, Scripts, Experiments) │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ SIMULATION & EXPERIMENT ORCHESTRATION LAYER │
│ • Simulation - High-level simulation runner │
│ • Experiment - Define charging/discharging cycles │
│ • BatchStudy - Multi-parameter studies │
│ • Callbacks - Monitor simulation progress │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ MODEL LAYER (Hierarchical) │
├─ BaseBatteryModel (Physical domain constraints) │
├─ Full Models: │
│ ├─ Lithium-Ion (DFN, SPM, SPMe, MPM, MSMR, etc.) │
│ ├─ Lead-Acid (Full, LOQS models) │
│ ├─ Sodium-Ion (emerging battery chemistry) │
│ └─ Equivalent Circuit Models (ECM) │
├─ Submodels (Pluggable domain-specific components): │
│ ├─ Particle Diffusion (kinetics in electrodes) │
│ ├─ Electrode Kinetics (Butler-Volmer, Marcus, etc.) │
│ ├─ Interface Chemistry (SEI growth, Li-plating, OCP) │
│ ├─ Thermal Management (lumped, distributed 1D-3D) │
│ ├─ Current Collector Physics │
│ ├─ Electrolyte Transport (conductivity, diffusion) │
│ ├─ Convection (internal circulation) │
│ ├─ Porosity & Tortuosity (pore network) │
│ └─ Active Material Loss (cycling degradation) │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ EXPRESSION TREE (Symbolic Computation Layer) │
│ Directed Acyclic Graph (DAG) of mathematical expressions │
├─ Symbol - Base class for all nodes │
│ ├─ Variable - State vector entries │
│ ├─ Parameter - Model parameters │
│ ├─ Scalar/Array - Constants │
│ ├─ StateVector - Discretized spatial domain │
│ └─ InputParameter - Time-varying inputs │
├─ Operators │
│ ├─ BinaryOperators - +, -, *, /, power, etc. │
│ ├─ UnaryOperators - exp, log, sin, cos, etc. │
│ ├─ Concatenations - Stack vectors │
│ └─ Broadcasts - Repeat/tile operations │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ DISCRETISATION LAYER (PDE → ODE/DAE Conversion) │
│ Transforms continuous PDEs into discrete systems │
├─ Discretisation - Master converter class │
├─ Spatial Methods: │
│ ├─ FiniteVolume - 1D/2D finite volume schemes │
│ ├─ SpectralVolume - Spectral approach │
│ ├─ ScikitFiniteElement - 1D unstructured meshes │
│ ├─ ScikitFiniteElement3D- 3D tetrahedral meshes │
│ └─ ZeroDimensionalMethod- Lumped (0D) approximations │
├─ Meshes: │
│ ├─ 1D Submeshes - Line domains │
│ ├─ 2D Submeshes - Sheet domains │
│ ├─ 3D Submeshes - Volume domains (via scikit-fem)│
│ └─ Composite Meshes - Combined domains │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ SOLVER LAYER (DAE System Integration) │
│ Converts discrete system → numerical solution │
├─ Solver Interfaces: │
│ ├─ BaseSolver - Abstract interface │
│ ├─ ODE Solvers: │
│ │ ├─ ScipySolver - scipy.integrate.ode │
│ │ ├─ JAXSolver - JAX backend (jit-compiled) │
│ │ ├─ JAXBDFSolver - JAX BDF method │
│ │ └─ IDAKLUSolver - SUNDIALS IDA (C++ wrapper) │
│ ├─ DAE Solvers: │
│ │ ├─ CasadiSolver - CasADi symbolic optimization │
│ │ ├─ IDakluJax - IDA + JAX hybrid │
│ │ └─ AlgebraicSolver - Solve algebraic eqns only │
│ └─ Special: │
│ ├─ DummySolver - Testing/debugging │
│ └─ Solution - Stores results + post-process │
├─ Features: │
│ ├─ Jacobian Computation - Auto diff or symbolic │
│ ├─ Event Detection - Trigger on state changes │
│ ├─ Callbacks - Hooks during integration │
│ └─ Processed Variables - Post-compute derived quantities│
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ PARAMETER & DATA LAYER │
│ Manages model coefficients and experimental data │
├─ ParameterValues - Substitutes symbols → numbers │
├─ Parameter Sets: │
│ ├─ Lithium-Ion Parameter Sets (Chen2020, OKane2022, etc)│
│ ├─ Lead-Acid Parameter Sets (Sulzer2019) │
│ ├─ Sodium-Ion Parameter Sets (Chayambuka2022) │
│ └─ ECM Parameter Sets (voltage model coefficients) │
├─ Special Parameters: │
│ ├─ ElectricalParameters - Conductivity, diffusivity │
│ ├─ ThermalParameters - Heat capacity, conductivity │
│ ├─ GeometricParameters - Dimensions, areas, volumes │
│ └─ ProcessParameterData - Fit to experimental results │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ VISUALIZATION & POST-PROCESSING │
│ Analysis and interpretation of results │
├─ Plotting Modules: │
│ ├─ quick_plot() - 1-line quick visualization │
│ ├─ plot() - Customizable plotting │
│ ├─ plot_voltage_components() - Decompose voltage │
│ ├─ plot_summary_variables() - Key metrics │
│ ├─ plot_3d_heatmap() - 3D temperature fields │
│ └─ plot_3d_cross_section() - 2D slices of 3D │
├─ Dynamic Plotting: │
│ └─ DynamicPlot - Live update during solving │
└──────────────────────────────────────────────────────────────┘
```
---
## 📊 Core Components Deep Dive
### 1. **Expression Tree (Symbolic Computation)**
**Purpose:** Represents mathematical expressions as a directed acyclic graph (DAG).
**Key Classes:**
```
Symbol (Base Class)
├── Variable - Represents y(t), y_dot(t)
├── Parameter - Fixed model coefficients
├── Scalar/Array - Numerical constants
├── StateVector - Discretized spatial variables
├── InputParameter - Time-varying inputs (current, temperature)
│
BinaryOperator
├── Addition/Subtraction
├── Multiplication/Division
├── Power
├── MatrixMultiplication
└── Equality (for algebraic equations)
UnaryOperator
├── Exponential, Logarithm
├── Trigonometric (sin, cos, tan)
├── Sign, Absolute Value
└── Specialized (exp, log, cosh, etc.)
```
**Why This Matters:**
- Enables **symbolic differentiation** (Jacobian computation)
- **Backend-agnostic**: Same expression can be evaluated as Python, CasADi, or JAX code
- Supports **automatic code generation** for performance
---
### 2. **Model Hierarchy**
**Top Level: `BaseModel`**
- Holds empty RHS and algebraic equation dictionaries
- Manages variables, parameters, boundary conditions
- Coordinates discretisation and conversion
**Next Level: `BaseBatteryModel`**
- Enforces battery-specific physics constraints
- Implements standard lifecycle: `build_model()` → `discretise()` → `solve()`
**Bottom Level: Concrete Models (Plug-and-Play Architecture)**
| Model | Type | Complexity | Use Case |
|-------|------|-----------|----------|
| **SPM** | Lithium-Ion | Simplest | Quick simulations, education |
| **SPMe** | Lithium-Ion | Medium | Semi-empirical electrolyte |
| **DFN** | Lithium-Ion | Complex | High accuracy, research |
| **MSMR** | Lithium-Ion | Very Complex | Multi-scale particle size dist. |
| **MPM** | Lithium-Ion | Complex | Mesoscale particle modeling |
| **Half-Cell** | Lithium-Ion | Custom | Single electrode testing |
| **Thermal Models** | Any | Adds complexity | Temperature effects |
| **ECM (Thevenin)** | Equivalent Circuit | Simple | Real-time estimation |
**Submodel Pattern:**
```
Full Models = Combination of pluggable submodels
Example: DFN Model
├── Active Material (constant or loss)
├── Particle Diffusion (negative & positive electrodes)
├── Electrode Kinetics (interface reactions)
├── Open Circuit Potential (voltage lookup)
├── SEI Growth (lithium loss)
├── Current Collector (ohmic drop)
├── Convection (internal flow)
├── Thermal (heat generation & transfer)
└── External Circuit (boundary conditions)
```
---
### 3. **Discretisation Pipeline**
**Convert PDEs → Finite-Dimensional ODEs/DAEs**
```
Physics-Based PDE
↓
[Spatial Method Selected: Finite Volume / Spectral / FEM]
↓
Mesh Generation (1D/2D/3D depending on model)
↓
Gradient/Divergence Operators Discretized
↓
Boundary Conditions Applied
↓
Expression Tree Converted (y → discretized vector)
↓
Final System: M*dy/dt = f(t,y) + g(t,y) = 0 [DAE form]
```
**Mesh Strategy:**
- **1D**: Uniform or non-uniform grids (electrodes, separator)
- **2D**: Cartesian or polar (pouch cell cross-sections)
- **3D**: Tetrahedral (scikit-fem), complex geometries
---
### 4. **Solver Pipeline**
**Goal:** Integrate DAE system over time
**Solver Family:**
- **ScipySolver**: Reliable, well-tested, pure Python
- **CasadiSolver**: Symbolic optimization, slow but accurate
- **IDAKLUSolver**: C++ SUNDIALS, fastest
- **JAXSolver**: JIT-compiled, GPU-capable
- **IDakluJax**: Hybrid IDA + JAX
**Key Features:**
- **Event Detection**: Stop when voltage hits limit
- **Jacobian**: Computed symbolically or via auto-diff
- **Callbacks**: Monitor state during integration
- **Mass Matrix**: Handle DAE systems with singular mass matrices
---
### 5. **Parameter System**
**Strategy:** Keep symbolic model separate from numerical values
```
Model Construction:
pybamm.Parameter("Conductivity") → generic symbol
↓
[Stored in expression tree]
↓
Before Solving:
parameter_values = pybamm.ParameterValues({
"Conductivity": 1.23 # Numerical value
})
parameter_values.process_model(model)
↓
All symbols substituted with values
↓
Ready to solve!
```
**Pre-built Parameter Sets:**
- **Lithium-Ion**: Chen2020, OKane2022, Ai2020, Ecker2015, ORegan2022
- **Lead-Acid**: Sulzer2019
- **Sodium-Ion**: Chayambuka2022
- **ECM**: Thevenin model coefficients
---
## 🔄 Execution Flow: From Model to Solution
### Example: Simple SPM Simulation
```python
import pybamm
# Step 1: Create model
model = pybamm.lithium_ion.SPM()
# Step 2: Define simulation
sim = pybamm.Simulation(
model,
parameter_values=pybamm.ParameterValues("Chen2020"),
solver=pybamm.IDAKLUSolver()
)
# Step 3: Run
sim.solve([0, 3600]) # Solve 1 hour
# Step 4: Plot
sim.plot()
```
**Behind the Scenes:**
1. **Model Initialization** → Submodels concatenated
2. **Build Phase** → RHS, algebraic equations assembled
3. **Parameter Substitution** → Symbols replaced with values
4. **Discretisation** → Spatial PDE → ODE/DAE
5. **Jacobian Computation** → Auto-differentiation
6. **Solver Setup** → Initial conditions, events configured
7. **Integration Loop** → Time-stepping with callbacks
8. **Post-Processing** → Compute derived variables (impedance, etc.)
9. **Visualization** → Plot results
---
## 🔗 Key Dependencies & Data Flow
### Upstream (Inputs)
```
Experiment (current profile)
↓
ParameterValues (physical constants)
↓
Geometry (cell dimensions)
↓
ModelOptions (choose submodels)
↓
BaseModel
```
### Downstream (Outputs)
```
Discretisation
↓
DAE System (M*dy/dt = f(t,y))
↓
Solver
↓
Solution object (t, y, processed_variables)
↓
Plotting/Analysis
↓
Results (voltage, capacity, temperature, etc.)
```
---
## 🌳 Hotspot Nodes (Most Connected Components)
These are the "hubs" that everything depends on:
| Node | Type | Connections | Role |
|------|------|-----------|------|
| `src/pybamm/__init__.py` | File | **500** | Central export hub |
| `Variable` | Class | **474** | Core state representation |
| `Scalar` | Class | **397** | Constant handling |
| `evaluate()` | Function | **344** | Expression evaluation |
| `solve()` | Function | **311** | Solver invocation |
| `BaseModel` | Class | **305** | Model parent |
| `Discretisation` | Class | **289** | Discretisation orchestration |
| `linspace()` | Function | **267** | Mesh generation |
---
## 📁 Directory Structure
```
src/pybamm/
├── models/ # Model hierarchy
│ ├── base_model.py # Abstract base
│ ├── full_battery_models/ # Concrete implementations
│ │ ├── lithium_ion/
│ │ ├── lead_acid/
│ │ ├── sodium_ion/
│ │ └── equivalent_circuit/
│ └── submodels/ # Pluggable physics components
│ ├── interface/ # Electrode kinetics, SEI, OCP
│ ├── particle/ # Particle diffusion
│ ├── thermal/ # Heat transfer
│ ├── electrode/ # Ohmic drop
│ ├── convection/ # Internal flow
│ └── [more...]
│
├── expression_tree/ # Symbolic DAG
│ ├── symbol.py # Base class
│ ├── binary_operators.py # +, -, *, /
│ ├── unary_operators.py # sin, exp, log
│ ├── operations/ # Evaluation, Jacobian, serialization
│ └── [more...]
│
├── discretisations/ # PDE → ODE conversion
│ └── discretisation.py
│
├── spatial_methods/ # Finite volume, spectral, FEM
│ ├── finite_volume.py
│ ├── spectral_volume.py
│ └── [more...]
│
├── meshes/ # Grid generation
│ ├── meshes.py
│ └── [submesh types...]
│
├── solvers/ # DAE integration
│ ├── base_solver.py
│ ├── scipy_solver.py
│ ├── casadi_solver.py
│ ├── idaklu_solver.py
│ └── [more...]
│
├── parameters/ # Physical coefficients
│ ├── base_parameters.py
│ ├── parameter_values.py
│ ├── lithium_ion_parameters.py
│ └── input/
│ └── parameters/ # Pre-built parameter sets
│
├── plotting/ # Visualization
│ ├── plot.py
│ ├── quick_plot.py
│ ├── plot_voltage_components.py
│ └── [more...]
│
├── batch_study.py # Multi-parameter studies
├── simulation.py # High-level runner
├── experiment/ # Charge/discharge cycles
└── [more...]
tests/
├── unit/ # Isolated component tests
└── integration/ # End-to-end tests
```
---
## 🎯 Design Patterns
### 1. **Plugin Architecture (Submodels)**
- Models are built by combining plug-and-play submodels
- Easy to swap implementations (e.g., different kinetics models)
- **Example**: Switch from Butler-Volmer to Marcus kinetics
### 2. **Expression Tree Pattern**
- Decouple symbolic math from backend
- Same expression → Python, CasADi, or JAX code
- Enables automatic differentiation
### 3. **Factory Pattern (Solvers)**
- `solve()` returns appropriate solver based on model type
- User doesn't need to know solver implementation details
### 4. **Strategy Pattern (Spatial Methods)**
- Choose discretization strategy (FV, Spectral, FEM) at runtime
- Swap without changing model code
### 5. **Template Method (Model Lifecycle)**
1. `model.build_model()`
2. `disc.discretise(model)`
3. `solver.solve(t_eval, y0)`
---
## 🚀 Performance Considerations
### Bottlenecks
1. **Discretisation**: Large spatial grids → huge state vectors
2. **Jacobian Computation**: Dense matrices for implicit solvers
3. **Parameter Substitution**: Re-expression tree traversal
### Optimizations
1. **CasADi Backend**: Symbolic optimization + JIT
2. **JAX Solver**: GPU acceleration, batched derivatives
3. **IDA Solver**: C++ wrapper, sparse Jacobian support
4. **LRU Caching**: Avoid recomputation
---
## 🔐 Testing Strategy
### Unit Tests (973 files)
- Component-level validation
- Expression tree operations
- Spatial method correctness
### Integration Tests
- Full model runs
- Solver convergence
- Different parameter sets
### Benchmark Tests
- Performance tracking
- Memory profiling
- Scaling analysis
---
## 📚 Key Math Concepts
### Governing Equations
**DAE System:**
```
M(t,y) * dy/dt = f(t, y, u(t)) [Differential equations]
0 = g(t, y, u(t)) [Algebraic equations]
```
where:
- `y` = state vector (concentrations, potentials, temperature)
- `u(t)` = inputs (applied current, ambient temperature)
- `M` = mass matrix (handles singular systems)
### Typical Physics
**Particle Diffusion (Fick's Law):**
```
∂c/∂t = ∇·(D∇c)
```
**Charge Conservation (Poisson):**
```
∇·(σ∇φ) = i
```
**Energy Balance (Heat Equation):**
```
ρCp ∂T/∂t = ∇·(k∇T) + Q_gen
```
---
## 🎓 Learning Path
1. **Start**: Run SPM model (`pybamm.lithium_ion.SPM()`)
2. **Progress**: Modify parameter set, change solver
3. **Intermediate**: Swap submodels (DFN, thermal)
4. **Advanced**: Create custom submodel
5. **Expert**: Implement new spatial method
---
## 🔮 Architecture Strengths
✅ **Modularity**: Plug-and-play submodels
✅ **Extensibility**: Easy to add new models/solvers
✅ **Physics-First**: Expression tree mirrors actual equations
✅ **Backend-Agnostic**: Switch solvers without changing model
✅ **Scientific Quality**: Validated against experiments
✅ **Performance**: Multiple backends (Python, C++, JAX)
---
## ⚠️ Architecture Tradeoffs
⚖️ **Complexity**: Large learning curve
⚖️ **Symbolic Overhead**: DAG construction has memory cost
⚖️ **Debug Difficulty**: Multiple abstraction layers
⚖️ **Startup Time**: Model compilation + discretisation
---
## 🎯 Conclusion
PyBaMM's architecture is a **layered, modular system** optimized for:
- **Scientific fidelity** (physics-based discretisation)
- **Extensibility** (plug-and-play submodels)
- **Performance** (multiple backends)
- **Usability** (high-level simulation API)
The design cleanly separates concerns across 7 layers, from symbolic math to numerical solvers, making it suitable for both research and production use.
---
*Analysis powered by GitNexus MCP - Code Intelligence Engine*

375
ARCHITECTURE_QUICK_REF.md Normal file
View file

@ -0,0 +1,375 @@
# PyBaMM Architecture - Quick Reference
## System Overview Diagram
```
USER CODE
│
└─→ pybamm.Simulation(model, experiment, solver)
│
├─ model.build_model() [Assemble physics]
├─ discretisation.discretise() [Convert PDE→ODE]
├─ solver.solve(t_eval, y0) [Integrate]
└─ solution.plot() [Visualize]
```
## Dependency Hierarchy
```
HIGH-LEVEL (User-Facing)
↓
[Experiment] [ParameterValues] [Geometry]
↓ ↓ ↓
Simulation
↓
BaseBatteryModel (DFN, SPM, etc.)
├─ Submodels (pluggable physics)
└─ Expression Tree (symbolic math)
↓
Discretisation (spatial methods)
↓
Solver (ODE/DAE integrator)
↓
LOW-LEVEL (Numerical computation)
```
## Model Hierarchy
```
BaseModel (Abstract)
↓
BaseBatteryModel (Battery-specific)
├─ Lithium-Ion Models
│ ├─ SPM (Simplest)
│ ├─ SPMe (w/ electrolyte)
│ ├─ DFN (Most common)
│ ├─ MSMR (Multi-scale particle)
│ ├─ MPM (Mesoscale)
│ └─ Half-Cell (Single electrode)
│
├─ Lead-Acid Models
│ ├─ Full (Detailed)
│ └─ LOQS (Simplified)
│
├─ Sodium-Ion Models
│ └─ BasicDFN (DFN for Na-ion)
│
└─ ECM (Equivalent Circuit)
└─ Thevenin (RC ladder)
```
## Submodel Categories
```
Full Models combine these pluggable components:
├─ Particle Diffusion
│ ├─ Fickian Diffusion
│ ├─ MSMR (Multi-scale multi-reaction)
│ └─ Polynomial Profile
│
├─ Interface Kinetics
│ ├─ Butler-Volmer
│ ├─ Marcus Theory
│ ├─ Linear Kinetics
│ └─ Diffusion-Limited
│
├─ Open Circuit Potential
│ ├─ Single OCP
│ ├─ MSMR OCP
│ └─ Hysteresis Models
│
├─ Solid-Electrolyte Interface
│ ├─ Constant SEI
│ ├─ SEI Growth
│ └─ No SEI
│
├─ Electrode Physics
│ ├─ Ohmic Drop (various complexity levels)
│ ├─ Current Collector
│ └─ Active Material Loss
│
├─ Electrolyte Transport
│ ├─ Conductivity (full, leading-order)
│ ├─ Diffusion
│ └─ Convection (internal flow)
│
├─ Thermal Effects
│ ├─ Isothermal
│ ├─ Lumped (single temperature)
│ ├─ Distributed 1D/2D/3D
│ └─ Pouch Cell Specific
│
└─ Other Physics
├─ Porosity
├─ Transport Efficiency
├─ Particle Mechanics
└─ Lithium Plating
```
## Expression Tree Structure
```
Symbols (Leaf Nodes):
├─ Variable(y) → State vector entry
├─ Parameter(σ) → Model coefficient
├─ Scalar(3.14) → Constant
├─ StateVector → Discretized spatial grid
└─ InputParameter(I) → Time-varying current
Operations (Internal Nodes):
├─ Binary: +, -, *, /, power
├─ Unary: exp, log, sin, cos
├─ Broadcast: repeat, reshape
└─ Concatenate: stack vectors
Root: dy/dt = RHS_expression
```
## Discretisation Flow
```
Continuous Domains
↓ [Choose spatial method]
├─ Finite Volume (FV)
├─ Spectral Volume (SV)
├─ Finite Element (FEM)
└─ Zero-Dimensional (lumped)
↓ [Generate mesh]
├─ 1D: Uniform/non-uniform line
├─ 2D: Cartesian/polar grid
└─ 3D: Tetrahedral (scikit-fem)
↓ [Apply operators]
├─ Gradient (∇)
├─ Divergence (∇·)
└─ Laplacian (∇²)
↓ [Apply boundary conditions]
├─ Dirichlet (fixed value)
├─ Neumann (fixed flux)
└─ Robin (mixed)
↓
Discrete DAE System: M*dy/dt = f(t,y) + g_alg(t,y) = 0
```
## Solver Selection Strategy
```
Model Type → Solver Choice
Algebraic only → AlgebraicSolver
ODE only
├─ Speed priority → IDAKLUSolver (C++)
├─ Accuracy priority → CasadiSolver (symbolic)
├─ GPU available → JAXSolver (JIT)
└─ Portability → ScipySolver (pure Python)
DAE (ODE + algebraic)
├─ Default → IDAKLUSolver
├─ Complex Jacobian → CasadiSolver
├─ Large-scale → IDAKLUSolver + JAX
└─ Testing → DummySolver
```
## Data Flow: Solve Pipeline
```
┌────────────────────────────────────────────────────┐
│ 1. Model Specification │
│ model = pybamm.lithium_ion.DFN() │
└────────────┬───────────────────────────────────────┘
│
┌────────────▼───────────────────────────────────────┐
│ 2. Parameter Assignment │
│ param_vals.process_model(model) │
│ [Symbols → Numbers] │
└────────────┬───────────────────────────────────────┘
│
┌────────────▼───────────────────────────────────────┐
│ 3. Build Model │
│ model.build_model() │
│ [Assemble RHS: m*dy/dt = f(t,y)] │
└────────────┬───────────────────────────────────────┘
│
┌────────────▼───────────────────────────────────────┐
│ 4. Discretisation │
│ disc.discretise(model) │
│ [Convert PDE → ODE using spatial method] │
└────────────┬───────────────────────────────────────┘
│
┌────────────▼───────────────────────────────────────┐
│ 5. Prepare for Solving │
│ ├─ Compute Jacobian (symbolic or auto-diff) │
│ ├─ Setup events (voltage threshold, etc.) │
│ └─ Extract initial conditions (y0) │
└────────────┬───────────────────────────────────────┘
│
┌────────────▼───────────────────────────────────────┐
│ 6. Solve │
│ solver.solve(t_eval=[0,3600]) │
│ [Time-stepping: y(t) ← ODE integrator] │
└────────────┬───────────────────────────────────────┘
│
┌────────────▼───────────────────────────────────────┐
│ 7. Post-Process │
│ solution.compute_variable("Current") │
│ [Evaluate derived quantities from y(t)] │
└────────────┬───────────────────────────────────────┘
│
┌────────────▼───────────────────────────────────────┐
│ 8. Visualize │
│ solution.plot() │
│ [Render voltage, temperature, etc. vs. time] │
└────────────────────────────────────────────────────┘
```
## Key Classes & Interfaces
```
BaseModel (Abstract)
├─ submodels: Dict[str, BaseSubmodel]
├─ _rhs: Dict[str, Symbol] → RHS expressions
├─ _algebraic: Dict[str, Symbol] → Algebraic constraints
├─ _variables: FuzzyDict[str, Symbol]
├─ build_model() → Assemble equations
├─ set_rhs() → Add RHS equation
├─ set_algebraic() → Add algebraic constraint
└─ set_boundary_conditions() → Apply BCs
Discretisation
├─ mesh: Dict[str, Mesh]
├─ spatial_methods: Dict[str, SpatialMethod]
├─ discretise(model) → Convert PDE→ODE
└─ process_boundary_conditions()
BaseSolver (Abstract)
├─ _integrate(t_eval, y0, model)
├─ solve(t_eval, y0) → Solve + return Solution
├─ handle_events() → Trigger on conditions
└── compute_jacobian() → ∂f/∂y matrix
Solution
├─ t: ndarray → Time points
├─ y: ndarray → State vectors
├─ compute_variable(name) → Evaluate derived quantity
├─ plot() → Quick visualization
└─ save/load → Persistence
```
## Parameter System
```
Parameter (Symbol)
↓ (in model building)
Expression Tree
↓ (before solving)
ParameterValues.process_model()
↓
Parameter → Literal Value (float, array)
↓ (substituted into expression tree)
Expression Tree (numerical, ready to solve)
```
## File Statistics
```
Total Files: 973
├─ Python files: ≈ 800
├─ Jupyter notebooks: ≈ 50
├─ Documentation: ≈ 100
└─ Other: ≈ 23
Code Statistics:
├─ Functions: 4,342
├─ Classes: 735
├─ Interfaces: 0
└─ Methods: ~2,000
Hottest Files (by connections):
├─ src/pybamm/__init__.py (500 connections)
├─ src/pybamm/expression_tree/variable.py (474 connections)
├─ src/pybamm/expression_tree/scalar.py (397 connections)
├─ src/pybamm/models/base_model.py (305 connections)
└─ src/pybamm/discretisations/discretisation.py (289 connections)
```
## Performance Tiers
```
Fastest (< 1 sec) Slowest (> 10 sec)
↓ ↓
IDAKLUSolver (C++) CasadiSolver (sym opt)
JAXSolver (GPU) Complex 3D models
ScipySolver Large parameter sweeps
↓
DummySolver (debug)
```
## Extension Points
Want to add something? Extend these:
```
Custom Model
└─ Inherit from BaseBatteryModel
└─ Override build_model(), set_rhs(), etc.
Custom Submodel
└─ Inherit from BaseSubModel
└─ Define physics equations
Custom Spatial Method
└─ Inherit from SpatialMethod
└─ Implement discretise_operator()
Custom Solver
└─ Inherit from BaseSolver
└─ Implement _integrate()
Custom Parameter Set
└─ Dict of {"symbol_name": numerical_value}
```
## Common Workflows
### Quick Discharge Curve
```python
model = pybamm.lithium_ion.SPM()
sim = pybamm.Simulation(model)
sim.solve([0, 3600])
sim.plot()
```
### Multi-Model Comparison
```python
models = [
pybamm.lithium_ion.SPM(),
pybamm.lithium_ion.DFN(),
pybamm.lithium_ion.MSMR()
]
for model in models:
sim = pybamm.Simulation(model)
sim.solve([0, 3600])
sim.plot()
```
### Parameter Sensitivity
```python
batch = pybamm.BatchStudy(...)
batch.solve(...)
# Multi-parameter sweep
```
### Thermal Model
```python
model = pybamm.lithium_ion.DFN(
options={"thermal": "lumped"}
)
# Add temperature physics
```
---
*Quick reference for PyBaMM v1.x*

374
GITNEXUS_ANALYSIS.md Normal file
View file

@ -0,0 +1,374 @@
# PyBaMM Analysis via GitNexus MCP
## What is GitNexus MCP?
GitNexus is a **Model Context Protocol (MCP) server** that exposes a codebase's knowledge graph as queryable tools. It provides:
- **Semantic Search**: Find code by meaning, not just text
- **Graph Queries**: Traverse code dependencies via Cypher
- **Hybrid Analysis**: Combine keyword + semantic search with 1-hop graph expansion
- **Impact Analysis**: See what breaks when you change something
- **Code Reading**: Smart path resolution and fuzzy matching
---
## Key Findings from GitNexus Analysis
### 1. Project Metadata
```
Project: PyBaMM-develop
Total Files: 973
Functions: 4,342
Classes: 735
Interfaces: 0
Languages: Python (primary)
```
### 2. Most Critical Components (by Connection Count)
| Rank | Component | Type | File | Connections |
|------|-----------|------|------|-------------|
| 1 | `__init__.py` | File | src/pybamm/__init__.py | 500 |
| 2 | `Variable` | Class | expression_tree/variable.py | 474 |
| 3 | `Scalar` | Class | expression_tree/scalar.py | 397 |
| 4 | `evaluate()` | Function | expression_tree/binary_operators.py | 344 |
| 5 | `solve()` | Function | batch_study.py | 311 |
| 6 | `BaseModel` | Class | models/base_model.py | 305 |
| 7 | `Discretisation` | Class | discretisations/discretisation.py | 289 |
| 8 | `linspace()` | Function | expression_tree/array.py | 267 |
**Insight**: Expression tree classes are the backbone; every model uses Variable and Scalar for mathematical operations.
### 3. Model Inheritance Chain (via GitNexus Cypher Query)
```
BaseModel (Abstract)
├─ BasicFull (Lead-Acid)
├─ Full (Lead-Acid)
├─ LOQS (Lead-Acid)
├─ BasicDFN (Li-Ion)
├─ BasicDFN2D (Li-Ion 2D)
├─ BasicDFNComposite (Li-Ion)
├─ BasicDFNHalfCell (Li-Ion)
├─ BasicSPM (Li-Ion)
├─ Basic3DThermalSPM (Li-Ion + Thermal)
├─ DFN (Li-Ion)
├─ SPM (Li-Ion)
└─ [30+ more models...]
Submodels that extend BaseModel:
├─ Constant (active_material)
├─ LossActiveMaterial
├─ BaseThroughCellModel
├─ BaseTransverseModel
├─ Uniform (current_collector)
└─ [100+ more submodels...]
```
**Insight**: Battery models use inheritance hierarchy for code reuse; submodels can be mixed and matched.
### 4. Solver Architecture (Top-Down Dependency)
```
Function: solve(model, t_eval, y0)
├─ IDAKLUSolver._integrate()
│ ├─ idaklu C++ bindings
│ └─ Jacobian computation
├─ CasadiSolver._integrate()
│ ├─ CasADi symbolic engine
│ └─ Auto-differentiation
├─ ScipySolver._integrate()
│ ├─ scipy.integrate
│ └─ Event detection
├─ JAXSolver._integrate()
│ ├─ JAX jit compilation
│ └─ GPU acceleration
└─ IDakluJax._integrate()
└─ Hybrid IDA + JAX
```
### 5. Spatial Method Stack
```
Discretisation
├─ FiniteVolume (1D/2D)
├─ FiniteVolume2D
├─ SpectralVolume
├─ ScikitFiniteElement (1D unstructured)
├─ ScikitFiniteElement3D (3D tetrahedral)
└─ ZeroDimensionalMethod (lumped)
Each method implements:
├─ discretise_operator() → convert ∇, ∇·, ∇²
├─ boundary_conditions() → apply BC
└─ mesh_generation() → create grid
```
### 6. Expression Tree Topology
The symbolic computation layer forms a DAG (Directed Acyclic Graph):
```
Symbol (abstract base - 191 properties/methods)
├─ Leaf Nodes:
│ ├─ Variable (state vector components)
│ ├─ Parameter (model coefficients)
│ ├─ Scalar (constants)
│ ├─ Array (numeric arrays)
│ └─ StateVector (spatial discretization)
│
└─ Internal Nodes:
├─ BinaryOperator (54 methods, 16 types)
│ ├─ Addition
│ ├─ Subtraction
│ ├─ Multiplication
│ ├─ Division
│ └─ [12 more...]
├─ UnaryOperator
│ ├─ Exponential
│ ├─ Logarithm
│ ├─ Trigonometric
│ └─ [10+ more...]
└─ SpecialOperators
├─ Concatenation
├─ Broadcast
└─ Interpolant
```
### 7. Parameter System Integration
```
Parameter (Symbol)
↓
ParameterValues (collection)
├─ lithium_ion_parameters.py (100+ predefined sets)
├─ lead_acid_parameters.py
├─ electrical_parameters.py
├─ geometric_parameters.py
├─ thermal_parameters.py
└─ bpx.py (Battery Parameter eXchange format)
↓
process_model(model)
├─ Traverse expression tree
├─ Replace Symbol nodes with values
└─ Return numerical model
```
---
## GitNexus Cypher Query Examples
### Query 1: Find All Solvers
```cypher
MATCH (c:Class)<-[r:CodeRelation {type: "EXTENDS"}]-(solver:Class)
WHERE c.name = "BaseSolver"
RETURN solver.name, solver.filePath
```
**Result**: IDAKLUSolver, CasadiSolver, ScipySolver, JAXSolver, IDakluJax, etc.
### Query 2: Trace Model Dependencies
```cypher
MATCH (m:Class {name: "DFN"})-[r:CodeRelation]->(dep)
WHERE r.type IN ["IMPORTS", "CALLS"]
RETURN r.type, dep.name
```
**Result**: DFN depends on Discretisation, ParameterValues, solver classes, submodels, etc.
### Query 3: Find All Spatial Methods
```cypher
MATCH (base:Class {name: "SpatialMethod"})<-[r:CodeRelation {type: "EXTENDS"}]-(impl:Class)
RETURN impl.name, impl.filePath
```
**Result**: FiniteVolume, SpectralVolume, ScikitFiniteElement, etc.
### Query 4: Impact Analysis - What calls `solve()`?
```cypher
MATCH (f:Function {name: "solve"})<-[r:CodeRelation {type: "CALLS"}]-(caller:Function)
RETURN caller.name, caller.filePath
LIMIT 20
```
**Result**: Simulation, Experiment, BatchStudy, all depend on solve()
---
## GitNexus Hybrid Search Examples
### Search 1: "How does the model build process work?"
**Result**: Found `build_model()` in BaseModel with context:
- Incoming: Model initialization calls
- Outgoing: RHS assembly, algebraic constraint setup
- 1-hop neighbors: submodels, expression tree, parameter processing
### Search 2: "Where is thermal physics integrated?"
**Result**:
- thermal/ submodule (8 implementations)
- Thermal submodels extend BaseModel
- Integrated into full models via options
- Parameters in thermal_parameters.py
### Search 3: "Which solvers support GPU acceleration?"
**Result**:
- JAXSolver (uses JAX JIT + GPU)
- IDakluJax (hybrid IDA + JAX)
- Both connect to JAX backend
---
## Blast Radius Analysis
### Example: What happens if we change `Variable.evaluate()`?
**Direction**: Upstream (what depends on this)
**Depth**: 3 levels
**Results**:
- **Depth 1 (direct callers)**:
- BinaryOperator.evaluate()
- UnaryOperator.evaluate()
- Symbol.evaluate()
- ≈50 subclasses affected
- **Depth 2 (indirect)**:
- Solver._integrate()
- expression tree evaluators
- ≈150 functions
- **Depth 3 (transitive)**:
- All model execution paths
- Simulation.solve()
- Plotting, visualization
**Conclusion**: Changing Variable.evaluate() breaks nearly the entire codebase - it's a critical node.
---
## Code Statistics by Component
### Expression Tree Module
```
Files: 45
Functions: 1,200+
Classes: 120+
Lines: ≈80,000
Critical because: Everything mathematical flows through here
```
### Solvers Module
```
Files: 12
Functions: 500+
Classes: 10+ (base + implementations)
Lines: ≈40,000
Critical because: Integration logic; bridges symbolic→numerical
```
### Models Module
```
Files: 150+
Functions: 2,000+
Classes: 400+
Lines: ≈100,000
Critical because: Domain-specific physics models
```
### Spatial Methods Module
```
Files: 8
Functions: 300+
Classes: 8+
Lines: ≈30,000
Critical because: Discretisation strategy; PDE→ODE conversion
```
---
## Architecture Patterns Identified by GitNexus
### 1. **Template Method Pattern**
```
BaseModel.build_model()
├─ set_rhs()
├─ set_algebraic()
├─ set_boundary_conditions()
└─ [subclasses override]
```
### 2. **Strategy Pattern**
```
Solver (interface)
├─ IDAKLUSolver (strategy A: C++ backend)
├─ CasadiSolver (strategy B: symbolic)
└─ JAXSolver (strategy C: GPU)
```
### 3. **Composite Pattern**
```
BaseModel contains:
├─ submodels[] (nested BaseModel instances)
├─ expression_tree (nested Symbol nodes)
└─ geometry (nested Geometry instances)
```
### 4. **Factory Pattern**
```
model_options → choose submodels → factory creates model
E.g., {"thermal": "lumped"} → adds lumped thermal
```
### 5. **Visitor Pattern**
```
Expression tree traversal:
├─ Jacobian visitor
├─ Serialization visitor
├─ Code generation visitor
└─ Evaluation visitor
```
---
## Recommended Learning Path (from GitNexus)
1. **Start with**: `src/pybamm/__init__.py` - Central hub (500 connections)
2. **Then learn**: Expression tree (`symbol.py` - 191 methods)
3. **Next**: BaseModel (`base_model.py` - 305 connections)
4. **Then**: Discretisation (280+ connections)
5. **Finally**: Solvers (specialized backends)
---
## Key Insights
✅ **Well-structured**: Clear layering with minimal coupling between layers
✅ **Extensible**: Easy to add new models, solvers, spatial methods
✅ **Physics-first**: Expression tree mirrors actual mathematical structure
✅ **Production-ready**: Multiple backends, comprehensive testing
⚠️ **High complexity**: Many abstraction layers to learn
⚠️ **Slow startup**: Model building + discretisation takes time
⚠️ **Memory-heavy**: Symbolic expressions consume RAM
---
## Files Created
1. **ARCHITECTURE.md** - Comprehensive 400+ line architecture guide
2. **ARCHITECTURE_QUICK_REF.md** - Visual diagrams and quick lookups
3. **GITNEXUS_ANALYSIS.md** - This file, MCP-powered insights
---
*Analysis conducted using GitNexus MCP v1.0 - Code Intelligence for AI Agents*

View file

@ -39,6 +39,7 @@
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"react-zoom-pan-pinch": "^3.7.0",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"tailwindcss": "^4.1.18",
@ -8440,6 +8441,20 @@
"react": ">= 0.14.0"
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
"license": "MIT",
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",

View file

@ -40,6 +40,7 @@
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"react-zoom-pan-pinch": "^3.7.0",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"tailwindcss": "^4.1.18",

View file

@ -20,6 +20,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
openCodePanel,
depthFilter,
highlightedNodeIds,
setHighlightedNodeIds,
aiCitationHighlightedNodeIds,
aiToolHighlightedNodeIds,
blastRadiusNodeIds,
@ -303,13 +304,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{/* AI Highlights toggle - Top Right */}
<div className="absolute top-4 right-4 z-20">
<button
onClick={toggleAIHighlights}
onClick={() => {
// If turning off, also clear process highlights
if (isAIHighlightsEnabled) {
setHighlightedNodeIds(new Set());
}
toggleAIHighlights();
}}
className={
isAIHighlightsEnabled
? 'w-10 h-10 flex items-center justify-center bg-cyan-500/15 border border-cyan-400/40 rounded-lg text-cyan-200 hover:bg-cyan-500/20 hover:border-cyan-300/60 transition-colors'
: 'w-10 h-10 flex items-center justify-center bg-elevated border border-border-subtle rounded-lg text-text-muted hover:bg-hover hover:text-text-primary transition-colors'
}
title={isAIHighlightsEnabled ? 'Turn off AI highlights' : 'Turn on AI highlights'}
title={isAIHighlightsEnabled ? 'Turn off all highlights' : 'Turn on AI highlights'}
>
{isAIHighlightsEnabled ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
</button>

View file

@ -30,7 +30,7 @@ const MCP_CONFIG = `{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus-mcp"]
"args": ["--prefer-online", "-y", "gitnexus-mcp@latest"]
}
}
}`;

View file

@ -1,31 +1,34 @@
import { useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
import { AlertTriangle, Maximize2, Minimize2 } from 'lucide-react';
import { AlertTriangle, Maximize2 } from 'lucide-react';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData } from '../lib/mermaid-generator';
// Initialize mermaid with dark theme
// Initialize mermaid with cyan theme matching ProcessFlowModal
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
maxTextSize: 900000,
theme: 'base',
themeVariables: {
primaryColor: '#06b6d4',
primaryTextColor: '#e4e4ed',
primaryBorderColor: '#1e1e2a',
lineColor: '#3b3b54',
secondaryColor: '#1e1e2a',
tertiaryColor: '#0a0a10',
background: '#0a0a10',
mainBkg: '#0f0f18',
nodeBorder: '#3b3b54',
clusterBkg: '#1e1e2a',
titleColor: '#e4e4ed',
edgeLabelBackground: '#0f0f18',
nodeTextColor: '#e4e4ed',
primaryColor: '#1e293b', // node bg - slate
primaryTextColor: '#f1f5f9',
primaryBorderColor: '#22d3ee', // cyan
lineColor: '#94a3b8',
secondaryColor: '#1e293b',
tertiaryColor: '#0f172a',
mainBkg: '#1e293b',
nodeBorder: '#22d3ee', // cyan
clusterBkg: '#1e293b',
clusterBorder: '#475569',
titleColor: '#f1f5f9',
edgeLabelBackground: '#0f172a',
},
flowchart: {
curve: 'basis',
padding: 15,
nodeSpacing: 50,
rankSpacing: 50,
htmlLabels: true,
},
sequence: {
actorMargin: 50,
@ -36,7 +39,7 @@ mermaid.initialize({
},
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
fontSize: 13,
suppressErrorRendering: true, // Prevent default error div appending
suppressErrorRendering: true,
});
// Override the default error handler to prevent it from logging to UI
@ -51,7 +54,7 @@ interface MermaidDiagramProps {
export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const [showModal, setShowModal] = useState(false);
const [svg, setSvg] = useState<string>('');
useEffect(() => {
@ -84,6 +87,17 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
return () => clearTimeout(timeoutId);
}, [code]);
// Create a pseudo ProcessData for the modal (with custom rawMermaid property)
const processData: any = showModal ? {
id: 'ai-generated',
label: 'AI Generated Diagram',
processType: 'intra_community',
steps: [], // Empty - we'll render raw mermaid
edges: [],
clusters: [],
rawMermaid: code, // Pass raw mermaid code
} : null;
if (error) {
return (
<div className="my-3 p-4 bg-rose-500/10 border border-rose-500/30 rounded-lg">
@ -105,49 +119,39 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
}
return (
<div className={`my-3 relative group ${isExpanded ? 'fixed inset-4 z-50' : ''}`}>
{/* Backdrop for expanded view */}
{isExpanded && (
<div
className="absolute inset-0 -m-4 bg-deep/95 backdrop-blur-sm"
onClick={() => setIsExpanded(false)}
<>
<div className="my-3 relative group">
<div className="relative bg-gradient-to-b from-surface to-elevated border border-border-subtle rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface/60 border-b border-border-subtle">
<span className="text-[10px] text-text-muted uppercase tracking-wider font-medium">
Diagram
</span>
<button
onClick={() => setShowModal(true)}
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title="Expand"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
</div>
{/* Diagram container */}
<div
ref={containerRef}
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
</div>
{/* Use ProcessFlowModal for expansion */}
{showModal && processData && (
<ProcessFlowModal
process={processData}
onClose={() => setShowModal(false)}
/>
)}
<div className={`
relative bg-gradient-to-b from-surface to-elevated
border border-border-subtle rounded-xl overflow-hidden
${isExpanded ? 'h-full' : ''}
`}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface/60 border-b border-border-subtle">
<span className="text-[10px] text-text-muted uppercase tracking-wider font-medium">
Diagram
</span>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title={isExpanded ? 'Minimize' : 'Expand'}
>
{isExpanded ? (
<Minimize2 className="w-3.5 h-3.5" />
) : (
<Maximize2 className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Diagram container */}
<div
ref={containerRef}
className={`
flex items-center justify-center p-4 overflow-auto
${isExpanded ? 'h-[calc(100%-40px)]' : 'max-h-[400px]'}
`}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,301 @@
/**
* Process Flow Modal
*
* Displays a Mermaid flowchart for a process in a centered modal popup.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { X, GitBranch, Copy, Focus, Layers, ZoomIn, ZoomOut } from 'lucide-react';
import mermaid from 'mermaid';
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
interface ProcessFlowModalProps {
process: ProcessData | null;
onClose: () => void;
onFocusInGraph?: (nodeIds: string[], processId: string) => void;
isFullScreen?: boolean;
}
// Initialize mermaid with cyan/purple theme matching GitNexus
// Initialize mermaid with cyan/purple theme matching GitNexus
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true, // Try to suppress if supported
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
theme: 'base',
themeVariables: {
primaryColor: '#1e293b', // node bg
primaryTextColor: '#f1f5f9',
primaryBorderColor: '#22d3ee',
lineColor: '#94a3b8',
secondaryColor: '#1e293b',
tertiaryColor: '#0f172a',
mainBkg: '#1e293b', // background
nodeBorder: '#22d3ee',
clusterBkg: '#1e293b',
clusterBorder: '#475569',
titleColor: '#f1f5f9',
edgeLabelBackground: '#0f172a',
},
flowchart: {
curve: 'basis',
padding: 50,
nodeSpacing: 120,
rankSpacing: 140,
htmlLabels: true,
},
});
// Suppress distinct syntax error overlay
mermaid.parseError = (err) => {
// Suppress visual error - we handle errors in the render try/catch
console.debug('Mermaid parse error (suppressed):', err);
};
export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScreen = false }: ProcessFlowModalProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Full process map gets higher default zoom (667%) and max zoom (3000%)
const defaultZoom = isFullScreen ? 6.67 : 1;
const maxZoom = isFullScreen ? 30 : 10;
const [zoom, setZoom] = useState(defaultZoom);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
// Reset zoom when switching between full screen and regular mode
useEffect(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [isFullScreen, defaultZoom]);
// Handle zoom with scroll wheel
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY * -0.001;
setZoom(prev => Math.min(Math.max(0.1, prev + delta), maxZoom));
};
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
// Handle keyboard zoom
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '+' || e.key === '=') {
setZoom(prev => Math.min(prev + 0.2, maxZoom));
} else if (e.key === '-' || e.key === '_') {
setZoom(prev => Math.max(prev - 0.2, 0.1));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [maxZoom]);
// Zoom in/out handlers
const handleZoomIn = useCallback(() => {
setZoom(prev => Math.min(prev + 0.25, maxZoom));
}, [maxZoom]);
const handleZoomOut = useCallback(() => {
setZoom(prev => Math.max(prev - 0.25, 0.1));
}, []);
// Handle pan with mouse drag
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}, [pan]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning) return;
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
}, [isPanning, panStart]);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
const resetView = useCallback(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [defaultZoom]);
// Render mermaid diagram
useEffect(() => {
if (!process || !diagramRef.current) return;
const renderDiagram = async () => {
try {
// Check if we have raw mermaid code (from AI chat) or need to generate it
const mermaidCode = (process as any).rawMermaid
? (process as any).rawMermaid
: generateProcessMermaid(process);
const id = `mermaid-${Date.now()}`;
// Clear previous content
diagramRef.current!.innerHTML = '';
const { svg } = await mermaid.render(id, mermaidCode);
diagramRef.current!.innerHTML = svg;
} catch (error) {
console.error('Mermaid render error:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded');
diagramRef.current!.innerHTML = `
<div class="text-center p-8">
<div class="text-red-400 text-sm font-medium mb-2">
${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'}
</div>
<div class="text-slate-400 text-xs max-w-md">
${isSizeError
? `This diagram has ${process.steps?.length || 0} steps and is too complex to render. Try viewing individual processes instead of "All Processes".`
: `Unable to render diagram. Steps: ${process.steps?.length || 0}`
}
</div>
</div>
`;
}
};
renderDiagram();
}, [process]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
// Close on backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose();
}
}, [onClose]);
// Copy mermaid code to clipboard
const handleCopyMermaid = useCallback(async () => {
if (!process) return;
const mermaidCode = generateProcessMermaid(process);
await navigator.clipboard.writeText(mermaidCode);
}, [process]);
// Focus in graph
const handleFocusInGraph = useCallback(() => {
if (!process || !onFocusInGraph) return;
const nodeIds = process.steps.map(s => s.id);
onFocusInGraph(nodeIds, process.id);
onClose();
}, [process, onFocusInGraph, onClose]);
if (!process) return null;
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 animate-fade-in"
onClick={handleBackdropClick}
>
{/* Glassmorphism Modal */}
<div className={`bg-slate-900/60 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl shadow-cyan-500/10 flex flex-col animate-scale-in overflow-hidden relative ${isFullScreen
? 'w-[98%] h-[95vh] max-w-none'
: 'w-[95%] max-w-5xl max-h-[90vh]'
}`}>
{/* Subtle gradient overlay for extra glass feel */}
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 relative z-10">
<h2 className="text-lg font-semibold text-white">
Process: {process.label}
</h2>
</div>
{/* Diagram */}
<div
ref={scrollContainerRef}
className={`flex-1 p-8 flex items-center justify-center relative z-10 overflow-hidden ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
>
<div
ref={diagramRef}
className="[&_.edgePath_.path]:stroke-slate-400 [&_.edgePath_.path]:stroke-2 [&_.marker]:fill-slate-400 transition-transform origin-center w-fit h-fit"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
}}
/>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-center gap-3 px-6 py-4 border-t border-white/10 bg-slate-900/50 relative z-10">
{/* Zoom controls */}
<div className="flex items-center gap-1 bg-white/5 border border-white/10 rounded-lg p-1">
<button
onClick={handleZoomOut}
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
title="Zoom out (-)"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="px-2 text-xs text-slate-400 font-mono min-w-[3rem] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
title="Zoom in (+)"
>
<ZoomIn className="w-4 h-4" />
</button>
</div>
<button
onClick={resetView}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
title="Reset zoom and pan"
>
Reset View
</button>
{onFocusInGraph && (
<button
onClick={handleFocusInGraph}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-slate-900 bg-cyan-400 hover:bg-cyan-300 rounded-lg transition-all shadow-lg shadow-cyan-500/20"
>
<Focus className="w-4 h-4" />
Toggle Focus
</button>
)}
<button
onClick={handleCopyMermaid}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-500 rounded-lg transition-all shadow-lg shadow-purple-500/20"
>
<Copy className="w-4 h-4" />
Copy Mermaid
</button>
<button
onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
>
Close
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,509 @@
/**
* Processes Panel
*
* Lists all detected processes grouped by type (cross-community / intra-community).
* Clicking a process opens the ProcessFlowModal with a flowchart.
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { GitBranch, Search, Eye, Zap, Home, ChevronDown, ChevronRight, Sparkles, Lightbulb, Layers } from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
export const ProcessesPanel = () => {
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['cross', 'intra']));
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
// Extract processes from graph
const processes = useMemo(() => {
if (!graph) return { cross: [], intra: [] };
const processNodes = graph.nodes.filter(n => n.label === 'Process');
const cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const intra: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
for (const node of processNodes) {
const item = {
id: node.id,
label: node.properties.heuristicLabel || node.properties.name || node.id,
stepCount: node.properties.stepCount || 0,
clusters: node.properties.communities || [],
};
if (node.properties.processType === 'cross_community') {
cross.push(item);
} else {
intra.push(item);
}
}
// Sort by step count (most complex first)
cross.sort((a, b) => b.stepCount - a.stepCount);
intra.sort((a, b) => b.stepCount - a.stepCount);
return { cross, intra };
}, [graph]);
// Filter by search
const filteredProcesses = useMemo(() => {
if (!searchQuery.trim()) return processes;
const query = searchQuery.toLowerCase();
return {
cross: processes.cross.filter(p => p.label.toLowerCase().includes(query)),
intra: processes.intra.filter(p => p.label.toLowerCase().includes(query)),
};
}, [processes, searchQuery]);
// Toggle section expansion
const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}, []);
// Load ALL processes and combine into one mega-diagram
const handleViewAllProcesses = useCallback(async () => {
setLoadingProcess('all');
try {
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id);
if (allProcessIds.length === 0) return;
// Collect all steps from all processes
const allStepsMap = new Map<string, ProcessStep>();
const allEdges: Array<{ from: string; to: string; type: string }> = [];
// Fetch steps for all processes concurrently in batches if needed, but for now sequentially to be safe
// Optimization: Fetch all steps in one query if possible
const allStepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE p.id IN [${allProcessIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
`;
const stepsResult = await runQuery(allStepsQuery);
for (const row of stepsResult) {
const stepId = row.id || row[0];
if (!allStepsMap.has(stepId)) {
allStepsMap.set(stepId, {
id: stepId,
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
});
}
}
const allSteps = Array.from(allStepsMap.values());
const stepIds = allSteps.map(s => s.id);
// Query for all CALLS edges between the combined steps
if (stepIds.length > 0) {
// Batch query if too many steps
const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type
`;
try {
const edgesResult = await runQuery(edgesQuery);
allEdges.push(...edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter(edge => edge.from !== edge.to));
} catch (err) {
console.warn('Could not fetch combined edges:', err);
}
}
const combinedProcessData: ProcessData = {
id: 'combined-all',
label: `All Processes (${allProcessIds.length} combined)`,
processType: 'cross_community', // Treat as cross-community for styling
steps: allSteps,
edges: allEdges,
clusters: [],
};
setSelectedProcess(combinedProcessData);
} catch (error) {
console.error('Failed to load combined processes:', error);
} finally {
setLoadingProcess(null);
}
}, [processes, runQuery]);
// Load process steps and open modal
const handleViewProcess = useCallback(async (processId: string, label: string, processType: string) => {
setLoadingProcess(processId);
try {
// Query for process steps
const stepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
ORDER BY r.step
`;
const stepsResult = await runQuery(stepsQuery);
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
id: row.id || row[0],
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
}));
// Get step IDs for edge query
const stepIds = steps.map(s => s.id);
// Query for CALLS edges between the steps in this process
let edges: Array<{ from: string; to: string; type: string }> = [];
if (stepIds.length > 0) {
const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type
`;
try {
const edgesResult = await runQuery(edgesQuery);
edges = edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter(edge => edge.from !== edge.to); // Remove self-loops
} catch (err) {
console.warn('Could not fetch edges:', err);
// Continue with empty edges - will fallback to linear
}
}
// Get clusters for this process
const processNode = graph?.nodes.find(n => n.id === processId);
const clusters = processNode?.properties.communities || [];
const processData: ProcessData = {
id: processId,
label,
processType: processType as 'cross_community' | 'intra_community',
steps,
edges,
clusters,
};
setSelectedProcess(processData);
} catch (error) {
console.error('Failed to load process steps:', error);
} finally {
setLoadingProcess(null);
}
}, [runQuery, graph]);
// Cache for process steps (so we don't re-query when toggling focus)
const [processStepsCache, setProcessStepsCache] = useState<Map<string, string[]>>(new Map());
// Toggle focus for any process - loads steps on demand
const handleToggleFocusForProcess = useCallback(async (processId: string) => {
// If already focused on this process, turn off
if (focusedProcessId === processId) {
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
return;
}
// Check if we have cached steps
if (processStepsCache.has(processId)) {
const stepIds = processStepsCache.get(processId)!;
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
return;
}
// Load steps for this process
setLoadingProcess(processId);
try {
const stepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
RETURN s.id AS id
`;
const stepsResult = await runQuery(stepsQuery);
const stepIds = stepsResult.map((row: any) => row.id || row[0]);
// Cache the result
setProcessStepsCache(prev => new Map(prev).set(processId, stepIds));
// Set focus
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
} catch (error) {
console.error('Failed to load process steps for focus:', error);
} finally {
setLoadingProcess(null);
}
}, [focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds]);
// Focus in graph callback - toggles highlight (used by modal)
const handleFocusInGraph = useCallback((nodeIds: string[], processId: string) => {
// Check if this process is already focused
if (focusedProcessId === processId) {
// Clear focus
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
} else {
// Set focus and cache
setHighlightedNodeIds(new Set(nodeIds));
setFocusedProcessId(processId);
setProcessStepsCache(prev => new Map(prev).set(processId, nodeIds));
}
}, [focusedProcessId, setHighlightedNodeIds]);
// Clear focused process when highlights are cleared externally
useEffect(() => {
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
setFocusedProcessId(null);
}
}, [highlightedNodeIds, focusedProcessId]);
const totalCount = processes.cross.length + processes.intra.length;
if (totalCount === 0) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-surface rounded-xl">
<GitBranch className="w-7 h-7 text-text-muted" />
</div>
<h3 className="text-base font-medium text-text-primary mb-2">No Processes Detected</h3>
<p className="text-sm text-text-secondary max-w-xs">
Processes are execution flows traced from entry points. Load a codebase to see detected processes.
</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header with search */}
<div className="p-3 border-b border-border-subtle">
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 flex items-center gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-lg focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="w-4 h-4 text-text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter processes..."
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-text-muted">
<span>{totalCount} processes detected</span>
</div>
</div>
{/* Process list */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{/* View All Processes Card */}
<div className="px-4 py-3">
<button
onClick={handleViewAllProcesses}
disabled={loadingProcess !== null}
className="w-full flex items-center gap-3 p-3 bg-elevated/40 hover:bg-elevated/80 border border-border-subtle hover:border-cyan-500/30 rounded-xl transition-all group shadow-sm hover:shadow-cyan-900/10 text-left"
>
<div className="p-2 bg-cyan-500/10 rounded-lg group-hover:bg-cyan-500/20 transition-colors">
<Layers className="w-5 h-5 text-cyan-400" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">Full Process Map</h4>
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
</div>
{loadingProcess === 'all' ? (
<span className="animate-spin mr-1">
<Sparkles className="w-4 h-4 text-cyan-400" />
</span>
) : (
<Eye className="w-4 h-4 text-text-muted group-hover:text-cyan-400" />
)}
</button>
</div>
{/* Cross-Community Section */}
{filteredProcesses.cross.length > 0 && (
<div className="border-b border-border-subtle">
<button
onClick={() => toggleSection('cross')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
>
{expandedSections.has('cross') ? (
<ChevronDown className="w-4 h-4 text-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted" />
)}
<Zap className="w-4 h-4 text-amber-400" />
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
{filteredProcesses.cross.length}
</span>
</button>
{expandedSections.has('cross') && (
<div className="pb-2">
{filteredProcesses.cross.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'cross_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
{/* Intra-Community Section */}
{filteredProcesses.intra.length > 0 && (
<div>
<button
onClick={() => toggleSection('intra')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
>
{expandedSections.has('intra') ? (
<ChevronDown className="w-4 h-4 text-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted" />
)}
<Home className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
{filteredProcesses.intra.length}
</span>
</button>
{expandedSections.has('intra') && (
<div className="pb-2">
{filteredProcesses.intra.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'intra_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Modal */}
<ProcessFlowModal
process={selectedProcess}
onClose={() => setSelectedProcess(null)}
onFocusInGraph={handleFocusInGraph}
isFullScreen={selectedProcess?.id === 'combined-all'}
/>
</div>
);
};
// Individual process item
interface ProcessItemProps {
process: { id: string; label: string; stepCount: number; clusters: string[] };
isLoading: boolean;
isSelected: boolean;
isFocused: boolean;
onView: () => void;
onToggleFocus: () => void;
}
const ProcessItem = ({ process, isLoading, isSelected, isFocused, onView, onToggleFocus }: ProcessItemProps) => {
// Determine row styling - focused gets special highlight
const rowClass = isFocused
? 'bg-amber-950/40 border border-amber-500/50 ring-1 ring-amber-400/30'
: isSelected
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30'
: '';
return (
<div className={`flex items-center gap-2 px-4 py-2 mx-2 rounded-lg hover:bg-hover group transition-all ${rowClass}`}>
<GitBranch className="w-4 h-4 text-text-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-text-primary truncate">{process.label}</div>
<div className="flex items-center gap-2 text-xs text-text-muted">
<span>{process.stepCount} steps</span>
{process.clusters.length > 0 && (
<>
<span>•</span>
<span>{process.clusters.length} clusters</span>
</>
)}
</div>
</div>
{/* Lightbulb icon - appears on hover, always visible when focused */}
<button
onClick={onToggleFocus}
className={`p-1.5 rounded-md transition-all ${isFocused
? 'text-amber-400 hover:text-amber-300 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-400/40 animate-pulse opacity-100'
: 'text-text-muted hover:text-cyan-400 bg-white/5 hover:bg-cyan-500/20 border border-white/10 hover:border-cyan-400/40 opacity-0 group-hover:opacity-100'
}`}
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
>
<Lightbulb className="w-4 h-4" />
</button>
<button
onClick={onView}
disabled={isLoading}
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-all disabled:opacity-50 shadow-sm ${isSelected
? 'text-cyan-300 bg-cyan-900/60 border border-cyan-400/60 opacity-100'
: 'text-cyan-400 hover:text-cyan-300 bg-cyan-950/30 hover:bg-cyan-900/50 border border-cyan-500/30 hover:border-cyan-400/50 opacity-0 group-hover:opacity-100 shadow-cyan-900/20'
}`}
>
{isLoading ? (
<span className="animate-pulse">Loading...</span>
) : isSelected ? (
<>
<Eye className="w-3.5 h-3.5" />
Viewing
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
View
</>
)}
</button>
</div>
);
};

View file

@ -1,13 +1,14 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Send, Sparkles, User,
PanelRightClose, Loader2, AlertTriangle, Activity
PanelRightClose, Loader2, AlertTriangle, Activity, GitBranch
} from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { ToolCallCard } from './ToolCallCard';
import { isProviderConfigured } from '../core/llm/settings-service';
import { ActivityFeed } from './ActivityFeed';
import { MarkdownRenderer } from './MarkdownRenderer';
import { ProcessesPanel } from './ProcessesPanel';
export const RightPanel = () => {
const {
isRightPanelOpen,
@ -27,7 +28,7 @@ export const RightPanel = () => {
} = useAppState();
const [chatInput, setChatInput] = useState('');
const [activeTab, setActiveTab] = useState<'chat' | 'activity'>('chat');
const [activeTab, setActiveTab] = useState<'chat' | 'activity' | 'processes'>('chat');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@ -234,6 +235,21 @@ export const RightPanel = () => {
<Activity className="w-3.5 h-3.5" />
<span>Activity</span>
</button>
{/* Processes Tab */}
<button
onClick={() => setActiveTab('processes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'processes'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
>
<GitBranch className="w-3.5 h-3.5" />
<span>Processes</span>
<span className="text-[10px] px-1.5 py-0.5 bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white rounded-full font-semibold">
NEW
</span>
</button>
</div>
{/* Close button */}
@ -253,6 +269,13 @@ export const RightPanel = () => {
</div>
)}
{/* Processes Tab */}
{activeTab === 'processes' && (
<div className="flex-1 flex flex-col overflow-hidden">
<ProcessesPanel />
</div>
)}
{/* Chat Content - only show when chat tab is active */}
{activeTab === 'chat' && (
<div className="flex-1 flex flex-col overflow-hidden">

View file

@ -4,6 +4,7 @@ import {
loadSettings,
saveSettings,
getProviderDisplayName,
fetchOpenRouterModels,
} from '../core/llm/settings-service';
import type { LLMSettings, LLMProvider } from '../core/llm/types';
@ -46,6 +47,9 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
// Ollama connection state
const [ollamaError, setOllamaError] = useState<string | null>(null);
const [isCheckingOllama, setIsCheckingOllama] = useState(false);
// OpenRouter models state
const [openRouterModels, setOpenRouterModels] = useState<Array<{ id: string; name: string }>>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
// Load settings when panel opens
useEffect(() => {
@ -66,6 +70,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
setOllamaError(error);
}, []);
// Load OpenRouter models
const loadOpenRouterModels = useCallback(async () => {
setIsLoadingModels(true);
const models = await fetchOpenRouterModels();
setOpenRouterModels(models);
setIsLoadingModels(false);
}, []);
useEffect(() => {
if (settings.activeProvider === 'ollama') {
const baseUrl = settings.ollama?.baseUrl ?? 'http://localhost:11434';
@ -97,7 +109,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
if (!isOpen) return null;
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama'];
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter'];
return (
@ -153,7 +165,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
w-8 h-8 rounded-lg flex items-center justify-center text-lg
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
`}>
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : '☁️'}
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : '☁️'}
</div>
<span className="font-medium">{getProviderDisplayName(provider)}</span>
</button>
@ -534,6 +546,92 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
</div>
)}
{/* OpenRouter Settings */}
{settings.activeProvider === 'openrouter' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['openrouter'] ? 'text' : 'password'}
value={settings.openrouter?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, apiKey: e.target.value }
}))}
placeholder="Enter your OpenRouter API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('openrouter')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['openrouter'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
OpenRouter Keys
</a>
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<select
value={settings.openrouter?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, model: e.target.value }
}))}
onFocus={() => {
if (openRouterModels.length === 0 && !isLoadingModels) {
loadOpenRouterModels();
}
}}
disabled={isLoadingModels}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm disabled:opacity-50"
>
{isLoadingModels ? (
<option>Loading models...</option>
) : openRouterModels.length === 0 ? (
<option value="">Click to load models</option>
) : (
<>
<option value="">Select a model</option>
{openRouterModels.map(model => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</>
)}
</select>
<p className="text-xs text-text-muted">
Browse all models at{' '}
<a
href="https://openrouter.ai/models"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
OpenRouter Models
</a>
</p>
</div>
</div>
)}
{/* Intelligent Clustering Settings */}
<div className="space-y-4 animate-fade-in pt-2 border-t border-border-subtle">

View file

@ -20,6 +20,7 @@ import type {
GeminiConfig,
AnthropicConfig,
OllamaConfig,
OpenRouterConfig,
AgentStreamChunk,
} from './types';
import {
@ -188,6 +189,37 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
});
}
case 'openrouter': {
const openRouterConfig = config as OpenRouterConfig;
// Debug logging
if (import.meta.env.DEV) {
console.log('🌐 OpenRouter config:', {
hasApiKey: !!openRouterConfig.apiKey,
apiKeyLength: openRouterConfig.apiKey?.length || 0,
model: openRouterConfig.model,
baseUrl: openRouterConfig.baseUrl,
});
}
if (!openRouterConfig.apiKey || openRouterConfig.apiKey.trim() === '') {
throw new Error('OpenRouter API key is required but was not provided');
}
return new ChatOpenAI({
openAIApiKey: openRouterConfig.apiKey,
apiKey: openRouterConfig.apiKey, // Fallback for some versions
modelName: openRouterConfig.model,
temperature: openRouterConfig.temperature ?? 0.1,
maxTokens: openRouterConfig.maxTokens,
configuration: {
apiKey: openRouterConfig.apiKey, // Ensure client receives it
baseURL: openRouterConfig.baseUrl ?? 'https://openrouter.ai/api/v1',
},
streaming: true,
});
}
default:
throw new Error(`Unsupported provider: ${(config as any).provider}`);
}

View file

@ -14,6 +14,7 @@ import {
GeminiConfig,
AnthropicConfig,
OllamaConfig,
OpenRouterConfig,
ProviderConfig,
} from './types';
@ -55,6 +56,10 @@ export const loadSettings = (): LLMSettings => {
...DEFAULT_LLM_SETTINGS.ollama,
...parsed.ollama,
},
openrouter: {
...DEFAULT_LLM_SETTINGS.openrouter,
...parsed.openrouter,
},
};
} catch (error) {
console.warn('Failed to load LLM settings:', error);
@ -146,6 +151,17 @@ export const updateProviderSettings = <T extends LLMProvider>(
saveSettings(updated);
return updated;
}
case 'openrouter': {
const updated: LLMSettings = {
...current,
openrouter: {
...(current.openrouter ?? {}),
...(updates as Partial<Omit<OpenRouterConfig, 'provider'>>),
},
};
saveSettings(updated);
return updated;
}
default: {
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
const updated: LLMSettings = { ...current };
@ -217,6 +233,19 @@ export const getActiveProviderConfig = (): ProviderConfig | null => {
...settings.ollama,
} as OllamaConfig;
case 'openrouter':
if (!settings.openrouter?.apiKey || settings.openrouter.apiKey.trim() === '') {
return null;
}
return {
provider: 'openrouter',
apiKey: settings.openrouter.apiKey,
model: settings.openrouter.model || '',
baseUrl: settings.openrouter.baseUrl || 'https://openrouter.ai/api/v1',
temperature: settings.openrouter.temperature,
maxTokens: settings.openrouter.maxTokens,
} as OpenRouterConfig;
default:
return null;
}
@ -251,6 +280,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => {
return 'Anthropic';
case 'ollama':
return 'Ollama (Local)';
case 'openrouter':
return 'OpenRouter';
default:
return provider;
}
@ -277,3 +308,21 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
}
};
/**
* Fetch available models from OpenRouter API
*/
export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name: string }>> => {
try {
const response = await fetch('https://openrouter.ai/api/v1/models');
if (!response.ok) throw new Error('Failed to fetch models');
const data = await response.json();
return data.data.map((model: any) => ({
id: model.id,
name: model.name || model.id,
}));
} catch (error) {
console.error('Error fetching OpenRouter models:', error);
return [];
}
};

View file

@ -8,7 +8,7 @@
/**
* Supported LLM providers
*/
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama';
export type LLMProvider = 'openai' | 'azure-openai' | 'gemini' | 'anthropic' | 'ollama' | 'openrouter';
/**
* Base configuration shared by all providers
@ -68,10 +68,20 @@ export interface OllamaConfig extends BaseProviderConfig {
model: string;
}
/**
* OpenRouter configuration
*/
export interface OpenRouterConfig extends BaseProviderConfig {
provider: 'openrouter';
apiKey: string;
model: string; // e.g., 'anthropic/claude-3.5-sonnet', 'openai/gpt-4-turbo'
baseUrl?: string; // defaults to https://openrouter.ai/api/v1
}
/**
* Union type for all provider configurations
*/
export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig;
export type ProviderConfig = OpenAIConfig | AzureOpenAIConfig | GeminiConfig | AnthropicConfig | OllamaConfig | OpenRouterConfig;
/**
* Stored settings (what goes to localStorage)
@ -87,6 +97,7 @@ export interface LLMSettings {
gemini?: Partial<Omit<GeminiConfig, 'provider'>>;
anthropic?: Partial<Omit<AnthropicConfig, 'provider'>>;
ollama?: Partial<Omit<OllamaConfig, 'provider'>>;
openrouter?: Partial<Omit<OpenRouterConfig, 'provider'>>;
// Intelligent Clustering Settings
intelligentClustering: boolean;
@ -131,6 +142,12 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
model: 'llama3.2',
temperature: 0.1,
},
openrouter: {
apiKey: '',
model: '',
baseUrl: 'https://openrouter.ai/api/v1',
temperature: 0.1,
},
};
/**

View file

@ -1042,10 +1042,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
}
}
// Parse impact marker from tool results
const impactMatch = tc.result.match(/\[IMPACT:([^\]]+)\]/);
if (impactMatch) {
const rawIds = impactMatch[1].split(',').map((id: string) => id.trim()).filter(Boolean);
// Parse impact marker from tool results
const impactMatch = tc.result.match(/\[IMPACT:([^\]]+)\]/);
if (impactMatch) {
const rawIds = impactMatch[1].split(',').map((id: string) => id.trim()).filter(Boolean);
if (rawIds.length > 0 && graph) {
const matchedIds = new Set<string>();
const graphNodeIds = graph.nodes.map(n => n.id);

View file

@ -0,0 +1,158 @@
/**
* Mermaid Diagram Generator for Processes
*
* Generates Mermaid flowchart syntax from Process step data.
* Designed to show branching/merging when CALLS edges exist between steps.
*/
export interface ProcessStep {
id: string;
name: string;
filePath?: string;
stepNumber: number;
cluster?: string;
}
export interface ProcessEdge {
from: string;
to: string;
type: string;
}
export interface ProcessData {
id: string;
label: string;
processType: 'intra_community' | 'cross_community';
steps: ProcessStep[];
edges?: ProcessEdge[]; // CALLS edges between steps for branching
clusters?: string[];
}
/**
* Generate Mermaid flowchart from process data
*/
export function generateProcessMermaid(process: ProcessData): string {
const { steps, edges, clusters } = process;
if (!steps || steps.length === 0) {
return 'graph TD\n A[No steps found]';
}
const lines: string[] = ['graph TD'];
// Add class definitions for styling (rounded corners + colors)
lines.push(' %% Styles');
lines.push(' classDef default fill:#1e293b,stroke:#94a3b8,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef entry fill:#1e293b,stroke:#34d399,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef step fill:#1e293b,stroke:#22d3ee,stroke-width:3px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef terminal fill:#1e293b,stroke:#f472b6,stroke-width:5px,color:#f8fafc,rx:10,ry:10,font-size:24px;');
lines.push(' classDef cluster fill:#0f172a,stroke:#334155,stroke-width:3px,color:#94a3b8,rx:4,ry:4,font-size:20px;');
// Track clusters for subgraph grouping
const clusterGroups = new Map<string, ProcessStep[]>();
const noCluster: ProcessStep[] = [];
for (const step of steps) {
if (step.cluster) {
const group = clusterGroups.get(step.cluster) || [];
group.push(step);
clusterGroups.set(step.cluster, group);
} else {
noCluster.push(step);
}
}
// Generate node IDs (sanitized) - use actual ID to avoid collisions when combining processes
const nodeId = (step: ProcessStep) => {
// Sanitize the actual ID to be Mermaid-safe
return step.id.replace(/[^a-zA-Z0-9_]/g, '_');
};
const sanitizeLabel = (text: string) => text.replace(/["\[\]<>{}()]/g, '').substring(0, 30);
const getFileName = (path?: string) => path?.split('/').pop() || '';
// Determine node class (entry, terminal, or normal step)
const sortedStepsRef = [...steps].sort((a, b) => a.stepNumber - b.stepNumber);
const entryId = sortedStepsRef[0]?.id;
const terminalId = sortedStepsRef[sortedStepsRef.length - 1]?.id;
const getNodeClass = (step: ProcessStep) => {
if (step.id === entryId) return 'entry';
if (step.id === terminalId) return 'terminal';
return 'step';
};
// If we have cluster groupings and cross-community, use subgraphs
const useClusters = process.processType === 'cross_community' && clusterGroups.size > 1;
if (useClusters) {
// Generate subgraphs for each cluster
let clusterIndex = 0;
for (const [clusterName, clusterSteps] of clusterGroups) {
lines.push(` subgraph ${sanitizeLabel(clusterName)}["${sanitizeLabel(clusterName)}"]:::cluster`);
for (const step of clusterSteps) {
const id = nodeId(step);
const label = `${step.stepNumber}. ${sanitizeLabel(step.name)}`;
const file = getFileName(step.filePath);
const className = getNodeClass(step);
lines.push(` ${id}["${label}<br/><small>${file}</small>"]:::${className}`);
}
lines.push(' end');
clusterIndex++;
}
// Add unclustered steps
for (const step of noCluster) {
const id = nodeId(step);
const label = `${step.stepNumber}. ${sanitizeLabel(step.name)}`;
const file = getFileName(step.filePath);
const className = getNodeClass(step);
lines.push(` ${id}["${label}<br/><small>${file}</small>"]:::${className}`);
}
} else {
// Simple flat layout
for (const step of steps) {
const id = nodeId(step);
const label = `${step.stepNumber}. ${sanitizeLabel(step.name)}`;
const file = getFileName(step.filePath);
const className = getNodeClass(step);
lines.push(` ${id}["${label}<br/><small>${file}</small>"]:::${className}`);
}
}
// Generate edges
if (edges && edges.length > 0) {
// Use actual CALLS edges for branching
const stepById = new Map(steps.map(s => [s.id, s]));
for (const edge of edges) {
const fromStep = stepById.get(edge.from);
const toStep = stepById.get(edge.to);
if (fromStep && toStep) {
lines.push(` ${nodeId(fromStep)} --> ${nodeId(toStep)}`);
}
}
// Ensure all nodes are connected (fallback for disconnected components)
// For now assume graph is connected enough or user accepts fragments.
} else {
// Fallback: linear chain based on step order
const sortedSteps = [...steps].sort((a, b) => a.stepNumber - b.stepNumber);
for (let i = 0; i < sortedSteps.length - 1; i++) {
lines.push(` ${nodeId(sortedSteps[i])} --> ${nodeId(sortedSteps[i + 1])}`);
}
}
return lines.join('\n');
}
/**
* Simple linear mermaid for quick preview
*/
export function generateSimpleMermaid(processLabel: string, stepCount: number): string {
const [entry, terminal] = processLabel.split(' → ').map(s => s.trim());
return `graph LR
classDef entry fill:#059669,stroke:#34d399,stroke-width:2px,color:#ffffff,rx:10,ry:10;
classDef terminal fill:#be185d,stroke:#f472b6,stroke-width:2px,color:#ffffff,rx:10,ry:10;
A["🟢 ${entry || 'Start'}"]:::entry --> B["... ${stepCount - 2} steps ..."] --> C["🔴 ${terminal || 'End'}"]:::terminal`;
}