@auxiora/dashboard 1.0.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 (97) hide show
  1. package/LICENSE +191 -0
  2. package/dist/auth.d.ts +13 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +69 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/cloud-types.d.ts +71 -0
  7. package/dist/cloud-types.d.ts.map +1 -0
  8. package/dist/cloud-types.js +2 -0
  9. package/dist/cloud-types.js.map +1 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +4 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/router.d.ts +13 -0
  15. package/dist/router.d.ts.map +1 -0
  16. package/dist/router.js +2250 -0
  17. package/dist/router.js.map +1 -0
  18. package/dist/types.d.ts +314 -0
  19. package/dist/types.d.ts.map +1 -0
  20. package/dist/types.js +7 -0
  21. package/dist/types.js.map +1 -0
  22. package/dist-ui/assets/index-BfY0i5jw.css +1 -0
  23. package/dist-ui/assets/index-CXpk9mvw.js +60 -0
  24. package/dist-ui/icon.svg +59 -0
  25. package/dist-ui/index.html +20 -0
  26. package/package.json +32 -0
  27. package/src/auth.ts +83 -0
  28. package/src/cloud-types.ts +63 -0
  29. package/src/index.ts +5 -0
  30. package/src/router.ts +2494 -0
  31. package/src/types.ts +269 -0
  32. package/tests/auth.test.ts +51 -0
  33. package/tests/cloud-router.test.ts +249 -0
  34. package/tests/desktop-router.test.ts +151 -0
  35. package/tests/router.test.ts +388 -0
  36. package/tests/trust-router.test.ts +170 -0
  37. package/tsconfig.json +12 -0
  38. package/tsconfig.tsbuildinfo +1 -0
  39. package/ui/index.html +19 -0
  40. package/ui/node_modules/.bin/browserslist +17 -0
  41. package/ui/node_modules/.bin/tsc +17 -0
  42. package/ui/node_modules/.bin/tsserver +17 -0
  43. package/ui/node_modules/.bin/vite +17 -0
  44. package/ui/package.json +23 -0
  45. package/ui/public/icon.svg +59 -0
  46. package/ui/src/App.tsx +63 -0
  47. package/ui/src/api.ts +238 -0
  48. package/ui/src/components/ActivityFeed.tsx +123 -0
  49. package/ui/src/components/BehaviorHealth.tsx +105 -0
  50. package/ui/src/components/DataTable.tsx +39 -0
  51. package/ui/src/components/Layout.tsx +160 -0
  52. package/ui/src/components/PasswordStrength.tsx +31 -0
  53. package/ui/src/components/SetupProgress.tsx +26 -0
  54. package/ui/src/components/StatusBadge.tsx +12 -0
  55. package/ui/src/components/ThemeSelector.tsx +39 -0
  56. package/ui/src/contexts/ThemeContext.tsx +58 -0
  57. package/ui/src/hooks/useApi.ts +19 -0
  58. package/ui/src/hooks/usePolling.ts +8 -0
  59. package/ui/src/main.tsx +16 -0
  60. package/ui/src/pages/AuditLog.tsx +36 -0
  61. package/ui/src/pages/Behaviors.tsx +426 -0
  62. package/ui/src/pages/Chat.tsx +688 -0
  63. package/ui/src/pages/Login.tsx +64 -0
  64. package/ui/src/pages/Overview.tsx +56 -0
  65. package/ui/src/pages/Sessions.tsx +26 -0
  66. package/ui/src/pages/SettingsAmbient.tsx +185 -0
  67. package/ui/src/pages/SettingsConnections.tsx +201 -0
  68. package/ui/src/pages/SettingsNotifications.tsx +241 -0
  69. package/ui/src/pages/SetupAppearance.tsx +45 -0
  70. package/ui/src/pages/SetupChannels.tsx +143 -0
  71. package/ui/src/pages/SetupComplete.tsx +31 -0
  72. package/ui/src/pages/SetupConnections.tsx +80 -0
  73. package/ui/src/pages/SetupDashboardPassword.tsx +50 -0
  74. package/ui/src/pages/SetupIdentity.tsx +68 -0
  75. package/ui/src/pages/SetupPersonality.tsx +78 -0
  76. package/ui/src/pages/SetupProvider.tsx +65 -0
  77. package/ui/src/pages/SetupVault.tsx +50 -0
  78. package/ui/src/pages/SetupWelcome.tsx +19 -0
  79. package/ui/src/pages/UnlockVault.tsx +56 -0
  80. package/ui/src/pages/Webhooks.tsx +158 -0
  81. package/ui/src/pages/settings/Appearance.tsx +63 -0
  82. package/ui/src/pages/settings/Channels.tsx +138 -0
  83. package/ui/src/pages/settings/Identity.tsx +61 -0
  84. package/ui/src/pages/settings/Personality.tsx +54 -0
  85. package/ui/src/pages/settings/PersonalityEditor.tsx +577 -0
  86. package/ui/src/pages/settings/Provider.tsx +537 -0
  87. package/ui/src/pages/settings/Security.tsx +111 -0
  88. package/ui/src/styles/global.css +2308 -0
  89. package/ui/src/styles/themes/index.css +7 -0
  90. package/ui/src/styles/themes/monolith.css +125 -0
  91. package/ui/src/styles/themes/nebula.css +90 -0
  92. package/ui/src/styles/themes/neon.css +149 -0
  93. package/ui/src/styles/themes/polar.css +151 -0
  94. package/ui/src/styles/themes/signal.css +163 -0
  95. package/ui/src/styles/themes/terra.css +146 -0
  96. package/ui/tsconfig.json +14 -0
  97. package/ui/vite.config.ts +20 -0
