Upload folder using huggingface_hub
Browse files- .gitignore +4 -0
- frontend/.gitignore +24 -0
- frontend/index.html +17 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +26 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +16 -0
- frontend/src/App.tsx +27 -0
- frontend/src/components/ApiKeysPanel.css +90 -0
- frontend/src/components/ApiKeysPanel.tsx +71 -0
- frontend/src/components/CachePanel.css +138 -0
- frontend/src/components/CachePanel.tsx +106 -0
- frontend/src/components/ChatPanel.css +299 -0
- frontend/src/components/ChatPanel.tsx +339 -0
- frontend/src/components/MessageBubble.css +334 -0
- frontend/src/components/MessageBubble.tsx +219 -0
- frontend/src/components/ModelSelector.css +93 -0
- frontend/src/components/ModelSelector.tsx +73 -0
- frontend/src/components/ThemeToggle.css +55 -0
- frontend/src/components/ThemeToggle.tsx +34 -0
- frontend/src/hooks/useWebSocket.ts +137 -0
- frontend/src/index.css +154 -0
- frontend/src/main.tsx +8 -0
- frontend/tsconfig.json +32 -0
- frontend/vite.config.ts +14 -0
- web/agent_wrapper.py +86 -4
- web/app.py +16 -7
- web/routes/pages.py +15 -16
- web/routes/websocket.py +27 -2
.gitignore
CHANGED
|
@@ -68,3 +68,7 @@ deep_searches/
|
|
| 68 |
# Generated project dumps
|
| 69 |
full_project.txt
|
| 70 |
src_structure.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
# Generated project dumps
|
| 69 |
full_project.txt
|
| 70 |
src_structure.txt
|
| 71 |
+
|
| 72 |
+
# Frontend
|
| 73 |
+
frontend/node_modules/
|
| 74 |
+
frontend/dist/
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<link rel="icon" href="data:image/svg+xml,π" />
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 8 |
+
<meta name="description" content="Eurus β Interactive ERA5 Climate Data Analysis Agent" />
|
| 9 |
+
<title>Eurus Climate Agent</title>
|
| 10 |
+
</head>
|
| 11 |
+
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 15 |
+
</body>
|
| 16 |
+
|
| 17 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"devDependencies": {
|
| 12 |
+
"@types/react": "^19.2.14",
|
| 13 |
+
"@types/react-dom": "^19.2.3",
|
| 14 |
+
"@vitejs/plugin-react": "^5.1.4",
|
| 15 |
+
"typescript": "~5.9.3",
|
| 16 |
+
"vite": "^7.3.1"
|
| 17 |
+
},
|
| 18 |
+
"dependencies": {
|
| 19 |
+
"lucide-react": "^0.577.0",
|
| 20 |
+
"react": "^19.2.4",
|
| 21 |
+
"react-dom": "^19.2.4",
|
| 22 |
+
"react-markdown": "^10.1.0",
|
| 23 |
+
"rehype-highlight": "^7.0.2",
|
| 24 |
+
"remark-gfm": "^4.0.1"
|
| 25 |
+
}
|
| 26 |
+
}
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.app-layout {
|
| 2 |
+
display: flex;
|
| 3 |
+
height: 100vh;
|
| 4 |
+
width: 100vw;
|
| 5 |
+
overflow: hidden;
|
| 6 |
+
position: relative;
|
| 7 |
+
z-index: 1;
|
| 8 |
+
padding: 0.5rem;
|
| 9 |
+
gap: 0.5rem;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
@media (max-width: 768px) {
|
| 13 |
+
.app-layout {
|
| 14 |
+
flex-direction: column;
|
| 15 |
+
}
|
| 16 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { Database } from 'lucide-react';
|
| 3 |
+
import ChatPanel from './components/ChatPanel';
|
| 4 |
+
import CachePanel from './components/CachePanel';
|
| 5 |
+
import './App.css';
|
| 6 |
+
|
| 7 |
+
export default function App() {
|
| 8 |
+
const [showCache, setShowCache] = useState(false);
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<div className="app-layout">
|
| 12 |
+
<ChatPanel
|
| 13 |
+
cacheToggle={
|
| 14 |
+
<button
|
| 15 |
+
className="icon-btn"
|
| 16 |
+
onClick={() => setShowCache(v => !v)}
|
| 17 |
+
title={showCache ? 'Hide datasets' : 'Show cached datasets'}
|
| 18 |
+
style={showCache ? { background: 'rgba(109,92,255,0.12)', borderColor: 'rgba(109,92,255,0.25)', color: '#a78bfa' } : undefined}
|
| 19 |
+
>
|
| 20 |
+
<Database size={16} />
|
| 21 |
+
</button>
|
| 22 |
+
}
|
| 23 |
+
/>
|
| 24 |
+
{showCache && <CachePanel />}
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
frontend/src/components/ApiKeysPanel.css
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ API Keys Panel ββββββββββββββββββββββββ */
|
| 2 |
+
.keys-panel {
|
| 3 |
+
margin: 1rem 2rem;
|
| 4 |
+
padding: 1.2rem;
|
| 5 |
+
background: var(--glass);
|
| 6 |
+
border: 1px solid rgba(251, 191, 36, 0.15);
|
| 7 |
+
border-radius: var(--radius);
|
| 8 |
+
backdrop-filter: blur(12px);
|
| 9 |
+
animation: fadeIn 0.3s ease;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.keys-header {
|
| 13 |
+
display: flex;
|
| 14 |
+
align-items: center;
|
| 15 |
+
gap: 0.5rem;
|
| 16 |
+
font-weight: 600;
|
| 17 |
+
font-size: 0.9rem;
|
| 18 |
+
color: var(--warning);
|
| 19 |
+
margin-bottom: 0.5rem;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.keys-note {
|
| 23 |
+
font-size: 0.78rem;
|
| 24 |
+
color: var(--text-3);
|
| 25 |
+
margin-bottom: 0.85rem;
|
| 26 |
+
line-height: 1.5;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.keys-field {
|
| 30 |
+
margin-bottom: 0.65rem;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.keys-field label {
|
| 34 |
+
display: block;
|
| 35 |
+
font-size: 0.78rem;
|
| 36 |
+
font-weight: 500;
|
| 37 |
+
color: var(--text-2);
|
| 38 |
+
margin-bottom: 0.3rem;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.keys-field .required {
|
| 42 |
+
color: var(--danger);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.keys-field input {
|
| 46 |
+
width: 100%;
|
| 47 |
+
padding: 0.5rem 0.7rem;
|
| 48 |
+
background: var(--input-bg);
|
| 49 |
+
border: 1px solid var(--glass-border);
|
| 50 |
+
border-radius: var(--radius-sm);
|
| 51 |
+
color: var(--text-1);
|
| 52 |
+
font-size: 0.85rem;
|
| 53 |
+
font-family: inherit;
|
| 54 |
+
outline: none;
|
| 55 |
+
transition: all 0.2s;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.keys-field input:focus {
|
| 59 |
+
border-color: rgba(109, 92, 255, 0.4);
|
| 60 |
+
box-shadow: 0 0 0 3px rgba(109, 92, 255, 0.08);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.keys-field input::placeholder {
|
| 64 |
+
color: var(--text-3);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.keys-submit {
|
| 68 |
+
width: 100%;
|
| 69 |
+
padding: 0.55rem;
|
| 70 |
+
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
| 71 |
+
border: none;
|
| 72 |
+
border-radius: var(--radius-sm);
|
| 73 |
+
color: #fff;
|
| 74 |
+
font-size: 0.85rem;
|
| 75 |
+
font-weight: 600;
|
| 76 |
+
font-family: inherit;
|
| 77 |
+
cursor: pointer;
|
| 78 |
+
transition: all 0.25s;
|
| 79 |
+
box-shadow: 0 2px 12px var(--accent-glow);
|
| 80 |
+
margin-top: 0.3rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.keys-submit:hover:not(:disabled) {
|
| 84 |
+
filter: brightness(1.12);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.keys-submit:disabled {
|
| 88 |
+
opacity: 0.4;
|
| 89 |
+
cursor: not-allowed;
|
| 90 |
+
}
|
frontend/src/components/ApiKeysPanel.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { Key } from 'lucide-react';
|
| 3 |
+
import './ApiKeysPanel.css';
|
| 4 |
+
|
| 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');
|
| 18 |
+
if (saved) {
|
| 19 |
+
try {
|
| 20 |
+
const k = JSON.parse(saved);
|
| 21 |
+
if (k.openai_api_key) setOpenaiKey(k.openai_api_key);
|
| 22 |
+
if (k.arraylake_api_key) setArraylakeKey(k.arraylake_api_key);
|
| 23 |
+
} catch { /* ignore */ }
|
| 24 |
+
}
|
| 25 |
+
}, []);
|
| 26 |
+
|
| 27 |
+
if (!visible) return null;
|
| 28 |
+
|
| 29 |
+
const handleSubmit = () => {
|
| 30 |
+
if (!openaiKey.trim()) return;
|
| 31 |
+
setSaving(true);
|
| 32 |
+
onSave({ openai_api_key: openaiKey.trim(), arraylake_api_key: arraylakeKey.trim() });
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="keys-panel">
|
| 37 |
+
<div className="keys-header">
|
| 38 |
+
<Key size={16} />
|
| 39 |
+
<span>API Keys Required</span>
|
| 40 |
+
</div>
|
| 41 |
+
<p className="keys-note">
|
| 42 |
+
Enter your API keys to use Eurus. Keys are kept in your browser session only β cleared when you close the browser.
|
| 43 |
+
</p>
|
| 44 |
+
<div className="keys-field">
|
| 45 |
+
<label>OpenAI API Key <span className="required">*</span></label>
|
| 46 |
+
<input
|
| 47 |
+
type="password"
|
| 48 |
+
value={openaiKey}
|
| 49 |
+
onChange={e => setOpenaiKey(e.target.value)}
|
| 50 |
+
placeholder="sk-..."
|
| 51 |
+
autoComplete="off"
|
| 52 |
+
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
|
| 53 |
+
/>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="keys-field">
|
| 56 |
+
<label>Arraylake API Key</label>
|
| 57 |
+
<input
|
| 58 |
+
type="password"
|
| 59 |
+
value={arraylakeKey}
|
| 60 |
+
onChange={e => setArraylakeKey(e.target.value)}
|
| 61 |
+
placeholder="ema_..."
|
| 62 |
+
autoComplete="off"
|
| 63 |
+
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
|
| 64 |
+
/>
|
| 65 |
+
</div>
|
| 66 |
+
<button className="keys-submit" onClick={handleSubmit} disabled={saving || !openaiKey.trim()}>
|
| 67 |
+
{saving ? 'Connecting...' : 'Connect'}
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
);
|
| 71 |
+
}
|
frontend/src/components/CachePanel.css
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ CachePanel ββββββββββββββββββββββββββββ */
|
| 2 |
+
.cache-panel {
|
| 3 |
+
width: 300px;
|
| 4 |
+
background: var(--glass);
|
| 5 |
+
border-left: 1px solid var(--glass-border);
|
| 6 |
+
display: flex;
|
| 7 |
+
flex-direction: column;
|
| 8 |
+
overflow: hidden;
|
| 9 |
+
backdrop-filter: blur(20px);
|
| 10 |
+
-webkit-backdrop-filter: blur(20px);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.cache-header {
|
| 14 |
+
display: flex;
|
| 15 |
+
align-items: center;
|
| 16 |
+
justify-content: space-between;
|
| 17 |
+
padding: 0.85rem 1.15rem;
|
| 18 |
+
border-bottom: 1px solid var(--glass-border);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.cache-title {
|
| 22 |
+
display: flex;
|
| 23 |
+
align-items: center;
|
| 24 |
+
gap: 0.4rem;
|
| 25 |
+
color: var(--text-1);
|
| 26 |
+
font-size: 0.82rem;
|
| 27 |
+
font-weight: 600;
|
| 28 |
+
letter-spacing: -0.01em;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.cache-refresh {
|
| 32 |
+
background: none;
|
| 33 |
+
border: 1px solid var(--glass-border);
|
| 34 |
+
color: var(--text-3);
|
| 35 |
+
cursor: pointer;
|
| 36 |
+
padding: 0.3rem;
|
| 37 |
+
display: flex;
|
| 38 |
+
border-radius: var(--radius-sm);
|
| 39 |
+
transition: all 0.2s;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.cache-refresh:hover {
|
| 43 |
+
color: var(--text-2);
|
| 44 |
+
background: var(--hover-bg);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.cache-summary {
|
| 48 |
+
display: flex;
|
| 49 |
+
align-items: center;
|
| 50 |
+
gap: 0.35rem;
|
| 51 |
+
padding: 0.55rem 1.15rem;
|
| 52 |
+
font-size: 0.72rem;
|
| 53 |
+
font-weight: 500;
|
| 54 |
+
color: var(--text-3);
|
| 55 |
+
border-bottom: 1px solid var(--subtle-border);
|
| 56 |
+
text-transform: uppercase;
|
| 57 |
+
letter-spacing: 0.05em;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.cache-list {
|
| 61 |
+
list-style: none;
|
| 62 |
+
margin: 0;
|
| 63 |
+
padding: 0;
|
| 64 |
+
flex: 1;
|
| 65 |
+
overflow-y: auto;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.cache-item {
|
| 69 |
+
padding: 0.55rem 1.15rem;
|
| 70 |
+
border-bottom: 1px solid var(--subtle-border);
|
| 71 |
+
transition: background 0.2s;
|
| 72 |
+
cursor: default;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.cache-item:hover {
|
| 76 |
+
background: var(--hover-bg);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.cache-item-row {
|
| 80 |
+
display: flex;
|
| 81 |
+
align-items: center;
|
| 82 |
+
justify-content: space-between;
|
| 83 |
+
gap: 0.5rem;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.cache-var {
|
| 87 |
+
font-size: 0.8rem;
|
| 88 |
+
font-weight: 600;
|
| 89 |
+
color: var(--accent-2);
|
| 90 |
+
letter-spacing: -0.01em;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.cache-meta {
|
| 94 |
+
font-size: 0.7rem;
|
| 95 |
+
color: var(--text-3);
|
| 96 |
+
margin-top: 0.12rem;
|
| 97 |
+
font-variant-numeric: tabular-nums;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.cache-dl-btn {
|
| 101 |
+
flex-shrink: 0;
|
| 102 |
+
background: none;
|
| 103 |
+
border: 1px solid var(--glass-border);
|
| 104 |
+
border-radius: var(--radius-sm);
|
| 105 |
+
color: var(--text-3);
|
| 106 |
+
padding: 0.3rem;
|
| 107 |
+
cursor: pointer;
|
| 108 |
+
display: flex;
|
| 109 |
+
align-items: center;
|
| 110 |
+
transition: all 0.2s;
|
| 111 |
+
opacity: 0;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.cache-item:hover .cache-dl-btn {
|
| 115 |
+
opacity: 1;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.cache-dl-btn:hover {
|
| 119 |
+
color: var(--accent-2);
|
| 120 |
+
border-color: rgba(56, 189, 248, 0.3);
|
| 121 |
+
background: rgba(56, 189, 248, 0.08);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.cache-empty {
|
| 125 |
+
padding: 2.5rem 1.15rem;
|
| 126 |
+
text-align: center;
|
| 127 |
+
color: var(--text-3);
|
| 128 |
+
font-size: 0.8rem;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
@media (max-width: 768px) {
|
| 132 |
+
.cache-panel {
|
| 133 |
+
width: 100%;
|
| 134 |
+
border-left: none;
|
| 135 |
+
border-top: 1px solid var(--glass-border);
|
| 136 |
+
max-height: 35vh;
|
| 137 |
+
}
|
| 138 |
+
}
|
frontend/src/components/CachePanel.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { Database, HardDrive, RefreshCw, Download } from 'lucide-react';
|
| 3 |
+
import './CachePanel.css';
|
| 4 |
+
|
| 5 |
+
interface Dataset {
|
| 6 |
+
variable: string;
|
| 7 |
+
query_type: string;
|
| 8 |
+
start_date: string;
|
| 9 |
+
end_date: string;
|
| 10 |
+
lat_bounds: [number, number];
|
| 11 |
+
lon_bounds: [number, number];
|
| 12 |
+
file_size_bytes: number;
|
| 13 |
+
path: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface CacheData {
|
| 17 |
+
datasets: Dataset[];
|
| 18 |
+
total_size_bytes: number;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function formatBytes(bytes: number): string {
|
| 22 |
+
if (bytes < 1024) return bytes + ' B';
|
| 23 |
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
| 24 |
+
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
| 25 |
+
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async function downloadDataset(path: string) {
|
| 29 |
+
try {
|
| 30 |
+
const resp = await fetch(`/api/cache/download?path=${encodeURIComponent(path)}`);
|
| 31 |
+
if (!resp.ok) throw new Error('Download failed');
|
| 32 |
+
const blob = await resp.blob();
|
| 33 |
+
const url = URL.createObjectURL(blob);
|
| 34 |
+
const a = document.createElement('a');
|
| 35 |
+
a.href = url;
|
| 36 |
+
a.download = path.split('/').pop() + '.zip';
|
| 37 |
+
document.body.appendChild(a);
|
| 38 |
+
a.click();
|
| 39 |
+
a.remove();
|
| 40 |
+
URL.revokeObjectURL(url);
|
| 41 |
+
} catch (err) {
|
| 42 |
+
console.error('Download error:', err);
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default function CachePanel() {
|
| 47 |
+
const [cache, setCache] = useState<CacheData | null>(null);
|
| 48 |
+
const [loading, setLoading] = useState(false);
|
| 49 |
+
|
| 50 |
+
const fetchCache = async () => {
|
| 51 |
+
setLoading(true);
|
| 52 |
+
try {
|
| 53 |
+
const res = await fetch('/api/cache');
|
| 54 |
+
if (res.ok) setCache(await res.json());
|
| 55 |
+
} catch { /* ignore */ }
|
| 56 |
+
setLoading(false);
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
useEffect(() => { fetchCache(); }, []);
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="cache-panel">
|
| 63 |
+
<div className="cache-header">
|
| 64 |
+
<div className="cache-title">
|
| 65 |
+
<Database size={16} />
|
| 66 |
+
<span>Cached Datasets</span>
|
| 67 |
+
</div>
|
| 68 |
+
<button className="cache-refresh" onClick={fetchCache} disabled={loading} title="Refresh">
|
| 69 |
+
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{cache && cache.datasets.length > 0 ? (
|
| 74 |
+
<>
|
| 75 |
+
<div className="cache-summary">
|
| 76 |
+
<HardDrive size={13} />
|
| 77 |
+
<span>{cache.datasets.length} datasets Β· {formatBytes(cache.total_size_bytes)}</span>
|
| 78 |
+
</div>
|
| 79 |
+
<ul className="cache-list">
|
| 80 |
+
{cache.datasets.map((ds, i) => (
|
| 81 |
+
<li key={i} className="cache-item">
|
| 82 |
+
<div className="cache-item-row">
|
| 83 |
+
<div>
|
| 84 |
+
<div className="cache-var">{ds.variable}</div>
|
| 85 |
+
<div className="cache-meta">
|
| 86 |
+
{ds.start_date} β {ds.end_date} Β· {formatBytes(ds.file_size_bytes)}
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
<button
|
| 90 |
+
className="cache-dl-btn"
|
| 91 |
+
onClick={() => downloadDataset(ds.path)}
|
| 92 |
+
title="Download as ZIP"
|
| 93 |
+
>
|
| 94 |
+
<Download size={13} />
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
</li>
|
| 98 |
+
))}
|
| 99 |
+
</ul>
|
| 100 |
+
</>
|
| 101 |
+
) : (
|
| 102 |
+
<div className="cache-empty">No cached datasets yet</div>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
);
|
| 106 |
+
}
|
frontend/src/components/ChatPanel.css
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ ChatPanel ββββββββββββββββββββββββββββββ */
|
| 2 |
+
.chat-panel {
|
| 3 |
+
display: flex;
|
| 4 |
+
flex-direction: column;
|
| 5 |
+
flex: 1;
|
| 6 |
+
min-width: 0;
|
| 7 |
+
height: 100vh;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/* ββ Header ββ */
|
| 11 |
+
.chat-header {
|
| 12 |
+
display: flex;
|
| 13 |
+
align-items: center;
|
| 14 |
+
justify-content: space-between;
|
| 15 |
+
padding: 0.85rem 1.5rem;
|
| 16 |
+
background: var(--glass);
|
| 17 |
+
border-bottom: 1px solid var(--glass-border);
|
| 18 |
+
backdrop-filter: blur(20px) saturate(1.6);
|
| 19 |
+
-webkit-backdrop-filter: blur(20px) saturate(1.6);
|
| 20 |
+
position: relative;
|
| 21 |
+
z-index: 10;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.chat-title {
|
| 25 |
+
display: flex;
|
| 26 |
+
align-items: center;
|
| 27 |
+
gap: 0.65rem;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.chat-logo {
|
| 31 |
+
width: 32px;
|
| 32 |
+
height: 32px;
|
| 33 |
+
border-radius: 8px;
|
| 34 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
| 35 |
+
display: flex;
|
| 36 |
+
align-items: center;
|
| 37 |
+
justify-content: center;
|
| 38 |
+
font-size: 1rem;
|
| 39 |
+
box-shadow: 0 0 16px var(--accent-glow);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.chat-title h1 {
|
| 43 |
+
font-size: 1rem;
|
| 44 |
+
font-weight: 600;
|
| 45 |
+
color: var(--text-1);
|
| 46 |
+
margin: 0;
|
| 47 |
+
letter-spacing: -0.01em;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.chat-header-actions {
|
| 51 |
+
display: flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
gap: 0.6rem;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.icon-btn {
|
| 57 |
+
background: var(--glass);
|
| 58 |
+
border: 1px solid var(--glass-border);
|
| 59 |
+
border-radius: var(--radius-sm);
|
| 60 |
+
color: var(--text-3);
|
| 61 |
+
padding: 0.4rem;
|
| 62 |
+
cursor: pointer;
|
| 63 |
+
display: flex;
|
| 64 |
+
align-items: center;
|
| 65 |
+
transition: all 0.2s ease;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.icon-btn:hover {
|
| 69 |
+
background: var(--hover-bg);
|
| 70 |
+
color: var(--text-2);
|
| 71 |
+
border-color: var(--hover-border);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.danger-btn:hover {
|
| 75 |
+
background: rgba(248, 113, 113, 0.1);
|
| 76 |
+
color: var(--danger);
|
| 77 |
+
border-color: rgba(248, 113, 113, 0.2);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.status-badge {
|
| 81 |
+
display: inline-flex;
|
| 82 |
+
align-items: center;
|
| 83 |
+
gap: 0.35rem;
|
| 84 |
+
font-size: 0.72rem;
|
| 85 |
+
font-weight: 500;
|
| 86 |
+
text-transform: uppercase;
|
| 87 |
+
letter-spacing: 0.06em;
|
| 88 |
+
padding: 0.3rem 0.65rem;
|
| 89 |
+
border-radius: 100px;
|
| 90 |
+
background: rgba(52, 211, 153, 0.08);
|
| 91 |
+
border: 1px solid rgba(52, 211, 153, 0.15);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.status-badge.disconnected {
|
| 95 |
+
background: rgba(248, 113, 113, 0.08);
|
| 96 |
+
border-color: rgba(248, 113, 113, 0.15);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* ββ Messages ββ */
|
| 100 |
+
.messages-container {
|
| 101 |
+
flex: 1;
|
| 102 |
+
overflow-y: auto;
|
| 103 |
+
padding: 1.5rem 2rem 1rem;
|
| 104 |
+
max-width: 960px;
|
| 105 |
+
margin: 0 auto;
|
| 106 |
+
width: 100%;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* ββ Empty state ββ */
|
| 110 |
+
.empty-state {
|
| 111 |
+
display: flex;
|
| 112 |
+
flex-direction: column;
|
| 113 |
+
align-items: center;
|
| 114 |
+
justify-content: center;
|
| 115 |
+
height: 100%;
|
| 116 |
+
text-align: center;
|
| 117 |
+
gap: 0.3rem;
|
| 118 |
+
animation: fadeIn 0.5s ease;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
@keyframes fadeIn {
|
| 122 |
+
from {
|
| 123 |
+
opacity: 0;
|
| 124 |
+
transform: translateY(12px);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
to {
|
| 128 |
+
opacity: 1;
|
| 129 |
+
transform: translateY(0);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.empty-icon {
|
| 134 |
+
width: 72px;
|
| 135 |
+
height: 72px;
|
| 136 |
+
border-radius: 20px;
|
| 137 |
+
background: linear-gradient(135deg, rgba(109, 92, 255, 0.12), rgba(56, 189, 248, 0.12));
|
| 138 |
+
border: 1px solid rgba(109, 92, 255, 0.15);
|
| 139 |
+
display: flex;
|
| 140 |
+
align-items: center;
|
| 141 |
+
justify-content: center;
|
| 142 |
+
font-size: 2rem;
|
| 143 |
+
margin-bottom: 0.75rem;
|
| 144 |
+
box-shadow: 0 0 40px rgba(109, 92, 255, 0.1);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.empty-state h2 {
|
| 148 |
+
font-size: 1.6rem;
|
| 149 |
+
font-weight: 700;
|
| 150 |
+
color: var(--text-1);
|
| 151 |
+
margin: 0;
|
| 152 |
+
letter-spacing: -0.02em;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.empty-state p {
|
| 156 |
+
max-width: 380px;
|
| 157 |
+
font-size: 0.88rem;
|
| 158 |
+
line-height: 1.55;
|
| 159 |
+
color: var(--text-2);
|
| 160 |
+
margin: 0.15rem 0 1.25rem;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.example-queries {
|
| 164 |
+
display: flex;
|
| 165 |
+
flex-wrap: wrap;
|
| 166 |
+
gap: 0.5rem;
|
| 167 |
+
justify-content: center;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.example-queries button {
|
| 171 |
+
background: var(--glass);
|
| 172 |
+
border: 1px solid var(--glass-border);
|
| 173 |
+
border-radius: 100px;
|
| 174 |
+
color: var(--text-2);
|
| 175 |
+
padding: 0.5rem 1rem;
|
| 176 |
+
font-size: 0.8rem;
|
| 177 |
+
font-family: inherit;
|
| 178 |
+
cursor: pointer;
|
| 179 |
+
transition: all 0.25s ease;
|
| 180 |
+
white-space: nowrap;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.example-queries button:hover {
|
| 184 |
+
background: rgba(109, 92, 255, 0.1);
|
| 185 |
+
border-color: rgba(109, 92, 255, 0.25);
|
| 186 |
+
color: var(--text-1);
|
| 187 |
+
transform: translateY(-1px);
|
| 188 |
+
box-shadow: 0 4px 16px rgba(109, 92, 255, 0.12);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* ββ Thinking ββ */
|
| 192 |
+
.thinking-indicator {
|
| 193 |
+
display: inline-flex;
|
| 194 |
+
align-items: center;
|
| 195 |
+
gap: 0.5rem;
|
| 196 |
+
color: var(--accent);
|
| 197 |
+
font-size: 0.82rem;
|
| 198 |
+
font-weight: 500;
|
| 199 |
+
padding: 0.6rem 1rem;
|
| 200 |
+
background: rgba(109, 92, 255, 0.06);
|
| 201 |
+
border: 1px solid rgba(109, 92, 255, 0.12);
|
| 202 |
+
border-radius: 100px;
|
| 203 |
+
margin-bottom: 0.75rem;
|
| 204 |
+
animation: fadeIn 0.3s ease;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.spin {
|
| 208 |
+
animation: spin 0.8s linear infinite;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
@keyframes spin {
|
| 212 |
+
to {
|
| 213 |
+
transform: rotate(360deg);
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/* ββ Input bar ββ */
|
| 218 |
+
.input-bar {
|
| 219 |
+
display: flex;
|
| 220 |
+
align-items: flex-end;
|
| 221 |
+
gap: 0.6rem;
|
| 222 |
+
padding: 0.85rem 2rem 1.1rem;
|
| 223 |
+
background: var(--glass);
|
| 224 |
+
border-top: 1px solid var(--glass-border);
|
| 225 |
+
backdrop-filter: blur(20px);
|
| 226 |
+
-webkit-backdrop-filter: blur(20px);
|
| 227 |
+
max-width: 960px;
|
| 228 |
+
margin: 0 auto;
|
| 229 |
+
width: 100%;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.input-bar textarea {
|
| 233 |
+
flex: 1;
|
| 234 |
+
resize: none;
|
| 235 |
+
border: 1px solid var(--glass-border);
|
| 236 |
+
border-radius: var(--radius);
|
| 237 |
+
background: var(--input-bg);
|
| 238 |
+
color: var(--text-1);
|
| 239 |
+
padding: 0.7rem 1rem;
|
| 240 |
+
font-size: 0.9rem;
|
| 241 |
+
font-family: inherit;
|
| 242 |
+
line-height: 1.45;
|
| 243 |
+
outline: none;
|
| 244 |
+
transition: all 0.25s ease;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.input-bar textarea:focus {
|
| 248 |
+
border-color: rgba(109, 92, 255, 0.4);
|
| 249 |
+
box-shadow: 0 0 0 3px rgba(109, 92, 255, 0.08);
|
| 250 |
+
background: var(--input-focus-bg);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.input-bar textarea::placeholder {
|
| 254 |
+
color: var(--text-3);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.input-bar textarea:disabled {
|
| 258 |
+
opacity: 0.35;
|
| 259 |
+
cursor: not-allowed;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.send-btn {
|
| 263 |
+
flex-shrink: 0;
|
| 264 |
+
width: 2.6rem;
|
| 265 |
+
height: 2.6rem;
|
| 266 |
+
display: flex;
|
| 267 |
+
align-items: center;
|
| 268 |
+
justify-content: center;
|
| 269 |
+
border: none;
|
| 270 |
+
border-radius: var(--radius);
|
| 271 |
+
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
| 272 |
+
color: #fff;
|
| 273 |
+
cursor: pointer;
|
| 274 |
+
transition: all 0.25s ease;
|
| 275 |
+
box-shadow: 0 2px 12px var(--accent-glow);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.send-btn:hover:not(:disabled) {
|
| 279 |
+
filter: brightness(1.15);
|
| 280 |
+
transform: translateY(-1px);
|
| 281 |
+
box-shadow: 0 4px 20px var(--accent-glow);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.send-btn:active:not(:disabled) {
|
| 285 |
+
transform: translateY(0);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.send-btn:disabled {
|
| 289 |
+
opacity: 0.3;
|
| 290 |
+
cursor: not-allowed;
|
| 291 |
+
box-shadow: none;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.empty-warning {
|
| 295 |
+
font-size: 0.78rem !important;
|
| 296 |
+
color: var(--text-3) !important;
|
| 297 |
+
opacity: 0.8;
|
| 298 |
+
margin-top: -0.5rem !important;
|
| 299 |
+
}
|
frontend/src/components/ChatPanel.tsx
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
|
| 2 |
+
import { Send, Wifi, WifiOff, Loader2, Trash2 } from 'lucide-react';
|
| 3 |
+
import ThemeToggle from './ThemeToggle';
|
| 4 |
+
import ModelSelector from './ModelSelector';
|
| 5 |
+
import { useWebSocket, WSEvent } from '../hooks/useWebSocket';
|
| 6 |
+
import MessageBubble, { ChatMessage, MediaItem } from './MessageBubble';
|
| 7 |
+
import ApiKeysPanel from './ApiKeysPanel';
|
| 8 |
+
import './ChatPanel.css';
|
| 9 |
+
|
| 10 |
+
interface ChatPanelProps {
|
| 11 |
+
cacheToggle?: ReactNode;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
let msgCounter = 0;
|
| 15 |
+
const uid = () => `msg-${++msgCounter}-${Date.now()}`;
|
| 16 |
+
|
| 17 |
+
export default function ChatPanel({ cacheToggle }: ChatPanelProps) {
|
| 18 |
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
| 19 |
+
const [input, setInput] = useState('');
|
| 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[]>([]);
|
| 26 |
+
const streamSnippets = useRef<string[]>([]);
|
| 27 |
+
const streamId = useRef<string | null>(null);
|
| 28 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
/* ββ event handler ββ */
|
| 32 |
+
const handleEvent = useCallback((ev: WSEvent) => {
|
| 33 |
+
switch (ev.type) {
|
| 34 |
+
case 'thinking':
|
| 35 |
+
setIsThinking(true);
|
| 36 |
+
setStatusMsg('');
|
| 37 |
+
streamBuf.current = '';
|
| 38 |
+
streamMedia.current = [];
|
| 39 |
+
streamSnippets.current = [];
|
| 40 |
+
streamId.current = uid();
|
| 41 |
+
break;
|
| 42 |
+
|
| 43 |
+
case 'status':
|
| 44 |
+
setStatusMsg(ev.content ?? '');
|
| 45 |
+
break;
|
| 46 |
+
|
| 47 |
+
case 'tool_start':
|
| 48 |
+
setMessages(prev => {
|
| 49 |
+
const id = streamId.current ?? uid();
|
| 50 |
+
streamId.current = id;
|
| 51 |
+
const exists = prev.find(m => m.id === id);
|
| 52 |
+
if (exists) {
|
| 53 |
+
return prev.map(m =>
|
| 54 |
+
m.id === id ? { ...m, toolLabel: ev.content ?? '' } : m
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
return [...prev, { id, role: 'assistant', content: '', toolLabel: ev.content ?? '', isStreaming: true }];
|
| 58 |
+
});
|
| 59 |
+
break;
|
| 60 |
+
|
| 61 |
+
case 'stream': {
|
| 62 |
+
setIsThinking(false);
|
| 63 |
+
setStatusMsg('');
|
| 64 |
+
const chunk = ev.content ?? '';
|
| 65 |
+
streamBuf.current += chunk;
|
| 66 |
+
const id = streamId.current ?? uid();
|
| 67 |
+
streamId.current = id;
|
| 68 |
+
setMessages(prev => {
|
| 69 |
+
const exists = prev.find(m => m.id === id);
|
| 70 |
+
if (exists) {
|
| 71 |
+
return prev.map(m =>
|
| 72 |
+
m.id === id ? { ...m, content: streamBuf.current, isStreaming: true } : m
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
return [...prev, { id, role: 'assistant', content: streamBuf.current, isStreaming: true }];
|
| 76 |
+
});
|
| 77 |
+
break;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
case 'plot': {
|
| 81 |
+
const id = streamId.current ?? uid();
|
| 82 |
+
streamId.current = id;
|
| 83 |
+
if (ev.data) {
|
| 84 |
+
streamMedia.current.push({
|
| 85 |
+
type: 'plot',
|
| 86 |
+
base64: ev.data as string,
|
| 87 |
+
path: ev.path as string | undefined,
|
| 88 |
+
code: ev.code as string | undefined,
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
setMessages(prev => {
|
| 92 |
+
const exists = prev.find(m => m.id === id);
|
| 93 |
+
if (exists) {
|
| 94 |
+
return prev.map(m =>
|
| 95 |
+
m.id === id ? { ...m, media: [...streamMedia.current] } : m
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
return [...prev, { id, role: 'assistant', content: streamBuf.current, media: [...streamMedia.current], isStreaming: true }];
|
| 99 |
+
});
|
| 100 |
+
break;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
case 'video': {
|
| 104 |
+
const id = streamId.current ?? uid();
|
| 105 |
+
streamId.current = id;
|
| 106 |
+
if (ev.data) {
|
| 107 |
+
streamMedia.current.push({
|
| 108 |
+
type: 'video',
|
| 109 |
+
base64: ev.data as string,
|
| 110 |
+
path: ev.path as string | undefined,
|
| 111 |
+
mimetype: ev.mimetype as string | undefined,
|
| 112 |
+
});
|
| 113 |
+
}
|
| 114 |
+
setMessages(prev => {
|
| 115 |
+
const exists = prev.find(m => m.id === id);
|
| 116 |
+
if (exists) {
|
| 117 |
+
return prev.map(m =>
|
| 118 |
+
m.id === id ? { ...m, media: [...streamMedia.current] } : m
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
return [...prev, { id, role: 'assistant', content: streamBuf.current, media: [...streamMedia.current], isStreaming: true }];
|
| 122 |
+
});
|
| 123 |
+
break;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
case 'arraylake_snippet': {
|
| 127 |
+
const id = streamId.current;
|
| 128 |
+
if (ev.content && id) {
|
| 129 |
+
streamSnippets.current.push(ev.content);
|
| 130 |
+
setMessages(prev =>
|
| 131 |
+
prev.map(m =>
|
| 132 |
+
m.id === id ? { ...m, arraylakeSnippets: [...streamSnippets.current] } : m
|
| 133 |
+
)
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
break;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
case 'complete':
|
| 140 |
+
setIsThinking(false);
|
| 141 |
+
setStatusMsg('');
|
| 142 |
+
// Only finalize the existing streaming message β never create a new one.
|
| 143 |
+
// Snapshot refs into locals BEFORE the state setter runs.
|
| 144 |
+
if (streamId.current) {
|
| 145 |
+
const capturedId = streamId.current;
|
| 146 |
+
const capturedContent = ev.content ?? streamBuf.current;
|
| 147 |
+
const capturedMedia = [...streamMedia.current];
|
| 148 |
+
const capturedSnippets = [...streamSnippets.current];
|
| 149 |
+
setMessages(prev =>
|
| 150 |
+
prev.map(m => {
|
| 151 |
+
if (m.id !== capturedId) return m;
|
| 152 |
+
return {
|
| 153 |
+
...m,
|
| 154 |
+
content: capturedContent || m.content,
|
| 155 |
+
// Preserve media/snippets already on the message if our refs are empty
|
| 156 |
+
media: capturedMedia.length > 0 ? capturedMedia : (m.media || []),
|
| 157 |
+
arraylakeSnippets: capturedSnippets.length > 0 ? capturedSnippets : (m.arraylakeSnippets || []),
|
| 158 |
+
isStreaming: false,
|
| 159 |
+
toolLabel: undefined,
|
| 160 |
+
statusText: undefined,
|
| 161 |
+
};
|
| 162 |
+
})
|
| 163 |
+
);
|
| 164 |
+
}
|
| 165 |
+
streamBuf.current = '';
|
| 166 |
+
streamMedia.current = [];
|
| 167 |
+
streamSnippets.current = [];
|
| 168 |
+
streamId.current = null;
|
| 169 |
+
break;
|
| 170 |
+
|
| 171 |
+
case 'error':
|
| 172 |
+
setIsThinking(false);
|
| 173 |
+
setStatusMsg('');
|
| 174 |
+
setMessages(prev => [...prev, { id: uid(), role: 'system', content: `β ${ev.content ?? 'Unknown error'}` }]);
|
| 175 |
+
streamId.current = null;
|
| 176 |
+
break;
|
| 177 |
+
|
| 178 |
+
case 'keys_configured':
|
| 179 |
+
if (ev.ready) {
|
| 180 |
+
setNeedKeys(false);
|
| 181 |
+
}
|
| 182 |
+
break;
|
| 183 |
+
|
| 184 |
+
case 'request_keys':
|
| 185 |
+
// Server lost keys β resend from sessionStorage
|
| 186 |
+
setNeedKeys(true);
|
| 187 |
+
break;
|
| 188 |
+
|
| 189 |
+
case 'clear':
|
| 190 |
+
setMessages([]);
|
| 191 |
+
streamBuf.current = '';
|
| 192 |
+
streamMedia.current = [];
|
| 193 |
+
streamSnippets.current = [];
|
| 194 |
+
streamId.current = null;
|
| 195 |
+
break;
|
| 196 |
+
|
| 197 |
+
default:
|
| 198 |
+
break;
|
| 199 |
+
}
|
| 200 |
+
}, []);
|
| 201 |
+
|
| 202 |
+
const { status, send, sendMessage, configureKeys } = useWebSocket(handleEvent);
|
| 203 |
+
|
| 204 |
+
/* ββ check if server has keys ββ */
|
| 205 |
+
useEffect(() => {
|
| 206 |
+
if (status !== 'connected') return; // only check when connected
|
| 207 |
+
fetch('/api/keys-status')
|
| 208 |
+
.then(r => r.json())
|
| 209 |
+
.then(data => {
|
| 210 |
+
setNeedKeys(!data.openai);
|
| 211 |
+
})
|
| 212 |
+
.catch(() => setNeedKeys(false)); // fallback: assume keys in .env
|
| 213 |
+
}, [status]);
|
| 214 |
+
|
| 215 |
+
/* ββ auto-scroll ββ */
|
| 216 |
+
useEffect(() => {
|
| 217 |
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 218 |
+
}, [messages, isThinking, statusMsg]);
|
| 219 |
+
|
| 220 |
+
/* ββ send ββ */
|
| 221 |
+
const handleSend = () => {
|
| 222 |
+
const text = input.trim();
|
| 223 |
+
if (!text || status !== 'connected') return;
|
| 224 |
+
setMessages(prev => [...prev, { id: uid(), role: 'user', content: text }]);
|
| 225 |
+
sendMessage(text);
|
| 226 |
+
setInput('');
|
| 227 |
+
if (textareaRef.current) {
|
| 228 |
+
textareaRef.current.style.height = 'auto';
|
| 229 |
+
}
|
| 230 |
+
};
|
| 231 |
+
|
| 232 |
+
/* ββ clear conversation ββ */
|
| 233 |
+
const handleClear = async () => {
|
| 234 |
+
if (!confirm('Clear conversation history?')) return;
|
| 235 |
+
try {
|
| 236 |
+
await fetch('/api/conversation', { method: 'DELETE' });
|
| 237 |
+
setMessages([]);
|
| 238 |
+
} catch { /* ignore */ }
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
/* ββ auto-resize textarea ββ */
|
| 242 |
+
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
| 243 |
+
setInput(e.target.value);
|
| 244 |
+
const ta = e.target;
|
| 245 |
+
ta.style.height = 'auto';
|
| 246 |
+
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
/* ββ keys handler ββ */
|
| 250 |
+
const handleSaveKeys = (keys: { openai_api_key: string; arraylake_api_key: string }) => {
|
| 251 |
+
configureKeys(keys);
|
| 252 |
+
};
|
| 253 |
+
|
| 254 |
+
const statusColor = status === 'connected' ? '#34d399' : status === 'connecting' ? '#fbbf24' : '#f87171';
|
| 255 |
+
const StatusIcon = status === 'connected' ? Wifi : WifiOff;
|
| 256 |
+
const statusClass = `status-badge ${status === 'disconnected' ? 'disconnected' : ''}`;
|
| 257 |
+
|
| 258 |
+
const canSend = status === 'connected' && needKeys !== true;
|
| 259 |
+
|
| 260 |
+
return (
|
| 261 |
+
<div className="chat-panel">
|
| 262 |
+
{/* header */}
|
| 263 |
+
<header className="chat-header">
|
| 264 |
+
<div className="chat-title">
|
| 265 |
+
<div className="chat-logo">π</div>
|
| 266 |
+
<h1>Eurus Climate Agent</h1>
|
| 267 |
+
</div>
|
| 268 |
+
<div className="chat-header-actions">
|
| 269 |
+
<div className={statusClass} style={{ color: statusColor }}>
|
| 270 |
+
<StatusIcon size={12} />
|
| 271 |
+
<span>{status}</span>
|
| 272 |
+
</div>
|
| 273 |
+
{cacheToggle}
|
| 274 |
+
<ModelSelector send={send} />
|
| 275 |
+
<ThemeToggle />
|
| 276 |
+
<button className="icon-btn danger-btn" onClick={handleClear} title="Clear conversation">
|
| 277 |
+
<Trash2 size={16} />
|
| 278 |
+
</button>
|
| 279 |
+
</div>
|
| 280 |
+
</header>
|
| 281 |
+
|
| 282 |
+
{/* API keys panel */}
|
| 283 |
+
<ApiKeysPanel visible={needKeys === true} onSave={handleSaveKeys} />
|
| 284 |
+
|
| 285 |
+
{/* messages */}
|
| 286 |
+
<div className="messages-container">
|
| 287 |
+
{messages.length === 0 && (
|
| 288 |
+
<div className="empty-state">
|
| 289 |
+
<div className="empty-icon">π</div>
|
| 290 |
+
<h2>Welcome to Eurus</h2>
|
| 291 |
+
<p>Ask about ERA5 climate data β SST, wind, precipitation, temperature and more.</p>
|
| 292 |
+
<p className="empty-warning">
|
| 293 |
+
β οΈ <strong>Experimental</strong> β research prototype. Avoid very large datasets. Use π¦ Arraylake Code for heavy workloads.
|
| 294 |
+
</p>
|
| 295 |
+
<div className="example-queries">
|
| 296 |
+
<button onClick={() => { setInput('Show SST map for the North Atlantic, Jan 2024'); }}>
|
| 297 |
+
π‘ SST β North Atlantic
|
| 298 |
+
</button>
|
| 299 |
+
<button onClick={() => { setInput('Compare 2m temperature Berlin vs Tokyo, March 2023'); }}>
|
| 300 |
+
π¨ Temperature β Berlin vs Tokyo
|
| 301 |
+
</button>
|
| 302 |
+
<button onClick={() => { setInput('Precipitation anomalies over Amazon, 2023'); }}>
|
| 303 |
+
π§ Rain β Amazon basin
|
| 304 |
+
</button>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
{messages.map((m) => <MessageBubble key={m.id} msg={m} />)}
|
| 309 |
+
{(isThinking || statusMsg) && (
|
| 310 |
+
<div className="thinking-indicator">
|
| 311 |
+
<Loader2 className="spin" size={16} />
|
| 312 |
+
<span>{statusMsg || 'Analyzing...'}</span>
|
| 313 |
+
</div>
|
| 314 |
+
)}
|
| 315 |
+
<div ref={bottomRef} />
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
{/* input */}
|
| 319 |
+
<div className="input-bar">
|
| 320 |
+
<textarea
|
| 321 |
+
ref={textareaRef}
|
| 322 |
+
value={input}
|
| 323 |
+
onChange={handleInputChange}
|
| 324 |
+
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
|
| 325 |
+
placeholder={canSend ? 'Ask about climate dataβ¦' : needKeys ? 'Enter API keys aboveβ¦' : 'Connectingβ¦'}
|
| 326 |
+
disabled={!canSend}
|
| 327 |
+
rows={1}
|
| 328 |
+
/>
|
| 329 |
+
<button
|
| 330 |
+
className="send-btn"
|
| 331 |
+
onClick={handleSend}
|
| 332 |
+
disabled={!input.trim() || !canSend}
|
| 333 |
+
>
|
| 334 |
+
<Send size={18} />
|
| 335 |
+
</button>
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
);
|
| 339 |
+
}
|
frontend/src/components/MessageBubble.css
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ MessageBubble ββββββββββββββββββββββββββ */
|
| 2 |
+
.bubble-row {
|
| 3 |
+
display: flex;
|
| 4 |
+
margin-bottom: 1.1rem;
|
| 5 |
+
animation: bubbleIn 0.35s ease;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@keyframes bubbleIn {
|
| 9 |
+
from {
|
| 10 |
+
opacity: 0;
|
| 11 |
+
transform: translateY(8px);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
to {
|
| 15 |
+
opacity: 1;
|
| 16 |
+
transform: translateY(0);
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.bubble-row.user {
|
| 21 |
+
justify-content: flex-end;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.bubble-row.assistant {
|
| 25 |
+
justify-content: flex-start;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.bubble {
|
| 29 |
+
max-width: 80%;
|
| 30 |
+
padding: 0.9rem 1.15rem;
|
| 31 |
+
border-radius: var(--radius);
|
| 32 |
+
line-height: 1.6;
|
| 33 |
+
font-size: 0.9rem;
|
| 34 |
+
word-break: break-word;
|
| 35 |
+
position: relative;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* user */
|
| 39 |
+
.bubble-user {
|
| 40 |
+
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
| 41 |
+
color: #fff;
|
| 42 |
+
border-bottom-right-radius: 4px;
|
| 43 |
+
box-shadow: 0 4px 20px var(--accent-glow);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* assistant */
|
| 47 |
+
.bubble-assistant {
|
| 48 |
+
background: var(--glass);
|
| 49 |
+
border: 1px solid var(--glass-border);
|
| 50 |
+
color: var(--text-1);
|
| 51 |
+
border-bottom-left-radius: 4px;
|
| 52 |
+
backdrop-filter: blur(8px);
|
| 53 |
+
-webkit-backdrop-filter: blur(8px);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* tool label */
|
| 57 |
+
.tool-label {
|
| 58 |
+
display: inline-flex;
|
| 59 |
+
align-items: center;
|
| 60 |
+
gap: 0.3rem;
|
| 61 |
+
font-size: 0.72rem;
|
| 62 |
+
font-weight: 500;
|
| 63 |
+
color: var(--accent-2);
|
| 64 |
+
background: rgba(56, 189, 248, 0.08);
|
| 65 |
+
border: 1px solid rgba(56, 189, 248, 0.12);
|
| 66 |
+
border-radius: 100px;
|
| 67 |
+
padding: 0.2rem 0.6rem;
|
| 68 |
+
margin-bottom: 0.45rem;
|
| 69 |
+
letter-spacing: 0.02em;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/* status text */
|
| 73 |
+
.status-text {
|
| 74 |
+
font-size: 0.8rem;
|
| 75 |
+
color: var(--text-2);
|
| 76 |
+
margin-bottom: 0.35rem;
|
| 77 |
+
font-style: italic;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* markdown prose */
|
| 81 |
+
.bubble p {
|
| 82 |
+
margin: 0.3em 0;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.bubble pre {
|
| 86 |
+
background: var(--code-bg);
|
| 87 |
+
border: 1px solid var(--glass-border);
|
| 88 |
+
border-radius: var(--radius-sm);
|
| 89 |
+
padding: 0.8rem;
|
| 90 |
+
overflow-x: auto;
|
| 91 |
+
font-size: 0.82rem;
|
| 92 |
+
margin: 0.55rem 0;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.bubble code {
|
| 96 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 97 |
+
font-size: 0.82rem;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.bubble :not(pre)>code {
|
| 101 |
+
background: rgba(109, 92, 255, 0.15);
|
| 102 |
+
padding: 0.12em 0.35em;
|
| 103 |
+
border-radius: 4px;
|
| 104 |
+
font-size: 0.82rem;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.bubble table {
|
| 108 |
+
border-collapse: collapse;
|
| 109 |
+
margin: 0.5rem 0;
|
| 110 |
+
width: 100%;
|
| 111 |
+
font-size: 0.82rem;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.bubble th,
|
| 115 |
+
.bubble td {
|
| 116 |
+
border: 1px solid var(--glass-border);
|
| 117 |
+
padding: 0.35rem 0.55rem;
|
| 118 |
+
text-align: left;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.bubble th {
|
| 122 |
+
background: var(--subtle-bg);
|
| 123 |
+
font-weight: 600;
|
| 124 |
+
color: var(--text-2);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.bubble ul,
|
| 128 |
+
.bubble ol {
|
| 129 |
+
padding-left: 1.3em;
|
| 130 |
+
margin: 0.3em 0;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.bubble li {
|
| 134 |
+
margin: 0.15em 0;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* ββ Plot / Video figure ββ */
|
| 138 |
+
.plot-figure {
|
| 139 |
+
margin: 0.65rem 0;
|
| 140 |
+
max-width: 560px;
|
| 141 |
+
border-radius: var(--radius-sm);
|
| 142 |
+
overflow: hidden;
|
| 143 |
+
border: 1px solid var(--glass-border);
|
| 144 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, var(--shadow-strength));
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.plot-img {
|
| 148 |
+
width: 100%;
|
| 149 |
+
display: block;
|
| 150 |
+
cursor: zoom-in;
|
| 151 |
+
transition: filter 0.2s ease, transform 0.2s ease;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.plot-img:hover {
|
| 155 |
+
filter: brightness(1.08);
|
| 156 |
+
transform: scale(1.01);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.plot-actions {
|
| 160 |
+
display: flex;
|
| 161 |
+
gap: 0.35rem;
|
| 162 |
+
padding: 0.45rem 0.6rem;
|
| 163 |
+
background: var(--plot-actions-bg);
|
| 164 |
+
border-top: 1px solid var(--glass-border);
|
| 165 |
+
flex-wrap: wrap;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.plot-action-btn {
|
| 169 |
+
display: inline-flex;
|
| 170 |
+
align-items: center;
|
| 171 |
+
gap: 0.25rem;
|
| 172 |
+
background: var(--glass);
|
| 173 |
+
border: 1px solid var(--glass-border);
|
| 174 |
+
border-radius: var(--radius-sm);
|
| 175 |
+
color: var(--text-2);
|
| 176 |
+
padding: 0.25rem 0.55rem;
|
| 177 |
+
font-size: 0.72rem;
|
| 178 |
+
font-family: inherit;
|
| 179 |
+
cursor: pointer;
|
| 180 |
+
transition: all 0.2s;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.plot-action-btn:hover {
|
| 184 |
+
background: rgba(109, 92, 255, 0.1);
|
| 185 |
+
border-color: rgba(109, 92, 255, 0.25);
|
| 186 |
+
color: var(--text-1);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.plot-action-btn.arraylake-btn {
|
| 190 |
+
color: var(--warning);
|
| 191 |
+
border-color: rgba(251, 191, 36, 0.2);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.plot-action-btn.arraylake-btn:hover {
|
| 195 |
+
background: rgba(251, 191, 36, 0.1);
|
| 196 |
+
border-color: rgba(251, 191, 36, 0.35);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/* ββ Code block under plot ββ */
|
| 200 |
+
.plot-code-block {
|
| 201 |
+
position: relative;
|
| 202 |
+
background: var(--code-block-bg);
|
| 203 |
+
border-top: 1px solid var(--glass-border);
|
| 204 |
+
padding: 0.7rem;
|
| 205 |
+
max-height: 300px;
|
| 206 |
+
overflow-y: auto;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.plot-code-block pre {
|
| 210 |
+
margin: 0;
|
| 211 |
+
background: none !important;
|
| 212 |
+
border: none !important;
|
| 213 |
+
padding: 0 !important;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.plot-code-block code {
|
| 217 |
+
font-family: 'JetBrains Mono', monospace;
|
| 218 |
+
font-size: 0.78rem;
|
| 219 |
+
color: var(--text-2);
|
| 220 |
+
white-space: pre-wrap;
|
| 221 |
+
word-break: break-all;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.copy-btn {
|
| 225 |
+
position: absolute;
|
| 226 |
+
top: 0.4rem;
|
| 227 |
+
right: 0.4rem;
|
| 228 |
+
background: var(--glass);
|
| 229 |
+
border: 1px solid var(--glass-border);
|
| 230 |
+
color: var(--text-3);
|
| 231 |
+
font-size: 0.68rem;
|
| 232 |
+
padding: 0.2rem 0.45rem;
|
| 233 |
+
border-radius: 4px;
|
| 234 |
+
cursor: pointer;
|
| 235 |
+
transition: all 0.2s;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.copy-btn:hover {
|
| 239 |
+
color: var(--text-1);
|
| 240 |
+
background: var(--hover-bg);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/* ββ Arraylake section ββ */
|
| 244 |
+
.arraylake-section {
|
| 245 |
+
margin-top: 0.5rem;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/* ββ Image modal ββ */
|
| 249 |
+
.image-modal-overlay {
|
| 250 |
+
position: fixed;
|
| 251 |
+
inset: 0;
|
| 252 |
+
background: var(--overlay-bg);
|
| 253 |
+
display: flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
justify-content: center;
|
| 256 |
+
z-index: 1000;
|
| 257 |
+
animation: fadeIn 0.2s ease;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
@keyframes fadeIn {
|
| 261 |
+
from {
|
| 262 |
+
opacity: 0;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
to {
|
| 266 |
+
opacity: 1;
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.image-modal-box {
|
| 271 |
+
position: relative;
|
| 272 |
+
max-width: 90vw;
|
| 273 |
+
max-height: 90vh;
|
| 274 |
+
display: flex;
|
| 275 |
+
flex-direction: column;
|
| 276 |
+
align-items: center;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.image-modal-box img {
|
| 280 |
+
max-width: 100%;
|
| 281 |
+
max-height: calc(90vh - 60px);
|
| 282 |
+
border-radius: var(--radius-sm);
|
| 283 |
+
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.image-modal-actions {
|
| 287 |
+
display: flex;
|
| 288 |
+
gap: 0.5rem;
|
| 289 |
+
margin-top: 0.75rem;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.modal-btn {
|
| 293 |
+
display: inline-flex;
|
| 294 |
+
align-items: center;
|
| 295 |
+
gap: 0.3rem;
|
| 296 |
+
padding: 0.45rem 0.85rem;
|
| 297 |
+
border: 1px solid var(--glass-border);
|
| 298 |
+
border-radius: var(--radius-sm);
|
| 299 |
+
cursor: pointer;
|
| 300 |
+
font-size: 0.8rem;
|
| 301 |
+
font-family: inherit;
|
| 302 |
+
background: rgba(109, 92, 255, 0.15);
|
| 303 |
+
color: #fff;
|
| 304 |
+
transition: all 0.2s;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.modal-btn:hover {
|
| 308 |
+
background: rgba(109, 92, 255, 0.3);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.modal-btn.modal-close {
|
| 312 |
+
background: var(--modal-close-bg);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.modal-btn.modal-close:hover {
|
| 316 |
+
background: var(--modal-close-hover);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/* streaming cursor */
|
| 320 |
+
.cursor-blink {
|
| 321 |
+
animation: cblink 0.8s steps(2) infinite;
|
| 322 |
+
color: var(--accent);
|
| 323 |
+
font-weight: bold;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
@keyframes cblink {
|
| 327 |
+
0% {
|
| 328 |
+
opacity: 1
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
50% {
|
| 332 |
+
opacity: 0
|
| 333 |
+
}
|
| 334 |
+
}
|
frontend/src/components/MessageBubble.tsx
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
import ReactMarkdown from 'react-markdown';
|
| 3 |
+
import remarkGfm from 'remark-gfm';
|
| 4 |
+
import rehypeHighlight from 'rehype-highlight';
|
| 5 |
+
import { Maximize2, Download, Code, X } from 'lucide-react';
|
| 6 |
+
import './MessageBubble.css';
|
| 7 |
+
|
| 8 |
+
export type MessageRole = 'user' | 'assistant' | 'system';
|
| 9 |
+
|
| 10 |
+
export interface MediaItem {
|
| 11 |
+
type: 'plot' | 'video';
|
| 12 |
+
base64: string;
|
| 13 |
+
path?: string;
|
| 14 |
+
code?: string;
|
| 15 |
+
mimetype?: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export interface ChatMessage {
|
| 19 |
+
id: string;
|
| 20 |
+
role: MessageRole;
|
| 21 |
+
content: string;
|
| 22 |
+
plots?: string[]; // base64 PNG strings (legacy)
|
| 23 |
+
media?: MediaItem[]; // full media items (plot + video)
|
| 24 |
+
toolLabel?: string; // e.g. "Fetching ERA5..."
|
| 25 |
+
statusText?: string; // e.g. "π Analyzing..."
|
| 26 |
+
arraylakeSnippets?: string[];
|
| 27 |
+
isStreaming?: boolean;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* ββ Image Modal ββ */
|
| 31 |
+
function ImageModal({ src, onClose }: { src: string; onClose: () => void }) {
|
| 32 |
+
return (
|
| 33 |
+
<div className="image-modal-overlay" onClick={onClose}>
|
| 34 |
+
<div className="image-modal-box" onClick={e => e.stopPropagation()}>
|
| 35 |
+
<img src={src} alt="Enlarged plot" />
|
| 36 |
+
<div className="image-modal-actions">
|
| 37 |
+
<button className="modal-btn" onClick={() => {
|
| 38 |
+
const a = document.createElement('a');
|
| 39 |
+
a.href = src;
|
| 40 |
+
a.download = 'eurus_plot.png';
|
| 41 |
+
a.click();
|
| 42 |
+
}}>
|
| 43 |
+
<Download size={14} /> Download
|
| 44 |
+
</button>
|
| 45 |
+
<button className="modal-btn modal-close" onClick={onClose}>
|
| 46 |
+
<X size={14} /> Close
|
| 47 |
+
</button>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* ββ Plot Figure ββ */
|
| 55 |
+
function PlotFigure({ item, onEnlarge }: { item: MediaItem; onEnlarge: (src: string) => void }) {
|
| 56 |
+
const [showCode, setShowCode] = useState(false);
|
| 57 |
+
const src = item.base64.startsWith('data:') ? item.base64 : `data:image/png;base64,${item.base64}`;
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<figure className="plot-figure">
|
| 61 |
+
<img
|
| 62 |
+
src={src}
|
| 63 |
+
alt="Generated plot"
|
| 64 |
+
className="plot-img"
|
| 65 |
+
onClick={() => onEnlarge(src)}
|
| 66 |
+
style={{ cursor: 'pointer' }}
|
| 67 |
+
/>
|
| 68 |
+
<div className="plot-actions">
|
| 69 |
+
<button className="plot-action-btn" onClick={() => onEnlarge(src)} title="Enlarge">
|
| 70 |
+
<Maximize2 size={13} /> Enlarge
|
| 71 |
+
</button>
|
| 72 |
+
<button className="plot-action-btn" onClick={() => {
|
| 73 |
+
const a = document.createElement('a');
|
| 74 |
+
a.href = src;
|
| 75 |
+
a.download = item.path ? item.path.split('/').pop()! : 'eurus_plot.png';
|
| 76 |
+
a.click();
|
| 77 |
+
}} title="Download">
|
| 78 |
+
<Download size={13} /> Download
|
| 79 |
+
</button>
|
| 80 |
+
{item.code && item.code.trim() && (
|
| 81 |
+
<button className="plot-action-btn" onClick={() => setShowCode(v => !v)} title="Toggle code">
|
| 82 |
+
<Code size={13} /> {showCode ? 'Hide Code' : 'Show Code'}
|
| 83 |
+
</button>
|
| 84 |
+
)}
|
| 85 |
+
</div>
|
| 86 |
+
{showCode && item.code && (
|
| 87 |
+
<div className="plot-code-block">
|
| 88 |
+
<pre><code>{item.code}</code></pre>
|
| 89 |
+
<button className="copy-btn" onClick={() => {
|
| 90 |
+
navigator.clipboard.writeText(item.code!);
|
| 91 |
+
}}>Copy</button>
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
+
</figure>
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* ββ Video Figure ββ */
|
| 99 |
+
function VideoFigure({ item, onEnlarge }: { item: MediaItem; onEnlarge: (src: string) => void }) {
|
| 100 |
+
const isGif = item.mimetype === 'image/gif';
|
| 101 |
+
const src = item.base64.startsWith('data:') ? item.base64 : `data:${item.mimetype || 'video/mp4'};base64,${item.base64}`;
|
| 102 |
+
|
| 103 |
+
const handleDownload = () => {
|
| 104 |
+
const a = document.createElement('a');
|
| 105 |
+
a.href = src;
|
| 106 |
+
const ext = isGif ? 'gif' : item.mimetype?.includes('webm') ? 'webm' : 'mp4';
|
| 107 |
+
a.download = item.path ? item.path.split('/').pop()! : `eurus_animation.${ext}`;
|
| 108 |
+
a.click();
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
if (isGif) {
|
| 112 |
+
return (
|
| 113 |
+
<figure className="plot-figure">
|
| 114 |
+
<img src={src} alt="Animation" className="plot-img" onClick={() => onEnlarge(src)} style={{ cursor: 'pointer' }} />
|
| 115 |
+
<div className="plot-actions">
|
| 116 |
+
<button className="plot-action-btn" onClick={() => onEnlarge(src)}><Maximize2 size={13} /> Enlarge</button>
|
| 117 |
+
<button className="plot-action-btn" onClick={handleDownload}><Download size={13} /> Download</button>
|
| 118 |
+
</div>
|
| 119 |
+
</figure>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
<figure className="plot-figure">
|
| 125 |
+
<video controls autoPlay loop muted playsInline style={{ maxWidth: '100%', borderRadius: '8px' }}>
|
| 126 |
+
<source src={src} type={item.mimetype || 'video/mp4'} />
|
| 127 |
+
</video>
|
| 128 |
+
<div className="plot-actions">
|
| 129 |
+
<button className="plot-action-btn" onClick={handleDownload}><Download size={13} /> Download</button>
|
| 130 |
+
</div>
|
| 131 |
+
</figure>
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* ββ Arraylake Snippet ββ */
|
| 136 |
+
function ArraylakeSnippet({ code }: { code: string }) {
|
| 137 |
+
const [open, setOpen] = useState(false);
|
| 138 |
+
// Strip markdown fences
|
| 139 |
+
const clean = code
|
| 140 |
+
.replace(/^\n?π¦[^\n]*\n/, '')
|
| 141 |
+
.replace(/^```python\n?/, '')
|
| 142 |
+
.replace(/\n?```$/, '')
|
| 143 |
+
.trim();
|
| 144 |
+
|
| 145 |
+
return (
|
| 146 |
+
<div className="arraylake-section">
|
| 147 |
+
<button className="plot-action-btn arraylake-btn" onClick={() => setOpen(v => !v)}>
|
| 148 |
+
π¦ {open ? 'Hide Arraylake' : 'Arraylake Code'}
|
| 149 |
+
</button>
|
| 150 |
+
{open && (
|
| 151 |
+
<div className="plot-code-block">
|
| 152 |
+
<pre><code>{clean}</code></pre>
|
| 153 |
+
<button className="copy-btn" onClick={() => navigator.clipboard.writeText(clean)}>Copy</button>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* ββ Legacy plot ββ */
|
| 161 |
+
function LegacyPlotImage({ base64, onEnlarge }: { base64: string; onEnlarge: (src: string) => void }) {
|
| 162 |
+
const src = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
|
| 163 |
+
return (
|
| 164 |
+
<figure className="plot-figure">
|
| 165 |
+
<img src={src} alt="Generated plot" className="plot-img" onClick={() => onEnlarge(src)} style={{ cursor: 'pointer' }} />
|
| 166 |
+
<div className="plot-actions">
|
| 167 |
+
<button className="plot-action-btn" onClick={() => onEnlarge(src)}><Maximize2 size={13} /> Enlarge</button>
|
| 168 |
+
<button className="plot-action-btn" onClick={() => {
|
| 169 |
+
const a = document.createElement('a'); a.href = src; a.download = 'eurus_plot.png'; a.click();
|
| 170 |
+
}}><Download size={13} /> Download</button>
|
| 171 |
+
</div>
|
| 172 |
+
</figure>
|
| 173 |
+
);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/* ββ Main Bubble ββ */
|
| 177 |
+
export default function MessageBubble({ msg }: { msg: ChatMessage }) {
|
| 178 |
+
const isUser = msg.role === 'user';
|
| 179 |
+
const [modalSrc, setModalSrc] = useState<string | null>(null);
|
| 180 |
+
|
| 181 |
+
const handleEnlarge = useCallback((src: string) => setModalSrc(src), []);
|
| 182 |
+
|
| 183 |
+
return (
|
| 184 |
+
<>
|
| 185 |
+
<div className={`bubble-row ${isUser ? 'user' : 'assistant'}`}>
|
| 186 |
+
<div className={`bubble ${isUser ? 'bubble-user' : 'bubble-assistant'}`}>
|
| 187 |
+
{msg.toolLabel && (
|
| 188 |
+
<div className="tool-label">β {msg.toolLabel}</div>
|
| 189 |
+
)}
|
| 190 |
+
|
| 191 |
+
{msg.statusText && (
|
| 192 |
+
<div className="status-text">{msg.statusText}</div>
|
| 193 |
+
)}
|
| 194 |
+
|
| 195 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
|
| 196 |
+
{msg.content}
|
| 197 |
+
</ReactMarkdown>
|
| 198 |
+
|
| 199 |
+
{/* Legacy plots */}
|
| 200 |
+
{msg.plots?.map((b64, i) => <LegacyPlotImage key={`p-${i}`} base64={b64} onEnlarge={handleEnlarge} />)}
|
| 201 |
+
|
| 202 |
+
{/* Rich media (plots + videos) */}
|
| 203 |
+
{msg.media?.map((item, i) =>
|
| 204 |
+
item.type === 'video'
|
| 205 |
+
? <VideoFigure key={`v-${i}`} item={item} onEnlarge={handleEnlarge} />
|
| 206 |
+
: <PlotFigure key={`m-${i}`} item={item} onEnlarge={handleEnlarge} />
|
| 207 |
+
)}
|
| 208 |
+
|
| 209 |
+
{/* Arraylake snippets */}
|
| 210 |
+
{msg.arraylakeSnippets?.map((s, i) => <ArraylakeSnippet key={`al-${i}`} code={s} />)}
|
| 211 |
+
|
| 212 |
+
{msg.isStreaming && !msg.media?.length && !msg.arraylakeSnippets?.length && <span className="cursor-blink">β</span>}
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{modalSrc && <ImageModal src={modalSrc} onClose={() => setModalSrc(null)} />}
|
| 217 |
+
</>
|
| 218 |
+
);
|
| 219 |
+
}
|
frontend/src/components/ModelSelector.css
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ ModelSelector ββββββββββββββββββββββββββ */
|
| 2 |
+
.model-selector {
|
| 3 |
+
position: relative;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
.model-selector-btn {
|
| 7 |
+
display: flex;
|
| 8 |
+
align-items: center;
|
| 9 |
+
gap: 4px;
|
| 10 |
+
padding: 4px 10px;
|
| 11 |
+
border-radius: var(--radius);
|
| 12 |
+
border: 1px solid var(--glass-border);
|
| 13 |
+
background: var(--glass);
|
| 14 |
+
color: var(--text-2);
|
| 15 |
+
cursor: pointer;
|
| 16 |
+
font-size: 0.78rem;
|
| 17 |
+
font-weight: 500;
|
| 18 |
+
transition: all 0.2s ease;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.model-selector-btn:hover {
|
| 22 |
+
background: var(--hover-bg);
|
| 23 |
+
color: var(--text-1);
|
| 24 |
+
border-color: var(--accent);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.model-label {
|
| 28 |
+
white-space: nowrap;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.chevron {
|
| 32 |
+
transition: transform 0.2s ease;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.chevron.open {
|
| 36 |
+
transform: rotate(180deg);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.model-dropdown {
|
| 40 |
+
position: absolute;
|
| 41 |
+
top: calc(100% + 6px);
|
| 42 |
+
right: 0;
|
| 43 |
+
min-width: 140px;
|
| 44 |
+
background: var(--bg-deep);
|
| 45 |
+
border: 1px solid var(--glass-border);
|
| 46 |
+
border-radius: var(--radius);
|
| 47 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
| 48 |
+
z-index: 100;
|
| 49 |
+
padding: 4px;
|
| 50 |
+
animation: dropIn 0.15s ease;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
@keyframes dropIn {
|
| 54 |
+
from {
|
| 55 |
+
opacity: 0;
|
| 56 |
+
transform: translateY(-4px);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
to {
|
| 60 |
+
opacity: 1;
|
| 61 |
+
transform: translateY(0);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.model-option {
|
| 66 |
+
display: flex;
|
| 67 |
+
align-items: center;
|
| 68 |
+
justify-content: space-between;
|
| 69 |
+
width: 100%;
|
| 70 |
+
padding: 7px 10px;
|
| 71 |
+
border: none;
|
| 72 |
+
border-radius: 6px;
|
| 73 |
+
background: none;
|
| 74 |
+
color: var(--text-2);
|
| 75 |
+
cursor: pointer;
|
| 76 |
+
font-size: 0.8rem;
|
| 77 |
+
transition: all 0.15s ease;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.model-option:hover {
|
| 81 |
+
background: var(--hover-bg);
|
| 82 |
+
color: var(--text-1);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.model-option.active {
|
| 86 |
+
color: var(--accent);
|
| 87 |
+
font-weight: 600;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.model-option .check {
|
| 91 |
+
color: var(--accent);
|
| 92 |
+
font-size: 0.9rem;
|
| 93 |
+
}
|
frontend/src/components/ModelSelector.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { ChevronDown } from 'lucide-react';
|
| 3 |
+
import './ModelSelector.css';
|
| 4 |
+
|
| 5 |
+
interface ModelOption {
|
| 6 |
+
id: string;
|
| 7 |
+
label: string;
|
| 8 |
+
provider: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface ModelSelectorProps {
|
| 12 |
+
send: (payload: Record<string, unknown>) => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function ModelSelector({ send }: ModelSelectorProps) {
|
| 16 |
+
const [current, setCurrent] = useState('gpt-5.2');
|
| 17 |
+
const [open, setOpen] = useState(false);
|
| 18 |
+
const ref = useRef<HTMLDivElement>(null);
|
| 19 |
+
|
| 20 |
+
const models: ModelOption[] = [
|
| 21 |
+
{ id: 'gpt-5.2', label: 'GPT-5.2', provider: 'openai' },
|
| 22 |
+
{ id: 'gpt-4.1', label: 'GPT-4.1', provider: 'openai' },
|
| 23 |
+
{ id: 'o3', label: 'o3', provider: 'openai' },
|
| 24 |
+
{ id: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro', provider: 'google' },
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
// Close on click outside
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
if (!open) return;
|
| 30 |
+
const handleClick = (e: MouseEvent) => {
|
| 31 |
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
| 32 |
+
setOpen(false);
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
document.addEventListener('mousedown', handleClick);
|
| 36 |
+
return () => document.removeEventListener('mousedown', handleClick);
|
| 37 |
+
}, [open]);
|
| 38 |
+
|
| 39 |
+
const handleSelect = (modelId: string) => {
|
| 40 |
+
setCurrent(modelId);
|
| 41 |
+
setOpen(false);
|
| 42 |
+
send({ type: 'set_provider', model: modelId });
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const currentLabel = models.find(m => m.id === current)?.label ?? current;
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className="model-selector" ref={ref}>
|
| 49 |
+
<button
|
| 50 |
+
className="model-selector-btn"
|
| 51 |
+
onClick={() => setOpen(v => !v)}
|
| 52 |
+
title="Switch AI model"
|
| 53 |
+
>
|
| 54 |
+
<span className="model-label">{currentLabel}</span>
|
| 55 |
+
<ChevronDown size={13} className={`chevron ${open ? 'open' : ''}`} />
|
| 56 |
+
</button>
|
| 57 |
+
{open && (
|
| 58 |
+
<div className="model-dropdown">
|
| 59 |
+
{models.map(m => (
|
| 60 |
+
<button
|
| 61 |
+
key={m.id}
|
| 62 |
+
className={`model-option ${m.id === current ? 'active' : ''}`}
|
| 63 |
+
onClick={() => handleSelect(m.id)}
|
| 64 |
+
>
|
| 65 |
+
<span>{m.label}</span>
|
| 66 |
+
{m.id === current && <span className="check">β</span>}
|
| 67 |
+
</button>
|
| 68 |
+
))}
|
| 69 |
+
</div>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
frontend/src/components/ThemeToggle.css
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ββ ThemeToggle ββββββββββββββββββββββββββββ */
|
| 2 |
+
.theme-toggle-btn {
|
| 3 |
+
background: none;
|
| 4 |
+
border: none;
|
| 5 |
+
padding: 0;
|
| 6 |
+
cursor: pointer;
|
| 7 |
+
display: flex;
|
| 8 |
+
align-items: center;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.toggle-track {
|
| 12 |
+
width: 44px;
|
| 13 |
+
height: 24px;
|
| 14 |
+
border-radius: 100px;
|
| 15 |
+
position: relative;
|
| 16 |
+
transition: background 0.35s ease, border-color 0.35s ease;
|
| 17 |
+
border: 1px solid var(--glass-border);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.toggle-track.dark {
|
| 21 |
+
background: rgba(109, 92, 255, 0.15);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.toggle-track.light {
|
| 25 |
+
background: rgba(251, 191, 36, 0.15);
|
| 26 |
+
border-color: rgba(251, 191, 36, 0.25);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.toggle-thumb {
|
| 30 |
+
position: absolute;
|
| 31 |
+
top: 2px;
|
| 32 |
+
width: 18px;
|
| 33 |
+
height: 18px;
|
| 34 |
+
border-radius: 50%;
|
| 35 |
+
display: flex;
|
| 36 |
+
align-items: center;
|
| 37 |
+
justify-content: center;
|
| 38 |
+
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.35s ease;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.toggle-track.dark .toggle-thumb {
|
| 42 |
+
transform: translateX(2px);
|
| 43 |
+
background: var(--accent);
|
| 44 |
+
color: #fff;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.toggle-track.light .toggle-thumb {
|
| 48 |
+
transform: translateX(22px);
|
| 49 |
+
background: #f59e0b;
|
| 50 |
+
color: #fff;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.theme-toggle-btn:hover .toggle-track {
|
| 54 |
+
box-shadow: 0 0 12px var(--accent-glow);
|
| 55 |
+
}
|
frontend/src/components/ThemeToggle.tsx
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { Sun, Moon } from 'lucide-react';
|
| 3 |
+
import './ThemeToggle.css';
|
| 4 |
+
|
| 5 |
+
export default function ThemeToggle() {
|
| 6 |
+
const [light, setLight] = useState(() => {
|
| 7 |
+
return localStorage.getItem('eurus-theme') === 'light';
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const root = document.documentElement;
|
| 12 |
+
if (light) {
|
| 13 |
+
root.classList.add('light-theme');
|
| 14 |
+
} else {
|
| 15 |
+
root.classList.remove('light-theme');
|
| 16 |
+
}
|
| 17 |
+
localStorage.setItem('eurus-theme', light ? 'light' : 'dark');
|
| 18 |
+
}, [light]);
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<button
|
| 22 |
+
className="theme-toggle-btn"
|
| 23 |
+
onClick={() => setLight(prev => !prev)}
|
| 24 |
+
title={light ? 'Switch to dark mode' : 'Switch to light mode'}
|
| 25 |
+
aria-label="Toggle theme"
|
| 26 |
+
>
|
| 27 |
+
<div className={`toggle-track ${light ? 'light' : 'dark'}`}>
|
| 28 |
+
<div className="toggle-thumb">
|
| 29 |
+
{light ? <Sun size={12} /> : <Moon size={12} />}
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</button>
|
| 33 |
+
);
|
| 34 |
+
}
|
frontend/src/hooks/useWebSocket.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
/* βββββββββββββββββ Types βββββββββββββββββ */
|
| 4 |
+
export type WSEventType =
|
| 5 |
+
| 'thinking'
|
| 6 |
+
| 'status'
|
| 7 |
+
| 'tool_start'
|
| 8 |
+
| 'stream'
|
| 9 |
+
| 'chunk' // old backend sends 'chunk', we normalize to 'stream'
|
| 10 |
+
| 'plot'
|
| 11 |
+
| 'video'
|
| 12 |
+
| 'complete'
|
| 13 |
+
| 'error'
|
| 14 |
+
| 'clear'
|
| 15 |
+
| 'keys_configured'
|
| 16 |
+
| 'request_keys'
|
| 17 |
+
| 'arraylake_snippet';
|
| 18 |
+
|
| 19 |
+
export interface WSEvent {
|
| 20 |
+
type: WSEventType;
|
| 21 |
+
content?: string;
|
| 22 |
+
ready?: boolean;
|
| 23 |
+
reason?: string;
|
| 24 |
+
data?: string; // base64 payload for plot/video
|
| 25 |
+
path?: string; // file path for plot/video
|
| 26 |
+
code?: string; // code that generated the plot
|
| 27 |
+
mimetype?: string; // video mimetype
|
| 28 |
+
[key: string]: unknown;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export type WSStatus = 'connecting' | 'connected' | 'disconnected';
|
| 32 |
+
|
| 33 |
+
interface UseWebSocketReturn {
|
| 34 |
+
status: WSStatus;
|
| 35 |
+
send: (payload: Record<string, unknown>) => void;
|
| 36 |
+
sendMessage: (message: string) => void;
|
| 37 |
+
configureKeys: (keys: { openai_api_key?: string; arraylake_api_key?: string; hf_token?: string }) => void;
|
| 38 |
+
lastEvent: WSEvent | null;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* βββββββββββββββ Hook βββββββββββββββ */
|
| 42 |
+
export function useWebSocket(onEvent?: (event: WSEvent) => void): UseWebSocketReturn {
|
| 43 |
+
const wsRef = useRef<WebSocket | null>(null);
|
| 44 |
+
const [status, setStatus] = useState<WSStatus>('disconnected');
|
| 45 |
+
const [lastEvent, setLastEvent] = useState<WSEvent | null>(null);
|
| 46 |
+
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
| 47 |
+
const onEventRef = useRef(onEvent);
|
| 48 |
+
const connectingRef = useRef(false); // guard against double-connect
|
| 49 |
+
onEventRef.current = onEvent;
|
| 50 |
+
|
| 51 |
+
const connect = useCallback(() => {
|
| 52 |
+
// Guard: don't open a second WS if one is OPEN or CONNECTING
|
| 53 |
+
if (wsRef.current) {
|
| 54 |
+
const rs = wsRef.current.readyState;
|
| 55 |
+
if (rs === WebSocket.OPEN || rs === WebSocket.CONNECTING) return;
|
| 56 |
+
}
|
| 57 |
+
if (connectingRef.current) return;
|
| 58 |
+
connectingRef.current = true;
|
| 59 |
+
|
| 60 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 61 |
+
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/chat`);
|
| 62 |
+
wsRef.current = ws;
|
| 63 |
+
setStatus('connecting');
|
| 64 |
+
|
| 65 |
+
ws.onopen = () => {
|
| 66 |
+
connectingRef.current = false;
|
| 67 |
+
setStatus('connected');
|
| 68 |
+
// Auto-resend keys from sessionStorage on reconnect
|
| 69 |
+
const saved = sessionStorage.getItem('eurus-keys');
|
| 70 |
+
if (saved) {
|
| 71 |
+
try {
|
| 72 |
+
const keys = JSON.parse(saved);
|
| 73 |
+
if (keys.openai_api_key) {
|
| 74 |
+
ws.send(JSON.stringify({ type: 'configure_keys', ...keys }));
|
| 75 |
+
}
|
| 76 |
+
} catch { /* ignore */ }
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
ws.onmessage = (e) => {
|
| 81 |
+
try {
|
| 82 |
+
const event: WSEvent = JSON.parse(e.data);
|
| 83 |
+
// Normalize 'chunk' β 'stream' for unified handling
|
| 84 |
+
if (event.type === 'chunk') {
|
| 85 |
+
event.type = 'stream';
|
| 86 |
+
}
|
| 87 |
+
setLastEvent(event);
|
| 88 |
+
onEventRef.current?.(event);
|
| 89 |
+
} catch { /* ignore non-json */ }
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
ws.onclose = () => {
|
| 93 |
+
connectingRef.current = false;
|
| 94 |
+
setStatus('disconnected');
|
| 95 |
+
wsRef.current = null;
|
| 96 |
+
// auto-reconnect after 2 s
|
| 97 |
+
reconnectTimer.current = setTimeout(connect, 2000);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
ws.onerror = () => {
|
| 101 |
+
connectingRef.current = false;
|
| 102 |
+
ws.close();
|
| 103 |
+
};
|
| 104 |
+
}, []);
|
| 105 |
+
|
| 106 |
+
useEffect(() => {
|
| 107 |
+
connect();
|
| 108 |
+
return () => {
|
| 109 |
+
clearTimeout(reconnectTimer.current);
|
| 110 |
+
connectingRef.current = false;
|
| 111 |
+
if (wsRef.current) {
|
| 112 |
+
wsRef.current.onclose = null; // prevent reconnect on cleanup close
|
| 113 |
+
wsRef.current.close();
|
| 114 |
+
wsRef.current = null;
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
}, [connect]);
|
| 118 |
+
|
| 119 |
+
const send = useCallback((payload: Record<string, unknown>) => {
|
| 120 |
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 121 |
+
wsRef.current.send(JSON.stringify(payload));
|
| 122 |
+
}
|
| 123 |
+
}, []);
|
| 124 |
+
|
| 125 |
+
const sendMessage = useCallback((message: string) => send({ message }), [send]);
|
| 126 |
+
|
| 127 |
+
const configureKeys = useCallback(
|
| 128 |
+
(keys: { openai_api_key?: string; arraylake_api_key?: string; hf_token?: string }) => {
|
| 129 |
+
// Save to sessionStorage
|
| 130 |
+
sessionStorage.setItem('eurus-keys', JSON.stringify(keys));
|
| 131 |
+
send({ type: 'configure_keys', ...keys });
|
| 132 |
+
},
|
| 133 |
+
[send],
|
| 134 |
+
);
|
| 135 |
+
|
| 136 |
+
return { status, send, sendMessage, configureKeys, lastEvent };
|
| 137 |
+
}
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 2 |
+
|
| 3 |
+
*,
|
| 4 |
+
*::before,
|
| 5 |
+
*::after {
|
| 6 |
+
box-sizing: border-box;
|
| 7 |
+
margin: 0;
|
| 8 |
+
padding: 0;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/* ββ Dark theme (default) ββ */
|
| 12 |
+
:root {
|
| 13 |
+
--bg-deep: #06080f;
|
| 14 |
+
--bg-surface: #0c1018;
|
| 15 |
+
--bg-card: rgba(14, 18, 30, 0.7);
|
| 16 |
+
--glass: rgba(255, 255, 255, 0.03);
|
| 17 |
+
--glass-border: rgba(255, 255, 255, 0.06);
|
| 18 |
+
--accent: #6d5cff;
|
| 19 |
+
--accent-2: #38bdf8;
|
| 20 |
+
--accent-glow: rgba(109, 92, 255, 0.25);
|
| 21 |
+
--text-1: #f0f2f7;
|
| 22 |
+
--text-2: #a0a8c0;
|
| 23 |
+
--text-3: #5a6280;
|
| 24 |
+
--danger: #f87171;
|
| 25 |
+
--success: #34d399;
|
| 26 |
+
--warning: #fbbf24;
|
| 27 |
+
--radius: 0.875rem;
|
| 28 |
+
--radius-sm: 0.5rem;
|
| 29 |
+
|
| 30 |
+
/* theme-aware helpers */
|
| 31 |
+
--hover-bg: rgba(255, 255, 255, 0.06);
|
| 32 |
+
--hover-border: rgba(255, 255, 255, 0.1);
|
| 33 |
+
--input-bg: rgba(255, 255, 255, 0.03);
|
| 34 |
+
--input-focus-bg: rgba(255, 255, 255, 0.04);
|
| 35 |
+
--code-bg: rgba(0, 0, 0, 0.4);
|
| 36 |
+
--overlay-bg: rgba(0, 0, 0, 0.85);
|
| 37 |
+
--subtle-border: rgba(255, 255, 255, 0.025);
|
| 38 |
+
--subtle-bg: rgba(255, 255, 255, 0.04);
|
| 39 |
+
--plot-actions-bg: rgba(0, 0, 0, 0.3);
|
| 40 |
+
--code-block-bg: rgba(0, 0, 0, 0.5);
|
| 41 |
+
--modal-close-bg: rgba(255, 255, 255, 0.08);
|
| 42 |
+
--modal-close-hover: rgba(255, 255, 255, 0.15);
|
| 43 |
+
--shadow-strength: 0.3;
|
| 44 |
+
--scrollbar-thumb: rgba(255, 255, 255, 0.08);
|
| 45 |
+
--scrollbar-hover: rgba(255, 255, 255, 0.14);
|
| 46 |
+
--glow-1-opacity: 0.08;
|
| 47 |
+
--glow-2-opacity: 0.06;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ββ Light theme ββ */
|
| 51 |
+
:root.light-theme {
|
| 52 |
+
--bg-deep: #f5f6fa;
|
| 53 |
+
--bg-surface: #ffffff;
|
| 54 |
+
--bg-card: rgba(255, 255, 255, 0.85);
|
| 55 |
+
--glass: rgba(0, 0, 0, 0.02);
|
| 56 |
+
--glass-border: rgba(0, 0, 0, 0.08);
|
| 57 |
+
--accent: #6d5cff;
|
| 58 |
+
--accent-2: #0ea5e9;
|
| 59 |
+
--accent-glow: rgba(109, 92, 255, 0.18);
|
| 60 |
+
--text-1: #1a1d2e;
|
| 61 |
+
--text-2: #5b6178;
|
| 62 |
+
--text-3: #9499b0;
|
| 63 |
+
--danger: #ef4444;
|
| 64 |
+
--success: #10b981;
|
| 65 |
+
--warning: #f59e0b;
|
| 66 |
+
|
| 67 |
+
--hover-bg: rgba(0, 0, 0, 0.04);
|
| 68 |
+
--hover-border: rgba(0, 0, 0, 0.12);
|
| 69 |
+
--input-bg: rgba(0, 0, 0, 0.02);
|
| 70 |
+
--input-focus-bg: rgba(0, 0, 0, 0.03);
|
| 71 |
+
--code-bg: rgba(0, 0, 0, 0.05);
|
| 72 |
+
--overlay-bg: rgba(0, 0, 0, 0.6);
|
| 73 |
+
--subtle-border: rgba(0, 0, 0, 0.04);
|
| 74 |
+
--subtle-bg: rgba(0, 0, 0, 0.03);
|
| 75 |
+
--plot-actions-bg: rgba(0, 0, 0, 0.04);
|
| 76 |
+
--code-block-bg: rgba(0, 0, 0, 0.04);
|
| 77 |
+
--modal-close-bg: rgba(0, 0, 0, 0.08);
|
| 78 |
+
--modal-close-hover: rgba(0, 0, 0, 0.15);
|
| 79 |
+
--shadow-strength: 0.1;
|
| 80 |
+
--scrollbar-thumb: rgba(0, 0, 0, 0.1);
|
| 81 |
+
--scrollbar-hover: rgba(0, 0, 0, 0.18);
|
| 82 |
+
--glow-1-opacity: 0.04;
|
| 83 |
+
--glow-2-opacity: 0.03;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
html,
|
| 87 |
+
body,
|
| 88 |
+
#root {
|
| 89 |
+
height: 100%;
|
| 90 |
+
width: 100%;
|
| 91 |
+
overflow: hidden;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
body {
|
| 95 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 96 |
+
background: var(--bg-deep);
|
| 97 |
+
color: var(--text-1);
|
| 98 |
+
-webkit-font-smoothing: antialiased;
|
| 99 |
+
-moz-osx-font-smoothing: grayscale;
|
| 100 |
+
transition: background 0.35s ease, color 0.35s ease;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* ambient glow */
|
| 104 |
+
body::before {
|
| 105 |
+
content: '';
|
| 106 |
+
position: fixed;
|
| 107 |
+
top: -40%;
|
| 108 |
+
left: -20%;
|
| 109 |
+
width: 70%;
|
| 110 |
+
height: 70%;
|
| 111 |
+
background: radial-gradient(ellipse, rgba(109, 92, 255, var(--glow-1-opacity)) 0%, transparent 70%);
|
| 112 |
+
pointer-events: none;
|
| 113 |
+
z-index: 0;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
body::after {
|
| 117 |
+
content: '';
|
| 118 |
+
position: fixed;
|
| 119 |
+
bottom: -30%;
|
| 120 |
+
right: -15%;
|
| 121 |
+
width: 60%;
|
| 122 |
+
height: 60%;
|
| 123 |
+
background: radial-gradient(ellipse, rgba(56, 189, 248, var(--glow-2-opacity)) 0%, transparent 70%);
|
| 124 |
+
pointer-events: none;
|
| 125 |
+
z-index: 0;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* highlight.js */
|
| 129 |
+
.hljs {
|
| 130 |
+
background: transparent !important;
|
| 131 |
+
padding: 0 !important;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
::selection {
|
| 135 |
+
background: rgba(109, 92, 255, 0.3);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* scrollbar */
|
| 139 |
+
::-webkit-scrollbar {
|
| 140 |
+
width: 5px;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
::-webkit-scrollbar-track {
|
| 144 |
+
background: transparent;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
::-webkit-scrollbar-thumb {
|
| 148 |
+
background: var(--scrollbar-thumb);
|
| 149 |
+
border-radius: 3px;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
::-webkit-scrollbar-thumb:hover {
|
| 153 |
+
background: var(--scrollbar-hover);
|
| 154 |
+
}
|
frontend/src/main.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
import './index.css';
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<App />,
|
| 8 |
+
);
|
frontend/tsconfig.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"module": "ESNext",
|
| 6 |
+
"lib": [
|
| 7 |
+
"ES2022",
|
| 8 |
+
"DOM",
|
| 9 |
+
"DOM.Iterable"
|
| 10 |
+
],
|
| 11 |
+
"types": [
|
| 12 |
+
"vite/client"
|
| 13 |
+
],
|
| 14 |
+
"skipLibCheck": true,
|
| 15 |
+
/* JSX */
|
| 16 |
+
"jsx": "react-jsx",
|
| 17 |
+
/* Bundler mode */
|
| 18 |
+
"moduleResolution": "bundler",
|
| 19 |
+
"allowImportingTsExtensions": true,
|
| 20 |
+
"moduleDetection": "force",
|
| 21 |
+
"noEmit": true,
|
| 22 |
+
/* Linting */
|
| 23 |
+
"strict": true,
|
| 24 |
+
"noUnusedLocals": true,
|
| 25 |
+
"noUnusedParameters": true,
|
| 26 |
+
"noFallthroughCasesInSwitch": true,
|
| 27 |
+
"noUncheckedSideEffectImports": true
|
| 28 |
+
},
|
| 29 |
+
"include": [
|
| 30 |
+
"src"
|
| 31 |
+
]
|
| 32 |
+
}
|
frontend/vite.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
port: 5182,
|
| 8 |
+
proxy: {
|
| 9 |
+
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true },
|
| 10 |
+
'/ws': { target: 'ws://127.0.0.1:8000', ws: true },
|
| 11 |
+
'/plots': { target: 'http://127.0.0.1:8000', changeOrigin: true },
|
| 12 |
+
},
|
| 13 |
+
},
|
| 14 |
+
});
|
web/agent_wrapper.py
CHANGED
|
@@ -39,12 +39,21 @@ class AgentSession:
|
|
| 39 |
Manages a single agent session with streaming support.
|
| 40 |
"""
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def __init__(self, api_keys: Optional[Dict[str, str]] = None):
|
| 43 |
self._agent = None
|
| 44 |
self._repl_tool: Optional[PythonREPLTool] = None
|
| 45 |
self._messages: List[Dict] = []
|
| 46 |
self._initialized = False
|
| 47 |
self._api_keys = api_keys or {}
|
|
|
|
| 48 |
|
| 49 |
# Global singleton keeps the dataset cache (shared across sessions)
|
| 50 |
self._memory = get_memory()
|
|
@@ -135,6 +144,65 @@ class AgentSession:
|
|
| 135 |
"""Check if the agent is ready."""
|
| 136 |
return self._initialized and self._agent is not None
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def reinitialize(self):
|
| 139 |
"""Retry initialization (e.g., after transient failure)."""
|
| 140 |
logger.warning("Attempting agent reinitialization...")
|
|
@@ -183,8 +251,8 @@ class AgentSession:
|
|
| 183 |
await stream_callback("status", "π Analyzing your request...")
|
| 184 |
await asyncio.sleep(0.3)
|
| 185 |
|
| 186 |
-
# Invoke the agent in executor (
|
| 187 |
-
config = {"recursion_limit":
|
| 188 |
|
| 189 |
# Stream status updates while agent is working
|
| 190 |
await stream_callback("status", "π€ Processing with AI...")
|
|
@@ -276,9 +344,23 @@ class AgentSession:
|
|
| 276 |
last_message = self._messages[-1]
|
| 277 |
|
| 278 |
if hasattr(last_message, 'content') and last_message.content:
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
elif isinstance(last_message, dict) and last_message.get('content'):
|
| 281 |
-
response_text = last_message['content']
|
| 282 |
else:
|
| 283 |
response_text = str(last_message)
|
| 284 |
|
|
|
|
| 39 |
Manages a single agent session with streaming support.
|
| 40 |
"""
|
| 41 |
|
| 42 |
+
# Available models for the selector
|
| 43 |
+
AVAILABLE_MODELS = [
|
| 44 |
+
{"id": "gpt-5.2", "label": "GPT-5.2", "provider": "openai"},
|
| 45 |
+
{"id": "gpt-4.1", "label": "GPT-4.1", "provider": "openai"},
|
| 46 |
+
{"id": "o3", "label": "o3", "provider": "openai"},
|
| 47 |
+
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro", "provider": "google"},
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
def __init__(self, api_keys: Optional[Dict[str, str]] = None):
|
| 51 |
self._agent = None
|
| 52 |
self._repl_tool: Optional[PythonREPLTool] = None
|
| 53 |
self._messages: List[Dict] = []
|
| 54 |
self._initialized = False
|
| 55 |
self._api_keys = api_keys or {}
|
| 56 |
+
self._current_model = CONFIG.model_name
|
| 57 |
|
| 58 |
# Global singleton keeps the dataset cache (shared across sessions)
|
| 59 |
self._memory = get_memory()
|
|
|
|
| 144 |
"""Check if the agent is ready."""
|
| 145 |
return self._initialized and self._agent is not None
|
| 146 |
|
| 147 |
+
def get_current_model(self) -> str:
|
| 148 |
+
"""Return the current model name."""
|
| 149 |
+
return self._current_model
|
| 150 |
+
|
| 151 |
+
def set_provider(self, model_id: str):
|
| 152 |
+
"""Switch the LLM model. Reinitializes the agent with the new model."""
|
| 153 |
+
openai_key = self._api_keys.get("openai_api_key") or os.environ.get("OPENAI_API_KEY")
|
| 154 |
+
vertex_key = self._api_keys.get("vertex_api_key") or os.environ.get("vertex_api_key")
|
| 155 |
+
|
| 156 |
+
# Determine provider from model id
|
| 157 |
+
is_gemini = model_id.startswith("gemini")
|
| 158 |
+
|
| 159 |
+
if is_gemini and not vertex_key:
|
| 160 |
+
logger.error("Cannot switch to Gemini: no vertex_api_key in .env")
|
| 161 |
+
return
|
| 162 |
+
if not is_gemini and not openai_key:
|
| 163 |
+
logger.error("Cannot switch model: no OPENAI_API_KEY")
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
logger.info(f"Switching model from {self._current_model} to {model_id}")
|
| 167 |
+
self._current_model = model_id
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
if is_gemini:
|
| 171 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 172 |
+
llm = ChatGoogleGenerativeAI(
|
| 173 |
+
model=model_id,
|
| 174 |
+
temperature=CONFIG.temperature,
|
| 175 |
+
api_key=vertex_key,
|
| 176 |
+
vertexai=True,
|
| 177 |
+
)
|
| 178 |
+
else:
|
| 179 |
+
llm = ChatOpenAI(
|
| 180 |
+
model=model_id,
|
| 181 |
+
temperature=CONFIG.temperature,
|
| 182 |
+
api_key=openai_key,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
tools = get_all_tools(enable_routing=True, enable_guide=True)
|
| 186 |
+
tools = [t for t in tools if t.name != "python_repl"] + [self._repl_tool]
|
| 187 |
+
|
| 188 |
+
datasets = self._memory.list_datasets()
|
| 189 |
+
enhanced_prompt = AGENT_SYSTEM_PROMPT
|
| 190 |
+
if datasets != "No datasets in cache.":
|
| 191 |
+
enhanced_prompt += f"\n\n## CACHED DATASETS\n{datasets}"
|
| 192 |
+
|
| 193 |
+
self._agent = create_agent(
|
| 194 |
+
model=llm,
|
| 195 |
+
tools=tools,
|
| 196 |
+
system_prompt=enhanced_prompt,
|
| 197 |
+
debug=False
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# Keep conversation intact β only reset tool calls
|
| 201 |
+
self._messages = []
|
| 202 |
+
logger.info(f"Model switched to {model_id} successfully")
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.exception(f"Failed to switch model: {e}")
|
| 205 |
+
|
| 206 |
def reinitialize(self):
|
| 207 |
"""Retry initialization (e.g., after transient failure)."""
|
| 208 |
logger.warning("Attempting agent reinitialization...")
|
|
|
|
| 251 |
await stream_callback("status", "π Analyzing your request...")
|
| 252 |
await asyncio.sleep(0.3)
|
| 253 |
|
| 254 |
+
# Invoke the agent in executor (20 iterations max to save tokens)
|
| 255 |
+
config = {"recursion_limit": 20}
|
| 256 |
|
| 257 |
# Stream status updates while agent is working
|
| 258 |
await stream_callback("status", "π€ Processing with AI...")
|
|
|
|
| 344 |
last_message = self._messages[-1]
|
| 345 |
|
| 346 |
if hasattr(last_message, 'content') and last_message.content:
|
| 347 |
+
raw_content = last_message.content
|
| 348 |
+
# Gemini can return content as a list of content blocks
|
| 349 |
+
if isinstance(raw_content, list):
|
| 350 |
+
# Extract text from each block
|
| 351 |
+
parts = []
|
| 352 |
+
for block in raw_content:
|
| 353 |
+
if isinstance(block, str):
|
| 354 |
+
parts.append(block)
|
| 355 |
+
elif isinstance(block, dict) and block.get('text'):
|
| 356 |
+
parts.append(block['text'])
|
| 357 |
+
elif hasattr(block, 'text'):
|
| 358 |
+
parts.append(block.text)
|
| 359 |
+
response_text = "\n".join(parts) if parts else str(raw_content)
|
| 360 |
+
else:
|
| 361 |
+
response_text = str(raw_content)
|
| 362 |
elif isinstance(last_message, dict) and last_message.get('content'):
|
| 363 |
+
response_text = str(last_message['content'])
|
| 364 |
else:
|
| 365 |
response_text = str(last_message)
|
| 366 |
|
web/app.py
CHANGED
|
@@ -15,7 +15,7 @@ load_dotenv() # Load .env EARLY so /api/keys-status sees the keys
|
|
| 15 |
|
| 16 |
from fastapi import FastAPI
|
| 17 |
from fastapi.staticfiles import StaticFiles
|
| 18 |
-
from fastapi.
|
| 19 |
|
| 20 |
# Add parent and src directory to path for eurus package
|
| 21 |
PROJECT_ROOT = Path(__file__).parent.parent
|
|
@@ -69,6 +69,15 @@ def create_app() -> FastAPI:
|
|
| 69 |
lifespan=lifespan,
|
| 70 |
)
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
# Mount static files
|
| 73 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 74 |
|
|
@@ -100,12 +109,12 @@ def main():
|
|
| 100 |
print(f"""
|
| 101 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
β β
|
| 103 |
-
β ββ
|
| 104 |
-
β ββ
|
| 105 |
-
β ββ
|
| 106 |
-
β
|
| 107 |
-
β
|
| 108 |
-
β
|
| 109 |
β β
|
| 110 |
β Eurus Web Interface v1.0 β
|
| 111 |
β βββββββββββββββββββββββββββββββββββββ β
|
|
|
|
| 15 |
|
| 16 |
from fastapi import FastAPI
|
| 17 |
from fastapi.staticfiles import StaticFiles
|
| 18 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
|
| 20 |
# Add parent and src directory to path for eurus package
|
| 21 |
PROJECT_ROOT = Path(__file__).parent.parent
|
|
|
|
| 69 |
lifespan=lifespan,
|
| 70 |
)
|
| 71 |
|
| 72 |
+
# CORS β allow React dev server
|
| 73 |
+
app.add_middleware(
|
| 74 |
+
CORSMiddleware,
|
| 75 |
+
allow_origins=["*"],
|
| 76 |
+
allow_credentials=True,
|
| 77 |
+
allow_methods=["*"],
|
| 78 |
+
allow_headers=["*"],
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
# Mount static files
|
| 82 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 83 |
|
|
|
|
| 109 |
print(f"""
|
| 110 |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
β β
|
| 112 |
+
β βββββββββββ ββββββββββ βββ βββββββββββ β
|
| 113 |
+
β βββββββββββ ββββββββββββββ βββββββββββ β
|
| 114 |
+
β ββββββ βββ ββββββββββββββ βββββββββββ β
|
| 115 |
+
β ββββββ βββ ββββββββββββββ βββββββββββ β
|
| 116 |
+
β ββββββββββββββββββββ ββββββββββββββββββββ β
|
| 117 |
+
β ββββββββ βββββββ βββ βββ βββββββ ββββββββ β
|
| 118 |
β β
|
| 119 |
β Eurus Web Interface v1.0 β
|
| 120 |
β βββββββββββββββββββββββββββββββββββββ β
|
web/routes/pages.py
CHANGED
|
@@ -1,27 +1,26 @@
|
|
| 1 |
"""
|
| 2 |
Page Routes
|
| 3 |
===========
|
| 4 |
-
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
import sys
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
-
from fastapi import APIRouter
|
| 11 |
-
from fastapi.responses import
|
| 12 |
-
from fastapi.templating import Jinja2Templates
|
| 13 |
-
|
| 14 |
-
# Templates directory
|
| 15 |
-
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
| 16 |
-
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 17 |
|
| 18 |
router = APIRouter()
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
@router.get("/", response_class=HTMLResponse)
|
| 22 |
-
async def index(request: Request):
|
| 23 |
-
"""Render the main chat page."""
|
| 24 |
-
return templates.TemplateResponse(
|
| 25 |
-
"index.html",
|
| 26 |
-
{"request": request}
|
| 27 |
-
)
|
|
|
|
| 1 |
"""
|
| 2 |
Page Routes
|
| 3 |
===========
|
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/routes/websocket.py
CHANGED
|
@@ -10,6 +10,7 @@ import logging
|
|
| 10 |
from typing import Optional
|
| 11 |
|
| 12 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter()
|
| 15 |
logger = logging.getLogger(__name__)
|
|
@@ -74,6 +75,31 @@ async def websocket_chat(websocket: WebSocket):
|
|
| 74 |
})
|
| 75 |
continue
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
# Create default session if not yet created (keys from env)
|
| 78 |
if session is None:
|
| 79 |
session = create_session(connection_id)
|
|
@@ -115,10 +141,9 @@ async def websocket_chat(websocket: WebSocket):
|
|
| 115 |
# Process message
|
| 116 |
response = await session.process_message(message, stream_callback)
|
| 117 |
|
| 118 |
-
# Send complete
|
| 119 |
await manager.send_json(websocket, {
|
| 120 |
"type": "complete",
|
| 121 |
-
"content": response
|
| 122 |
})
|
| 123 |
|
| 124 |
except Exception as e:
|
|
|
|
| 10 |
from typing import Optional
|
| 11 |
|
| 12 |
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 13 |
+
from eurus.config import CONFIG
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
logger = logging.getLogger(__name__)
|
|
|
|
| 75 |
})
|
| 76 |
continue
|
| 77 |
|
| 78 |
+
# Handle model switching
|
| 79 |
+
if data.get("type") == "set_provider":
|
| 80 |
+
model_id = data.get("model", "gpt-5.2")
|
| 81 |
+
# Create session if it doesn't exist yet (keys from env)
|
| 82 |
+
if session is None:
|
| 83 |
+
session = create_session(connection_id)
|
| 84 |
+
session.set_provider(model_id)
|
| 85 |
+
await manager.send_json(websocket, {
|
| 86 |
+
"type": "provider_changed",
|
| 87 |
+
"model": model_id,
|
| 88 |
+
})
|
| 89 |
+
continue
|
| 90 |
+
|
| 91 |
+
# Handle get_models request
|
| 92 |
+
if data.get("type") == "get_models":
|
| 93 |
+
from web.agent_wrapper import AgentSession
|
| 94 |
+
models = AgentSession.AVAILABLE_MODELS
|
| 95 |
+
current = session.get_current_model() if session else CONFIG.model_name
|
| 96 |
+
await manager.send_json(websocket, {
|
| 97 |
+
"type": "models_list",
|
| 98 |
+
"models": models,
|
| 99 |
+
"current": current,
|
| 100 |
+
})
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
# Create default session if not yet created (keys from env)
|
| 104 |
if session is None:
|
| 105 |
session = create_session(connection_id)
|
|
|
|
| 141 |
# Process message
|
| 142 |
response = await session.process_message(message, stream_callback)
|
| 143 |
|
| 144 |
+
# Send complete (content already streamed via chunks)
|
| 145 |
await manager.send_json(websocket, {
|
| 146 |
"type": "complete",
|
|
|
|
| 147 |
})
|
| 148 |
|
| 149 |
except Exception as e:
|