@nandansai08/personal-ai 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.env.example +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/bin/personal-ai.js +4 -0
  5. package/config/mcp.json +3 -0
  6. package/config/models.yaml +23 -0
  7. package/config/persona.yaml +24 -0
  8. package/config/profiles.yaml +61 -0
  9. package/config/providers.yaml +22 -0
  10. package/dist/bootstrap.js +41 -0
  11. package/dist/core/assistant.js +170 -0
  12. package/dist/core/context.js +35 -0
  13. package/dist/core/events.js +45 -0
  14. package/dist/core/logger.js +67 -0
  15. package/dist/core/model-manager.js +101 -0
  16. package/dist/index.js +98 -0
  17. package/dist/mcp/client.js +3 -0
  18. package/dist/mcp/loader.js +3 -0
  19. package/dist/memory/embeddings.js +53 -0
  20. package/dist/memory/intent.js +113 -0
  21. package/dist/memory/long-term.js +312 -0
  22. package/dist/memory/short-term.js +63 -0
  23. package/dist/memory/types.js +5 -0
  24. package/dist/memory/vector-store.js +57 -0
  25. package/dist/persona/loader.js +56 -0
  26. package/dist/persona/profiles.js +51 -0
  27. package/dist/persona/system-prompt.js +99 -0
  28. package/dist/persona/types.js +22 -0
  29. package/dist/plugins/interface.js +1 -0
  30. package/dist/plugins/loader.js +3 -0
  31. package/dist/providers/anthropic.js +112 -0
  32. package/dist/providers/factory.js +40 -0
  33. package/dist/providers/gemini.js +86 -0
  34. package/dist/providers/groq.js +14 -0
  35. package/dist/providers/interface.js +2 -0
  36. package/dist/providers/lmstudio.js +13 -0
  37. package/dist/providers/metadata.js +96 -0
  38. package/dist/providers/mistral.js +133 -0
  39. package/dist/providers/ollama.js +265 -0
  40. package/dist/providers/openai-compatible.js +110 -0
  41. package/dist/providers/openai.js +14 -0
  42. package/dist/providers/together.js +14 -0
  43. package/dist/providers/utils.js +57 -0
  44. package/dist/tools/calculator.js +44 -0
  45. package/dist/tools/file-reader.js +101 -0
  46. package/dist/tools/memory-tool.js +58 -0
  47. package/dist/tools/notes.js +121 -0
  48. package/dist/tools/parser.js +119 -0
  49. package/dist/tools/registry.js +88 -0
  50. package/dist/tools/tasks.js +134 -0
  51. package/dist/tools/types.js +3 -0
  52. package/dist/tools/web-search.js +108 -0
  53. package/dist/ui/cli-helpers.js +153 -0
  54. package/dist/ui/cli.js +647 -0
  55. package/dist/ui/setup.js +196 -0
  56. package/dist/ui/web/client/index.html +2081 -0
  57. package/dist/ui/web/server.js +310 -0
  58. package/dist/voice/stt.js +3 -0
  59. package/dist/voice/tts.js +3 -0
  60. package/dist/web.js +63 -0
  61. package/package.json +68 -0
