@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.
- package/LICENSE +191 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +69 -0
- package/dist/auth.js.map +1 -0
- package/dist/cloud-types.d.ts +71 -0
- package/dist/cloud-types.d.ts.map +1 -0
- package/dist/cloud-types.js +2 -0
- package/dist/cloud-types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +2250 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +314 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist-ui/assets/index-BfY0i5jw.css +1 -0
- package/dist-ui/assets/index-CXpk9mvw.js +60 -0
- package/dist-ui/icon.svg +59 -0
- package/dist-ui/index.html +20 -0
- package/package.json +32 -0
- package/src/auth.ts +83 -0
- package/src/cloud-types.ts +63 -0
- package/src/index.ts +5 -0
- package/src/router.ts +2494 -0
- package/src/types.ts +269 -0
- package/tests/auth.test.ts +51 -0
- package/tests/cloud-router.test.ts +249 -0
- package/tests/desktop-router.test.ts +151 -0
- package/tests/router.test.ts +388 -0
- package/tests/trust-router.test.ts +170 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/ui/index.html +19 -0
- package/ui/node_modules/.bin/browserslist +17 -0
- package/ui/node_modules/.bin/tsc +17 -0
- package/ui/node_modules/.bin/tsserver +17 -0
- package/ui/node_modules/.bin/vite +17 -0
- package/ui/package.json +23 -0
- package/ui/public/icon.svg +59 -0
- package/ui/src/App.tsx +63 -0
- package/ui/src/api.ts +238 -0
- package/ui/src/components/ActivityFeed.tsx +123 -0
- package/ui/src/components/BehaviorHealth.tsx +105 -0
- package/ui/src/components/DataTable.tsx +39 -0
- package/ui/src/components/Layout.tsx +160 -0
- package/ui/src/components/PasswordStrength.tsx +31 -0
- package/ui/src/components/SetupProgress.tsx +26 -0
- package/ui/src/components/StatusBadge.tsx +12 -0
- package/ui/src/components/ThemeSelector.tsx +39 -0
- package/ui/src/contexts/ThemeContext.tsx +58 -0
- package/ui/src/hooks/useApi.ts +19 -0
- package/ui/src/hooks/usePolling.ts +8 -0
- package/ui/src/main.tsx +16 -0
- package/ui/src/pages/AuditLog.tsx +36 -0
- package/ui/src/pages/Behaviors.tsx +426 -0
- package/ui/src/pages/Chat.tsx +688 -0
- package/ui/src/pages/Login.tsx +64 -0
- package/ui/src/pages/Overview.tsx +56 -0
- package/ui/src/pages/Sessions.tsx +26 -0
- package/ui/src/pages/SettingsAmbient.tsx +185 -0
- package/ui/src/pages/SettingsConnections.tsx +201 -0
- package/ui/src/pages/SettingsNotifications.tsx +241 -0
- package/ui/src/pages/SetupAppearance.tsx +45 -0
- package/ui/src/pages/SetupChannels.tsx +143 -0
- package/ui/src/pages/SetupComplete.tsx +31 -0
- package/ui/src/pages/SetupConnections.tsx +80 -0
- package/ui/src/pages/SetupDashboardPassword.tsx +50 -0
- package/ui/src/pages/SetupIdentity.tsx +68 -0
- package/ui/src/pages/SetupPersonality.tsx +78 -0
- package/ui/src/pages/SetupProvider.tsx +65 -0
- package/ui/src/pages/SetupVault.tsx +50 -0
- package/ui/src/pages/SetupWelcome.tsx +19 -0
- package/ui/src/pages/UnlockVault.tsx +56 -0
- package/ui/src/pages/Webhooks.tsx +158 -0
- package/ui/src/pages/settings/Appearance.tsx +63 -0
- package/ui/src/pages/settings/Channels.tsx +138 -0
- package/ui/src/pages/settings/Identity.tsx +61 -0
- package/ui/src/pages/settings/Personality.tsx +54 -0
- package/ui/src/pages/settings/PersonalityEditor.tsx +577 -0
- package/ui/src/pages/settings/Provider.tsx +537 -0
- package/ui/src/pages/settings/Security.tsx +111 -0
- package/ui/src/styles/global.css +2308 -0
- package/ui/src/styles/themes/index.css +7 -0
- package/ui/src/styles/themes/monolith.css +125 -0
- package/ui/src/styles/themes/nebula.css +90 -0
- package/ui/src/styles/themes/neon.css +149 -0
- package/ui/src/styles/themes/polar.css +151 -0
- package/ui/src/styles/themes/signal.css +163 -0
- package/ui/src/styles/themes/terra.css +146 -0
- package/ui/tsconfig.json +14 -0
- 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, '&')
|
|
91
|
+
.replace(/</g, '<')
|
|
92
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|