dmpantiu commited on
Commit
1c9cb5b
Β·
verified Β·
1 Parent(s): a62555d

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -1,25 +1,30 @@
1
  # ============================================================================
2
  # Eurus ERA5 Agent β€” Docker Image
3
  # ============================================================================
4
- # Multi-target build:
5
- # docker build --target agent -t eurus-agent .
6
- # docker build --target web -t eurus-web .
7
  #
8
- # Or use docker-compose (preferred):
9
- # docker compose run --rm agent # interactive CLI
10
- # docker compose up web # FastAPI on :8000
 
 
 
11
  # ============================================================================
12
 
13
- # ---------- base ----------
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
- gcc g++ \
19
- libgeos-dev \
20
- libproj-dev \
21
- libffi-dev \
22
- curl \
 
 
 
 
 
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
- # Signal to the REPL that we're inside Docker β†’ security checks disabled
54
- ENV EURUS_DOCKER=1
 
 
 
 
 
 
 
 
 
 
 
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(false)); // fallback: assume keys in .env
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
- era5_tool = StructuredTool.from_function(
190
- func=retrieve_era5_data,
191
- name="retrieve_era5_data",
192
- description=(
193
- "Retrieves ERA5 climate reanalysis data from Earthmover's cloud archive.\n\n"
194
- "⚠️ query_type is AUTO-DETECTED - you don't need to specify it!\n\n"
195
- "Just provide:\n"
196
- "- variable_id: one of 22 ERA5 variables (sst, t2, d2, skt, u10, v10, u100, v100, "
197
- "sp, mslp, blh, cape, tcc, cp, lsp, tp, ssr, ssrd, tcw, tcwv, sd, stl1, swvl1)\n"
198
- "- start_date, end_date: YYYY-MM-DD format\n"
199
- "- lat/lon bounds: Use values from maritime route bounding box!\n\n"
200
- "DATA: 1975-2024.\n"
201
- "Returns file path. Load with: xr.open_zarr('PATH')"
202
- ),
203
- args_schema=ERA5RetrievalArgs
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
- if hf_token and not os.environ.get("HF_TOKEN"):
85
- os.environ["HF_TOKEN"] = hf_token
 
 
 
 
 
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
- tools = get_all_tools(enable_routing=True, enable_guide=True)
 
 
 
 
 
 
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 and agent are healthy."""
70
- from web.agent_wrapper import get_agent_session
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=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
- # Also clear the agent session messages
192
- session = get_agent_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
- @router.get("/")
19
- async def index():
20
- """Serve React build in production, redirect to Vite in dev."""
 
 
 
 
 
 
 
 
 
 
 
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)