@@ -0,0 +1,2081 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PersonalAI</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ /* ── Light main content ─────────────────────────────────── */
12
+ --bg: #f9fafb;
13
+ --surface: #ffffff;
14
+ --surface-2: #f3f4f6;
15
+ --surface-3: #e9ebee;
16
+ --surface-4: #e1e3e7;
17
+ --border: #e5e7eb;
18
+ --border-subtle: #f0f1f3;
19
+ --text: #111827;
20
+ --text-dim: #6b7280;
21
+ --text-muted: #9ca3af;
22
+
23
+ /* ── Blue accent (not purple) ───────────────────────────── */
24
+ --accent: #2563eb;
25
+ --accent-dim: rgba(37,99,235,.08);
26
+ --accent-border: rgba(37,99,235,.3);
27
+ --accent-glow: rgba(37,99,235,.05);
28
+
29
+ /* ── Chat bubbles ───────────────────────────────────────── */
30
+ --bubble-user: #1d4ed8;
31
+ --bubble-ai: #ffffff;
32
+
33
+ /* ── Semantic colours ───────────────────────────────────── */
34
+ --green: #16a34a;
35
+ --green-dim: rgba(22,163,74,.1);
36
+ --yellow: #d97706;
37
+ --yellow-dim: rgba(217,119,6,.1);
38
+ --red: #dc2626;
39
+ --red-dim: rgba(220,38,38,.1);
40
+ --blue: #2563eb;
41
+ --blue-dim: rgba(37,99,235,.1);
42
+ --cyan: #0891b2;
43
+ --cyan-dim: rgba(8,145,178,.1);
44
+ --orange: #ea580c;
45
+ --orange-dim: rgba(234,88,12,.1);
46
+
47
+ /* ── Nav dark theme (used in #nav overrides) ────────────── */
48
+ --nav-bg: #111928;
49
+ --nav-border: #1f2d3d;
50
+ --nav-item-border: rgba(255,255,255,0.055);
51
+ --nav-item-active-bg: #050a14;
52
+ --nav-item-active-brd: rgba(59,130,246,0.55);
53
+ --nav-text: #8b9bb4;
54
+ --nav-text-active: #ffffff;
55
+
56
+ /* ── Dark panel (live events, hw, status bar) ───────────── */
57
+ --dark-bg: #0c1017;
58
+ --dark-surface: #131b27;
59
+ --dark-border: #1e2d3d;
60
+ --dark-text: #c9d6e3;
61
+ --dark-muted: #5a6b7e;
62
+
63
+ --radius: 10px;
64
+ --radius-sm: 7px;
65
+ --nav-w: 220px;
66
+ --topbar-h: 54px;
67
+ }
68
+
69
+ html, body { height: 100%; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, sans-serif; font-size: 13.5px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
70
+
71
+ /* ── App layout ──────────────────────────────────────────────────────────── */
72
+ #app { display: flex; height: 100dvh; overflow: hidden; }
73
+
74
+ /* ── Nav sidebar (dark) ──────────────────────────────────────────────────── */
75
+ #nav {
76
+ width: var(--nav-w);
77
+ background: var(--nav-bg);
78
+ border-right: 1px solid var(--nav-border);
79
+ display: flex; flex-direction: column;
80
+ flex-shrink: 0; overflow: hidden;
81
+ transition: transform 0.22s cubic-bezier(0.4,0,0.2,1);
82
+ }
83
+
84
+ #nav-logo {
85
+ padding: 16px 14px 14px;
86
+ border-bottom: 1px solid var(--nav-border);
87
+ flex-shrink: 0;
88
+ }
89
+ .logo-name {
90
+ font-size: 14px; font-weight: 700; letter-spacing: -0.3px;
91
+ color: #fff; line-height: 1.2;
92
+ }
93
+ .logo-name span { color: #60a5fa; }
94
+ .logo-tag {
95
+ font-size: 10px; color: var(--nav-text); margin-top: 2px;
96
+ text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500;
97
+ }
98
+
99
+ #nav-items { flex: 1; padding: 8px 8px; display: flex; flex-direction: column; gap: 3px; overflow-y: auto; }
100
+
101
+ .nav-item {
102
+ display: flex; align-items: center; gap: 10px;
103
+ padding: 8px 10px; border-radius: var(--radius-sm);
104
+ color: var(--nav-text); cursor: pointer;
105
+ border: 1px solid var(--nav-item-border);
106
+ background: none;
107
+ font-size: 13px; font-family: inherit; text-align: left; width: 100%;
108
+ transition: all 0.13s;
109
+ }
110
+ .nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; border-color: rgba(255,255,255,0.1); }
111
+ .nav-item.active {
112
+ background: var(--nav-item-active-bg);
113
+ border: 1px dashed var(--nav-item-active-brd);
114
+ color: var(--nav-text-active);
115
+ }
116
+ .nav-item.active .nav-icon { color: #60a5fa; }
117
+ .nav-icon { width: 17px; height: 17px; flex-shrink: 0; }
118
+
119
+ #nav-bottom { padding: 12px 8px; border-top: 1px solid var(--nav-border); flex-shrink: 0; }
120
+
121
+ #new-session-btn {
122
+ display: flex; align-items: center; justify-content: center; gap: 7px;
123
+ width: 100%; padding: 9px 12px; border-radius: var(--radius-sm);
124
+ background: #2563eb; color: #fff; border: none;
125
+ font-size: 12.5px; font-weight: 600; font-family: inherit; cursor: pointer;
126
+ transition: background 0.15s;
127
+ margin-bottom: 12px;
128
+ }
129
+ #new-session-btn:hover { background: #1d4ed8; }
130
+ #new-session-btn svg { width: 13px; height: 13px; }
131
+
132
+ .status-row {
133
+ display: flex; align-items: center; gap: 8px;
134
+ padding: 5px 6px; margin-bottom: 3px; border-radius: 6px;
135
+ }
136
+ .status-dot {
137
+ width: 6px; height: 6px; border-radius: 50%;
138
+ background: var(--dark-muted); flex-shrink: 0; transition: background 0.3s;
139
+ }
140
+ .status-dot.ok { background: #22c55e; }
141
+ .status-dot.err { background: #ef4444; }
142
+ .status-dot.loading { background: #f59e0b; animation: pulse-dot 1.5s ease infinite; }
143
+ .status-label { font-size: 11.5px; color: var(--nav-text); flex: 1; }
144
+ .status-value { font-size: 10.5px; color: var(--dark-muted); }
145
+
146
+ @keyframes pulse-dot { 0%,100% { opacity:1; } 50% { opacity:.35; } }
147
+
148
+ /* ── Views container ─────────────────────────────────────────────────────── */
149
+ #views { flex: 1; display: flex; overflow: hidden; min-width: 0; }
150
+ .view { display: none; flex: 1; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
151
+ .view.active { display: flex; }
152
+
153
+ /* ── Shared topbar (light) ───────────────────────────────────────────────── */
154
+ .topbar {
155
+ background: var(--surface); border-bottom: 1px solid var(--border);
156
+ height: var(--topbar-h); padding: 0 20px;
157
+ display: flex; align-items: center; gap: 16px; flex-shrink: 0;
158
+ }
159
+ .topbar-logo {
160
+ font-size: 15px; font-weight: 700; letter-spacing: -0.4px; color: var(--text);
161
+ flex-shrink: 0; white-space: nowrap;
162
+ }
163
+ .topbar-title { font-size: 15px; font-weight: 700; color: var(--text); flex-shrink: 0; }
164
+ .topbar-sub { font-size: 11.5px; color: var(--text-dim); }
165
+
166
+ #hamburger {
167
+ display: none; cursor: pointer; background: none; border: none;
168
+ color: var(--text-dim); padding: 4px; border-radius: 6px;
169
+ transition: all 0.15s; flex-shrink: 0;
170
+ }
171
+ #hamburger:hover { color: var(--text); background: var(--surface-2); }
172
+ #hamburger svg { display: block; width: 18px; height: 18px; }
173
+
174
+ /* ── Profile tabs — underline style ─────────────────────────────────────── */
175
+ #profile-tabs { display: flex; align-items: center; gap: 0; }
176
+ .profile-tab {
177
+ padding: 0 14px; height: var(--topbar-h); line-height: var(--topbar-h);
178
+ font-size: 13px; cursor: pointer;
179
+ background: none; color: var(--text-dim);
180
+ border: none; border-bottom: 2px solid transparent;
181
+ transition: all 0.13s; white-space: nowrap; font-family: inherit;
182
+ display: flex; align-items: center;
183
+ }
184
+ .profile-tab:hover { color: var(--text); }
185
+ .profile-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
186
+
187
+ #ws-indicator { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
188
+ #ws-dot {
189
+ width: 7px; height: 7px; border-radius: 50%;
190
+ background: var(--text-muted); transition: background 0.3s;
191
+ }
192
+ #ws-dot.connected { background: #22c55e; }
193
+ #ws-dot.error { background: #ef4444; }
194
+ #ws-model-label { font-size: 11.5px; color: var(--text-dim); background: var(--surface-2); padding: 3px 8px; border-radius: 99px; border: 1px solid var(--border); }
195
+
196
+ /* ── Chat layout: main + right panel ────────────────────────────────────── */
197
+ #chat-layout { display: flex; flex: 1; overflow: hidden; min-height: 0; }
198
+ #chat-main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
199
+
200
+ /* ── Messages ────────────────────────────────────────────────────────────── */
201
+ #messages {
202
+ flex: 1; overflow-y: auto; padding: 20px 24px 12px;
203
+ display: flex; flex-direction: column; gap: 14px;
204
+ position: relative; background: var(--bg);
205
+ }
206
+
207
+ #empty-state {
208
+ position: absolute; inset: 0;
209
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
210
+ gap: 18px; padding: 32px; pointer-events: none;
211
+ }
212
+ #empty-state.hidden { display: none; }
213
+ .empty-logo { font-size: 24px; font-weight: 700; letter-spacing: -0.5px; }
214
+ .empty-logo span { color: var(--accent); }
215
+ .empty-tagline { font-size: 12.5px; color: var(--text-dim); }
216
+ .empty-suggestions {
217
+ display: flex; flex-wrap: wrap; gap: 7px; justify-content: center;
218
+ max-width: 440px; pointer-events: all;
219
+ }
220
+ .suggestion-chip {
221
+ padding: 6px 13px; border: 1px solid var(--border); border-radius: 99px;
222
+ background: var(--surface); color: var(--text-dim); font-size: 12px;
223
+ cursor: pointer; transition: all 0.13s; font-family: inherit;
224
+ }
225
+ .suggestion-chip:hover { border-color: var(--accent-border); color: var(--text); background: var(--accent-dim); }
226
+
227
+ .msg-row { display: flex; flex-direction: column; gap: 3px; }
228
+ .msg-row.user { align-items: flex-end; }
229
+ .msg-row.assistant { align-items: flex-start; }
230
+
231
+ .bubble {
232
+ max-width: 78%; padding: 9px 13px; border-radius: var(--radius);
233
+ line-height: 1.65; word-wrap: break-word;
234
+ }
235
+ .msg-row.user .bubble { background: var(--bubble-user); color: #fff; border-bottom-right-radius: 3px; }
236
+ .msg-row.assistant .bubble { background: var(--bubble-ai); color: var(--text); border-bottom-left-radius: 3px; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
237
+
238
+ .bubble code { background: rgba(0,0,0,.06); padding: 1px 5px; border-radius: 4px; font-size: 12px; font-family: ui-monospace,'SF Mono','Cascadia Code',monospace; color: var(--text); }
239
+ .bubble .code-wrap { position: relative; margin: 8px 0; }
240
+ .bubble pre { background: #1e2535; padding: 11px 13px; border-radius: 8px; overflow-x: auto; border: 1px solid #2d3748; }
241
+ .bubble pre code { background: none; padding: 0; font-size: 11.5px; line-height: 1.6; color: #e2e8f0; }
242
+ .copy-btn {
243
+ position: absolute; top: 6px; right: 6px;
244
+ padding: 2px 7px; font-size: 10px; border-radius: 4px;
245
+ background: #2d3748; border: 1px solid #4a5568;
246
+ color: #a0aec0; cursor: pointer; font-family: inherit;
247
+ transition: all 0.13s; opacity: 0;
248
+ }
249
+ .code-wrap:hover .copy-btn { opacity: 1; }
250
+ .copy-btn.copied { color: #68d391; border-color: #68d391; }
251
+ .bubble strong { font-weight: 600; }
252
+ .bubble em { font-style: italic; }
253
+ .bubble h1, .bubble h2, .bubble h3 { font-weight: 600; margin: 9px 0 3px; }
254
+ .bubble h1 { font-size: 1.05em; } .bubble h2 { font-size: 1em; } .bubble h3 { font-size: .93em; color: var(--text-dim); }
255
+ .bubble ul, .bubble ol { padding-left: 18px; margin: 5px 0; }
256
+ .bubble li { margin: 2px 0; }
257
+ .bubble p { margin: 3px 0; }
258
+ .bubble p:first-child { margin-top: 0; }
259
+ .bubble p:last-child { margin-bottom: 0; }
260
+
261
+ .bubble.streaming::after {
262
+ content: ''; display: inline-block; width: 2px; height: 0.83em;
263
+ background: var(--accent); margin-left: 2px; vertical-align: text-bottom;
264
+ animation: blink-cursor 0.8s step-end infinite;
265
+ }
266
+ @keyframes blink-cursor { 0%,100% { opacity:1; } 50% { opacity:0; } }
267
+
268
+ .tool-call-pill {
269
+ display: inline-flex; align-items: center; gap: 6px;
270
+ padding: 3px 8px; border-radius: 99px;
271
+ background: var(--yellow-dim); border: 1px solid rgba(245,158,11,.28);
272
+ color: var(--yellow); font-size: 11px;
273
+ font-family: ui-monospace,'SF Mono',monospace; margin: 3px 0;
274
+ }
275
+ .tool-call-pill.done { background: var(--green-dim); border-color: rgba(34,197,94,.28); color: var(--green); }
276
+ .tool-spinner { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid currentColor; border-top-color: transparent; animation: spin 0.7s linear infinite; flex-shrink: 0; }
277
+ .done-check { width: 10px; height: 10px; flex-shrink: 0; }
278
+ @keyframes spin { to { transform: rotate(360deg); } }
279
+
280
+ .tool-result-wrap { margin: 3px 0 3px 2px; }
281
+ .tool-result-toggle { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-muted); cursor: pointer; padding: 2px 0; }
282
+ .tool-result-toggle:hover { color: var(--text-dim); }
283
+ .toggle-chevron { transition: transform 0.13s; }
284
+ .tool-result-toggle.open .toggle-chevron { transform: rotate(90deg); }
285
+ .tool-result-body { display: none; margin-top: 4px; padding: 8px 10px; background: #080808; border-radius: 6px; border: 1px solid var(--border); font-size: 11px; color: var(--text-dim); white-space: pre-wrap; font-family: ui-monospace,'SF Mono',monospace; max-height: 200px; overflow-y: auto; line-height: 1.5; }
286
+ .tool-result-body.open { display: block; }
287
+
288
+ .model-switch-pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 99px; background: var(--blue-dim); border: 1px solid rgba(96,165,250,.22); color: var(--blue); font-size: 10.5px; align-self: center; margin: 2px 0; }
289
+
290
+ .thinking-bubble { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); border-bottom-left-radius: 3px; padding: 12px 15px; display: flex; align-items: center; gap: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
291
+ .thinking-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); animation: think-bounce 1.1s ease infinite; flex-shrink: 0; }
292
+ .thinking-dot:nth-child(2) { animation-delay: 0.15s; }
293
+ .thinking-dot:nth-child(3) { animation-delay: 0.30s; }
294
+ @keyframes think-bounce { 0%,60%,100% { transform: translateY(0); opacity:.3; } 30% { transform: translateY(-5px); opacity:.9; } }
295
+
296
+ .usage-line { font-size: 10px; color: var(--text-muted); margin-top: 3px; }
297
+
298
+ /* ── Input area ──────────────────────────────────────────────────────────── */
299
+ #input-area { padding: 12px 20px 14px; border-top: 1px solid var(--border); background: var(--surface); flex-shrink: 0; }
300
+ #input-row { display: flex; gap: 8px; align-items: flex-end; }
301
+ #msg-input {
302
+ flex: 1; padding: 10px 14px; background: var(--bg); color: var(--text);
303
+ border: 1px solid var(--border); border-radius: var(--radius);
304
+ font-size: 13.5px; font-family: inherit; resize: none; outline: none;
305
+ max-height: 150px; min-height: 44px; line-height: 1.5; transition: border-color 0.15s;
306
+ }
307
+ #msg-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
308
+ #msg-input::placeholder { color: var(--text-muted); }
309
+
310
+ #send-btn {
311
+ display: flex; align-items: center; justify-content: center;
312
+ width: 44px; height: 44px;
313
+ background: var(--accent); color: #fff;
314
+ border: none; border-radius: 99px; font-size: 13px; font-weight: 500;
315
+ cursor: pointer; transition: background 0.15s, transform 0.1s;
316
+ flex-shrink: 0;
317
+ }
318
+ #send-btn:hover:not(:disabled) { background: #1d4ed8; }
319
+ #send-btn:active:not(:disabled) { transform: scale(0.95); }
320
+ #send-btn:disabled { opacity: .3; cursor: default; }
321
+ #send-btn svg { width: 14px; height: 14px; }
322
+ /* hide "Send" text, icon only */
323
+ #send-btn .send-text { display: none; }
324
+
325
+ #token-count { font-size: 10.5px; color: var(--text-muted); text-align: right; margin-top: 4px; }
326
+ #status-bar { font-size: 10.5px; color: var(--text-muted); margin-top: 4px; height: 14px; }
327
+
328
+ /* ── Live events panel (dark) ────────────────────────────────────────────── */
329
+ #live-panel {
330
+ width: 290px; flex-shrink: 0;
331
+ background: var(--dark-bg); border-left: 1px solid var(--dark-border);
332
+ display: flex; flex-direction: column; overflow: hidden;
333
+ transition: width 0.2s cubic-bezier(0.4,0,0.2,1);
334
+ }
335
+ #live-panel.collapsed { width: 36px; }
336
+ #live-panel-header {
337
+ height: var(--topbar-h); padding: 0 10px;
338
+ display: flex; align-items: center; gap: 8px;
339
+ border-bottom: 1px solid var(--dark-border); flex-shrink: 0;
340
+ }
341
+ #live-panel-toggle {
342
+ background: none; border: none; color: var(--dark-muted); cursor: pointer;
343
+ padding: 4px; border-radius: 5px; flex-shrink: 0; transition: all 0.13s;
344
+ display: flex; align-items: center; justify-content: center;
345
+ }
346
+ #live-panel-toggle:hover { color: var(--dark-text); background: var(--dark-surface); }
347
+ #live-panel-toggle svg { width: 15px; height: 15px; transition: transform 0.2s; }
348
+ #live-panel.collapsed #live-panel-toggle svg { transform: rotate(180deg); }
349
+ .live-panel-title { font-size: 10.5px; font-weight: 600; color: var(--dark-muted); text-transform: uppercase; letter-spacing: 0.1em; flex: 1; white-space: nowrap; overflow: hidden; }
350
+ #live-panel.collapsed .live-panel-title { display: none; }
351
+ #live-panel.collapsed .live-perf-section { display: none; }
352
+ #live-panel.collapsed #live-events-list { display: none; }
353
+
354
+ #live-events-list { flex: 1; overflow-y: auto; padding: 6px; min-height: 0; }
355
+ .live-event {
356
+ display: flex; gap: 7px; padding: 5px 7px; border-radius: 5px;
357
+ margin-bottom: 1px; align-items: flex-start; font-size: 11px;
358
+ }
359
+ .live-event:hover { background: var(--dark-surface); }
360
+ .live-event-time { color: var(--dark-muted); font-family: ui-monospace,'SF Mono',monospace; flex-shrink: 0; font-size: 9.5px; margin-top: 1px; }
361
+ .live-event-body { flex: 1; min-width: 0; }
362
+ .live-event-type { font-family: ui-monospace,'SF Mono',monospace; font-size: 10px; font-weight: 600; }
363
+ .live-event-detail { color: #5a7a99; font-size: 10px; margin-top: 1px; word-break: break-all; }
364
+ .evt-model { color: #7eb8f7; }
365
+ .evt-tool { color: #f6c26b; }
366
+ .evt-result { color: #5ed98e; }
367
+ .evt-switch { color: #63d4e8; }
368
+ .evt-done { color: #a78bfa; }
369
+ .evt-error { color: #f87171; }
370
+ .evt-conn { color: #5ed98e; }
371
+
372
+ .live-perf-section { padding: 10px; border-top: 1px solid var(--dark-border); flex-shrink: 0; }
373
+ .perf-title { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--dark-muted); font-weight: 600; margin-bottom: 10px; }
374
+ .perf-row { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
375
+ .perf-label { font-size: 10px; color: var(--dark-muted); flex-shrink: 0; width: 72px; }
376
+ .perf-bar-wrap { flex: 1; height: 3px; background: var(--dark-surface); border-radius: 2px; overflow: hidden; }
377
+ .perf-bar { height: 100%; background: #2563eb; border-radius: 2px; transition: width 0.5s; }
378
+ .perf-bar.green { background: #22c55e; }
379
+ .perf-val { font-size: 10px; color: var(--dark-text); font-family: ui-monospace,'SF Mono',monospace; flex-shrink: 0; width: 36px; text-align: right; }
380
+ .perf-stat-row { display: flex; justify-content: space-between; margin-bottom: 4px; }
381
+ .perf-stat-label { font-size: 10px; color: var(--dark-muted); }
382
+ .perf-stat-val { font-size: 10px; color: var(--dark-text); font-family: ui-monospace,'SF Mono',monospace; }
383
+
384
+ /* ── Bottom status bar (dark, like screenshots) ──────────────────────────── */
385
+ #bottom-bar {
386
+ height: 26px; background: var(--dark-bg); border-top: 1px solid var(--dark-border);
387
+ display: flex; align-items: center; padding: 0 14px; gap: 14px; flex-shrink: 0;
388
+ font-family: ui-monospace,'SF Mono',monospace; font-size: 10.5px; color: var(--dark-muted);
389
+ }
390
+ .bb-sep { color: var(--dark-border); }
391
+ .bb-item { display: flex; align-items: center; gap: 5px; }
392
+ .bb-dot { width: 5px; height: 5px; border-radius: 50%; background: #22c55e; }
393
+ .bb-spacer { flex: 1; }
394
+ .bb-log { color: var(--dark-muted); }
395
+
396
+ /* ── Toast ───────────────────────────────────────────────────────────────── */
397
+ #toast-container { position: fixed; bottom: 46px; right: 16px; display: flex; flex-direction: column; gap: 6px; pointer-events: none; z-index: 200; }
398
+ .toast { padding: 8px 13px; border-radius: 8px; background: #1e293b; border: 1px solid #334155; font-size: 12px; color: #cbd5e1; animation: toast-in 0.18s ease; pointer-events: all; box-shadow: 0 4px 20px rgba(0,0,0,.4); }
399
+ @keyframes toast-in { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } }
400
+
401
+ /* ── Mobile overlay ──────────────────────────────────────────────────────── */
402
+ #nav-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.65); z-index: 9; backdrop-filter: blur(2px); }
403
+
404
+ /* ── View: Code ──────────────────────────────────────────────────────────── */
405
+ #view-code { flex-direction: row; }
406
+ #code-tree { width: 200px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
407
+ #code-tree-header { padding: 12px 12px 8px; border-bottom: 1px solid var(--border-subtle); font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
408
+ #code-tree-list { flex: 1; overflow-y: auto; padding: 6px; }
409
+ .tree-folder { padding: 4px 8px; border-radius: 5px; font-size: 12px; color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 7px; }
410
+ .tree-folder:hover { background: var(--surface-2); color: var(--text); }
411
+ .tree-file { padding: 3px 8px 3px 26px; border-radius: 5px; font-size: 11.5px; color: var(--text-muted); cursor: pointer; }
412
+ .tree-file:hover { background: var(--surface-2); color: var(--text-dim); }
413
+ .tree-ext { font-size: 10px; color: var(--text-muted); margin-left: auto; }
414
+
415
+ #code-editor { flex: 1; display: flex; flex-direction: column; min-width: 0; }
416
+ #code-editor-area { flex: 1; background: var(--bg); color: var(--text); border: none; outline: none; resize: none; font-family: ui-monospace,'SF Mono','Cascadia Code',monospace; font-size: 12.5px; line-height: 1.7; padding: 16px; overflow: auto; tab-size: 2; }
417
+
418
+ #code-assistant { width: 280px; flex-shrink: 0; background: var(--surface); border-left: 1px solid var(--border); display: flex; flex-direction: column; }
419
+ .panel-section { padding: 12px; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; }
420
+ .panel-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); font-weight: 600; margin-bottom: 10px; }
421
+
422
+ /* ── View: Research ──────────────────────────────────────────────────────── */
423
+ #view-research { flex-direction: column; }
424
+ #research-topbar { padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; display: flex; gap: 8px; align-items: center; }
425
+ #research-search-input { flex: 1; padding: 9px 13px; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; outline: none; font-family: inherit; transition: border-color 0.15s; }
426
+ #research-search-input:focus { border-color: var(--accent-border); }
427
+ #research-search-input::placeholder { color: var(--text-muted); }
428
+ #research-search-btn { padding: 9px 16px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius); font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit; transition: opacity 0.15s; white-space: nowrap; }
429
+ #research-search-btn:hover { opacity: .85; }
430
+ .brave-badge { font-size: 10px; color: var(--text-muted); background: var(--surface-3); padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); white-space: nowrap; }
431
+ #research-body { flex: 1; display: flex; overflow: hidden; min-height: 0; }
432
+ #research-memories { width: 300px; flex-shrink: 0; border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
433
+ #research-memories-header { padding: 12px; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; }
434
+ .r-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); font-weight: 600; }
435
+ #research-mem-list { flex: 1; overflow-y: auto; padding: 6px; }
436
+ .research-mem-item { padding: 8px 9px; border-radius: 7px; margin-bottom: 3px; cursor: pointer; border: 1px solid transparent; transition: all 0.13s; }
437
+ .research-mem-item:hover { background: var(--surface-2); border-color: var(--border); }
438
+ .research-mem-badges { display: flex; gap: 4px; margin-bottom: 4px; }
439
+ .r-badge { font-size: 9.5px; padding: 2px 6px; border-radius: 4px; font-weight: 600; letter-spacing: 0.04em; }
440
+ .r-badge.semantic { background: var(--blue-dim); color: var(--blue); border: 1px solid rgba(96,165,250,.22); }
441
+ .r-badge.like { background: var(--cyan-dim); color: var(--cyan); border: 1px solid rgba(34,211,238,.22); }
442
+ .r-badge.type { background: var(--surface-3); color: var(--text-muted); border: 1px solid var(--border); }
443
+ .research-mem-text { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
444
+ .research-mem-time { font-size: 10px; color: var(--text-muted); margin-top: 3px; }
445
+ #research-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
446
+ #research-summary { flex: 1; overflow-y: auto; padding: 16px 20px; }
447
+ .research-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; color: var(--text-muted); }
448
+ .research-result { padding: 14px 16px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 10px; }
449
+ .research-result-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
450
+ .research-result-url { font-size: 11px; color: var(--blue); margin-bottom: 6px; word-break: break-all; }
451
+ .research-result-desc { font-size: 12.5px; color: var(--text-dim); line-height: 1.55; }
452
+
453
+ /* ── View: Memory / Vault ────────────────────────────────────────────────── */
454
+ #view-memory { flex-direction: column; }
455
+ #memory-stats-bar { padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; display: flex; gap: 12px; }
456
+ .mem-stat-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 14px; min-width: 120px; }
457
+ .mem-stat-label { font-size: 10.5px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.07em; font-weight: 600; margin-bottom: 5px; }
458
+ .mem-stat-val { font-size: 20px; font-weight: 700; color: var(--text); font-family: ui-monospace,'SF Mono',monospace; line-height: 1; }
459
+ .mem-stat-sub { font-size: 10.5px; color: var(--text-muted); margin-top: 3px; }
460
+ #memory-body { flex: 1; display: flex; overflow: hidden; min-height: 0; }
461
+ #memory-list-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
462
+ #memory-search-bar { padding: 10px 14px; border-bottom: 1px solid var(--border-subtle); display: flex; gap: 7px; align-items: center; flex-shrink: 0; }
463
+ #vault-search { flex: 1; padding: 7px 11px; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 12.5px; outline: none; font-family: inherit; transition: border-color 0.15s; }
464
+ #vault-search:focus { border-color: var(--accent-border); }
465
+ #vault-search::placeholder { color: var(--text-muted); }
466
+ .engine-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
467
+ .engine-btn { padding: 5px 9px; font-size: 10.5px; font-weight: 600; cursor: pointer; background: none; border: none; color: var(--text-muted); font-family: inherit; transition: all 0.13s; }
468
+ .engine-btn.active { background: var(--accent-dim); color: #c4b5fd; }
469
+ #vault-list { flex: 1; overflow-y: auto; padding: 6px; }
470
+ .vault-item { display: flex; gap: 10px; padding: 9px 10px; border-radius: 7px; margin-bottom: 2px; border: 1px solid transparent; cursor: pointer; transition: all 0.13s; align-items: flex-start; }
471
+ .vault-item:hover { background: var(--surface-2); border-color: var(--border); }
472
+ .vault-relevance { font-size: 11px; font-family: ui-monospace,'SF Mono',monospace; color: var(--accent); flex-shrink: 0; margin-top: 1px; min-width: 34px; }
473
+ .vault-item-body { flex: 1; min-width: 0; }
474
+ .vault-item-type { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); font-weight: 600; margin-bottom: 2px; }
475
+ .vault-item-text { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
476
+ .vault-item-time { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
477
+ #memory-sidebar { width: 260px; flex-shrink: 0; border-left: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
478
+ .vault-ops-section { padding: 12px; border-bottom: 1px solid var(--border-subtle); flex-shrink: 0; }
479
+ .vault-op-btn { display: flex; align-items: center; gap: 7px; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); background: none; border: 1px solid var(--border); color: var(--text-dim); font-size: 12px; font-family: inherit; cursor: pointer; margin-bottom: 5px; transition: all 0.13s; text-align: left; }
480
+ .vault-op-btn:hover { background: var(--surface-2); color: var(--text); border-color: var(--accent-border); }
481
+ .vault-op-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
482
+ .retention-section { padding: 12px; flex-shrink: 0; }
483
+ .retention-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
484
+ .retention-label { font-size: 12px; color: var(--text-dim); }
485
+ .retention-val { font-size: 12px; color: var(--text-muted); font-family: ui-monospace,'SF Mono',monospace; }
486
+
487
+ /* ── View: Settings ──────────────────────────────────────────────────────── */
488
+ #view-settings { flex-direction: column; overflow-y: auto; }
489
+ .settings-body { padding: 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; max-width: 1100px; }
490
+ .settings-section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
491
+ .settings-section.full-width { grid-column: 1 / -1; }
492
+ .settings-section-header { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; justify-content: space-between; }
493
+ .settings-section-title { font-size: 12px; font-weight: 600; color: var(--text); text-transform: uppercase; letter-spacing: 0.07em; }
494
+ .settings-section-body { padding: 14px 16px; }
495
+
496
+ .provider-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; margin-bottom: 8px; display: flex; gap: 12px; align-items: flex-start; }
497
+ .provider-card:last-child { margin-bottom: 0; }
498
+ .provider-status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0; margin-top: 4px; transition: background 0.3s; }
499
+ .provider-status-dot.ok { background: var(--green); }
500
+ .provider-status-dot.err { background: var(--red); }
501
+ .provider-card-body { flex: 1; min-width: 0; }
502
+ .provider-card-name { font-size: 13px; font-weight: 600; color: var(--text); }
503
+ .provider-card-model { font-size: 11.5px; color: var(--text-dim); margin-top: 2px; font-family: ui-monospace,'SF Mono',monospace; }
504
+ .provider-card-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
505
+ .provider-card-badge { display: inline-flex; font-size: 9.5px; padding: 2px 6px; border-radius: 4px; font-weight: 600; margin-top: 5px; }
506
+ .provider-card-badge.active { background: var(--green-dim); color: var(--green); border: 1px solid rgba(34,197,94,.22); }
507
+ .provider-card-badge.inactive { background: var(--surface-3); color: var(--text-muted); border: 1px solid var(--border); }
508
+
509
+ .hw-panel { background: var(--dark-bg); border: 1px solid var(--dark-border); border-radius: var(--radius-sm); padding: 14px; font-family: ui-monospace,'SF Mono',monospace; }
510
+ .hw-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid var(--dark-border); }
511
+ .hw-row:last-child { border-bottom: none; }
512
+ .hw-key { font-size: 11.5px; color: var(--dark-muted); }
513
+ .hw-val { font-size: 11.5px; color: #22c55e; }
514
+
515
+ .routing-table { width: 100%; border-collapse: collapse; }
516
+ .routing-table th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); font-weight: 600; padding: 6px 10px; border-bottom: 1px solid var(--border); }
517
+ .routing-table td { font-size: 12px; padding: 7px 10px; border-bottom: 1px solid var(--border-subtle); }
518
+ .routing-table tr:last-child td { border-bottom: none; }
519
+ .route-task { color: var(--text-dim); }
520
+ .route-model { color: #c4b5fd; font-family: ui-monospace,'SF Mono',monospace; font-size: 11px; }
521
+ .route-reason { color: var(--text-muted); font-size: 11px; }
522
+ .add-rule-btn { margin-top: 10px; padding: 6px 12px; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-dim); font-size: 12px; font-family: inherit; cursor: pointer; transition: all 0.13s; }
523
+ .add-rule-btn:hover { border-color: var(--accent-border); color: var(--text); background: var(--accent-dim); }
524
+
525
+ /* ── Scrollbars ──────────────────────────────────────────────────────────── */
526
+ ::-webkit-scrollbar { width: 4px; height: 4px; }
527
+ ::-webkit-scrollbar-track { background: transparent; }
528
+ ::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 2px; }
529
+ ::-webkit-scrollbar-thumb:hover { background: var(--surface-4); }
530
+
531
+ /* ── Message avatars ─────────────────────────────────────────────────────── */
532
+ .msg-inner { display: flex; align-items: flex-end; gap: 8px; width: 100%; }
533
+ .msg-row.user .msg-inner { flex-direction: row-reverse; justify-content: flex-start; }
534
+ .msg-row.assistant .msg-inner { flex-direction: row; }
535
+ .msg-avatar {
536
+ width: 26px; height: 26px; border-radius: 50%;
537
+ display: flex; align-items: center; justify-content: center;
538
+ font-size: 10px; font-weight: 700; flex-shrink: 0; user-select: none;
539
+ }
540
+ .user-avatar { background: #374151; color: #d1d5db; }
541
+ .ai-avatar { background: var(--accent); color: #fff; border-radius: 6px; font-size: 8.5px; letter-spacing: 0.03em; }
542
+ .msg-bubble-wrap { display: flex; flex-direction: column; gap: 3px; max-width: min(540px, calc(100% - 34px)); }
543
+ .msg-row.user .msg-bubble-wrap { align-items: flex-end; }
544
+ .msg-row.assistant .msg-bubble-wrap { align-items: flex-start; }
545
+ .msg-bubble-wrap .bubble { max-width: 100%; }
546
+ .msg-timestamp { font-size: 9px; color: var(--text-muted); padding: 0 2px; letter-spacing: 0.04em; }
547
+
548
+ /* ── Streaming badge in live panel ───────────────────────────────────────── */
549
+ #streaming-badge {
550
+ display: none; align-items: center; gap: 4px; margin-left: auto;
551
+ padding: 2px 7px; border-radius: 99px;
552
+ background: rgba(37,99,235,.18); border: 1px solid rgba(37,99,235,.4);
553
+ color: #7eb8f7; font-size: 9px; font-weight: 700; letter-spacing: 0.08em;
554
+ }
555
+ #streaming-badge.active { display: flex; }
556
+ .streaming-blink { width: 5px; height: 5px; border-radius: 50%; background: #7eb8f7; animation: pulse-dot 0.8s ease infinite; }
557
+
558
+ /* ── Code tabs bar ───────────────────────────────────────────────────────── */
559
+ #code-tabs-bar {
560
+ height: 38px; background: var(--surface); border-bottom: 1px solid var(--border);
561
+ display: flex; align-items: flex-end; overflow-x: auto; flex-shrink: 0; padding: 0 8px; gap: 2px;
562
+ }
563
+ .code-tab {
564
+ display: flex; align-items: center; gap: 7px; padding: 0 10px; height: 32px;
565
+ font-size: 11.5px; color: var(--text-muted); cursor: pointer;
566
+ background: var(--surface-2); border: 1px solid var(--border); border-bottom: none;
567
+ border-radius: 5px 5px 0 0; transition: all 0.13s; white-space: nowrap; flex-shrink: 0;
568
+ font-family: ui-monospace,'SF Mono',monospace;
569
+ }
570
+ .code-tab:hover { color: var(--text-dim); }
571
+ .code-tab.active { background: var(--bg); color: var(--text); }
572
+ .code-tab-close { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 13px; line-height: 1; transition: color 0.13s; }
573
+ .code-tab-close:hover { color: var(--red); }
574
+
575
+ /* ── Line numbers ────────────────────────────────────────────────────────── */
576
+ #line-numbers {
577
+ width: 44px; background: var(--surface); border-right: 1px solid var(--border-subtle);
578
+ padding: 16px 8px 16px 0; font-family: ui-monospace,'SF Mono',monospace;
579
+ font-size: 12.5px; line-height: 1.7; color: var(--text-muted); text-align: right;
580
+ user-select: none; overflow: hidden; flex-shrink: 0; white-space: pre;
581
+ }
582
+
583
+ /* ── Knowledge graph / Vector topology SVG ───────────────────────────────── */
584
+ .kg-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--dark-muted); font-weight: 600; margin-bottom: 6px; }
585
+ .kg-svg-wrap { background: var(--dark-bg); border: 1px solid var(--dark-border); border-radius: 6px; overflow: hidden; }
586
+ .kg-svg-wrap svg { display: block; }
587
+
588
+ /* ── GPU UTIL card ───────────────────────────────────────────────────────── */
589
+ .gpu-card {
590
+ margin: 8px 10px 0; padding: 8px 10px; background: var(--dark-surface);
591
+ border: 1px solid var(--dark-border); border-radius: 6px; flex-shrink: 0;
592
+ }
593
+ .gpu-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
594
+ .gpu-card-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--dark-muted); font-weight: 600; }
595
+ .gpu-card-val { font-size: 13px; font-weight: 700; color: #7eb8f7; font-family: ui-monospace,'SF Mono',monospace; }
596
+ .gpu-bar-wrap { height: 3px; background: var(--dark-bg); border-radius: 2px; }
597
+ .gpu-bar { height: 100%; background: #7eb8f7; border-radius: 2px; transition: width 0.6s; width: 0%; }
598
+
599
+ /* ── Provider icon + badges ──────────────────────────────────────────────── */
600
+ .provider-icon {
601
+ width: 34px; height: 34px; border-radius: 7px;
602
+ display: flex; align-items: center; justify-content: center;
603
+ font-size: 11px; font-weight: 800; color: #fff; flex-shrink: 0; letter-spacing: 0.03em;
604
+ }
605
+ .provider-icon.ollama { background: #111; border: 1px solid #333; }
606
+ .provider-icon.anthropic { background: #c9513a; }
607
+ .provider-icon.openai { background: #10a37f; }
608
+ .provider-icon.groq { background: #f55036; }
609
+ .provider-icon.gemini { background: #4285f4; }
610
+ .provider-icon.default { background: #374151; }
611
+
612
+ .prov-badge {
613
+ display: inline-flex; align-items: center; gap: 4px;
614
+ font-size: 9.5px; padding: 2px 7px; border-radius: 4px; font-weight: 600;
615
+ letter-spacing: 0.04em; margin-top: 5px;
616
+ }
617
+ .prov-badge.connected { background: var(--green-dim); color: var(--green); border: 1px solid rgba(34,197,94,.22); }
618
+ .prov-badge.api-linked { background: var(--blue-dim); color: var(--blue); border: 1px solid rgba(37,99,235,.3); }
619
+ .prov-badge.needs-cfg { background: var(--surface-3); color: var(--text-muted); border: 1px solid var(--border); }
620
+
621
+ .model-chip {
622
+ display: inline-flex; align-items: center; gap: 4px;
623
+ padding: 2px 8px; border-radius: 99px; margin: 2px 2px 0 0;
624
+ background: rgba(37,99,235,.09); border: 1px solid rgba(37,99,235,.25);
625
+ color: #7eb8f7; font-size: 10.5px; font-family: ui-monospace,'SF Mono',monospace;
626
+ }
627
+ .model-chip-dot { width: 5px; height: 5px; border-radius: 50%; background: #22c55e; flex-shrink: 0; }
628
+
629
+ /* ── HW RAM bar ──────────────────────────────────────────────────────────── */
630
+ .hw-bar-wrap { height: 4px; background: var(--dark-border); border-radius: 2px; margin-top: 5px; overflow: hidden; }
631
+ .hw-bar { height: 100%; background: #2563eb; border-radius: 2px; transition: width 0.5s; }
632
+
633
+ /* ── Toggle switch ───────────────────────────────────────────────────────── */
634
+ .toggle-sw { position: relative; width: 32px; height: 17px; flex-shrink: 0; }
635
+ .toggle-sw input { opacity: 0; width: 0; height: 0; position: absolute; }
636
+ .toggle-track {
637
+ position: absolute; inset: 0; cursor: pointer;
638
+ background: var(--surface-3); border-radius: 17px; transition: 0.2s; border: 1px solid var(--border);
639
+ }
640
+ .toggle-track::before {
641
+ content: ''; position: absolute; height: 11px; width: 11px;
642
+ left: 2px; top: 2px; background: var(--text-muted); border-radius: 50%; transition: 0.2s;
643
+ }
644
+ .toggle-sw input:checked + .toggle-track { background: var(--accent); border-color: var(--accent); }
645
+ .toggle-sw input:checked + .toggle-track::before { transform: translateX(15px); background: #fff; }
646
+
647
+ /* ── Danger button ───────────────────────────────────────────────────────── */
648
+ .vault-op-btn.danger { border-color: rgba(220,38,38,.3); color: var(--red); }
649
+ .vault-op-btn.danger:hover { background: var(--red-dim); border-color: var(--red); }
650
+
651
+ /* ── Nav user profile ────────────────────────────────────────────────────── */
652
+ .nav-user-row {
653
+ display: flex; align-items: center; gap: 9px; padding: 7px 10px;
654
+ border-radius: var(--radius-sm); cursor: pointer; transition: background 0.13s;
655
+ border: 1px solid var(--nav-item-border);
656
+ }
657
+ .nav-user-row:hover { background: rgba(255,255,255,.06); }
658
+ .nav-user-avatar {
659
+ width: 28px; height: 28px; border-radius: 50%;
660
+ background: linear-gradient(135deg,#2563eb,#1d4ed8);
661
+ display: flex; align-items: center; justify-content: center;
662
+ font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0;
663
+ }
664
+ .nav-user-name { font-size: 12px; font-weight: 600; color: var(--nav-text-active); }
665
+ .nav-user-sub { font-size: 9.5px; color: var(--nav-text); margin-top: 1px; }
666
+
667
+ /* ── Topbar icon buttons ─────────────────────────────────────────────────── */
668
+ .topbar-icon-btn {
669
+ display: flex; align-items: center; justify-content: center;
670
+ width: 30px; height: 30px; border-radius: 7px;
671
+ background: none; border: 1px solid var(--border); color: var(--text-dim);
672
+ cursor: pointer; transition: all 0.13s; flex-shrink: 0;
673
+ }
674
+ .topbar-icon-btn:hover { background: var(--surface-2); color: var(--text); }
675
+ .topbar-icon-btn svg { width: 14px; height: 14px; }
676
+
677
+ /* ── Mobile ──────────────────────────────────────────────────────────────── */
678
+ @media (max-width: 700px) {
679
+ #hamburger { display: flex; }
680
+ #nav { position: fixed; left: 0; top: 0; height: 100%; z-index: 10; transform: translateX(-100%); }
681
+ #nav.open { transform: translateX(0); }
682
+ #nav-overlay.open { display: block; }
683
+ .bubble { max-width: 94%; }
684
+ #live-panel { display: none; }
685
+ .settings-body { grid-template-columns: 1fr; }
686
+ .settings-section.full-width { grid-column: 1; }
687
+ #memory-sidebar { display: none; }
688
+ #research-memories { display: none; }
689
+ #code-tree { display: none; }
690
+ #code-assistant { display: none; }
691
+ }
692
+ </style>
693
+ </head>
694
+ <body>
695
+ <div id="app">
696
+
697
+ <!-- ── Left nav sidebar ──────────────────────────────────────────────── -->
698
+ <nav id="nav">
699
+ <div id="nav-logo">
700
+ <div class="logo-name">Personal<span>AI</span></div>
701
+ <div class="logo-tag">Local-First Engine</div>
702
+ </div>
703
+
704
+ <div id="nav-items">
705
+ <button class="nav-item active" data-view="chat">
706
+ <svg class="nav-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
707
+ <path d="M2 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6l-4 3V4z"/>
708
+ </svg>
709
+ Chat
710
+ </button>
711
+ <button class="nav-item" data-view="code">
712
+ <svg class="nav-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
713
+ <path d="M7 7l-4 3 4 3M13 7l4 3-4 3M11 4l-2 12"/>
714
+ </svg>
715
+ Code Workspace
716
+ </button>
717
+ <button class="nav-item" data-view="research">
718
+ <svg class="nav-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
719
+ <circle cx="8" cy="8" r="5"/><path d="M18 18l-4-4"/>
720
+ </svg>
721
+ Research
722
+ </button>
723
+ <button class="nav-item" data-view="memory">
724
+ <svg class="nav-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
725
+ <ellipse cx="10" cy="7" rx="8" ry="3"/><path d="M2 7v6c0 1.66 3.58 3 8 3s8-1.34 8-3V7"/>
726
+ <path d="M2 10c0 1.66 3.58 3 8 3s8-1.34 8-3"/>
727
+ </svg>
728
+ Memory
729
+ </button>
730
+ <button class="nav-item" data-view="settings">
731
+ <svg class="nav-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
732
+ <circle cx="10" cy="10" r="2.5"/>
733
+ <path d="M10 2v2M10 16v2M2 10h2M16 10h2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42"/>
734
+ </svg>
735
+ Settings
736
+ </button>
737
+ </div>
738
+
739
+ <div id="nav-bottom">
740
+ <div class="nav-user-row">
741
+ <div class="nav-user-avatar">N</div>
742
+ <div>
743
+ <div class="nav-user-name">User Profile</div>
744
+ <div class="nav-user-sub">Local mode</div>
745
+ </div>
746
+ </div>
747
+ <button id="new-session-btn" style="margin-top:8px">
748
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
749
+ <path d="M7 2H2a1 1 0 0 0-1 1v8l2.5-2H12a1 1 0 0 0 1-1V7"/><path d="M11 0v4M9 2h4"/>
750
+ </svg>
751
+ New Session
752
+ </button>
753
+ <div class="status-row">
754
+ <span class="status-dot loading" id="ollama-dot"></span>
755
+ <span class="status-label">Ollama</span>
756
+ <span class="status-value" id="ollama-status">—</span>
757
+ </div>
758
+ <div class="status-row">
759
+ <span class="status-dot" id="provider-dot"></span>
760
+ <span class="status-label" id="provider-label">Provider</span>
761
+ <span class="status-value" id="provider-status-val">—</span>
762
+ </div>
763
+ </div>
764
+ </nav>
765
+
766
+ <div id="nav-overlay"></div>
767
+
768
+ <!-- ── Views ─────────────────────────────────────────────────────────── -->
769
+ <div id="views">
770
+
771
+ <!-- ── Chat view ───────────────────────────────────────────────────── -->
772
+ <div id="view-chat" class="view active">
773
+ <div class="topbar">
774
+ <button id="hamburger" aria-label="Menu">
775
+ <svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
776
+ <path d="M2 5h14M2 9h14M2 13h14"/>
777
+ </svg>
778
+ </button>
779
+ <span class="topbar-logo">PersonalAI</span>
780
+ <div id="profile-tabs"></div>
781
+ <div style="flex:1"></div>
782
+ <button class="topbar-icon-btn" title="Notifications" id="bell-btn">
783
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
784
+ <path d="M8 2a5 5 0 0 0-5 5v2l-1 2h12l-1-2V7a5 5 0 0 0-5-5z"/><path d="M7 13a1 1 0 0 0 2 0"/>
785
+ </svg>
786
+ </button>
787
+ <button class="topbar-icon-btn" title="App grid" id="grid-btn" style="margin-left:4px">
788
+ <svg viewBox="0 0 16 16" fill="currentColor">
789
+ <rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/>
790
+ <rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/>
791
+ </svg>
792
+ </button>
793
+ <div style="width:8px"></div>
794
+ <div id="ws-indicator">
795
+ <div id="ws-dot" title="Connecting…"></div>
796
+ <span id="ws-model-label"></span>
797
+ </div>
798
+ </div>
799
+
800
+ <div id="chat-layout">
801
+ <div id="chat-main">
802
+ <div id="messages">
803
+ <div id="empty-state">
804
+ <div class="empty-logo">Personal<span>AI</span></div>
805
+ <div class="empty-tagline" id="empty-provider-line">What can I help you with?</div>
806
+ <div class="empty-suggestions">
807
+ <button class="suggestion-chip">Write a Python function</button>
808
+ <button class="suggestion-chip">Explain a concept</button>
809
+ <button class="suggestion-chip">Debug my code</button>
810
+ <button class="suggestion-chip">Research a topic</button>
811
+ </div>
812
+ </div>
813
+ </div>
814
+
815
+ <div id="toast-container"></div>
816
+
817
+ <div id="input-area">
818
+ <div id="input-row">
819
+ <textarea id="msg-input" rows="1" placeholder="Ask about code optimization, research, or tool calls…"></textarea>
820
+ <button id="send-btn" title="Send">
821
+ <svg viewBox="0 0 16 16" fill="currentColor">
822
+ <path d="M1.5 1.5a.5.5 0 0 1 .65-.065l13 7a.5.5 0 0 1 0 .13l-13 7a.5.5 0 0 1-.71-.64L3.46 9H10.5a.5.5 0 0 0 0-1H3.46L1.44 2.2a.5.5 0 0 1 .06-.7z"/>
823
+ </svg>
824
+ <span class="send-text">Send</span>
825
+ </button>
826
+ </div>
827
+ <div id="token-count"></div>
828
+ <div id="status-bar"></div>
829
+ </div>
830
+ <div id="bottom-bar">
831
+ <span class="bb-item"><span id="bb-version">v1.0.0</span></span>
832
+ <span class="bb-sep">·</span>
833
+ <span class="bb-item"><span id="bb-latency">—</span> Latency</span>
834
+ <span class="bb-sep">·</span>
835
+ <span class="bb-item"><span id="bb-tokens">—</span> Tokens</span>
836
+ <span class="bb-spacer"></span>
837
+ <span class="bb-item"><span class="bb-dot" id="bb-conn-dot"></span> <span id="bb-conn">Connecting</span></span>
838
+ <span class="bb-sep">·</span>
839
+ <span class="bb-log">Log: DEBUG</span>
840
+ </div>
841
+ </div>
842
+
843
+ <!-- Live events panel -->
844
+ <div id="live-panel">
845
+ <div id="live-panel-header">
846
+ <button id="live-panel-toggle" title="Collapse events panel">
847
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
848
+ <path d="M10 4l-4 4 4 4"/>
849
+ </svg>
850
+ </button>
851
+ <span class="live-panel-title">Live Events</span>
852
+ <span id="streaming-badge"><span class="streaming-blink"></span>STREAMING</span>
853
+ </div>
854
+ <div id="live-events-list"></div>
855
+ <div class="gpu-card" id="gpu-card">
856
+ <div class="gpu-card-header">
857
+ <span class="gpu-card-label">GPU UTIL</span>
858
+ <span class="gpu-card-val" id="gpu-util-val">—</span>
859
+ </div>
860
+ <div class="gpu-bar-wrap"><div class="gpu-bar" id="gpu-util-bar"></div></div>
861
+ </div>
862
+ <div class="live-perf-section">
863
+ <div class="perf-title">System Performance</div>
864
+ <div class="perf-row">
865
+ <span class="perf-label">Tokens/sec</span>
866
+ <div class="perf-bar-wrap"><div class="perf-bar green" id="perf-tps-bar" style="width:0%"></div></div>
867
+ <span class="perf-val" id="perf-tps">—</span>
868
+ </div>
869
+ <div class="perf-row">
870
+ <span class="perf-label">Context</span>
871
+ <div class="perf-bar-wrap"><div class="perf-bar" id="perf-ctx-bar" style="width:0%"></div></div>
872
+ <span class="perf-val" id="perf-ctx">—</span>
873
+ </div>
874
+ <div class="perf-stat-row">
875
+ <span class="perf-stat-label">Latency</span>
876
+ <span class="perf-stat-val" id="perf-latency">—</span>
877
+ </div>
878
+ <div class="perf-stat-row">
879
+ <span class="perf-stat-label">Total tokens</span>
880
+ <span class="perf-stat-val" id="perf-tokens">—</span>
881
+ </div>
882
+ </div>
883
+ </div>
884
+ </div>
885
+ </div>
886
+
887
+ <!-- ── Code view ───────────────────────────────────────────────────── -->
888
+ <div id="view-code" class="view">
889
+ <div class="topbar">
890
+ <span class="topbar-title">Code Workspace</span>
891
+ <span class="topbar-sub" id="code-active-file">No file open</span>
892
+ </div>
893
+ <div id="code-tabs-bar"></div>
894
+ <div style="display:flex;flex:1;overflow:hidden;min-height:0">
895
+ <div id="code-tree">
896
+ <div id="code-tree-header">Explorer</div>
897
+ <div id="code-tree-list">
898
+ <div class="tree-folder">
899
+ <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1 4h12v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm0 0V3a1 1 0 0 1 1-1h4l1 2"/></svg>
900
+ src/core
901
+ </div>
902
+ <div class="tree-file">assistant.ts <span class="tree-ext">.ts</span></div>
903
+ <div class="tree-file">context.ts <span class="tree-ext">.ts</span></div>
904
+ <div class="tree-file">logger.ts <span class="tree-ext">.ts</span></div>
905
+ <div class="tree-folder" style="margin-top:4px">
906
+ <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1 4h12v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm0 0V3a1 1 0 0 1 1-1h4l1 2"/></svg>
907
+ src/providers
908
+ </div>
909
+ <div class="tree-file">ollama.ts <span class="tree-ext">.ts</span></div>
910
+ <div class="tree-file">anthropic.ts <span class="tree-ext">.ts</span></div>
911
+ <div class="tree-file">openai.ts <span class="tree-ext">.ts</span></div>
912
+ <div class="tree-folder" style="margin-top:4px">
913
+ <svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1 4h12v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm0 0V3a1 1 0 0 1 1-1h4l1 2"/></svg>
914
+ src/tools
915
+ </div>
916
+ <div class="tree-file">registry.ts <span class="tree-ext">.ts</span></div>
917
+ <div class="tree-file">calculator.ts <span class="tree-ext">.ts</span></div>
918
+ </div>
919
+ </div>
920
+ <div id="code-editor" style="display:flex">
921
+ <div id="line-numbers">1</div>
922
+ <textarea id="code-editor-area" spellcheck="false" placeholder="// Select a file from the explorer, or paste code here&#10;// Use the AI assistant panel to get help with your code" style="flex:1;border-radius:0"></textarea>
923
+ </div>
924
+ <div id="code-assistant">
925
+ <div class="panel-section">
926
+ <div class="panel-section-title">Coding Assistant</div>
927
+ <div style="font-size:12px;color:var(--text-muted);line-height:1.6">Switch to Coder profile for optimized code assistance using qwen2.5-coder.</div>
928
+ <button style="margin-top:10px;padding:6px 12px;background:var(--accent-dim);border:1px solid var(--accent-border);color:#c4b5fd;border-radius:var(--radius-sm);font-size:12px;font-family:inherit;cursor:pointer;" onclick="document.querySelector('[data-view=chat]').click();switchProfile('coder')">
929
+ Open Chat / Coder Mode
930
+ </button>
931
+ </div>
932
+ <div class="panel-section">
933
+ <div class="panel-section-title">Quick Actions</div>
934
+ <div id="code-quick-actions" style="display:flex;flex-direction:column;gap:4px"></div>
935
+ </div>
936
+ </div>
937
+ </div>
938
+ </div>
939
+
940
+ <!-- ── Research view ──────────────────────────────────────────────── -->
941
+ <div id="view-research" class="view">
942
+ <div id="research-topbar">
943
+ <input id="research-search-input" type="search" placeholder="Search the web — enter a query…" autocomplete="off">
944
+ <button id="research-search-btn">Search</button>
945
+ <span class="brave-badge">Web Search</span>
946
+ </div>
947
+ <div id="research-body">
948
+ <div id="research-memories">
949
+ <div id="research-memories-header">
950
+ <div class="r-section-title">Recent Memories</div>
951
+ </div>
952
+ <div id="research-mem-list"></div>
953
+ <div style="padding:10px;border-top:1px solid var(--border-subtle)">
954
+ <div class="kg-title" style="color:var(--text-muted)">Knowledge Graph</div>
955
+ <div class="kg-svg-wrap" style="height:120px">
956
+ <svg id="kg-svg" viewBox="0 0 260 120" xmlns="http://www.w3.org/2000/svg"></svg>
957
+ </div>
958
+ </div>
959
+ </div>
960
+ <div id="research-main">
961
+ <div id="research-summary">
962
+ <div class="research-empty" id="research-empty">
963
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text-muted)">
964
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
965
+ </svg>
966
+ <div style="font-size:13px;color:var(--text-muted)">Enter a query above to search the web</div>
967
+ <div style="font-size:11.5px;color:var(--text-muted)">Results appear here. Memories shown on the left.</div>
968
+ </div>
969
+ </div>
970
+ </div>
971
+ </div>
972
+ </div>
973
+
974
+ <!-- ── Memory / Vault view ────────────────────────────────────────── -->
975
+ <div id="view-memory" class="view">
976
+ <div class="topbar" style="border-bottom:none;padding-bottom:0">
977
+ <span class="topbar-title">Vault Index</span>
978
+ <div style="flex:1"></div>
979
+ <span style="font-size:11px;color:var(--text-muted);background:var(--green-dim);color:var(--green);padding:3px 9px;border-radius:4px;border:1px solid rgba(34,197,94,.22);font-weight:600">ENGINE: M2</span>
980
+ </div>
981
+ <div id="memory-stats-bar" style="border-top:1px solid var(--border-subtle)">
982
+ <div class="mem-stat-card">
983
+ <div class="mem-stat-label">Total Memories</div>
984
+ <div class="mem-stat-val" id="vault-total">—</div>
985
+ <div class="mem-stat-sub">stored entries</div>
986
+ </div>
987
+ <div class="mem-stat-card">
988
+ <div class="mem-stat-label">Search Engine</div>
989
+ <div class="mem-stat-val" style="font-size:14px;padding-top:3px" id="vault-engine">M2</div>
990
+ <div class="mem-stat-sub">LIKE search</div>
991
+ </div>
992
+ <div class="mem-stat-card">
993
+ <div class="mem-stat-label">Avg / Entry</div>
994
+ <div class="mem-stat-val" id="vault-avg-len">—</div>
995
+ <div class="mem-stat-sub">characters</div>
996
+ </div>
997
+ <div class="mem-stat-card">
998
+ <div class="mem-stat-label">Architecture</div>
999
+ <div class="mem-stat-val" style="font-size:14px;padding-top:3px">SQLite</div>
1000
+ <div class="mem-stat-sub">WAL mode</div>
1001
+ </div>
1002
+ <div class="mem-stat-card">
1003
+ <div class="mem-stat-label">Disk Usage</div>
1004
+ <div class="mem-stat-val" id="vault-disk" style="font-size:16px;padding-top:2px">—</div>
1005
+ <div class="mem-stat-sub">KB on disk</div>
1006
+ </div>
1007
+ </div>
1008
+ <div id="memory-body">
1009
+ <div id="memory-list-panel">
1010
+ <div id="memory-search-bar">
1011
+ <input id="vault-search" type="search" placeholder="Search memories…" autocomplete="off">
1012
+ <div class="engine-toggle">
1013
+ <button class="engine-btn active" data-engine="M2">M2</button>
1014
+ <button class="engine-btn" data-engine="M10">M10</button>
1015
+ </div>
1016
+ </div>
1017
+ <div id="vault-list"></div>
1018
+ </div>
1019
+ <div id="memory-sidebar">
1020
+ <div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
1021
+ <div class="kg-title" style="color:var(--text-muted);margin-bottom:8px">Vector Topology</div>
1022
+ <div class="kg-svg-wrap" style="height:120px">
1023
+ <svg id="vault-topo-svg" viewBox="0 0 230 120" xmlns="http://www.w3.org/2000/svg"></svg>
1024
+ </div>
1025
+ </div>
1026
+ <div class="vault-ops-section">
1027
+ <div class="panel-section-title" style="padding:0 0 10px;font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-muted);font-weight:600">Vault Operations</div>
1028
+ <button class="vault-op-btn" onclick="loadVaultMemories()">
1029
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M7 1v12M1 7l6-6 6 6"/></svg>
1030
+ Refresh memories
1031
+ </button>
1032
+ <button class="vault-op-btn" onclick="exportMemories()">
1033
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M7 9V1M1 9l6 4 6-4"/><rect x="1" y="11" width="12" height="2" rx="1"/></svg>
1034
+ Export to JSON
1035
+ </button>
1036
+ <button class="vault-op-btn danger" onclick="confirmPrune()">
1037
+ <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 2l10 10M12 2L2 12"/></svg>
1038
+ Prune Old Memories
1039
+ </button>
1040
+ </div>
1041
+ <div class="retention-section">
1042
+ <div class="panel-section-title" style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-muted);font-weight:600;margin-bottom:10px">Retention Policies</div>
1043
+ <div class="retention-row">
1044
+ <span class="retention-label">Auto-archive</span>
1045
+ <label class="toggle-sw"><input type="checkbox" checked id="toggle-archive"><span class="toggle-track"></span></label>
1046
+ </div>
1047
+ <div class="retention-row" style="margin-top:6px">
1048
+ <span class="retention-label">Auto-save</span>
1049
+ <label class="toggle-sw"><input type="checkbox" checked id="toggle-autosave"><span class="toggle-track"></span></label>
1050
+ </div>
1051
+ <div class="retention-row" style="margin-top:6px">
1052
+ <span class="retention-label">Max entries</span>
1053
+ <span class="retention-val" id="vault-max">∞</span>
1054
+ </div>
1055
+ </div>
1056
+ </div>
1057
+ </div>
1058
+ </div>
1059
+
1060
+ <!-- ── Settings view ─────────────────────────────────────────────── -->
1061
+ <div id="view-settings" class="view">
1062
+ <div class="topbar">
1063
+ <span class="topbar-title">Settings</span>
1064
+ </div>
1065
+ <div class="settings-body">
1066
+ <!-- Provider cards -->
1067
+ <div class="settings-section">
1068
+ <div class="settings-section-header">
1069
+ <span class="settings-section-title">Model Providers</span>
1070
+ </div>
1071
+ <div class="settings-section-body" id="provider-cards"></div>
1072
+ </div>
1073
+
1074
+ <!-- Hardware context -->
1075
+ <div class="settings-section">
1076
+ <div class="settings-section-header">
1077
+ <span class="settings-section-title">Hardware Context</span>
1078
+ </div>
1079
+ <div class="settings-section-body">
1080
+ <div class="hw-panel" id="hw-panel">
1081
+ <div class="hw-row">
1082
+ <span class="hw-key">RAM</span>
1083
+ <span class="hw-val" id="hw-ram">—</span>
1084
+ </div>
1085
+ <div style="padding:4px 0 8px">
1086
+ <div class="hw-bar-wrap"><div class="hw-bar" id="hw-ram-bar" style="width:0%"></div></div>
1087
+ <div style="font-size:9.5px;color:var(--dark-muted);margin-top:3px" id="hw-ram-used">Loading…</div>
1088
+ </div>
1089
+ <div class="hw-row"><span class="hw-key">CPU</span><span class="hw-val" id="hw-cpu-model" style="font-size:10px;color:#7eb8f7;max-width:160px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap">—</span></div>
1090
+ <div class="hw-row"><span class="hw-key">CPU LOAD</span><span class="hw-val" id="hw-cpu-load">—</span></div>
1091
+ <div class="hw-row"><span class="hw-key">THERMAL STATUS</span><span class="hw-val" id="hw-thermal">—</span></div>
1092
+ <div class="hw-row"><span class="hw-key">SWAP LATENCY</span><span class="hw-val" id="hw-swap">—</span></div>
1093
+ <div class="hw-row"><span class="hw-key">Primary model</span><span class="hw-val" id="hw-primary">—</span></div>
1094
+ <div class="hw-row"><span class="hw-key">Provider</span><span class="hw-val" id="hw-provider-name">—</span></div>
1095
+ <div class="hw-row"><span class="hw-key">Tool use</span><span class="hw-val" id="hw-tools">—</span></div>
1096
+ </div>
1097
+ </div>
1098
+ </div>
1099
+
1100
+ <!-- Task routing table -->
1101
+ <div class="settings-section full-width">
1102
+ <div class="settings-section-header">
1103
+ <span class="settings-section-title">Task Routing Table</span>
1104
+ <button class="add-rule-btn">+ New Rule</button>
1105
+ </div>
1106
+ <div class="settings-section-body">
1107
+ <table class="routing-table">
1108
+ <thead>
1109
+ <tr><th>Task Type</th><th>Model</th><th>Fallback Provider</th><th>Reason</th></tr>
1110
+ </thead>
1111
+ <tbody id="routing-tbody">
1112
+ <tr><td class="route-task">tools</td><td class="route-model">qwen2.5:14b</td><td class="route-reason">ollama</td><td class="route-reason">Native tool-use support</td></tr>
1113
+ <tr><td class="route-task">coding</td><td class="route-model">qwen2.5:14b</td><td class="route-reason">ollama</td><td class="route-reason">Code generation</td></tr>
1114
+ <tr><td class="route-task">reasoning</td><td class="route-model">qwen2.5:14b</td><td class="route-reason">ollama</td><td class="route-reason">Strong logical reasoning</td></tr>
1115
+ <tr><td class="route-task">chat</td><td class="route-model">gemma3:12b</td><td class="route-reason">ollama</td><td class="route-reason">Conversational, low latency</td></tr>
1116
+ <tr><td class="route-task">longcontext</td><td class="route-model">gemma3:12b</td><td class="route-reason">ollama</td><td class="route-reason">128k context window</td></tr>
1117
+ <tr><td class="route-task">quick</td><td class="route-model">gemma3:12b</td><td class="route-reason">ollama</td><td class="route-reason">Fast responses</td></tr>
1118
+ </tbody>
1119
+ </table>
1120
+ </div>
1121
+ </div>
1122
+
1123
+ <!-- Tools registered -->
1124
+ <div class="settings-section full-width">
1125
+ <div class="settings-section-header">
1126
+ <span class="settings-section-title">Registered Tools</span>
1127
+ </div>
1128
+ <div class="settings-section-body" id="tools-registry-body">
1129
+ <div style="font-size:12px;color:var(--text-muted)">Loading…</div>
1130
+ </div>
1131
+ </div>
1132
+ </div>
1133
+ </div>
1134
+
1135
+ </div><!-- /views -->
1136
+ </div><!-- /app -->
1137
+
1138
+ <script>
1139
+ (function() {
1140
+ 'use strict';
1141
+
1142
+ // ── Auth token ───────────────────────────────────────────────────────────
1143
+ // The launch URL carries ?token=… once. Store it, strip it from the address
1144
+ // bar, and attach it to every /api fetch and the WebSocket connection.
1145
+ const urlToken = new URLSearchParams(location.search).get('token');
1146
+ if (urlToken) {
1147
+ sessionStorage.setItem('pai_token', urlToken);
1148
+ history.replaceState(null, '', location.pathname);
1149
+ }
1150
+ const AUTH_TOKEN = sessionStorage.getItem('pai_token') || '';
1151
+
1152
+ const _fetch = window.fetch.bind(window);
1153
+ window.fetch = function(input, init) {
1154
+ const url = typeof input === 'string' ? input : input.url;
1155
+ if (url.startsWith('/api')) {
1156
+ init = init || {};
1157
+ init.headers = Object.assign({}, init.headers, { 'Authorization': 'Bearer ' + AUTH_TOKEN });
1158
+ }
1159
+ return _fetch(input, init);
1160
+ };
1161
+
1162
+ // ── State ──────────────────────────────────────────────────────────────────
1163
+ const state = {
1164
+ ws: null,
1165
+ wsReady: false,
1166
+ reconnectDelay: 1000,
1167
+ streaming: false,
1168
+ streamingBubble: null,
1169
+ streamingToolPills: {},
1170
+ scrollLocked: false,
1171
+ activeProfile: 'assistant',
1172
+ currentModel: '',
1173
+ hasMessages: false,
1174
+ activeView: 'chat',
1175
+ liveEventCount: 0,
1176
+ perfTokenCount: 0,
1177
+ perfStreamStart: 0,
1178
+ perfTotalTokens: 0,
1179
+ perfLastLatency: 0,
1180
+ vaultEngine: 'M2',
1181
+ };
1182
+ let accText = '';
1183
+
1184
+ // ── DOM refs ───────────────────────────────────────────────────────────────
1185
+ const $ = id => document.getElementById(id);
1186
+ const $messages = $('messages');
1187
+ const $input = $('msg-input');
1188
+ const $sendBtn = $('send-btn');
1189
+ const $status = $('status-bar');
1190
+ const $profileTabs = $('profile-tabs');
1191
+ const $hamburger = $('hamburger');
1192
+ const $nav = $('nav');
1193
+ const $overlay = $('nav-overlay');
1194
+ const $emptyState = $('empty-state');
1195
+ const $emptyProv = $('empty-provider-line');
1196
+ const $wsDot = $('ws-dot');
1197
+ const $wsModelLbl = $('ws-model-label');
1198
+ const $liveList = $('live-events-list');
1199
+ const $livePanel = $('live-panel');
1200
+
1201
+ // ── Utilities ──────────────────────────────────────────────────────────────
1202
+ function esc(s) {
1203
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1204
+ }
1205
+ function fmtTime() {
1206
+ const d = new Date();
1207
+ return d.getHours().toString().padStart(2,'0') + ':' +
1208
+ d.getMinutes().toString().padStart(2,'0') + ':' +
1209
+ d.getSeconds().toString().padStart(2,'0');
1210
+ }
1211
+ function relativeTime(date) {
1212
+ const ms = Date.now() - new Date(date).getTime();
1213
+ if (ms < 60000) return 'JUST NOW';
1214
+ if (ms < 3600000) return Math.floor(ms / 60000) + 'M AGO';
1215
+ if (ms < 86400000) return Math.floor(ms / 3600000) + 'H AGO';
1216
+ return Math.floor(ms / 86400000) + 'D AGO';
1217
+ }
1218
+ function toast(msg) {
1219
+ const t = document.createElement('div');
1220
+ t.className = 'toast';
1221
+ t.textContent = msg;
1222
+ $('toast-container').appendChild(t);
1223
+ setTimeout(() => t.remove(), 2800);
1224
+ }
1225
+
1226
+ // ── Markdown renderer ──────────────────────────────────────────────────────
1227
+ function renderMarkdown(text) {
1228
+ if (!text) return '';
1229
+ let s = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1230
+ s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, _lang, code) =>
1231
+ `<div class="code-wrap"><button class="copy-btn">Copy</button><pre><code>${code.trimEnd()}</code></pre></div>`);
1232
+ s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
1233
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
1234
+ s = s.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
1235
+ s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
1236
+ s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
1237
+ s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
1238
+ s = s.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
1239
+ s = s.replace(/(<li>.*?<\/li>\n?)+/g, m => `<ul>${m}</ul>`);
1240
+ s = s.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
1241
+ s = s.replace(/(<li>.*?<\/li>\n?)+/g, m => {
1242
+ if (m.includes('<ul>')) return m;
1243
+ return `<ol>${m}</ol>`;
1244
+ });
1245
+ s = s.split(/\n{2,}/).map(p => {
1246
+ p = p.trim();
1247
+ if (!p) return '';
1248
+ if (/^<(h[123]|ul|ol|pre|div)/.test(p)) return p;
1249
+ return `<p>${p.replace(/\n/g,'<br>')}</p>`;
1250
+ }).join('');
1251
+ return s;
1252
+ }
1253
+
1254
+ // ── Scroll ─────────────────────────────────────────────────────────────────
1255
+ $messages.addEventListener('scroll', () => {
1256
+ const near = $messages.scrollHeight - $messages.scrollTop - $messages.clientHeight < 80;
1257
+ state.scrollLocked = !near;
1258
+ });
1259
+ function scrollBottom() {
1260
+ if (!state.scrollLocked) $messages.scrollTop = $messages.scrollHeight;
1261
+ }
1262
+
1263
+ // ── Copy code blocks ───────────────────────────────────────────────────────
1264
+ $messages.addEventListener('click', e => {
1265
+ if (!e.target.classList.contains('copy-btn')) return;
1266
+ const code = e.target.nextElementSibling?.querySelector('code')?.textContent ?? '';
1267
+ navigator.clipboard.writeText(code).then(() => {
1268
+ e.target.textContent = 'Copied!';
1269
+ e.target.classList.add('copied');
1270
+ setTimeout(() => { e.target.textContent = 'Copy'; e.target.classList.remove('copied'); }, 2000);
1271
+ }).catch(() => {});
1272
+ });
1273
+
1274
+ $emptyState.addEventListener('click', e => {
1275
+ if (!e.target.classList.contains('suggestion-chip')) return;
1276
+ $input.value = e.target.textContent + ' ';
1277
+ $input.focus();
1278
+ autoResize();
1279
+ });
1280
+
1281
+ // ── Empty state ────────────────────────────────────────────────────────────
1282
+ function hideEmptyState() {
1283
+ if (!state.hasMessages) {
1284
+ state.hasMessages = true;
1285
+ $emptyState.classList.add('hidden');
1286
+ }
1287
+ }
1288
+
1289
+ // ── Message building ───────────────────────────────────────────────────────
1290
+ function makeMsgRow(role, bubble) {
1291
+ const row = document.createElement('div');
1292
+ row.className = `msg-row ${role}`;
1293
+ const inner = document.createElement('div');
1294
+ inner.className = 'msg-inner';
1295
+ const avatar = document.createElement('div');
1296
+ avatar.className = `msg-avatar ${role === 'user' ? 'user-avatar' : 'ai-avatar'}`;
1297
+ avatar.textContent = role === 'user' ? 'U' : 'AI';
1298
+ const wrap = document.createElement('div');
1299
+ wrap.className = 'msg-bubble-wrap';
1300
+ const ts = document.createElement('div');
1301
+ ts.className = 'msg-timestamp';
1302
+ ts.textContent = 'JUST NOW';
1303
+ wrap.appendChild(bubble);
1304
+ wrap.appendChild(ts);
1305
+ inner.appendChild(avatar);
1306
+ inner.appendChild(wrap);
1307
+ row.appendChild(inner);
1308
+ return row;
1309
+ }
1310
+ function addMessage(role, html) {
1311
+ hideEmptyState();
1312
+ const bubble = document.createElement('div');
1313
+ bubble.className = 'bubble';
1314
+ bubble.innerHTML = html;
1315
+ const row = makeMsgRow(role, bubble);
1316
+ $messages.appendChild(row);
1317
+ scrollBottom();
1318
+ return bubble;
1319
+ }
1320
+ function addUserMessage(text) {
1321
+ addMessage('user', `<p>${esc(text).replace(/\n/g,'<br>')}</p>`);
1322
+ }
1323
+ function startAssistantBubble() {
1324
+ hideEmptyState();
1325
+ const bubble = document.createElement('div');
1326
+ bubble.className = 'bubble';
1327
+ const row = makeMsgRow('assistant', bubble);
1328
+ $messages.appendChild(row);
1329
+ scrollBottom();
1330
+ return bubble;
1331
+ }
1332
+ function showSkeleton() {
1333
+ removeSkeleton();
1334
+ const row = document.createElement('div');
1335
+ row.className = 'msg-row assistant';
1336
+ row.id = 'skeleton-row';
1337
+ row.innerHTML = '<div class="thinking-bubble"><span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span></div>';
1338
+ $messages.appendChild(row);
1339
+ scrollBottom();
1340
+ }
1341
+ function removeSkeleton() {
1342
+ const s = $('skeleton-row');
1343
+ if (s) s.remove();
1344
+ }
1345
+ function addModelSwitchPill(from, to) {
1346
+ const pill = document.createElement('div');
1347
+ pill.className = 'model-switch-pill';
1348
+ pill.innerHTML = `<svg viewBox="0 0 10 10" fill="currentColor" width="10" height="10"><path d="M5 0l1.5 3h-3L5 0zm0 10L3.5 7h3L5 10zM0 5l3-1.5v3L0 5zm10 0L7 6.5v-3L10 5z"/></svg>${esc(from)} &rarr; ${esc(to)}`;
1349
+ $messages.appendChild(pill);
1350
+ scrollBottom();
1351
+ }
1352
+
1353
+ // ── Live events panel ──────────────────────────────────────────────────────
1354
+ const dotClass = { 'evt-model':'#7eb8f7','evt-tool':'#f6c26b','evt-result':'#5ed98e','evt-switch':'#63d4e8','evt-done':'#a78bfa','evt-error':'#f87171','evt-conn':'#5ed98e' };
1355
+ function addLiveEvent(cssClass, typeLabel, detail) {
1356
+ state.liveEventCount++;
1357
+ const dotColor = dotClass[cssClass] || '#5a6b7e';
1358
+ const el = document.createElement('div');
1359
+ el.className = 'live-event';
1360
+ el.innerHTML = `<svg width="6" height="6" viewBox="0 0 6 6" style="flex-shrink:0;margin-top:4px"><circle cx="3" cy="3" r="3" fill="${dotColor}"/></svg><span class="live-event-time">${fmtTime()}</span><div class="live-event-body"><div class="live-event-type ${cssClass}">${esc(typeLabel)}</div><div class="live-event-detail">${esc(String(detail).slice(0,80))}</div></div>`;
1361
+ $liveList.appendChild(el);
1362
+ while ($liveList.children.length > 80) $liveList.removeChild($liveList.firstChild);
1363
+ $liveList.scrollTop = $liveList.scrollHeight;
1364
+ }
1365
+
1366
+ $('live-panel-toggle').addEventListener('click', () => {
1367
+ $livePanel.classList.toggle('collapsed');
1368
+ });
1369
+
1370
+ // ── Perf tracking ──────────────────────────────────────────────────────────
1371
+ function updatePerf() {
1372
+ const now = Date.now();
1373
+ if (state.perfStreamStart && state.perfTokenCount > 0) {
1374
+ const elapsed = (now - state.perfStreamStart) / 1000;
1375
+ const tps = elapsed > 0 ? Math.round(state.perfTokenCount / elapsed) : 0;
1376
+ const tpsMax = 80;
1377
+ $('perf-tps').textContent = tps + '/s';
1378
+ $('perf-tps-bar').style.width = Math.min(100, (tps / tpsMax) * 100) + '%';
1379
+ }
1380
+ if (state.perfTotalTokens) {
1381
+ const ctx = state.perfTotalTokens;
1382
+ const ctxMax = 8192;
1383
+ $('perf-ctx').textContent = ctx > 999 ? (ctx/1000).toFixed(1)+'k' : String(ctx);
1384
+ $('perf-ctx-bar').style.width = Math.min(100, (ctx / ctxMax) * 100) + '%';
1385
+ $('perf-tokens').textContent = ctx > 999 ? (ctx/1000).toFixed(1)+'k' : String(ctx);
1386
+ }
1387
+ if (state.perfLastLatency) {
1388
+ $('perf-latency').textContent = state.perfLastLatency + 'ms';
1389
+ }
1390
+ }
1391
+
1392
+ // ── WebSocket ──────────────────────────────────────────────────────────────
1393
+ function connect() {
1394
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1395
+ try { state.ws = new WebSocket(`${proto}//${location.host}/api/chat?token=${encodeURIComponent(AUTH_TOKEN)}`); }
1396
+ catch { scheduleReconnect(); return; }
1397
+ setWsDot('');
1398
+
1399
+ state.ws.onopen = () => {
1400
+ state.wsReady = true;
1401
+ state.reconnectDelay = 1000;
1402
+ setWsDot('connected');
1403
+ setStatus('', '');
1404
+ addLiveEvent('evt-conn', 'ws_connected', location.host);
1405
+ };
1406
+ state.ws.onclose = () => {
1407
+ state.wsReady = false;
1408
+ setWsDot('error');
1409
+ if (state.streaming) {
1410
+ state.streaming = false;
1411
+ if (state.streamingBubble) state.streamingBubble.classList.remove('streaming');
1412
+ state.streamingBubble = null;
1413
+ state.streamingToolPills = {};
1414
+ accText = '';
1415
+ setSendEnabled(true);
1416
+ }
1417
+ scheduleReconnect();
1418
+ };
1419
+ state.ws.onerror = () => { state.wsReady = false; setWsDot('error'); };
1420
+ state.ws.onmessage = evt => {
1421
+ let chunk;
1422
+ try { chunk = JSON.parse(evt.data); } catch { return; }
1423
+ handleChunk(chunk);
1424
+ };
1425
+ }
1426
+
1427
+ function scheduleReconnect() {
1428
+ setStatus(`Reconnecting in ${state.reconnectDelay / 1000}s…`, 'muted');
1429
+ setTimeout(connect, state.reconnectDelay);
1430
+ state.reconnectDelay = Math.min(state.reconnectDelay * 2, 16000);
1431
+ }
1432
+ function setWsDot(cls) {
1433
+ $wsDot.className = cls;
1434
+ $wsDot.title = cls === 'connected' ? 'Connected' : cls === 'error' ? 'Disconnected' : 'Connecting…';
1435
+ const bbDot = $('bb-conn-dot');
1436
+ const bbConn = $('bb-conn');
1437
+ if (bbDot && bbConn) {
1438
+ bbDot.style.background = cls === 'connected' ? '#22c55e' : cls === 'error' ? '#ef4444' : '#f59e0b';
1439
+ bbConn.textContent = cls === 'connected' ? 'Connected' : cls === 'error' ? 'Disconnected' : 'Connecting';
1440
+ }
1441
+ }
1442
+
1443
+ // ── Chunk handler ──────────────────────────────────────────────────────────
1444
+ function handleChunk(chunk) {
1445
+ if (chunk.type === 'text') {
1446
+ removeSkeleton();
1447
+ if (!state.streamingBubble) {
1448
+ state.streamingBubble = startAssistantBubble();
1449
+ accText = '';
1450
+ state.perfStreamStart = Date.now();
1451
+ state.perfTokenCount = 0;
1452
+ }
1453
+ accText += chunk.delta;
1454
+ state.perfTokenCount += chunk.delta.length / 4; // approx tokens
1455
+ state.streamingBubble.innerHTML = renderMarkdown(accText);
1456
+ state.streamingBubble.classList.add('streaming');
1457
+ scrollBottom();
1458
+ updatePerf();
1459
+ return;
1460
+ }
1461
+ if (chunk.type === 'tool_call') {
1462
+ removeSkeleton();
1463
+ if (!state.streamingBubble) {
1464
+ state.streamingBubble = startAssistantBubble();
1465
+ accText = '';
1466
+ }
1467
+ const pill = document.createElement('div');
1468
+ pill.className = 'tool-call-pill';
1469
+ pill.id = `tc-${chunk.id}`;
1470
+ pill.innerHTML = `<span class="tool-spinner"></span><span>${esc(chunk.name)}</span>`;
1471
+ state.streamingBubble.appendChild(document.createElement('br'));
1472
+ state.streamingBubble.appendChild(pill);
1473
+ state.streamingToolPills[chunk.id] = pill;
1474
+ addLiveEvent('evt-tool', 'tool_called', chunk.name + (chunk.arguments ? ' ' + JSON.stringify(chunk.arguments).slice(0,60) : ''));
1475
+ scrollBottom();
1476
+ return;
1477
+ }
1478
+ if (chunk.type === 'tool_result') {
1479
+ const pill = state.streamingToolPills[chunk.id];
1480
+ if (pill) {
1481
+ pill.className = 'tool-call-pill done';
1482
+ pill.innerHTML = `<svg class="done-check" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1.5 5l2.5 2.5 4.5-4.5"/></svg><span>${esc(chunk.name)}</span>`;
1483
+ const wrap = document.createElement('div');
1484
+ wrap.className = 'tool-result-wrap';
1485
+ const toggle = document.createElement('div');
1486
+ toggle.className = 'tool-result-toggle';
1487
+ toggle.innerHTML = `<svg class="toggle-chevron" viewBox="0 0 10 10" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 2l4 3-4 3"/></svg> result`;
1488
+ const body = document.createElement('div');
1489
+ body.className = 'tool-result-body';
1490
+ body.textContent = typeof chunk.result === 'string' ? chunk.result : JSON.stringify(chunk.result, null, 2);
1491
+ toggle.addEventListener('click', () => { body.classList.toggle('open'); toggle.classList.toggle('open'); });
1492
+ wrap.appendChild(toggle);
1493
+ wrap.appendChild(body);
1494
+ pill.after(wrap);
1495
+ }
1496
+ delete state.streamingToolPills[chunk.id];
1497
+ addLiveEvent('evt-result', 'tool_result', chunk.name);
1498
+ if (state.streaming) showSkeleton();
1499
+ return;
1500
+ }
1501
+ if (chunk.type === 'model_switch') {
1502
+ updateModelDisplay(chunk.to);
1503
+ addModelSwitchPill(chunk.from, chunk.to);
1504
+ addLiveEvent('evt-switch', 'model_selected', chunk.from + ' → ' + chunk.to);
1505
+ return;
1506
+ }
1507
+ if (chunk.type === 'done') {
1508
+ state.streaming = false;
1509
+ if (state.streamingBubble) state.streamingBubble.classList.remove('streaming');
1510
+ state.streamingBubble = null;
1511
+ state.streamingToolPills = {};
1512
+ accText = '';
1513
+ setSendEnabled(true);
1514
+ const $sb2 = $('streaming-badge');
1515
+ if ($sb2) $sb2.classList.remove('active');
1516
+ if (chunk.usage) {
1517
+ state.perfTotalTokens = (chunk.usage.input || 0) + (chunk.usage.output || 0);
1518
+ const row = $messages.lastElementChild;
1519
+ if (row?.classList.contains('msg-row')) {
1520
+ const u = document.createElement('div');
1521
+ u.className = 'usage-line';
1522
+ u.textContent = `${chunk.usage.input} in / ${chunk.usage.output} out`;
1523
+ row.appendChild(u);
1524
+ }
1525
+ addLiveEvent('evt-done', 'done', `in:${chunk.usage.input} out:${chunk.usage.output}`);
1526
+ // update bottom bar tokens
1527
+ const total = state.perfTotalTokens;
1528
+ const bbTok = $('bb-tokens');
1529
+ if (bbTok) bbTok.textContent = total > 999 ? (total/1000).toFixed(1)+'k' : String(total);
1530
+ const tc = $('token-count');
1531
+ if (tc) tc.textContent = total + ' tokens';
1532
+ }
1533
+ updatePerf();
1534
+ setStatus('', '');
1535
+ scrollBottom();
1536
+ return;
1537
+ }
1538
+ if (chunk.type === 'error') {
1539
+ removeSkeleton();
1540
+ state.streaming = false;
1541
+ if (state.streamingBubble) state.streamingBubble.classList.remove('streaming');
1542
+ state.streamingBubble = null;
1543
+ setSendEnabled(true);
1544
+ const $sb3 = $('streaming-badge');
1545
+ if ($sb3) $sb3.classList.remove('active');
1546
+ accText = '';
1547
+ addMessage('assistant', `<p style="color:var(--red)">${esc(chunk.message)}</p>`);
1548
+ addLiveEvent('evt-error', 'error', chunk.message.slice(0,80));
1549
+ setStatus('', '');
1550
+ return;
1551
+ }
1552
+ if (chunk.type === 'profile_changed') {
1553
+ state.activeProfile = chunk.name;
1554
+ updateProfileUI();
1555
+ toast(`Profile: ${chunk.name}`);
1556
+ return;
1557
+ }
1558
+ }
1559
+
1560
+ // ── Send ───────────────────────────────────────────────────────────────────
1561
+ function send() {
1562
+ const text = $input.value.trim();
1563
+ if (!text || state.streaming || !state.wsReady) return;
1564
+ addUserMessage(text);
1565
+ $input.value = '';
1566
+ $input.style.height = 'auto';
1567
+ state.streaming = true;
1568
+ state.streamingBubble = null;
1569
+ state.streamingToolPills = {};
1570
+ state.perfTokenCount = 0;
1571
+ state.perfStreamStart = 0;
1572
+ accText = '';
1573
+ setSendEnabled(false);
1574
+ showSkeleton();
1575
+ setStatus('Thinking…', 'muted');
1576
+ const $sb = $('streaming-badge');
1577
+ if ($sb) $sb.classList.add('active');
1578
+ state.scrollLocked = false;
1579
+ state.ws.send(JSON.stringify({ type: 'chat', content: text }));
1580
+ addLiveEvent('evt-model', 'user_message', text.slice(0,60) + (text.length > 60 ? '…' : ''));
1581
+ // Estimate latency on first token
1582
+ const sendAt = Date.now();
1583
+ const origHandler = state.ws.onmessage;
1584
+ const latchLatency = evt => {
1585
+ try {
1586
+ const c = JSON.parse(evt.data);
1587
+ if (c.type === 'text' && !state.perfLastLatency) {
1588
+ state.perfLastLatency = Date.now() - sendAt;
1589
+ addLiveEvent('evt-result', 'provider_latency', state.perfLastLatency + 'ms');
1590
+ updatePerf();
1591
+ const bbLat = $('bb-latency');
1592
+ if (bbLat) bbLat.textContent = state.perfLastLatency + 'ms';
1593
+ }
1594
+ } catch {}
1595
+ origHandler(evt);
1596
+ state.ws.onmessage = origHandler; // restore after first text chunk
1597
+ };
1598
+ state.ws.onmessage = latchLatency;
1599
+ }
1600
+
1601
+ function setSendEnabled(v) { $sendBtn.disabled = !v; }
1602
+ function setStatus(msg, _tone) { $status.textContent = msg; }
1603
+
1604
+ // ── Auto-resize textarea ───────────────────────────────────────────────────
1605
+ function autoResize() {
1606
+ $input.style.height = 'auto';
1607
+ $input.style.height = Math.min($input.scrollHeight, 150) + 'px';
1608
+ }
1609
+ $input.addEventListener('input', autoResize);
1610
+ $input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
1611
+ $sendBtn.addEventListener('click', send);
1612
+
1613
+ // ── New session ────────────────────────────────────────────────────────────
1614
+ function newChat() {
1615
+ if (state.ws) { state.ws.close(); state.ws = null; }
1616
+ state.wsReady = false;
1617
+ state.streaming = false;
1618
+ state.streamingBubble = null;
1619
+ state.streamingToolPills = {};
1620
+ state.hasMessages = false;
1621
+ state.perfTokenCount = 0;
1622
+ state.perfStreamStart = 0;
1623
+ state.perfLastLatency = 0;
1624
+ accText = '';
1625
+ setSendEnabled(true);
1626
+ $messages.innerHTML = '';
1627
+ $messages.appendChild($emptyState);
1628
+ $emptyState.classList.remove('hidden');
1629
+ $liveList.innerHTML = '';
1630
+ connect();
1631
+ switchView('chat');
1632
+ }
1633
+ $('new-session-btn').addEventListener('click', newChat);
1634
+
1635
+ // ── Navigation ─────────────────────────────────────────────────────────────
1636
+ function switchView(name) {
1637
+ state.activeView = name;
1638
+ document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === `view-${name}`));
1639
+ document.querySelectorAll('.nav-item').forEach(item => item.classList.toggle('active', item.dataset.view === name));
1640
+ // Close mobile nav
1641
+ $nav.classList.remove('open');
1642
+ $overlay.classList.remove('open');
1643
+ // Lazy-load data for each view
1644
+ if (name === 'memory') loadVaultMemories();
1645
+ if (name === 'research') loadResearchMemories();
1646
+ if (name === 'settings') loadSettingsData();
1647
+ }
1648
+ document.querySelectorAll('.nav-item').forEach(item => {
1649
+ item.addEventListener('click', () => switchView(item.dataset.view));
1650
+ });
1651
+
1652
+ // ── Profile ────────────────────────────────────────────────────────────────
1653
+ function switchProfile(name) {
1654
+ if (!state.wsReady) { toast('Not connected'); return; }
1655
+ state.ws.send(JSON.stringify({ type: 'profile', name }));
1656
+ fetch('/api/profile', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name}) })
1657
+ .then(() => { state.activeProfile = name; updateProfileUI(); })
1658
+ .catch(() => {});
1659
+ }
1660
+ function updateProfileUI() {
1661
+ $profileTabs.querySelectorAll('.profile-tab').forEach(btn => {
1662
+ btn.classList.toggle('active', btn.dataset.profile === state.activeProfile);
1663
+ });
1664
+ }
1665
+
1666
+ // ── Model display ───────────────────────────────────────────────────────────
1667
+ function updateModelDisplay(model, fromPoll) {
1668
+ if (model === state.currentModel) return;
1669
+ const prev = state.currentModel;
1670
+ state.currentModel = model;
1671
+ $wsModelLbl.textContent = model ? model : '';
1672
+ // only log to live events on actual switches, not on initial poll
1673
+ if (!fromPoll || prev === '') {
1674
+ addLiveEvent('evt-model', 'model_selected', model);
1675
+ }
1676
+ }
1677
+
1678
+ // ── Provider info ────────────────────────────────────────────────────────────
1679
+ function loadProvider() {
1680
+ fetch('/api/provider').then(r => r.json()).then(data => {
1681
+ updateModelDisplay(data.model, true);
1682
+ $emptyProv.textContent = `Powered by ${data.name} / ${data.model}`;
1683
+ // Nav status
1684
+ const provName = data.name || 'Provider';
1685
+ $('provider-label').textContent = provName;
1686
+ $('provider-status-val').textContent = data.model ? data.model.slice(0,12) : '—';
1687
+ $('provider-dot').className = 'status-dot ok';
1688
+ // Settings hw
1689
+ $('hw-provider-name').textContent = data.name;
1690
+ $('hw-tools').textContent = data.supportsToolUse ? 'Yes' : 'No';
1691
+ $('hw-streaming').textContent = 'Yes';
1692
+ }).catch(() => { $('provider-dot').className = 'status-dot err'; });
1693
+ }
1694
+
1695
+ function loadHealth() {
1696
+ fetch('/api/health').then(r => r.json()).then(data => {
1697
+ const dot = $('ollama-dot');
1698
+ dot.className = 'status-dot ' + (data.ok ? 'ok' : 'err');
1699
+ $('ollama-status').textContent = data.latencyMs ? data.latencyMs + 'ms' : (data.ok ? 'ok' : 'err');
1700
+ }).catch(() => {
1701
+ $('ollama-dot').className = 'status-dot err';
1702
+ $('ollama-status').textContent = 'offline';
1703
+ });
1704
+ }
1705
+
1706
+ // ── Profiles ───────────────────────────────────────────────────────────────
1707
+ function loadProfiles() {
1708
+ fetch('/api/profile').then(r => r.json()).then(data => {
1709
+ state.activeProfile = data.name;
1710
+ $profileTabs.innerHTML = '';
1711
+ Object.entries(data.all).forEach(([key, cfg]) => {
1712
+ const btn = document.createElement('button');
1713
+ btn.className = 'profile-tab' + (key === data.name ? ' active' : '');
1714
+ btn.dataset.profile = key;
1715
+ btn.textContent = cfg.name;
1716
+ btn.title = cfg.description || '';
1717
+ btn.addEventListener('click', () => switchProfile(key));
1718
+ $profileTabs.appendChild(btn);
1719
+ });
1720
+ }).catch(() => {});
1721
+ }
1722
+
1723
+ // ── Vault / Memory view ────────────────────────────────────────────────────
1724
+ function loadVaultMemories(q) {
1725
+ const url = q ? `/api/memories?q=${encodeURIComponent(q)}` : '/api/memories';
1726
+ fetch(url).then(r => r.json()).then(mems => {
1727
+ const list = $('vault-list');
1728
+ list.innerHTML = '';
1729
+ $('vault-total').textContent = mems.length;
1730
+ if (!mems.length) {
1731
+ list.innerHTML = '<div style="padding:24px;text-align:center;font-size:12px;color:var(--text-muted)">No memories yet.</div>';
1732
+ return;
1733
+ }
1734
+ const totalLen = mems.reduce((acc, m) => acc + (m.content?.length || 0), 0);
1735
+ $('vault-avg-len').textContent = mems.length ? Math.round(totalLen / mems.length) : '—';
1736
+ $('vault-max').textContent = '∞';
1737
+ mems.forEach((m, i) => {
1738
+ const el = document.createElement('div');
1739
+ el.className = 'vault-item';
1740
+ const relevance = q ? ((1 - i * 0.05).toFixed(2)) : '1.00';
1741
+ const time = m.createdAt ? relativeTime(m.createdAt) : '—';
1742
+ el.innerHTML = `<span class="vault-relevance">${relevance}</span><div class="vault-item-body"><div class="vault-item-type">${esc(m.type)}</div><div class="vault-item-text">${esc((m.content||'').slice(0,80))}${(m.content||'').length>80?'…':''}</div><div class="vault-item-time">${time}</div></div>`;
1743
+ el.addEventListener('click', () => {
1744
+ if (state.activeView !== 'chat') switchView('chat');
1745
+ $input.value += (($input.value && !$input.value.endsWith(' ')) ? ' ' : '') + (m.content||'');
1746
+ $input.focus(); autoResize();
1747
+ });
1748
+ list.appendChild(el);
1749
+ });
1750
+ // Update vector topology
1751
+ const topoSvg = $('vault-topo-svg');
1752
+ if (topoSvg) buildGraphSvg(topoSvg, mems, 230, 120);
1753
+ // Estimate disk usage
1754
+ const totalChars = mems.reduce((a, m) => a + (m.content?.length || 0), 0);
1755
+ const diskEl = $('vault-disk');
1756
+ if (diskEl) diskEl.textContent = Math.round(totalChars / 1024) || '<1';
1757
+ }).catch(() => {});
1758
+ }
1759
+
1760
+ let vaultSearchTimer;
1761
+ $('vault-search').addEventListener('input', e => {
1762
+ clearTimeout(vaultSearchTimer);
1763
+ vaultSearchTimer = setTimeout(() => loadVaultMemories(e.target.value.trim() || null), 280);
1764
+ });
1765
+
1766
+ document.querySelectorAll('.engine-btn').forEach(btn => {
1767
+ btn.addEventListener('click', () => {
1768
+ document.querySelectorAll('.engine-btn').forEach(b => b.classList.remove('active'));
1769
+ btn.classList.add('active');
1770
+ state.vaultEngine = btn.dataset.engine;
1771
+ $('vault-engine').textContent = btn.dataset.engine;
1772
+ });
1773
+ });
1774
+
1775
+ window.exportMemories = function() {
1776
+ fetch('/api/memories').then(r => r.json()).then(mems => {
1777
+ const blob = new Blob([JSON.stringify(mems, null, 2)], {type: 'application/json'});
1778
+ const a = document.createElement('a');
1779
+ a.href = URL.createObjectURL(blob);
1780
+ a.download = 'memories.json';
1781
+ a.click();
1782
+ toast('Exported memories.json');
1783
+ }).catch(() => toast('Export failed'));
1784
+ };
1785
+
1786
+ // ── Research view ──────────────────────────────────────────────────────────
1787
+ function loadResearchMemories() {
1788
+ fetch('/api/memories').then(r => r.json()).then(mems => {
1789
+ const list = $('research-mem-list');
1790
+ list.innerHTML = '';
1791
+ if (!mems.length) {
1792
+ list.innerHTML = '<div style="padding:16px;font-size:12px;color:var(--text-muted)">No memories yet.</div>';
1793
+ return;
1794
+ }
1795
+ mems.slice(0, 20).forEach(m => {
1796
+ const el = document.createElement('div');
1797
+ el.className = 'research-mem-item';
1798
+ const time = m.createdAt ? new Date(m.createdAt).toLocaleDateString() : '';
1799
+ const engineBadge = state.vaultEngine === 'M10'
1800
+ ? '<span class="r-badge semantic">SEMANTIC</span>'
1801
+ : '<span class="r-badge like">LIKE SEARCH</span>';
1802
+ el.innerHTML = `<div class="research-mem-badges">${engineBadge}<span class="r-badge type">${esc(m.type)}</span></div><div class="research-mem-text">${esc((m.content||'').slice(0,70))}${(m.content||'').length>70?'…':''}</div><div class="research-mem-time">${time}</div>`;
1803
+ el.addEventListener('click', () => {
1804
+ switchView('chat');
1805
+ $input.value += (($input.value && !$input.value.endsWith(' ')) ? ' ' : '') + (m.content||'');
1806
+ $input.focus(); autoResize();
1807
+ });
1808
+ list.appendChild(el);
1809
+ });
1810
+ // Update knowledge graph
1811
+ const kgSvg = $('kg-svg');
1812
+ if (kgSvg) buildGraphSvg(kgSvg, mems, 260, 120);
1813
+ }).catch(() => {});
1814
+ }
1815
+
1816
+ $('research-search-btn').addEventListener('click', doResearch);
1817
+ $('research-search-input').addEventListener('keydown', e => { if (e.key === 'Enter') doResearch(); });
1818
+
1819
+ function doResearch() {
1820
+ const q = $('research-search-input').value.trim();
1821
+ if (!q) return;
1822
+ const summary = $('research-summary');
1823
+ $('research-empty') && ($('research-empty').style.display = 'none');
1824
+ summary.innerHTML = '<div style="padding:20px;font-size:12px;color:var(--text-muted)">Searching…</div>';
1825
+ // Route to chat for actual research
1826
+ switchView('chat');
1827
+ $input.value = `Research: ${q}`;
1828
+ $input.focus();
1829
+ autoResize();
1830
+ toast(`Switched to chat to research: "${q}"`);
1831
+ }
1832
+
1833
+ // ── Settings data ──────────────────────────────────────────────────────────
1834
+ function loadSettingsData() {
1835
+ loadSystemInfo();
1836
+
1837
+ // Provider cards
1838
+ fetch('/api/provider').then(r => r.json()).then(data => {
1839
+ const container = $('provider-cards');
1840
+ const provName = (data.name || 'ollama').toLowerCase();
1841
+ const providers = [
1842
+ { key: provName, name: data.name || 'Ollama', model: data.model, badge: 'connected', badgeLabel: 'LOCAL CONNECTED',
1843
+ meta: `Tool use: ${data.supportsToolUse ? 'Yes' : 'No'} · Streaming: Yes`,
1844
+ models: [data.model].filter(Boolean) },
1845
+ { key: 'anthropic', name: 'Anthropic', model: 'claude-sonnet-4-6', badge: 'api-linked', badgeLabel: 'API LINKED', meta: 'Set ANTHROPIC_API_KEY', models: [] },
1846
+ { key: 'openai', name: 'OpenAI', model: 'gpt-4o', badge: 'needs-cfg', badgeLabel: 'NEEDS CONFIG', meta: 'Set OPENAI_API_KEY', models: [] },
1847
+ { key: 'gemini', name: 'Gemini', model: 'gemini-2.0-flash', badge: 'needs-cfg', badgeLabel: 'NEEDS CONFIG', meta: 'Set GEMINI_API_KEY', models: [] },
1848
+ ];
1849
+ container.innerHTML = providers.map(p => `
1850
+ <div class="provider-card">
1851
+ <div class="provider-icon ${p.key}">${p.name.slice(0,2).toUpperCase()}</div>
1852
+ <div class="provider-card-body">
1853
+ <div class="provider-card-name">${esc(p.name)}</div>
1854
+ <div class="provider-card-model">${esc(p.model)}</div>
1855
+ <div class="provider-card-meta">${esc(p.meta)}</div>
1856
+ <div style="margin-top:5px;display:flex;flex-wrap:wrap;gap:4px;align-items:center">
1857
+ <span class="prov-badge ${p.badge}">${p.badgeLabel}</span>
1858
+ ${p.models.map(m => `<span class="model-chip"><span class="model-chip-dot"></span>${esc(m)}</span>`).join('')}
1859
+ </div>
1860
+ </div>
1861
+ </div>`).join('');
1862
+ $('hw-primary').textContent = data.model || 'qwen2.5:14b';
1863
+ }).catch(() => {});
1864
+
1865
+ // Tools
1866
+ fetch('/api/stats').then(r => r.json()).then(data => {
1867
+ const tools = data.tools || [];
1868
+ const body = $('tools-registry-body');
1869
+ if (!tools.length) { body.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">No tools registered.</div>'; return; }
1870
+ body.innerHTML = tools.map(t => `
1871
+ <div style="display:flex;gap:10px;padding:6px 0;border-bottom:1px solid var(--border-subtle)">
1872
+ <span style="font-family:ui-monospace,'SF Mono',monospace;font-size:11.5px;color:#c4b5fd;width:130px;flex-shrink:0">${esc(t.name)}</span>
1873
+ <span style="font-size:12px;color:var(--text-dim)">${esc(t.description)}</span>
1874
+ </div>`).join('');
1875
+ }).catch(() => {});
1876
+
1877
+ // Routing from model manager
1878
+ fetch('/api/stats').then(r => r.json()).then(data => {
1879
+ if (data.model?.config?.tasks) {
1880
+ const tasks = data.model.config.tasks;
1881
+ const tbody = $('routing-tbody');
1882
+ if (Object.keys(tasks).length > 0) {
1883
+ tbody.innerHTML = Object.entries(tasks).map(([task, model]) =>
1884
+ `<tr><td class="route-task">${esc(task)}</td><td class="route-model">${esc(String(model))}</td><td class="route-reason">ollama</td><td class="route-reason">Auto-routed</td></tr>`
1885
+ ).join('');
1886
+ }
1887
+ }
1888
+ }).catch(() => {});
1889
+ }
1890
+
1891
+ // ── Knowledge / Topology graph SVG ────────────────────────────────────────
1892
+ const TYPE_COLORS = { fact:'#7eb8f7', preference:'#f6c26b', task:'#5ed98e', note:'#a78bfa', experience:'#f87171', conversation:'#63d4e8' };
1893
+ function buildGraphSvg(svgEl, nodes, w, h) {
1894
+ if (!svgEl) return;
1895
+ if (!nodes || !nodes.length) { svgEl.innerHTML = `<text x="${w/2}" y="${h/2}" text-anchor="middle" font-size="10" fill="#3a4d5e">No memories yet</text>`; return; }
1896
+ const n = Math.min(nodes.length, 14);
1897
+ const pts = [];
1898
+ for (let i = 0; i < n; i++) {
1899
+ const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
1900
+ const r = Math.min(w, h) * 0.36;
1901
+ pts.push({ x: w/2 + r * Math.cos(angle), y: h/2 + r * Math.sin(angle), m: nodes[i] });
1902
+ }
1903
+ let svg = '';
1904
+ // Edges between adjacent nodes
1905
+ for (let i = 0; i < pts.length; i++) {
1906
+ const j = (i + 1) % pts.length;
1907
+ svg += `<line x1="${pts[i].x.toFixed(1)}" y1="${pts[i].y.toFixed(1)}" x2="${pts[j].x.toFixed(1)}" y2="${pts[j].y.toFixed(1)}" stroke="#1e2d3d" stroke-width="1"/>`;
1908
+ }
1909
+ if (pts.length > 4) {
1910
+ const mid = Math.floor(pts.length / 2);
1911
+ svg += `<line x1="${pts[0].x.toFixed(1)}" y1="${pts[0].y.toFixed(1)}" x2="${pts[mid].x.toFixed(1)}" y2="${pts[mid].y.toFixed(1)}" stroke="#1e2d3d" stroke-width="0.5" stroke-dasharray="2,3"/>`;
1912
+ }
1913
+ pts.forEach(({ x, y, m }) => {
1914
+ const col = TYPE_COLORS[m.type] || '#5a6b7e';
1915
+ svg += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="5.5" fill="${col}" fill-opacity="0.85" stroke="${col}" stroke-width="1"/>`;
1916
+ });
1917
+ svgEl.innerHTML = svg;
1918
+ }
1919
+
1920
+ // ── GPU utilization ────────────────────────────────────────────────────────
1921
+ function loadGpuUtil() {
1922
+ fetch('/api/ollama/ps').then(r => r.json()).then(data => {
1923
+ const models = data.models || [];
1924
+ const totalVram = models.reduce((s, m) => s + (m.size_vram || 0), 0);
1925
+ const totalSize = models.reduce((s, m) => s + (m.size || 0), 0);
1926
+ const pct = totalSize > 0 ? Math.round((totalVram / totalSize) * 100) : 0;
1927
+ const mb = Math.round(totalVram / 1024 / 1024);
1928
+ const disp = mb > 1024 ? (mb / 1024).toFixed(1) + ' GB' : mb + ' MB';
1929
+ const el = $('gpu-util-val');
1930
+ const bar = $('gpu-util-bar');
1931
+ if (el) el.textContent = pct + '%';
1932
+ if (bar) bar.style.width = pct + '%';
1933
+ const card = $('gpu-card');
1934
+ if (card && mb > 0) card.title = disp + ' VRAM in use';
1935
+ }).catch(() => {});
1936
+ }
1937
+
1938
+ // ── System info ────────────────────────────────────────────────────────────
1939
+ function loadSystemInfo() {
1940
+ fetch('/api/system').then(r => r.json()).then(d => {
1941
+ const ramGB = (d.totalMemMb / 1024).toFixed(0);
1942
+ const usedGB = (d.usedMemMb / 1024).toFixed(1);
1943
+ const ramEl = $('hw-ram');
1944
+ if (ramEl) ramEl.textContent = ramGB + ' GB';
1945
+ const usedEl = $('hw-ram-used');
1946
+ if (usedEl) usedEl.textContent = usedGB + ' GB used (' + d.usedMemPct + '%)';
1947
+ const barEl = $('hw-ram-bar');
1948
+ if (barEl) barEl.style.width = d.usedMemPct + '%';
1949
+ const cpuMod = $('hw-cpu-model');
1950
+ if (cpuMod) cpuMod.textContent = (d.cpuModel || '').replace(/@.*/, '').trim().slice(0, 35);
1951
+ const cpuLoad = $('hw-cpu-load');
1952
+ if (cpuLoad) cpuLoad.textContent = d.loadAvg1m.toFixed(2);
1953
+ const thermal = $('hw-thermal');
1954
+ if (thermal) {
1955
+ const lv = d.loadAvg1m < 1.5 ? ['NOMINAL','#22c55e'] : d.loadAvg1m < 3.5 ? ['WARM','#f59e0b'] : ['HIGH','#ef4444'];
1956
+ thermal.textContent = lv[0];
1957
+ thermal.style.color = lv[1];
1958
+ }
1959
+ const swap = $('hw-swap');
1960
+ if (swap) {
1961
+ const freePct = Math.round((d.freeMemMb / d.totalMemMb) * 100);
1962
+ swap.textContent = freePct > 30 ? 'LOW' : freePct > 10 ? 'MED' : 'HIGH';
1963
+ swap.style.color = freePct > 30 ? '#22c55e' : freePct > 10 ? '#f59e0b' : '#ef4444';
1964
+ }
1965
+ }).catch(() => {});
1966
+ }
1967
+
1968
+ // ── Code tabs ──────────────────────────────────────────────────────────────
1969
+ const codeTabs = [{ name: 'assistant.ts', active: true }, { name: 'context.ts', active: false }];
1970
+ function renderCodeTabs() {
1971
+ const bar = $('code-tabs-bar');
1972
+ if (!bar) return;
1973
+ bar.innerHTML = codeTabs.map((t, i) => `<div class="code-tab${t.active ? ' active' : ''}" data-tab="${i}"><span>${esc(t.name)}</span><button class="code-tab-close" data-close="${i}">×</button></div>`).join('');
1974
+ bar.querySelectorAll('.code-tab').forEach(el => {
1975
+ el.addEventListener('click', e => {
1976
+ if (e.target.classList.contains('code-tab-close')) return;
1977
+ const idx = parseInt(el.dataset.tab, 10);
1978
+ codeTabs.forEach((t, i) => { t.active = i === idx; });
1979
+ const af = $('code-active-file');
1980
+ if (af) af.textContent = codeTabs[idx]?.name || '';
1981
+ renderCodeTabs();
1982
+ });
1983
+ });
1984
+ bar.querySelectorAll('.code-tab-close').forEach(el => {
1985
+ el.addEventListener('click', e => {
1986
+ e.stopPropagation();
1987
+ const idx = parseInt(el.dataset.close, 10);
1988
+ codeTabs.splice(idx, 1);
1989
+ if (codeTabs.length > 0 && !codeTabs.some(t => t.active)) codeTabs[0].active = true;
1990
+ renderCodeTabs();
1991
+ });
1992
+ });
1993
+ }
1994
+
1995
+ // ── Line numbers ───────────────────────────────────────────────────────────
1996
+ function updateLineNumbers() {
1997
+ const ta = $('code-editor-area');
1998
+ const ln = $('line-numbers');
1999
+ if (!ta || !ln) return;
2000
+ const n = ta.value.split('\n').length;
2001
+ ln.textContent = Array.from({ length: n }, (_, i) => i + 1).join('\n');
2002
+ ln.scrollTop = ta.scrollTop;
2003
+ }
2004
+
2005
+ window.confirmPrune = function() {
2006
+ if (confirm('Prune memories older than 30 days? Archived entries will be hidden but not deleted.')) {
2007
+ toast('Prune complete (archive-only mode — nothing deleted).');
2008
+ }
2009
+ };
2010
+
2011
+ // ── Mobile ─────────────────────────────────────────────────────────────────
2012
+ $hamburger.addEventListener('click', () => { $nav.classList.toggle('open'); $overlay.classList.toggle('open'); });
2013
+ $overlay.addEventListener('click', () => { $nav.classList.remove('open'); $overlay.classList.remove('open'); });
2014
+
2015
+ // Code tree file click → open tab
2016
+ document.querySelectorAll('.tree-file').forEach(el => {
2017
+ el.addEventListener('click', () => {
2018
+ const name = el.childNodes[0]?.textContent?.trim() || el.textContent.trim().split(' ')[0];
2019
+ if (!codeTabs.find(t => t.name === name)) {
2020
+ codeTabs.forEach(t => { t.active = false; });
2021
+ codeTabs.push({ name, active: true });
2022
+ } else {
2023
+ codeTabs.forEach(t => { t.active = t.name === name; });
2024
+ }
2025
+ const af = $('code-active-file');
2026
+ if (af) af.textContent = name;
2027
+ renderCodeTabs();
2028
+ });
2029
+ });
2030
+
2031
+ // Code editor line numbers
2032
+ const $codeArea = $('code-editor-area');
2033
+ if ($codeArea) {
2034
+ $codeArea.addEventListener('input', updateLineNumbers);
2035
+ $codeArea.addEventListener('scroll', () => {
2036
+ const ln = $('line-numbers');
2037
+ if (ln) ln.scrollTop = $codeArea.scrollTop;
2038
+ });
2039
+ updateLineNumbers();
2040
+ }
2041
+
2042
+ // ── Init ──────────────────────────────────────────────────────────────────
2043
+ renderCodeTabs();
2044
+ connect();
2045
+ loadProvider();
2046
+ loadProfiles();
2047
+ loadHealth();
2048
+ loadGpuUtil();
2049
+ loadSystemInfo();
2050
+
2051
+ setInterval(() => {
2052
+ loadProvider();
2053
+ loadHealth();
2054
+ loadGpuUtil();
2055
+ }, 30000);
2056
+
2057
+ // Code quick action buttons
2058
+ const codeActions = [
2059
+ { label: 'Explain this code', prompt: 'Explain this code: ' },
2060
+ { label: 'Find bugs', prompt: 'Find bugs in: ' },
2061
+ { label: 'Optimize', prompt: 'Optimize this: ' },
2062
+ { label: 'Add types', prompt: 'Add TypeScript types: ' },
2063
+ ];
2064
+ const $cqa = $('code-quick-actions');
2065
+ codeActions.forEach(a => {
2066
+ const btn = document.createElement('button');
2067
+ btn.className = 'vault-op-btn';
2068
+ btn.innerHTML = `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 4l5 3-5 3V4zM9 7h4"/></svg>${esc(a.label)}`;
2069
+ btn.addEventListener('click', () => {
2070
+ const code = $('code-editor-area').value.trim();
2071
+ switchView('chat');
2072
+ $input.value = a.prompt + (code ? '\n```\n' + code.slice(0,500) + '\n```' : '');
2073
+ $input.focus(); autoResize();
2074
+ });
2075
+ $cqa.appendChild(btn);
2076
+ });
2077
+
2078
+ })();
2079
+ </script>
2080
+ </body>
2081
+ </html>