@@ -0,0 +1,688 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import { useApi } from '../hooks/useApi';
3
+ import { api } from '../api';
4
+
5
+ interface ChatMessage {
6
+ id: string;
7
+ role: 'user' | 'assistant' | 'error';
8
+ content: string;
9
+ model?: string;
10
+ }
11
+
12
+ interface ChatThread {
13
+ id: string;
14
+ title: string;
15
+ updatedAt: number;
16
+ }
17
+
18
+ interface ModelSelection {
19
+ provider: string;
20
+ model: string;
21
+ }
22
+
23
+ interface SlashCommand {
24
+ command: string;
25
+ description: string;
26
+ }
27
+
28
+ interface ContextMenuState {
29
+ chatId: string;
30
+ x: number;
31
+ y: number;
32
+ }
33
+
34
+ const SLASH_COMMANDS: SlashCommand[] = [
35
+ { command: '/help', description: 'Show available commands' },
36
+ { command: '/status', description: 'Show system status' },
37
+ { command: '/new', description: 'Start a new session' },
38
+ { command: '/reset', description: 'Clear current session' },
39
+ { command: '/mode', description: 'Show current mode' },
40
+ { command: '/mode auto', description: 'Auto-detect mode from messages' },
41
+ { command: '/mode off', description: 'Disable modes for this session' },
42
+ { command: '/mode operator', description: 'Fast, action-oriented execution' },
43
+ { command: '/mode analyst', description: 'Deep reasoning and analysis' },
44
+ { command: '/mode advisor', description: 'Strategic guidance and decisions' },
45
+ { command: '/mode writer', description: 'Creative and polished writing' },
46
+ { command: '/mode socratic', description: 'Question-based learning' },
47
+ { command: '/mode legal', description: 'Legal research and analysis' },
48
+ { command: '/mode roast', description: 'Playful, witty critique' },
49
+ { command: '/mode companion', description: 'Warm, supportive conversation' },
50
+ ];
51
+
52
+ /** Turn a raw model ID into a friendly display name */
53
+ function friendlyModelName(id: string): string {
54
+ if (id.startsWith('claude-opus-4-6')) return 'Claude Opus 4.6';
55
+ if (id.startsWith('claude-sonnet-4-5')) return 'Claude Sonnet 4.5';
56
+ if (id.startsWith('claude-haiku-4-5')) return 'Claude Haiku 4.5';
57
+ if (id.startsWith('claude-opus-4')) return 'Claude Opus 4';
58
+ if (id.startsWith('claude-sonnet-4')) return 'Claude Sonnet 4';
59
+ if (id.startsWith('claude-3-5-haiku')) return 'Claude Haiku 3.5';
60
+ if (id.startsWith('claude-3-5-sonnet')) return 'Claude Sonnet 3.5';
61
+ if (id.startsWith('claude-3-opus')) return 'Claude Opus 3';
62
+ if (id === 'gpt-4o') return 'GPT-4o';
63
+ if (id === 'gpt-4o-mini') return 'GPT-4o Mini';
64
+ if (id === 'gpt-4-turbo') return 'GPT-4 Turbo';
65
+ if (id.startsWith('o1')) return id.toUpperCase();
66
+ if (id.startsWith('o3')) return id.toUpperCase();
67
+ if (id.startsWith('gemini-'))return id.replace('gemini-', 'Gemini ');
68
+ return id;
69
+ }
70
+
71
+ function formatRelativeTime(timestamp: number): string {
72
+ const diff = Date.now() - timestamp;
73
+ const minutes = Math.floor(diff / 60000);
74
+ if (minutes < 1) return 'Just now';
75
+ if (minutes < 60) return `${minutes}m ago`;
76
+ const hours = Math.floor(minutes / 60);
77
+ if (hours < 24) return `${hours}h ago`;
78
+ const days = Math.floor(hours / 24);
79
+ if (days < 7) return `${days}d ago`;
80
+ return new Date(timestamp).toLocaleDateString();
81
+ }
82
+
83
+ /**
84
+ * Lightweight markdown-to-HTML renderer.
85
+ * Security: HTML-escapes all input FIRST, then applies markdown patterns.
86
+ * Only our own markdown transforms produce HTML tags, so XSS is prevented.
87
+ */
88
+ function renderMarkdown(text: string): string {
89
+ let html = text
90
+ .replace(/&/g, '&amp;')
91
+ .replace(/</g, '&lt;')
92
+ .replace(/>/g, '&gt;');
93
+
94
+ html = html
95
+ .replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang: string, code: string) => {
96
+ const header = lang
97
+ ? `<div class="code-header"><span class="code-lang">${lang}</span><button class="code-copy" onclick="navigator.clipboard.writeText(this.closest('.code-block').querySelector('code').textContent)">Copy</button></div>`
98
+ : `<div class="code-header"><button class="code-copy" onclick="navigator.clipboard.writeText(this.closest('.code-block').querySelector('code').textContent)">Copy</button></div>`;
99
+ return `<div class="code-block">${header}<pre><code>${code}</code></pre></div>`;
100
+ })
101
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
102
+ .replace(/^### (.+)$/gm, '<h4>$1</h4>')
103
+ .replace(/^## (.+)$/gm, '<h3>$1</h3>')
104
+ .replace(/^# (.+)$/gm, '<h2>$1</h2>')
105
+ .replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
106
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
107
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
108
+ .replace(/^---$/gm, '<hr/>')
109
+ .replace(/^[-*] (.+)$/gm, '<li>$1</li>');
110
+
111
+ html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
112
+ html = html.replace(/\n\n+/g, '</p><p>');
113
+ html = html.replace(/\n/g, '<br/>');
114
+
115
+ html = `<p>${html}</p>`;
116
+ html = html
117
+ .replace(/<p><\/p>/g, '')
118
+ .replace(/<p>(<h[234]>)/g, '$1')
119
+ .replace(/(<\/h[234]>)<\/p>/g, '$1')
120
+ .replace(/<p>(<pre>)/g, '$1')
121
+ .replace(/(<\/pre>)<\/p>/g, '$1')
122
+ .replace(/<p>(<div class="code-block">)/g, '$1')
123
+ .replace(/(<\/div>)<\/p>/g, '$1')
124
+ .replace(/<p>(<ul>)/g, '$1')
125
+ .replace(/(<\/ul>)<\/p>/g, '$1')
126
+ .replace(/<p>(<hr\/>)/g, '$1')
127
+ .replace(/(<hr\/>)<\/p>/g, '$1');
128
+
129
+ return html;
130
+ }
131
+
132
+ export function Chat() {
133
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
134
+ const [input, setInput] = useState('');
135
+ const [connected, setConnected] = useState(false);
136
+ const [streaming, setStreaming] = useState(false);
137
+ const [selectedModel, setSelectedModel] = useState<ModelSelection | null>(null);
138
+ const [lastModel, setLastModel] = useState('');
139
+ const [activeMode, setActiveMode] = useState('auto');
140
+ const [acIndex, setAcIndex] = useState(0);
141
+ const [acOpen, setAcOpen] = useState(false);
142
+ const [historyLoaded, setHistoryLoaded] = useState(false);
143
+
144
+ // Multi-chat state
145
+ const [chats, setChats] = useState<ChatThread[]>([]);
146
+ const [chatId, setChatId] = useState<string | null>(null);
147
+ const [sidebarOpen, setSidebarOpen] = useState(true);
148
+ const [editingChatId, setEditingChatId] = useState<string | null>(null);
149
+ const [editTitle, setEditTitle] = useState('');
150
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
151
+
152
+ const wsRef = useRef<WebSocket | null>(null);
153
+ const messagesEndRef = useRef<HTMLDivElement>(null);
154
+ const currentResponseRef = useRef('');
155
+ const requestIdRef = useRef(0);
156
+ const inputRef = useRef<HTMLInputElement>(null);
157
+ const acRef = useRef<HTMLDivElement>(null);
158
+ const chatIdRef = useRef<string | null>(null);
159
+ const { data: status } = useApi(() => api.getStatus(), []);
160
+ const { data: modelsData } = useApi(() => api.getModels(), []);
161
+ const { data: identityData } = useApi(() => api.getIdentity(), []);
162
+ const { data: personalityData } = useApi(() => api.getPersonality(), []);
163
+
164
+ // Keep chatIdRef in sync
165
+ useEffect(() => {
166
+ chatIdRef.current = chatId;
167
+ }, [chatId]);
168
+
169
+ // Load chat list on mount — auto-create first chat if none exist
170
+ useEffect(() => {
171
+ api.getChats().then(async res => {
172
+ if (res.data && res.data.length > 0) {
173
+ setChats(res.data.map((c: any) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })));
174
+ setChatId(res.data[0].id);
175
+ } else {
176
+ // No chats yet — create one automatically
177
+ const newRes = await api.createNewChat();
178
+ const c = newRes.data;
179
+ setChats([{ id: c.id, title: c.title, updatedAt: c.updatedAt }]);
180
+ setChatId(c.id);
181
+ setHistoryLoaded(true);
182
+ }
183
+ }).catch(() => {});
184
+ }, []);
185
+
186
+ // Load messages when chatId changes
187
+ useEffect(() => {
188
+ if (!chatId) {
189
+ setMessages([]);
190
+ setHistoryLoaded(true);
191
+ return;
192
+ }
193
+ setHistoryLoaded(false);
194
+ api.getChatMessages(chatId).then(res => {
195
+ if (res.data) {
196
+ setMessages(res.data.map(m => ({
197
+ id: m.id,
198
+ role: m.role as 'user' | 'assistant',
199
+ content: m.content,
200
+ })));
201
+ } else {
202
+ setMessages([]);
203
+ }
204
+ setHistoryLoaded(true);
205
+ requestAnimationFrame(() => {
206
+ messagesEndRef.current?.scrollIntoView();
207
+ });
208
+ }).catch(() => {
209
+ setMessages([]);
210
+ setHistoryLoaded(true);
211
+ });
212
+ }, [chatId]);
213
+
214
+ // Detect mode changes from assistant messages
215
+ useEffect(() => {
216
+ if (messages.length === 0) return;
217
+ const last = messages[messages.length - 1];
218
+ if (last.role !== 'assistant') return;
219
+ const modeMatch = last.content.match(/Switched to \*\*(\w+)\*\* mode/);
220
+ if (modeMatch) {
221
+ setActiveMode(modeMatch[1].toLowerCase());
222
+ return;
223
+ }
224
+ if (last.content.includes('Mode set to **auto**')) {
225
+ setActiveMode('auto');
226
+ return;
227
+ }
228
+ if (last.content.includes('Modes disabled')) {
229
+ setActiveMode('off');
230
+ }
231
+ }, [messages]);
232
+
233
+ // Slash command autocomplete filtering
234
+ const acMatches = useMemo(() => {
235
+ if (!input.startsWith('/')) return [];
236
+ const q = input.toLowerCase();
237
+ return SLASH_COMMANDS.filter(c => c.command.startsWith(q));
238
+ }, [input]);
239
+
240
+ useEffect(() => {
241
+ setAcOpen(acMatches.length > 0 && input.startsWith('/'));
242
+ setAcIndex(0);
243
+ }, [acMatches, input]);
244
+
245
+ useEffect(() => {
246
+ if (!acOpen || !acRef.current) return;
247
+ const item = acRef.current.children[acIndex] as HTMLElement | undefined;
248
+ item?.scrollIntoView({ block: 'nearest' });
249
+ }, [acIndex, acOpen]);
250
+
251
+ const scrollToBottom = useCallback(() => {
252
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
253
+ }, []);
254
+
255
+ useEffect(() => {
256
+ scrollToBottom();
257
+ }, [messages, scrollToBottom]);
258
+
259
+ // Close context menu on click outside
260
+ useEffect(() => {
261
+ if (!contextMenu) return;
262
+ const handler = () => setContextMenu(null);
263
+ document.addEventListener('click', handler);
264
+ return () => document.removeEventListener('click', handler);
265
+ }, [contextMenu]);
266
+
267
+ useEffect(() => {
268
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
269
+ const host = import.meta.env.DEV ? 'localhost:18800' : window.location.host;
270
+ const ws = new WebSocket(`${protocol}//${host}/`);
271
+ wsRef.current = ws;
272
+
273
+ ws.onopen = () => setConnected(true);
274
+ ws.onclose = () => {
275
+ setConnected(false);
276
+ setStreaming(false);
277
+ };
278
+
279
+ ws.onmessage = (event) => {
280
+ try {
281
+ const msg = JSON.parse(event.data);
282
+ switch (msg.type) {
283
+ case 'connected':
284
+ case 'auth_success':
285
+ break;
286
+ case 'chunk':
287
+ currentResponseRef.current += msg.payload?.content ?? '';
288
+ setMessages(prev => {
289
+ const last = prev[prev.length - 1];
290
+ if (last?.role === 'assistant' && last.id === `resp-${requestIdRef.current}`) {
291
+ return [...prev.slice(0, -1), { ...last, content: currentResponseRef.current }];
292
+ }
293
+ return [...prev, { id: `resp-${requestIdRef.current}`, role: 'assistant', content: currentResponseRef.current }];
294
+ });
295
+ break;
296
+ case 'message':
297
+ if (msg.payload?.role === 'assistant') {
298
+ setMessages(prev => [...prev, {
299
+ id: `msg-${Date.now()}`,
300
+ role: 'assistant',
301
+ content: msg.payload.content,
302
+ }]);
303
+ }
304
+ break;
305
+ case 'done': {
306
+ setStreaming(false);
307
+ const routing = msg.payload?.routing;
308
+ if (routing) {
309
+ const modelLabel = routing.model
310
+ ? `${routing.provider}/${routing.model}`
311
+ : routing.provider;
312
+ setLastModel(modelLabel);
313
+ setMessages(prev => {
314
+ const last = prev[prev.length - 1];
315
+ if (last?.role === 'assistant') {
316
+ return [...prev.slice(0, -1), { ...last, model: modelLabel }];
317
+ }
318
+ return prev;
319
+ });
320
+ }
321
+ currentResponseRef.current = '';
322
+ break;
323
+ }
324
+ case 'chat_created':
325
+ // Server created a new chat for this message
326
+ if (msg.payload?.chatId) {
327
+ setChatId(msg.payload.chatId);
328
+ // Refresh chat list
329
+ api.getChats().then(res => {
330
+ if (res.data) {
331
+ setChats(res.data.map((c: any) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })));
332
+ }
333
+ }).catch(() => {});
334
+ }
335
+ break;
336
+ case 'chat_titled':
337
+ // Server auto-titled a chat
338
+ if (msg.payload?.chatId && msg.payload?.title) {
339
+ setChats(prev => prev.map(c =>
340
+ c.id === msg.payload.chatId ? { ...c, title: msg.payload.title } : c
341
+ ));
342
+ }
343
+ break;
344
+ case 'error':
345
+ setStreaming(false);
346
+ currentResponseRef.current = '';
347
+ setMessages(prev => [...prev, {
348
+ id: `err-${Date.now()}`,
349
+ role: 'error',
350
+ content: msg.payload?.message ?? 'Unknown error',
351
+ }]);
352
+ break;
353
+ }
354
+ } catch {
355
+ // ignore parse errors
356
+ }
357
+ };
358
+
359
+ return () => {
360
+ ws.close();
361
+ };
362
+ }, []);
363
+
364
+ const providerGroups: Array<{ provider: string; displayName: string; models: string[] }> = [];
365
+ if (modelsData?.providers) {
366
+ for (const p of modelsData.providers) {
367
+ if (p.available && p.models) {
368
+ providerGroups.push({
369
+ provider: p.name,
370
+ displayName: p.displayName || p.name,
371
+ models: Object.keys(p.models),
372
+ });
373
+ }
374
+ }
375
+ }
376
+
377
+ const handleModelChange = (value: string) => {
378
+ if (!value) {
379
+ setSelectedModel(null);
380
+ return;
381
+ }
382
+ const [provider, ...rest] = value.split('/');
383
+ setSelectedModel({ provider, model: rest.join('/') });
384
+ };
385
+
386
+ const sendMessage = () => {
387
+ if (!input.trim() || !wsRef.current || !connected || streaming) return;
388
+ const content = input.trim();
389
+ const id = ++requestIdRef.current;
390
+
391
+ setMessages(prev => [...prev, { id: `user-${id}`, role: 'user', content }]);
392
+ setInput('');
393
+ setStreaming(true);
394
+ currentResponseRef.current = '';
395
+
396
+ const payload: Record<string, string> = { content };
397
+ if (selectedModel) {
398
+ payload.provider = selectedModel.provider;
399
+ payload.model = selectedModel.model;
400
+ }
401
+ if (chatIdRef.current) {
402
+ payload.chatId = chatIdRef.current;
403
+ }
404
+
405
+ wsRef.current.send(JSON.stringify({
406
+ type: 'message',
407
+ id: String(id),
408
+ payload,
409
+ }));
410
+ };
411
+
412
+ const handleNewChat = async () => {
413
+ try {
414
+ const res = await api.createNewChat();
415
+ const newChat = res.data;
416
+ setChats(prev => [{ id: newChat.id, title: newChat.title, updatedAt: newChat.updatedAt }, ...prev]);
417
+ setChatId(newChat.id);
418
+ setMessages([]);
419
+ setHistoryLoaded(true);
420
+ inputRef.current?.focus();
421
+ } catch {
422
+ // ignore
423
+ }
424
+ };
425
+
426
+ const handleRenameSubmit = async (id: string) => {
427
+ if (!editTitle.trim()) {
428
+ setEditingChatId(null);
429
+ return;
430
+ }
431
+ try {
432
+ await api.renameChat(id, editTitle.trim());
433
+ setChats(prev => prev.map(c => c.id === id ? { ...c, title: editTitle.trim() } : c));
434
+ } catch {
435
+ // ignore
436
+ }
437
+ setEditingChatId(null);
438
+ };
439
+
440
+ const handleDeleteChat = async (id: string) => {
441
+ try {
442
+ await api.deleteChatThread(id);
443
+ setChats(prev => prev.filter(c => c.id !== id));
444
+ if (chatId === id) {
445
+ setChatId(null);
446
+ setMessages([]);
447
+ }
448
+ } catch {
449
+ // ignore
450
+ }
451
+ setContextMenu(null);
452
+ };
453
+
454
+ const handleArchiveChat = async (id: string) => {
455
+ try {
456
+ await api.archiveChat(id);
457
+ setChats(prev => prev.filter(c => c.id !== id));
458
+ if (chatId === id) {
459
+ setChatId(null);
460
+ setMessages([]);
461
+ }
462
+ } catch {
463
+ // ignore
464
+ }
465
+ setContextMenu(null);
466
+ };
467
+
468
+ const modeLabel = activeMode === 'auto' ? 'Auto' : activeMode === 'off' ? 'Off' : activeMode.charAt(0).toUpperCase() + activeMode.slice(1);
469
+ const agentName = identityData?.data?.name ?? 'Auxiora';
470
+ const templateName = personalityData?.data?.template?.name ?? null;
471
+
472
+ // renderMarkdown already HTML-escapes all input before applying transforms,
473
+ // so only our safe markdown patterns produce HTML tags (no XSS risk).
474
+ const renderMessageHtml = (content: string) => ({ __html: renderMarkdown(content) });
475
+
476
+ return (
477
+ <div className="page">
478
+ <h2>Chat</h2>
479
+ <div className="chat-layout">
480
+ {/* Sidebar */}
481
+ <div className={`chat-sidebar${sidebarOpen ? '' : ' closed'}`}>
482
+ <div className="chat-sidebar-header">
483
+ <button className="new-chat-btn" onClick={handleNewChat}>+ New Chat</button>
484
+ <button
485
+ className="sidebar-toggle"
486
+ onClick={() => setSidebarOpen(v => !v)}
487
+ title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
488
+ >
489
+ {sidebarOpen ? '\u00AB' : '\u00BB'}
490
+ </button>
491
+ </div>
492
+ {sidebarOpen && (
493
+ <div className="chat-sidebar-list">
494
+ {chats.map(c => (
495
+ <div
496
+ key={c.id}
497
+ className={`chat-sidebar-item${chatId === c.id ? ' active' : ''}`}
498
+ onClick={() => {
499
+ if (editingChatId !== c.id) {
500
+ setChatId(c.id);
501
+ }
502
+ }}
503
+ onContextMenu={(e) => {
504
+ e.preventDefault();
505
+ setContextMenu({ chatId: c.id, x: e.clientX, y: e.clientY });
506
+ }}
507
+ >
508
+ {editingChatId === c.id ? (
509
+ <input
510
+ className="chat-rename-input"
511
+ value={editTitle}
512
+ onChange={e => setEditTitle(e.target.value)}
513
+ onKeyDown={e => {
514
+ if (e.key === 'Enter') handleRenameSubmit(c.id);
515
+ if (e.key === 'Escape') setEditingChatId(null);
516
+ }}
517
+ onBlur={() => handleRenameSubmit(c.id)}
518
+ autoFocus
519
+ />
520
+ ) : (
521
+ <>
522
+ <span className="chat-sidebar-title">{c.title}</span>
523
+ <span className="chat-sidebar-time">{formatRelativeTime(c.updatedAt)}</span>
524
+ </>
525
+ )}
526
+ </div>
527
+ ))}
528
+ {chats.length === 0 && (
529
+ <div className="chat-sidebar-empty">No chats yet</div>
530
+ )}
531
+ </div>
532
+ )}
533
+ </div>
534
+
535
+ {/* Context Menu */}
536
+ {contextMenu && (
537
+ <div
538
+ className="chat-context-menu"
539
+ style={{ position: 'fixed', top: contextMenu.y, left: contextMenu.x }}
540
+ >
541
+ <button onClick={() => {
542
+ const chat = chats.find(c => c.id === contextMenu.chatId);
543
+ setEditTitle(chat?.title ?? '');
544
+ setEditingChatId(contextMenu.chatId);
545
+ setContextMenu(null);
546
+ }}>Rename</button>
547
+ <button onClick={() => handleArchiveChat(contextMenu.chatId)}>Archive</button>
548
+ <button className="danger" onClick={() => handleDeleteChat(contextMenu.chatId)}>Delete</button>
549
+ </div>
550
+ )}
551
+
552
+ {/* Chat area */}
553
+ <div className="chat-container">
554
+ <div className="chat-status">
555
+ <span className="chat-status-left">
556
+ <span className="chat-status-dot" data-connected={connected} />
557
+ <span className="chat-agent-name">{agentName}</span>
558
+ <span className="chat-status-sep" />
559
+ <span className="chat-status-label">Mode: <strong>{modeLabel}</strong></span>
560
+ {templateName && (
561
+ <>
562
+ <span className="chat-status-sep" />
563
+ <span className="chat-status-label">Personality: <strong>{templateName}</strong></span>
564
+ </>
565
+ )}
566
+ </span>
567
+ <span className="chat-status-right">
568
+ <button
569
+ className="sidebar-toggle-inline"
570
+ onClick={() => setSidebarOpen(v => !v)}
571
+ title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
572
+ >
573
+ {sidebarOpen ? '\u2630' : '\u2630'}
574
+ </button>
575
+ <div className="model-selector">
576
+ <select
577
+ value={selectedModel ? `${selectedModel.provider}/${selectedModel.model}` : ''}
578
+ onChange={e => handleModelChange(e.target.value)}
579
+ >
580
+ <option value="">Auto (router)</option>
581
+ {providerGroups.map(g => (
582
+ <optgroup key={g.provider} label={g.displayName}>
583
+ {g.models.map(m => (
584
+ <option key={`${g.provider}/${m}`} value={`${g.provider}/${m}`}>
585
+ {friendlyModelName(m)}
586
+ </option>
587
+ ))}
588
+ </optgroup>
589
+ ))}
590
+ </select>
591
+ </div>
592
+ </span>
593
+ </div>
594
+ <div className="chat-messages">
595
+ {!chatId && (
596
+ <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
597
+ Select a chat or create a new one
598
+ </div>
599
+ )}
600
+ {chatId && !historyLoaded && (
601
+ <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
602
+ Loading...
603
+ </div>
604
+ )}
605
+ {chatId && historyLoaded && messages.length === 0 && (
606
+ <div style={{ textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
607
+ Send a message to start chatting
608
+ </div>
609
+ )}
610
+ {messages.map(msg => (
611
+ <div key={msg.id} className={`chat-message ${msg.role}`}>
612
+ {msg.role === 'assistant'
613
+ ? <div className="chat-markdown" dangerouslySetInnerHTML={renderMessageHtml(msg.content)} />
614
+ : msg.content
615
+ }
616
+ {msg.model && (
617
+ <div className="model-label">{msg.model}</div>
618
+ )}
619
+ </div>
620
+ ))}
621
+ <div ref={messagesEndRef} />
622
+ </div>
623
+ <div className="chat-input-area">
624
+ <div className="chat-input-wrapper">
625
+ {acOpen && acMatches.length > 0 && (
626
+ <div className="slash-autocomplete" ref={acRef}>
627
+ {acMatches.map((cmd, i) => (
628
+ <div
629
+ key={cmd.command}
630
+ className={`slash-ac-item${i === acIndex ? ' selected' : ''}`}
631
+ onMouseEnter={() => setAcIndex(i)}
632
+ onMouseDown={(e) => {
633
+ e.preventDefault();
634
+ setInput(cmd.command);
635
+ setAcOpen(false);
636
+ inputRef.current?.focus();
637
+ }}
638
+ >
639
+ <span className="slash-ac-cmd">{cmd.command}</span>
640
+ <span className="slash-ac-desc">{cmd.description}</span>
641
+ </div>
642
+ ))}
643
+ </div>
644
+ )}
645
+ <input
646
+ ref={inputRef}
647
+ type="text"
648
+ value={input}
649
+ onChange={(e) => setInput(e.target.value)}
650
+ onKeyDown={(e) => {
651
+ if (acOpen && acMatches.length > 0) {
652
+ if (e.key === 'ArrowDown') {
653
+ e.preventDefault();
654
+ setAcIndex(i => (i + 1) % acMatches.length);
655
+ return;
656
+ }
657
+ if (e.key === 'ArrowUp') {
658
+ e.preventDefault();
659
+ setAcIndex(i => (i - 1 + acMatches.length) % acMatches.length);
660
+ return;
661
+ }
662
+ if (e.key === 'Tab' || (e.key === 'Enter' && acMatches.length > 1)) {
663
+ e.preventDefault();
664
+ setInput(acMatches[acIndex].command);
665
+ setAcOpen(false);
666
+ return;
667
+ }
668
+ if (e.key === 'Escape') {
669
+ e.preventDefault();
670
+ setAcOpen(false);
671
+ return;
672
+ }
673
+ }
674
+ if (e.key === 'Enter') sendMessage();
675
+ }}
676
+ placeholder={!chatId ? 'Select or create a chat...' : connected ? 'Type / for commands...' : 'Connecting...'}
677
+ disabled={!connected || streaming || !chatId}
678
+ />
679
+ </div>
680
+ <button onClick={sendMessage} disabled={!connected || streaming || !input.trim() || !chatId}>
681
+ Send
682
+ </button>
683
+ </div>
684
+ </div>
685
+ </div>
686
+ </div>
687
+ );
688
+ }