total-recall-brain 3.1.2 → 3.2.2
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/extension/background.js +99 -0
- package/extension/content-script.css +16 -0
- package/extension/content-script.js +134 -0
- package/extension/icons/README.md +27 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/lib/brain-client.js +47 -0
- package/extension/manifest.json +39 -0
- package/extension/options/options.css +218 -0
- package/extension/options/options.html +72 -0
- package/extension/options/options.js +99 -0
- package/extension/popup/popup.css +221 -0
- package/extension/popup/popup.html +44 -0
- package/extension/popup/popup.js +107 -0
- package/extension/sidepanel/sidepanel.css +340 -0
- package/extension/sidepanel/sidepanel.html +38 -0
- package/extension/sidepanel/sidepanel.js +229 -0
- package/frontend/README.md +73 -0
- package/frontend/index.html +17 -0
- package/frontend/package.json +31 -0
- package/frontend/src/App.css +2 -0
- package/frontend/src/App.tsx +390 -0
- package/frontend/src/api.ts +688 -0
- package/frontend/src/assets/hero.png +0 -0
- package/frontend/src/assets/react.svg +1 -0
- package/frontend/src/assets/vite.svg +1 -0
- package/frontend/src/components/BrainSelector.tsx +272 -0
- package/frontend/src/components/DaemonLogsTab.tsx +54 -0
- package/frontend/src/components/Graph3D.tsx +850 -0
- package/frontend/src/components/MarkdownUtils.tsx +99 -0
- package/frontend/src/components/ResearchAgendaTab.tsx +707 -0
- package/frontend/src/components/TaskQueueTab.tsx +185 -0
- package/frontend/src/index.css +1589 -0
- package/frontend/src/main.tsx +10 -0
- package/frontend/src/pages/ApiKeysPage.tsx +288 -0
- package/frontend/src/pages/AutomationsPage.tsx +354 -0
- package/frontend/src/pages/ChatPage.tsx +831 -0
- package/frontend/src/pages/DeploymentsPage.tsx +275 -0
- package/frontend/src/pages/FilesPage.tsx +384 -0
- package/frontend/src/pages/HealthPage.tsx +91 -0
- package/frontend/src/pages/IntegrationsPage.tsx +382 -0
- package/frontend/src/pages/LoginPage.tsx +237 -0
- package/frontend/src/pages/MemoryPage.tsx +1192 -0
- package/frontend/src/pages/SandboxPage.tsx +113 -0
- package/frontend/src/pages/SettingsPage.tsx +662 -0
- package/frontend/src/pages/SkillsPage.tsx +618 -0
- package/frontend/src/pages/TasksPage.tsx +392 -0
- package/frontend/src/pages/UsagePage.tsx +260 -0
- package/frontend/src/types.ts +163 -0
- package/frontend/tsconfig.app.json +25 -0
- package/frontend/tsconfig.json +7 -0
- package/frontend/tsconfig.node.json +24 -0
- package/frontend/vite.config.ts +24 -0
- package/package.json +8 -1
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +35 -8
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +13 -8
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +28 -1
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +28 -1
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +830 -81
- package/src/cli/backup.mjs +172 -1
- package/src/cli/setup.mjs +24 -0
- package/src/server/rest.mjs +56 -7
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import { sendChat, createTask, listTasks, fetchTtsStatus, fetchTtsAudio, fetchChatHistory, fetchChatThreads, deleteChatThread, listMemory, listResearch, fetchHealth, fetchGeminiModels, shareToApi, fetchExtensionStatus } from '../api'
|
|
3
|
+
import type { ChatThread } from '../api'
|
|
4
|
+
import type { ChatMessage, MemoryNode, ResearchItem } from '../types'
|
|
5
|
+
import Graph3D from '../components/Graph3D'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
let msgId = 0
|
|
9
|
+
|
|
10
|
+
function getCurrentTimestamp(): number {
|
|
11
|
+
return Date.now()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatRelativeTime(timestamp: number): string {
|
|
15
|
+
const diffMs = getCurrentTimestamp() - timestamp
|
|
16
|
+
if (diffMs < 60000) return 'just now'
|
|
17
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
18
|
+
if (diffMins < 60) return `${diffMins}m ago`
|
|
19
|
+
const diffHours = Math.floor(diffMins / 60)
|
|
20
|
+
if (diffHours < 24) return `${diffHours}h ago`
|
|
21
|
+
const diffDays = Math.floor(diffHours / 24)
|
|
22
|
+
return `${diffDays}d ago`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function ChatPage({ activeBrainId, onBrainChange }: { activeBrainId?: string; onBrainChange?: (id: string) => void }) {
|
|
26
|
+
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
27
|
+
const [input, setInput] = useState('')
|
|
28
|
+
const [loading, setLoading] = useState(false)
|
|
29
|
+
const messagesEnd = useRef<HTMLDivElement>(null)
|
|
30
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
31
|
+
const abortControllerRef = useRef<AbortController | null>(null)
|
|
32
|
+
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
33
|
+
|
|
34
|
+
const [voiceMode, setVoiceMode] = useState(false)
|
|
35
|
+
const [kokoroEnabled, setKokoroEnabled] = useState<boolean | null>(null)
|
|
36
|
+
const [deepResearchMode, setDeepResearchMode] = useState(false)
|
|
37
|
+
const [detectedUrl, setDetectedUrl] = useState<string | null>(null)
|
|
38
|
+
const [urlToast, setUrlToast] = useState<string | null>(null)
|
|
39
|
+
|
|
40
|
+
// Extension banner state
|
|
41
|
+
const [extensionStatus, setExtensionStatus] = useState<{ available: boolean; connected: boolean } | null>(null)
|
|
42
|
+
const [extensionBannerDismissed, setExtensionBannerDismissed] = useState(
|
|
43
|
+
() => localStorage.getItem('tr-ext-banner-dismissed') === 'true'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// Threads listing & session control state
|
|
47
|
+
const [threads, setThreads] = useState<ChatThread[]>([])
|
|
48
|
+
const [activeThreadId, setActiveThreadId] = useState<string | undefined>(undefined)
|
|
49
|
+
|
|
50
|
+
// Grounding nodes & suggested discussions state
|
|
51
|
+
const [selectedGroundingNodes, setSelectedGroundingNodes] = useState<string[]>([])
|
|
52
|
+
const [allMemoryNodes, setAllMemoryNodes] = useState<MemoryNode[]>([])
|
|
53
|
+
const [researchItems, setResearchItems] = useState<ResearchItem[]>([])
|
|
54
|
+
const [showNodeSelector, setShowNodeSelector] = useState(false)
|
|
55
|
+
const [nodeSearchQuery, setNodeSearchQuery] = useState('')
|
|
56
|
+
const nodeSelectorRef = useRef<HTMLDivElement>(null)
|
|
57
|
+
|
|
58
|
+
// Model Selector state
|
|
59
|
+
const [availableModels, setAvailableModels] = useState<string[]>([])
|
|
60
|
+
const [selectedModel, setSelectedModel] = useState<string>(() => localStorage.getItem('selectedModel') || '')
|
|
61
|
+
const [selectedSubModel, setSelectedSubModel] = useState<string>(() => localStorage.getItem('selectedSubModel') || '')
|
|
62
|
+
const [geminiModels, setGeminiModels] = useState<{ id: string; displayName: string }[]>([])
|
|
63
|
+
const [showModelDropdown, setShowModelDropdown] = useState(false)
|
|
64
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
65
|
+
|
|
66
|
+
const scrollToBottom = useCallback(() => {
|
|
67
|
+
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
useEffect(scrollToBottom, [messages, scrollToBottom])
|
|
71
|
+
|
|
72
|
+
const refreshThreads = useCallback(() => {
|
|
73
|
+
fetchChatThreads()
|
|
74
|
+
.then(list => {
|
|
75
|
+
setThreads(list)
|
|
76
|
+
})
|
|
77
|
+
.catch(console.error)
|
|
78
|
+
|
|
79
|
+
listResearch()
|
|
80
|
+
.then(res => setResearchItems(res.items || []))
|
|
81
|
+
.catch(console.error)
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
// Load threads list, suggestions, and health on mount
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
fetchChatThreads()
|
|
87
|
+
.then(list => {
|
|
88
|
+
setThreads(list)
|
|
89
|
+
if (list.length > 0) {
|
|
90
|
+
setActiveThreadId(list[0].id)
|
|
91
|
+
// Auto-switch brain to the most recent thread's brain
|
|
92
|
+
if (list[0].brainId && onBrainChange) {
|
|
93
|
+
onBrainChange(list[0].brainId)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
.catch(console.error)
|
|
98
|
+
|
|
99
|
+
listResearch()
|
|
100
|
+
.then(res => setResearchItems(res.items || []))
|
|
101
|
+
.catch(console.error)
|
|
102
|
+
|
|
103
|
+
fetchHealth()
|
|
104
|
+
.then(health => {
|
|
105
|
+
const agents = health.cli_agents && health.cli_agents.length > 0
|
|
106
|
+
? health.cli_agents
|
|
107
|
+
: ['antigravity', 'gemini', 'claude', 'codex'];
|
|
108
|
+
setAvailableModels(agents);
|
|
109
|
+
|
|
110
|
+
const saved = localStorage.getItem('selectedModel');
|
|
111
|
+
if (saved && agents.includes(saved)) {
|
|
112
|
+
setSelectedModel(saved);
|
|
113
|
+
} else {
|
|
114
|
+
const defaultAgent = agents.includes('gemini') ? 'gemini' : agents[0];
|
|
115
|
+
setSelectedModel(defaultAgent);
|
|
116
|
+
localStorage.setItem('selectedModel', defaultAgent);
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
.catch(() => {
|
|
120
|
+
const agents = ['antigravity', 'gemini', 'claude', 'codex'];
|
|
121
|
+
setAvailableModels(agents);
|
|
122
|
+
const saved = localStorage.getItem('selectedModel');
|
|
123
|
+
if (saved && agents.includes(saved)) {
|
|
124
|
+
setSelectedModel(saved);
|
|
125
|
+
} else {
|
|
126
|
+
setSelectedModel('gemini');
|
|
127
|
+
localStorage.setItem('selectedModel', 'gemini');
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
fetchGeminiModels()
|
|
132
|
+
.then(models => {
|
|
133
|
+
setGeminiModels(models)
|
|
134
|
+
if (models && models.length > 0) {
|
|
135
|
+
const savedSub = localStorage.getItem('selectedSubModel');
|
|
136
|
+
const exists = models.some(m => m.id === savedSub);
|
|
137
|
+
if (savedSub && exists) {
|
|
138
|
+
setSelectedSubModel(savedSub);
|
|
139
|
+
} else {
|
|
140
|
+
setSelectedSubModel(models[0].id);
|
|
141
|
+
localStorage.setItem('selectedSubModel', models[0].id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
.catch(console.error)
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- onBrainChange is stable from parent
|
|
147
|
+
}, [])
|
|
148
|
+
|
|
149
|
+
// Reload memory nodes when active selected brain changes
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
listMemory(activeBrainId)
|
|
152
|
+
.then(setAllMemoryNodes)
|
|
153
|
+
.catch(console.error)
|
|
154
|
+
}, [activeBrainId])
|
|
155
|
+
|
|
156
|
+
// Click outside listener for grounding node selector popover and model selector dropdown
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
function handleClickOutside(event: MouseEvent) {
|
|
159
|
+
if (nodeSelectorRef.current && !nodeSelectorRef.current.contains(event.target as Node)) {
|
|
160
|
+
setShowNodeSelector(false)
|
|
161
|
+
}
|
|
162
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
163
|
+
setShowModelDropdown(false)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
document.addEventListener("mousedown", handleClickOutside)
|
|
167
|
+
return () => {
|
|
168
|
+
document.removeEventListener("mousedown", handleClickOutside)
|
|
169
|
+
}
|
|
170
|
+
}, [nodeSelectorRef, dropdownRef])
|
|
171
|
+
|
|
172
|
+
// Load chat history when activeThreadId changes
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
fetchChatHistory(activeThreadId)
|
|
175
|
+
.then(history => {
|
|
176
|
+
if (history) {
|
|
177
|
+
setMessages(history as ChatMessage[])
|
|
178
|
+
msgId = Math.max(msgId, history.length + 1)
|
|
179
|
+
} else {
|
|
180
|
+
setMessages([])
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
.catch(err => {
|
|
184
|
+
console.error(err)
|
|
185
|
+
setMessages([])
|
|
186
|
+
})
|
|
187
|
+
}, [activeThreadId])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
// Probe the Kokoro endpoint once when voice mode is turned on so we know
|
|
191
|
+
// whether to call /api/tts or fall back to the browser engine.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!voiceMode || kokoroEnabled !== null) return
|
|
194
|
+
fetchTtsStatus().then(s => setKokoroEnabled(s.enabled)).catch(() => setKokoroEnabled(false))
|
|
195
|
+
}, [voiceMode, kokoroEnabled])
|
|
196
|
+
|
|
197
|
+
// Check Chrome extension availability (once on mount)
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (!extensionBannerDismissed) {
|
|
200
|
+
fetchExtensionStatus().then(setExtensionStatus).catch(() => {})
|
|
201
|
+
}
|
|
202
|
+
}, [extensionBannerDismissed])
|
|
203
|
+
|
|
204
|
+
const speakBrowser = (text: string) => {
|
|
205
|
+
if (!('speechSynthesis' in window)) return
|
|
206
|
+
window.speechSynthesis.cancel()
|
|
207
|
+
const utterance = new SpeechSynthesisUtterance(text)
|
|
208
|
+
utterance.rate = 1.05
|
|
209
|
+
utterance.pitch = 0.95
|
|
210
|
+
window.speechSynthesis.speak(utterance)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const speak = async (text: string) => {
|
|
214
|
+
if (!voiceMode || !text.trim()) return
|
|
215
|
+
if (kokoroEnabled === false) { speakBrowser(text); return }
|
|
216
|
+
try {
|
|
217
|
+
const blob = await fetchTtsAudio(text)
|
|
218
|
+
if (!blob) {
|
|
219
|
+
// 503 → Kokoro not configured. Remember that and fall back.
|
|
220
|
+
setKokoroEnabled(false)
|
|
221
|
+
speakBrowser(text)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
const url = URL.createObjectURL(blob)
|
|
225
|
+
if (audioRef.current) {
|
|
226
|
+
audioRef.current.pause()
|
|
227
|
+
URL.revokeObjectURL(audioRef.current.src)
|
|
228
|
+
}
|
|
229
|
+
const audio = new Audio(url)
|
|
230
|
+
audioRef.current = audio
|
|
231
|
+
audio.onended = () => URL.revokeObjectURL(url)
|
|
232
|
+
audio.play().catch(() => speakBrowser(text))
|
|
233
|
+
} catch {
|
|
234
|
+
speakBrowser(text)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const handleNewChat = () => {
|
|
239
|
+
const newId = `thread-${getCurrentTimestamp()}`
|
|
240
|
+
setActiveThreadId(newId)
|
|
241
|
+
setMessages([])
|
|
242
|
+
setInput('')
|
|
243
|
+
setSelectedGroundingNodes([])
|
|
244
|
+
if (textareaRef.current) textareaRef.current.style.height = '44px'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const handleDeleteThread = async (e: React.MouseEvent, id: string) => {
|
|
248
|
+
e.stopPropagation()
|
|
249
|
+
if (!confirm('Are you sure you want to delete this conversation thread?')) return
|
|
250
|
+
try {
|
|
251
|
+
await deleteChatThread(id)
|
|
252
|
+
if (activeThreadId === id) {
|
|
253
|
+
const remaining = threads.filter(t => t.id !== id)
|
|
254
|
+
if (remaining.length > 0) {
|
|
255
|
+
setActiveThreadId(remaining[0].id)
|
|
256
|
+
} else {
|
|
257
|
+
setActiveThreadId(undefined)
|
|
258
|
+
setMessages([])
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
refreshThreads()
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error('Failed to delete thread:', err)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
const handleSend = async () => {
|
|
270
|
+
const text = input.trim()
|
|
271
|
+
if (!text || loading) return
|
|
272
|
+
|
|
273
|
+
const sessionId = activeThreadId || `thread-${getCurrentTimestamp()}`
|
|
274
|
+
if (!activeThreadId) {
|
|
275
|
+
setActiveThreadId(sessionId)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const userMsg: ChatMessage = { id: String(++msgId), role: 'user', content: text, timestamp: getCurrentTimestamp() }
|
|
279
|
+
setMessages(prev => [...prev, userMsg])
|
|
280
|
+
setInput('')
|
|
281
|
+
setLoading(true)
|
|
282
|
+
|
|
283
|
+
abortControllerRef.current = new AbortController()
|
|
284
|
+
|
|
285
|
+
// Auto-resize textarea back
|
|
286
|
+
if (textareaRef.current) textareaRef.current.style.height = '44px'
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
if (deepResearchMode) {
|
|
290
|
+
setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...', timestamp: getCurrentTimestamp() }]);
|
|
291
|
+
|
|
292
|
+
const { slug } = await createTask('proactive-research', text, text);
|
|
293
|
+
|
|
294
|
+
// Poll for task completion
|
|
295
|
+
let taskCompleted = false;
|
|
296
|
+
while (!taskCompleted) {
|
|
297
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
298
|
+
const tasks = await listTasks();
|
|
299
|
+
const t = tasks.find(x => x.slug === slug);
|
|
300
|
+
if (t && (t.status === 'completed' || t.status === 'failed')) {
|
|
301
|
+
taskCompleted = true;
|
|
302
|
+
setMessages(prev => prev.filter(m => m.content !== '🕵️ **Deep Research Agents dispatched.** Gathering facts across parallel web streams...'));
|
|
303
|
+
setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: t.body || '', timestamp: getCurrentTimestamp() }]);
|
|
304
|
+
speak(t.body || '');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
setLoading(false);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
|
|
312
|
+
? `${selectedModel}:${selectedSubModel}`
|
|
313
|
+
: selectedModel
|
|
314
|
+
|
|
315
|
+
const historyToSend = [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
|
|
316
|
+
const reply = await sendChat(historyToSend, abortControllerRef.current.signal, sessionId, selectedGroundingNodes, modelWithSubModel, activeBrainId)
|
|
317
|
+
const assistantMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: reply, timestamp: getCurrentTimestamp(), versions: [reply], currentVersionIndex: 0 }
|
|
318
|
+
setMessages(prev => [...prev, assistantMsg])
|
|
319
|
+
speak(reply)
|
|
320
|
+
refreshThreads()
|
|
321
|
+
} catch (e: unknown) {
|
|
322
|
+
if ((e as Error).name === 'AbortError') {
|
|
323
|
+
setMessages(prev => [...prev, { id: String(++msgId), role: 'assistant', content: '⛔ Generation stopped by user.', timestamp: getCurrentTimestamp(), versions: ['⛔ Generation stopped by user.'], currentVersionIndex: 0 }])
|
|
324
|
+
} else {
|
|
325
|
+
const errorMsg: ChatMessage = { id: String(++msgId), role: 'assistant', content: `⚠️ Error: ${(e as Error).message}`, timestamp: getCurrentTimestamp(), versions: [`⚠️ Error: ${(e as Error).message}`], currentVersionIndex: 0 }
|
|
326
|
+
setMessages(prev => [...prev, errorMsg])
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
setLoading(false)
|
|
330
|
+
abortControllerRef.current = null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const handleRegenerate = async (msgIndex: number) => {
|
|
335
|
+
if (loading) return;
|
|
336
|
+
const historyToSend = messages.slice(0, msgIndex).map(m => ({ role: m.role, content: m.content }))
|
|
337
|
+
setLoading(true)
|
|
338
|
+
abortControllerRef.current = new AbortController()
|
|
339
|
+
try {
|
|
340
|
+
const modelWithSubModel = ['gemini', 'antigravity'].includes(selectedModel) && selectedSubModel
|
|
341
|
+
? `${selectedModel}:${selectedSubModel}`
|
|
342
|
+
: selectedModel
|
|
343
|
+
|
|
344
|
+
const reply = await sendChat(historyToSend, abortControllerRef.current.signal, activeThreadId, undefined, modelWithSubModel, activeBrainId)
|
|
345
|
+
setMessages(prev => {
|
|
346
|
+
const next = [...prev]
|
|
347
|
+
const target = next[msgIndex]
|
|
348
|
+
const versions = target.versions || [target.content]
|
|
349
|
+
versions.push(reply)
|
|
350
|
+
target.versions = versions
|
|
351
|
+
target.currentVersionIndex = versions.length - 1
|
|
352
|
+
target.content = reply
|
|
353
|
+
return next
|
|
354
|
+
})
|
|
355
|
+
speak(reply)
|
|
356
|
+
refreshThreads()
|
|
357
|
+
} catch (e: unknown) {
|
|
358
|
+
if ((e as Error).name !== 'AbortError') console.error(e)
|
|
359
|
+
} finally {
|
|
360
|
+
setLoading(false)
|
|
361
|
+
abortControllerRef.current = null
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const handleCopy = (content: string) => {
|
|
366
|
+
navigator.clipboard.writeText(content).catch(console.error)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const handleVersionSwitch = (msgIndex: number, delta: number) => {
|
|
370
|
+
setMessages(prev => {
|
|
371
|
+
const next = [...prev]
|
|
372
|
+
const target = next[msgIndex]
|
|
373
|
+
if (!target.versions) return prev
|
|
374
|
+
const newIndex = Math.max(0, Math.min(target.versions.length - 1, (target.currentVersionIndex || 0) + delta))
|
|
375
|
+
target.currentVersionIndex = newIndex
|
|
376
|
+
target.content = target.versions[newIndex]
|
|
377
|
+
return next
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const handleStop = () => {
|
|
382
|
+
if (abortControllerRef.current) {
|
|
383
|
+
abortControllerRef.current.abort()
|
|
384
|
+
abortControllerRef.current = null
|
|
385
|
+
setLoading(false)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
390
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
391
|
+
e.preventDefault()
|
|
392
|
+
handleSend()
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
397
|
+
const value = e.target.value
|
|
398
|
+
setInput(value)
|
|
399
|
+
const ta = e.target
|
|
400
|
+
ta.style.height = '44px'
|
|
401
|
+
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'
|
|
402
|
+
|
|
403
|
+
// Detect URLs
|
|
404
|
+
const urlMatch = value.match(/https?:\/\/[^\s]+/)
|
|
405
|
+
setDetectedUrl(urlMatch ? urlMatch[0] : null)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const handleUrlAction = async (action: string) => {
|
|
409
|
+
if (!detectedUrl) return
|
|
410
|
+
try {
|
|
411
|
+
await shareToApi({ url: detectedUrl, action })
|
|
412
|
+
setUrlToast(`✅ URL sent to ${action}`)
|
|
413
|
+
setDetectedUrl(null)
|
|
414
|
+
setTimeout(() => setUrlToast(null), 3000)
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.error('Share API error:', err)
|
|
417
|
+
setUrlToast('⚠️ Failed to process URL')
|
|
418
|
+
setTimeout(() => setUrlToast(null), 3000)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const filteredNodes = allMemoryNodes.filter(node =>
|
|
423
|
+
node.title.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
|
|
424
|
+
node.slug.toLowerCase().includes(nodeSearchQuery.toLowerCase()) ||
|
|
425
|
+
(node.category && node.category.toLowerCase().includes(nodeSearchQuery.toLowerCase()))
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<div className="chat-layout">
|
|
430
|
+
{/* Threads Sidebar */}
|
|
431
|
+
<aside className="chat-threads-sidebar">
|
|
432
|
+
<div className="sidebar-header">
|
|
433
|
+
<button className="new-chat-btn" onClick={handleNewChat}>
|
|
434
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 6 }}>
|
|
435
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
436
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
437
|
+
</svg>
|
|
438
|
+
New Chat
|
|
439
|
+
</button>
|
|
440
|
+
</div>
|
|
441
|
+
<div className="thread-list">
|
|
442
|
+
{threads.map(t => (
|
|
443
|
+
<div
|
|
444
|
+
key={t.id}
|
|
445
|
+
className={`thread-item ${activeThreadId === t.id ? 'active' : ''}`}
|
|
446
|
+
onClick={() => {
|
|
447
|
+
setActiveThreadId(t.id)
|
|
448
|
+
// Auto-switch brain context when selecting a thread
|
|
449
|
+
if (t.brainId && onBrainChange) {
|
|
450
|
+
onBrainChange(t.brainId)
|
|
451
|
+
}
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
<div className="thread-info">
|
|
455
|
+
<div className="thread-title" title={t.title}>{t.title}</div>
|
|
456
|
+
<div className="thread-meta">
|
|
457
|
+
<span>{t.turns} {t.turns === 1 ? 'turn' : 'turns'}</span>
|
|
458
|
+
<span>•</span>
|
|
459
|
+
<span>{formatRelativeTime(t.lastUpdated)}</span>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
<button
|
|
463
|
+
className="thread-delete-btn"
|
|
464
|
+
onClick={(e) => handleDeleteThread(e, t.id)}
|
|
465
|
+
title="Delete chat thread"
|
|
466
|
+
>
|
|
467
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
468
|
+
<polyline points="3 6 5 6 21 6"></polyline>
|
|
469
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
470
|
+
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
471
|
+
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
472
|
+
</svg>
|
|
473
|
+
</button>
|
|
474
|
+
</div>
|
|
475
|
+
))}
|
|
476
|
+
</div>
|
|
477
|
+
</aside>
|
|
478
|
+
|
|
479
|
+
{/* Main Chat Area */}
|
|
480
|
+
<div className="chat-container">
|
|
481
|
+
<header className="chat-header animate-fade-in">
|
|
482
|
+
<div className="chat-header-title">
|
|
483
|
+
<h2>Chat Session</h2>
|
|
484
|
+
{activeThreadId && (
|
|
485
|
+
<span className="badge badge-accent" style={{ textTransform: 'none', letterSpacing: 'normal', fontSize: '11px', padding: '2px 8px' }}>
|
|
486
|
+
{activeThreadId}
|
|
487
|
+
</span>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
<div className="chat-header-model-selector" ref={dropdownRef}>
|
|
491
|
+
<span className="selector-label">Model:</span>
|
|
492
|
+
<div className="model-selector-dropdown-wrapper">
|
|
493
|
+
<button
|
|
494
|
+
className={`model-selector-dropdown-btn ${showModelDropdown ? 'active' : ''}`}
|
|
495
|
+
onClick={() => setShowModelDropdown(!showModelDropdown)}
|
|
496
|
+
title="Select CLI agent model"
|
|
497
|
+
>
|
|
498
|
+
<span style={{ textTransform: 'capitalize' }}>{selectedModel || 'Select Model'}</span>
|
|
499
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
500
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
501
|
+
</svg>
|
|
502
|
+
</button>
|
|
503
|
+
{showModelDropdown && (
|
|
504
|
+
<div className="model-selector-menu glass">
|
|
505
|
+
{availableModels.map(modelName => (
|
|
506
|
+
<div
|
|
507
|
+
key={modelName}
|
|
508
|
+
className={`model-selector-item ${selectedModel === modelName ? 'selected' : ''}`}
|
|
509
|
+
onClick={() => {
|
|
510
|
+
setSelectedModel(modelName)
|
|
511
|
+
localStorage.setItem('selectedModel', modelName)
|
|
512
|
+
setSelectedSubModel('')
|
|
513
|
+
localStorage.removeItem('selectedSubModel')
|
|
514
|
+
setShowModelDropdown(false)
|
|
515
|
+
}}
|
|
516
|
+
>
|
|
517
|
+
<div className="model-selector-item-info">
|
|
518
|
+
<span className="model-selector-item-name" style={{ textTransform: 'capitalize' }}>{modelName}</span>
|
|
519
|
+
<span className="model-selector-item-desc">
|
|
520
|
+
{modelName === 'antigravity' && 'Google Antigravity'}
|
|
521
|
+
{modelName === 'gemini' && 'Google Gemini CLI'}
|
|
522
|
+
{modelName === 'claude' && 'Anthropic Claude CLI'}
|
|
523
|
+
{modelName === 'codex' && 'Codex Coding Agent'}
|
|
524
|
+
</span>
|
|
525
|
+
</div>
|
|
526
|
+
<span className="model-selector-item-badge">CLI</span>
|
|
527
|
+
</div>
|
|
528
|
+
))}
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
{['gemini', 'antigravity'].includes(selectedModel) && geminiModels.length > 0 && (
|
|
533
|
+
<div className="chat-header-submodel-selector" style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
|
|
534
|
+
<span className="selector-label" style={{ fontSize: '12px', color: 'var(--text-tertiary)', fontWeight: 500 }}>Base:</span>
|
|
535
|
+
<select
|
|
536
|
+
value={selectedSubModel}
|
|
537
|
+
onChange={(e) => {
|
|
538
|
+
setSelectedSubModel(e.target.value)
|
|
539
|
+
localStorage.setItem('selectedSubModel', e.target.value)
|
|
540
|
+
}}
|
|
541
|
+
className="submodel-select"
|
|
542
|
+
style={{
|
|
543
|
+
background: 'rgba(255, 255, 255, 0.04)',
|
|
544
|
+
border: '1px solid var(--border)',
|
|
545
|
+
borderRadius: 'var(--radius-sm)',
|
|
546
|
+
color: 'var(--text-secondary)',
|
|
547
|
+
padding: '6px 12px',
|
|
548
|
+
fontSize: '13px',
|
|
549
|
+
fontWeight: 500,
|
|
550
|
+
outline: 'none',
|
|
551
|
+
cursor: 'pointer',
|
|
552
|
+
transition: 'all var(--transition-fast)'
|
|
553
|
+
}}
|
|
554
|
+
>
|
|
555
|
+
{geminiModels.map(m => (
|
|
556
|
+
<option key={m.id} value={m.id} style={{ background: '#12121a', color: 'var(--text-secondary)' }}>{m.displayName}</option>
|
|
557
|
+
))}
|
|
558
|
+
</select>
|
|
559
|
+
</div>
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
</header>
|
|
563
|
+
|
|
564
|
+
{/* Chrome Extension Banner — shows when available but not connected */}
|
|
565
|
+
{extensionStatus?.available && !extensionStatus.connected && !extensionBannerDismissed && (
|
|
566
|
+
<div style={{
|
|
567
|
+
margin: '0 16px 0',
|
|
568
|
+
padding: '12px 16px',
|
|
569
|
+
background: 'linear-gradient(135deg, rgba(108, 92, 231, 0.12), rgba(99, 179, 237, 0.12))',
|
|
570
|
+
border: '1px solid rgba(108, 92, 231, 0.3)',
|
|
571
|
+
borderRadius: 10,
|
|
572
|
+
display: 'flex',
|
|
573
|
+
alignItems: 'center',
|
|
574
|
+
gap: 12,
|
|
575
|
+
animation: 'fadeIn 0.3s ease'
|
|
576
|
+
}}>
|
|
577
|
+
<span style={{ fontSize: 22, flexShrink: 0 }}>🧩</span>
|
|
578
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
579
|
+
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
|
|
580
|
+
Chrome Extension Available
|
|
581
|
+
</div>
|
|
582
|
+
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
|
|
583
|
+
Browse with your brain — contextual memory, quick capture, and research from any page
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
<a
|
|
587
|
+
href="/api/extension/download"
|
|
588
|
+
download
|
|
589
|
+
style={{
|
|
590
|
+
flexShrink: 0,
|
|
591
|
+
padding: '6px 14px',
|
|
592
|
+
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
|
|
593
|
+
color: '#fff',
|
|
594
|
+
borderRadius: 6,
|
|
595
|
+
fontWeight: 500,
|
|
596
|
+
fontSize: 12,
|
|
597
|
+
textDecoration: 'none',
|
|
598
|
+
whiteSpace: 'nowrap'
|
|
599
|
+
}}
|
|
600
|
+
>
|
|
601
|
+
⬇ Download
|
|
602
|
+
</a>
|
|
603
|
+
<button
|
|
604
|
+
onClick={() => {
|
|
605
|
+
setExtensionBannerDismissed(true)
|
|
606
|
+
localStorage.setItem('tr-ext-banner-dismissed', 'true')
|
|
607
|
+
}}
|
|
608
|
+
style={{
|
|
609
|
+
flexShrink: 0,
|
|
610
|
+
background: 'none',
|
|
611
|
+
border: 'none',
|
|
612
|
+
color: 'var(--text-tertiary)',
|
|
613
|
+
cursor: 'pointer',
|
|
614
|
+
fontSize: 16,
|
|
615
|
+
padding: '0 2px',
|
|
616
|
+
lineHeight: 1
|
|
617
|
+
}}
|
|
618
|
+
title="Dismiss"
|
|
619
|
+
>✕</button>
|
|
620
|
+
</div>
|
|
621
|
+
)}
|
|
622
|
+
|
|
623
|
+
<div className="chat-messages">
|
|
624
|
+
{messages.length === 0 && (
|
|
625
|
+
<Graph3D
|
|
626
|
+
threads={threads}
|
|
627
|
+
memoryNodes={allMemoryNodes}
|
|
628
|
+
researchItems={researchItems}
|
|
629
|
+
onOpenThread={(threadId) => setActiveThreadId(threadId)}
|
|
630
|
+
onGroundMemoryNode={(slug) => {
|
|
631
|
+
setSelectedGroundingNodes(prev =>
|
|
632
|
+
prev.includes(slug) ? prev.filter(s => s !== slug) : [...prev, slug]
|
|
633
|
+
)
|
|
634
|
+
}}
|
|
635
|
+
selectedGroundingNodes={selectedGroundingNodes}
|
|
636
|
+
/>
|
|
637
|
+
)}
|
|
638
|
+
{messages.map((m, index) => (
|
|
639
|
+
<div key={m.id} className={`message message-${m.role}`}>
|
|
640
|
+
<div style={{ whiteSpace: 'pre-wrap', overflowWrap: 'break-word' }}>{m.content}</div>
|
|
641
|
+
{m.role === 'assistant' && (
|
|
642
|
+
<div style={{ display: 'flex', gap: 12, marginTop: 12, fontSize: 13, color: 'var(--text-tertiary)', alignItems: 'center', userSelect: 'none' }}>
|
|
643
|
+
{m.versions && m.versions.length > 1 && (
|
|
644
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginRight: 4 }}>
|
|
645
|
+
<button
|
|
646
|
+
onClick={() => handleVersionSwitch(index, -1)}
|
|
647
|
+
disabled={(m.currentVersionIndex || 0) === 0}
|
|
648
|
+
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === 0 ? 0.3 : 1 }}
|
|
649
|
+
>◄</button>
|
|
650
|
+
<span>{(m.currentVersionIndex || 0) + 1} / {m.versions.length}</span>
|
|
651
|
+
<button
|
|
652
|
+
onClick={() => handleVersionSwitch(index, 1)}
|
|
653
|
+
disabled={(m.currentVersionIndex || 0) === m.versions.length - 1}
|
|
654
|
+
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: 2, opacity: (m.currentVersionIndex || 0) === m.versions.length - 1 ? 0.3 : 1 }}
|
|
655
|
+
>►</button>
|
|
656
|
+
</div>
|
|
657
|
+
)}
|
|
658
|
+
<button onClick={() => handleCopy(m.content)} title="Copy to clipboard" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
659
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
660
|
+
</button>
|
|
661
|
+
<button onClick={() => speak(m.content)} title="Read Aloud" style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
662
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>
|
|
663
|
+
</button>
|
|
664
|
+
<button onClick={() => handleRegenerate(index)} disabled={loading} title="Regenerate Response" style={{ background: 'none', border: 'none', color: 'inherit', cursor: loading ? 'default' : 'pointer', display: 'flex', alignItems: 'center', gap: 4, opacity: loading ? 0.3 : 1 }}>
|
|
665
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"></polyline><polyline points="23 20 23 14 17 14"></polyline><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path></svg>
|
|
666
|
+
</button>
|
|
667
|
+
</div>
|
|
668
|
+
)}
|
|
669
|
+
</div>
|
|
670
|
+
))}
|
|
671
|
+
{loading && (
|
|
672
|
+
<div className="message message-assistant">
|
|
673
|
+
<span className="skeleton" style={{ display: 'inline-block', width: 180, height: 16 }} />
|
|
674
|
+
</div>
|
|
675
|
+
)}
|
|
676
|
+
<div ref={messagesEnd} />
|
|
677
|
+
</div>
|
|
678
|
+
<div className="chat-input-bar">
|
|
679
|
+
{selectedGroundingNodes.length > 0 && (
|
|
680
|
+
<div className="grounding-pills">
|
|
681
|
+
{selectedGroundingNodes.map(slug => {
|
|
682
|
+
const node = allMemoryNodes.find(n => n.slug === slug);
|
|
683
|
+
return (
|
|
684
|
+
<div key={slug} className="grounding-pill animate-fade-in">
|
|
685
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
686
|
+
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
|
687
|
+
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
|
688
|
+
</svg>
|
|
689
|
+
<span className="grounding-pill-title">{node?.title || slug}</span>
|
|
690
|
+
<button className="grounding-pill-remove" onClick={() => setSelectedGroundingNodes(prev => prev.filter(s => s !== slug))} title="Remove grounding context">×</button>
|
|
691
|
+
</div>
|
|
692
|
+
);
|
|
693
|
+
})}
|
|
694
|
+
</div>
|
|
695
|
+
)}
|
|
696
|
+
<div className="chat-input-wrapper">
|
|
697
|
+
<button
|
|
698
|
+
className={`btn btn-ghost ${voiceMode ? 'active' : ''}`}
|
|
699
|
+
style={{ width: 44, height: 44, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', color: voiceMode ? 'var(--accent)' : 'var(--text-tertiary)', borderColor: voiceMode ? 'var(--accent)' : 'var(--border)' }}
|
|
700
|
+
onClick={() => {
|
|
701
|
+
setVoiceMode(!voiceMode)
|
|
702
|
+
if (voiceMode && 'speechSynthesis' in window) window.speechSynthesis.cancel()
|
|
703
|
+
}}
|
|
704
|
+
title={voiceMode ? "Voice Mode On" : "Voice Mode Off"}
|
|
705
|
+
>
|
|
706
|
+
{voiceMode ? (
|
|
707
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 20, height: 20 }}>
|
|
708
|
+
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/><line x1="8" x2="16" y1="22" y2="22"/>
|
|
709
|
+
</svg>
|
|
710
|
+
) : (
|
|
711
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 20, height: 20 }}>
|
|
712
|
+
<line x1="1" x2="23" y1="1" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.88"/><path d="M19 10v2a7 7 0 0 1-1.39 4.2M5 10v2a7 7 0 0 0 1.93 4.88"/><line x1="12" x2="12" y1="19" y2="22"/><line x1="8" x2="16" y1="22" y2="22"/>
|
|
713
|
+
</svg>
|
|
714
|
+
)}
|
|
715
|
+
</button>
|
|
716
|
+
|
|
717
|
+
<button
|
|
718
|
+
className={`btn btn-ghost ${deepResearchMode ? 'active' : ''}`}
|
|
719
|
+
style={{ width: 44, height: 44, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', color: deepResearchMode ? 'var(--accent)' : 'var(--text-tertiary)', borderColor: deepResearchMode ? 'var(--accent)' : 'var(--border)' }}
|
|
720
|
+
onClick={() => setDeepResearchMode(!deepResearchMode)}
|
|
721
|
+
title={deepResearchMode ? "Deep Research On" : "Deep Research Off"}
|
|
722
|
+
>
|
|
723
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 18, height: 18 }}>
|
|
724
|
+
<path d="M12 2v20"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
|
725
|
+
</svg>
|
|
726
|
+
</button>
|
|
727
|
+
|
|
728
|
+
<div style={{ position: 'relative', display: 'flex' }} ref={nodeSelectorRef}>
|
|
729
|
+
<button
|
|
730
|
+
className={`btn btn-ghost ${selectedGroundingNodes.length > 0 ? 'active' : ''}`}
|
|
731
|
+
style={{ width: 44, height: 44, padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', color: selectedGroundingNodes.length > 0 ? 'var(--accent)' : 'var(--text-tertiary)', borderColor: selectedGroundingNodes.length > 0 ? 'var(--accent)' : 'var(--border)' }}
|
|
732
|
+
onClick={() => setShowNodeSelector(!showNodeSelector)}
|
|
733
|
+
title="Ground with brain nodes"
|
|
734
|
+
>
|
|
735
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 18, height: 18 }}>
|
|
736
|
+
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
|
737
|
+
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
|
738
|
+
</svg>
|
|
739
|
+
{selectedGroundingNodes.length > 0 && (
|
|
740
|
+
<span className="grounding-count-badge">{selectedGroundingNodes.length}</span>
|
|
741
|
+
)}
|
|
742
|
+
</button>
|
|
743
|
+
|
|
744
|
+
{showNodeSelector && (
|
|
745
|
+
<div className="node-selector-popover glass">
|
|
746
|
+
<div className="node-selector-header">
|
|
747
|
+
<h4>Ground with Brain Nodes</h4>
|
|
748
|
+
<input
|
|
749
|
+
type="text"
|
|
750
|
+
placeholder="Search nodes..."
|
|
751
|
+
value={nodeSearchQuery}
|
|
752
|
+
onChange={e => setNodeSearchQuery(e.target.value)}
|
|
753
|
+
className="node-search-input"
|
|
754
|
+
autoFocus
|
|
755
|
+
/>
|
|
756
|
+
</div>
|
|
757
|
+
<div className="node-selector-list">
|
|
758
|
+
{filteredNodes.length === 0 ? (
|
|
759
|
+
<div className="node-selector-empty">No nodes found</div>
|
|
760
|
+
) : (
|
|
761
|
+
filteredNodes.map(node => {
|
|
762
|
+
const isSelected = selectedGroundingNodes.includes(node.slug);
|
|
763
|
+
return (
|
|
764
|
+
<div
|
|
765
|
+
key={node.slug}
|
|
766
|
+
className={`node-selector-item ${isSelected ? 'selected' : ''}`}
|
|
767
|
+
onClick={() => {
|
|
768
|
+
if (isSelected) {
|
|
769
|
+
setSelectedGroundingNodes(prev => prev.filter(s => s !== node.slug));
|
|
770
|
+
} else {
|
|
771
|
+
setSelectedGroundingNodes(prev => [...prev, node.slug]);
|
|
772
|
+
}
|
|
773
|
+
}}
|
|
774
|
+
>
|
|
775
|
+
<span className="node-selector-item-checkbox">
|
|
776
|
+
{isSelected ? '✓' : ''}
|
|
777
|
+
</span>
|
|
778
|
+
<div className="node-selector-item-info">
|
|
779
|
+
<span className="node-selector-item-title">{node.title}</span>
|
|
780
|
+
<span className="node-selector-item-meta">{node.category} • {node.status || 'active'}</span>
|
|
781
|
+
</div>
|
|
782
|
+
</div>
|
|
783
|
+
);
|
|
784
|
+
})
|
|
785
|
+
)}
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
)}
|
|
789
|
+
</div>
|
|
790
|
+
|
|
791
|
+
<textarea
|
|
792
|
+
ref={textareaRef}
|
|
793
|
+
id="chat-input"
|
|
794
|
+
value={input}
|
|
795
|
+
onChange={handleInput}
|
|
796
|
+
onKeyDown={handleKeyDown}
|
|
797
|
+
placeholder="Message your brain…"
|
|
798
|
+
rows={1}
|
|
799
|
+
disabled={loading}
|
|
800
|
+
/>
|
|
801
|
+
{loading ? (
|
|
802
|
+
<button className="chat-send-btn" onClick={handleStop} id="chat-stop" style={{ background: 'var(--error)', color: '#fff' }} title="Stop Generation">
|
|
803
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style={{ width: 20, height: 20 }}>
|
|
804
|
+
<rect x="6" y="6" width="12" height="12" rx="2" ry="2"/>
|
|
805
|
+
</svg>
|
|
806
|
+
</button>
|
|
807
|
+
) : (
|
|
808
|
+
<button className="chat-send-btn" onClick={handleSend} disabled={!input.trim()} id="chat-send" title="Send Message">
|
|
809
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
810
|
+
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
|
|
811
|
+
</svg>
|
|
812
|
+
</button>
|
|
813
|
+
)}
|
|
814
|
+
</div>
|
|
815
|
+
{detectedUrl && (
|
|
816
|
+
<div style={{ display: 'flex', gap: '8px', padding: '8px 12px', background: 'var(--bg-secondary, #1e1e2e)', borderRadius: '8px', alignItems: 'center', fontSize: '0.85rem', marginTop: 4 }}>
|
|
817
|
+
<span style={{ color: 'var(--text-secondary)' }}>🔗 URL detected</span>
|
|
818
|
+
<button className="btn btn-sm" onClick={() => handleUrlAction('research')}>🔬 Research</button>
|
|
819
|
+
<button className="btn btn-sm" onClick={() => handleUrlAction('remember')}>📌 Remember</button>
|
|
820
|
+
</div>
|
|
821
|
+
)}
|
|
822
|
+
{urlToast && (
|
|
823
|
+
<div style={{ padding: '6px 12px', background: 'rgba(46, 204, 113, 0.1)', border: '1px solid rgba(46, 204, 113, 0.3)', borderRadius: '8px', fontSize: '0.8rem', color: 'var(--success)', marginTop: 4, textAlign: 'center' }}>
|
|
824
|
+
{urlToast}
|
|
825
|
+
</div>
|
|
826
|
+
)}
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
</div>
|
|
830
|
+
)
|
|
831
|
+
}
|