Upload folder using huggingface_hub
Browse files- Dockerfile +37 -21
- docker-compose.yml +1 -2
- frontend/src/components/ApiKeysPanel.tsx +7 -1
- frontend/src/components/ChatPanel.tsx +4 -2
- src/eurus/tools/__init__.py +6 -3
- src/eurus/tools/era5.py +32 -18
- src/eurus/tools/repl.py +26 -0
- web/agent_wrapper.py +18 -7
- web/routes/api.py +7 -16
- web/routes/pages.py +32 -7
Dockerfile
CHANGED
|
@@ -1,25 +1,30 @@
|
|
| 1 |
# ============================================================================
|
| 2 |
# Eurus ERA5 Agent β Docker Image
|
| 3 |
# ============================================================================
|
| 4 |
-
#
|
| 5 |
-
# docker build --target agent -t eurus-agent .
|
| 6 |
-
# docker build --target web -t eurus-web .
|
| 7 |
#
|
| 8 |
-
#
|
| 9 |
-
# docker
|
| 10 |
-
# docker
|
|
|
|
|
|
|
|
|
|
| 11 |
# ============================================================================
|
| 12 |
|
| 13 |
-
|
| 14 |
-
FROM python:3.12-slim AS base
|
| 15 |
|
| 16 |
# System deps for scientific stack (numpy/scipy wheels, geopandas/shapely, matplotlib)
|
| 17 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
&& rm -rf /var/lib/apt/lists/*
|
| 24 |
|
| 25 |
WORKDIR /app
|
|
@@ -28,6 +33,12 @@ WORKDIR /app
|
|
| 28 |
COPY requirements.txt .
|
| 29 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# Copy project source
|
| 32 |
COPY pyproject.toml .
|
| 33 |
COPY src/ src/
|
|
@@ -50,18 +61,23 @@ RUN python -c "import cartopy; cartopy.io.shapereader.natural_earth(resolution='
|
|
| 50 |
&& python -c "import cartopy; cartopy.io.shapereader.natural_earth(resolution='110m', category='physical', name='land')" \
|
| 51 |
&& python -c "import cartopy; cartopy.io.shapereader.natural_earth(resolution='50m', category='physical', name='land')"
|
| 52 |
|
| 53 |
-
#
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# Matplotlib: no GUI backend
|
| 56 |
ENV MPLBACKEND=Agg
|
| 57 |
# Ensure Python output is unbuffered (for docker logs)
|
| 58 |
ENV PYTHONUNBUFFERED=1
|
| 59 |
|
| 60 |
-
# ---------- agent (CLI mode) ----------
|
| 61 |
-
FROM base AS agent
|
| 62 |
-
ENTRYPOINT ["python", "main.py"]
|
| 63 |
-
|
| 64 |
-
# ---------- web (FastAPI mode) ----------
|
| 65 |
-
FROM base AS web
|
| 66 |
EXPOSE 7860
|
| 67 |
CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
# ============================================================================
|
| 2 |
# Eurus ERA5 Agent β Docker Image
|
| 3 |
# ============================================================================
|
| 4 |
+
# Single-stage build for HuggingFace Spaces + local docker-compose.
|
|
|
|
|
|
|
| 5 |
#
|
| 6 |
+
# Local usage:
|
| 7 |
+
# docker build -t eurus-web .
|
| 8 |
+
# docker run -p 7860:7860 --env-file .env eurus-web
|
| 9 |
+
#
|
| 10 |
+
# Or use docker-compose:
|
| 11 |
+
# docker compose up web
|
| 12 |
# ============================================================================
|
| 13 |
|
| 14 |
+
FROM python:3.12-slim
|
|
|
|
| 15 |
|
| 16 |
# System deps for scientific stack (numpy/scipy wheels, geopandas/shapely, matplotlib)
|
| 17 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 18 |
+
gcc g++ \
|
| 19 |
+
libgeos-dev \
|
| 20 |
+
libproj-dev \
|
| 21 |
+
libffi-dev \
|
| 22 |
+
curl \
|
| 23 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 24 |
+
|
| 25 |
+
# Install Node.js 20 for React frontend build
|
| 26 |
+
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 27 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 28 |
&& rm -rf /var/lib/apt/lists/*
|
| 29 |
|
| 30 |
WORKDIR /app
|
|
|
|
| 33 |
COPY requirements.txt .
|
| 34 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 35 |
|
| 36 |
+
# Build React frontend (separate layer for caching)
|
| 37 |
+
COPY frontend/package.json frontend/package-lock.json* frontend/
|
| 38 |
+
RUN cd frontend && npm ci
|
| 39 |
+
COPY frontend/ frontend/
|
| 40 |
+
RUN cd frontend && npm run build
|
| 41 |
+
|
| 42 |
# Copy project source
|
| 43 |
COPY pyproject.toml .
|
| 44 |
COPY src/ src/
|
|
|
|
| 61 |
&& python -c "import cartopy; cartopy.io.shapereader.natural_earth(resolution='110m', category='physical', name='land')" \
|
| 62 |
&& python -c "import cartopy; cartopy.io.shapereader.natural_earth(resolution='50m', category='physical', name='land')"
|
| 63 |
|
| 64 |
+
# Pre-import heavy modules at BUILD time to force compilation / bytecode caching.
|
| 65 |
+
# This avoids a 30+ minute first-import delay on HF Spaces' constrained free tier.
|
| 66 |
+
RUN python -c "\
|
| 67 |
+
import langchain; \
|
| 68 |
+
import langchain_openai; \
|
| 69 |
+
import arraylake; \
|
| 70 |
+
import icechunk; \
|
| 71 |
+
import xarray; \
|
| 72 |
+
import scipy; \
|
| 73 |
+
import matplotlib; \
|
| 74 |
+
import scgraph; \
|
| 75 |
+
print('All heavy modules pre-imported successfully')"
|
| 76 |
+
|
| 77 |
# Matplotlib: no GUI backend
|
| 78 |
ENV MPLBACKEND=Agg
|
| 79 |
# Ensure Python output is unbuffered (for docker logs)
|
| 80 |
ENV PYTHONUNBUFFERED=1
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
EXPOSE 7860
|
| 83 |
CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
docker-compose.yml
CHANGED
|
@@ -12,8 +12,8 @@ services:
|
|
| 12 |
agent:
|
| 13 |
build:
|
| 14 |
context: .
|
| 15 |
-
target: agent
|
| 16 |
image: eurus-agent
|
|
|
|
| 17 |
env_file: .env
|
| 18 |
environment:
|
| 19 |
- EURUS_DOCKER=1
|
|
@@ -28,7 +28,6 @@ services:
|
|
| 28 |
web:
|
| 29 |
build:
|
| 30 |
context: .
|
| 31 |
-
target: web
|
| 32 |
image: eurus-web
|
| 33 |
env_file: .env
|
| 34 |
environment:
|
|
|
|
| 12 |
agent:
|
| 13 |
build:
|
| 14 |
context: .
|
|
|
|
| 15 |
image: eurus-agent
|
| 16 |
+
entrypoint: ["python", "main.py"]
|
| 17 |
env_file: .env
|
| 18 |
environment:
|
| 19 |
- EURUS_DOCKER=1
|
|
|
|
| 28 |
web:
|
| 29 |
build:
|
| 30 |
context: .
|
|
|
|
| 31 |
image: eurus-web
|
| 32 |
env_file: .env
|
| 33 |
environment:
|
frontend/src/components/ApiKeysPanel.tsx
CHANGED
|
@@ -5,13 +5,19 @@ import './ApiKeysPanel.css';
|
|
| 5 |
interface ApiKeysPanelProps {
|
| 6 |
visible: boolean;
|
| 7 |
onSave: (keys: { openai_api_key: string; arraylake_api_key: string }) => void;
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
-
export default function ApiKeysPanel({ visible, onSave }: ApiKeysPanelProps) {
|
| 11 |
const [openaiKey, setOpenaiKey] = useState('');
|
| 12 |
const [arraylakeKey, setArraylakeKey] = useState('');
|
| 13 |
const [saving, setSaving] = useState(false);
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
// Restore from sessionStorage
|
| 16 |
useEffect(() => {
|
| 17 |
const saved = sessionStorage.getItem('eurus-keys');
|
|
|
|
| 5 |
interface ApiKeysPanelProps {
|
| 6 |
visible: boolean;
|
| 7 |
onSave: (keys: { openai_api_key: string; arraylake_api_key: string }) => void;
|
| 8 |
+
configured?: boolean;
|
| 9 |
}
|
| 10 |
|
| 11 |
+
export default function ApiKeysPanel({ visible, onSave, configured }: ApiKeysPanelProps) {
|
| 12 |
const [openaiKey, setOpenaiKey] = useState('');
|
| 13 |
const [arraylakeKey, setArraylakeKey] = useState('');
|
| 14 |
const [saving, setSaving] = useState(false);
|
| 15 |
|
| 16 |
+
// Reset saving state when keys are confirmed configured
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (configured) setSaving(false);
|
| 19 |
+
}, [configured]);
|
| 20 |
+
|
| 21 |
// Restore from sessionStorage
|
| 22 |
useEffect(() => {
|
| 23 |
const saved = sessionStorage.getItem('eurus-keys');
|
frontend/src/components/ChatPanel.tsx
CHANGED
|
@@ -20,6 +20,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
|
|
| 20 |
const [isThinking, setIsThinking] = useState(false);
|
| 21 |
const [statusMsg, setStatusMsg] = useState('');
|
| 22 |
const [needKeys, setNeedKeys] = useState<boolean | null>(null); // null = don't know yet
|
|
|
|
| 23 |
const bottomRef = useRef<HTMLDivElement>(null);
|
| 24 |
const streamBuf = useRef('');
|
| 25 |
const streamMedia = useRef<MediaItem[]>([]);
|
|
@@ -178,6 +179,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
|
|
| 178 |
case 'keys_configured':
|
| 179 |
if (ev.ready) {
|
| 180 |
setNeedKeys(false);
|
|
|
|
| 181 |
}
|
| 182 |
break;
|
| 183 |
|
|
@@ -209,7 +211,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
|
|
| 209 |
.then(data => {
|
| 210 |
setNeedKeys(!data.openai);
|
| 211 |
})
|
| 212 |
-
.catch(() => setNeedKeys(
|
| 213 |
}, [status]);
|
| 214 |
|
| 215 |
/* ββ auto-scroll ββ */
|
|
@@ -280,7 +282,7 @@ export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
|
|
| 280 |
</header>
|
| 281 |
|
| 282 |
{/* API keys panel */}
|
| 283 |
-
<ApiKeysPanel visible={needKeys === true} onSave={handleSaveKeys} />
|
| 284 |
|
| 285 |
{/* messages */}
|
| 286 |
<div className="messages-container">
|
|
|
|
| 20 |
const [isThinking, setIsThinking] = useState(false);
|
| 21 |
const [statusMsg, setStatusMsg] = useState('');
|
| 22 |
const [needKeys, setNeedKeys] = useState<boolean | null>(null); // null = don't know yet
|
| 23 |
+
const [keysConfigured, setKeysConfigured] = useState(false);
|
| 24 |
const bottomRef = useRef<HTMLDivElement>(null);
|
| 25 |
const streamBuf = useRef('');
|
| 26 |
const streamMedia = useRef<MediaItem[]>([]);
|
|
|
|
| 179 |
case 'keys_configured':
|
| 180 |
if (ev.ready) {
|
| 181 |
setNeedKeys(false);
|
| 182 |
+
setKeysConfigured(true);
|
| 183 |
}
|
| 184 |
break;
|
| 185 |
|
|
|
|
| 211 |
.then(data => {
|
| 212 |
setNeedKeys(!data.openai);
|
| 213 |
})
|
| 214 |
+
.catch(() => setNeedKeys(true)); // no server keys β show panel
|
| 215 |
}, [status]);
|
| 216 |
|
| 217 |
/* ββ auto-scroll ββ */
|
|
|
|
| 282 |
</header>
|
| 283 |
|
| 284 |
{/* API keys panel */}
|
| 285 |
+
<ApiKeysPanel visible={needKeys === true} onSave={handleSaveKeys} configured={keysConfigured} />
|
| 286 |
|
| 287 |
{/* messages */}
|
| 288 |
<div className="messages-container">
|
src/eurus/tools/__init__.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import List
|
|
| 14 |
from langchain_core.tools import BaseTool
|
| 15 |
|
| 16 |
# Import core tools
|
| 17 |
-
from .era5 import era5_tool
|
| 18 |
from .repl import PythonREPLTool
|
| 19 |
from .routing import routing_tool
|
| 20 |
from .analysis_guide import analysis_guide_tool, visualization_guide_tool
|
|
@@ -29,7 +29,8 @@ except ImportError:
|
|
| 29 |
|
| 30 |
def get_all_tools(
|
| 31 |
enable_routing: bool = True,
|
| 32 |
-
enable_guide: bool = True
|
|
|
|
| 33 |
) -> List[BaseTool]:
|
| 34 |
"""
|
| 35 |
Return a list of all available tools for the agent.
|
|
@@ -37,13 +38,15 @@ def get_all_tools(
|
|
| 37 |
Args:
|
| 38 |
enable_routing: If True, includes the maritime routing tool (default: True).
|
| 39 |
enable_guide: If True, includes the guide tools (default: True).
|
|
|
|
| 40 |
|
| 41 |
Returns:
|
| 42 |
List of LangChain tools for the agent.
|
| 43 |
"""
|
| 44 |
# Core tools: data retrieval + Python analysis
|
|
|
|
| 45 |
tools = [
|
| 46 |
-
era5_tool,
|
| 47 |
PythonREPLTool(working_dir=".")
|
| 48 |
]
|
| 49 |
|
|
|
|
| 14 |
from langchain_core.tools import BaseTool
|
| 15 |
|
| 16 |
# Import core tools
|
| 17 |
+
from .era5 import era5_tool, create_era5_tool
|
| 18 |
from .repl import PythonREPLTool
|
| 19 |
from .routing import routing_tool
|
| 20 |
from .analysis_guide import analysis_guide_tool, visualization_guide_tool
|
|
|
|
| 29 |
|
| 30 |
def get_all_tools(
|
| 31 |
enable_routing: bool = True,
|
| 32 |
+
enable_guide: bool = True,
|
| 33 |
+
arraylake_api_key: str | None = None,
|
| 34 |
) -> List[BaseTool]:
|
| 35 |
"""
|
| 36 |
Return a list of all available tools for the agent.
|
|
|
|
| 38 |
Args:
|
| 39 |
enable_routing: If True, includes the maritime routing tool (default: True).
|
| 40 |
enable_guide: If True, includes the guide tools (default: True).
|
| 41 |
+
arraylake_api_key: If provided, binds this key to the ERA5 tool (session isolation).
|
| 42 |
|
| 43 |
Returns:
|
| 44 |
List of LangChain tools for the agent.
|
| 45 |
"""
|
| 46 |
# Core tools: data retrieval + Python analysis
|
| 47 |
+
# Use session-specific ERA5 tool if key is provided, else default (env-based)
|
| 48 |
tools = [
|
| 49 |
+
create_era5_tool(api_key=arraylake_api_key) if arraylake_api_key else era5_tool,
|
| 50 |
PythonREPLTool(working_dir=".")
|
| 51 |
]
|
| 52 |
|
src/eurus/tools/era5.py
CHANGED
|
@@ -13,6 +13,7 @@ QUERY_TYPE IS AUTO-DETECTED based on time/area rules:
|
|
| 13 |
import logging
|
| 14 |
from typing import Optional
|
| 15 |
from datetime import datetime
|
|
|
|
| 16 |
|
| 17 |
from pydantic import BaseModel, Field, field_validator
|
| 18 |
from langchain_core.tools import StructuredTool
|
|
@@ -156,10 +157,12 @@ def retrieve_era5_data(
|
|
| 156 |
max_latitude: float,
|
| 157 |
min_longitude: float,
|
| 158 |
max_longitude: float,
|
| 159 |
-
region: Optional[str] = None
|
|
|
|
| 160 |
) -> str:
|
| 161 |
"""
|
| 162 |
Wrapper that auto-detects query_type and calls the real retrieval function.
|
|
|
|
| 163 |
"""
|
| 164 |
# Auto-detect query type
|
| 165 |
query_type = _auto_detect_query_type(
|
|
@@ -178,7 +181,8 @@ def retrieve_era5_data(
|
|
| 178 |
max_latitude=max_latitude,
|
| 179 |
min_longitude=min_longitude,
|
| 180 |
max_longitude=max_longitude,
|
| 181 |
-
region=region
|
|
|
|
| 182 |
)
|
| 183 |
|
| 184 |
|
|
@@ -186,19 +190,29 @@ def retrieve_era5_data(
|
|
| 186 |
# LANGCHAIN TOOL CREATION
|
| 187 |
# ============================================================================
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
"
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
import logging
|
| 14 |
from typing import Optional
|
| 15 |
from datetime import datetime
|
| 16 |
+
from functools import partial
|
| 17 |
|
| 18 |
from pydantic import BaseModel, Field, field_validator
|
| 19 |
from langchain_core.tools import StructuredTool
|
|
|
|
| 157 |
max_latitude: float,
|
| 158 |
min_longitude: float,
|
| 159 |
max_longitude: float,
|
| 160 |
+
region: Optional[str] = None,
|
| 161 |
+
_api_key: Optional[str] = None,
|
| 162 |
) -> str:
|
| 163 |
"""
|
| 164 |
Wrapper that auto-detects query_type and calls the real retrieval function.
|
| 165 |
+
_api_key is injected by the session β not exposed to the LLM.
|
| 166 |
"""
|
| 167 |
# Auto-detect query type
|
| 168 |
query_type = _auto_detect_query_type(
|
|
|
|
| 181 |
max_latitude=max_latitude,
|
| 182 |
min_longitude=min_longitude,
|
| 183 |
max_longitude=max_longitude,
|
| 184 |
+
region=region,
|
| 185 |
+
api_key=_api_key,
|
| 186 |
)
|
| 187 |
|
| 188 |
|
|
|
|
| 190 |
# LANGCHAIN TOOL CREATION
|
| 191 |
# ============================================================================
|
| 192 |
|
| 193 |
+
def create_era5_tool(api_key: Optional[str] = None) -> StructuredTool:
|
| 194 |
+
"""Create an ERA5 tool, optionally binding a session-specific API key."""
|
| 195 |
+
func = partial(retrieve_era5_data, _api_key=api_key) if api_key else retrieve_era5_data
|
| 196 |
+
# partial objects lose __name__, so set it explicitly
|
| 197 |
+
if hasattr(func, 'func'):
|
| 198 |
+
func.__name__ = 'retrieve_era5_data'
|
| 199 |
+
return StructuredTool.from_function(
|
| 200 |
+
func=func,
|
| 201 |
+
name="retrieve_era5_data",
|
| 202 |
+
description=(
|
| 203 |
+
"Retrieves ERA5 climate reanalysis data from Earthmover's cloud archive.\n\n"
|
| 204 |
+
"β οΈ query_type is AUTO-DETECTED - you don't need to specify it!\n\n"
|
| 205 |
+
"Just provide:\n"
|
| 206 |
+
"- variable_id: one of 22 ERA5 variables (sst, t2, d2, skt, u10, v10, u100, v100, "
|
| 207 |
+
"sp, mslp, blh, cape, tcc, cp, lsp, tp, ssr, ssrd, tcw, tcwv, sd, stl1, swvl1)\n"
|
| 208 |
+
"- start_date, end_date: YYYY-MM-DD format\n"
|
| 209 |
+
"- lat/lon bounds: Use values from maritime route bounding box!\n\n"
|
| 210 |
+
"DATA: 1975-2024.\n"
|
| 211 |
+
"Returns file path. Load with: xr.open_zarr('PATH')"
|
| 212 |
+
),
|
| 213 |
+
args_schema=ERA5RetrievalArgs,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# Default tool instance (reads key from os.environ β for CLI/server .env usage)
|
| 218 |
+
era5_tool = create_era5_tool()
|
src/eurus/tools/repl.py
CHANGED
|
@@ -308,6 +308,27 @@ class PersistentREPL:
|
|
| 308 |
self._cleanup_process()
|
| 309 |
self._start_subprocess()
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
def run(self, code: str, timeout: int = 300) -> str:
|
| 312 |
"""Execute code in the subprocess. Returns output string."""
|
| 313 |
with self._lock:
|
|
@@ -464,6 +485,11 @@ class PythonREPLTool(BaseTool):
|
|
| 464 |
if plots_dir:
|
| 465 |
self._repl._update_plots_dir(plots_dir)
|
| 466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
def set_plot_callback(self, callback: Callable):
|
| 468 |
"""Set callback for plot capture (used by web interface)."""
|
| 469 |
self._plot_callback = callback
|
|
|
|
| 308 |
self._cleanup_process()
|
| 309 |
self._start_subprocess()
|
| 310 |
|
| 311 |
+
def set_extra_env(self, env_vars: dict):
|
| 312 |
+
"""Inject extra environment variables into the running subprocess."""
|
| 313 |
+
if not env_vars:
|
| 314 |
+
return
|
| 315 |
+
with self._lock:
|
| 316 |
+
self._ensure_alive()
|
| 317 |
+
# Build a Python command that sets the env vars inside the subprocess
|
| 318 |
+
set_cmds = []
|
| 319 |
+
for key, value in env_vars.items():
|
| 320 |
+
if value: # Only set non-empty values
|
| 321 |
+
set_cmds.append(f"os.environ[{key!r}] = {value!r}")
|
| 322 |
+
if set_cmds:
|
| 323 |
+
code = "import os; " + "; ".join(set_cmds)
|
| 324 |
+
cmd = json.dumps({"type": "exec", "code": code}) + "\n"
|
| 325 |
+
try:
|
| 326 |
+
self._process.stdin.write(cmd)
|
| 327 |
+
self._process.stdin.flush()
|
| 328 |
+
self._read_with_timeout(5) # consume the response
|
| 329 |
+
except Exception as e:
|
| 330 |
+
logger.warning("Failed to inject env vars into subprocess: %s", e)
|
| 331 |
+
|
| 332 |
def run(self, code: str, timeout: int = 300) -> str:
|
| 333 |
"""Execute code in the subprocess. Returns output string."""
|
| 334 |
with self._lock:
|
|
|
|
| 485 |
if plots_dir:
|
| 486 |
self._repl._update_plots_dir(plots_dir)
|
| 487 |
|
| 488 |
+
def inject_env(self, env_vars: dict):
|
| 489 |
+
"""Inject environment variables into the REPL subprocess (session-scoped)."""
|
| 490 |
+
if self._repl:
|
| 491 |
+
self._repl.set_extra_env(env_vars)
|
| 492 |
+
|
| 493 |
def set_plot_callback(self, callback: Callable):
|
| 494 |
"""Set callback for plot capture (used by web interface)."""
|
| 495 |
self._plot_callback = callback
|
web/agent_wrapper.py
CHANGED
|
@@ -76,13 +76,14 @@ class AgentSession:
|
|
| 76 |
|
| 77 |
if not arraylake_key:
|
| 78 |
logger.warning("ARRAYLAKE_API_KEY not found")
|
| 79 |
-
elif not os.environ.get("ARRAYLAKE_API_KEY"):
|
| 80 |
-
# Only set env var if not already configured (avoid overwriting
|
| 81 |
-
# server-configured keys with user-provided ones in multi-user scenarios)
|
| 82 |
-
os.environ["ARRAYLAKE_API_KEY"] = arraylake_key
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
if not openai_key:
|
| 88 |
logger.error("OPENAI_API_KEY not found")
|
|
@@ -93,6 +94,10 @@ class AgentSession:
|
|
| 93 |
logger.info("Starting Python kernel...")
|
| 94 |
self._repl_tool = PythonREPLTool(working_dir=os.getcwd())
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# Set up plot callback using the proper method
|
| 97 |
def on_plot_captured(base64_data: str, filepath: str, code: str = ""):
|
| 98 |
logger.info(f"Plot captured, adding to queue: {filepath}")
|
|
@@ -102,7 +107,13 @@ class AgentSession:
|
|
| 102 |
logger.info("Plot callback registered")
|
| 103 |
|
| 104 |
# Get ALL tools from centralized registry (no SCIENCE_TOOLS!)
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
# Replace the default REPL with our configured one
|
| 107 |
tools = [t for t in tools if t.name != "python_repl"] + [self._repl_tool]
|
| 108 |
|
|
|
|
| 76 |
|
| 77 |
if not arraylake_key:
|
| 78 |
logger.warning("ARRAYLAKE_API_KEY not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
# SECURITY: Do NOT write user-provided keys to os.environ!
|
| 81 |
+
# os.environ is process-global β leaks keys to other sessions on shared hosts (e.g. HF Spaces).
|
| 82 |
+
# Instead, store in self and pass directly to tools that need them.
|
| 83 |
+
self._resolved_keys = {
|
| 84 |
+
"ARRAYLAKE_API_KEY": arraylake_key or "",
|
| 85 |
+
"HF_TOKEN": hf_token or "",
|
| 86 |
+
}
|
| 87 |
|
| 88 |
if not openai_key:
|
| 89 |
logger.error("OPENAI_API_KEY not found")
|
|
|
|
| 94 |
logger.info("Starting Python kernel...")
|
| 95 |
self._repl_tool = PythonREPLTool(working_dir=os.getcwd())
|
| 96 |
|
| 97 |
+
# Inject session-specific keys into the REPL subprocess
|
| 98 |
+
# (keeps them isolated from other sessions β no os.environ pollution)
|
| 99 |
+
self._repl_tool.inject_env(self._resolved_keys)
|
| 100 |
+
|
| 101 |
# Set up plot callback using the proper method
|
| 102 |
def on_plot_captured(base64_data: str, filepath: str, code: str = ""):
|
| 103 |
logger.info(f"Plot captured, adding to queue: {filepath}")
|
|
|
|
| 107 |
logger.info("Plot callback registered")
|
| 108 |
|
| 109 |
# Get ALL tools from centralized registry (no SCIENCE_TOOLS!)
|
| 110 |
+
# Pass session-specific Arraylake key for isolation
|
| 111 |
+
arraylake_key = self._resolved_keys.get("ARRAYLAKE_API_KEY")
|
| 112 |
+
tools = get_all_tools(
|
| 113 |
+
enable_routing=True,
|
| 114 |
+
enable_guide=True,
|
| 115 |
+
arraylake_api_key=arraylake_key or None,
|
| 116 |
+
)
|
| 117 |
# Replace the default REPL with our configured one
|
| 118 |
tools = [t for t in tools if t.name != "python_repl"] + [self._repl_tool]
|
| 119 |
|
web/routes/api.py
CHANGED
|
@@ -66,19 +66,13 @@ async def keys_status():
|
|
| 66 |
|
| 67 |
@router.get("/health", response_model=HealthResponse)
|
| 68 |
async def health_check():
|
| 69 |
-
"""Check if the server
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
try:
|
| 73 |
-
session = get_agent_session()
|
| 74 |
-
agent_ready = session is not None and session.is_ready()
|
| 75 |
-
except Exception:
|
| 76 |
-
agent_ready = False
|
| 77 |
-
|
| 78 |
return HealthResponse(
|
| 79 |
status="ok",
|
| 80 |
version="1.0.0",
|
| 81 |
-
agent_ready=
|
| 82 |
)
|
| 83 |
|
| 84 |
|
|
@@ -181,17 +175,14 @@ async def get_config():
|
|
| 181 |
|
| 182 |
@router.delete("/conversation")
|
| 183 |
async def clear_conversation():
|
| 184 |
-
"""Clear the conversation history."""
|
| 185 |
from eurus.memory import get_memory
|
| 186 |
-
from web.agent_wrapper import get_agent_session
|
| 187 |
|
| 188 |
memory = get_memory()
|
| 189 |
memory.clear_conversation()
|
| 190 |
|
| 191 |
-
#
|
| 192 |
-
session
|
| 193 |
-
if session:
|
| 194 |
-
session.clear_messages()
|
| 195 |
|
| 196 |
return {"status": "ok", "message": "Conversation cleared"}
|
| 197 |
|
|
|
|
| 66 |
|
| 67 |
@router.get("/health", response_model=HealthResponse)
|
| 68 |
async def health_check():
|
| 69 |
+
"""Check if the server is healthy."""
|
| 70 |
+
# NOTE: We no longer check a shared singleton session.
|
| 71 |
+
# Each WebSocket connection has its own AgentSession.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
return HealthResponse(
|
| 73 |
status="ok",
|
| 74 |
version="1.0.0",
|
| 75 |
+
agent_ready=True # Server is up β ready to accept WS connections
|
| 76 |
)
|
| 77 |
|
| 78 |
|
|
|
|
| 175 |
|
| 176 |
@router.delete("/conversation")
|
| 177 |
async def clear_conversation():
|
| 178 |
+
"""Clear the conversation history (shared memory only)."""
|
| 179 |
from eurus.memory import get_memory
|
|
|
|
| 180 |
|
| 181 |
memory = get_memory()
|
| 182 |
memory.clear_conversation()
|
| 183 |
|
| 184 |
+
# NOTE: Agent session messages are managed per-WebSocket connection.
|
| 185 |
+
# Each session clears its own messages when the connection closes.
|
|
|
|
|
|
|
| 186 |
|
| 187 |
return {"status": "ok", "message": "Conversation cleared"}
|
| 188 |
|
web/routes/pages.py
CHANGED
|
@@ -4,23 +4,48 @@ Page Routes
|
|
| 4 |
Serves React frontend build in production, redirects to Vite dev server in development.
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
-
from fastapi import APIRouter
|
| 10 |
-
from fastapi.responses import FileResponse, RedirectResponse
|
| 11 |
|
| 12 |
router = APIRouter()
|
| 13 |
|
| 14 |
# React production build directory
|
| 15 |
BUILD_DIR = Path(__file__).parent.parent.parent / "frontend" / "dist"
|
| 16 |
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
index_file = BUILD_DIR / "index.html"
|
|
|
|
|
|
|
| 22 |
if index_file.exists():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return FileResponse(index_file)
|
| 24 |
-
# Dev mode fallback: redirect to Vite dev server
|
| 25 |
-
return RedirectResponse(url="http://localhost:5182/")
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
Serves React frontend build in production, redirects to Vite dev server in development.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import os
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
+
from fastapi import APIRouter, Request
|
| 11 |
+
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
|
| 15 |
# React production build directory
|
| 16 |
BUILD_DIR = Path(__file__).parent.parent.parent / "frontend" / "dist"
|
| 17 |
|
| 18 |
+
# Detect dev mode: EURUS_DEV=1 or no dist folder AND localhost access
|
| 19 |
+
_DEV_MODE = os.environ.get("EURUS_DEV") == "1"
|
| 20 |
|
| 21 |
+
# Minimal HTML fallback β returns 200 so HF Spaces health check passes
|
| 22 |
+
_FALLBACK_HTML = """<!DOCTYPE html>
|
| 23 |
+
<html lang="en"><head><meta charset="utf-8"><title>Eurus</title>
|
| 24 |
+
<style>body{font-family:system-ui;display:flex;align-items:center;
|
| 25 |
+
justify-content:center;height:100vh;margin:0;background:#0f172a;color:#e2e8f0}
|
| 26 |
+
.c{text-align:center}h1{font-size:2rem;margin-bottom:.5rem}
|
| 27 |
+
p{opacity:.7}</style></head>
|
| 28 |
+
<body><div class="c"><h1>π Eurus</h1><p>Climate Agent is running.
|
| 29 |
+
Connect via WebSocket to start a session.</p></div></body></html>"""
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/{full_path:path}")
|
| 33 |
+
async def catch_all(request: Request, full_path: str = ""):
|
| 34 |
+
"""Serve React SPA β all non-API routes go to index.html."""
|
| 35 |
index_file = BUILD_DIR / "index.html"
|
| 36 |
+
|
| 37 |
+
# Production: serve from built React app
|
| 38 |
if index_file.exists():
|
| 39 |
+
# Try to serve the exact static file first (e.g. /assets/index-abc.js)
|
| 40 |
+
requested = BUILD_DIR / full_path
|
| 41 |
+
if full_path and requested.is_file() and requested.resolve().is_relative_to(BUILD_DIR.resolve()):
|
| 42 |
+
return FileResponse(requested)
|
| 43 |
+
# Otherwise serve index.html for SPA routing
|
| 44 |
return FileResponse(index_file)
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
# Dev mode on localhost only β redirect to Vite dev server
|
| 47 |
+
if _DEV_MODE:
|
| 48 |
+
return RedirectResponse(url=f"http://localhost:5182/{full_path}")
|
| 49 |
+
|
| 50 |
+
# No React build available β return 200 OK fallback so HF health check passes
|
| 51 |
+
return HTMLResponse(content=_FALLBACK_HTML, status_code=200)
|