dmpantiu commited on
Commit
973d6a7
Β·
verified Β·
1 Parent(s): 6b1a94c

Upload folder using huggingface_hub

Browse files
.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 (~15 tool calls max)
187
- config = {"recursion_limit": 35}
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
- response_text = last_message.content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.templating import Jinja2Templates
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
- HTML page rendering endpoints.
5
  """
6
 
7
- import sys
8
  from pathlib import Path
9
 
10
- from fastapi import APIRouter, Request
11
- from fastapi.responses import HTMLResponse
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: