// /Users/giers/Heimgeist/src/App.jsx import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { flushSync } from 'react-dom'; import TextareaAutosize from 'react-textarea-autosize'; import GeneralSettings from './GeneralSettings' import InterfaceSettings from './InterfaceSettings' import LibraryManager from './LibraryManager' import WebsearchSettings from './WebsearchSettings' import { markdownToHTML } from './markdown'; import { applyColorScheme } from './colorSchemes' import { loadStoredWebsearchEngines, normalizeWebsearchEngines, } from './websearchEngines' import { getAudioInputConstraints, getPreferredAudioRecorderMimeType, stopMediaStream, supportsAudioInputCapture, } from './audioInput' function sanitizeGeneratedChatTitle(title) { return (title || '') .replace(/[\s\S]*?<\/think(?:ing)?>/gi, '') .replace(/[*#]/g, '') .replace(/\s+/g, ' ') .trim() } // Extract or block (first occurrence) and return { think, answer } function splitThinkBlocks(text) { if (!text) return { think: null, answer: '' }; const openTagRe = //i; const closeTagRe = /<\/think(?:ing)?>/i; const openMatch = text.match(openTagRe); if (!openMatch) { // No opening tag found, so all content is answer return { think: null, answer: text }; } const openTagIndex = openMatch.index; const openTagLength = openMatch[0].length; const answerPartBeforeThink = text.substring(0, openTagIndex).trim(); let contentAfterOpenTag = text.substring(openTagIndex + openTagLength); const closeMatch = contentAfterOpenTag.match(closeTagRe); let thinkInner = null; let finalAnswer = answerPartBeforeThink; if (closeMatch) { // Both open and close tags are present thinkInner = contentAfterOpenTag.substring(0, closeMatch.index).trim(); finalAnswer += contentAfterOpenTag.substring(closeMatch.index + closeMatch[0].length); } else { // Only open tag found (streaming case), take everything after it as think thinkInner = contentAfterOpenTag.trim(); } return { think: thinkInner || null, answer: finalAnswer.trim() }; } // Renders assistant message with a collapsible "Thoughts" block (if present) function AssistantMessageContent({ content, streamOutput, sources }) { const { think, answer } = splitThinkBlocks(content || ''); const [open, setOpen] = React.useState(false); const showThinkButton = !!think; return (
{showThinkButton && (
{open && (
)}
)}
{Array.isArray(sources) && sources.length > 0 && (
{sources.map((u, i) => { let label = u; let isFile = false; try { const parsed = new URL(u); if (parsed.protocol === 'file:') { isFile = true; const parts = parsed.pathname.split('/').filter(Boolean); label = decodeURIComponent(parts[parts.length - 1] || u); } else { const host = parsed.hostname || u; label = host.replace(/^www\./i, ''); } } catch {} return ( { if (!isFile) return; event.preventDefault(); try { const parsed = new URL(u); window.electronAPI?.openPath?.(decodeURIComponent(parsed.pathname)); } catch {} }} > {label} ); })}
)}
); } function ImageAttachmentStrip({ attachments, className = '', removable = false, onRemove }) { if (!Array.isArray(attachments) || attachments.length === 0) { return null; } return (
{attachments.map((attachment, index) => { const src = attachment?.data_url; if (!src) return null; const key = attachment.id || `${attachment.name || 'image'}-${index}-${src.length}`; return (
{removable && ( )} {attachment.name
); })}
); } const API_URL_KEY = 'backendApiUrl'; const COLOR_SCHEME_KEY = 'colorScheme'; const WEBSEARCH_URL_KEY = 'websearch.searxUrl'; const WEBSEARCH_ENGINES_KEY = 'websearch.engines'; const CHAT_LIBRARY_MAP_KEY = 'chat.libraryBySession'; const DEFAULT_SEARX_URL = 'http://127.0.0.1:8888'; const MAX_IMAGE_ATTACHMENTS = 6; const MAX_IMAGE_ATTACHMENT_BYTES = 20 * 1024 * 1024; const MAX_AUDIO_RECORDING_MS = 5 * 60 * 1000; const AUDIO_RECORDING_TICK_MS = 200; // Initial API value will be set by useEffect after settings are loaded let API = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'; const TOP_ALIGN_OFFSET = 48; // match .chat padding + header height for exact top alignment (should be more dynamic depending on header height) const BOTTOM_EPSILON = 24; // px tolerance for treating as bottom function resolveBackendApiUrl(settings) { return settings.backendApiUrl || settings.ollamaApiUrl || API; } function migrateLegacySearxUrl(value) { const trimmed = typeof value === 'string' ? value.trim() : ''; if (!trimmed) return DEFAULT_SEARX_URL; if (trimmed === 'http://localhost:8888') return DEFAULT_SEARX_URL; return trimmed; } function hasFilePayload(event) { const types = Array.from(event?.dataTransfer?.types || []); return types.includes('Files'); } function isImageFile(file) { if (!file) return false; if (typeof file.type === 'string' && file.type.toLowerCase().startsWith('image/')) { return true; } return /\.(png|jpe?g|gif|bmp|webp|tiff?|heic|avif)$/i.test(file.name || ''); } function eventHasImageFiles(event) { const items = Array.from(event?.dataTransfer?.items || []); if (items.length > 0) { return items.some(item => item.kind === 'file' && isImageFile(item.getAsFile?.())); } const files = Array.from(event?.dataTransfer?.files || []); return files.some(isImageFile); } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(reader.error || new Error(`Failed to read ${file?.name || 'image'}`)); reader.onload = () => resolve(String(reader.result || '')); reader.readAsDataURL(file); }); } function appendTranscriptToComposer(currentInput, transcript) { const nextTranscript = String(transcript || '').trim() if (!nextTranscript) { return currentInput || '' } const existing = String(currentInput || '') if (!existing.trim()) { return nextTranscript } const separator = /[\s\n]$/.test(existing) ? '' : '\n' return `${existing}${separator}${nextTranscript}` } function formatRecordingDuration(milliseconds) { const totalSeconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000)) const minutes = Math.floor(totalSeconds / 60) const seconds = totalSeconds % 60 return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } export default function App() { const [chatSessions, setChatSessions] = useState([]) const [activeSessionId, setActiveSessionId] = useState(null) const [activeSidebarMode, setActiveSidebarMode] = useState('chats') // 'chats', 'dbs', 'settings' const [activeSettingsSubmenu, setActiveSettingsSubmenu] = useState('General'); // 'General', 'Interface' const [editingSessionId, setEditingSessionId] = useState(null); // ID of the session being edited const [editingLibrarySlug, setEditingLibrarySlug] = useState(null) const [libraries, setLibraries] = useState([]) const [libraryJobs, setLibraryJobs] = useState([]) const [activeLibrarySlug, setActiveLibrarySlug] = useState(null) const [chatLibraryBySession, setChatLibraryBySession] = useState(() => { try { const raw = localStorage.getItem(CHAT_LIBRARY_MAP_KEY) return raw ? JSON.parse(raw) : {} } catch { return {} } }) const [isCreatingLibrary, setIsCreatingLibrary] = useState(false) const [newLibraryName, setNewLibraryName] = useState('') const [libraryCreateError, setLibraryCreateError] = useState('') const [isDbPickerOpen, setIsDbPickerOpen] = useState(false) // Use currentSessionId for the actual chat operations const [model, setModel] = useState('') const [selectedModelSupportsVision, setSelectedModelSupportsVision] = useState(false) const [input, setInput] = useState('') const [composerAttachments, setComposerAttachments] = useState([]) const [isChatDragActive, setIsChatDragActive] = useState(false) const chatRef = useRef(null) const textareaRef = useRef(null); // Ref for the textarea const dbPickerRef = useRef(null) const imageInputRef = useRef(null) const imageDragDepthRef = useRef(0) const [audioInputEnabled, setAudioInputEnabled] = useState(false) const [audioInputDeviceId, setAudioInputDeviceId] = useState('') const [audioInputLanguage, setAudioInputLanguage] = useState('') const [audioInputRuntimeReady, setAudioInputRuntimeReady] = useState(true) const [audioInputRuntimeMessage, setAudioInputRuntimeMessage] = useState('') const [isRecordingAudio, setIsRecordingAudio] = useState(false) const [isTranscribingAudio, setIsTranscribingAudio] = useState(false) const [audioRecordingMs, setAudioRecordingMs] = useState(0) const audioRecorderRef = useRef(null) const audioStreamRef = useRef(null) const audioChunksRef = useRef([]) const audioStopPromiseRef = useRef(null) const audioRecorderMimeTypeRef = useRef('') const audioTickTimerRef = useRef(null) const audioAutoStopTimerRef = useRef(null) const audioStartedAtRef = useRef(0) const audioTranscriptionAbortRef = useRef(null) const [backendApiUrl, setBackendApiUrl] = useState(API); // State for Heimgeist backend URL const [colorScheme, setColorScheme] = useState('Default'); // State for color scheme const [streamOutput, setStreamOutput] = useState(false); const [startupTaskMessage, setStartupTaskMessage] = useState(''); const [startupTaskBusy, setStartupTaskBusy] = useState(false); const [searxUrl, setSearxUrl] = useState(() => migrateLegacySearxUrl(localStorage.getItem(WEBSEARCH_URL_KEY))); const [searxEngines, setSearxEngines] = useState(() => loadStoredWebsearchEngines(localStorage.getItem(WEBSEARCH_ENGINES_KEY)) ); useEffect(() => { localStorage.setItem(WEBSEARCH_URL_KEY, searxUrl || ''); }, [searxUrl]); useEffect(() => { try { localStorage.setItem( WEBSEARCH_ENGINES_KEY, JSON.stringify(normalizeWebsearchEngines(searxEngines)) ); } catch {} }, [searxEngines]); const [webSearchEnabled, setWebSearchEnabled] = useState(false); const [isSending, setIsSending] = useState(false); const [loading, setLoading] = useState(true); // Loading state for initial session fetch const [unreadSessions, setUnreadSessions] = useState([]); // Track unread messages const [scrollPositions, setScrollPositions] = useState({}); // Store scroll positions for each session const [settingsLoaded, setSettingsLoaded] = useState(false); const startupOllamaCheckRanRef = useRef(false); // Editing state for user messages const [editingMessageIndex, setEditingMessageIndex] = useState(null); const [editText, setEditText] = useState(''); // Helpers + handlers for message copy/edit/regenerate (must live inside App) function getMarkdownForCopy(message) { const raw = message.content || ''; if (message.role === 'assistant') { // Copy the assistant's raw *markdown answer*, not rendered text, // and strip any ... block. try { const { answer } = splitThinkBlocks(raw); return (answer || raw).trim(); } catch { return raw.trim(); } } // User messages: copy exactly as typed return raw; } async function handleCopyMessage(message) { try { await navigator.clipboard.writeText(getMarkdownForCopy(message)); } catch (err) { console.error('Failed to copy message:', err); } } function setAssistantMessageContent(sessionId, messageId, content, options = {}) { const { removeIfEmpty = false } = options setChatSessions(prevSessions => prevSessions.map(session => { if (session.session_id !== sessionId) return session const nextMessages = [] for (const message of session.messages || []) { if (message.id !== messageId) { nextMessages.push(message) continue } if (removeIfEmpty && !content) continue nextMessages.push({ ...message, content }) } return { ...session, messages: nextMessages } }) ) } function isAbortError(error) { return error?.name === 'AbortError' } function getErrorText(error) { if (error instanceof Error && error.message) return error.message return String(error) } async function expectBackendJson(response) { const data = await response.json().catch(() => null) if (response.ok) return data const detail = typeof data?.detail === 'string' ? data.detail : (typeof data?.message === 'string' ? data.message : '') throw new Error(detail || `HTTP ${response.status}`) } async function fetchModelCapabilities(modelName, signal) { const response = await fetch( `${backendApiUrl}/models/capabilities?name=${encodeURIComponent(modelName)}`, { signal } ) return expectBackendJson(response) } async function fetchStartupOllamaStatus() { const response = await fetch(`${backendApiUrl}/ollama/startup-status`) return expectBackendJson(response) } async function prepareStartupModels() { const response = await fetch(`${backendApiUrl}/startup/prepare-models`, { method: 'POST' }) return expectBackendJson(response) } function syncAudioInputRuntimeFromStartupStatus(status) { const whisperReady = Boolean(status?.whisper_model_available) if (whisperReady) { setAudioInputRuntimeReady(true) setAudioInputRuntimeMessage('') return } const modelName = status?.whisper_model || 'base' const reason = String(status?.whisper_error || '').trim() setAudioInputRuntimeReady(false) setAudioInputRuntimeMessage(reason || `Whisper ${modelName} is not available.`) } async function fetchLocalLibraryContext(slug, prompt, signal) { if (!slug) return { contextBlock: null, sources: [] } const resp = await fetch(`${backendApiUrl}/libraries/${slug}/context`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, body: JSON.stringify({ prompt, top_k: 5 }) }) const data = await resp.json() return { contextBlock: typeof data?.context_block === 'string' && data.context_block.trim() ? data.context_block.trim() : null, sources: Array.isArray(data?.sources) ? data.sources : [], } } async function appendComposerImageFiles(fileList) { const incoming = Array.from(fileList || []).filter(isImageFile) if (!incoming.length) { return } if (!selectedModelSupportsVision) { return } const remainingSlots = Math.max(0, MAX_IMAGE_ATTACHMENTS - composerAttachments.length) if (remainingSlots <= 0) { window.alert(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images per message.`) return } const candidates = incoming.slice(0, remainingSlots) const oversized = candidates.filter(file => Number(file.size) > MAX_IMAGE_ATTACHMENT_BYTES) const acceptedFiles = candidates.filter(file => Number(file.size) <= MAX_IMAGE_ATTACHMENT_BYTES) if (oversized.length > 0) { window.alert(`Images must be ${Math.round(MAX_IMAGE_ATTACHMENT_BYTES / (1024 * 1024))} MB or smaller.`) } if (!acceptedFiles.length) { return } try { const nextAttachments = await Promise.all( acceptedFiles.map(async (file, index) => ({ id: `attachment-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`, name: file.name || 'image', mime_type: file.type || 'image/*', data_url: await readFileAsDataUrl(file), })) ) setComposerAttachments(prev => [...prev, ...nextAttachments]) if (incoming.length > remainingSlots) { window.alert(`Only the first ${MAX_IMAGE_ATTACHMENTS} images can be attached.`) } } catch (error) { console.error('Failed to load image attachments', error) window.alert(`Image import failed: ${getErrorText(error)}`) } } function removeComposerAttachment(attachmentId) { setComposerAttachments(prev => prev.filter(attachment => attachment.id !== attachmentId)) } function openImagePicker() { if (!selectedModelSupportsVision) { return } imageInputRef.current?.click() } function clearAudioTimers() { if (audioTickTimerRef.current) { window.clearInterval(audioTickTimerRef.current) audioTickTimerRef.current = null } if (audioAutoStopTimerRef.current) { window.clearTimeout(audioAutoStopTimerRef.current) audioAutoStopTimerRef.current = null } } function cleanupAudioRecorder(stream = audioStreamRef.current) { clearAudioTimers() stopMediaStream(stream) if (audioStreamRef.current === stream) { audioStreamRef.current = null } audioRecorderRef.current = null } async function transcribeRecordedAudio(blob, mimeType) { if (!backendApiUrl) { throw new Error('The Heimgeist backend is not configured.') } const controller = new AbortController() audioTranscriptionAbortRef.current = controller setIsTranscribingAudio(true) try { const dataUrl = await readFileAsDataUrl(blob) const commaIndex = dataUrl.indexOf(',') if (commaIndex <= 5) { throw new Error('Recorded audio could not be encoded for upload.') } const header = dataUrl.slice(0, commaIndex) const payload = dataUrl.slice(commaIndex + 1) if (!/^data:[^,]+;base64$/i.test(header) || !payload) { throw new Error('Recorded audio could not be encoded for upload.') } const detectedMimeType = header .slice(5) .replace(/;base64$/i, '') .trim() const response = await fetch(`${backendApiUrl}/audio/transcribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ mime_type: mimeType || detectedMimeType || 'audio/webm', audio_base64: payload, language: audioInputLanguage || null, }), }) const data = await expectBackendJson(response) const transcript = String(data?.text || '').trim() if (!transcript) { window.alert('No speech was detected. Try again and speak closer to the selected microphone.') return } setInput(prev => appendTranscriptToComposer(prev, transcript)) requestAnimationFrame(() => textareaRef.current?.focus()) } catch (error) { if (isAbortError(error)) { return } throw error } finally { if (audioTranscriptionAbortRef.current === controller) { audioTranscriptionAbortRef.current = null } setIsTranscribingAudio(false) } } async function stopAudioRecording(options = {}) { const { shouldTranscribe = true } = options const recorder = audioRecorderRef.current const stopPromise = audioStopPromiseRef.current const mimeType = audioRecorderMimeTypeRef.current || recorder?.mimeType || 'audio/webm' if (!recorder || !stopPromise) { return } setIsRecordingAudio(false) clearAudioTimers() try { if (recorder.state !== 'inactive') { recorder.stop() } const blob = await stopPromise if (!shouldTranscribe) { return } if (!blob || blob.size <= 0) { throw new Error('Recorded audio was empty.') } await transcribeRecordedAudio(blob, mimeType) } catch (error) { if (shouldTranscribe) { console.error('Failed to stop microphone recording', error) window.alert(`Microphone transcription failed: ${getErrorText(error)}`) } } finally { audioStopPromiseRef.current = null audioChunksRef.current = [] audioRecorderMimeTypeRef.current = '' audioStartedAtRef.current = 0 setAudioRecordingMs(0) } } async function startAudioRecording() { if (!audioInputEnabled || isRecordingAudio || isTranscribingAudio || isSending) { return } if (!supportsAudioInputCapture()) { window.alert('Microphone capture is not available in this environment.') return } let stream = null try { stream = await navigator.mediaDevices.getUserMedia(getAudioInputConstraints(audioInputDeviceId)) } catch (error) { console.error('Failed to access microphone', error) window.alert(`Microphone access failed: ${getErrorText(error)}`) return } const preferredMimeType = getPreferredAudioRecorderMimeType() let recorder = null try { recorder = preferredMimeType ? new MediaRecorder(stream, { mimeType: preferredMimeType }) : new MediaRecorder(stream) } catch (error) { cleanupAudioRecorder(stream) console.error('Failed to create audio recorder', error) window.alert(`Microphone recording is not available: ${getErrorText(error)}`) return } audioStreamRef.current = stream audioRecorderRef.current = recorder audioChunksRef.current = [] audioRecorderMimeTypeRef.current = recorder.mimeType || preferredMimeType || 'audio/webm' audioStartedAtRef.current = Date.now() setAudioRecordingMs(0) setIsRecordingAudio(true) recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { audioChunksRef.current.push(event.data) } } audioStopPromiseRef.current = new Promise((resolve, reject) => { recorder.onstop = () => { const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || audioRecorderMimeTypeRef.current || 'audio/webm', }) cleanupAudioRecorder(stream) resolve(blob) } recorder.onerror = (event) => { cleanupAudioRecorder(stream) reject(event?.error || new Error('Audio recording failed.')) } }) audioTickTimerRef.current = window.setInterval(() => { setAudioRecordingMs(Date.now() - audioStartedAtRef.current) }, AUDIO_RECORDING_TICK_MS) audioAutoStopTimerRef.current = window.setTimeout(() => { stopAudioRecording() }, MAX_AUDIO_RECORDING_MS) recorder.start() } async function toggleAudioRecording() { if (isTranscribingAudio) { return } if (isRecordingAudio) { await stopAudioRecording() return } await startAudioRecording() } async function handleComposerImageSelection(event) { const files = event.target?.files try { await appendComposerImageFiles(files) } finally { if (event.target) { event.target.value = '' } } } function startEditMessage(index, content) { setEditingMessageIndex(index); setEditText(content || ''); } function cancelEditMessage() { setEditingMessageIndex(null); setEditText(''); } async function commitEditMessage(index) { const original = (messages[index]?.content || '').trim(); const nextRaw = editText ?? ''; const next = nextRaw.trim(); // NEW: If empty after trimming, cancel edit (revert to original) if (next.length === 0) { cancelEditMessage(); return; } // If nothing changed, cancel edit if (next === original) { cancelEditMessage(); return; } const sessionId = activeSessionId; if (!sessionId) return; // Optimistically update UI: set edited content and prune following messages setChatSessions(prev => prev.map(s => { if (s.session_id !== sessionId) return s; const old = s.messages || []; const updated = old.slice(0, index + 1).map((m, j) => j === index ? { ...m, content: next } : m ); return { ...s, messages: updated }; }) ); // Exit edit mode immediately setEditingMessageIndex(null); setEditText(''); // ⬇️ Scroll the chat frame to the bottom after the DOM updates requestAnimationFrame(() => scrollToBottom('auto', sessionId)); try { const resp = await fetch(`${backendApiUrl}/sessions/${sessionId}/messages/${index}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: next }) }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); } catch (err) { // Roll back to original content on failure console.error('Failed to update message:', err); setChatSessions(prev => prev.map(s => { if (s.session_id !== sessionId) return s; const old = s.messages || []; const restored = old.map((m, j) => j === index ? { ...m, content: original } : m ); return { ...s, messages: restored }; }) ); return; // don't regenerate on failure } // Continue conversation from the edited message await regenerateFromIndex(index, next); } async function regenerateFromIndex(index, overrideUserText = null) { const sessionId = activeSessionId if (isSending || !sessionId || typeof index !== 'number') return const msgs = (chatSessions.find(s => s.session_id === sessionId)?.messages) || [] let lastUserIdx = index for (let i = index; i >= 0; i--) { if (msgs[i]?.role === 'user') { lastUserIdx = i break } } setChatSessions(prev => prev.map(s => s.session_id === sessionId ? { ...s, messages: (s.messages || []).slice(0, lastUserIdx + 1) } : s ) ) const requestController = beginCancelableRequest(sessionId) let enrichedPrompt = overrideUserText != null ? overrideUserText : (msgs[lastUserIdx]?.content || '') let citationSources = [] const contextBlocks = [] try { const selectedLibrary = getChatLibraryForSession(sessionId) const promptText = overrideUserText != null ? overrideUserText : (msgs[lastUserIdx]?.content || '') const hasPromptText = Boolean((promptText || '').trim()) if (hasPromptText && selectedLibrary?.states?.is_indexed) { try { const localContext = await fetchLocalLibraryContext(selectedLibrary.slug, promptText, requestController.signal) if (localContext.contextBlock) { contextBlocks.push(localContext.contextBlock) } if (Array.isArray(localContext.sources)) { citationSources.push(...localContext.sources) } } catch (error) { if (isAbortError(error)) throw error console.warn('local library enrichment (regenerate) failed', error) } } if (hasPromptText && webSearchEnabled) { try { const historyForSearch = msgs .slice(Math.max(0, lastUserIdx - 7), lastUserIdx + 1) .map(m => ({ role: m.role, content: m.content || '' })) if (historyForSearch.length > 0) { historyForSearch[historyForSearch.length - 1] = { role: 'user', content: promptText } } const resp = await fetch(`${backendApiUrl}/websearch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: requestController.signal, body: JSON.stringify({ prompt: promptText, model, messages: historyForSearch, history_limit: 8, searx_url: searxUrl || null, engines: Array.isArray(searxEngines) ? searxEngines : null, }) }) const data = await resp.json() if (data && typeof data.context_block === 'string' && data.context_block.trim()) { contextBlocks.push(data.context_block.trim()) } if (Array.isArray(data?.sources)) { citationSources.push(...data.sources) } } catch (error) { if (isAbortError(error)) throw error console.warn('web search enrichment (regenerate) failed', error) } } citationSources = [...new Set(citationSources)] if (hasPromptText && contextBlocks.length > 0) { enrichedPrompt = `${promptText}\n\n${contextBlocks.join('\n\n')}` } else { enrichedPrompt = null } if (streamOutput) { const assistantMsgId = `msg-${Date.now()}-${Math.random()}` let full = '' setChatSessions(prev => prev.map(s => s.session_id === sessionId ? { ...s, messages: [...(s.messages || []), { id: assistantMsgId, role: 'assistant', content: '', sources: citationSources }] } : s ) ) try { const res = await fetch(`${backendApiUrl}/sessions/${sessionId}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: requestController.signal, body: JSON.stringify({ index, model, stream: true, enriched_message: enrichedPrompt, sources: citationSources || [] }) }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const reader = res.body?.getReader() if (!reader) throw new Error('Missing response body') const decoder = new TextDecoder() let unreadMarked = false while (true) { const { value, done } = await reader.read() if (done) break const chunk = decoder.decode(value, { stream: true }) full += chunk setAssistantMessageContent(sessionId, assistantMsgId, full) if (!unreadMarked && activeSessionIdRef.current !== sessionId) { unreadMarked = true setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId })) setUnreadSessions(prev => [...new Set([...prev, sessionId])]) } } if (activeSessionIdRef.current !== sessionId) { setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId })) setUnreadSessions(prev => [...new Set([...prev, sessionId])]) } else if (!userScrolledUpRef.current[sessionId]) { requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId)) } else { setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId })) } } catch (error) { if (isAbortError(error)) { setAssistantMessageContent(sessionId, assistantMsgId, full, { removeIfEmpty: true }) return } console.error(error) setAssistantMessageContent(sessionId, assistantMsgId, `Error: ${getErrorText(error)}`, { removeIfEmpty: true }) return } } else { const res = await fetch(`${backendApiUrl}/sessions/${sessionId}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: requestController.signal, body: JSON.stringify({ index, model, stream: false, enriched_message: enrichedPrompt, sources: citationSources || [] }) }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() const assistantMsgId = `msg-${Date.now()}` setChatSessions(prev => prev.map(s => s.session_id === sessionId ? { ...s, messages: [...(s.messages || []), { role: 'assistant', content: data.reply, id: assistantMsgId, sources: citationSources }] } : s ) ) if (activeSessionIdRef.current !== sessionId) { setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId })) setUnreadSessions(prev => [...new Set([...prev, sessionId])]) } else if (!userScrolledUpRef.current[sessionId]) { requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId)) } else { setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId })) } } } catch (error) { if (!isAbortError(error)) { console.error(error) } } finally { finishCancelableRequest(requestController) } } // Persist userScrolledUp state per session + live ref for closures (streaming) const [userScrolledUpState, setUserScrolledUpState] = useState({}); const userScrolledUpRef = useRef({}); // When a response arrives in a non-active chat, remember to scroll to the new ASSISTANT message on open const [pendingScrollToLastUser, setPendingScrollToLastUser] = useState({}); // { [sessionId]: assistantMsgId } // Live per-session scrollTop tracker to avoid races const scrollTopsRef = useRef({}); // Live per-session previous scrollTop tracker to detect scroll direction const prevScrollTopsRef = useRef({}); // Tip state: { [sessionId]: messageId } const [newMsgTip, setNewMsgTip] = useState({}); // Collapse state per user message: { [msgKey]: boolean } — true means "collapsed" const [collapsedUserMsgs, setCollapsedUserMsgs] = useState({}); // Compute a stable key for collapse map (prefer id, else session:index) const collapseKeyFor = (m, i, sessionId) => (m?.id ? m.id : `${sessionId}:${i}`); // Initialize/maintain collapsed map whenever messages or the active session change useEffect(() => { if (!activeSessionId) return; const msgs = (chatSessions.find(s => s.session_id === activeSessionId)?.messages) || []; setCollapsedUserMsgs(prev => { const next = {}; msgs.forEach((m, i) => { if (m.role !== 'user') return; const key = collapseKeyFor(m, i, activeSessionId); const lineCount = (m.content || '').split(/\r\n|\r|\n/).length; const needsCollapse = lineCount > 30; // Default collapsed = true when needsCollapse; preserve user toggles next[key] = needsCollapse ? (prev[key] ?? true) : false; }); return next; }); }, [chatSessions, activeSessionId]); // Toggle collapse/expand for a specific message function toggleUserMsgCollapse(key) { setCollapsedUserMsgs(prev => ({ ...prev, [key]: !(prev[key] ?? true) })); } const setUserScrolledUp = React.useCallback((sessionId, value) => { setUserScrolledUpState(prev => { const next = { ...prev, [sessionId]: value }; userScrolledUpRef.current = next; return next; }); }, []); const activeRequestRef = useRef(null); const justSentMessage = useRef(false); const lastSentSessionRef = useRef(null); const activeSessionIdRef = useRef(activeSessionId); useEffect(() => { activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); const beginCancelableRequest = React.useCallback((sessionId) => { const controller = new AbortController() activeRequestRef.current = { controller, sessionId } setIsSending(true) return controller }, []) const finishCancelableRequest = React.useCallback((controller) => { if (activeRequestRef.current?.controller !== controller) return activeRequestRef.current = null setIsSending(false) }, []) const cancelActiveRequest = React.useCallback(() => { const activeRequest = activeRequestRef.current if (!activeRequest) return activeRequestRef.current = null activeRequest.controller.abort() setIsSending(false) }, []) useEffect(() => { return () => { activeRequestRef.current?.controller.abort() } }, []) // Flag to ensure we only restore once per open of a chat const restoredForRef = useRef(null); // Sidebar resizing state const [sidebarWidth, setSidebarWidth] = useState(230); const [isResizing, setIsResizing] = useState(false); const startResizing = React.useCallback((mouseDownEvent) => { setIsResizing(true); }, []); const stopResizing = React.useCallback(() => { setIsResizing(false); }, []); const resizeSidebar = React.useCallback((mouseMoveEvent) => { if (isResizing) { const newWidth = Math.max(230, Math.min(500, mouseMoveEvent.clientX)); setSidebarWidth(newWidth); } }, [isResizing]); React.useEffect(() => { window.addEventListener('mousemove', resizeSidebar); window.addEventListener('mouseup', stopResizing); return () => { window.removeEventListener('mousemove', resizeSidebar); window.removeEventListener('mouseup', stopResizing); }; }, [resizeSidebar, stopResizing]); React.useEffect(() => { if (isResizing) { document.body.classList.add('no-select'); } else { document.body.classList.remove('no-select'); } }, [isResizing]); React.useEffect(() => { const onClick = async (e) => { const btn = e.target.closest('.codeblock__copy'); if (!btn) return; const wrapper = btn.closest('.codeblock'); const codeEl = wrapper?.querySelector('pre > code'); if (!codeEl) return; try { // Use textContent to copy the plain code accurately await navigator.clipboard.writeText(codeEl.textContent || ''); // Optional: brief visual feedback btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 800); } catch (err) { console.error('Copy failed:', err); } }; document.addEventListener('click', onClick); return () => document.removeEventListener('click', onClick); }, []); // Load settings on startup useEffect(() => { window.electronAPI.getSettings().then(settings => { setBackendApiUrl(resolveBackendApiUrl(settings)); setColorScheme(settings.colorScheme || 'Default'); setModel(settings.chatModel || ''); // Load the selected model, with a fallback setStreamOutput(settings.streamOutput || false); setAudioInputEnabled(settings.audioInputEnabled === true); setAudioInputDeviceId(typeof settings.audioInputDeviceId === 'string' ? settings.audioInputDeviceId : ''); setAudioInputLanguage(typeof settings.audioInputLanguage === 'string' ? settings.audioInputLanguage : ''); setScrollPositions(settings.scrollPositions || {}); // Load scroll positions applyColorScheme(settings.colorScheme || 'Default'); // Apply initial scheme }).finally(() => { setSettingsLoaded(true); }); const handleFocus = () => { if (activeSidebarMode === 'chats') { textareaRef.current?.focus(); } }; window.electronAPI.onWindowFocus(handleFocus); return () => { // Clean up the listener when the component unmounts // This part is tricky with the current setup, as `onWindowFocus` uses `ipcRenderer.on` // which doesn't return a cleanup function. A more robust implementation // would involve `ipcRenderer.removeListener`. For now, we'll assume this is okay // for the lifetime of the app. }; }, [activeSidebarMode]); useEffect(() => { let cancelled = false const controller = new AbortController() if (!backendApiUrl || !model) { setSelectedModelSupportsVision(false) return () => { controller.abort() } } ;(async () => { try { const data = await fetchModelCapabilities(model, controller.signal) if (!cancelled) { setSelectedModelSupportsVision(Boolean(data?.supports_vision)) } } catch (error) { if (!cancelled && !isAbortError(error)) { console.warn('Failed to load model capabilities', error) setSelectedModelSupportsVision(false) } } })() return () => { cancelled = true controller.abort() } }, [backendApiUrl, model]) useEffect(() => { imageDragDepthRef.current = 0 setIsChatDragActive(false) }, [selectedModelSupportsVision, activeSidebarMode]) useEffect(() => { if (audioInputEnabled || !isRecordingAudio) { return } stopAudioRecording({ shouldTranscribe: false }) }, [audioInputEnabled, isRecordingAudio]) useEffect(() => { if (activeSidebarMode === 'chats' || !isRecordingAudio) { return } stopAudioRecording() }, [activeSidebarMode, isRecordingAudio]) useEffect(() => { return () => { audioTranscriptionAbortRef.current?.abort() clearAudioTimers() const recorder = audioRecorderRef.current if (recorder) { recorder.ondataavailable = null recorder.onstop = null recorder.onerror = null try { if (recorder.state !== 'inactive') { recorder.stop() } } catch {} } stopMediaStream(audioStreamRef.current) } }, []) useEffect(() => { if (!settingsLoaded || loading || !backendApiUrl || startupOllamaCheckRanRef.current) return startupOllamaCheckRanRef.current = true let cancelled = false const timerId = window.setTimeout(() => { ;(async () => { let actionStarted = false try { let status = await fetchStartupOllamaStatus() if (cancelled) return if (!status?.ollama_running && status?.can_manage_locally) { const confirmed = window.confirm( `Ollama is not running at ${status.ollama_url}. Start it in the background now with "ollama serve"?` ) if (cancelled) return if (confirmed) { actionStarted = true setStartupTaskBusy(true) setStartupTaskMessage('Starting Ollama in the background...') const response = await fetch(`${backendApiUrl}/ollama/start`, { method: 'POST' }) status = await expectBackendJson(response) if (cancelled) return } } const needsWhisper = !status?.whisper_model_available const needsEmbedding = Boolean(status?.ollama_running && status?.can_manage_locally && !status?.embedding_model_available) if (needsWhisper || needsEmbedding) { actionStarted = true setStartupTaskBusy(true) if (needsWhisper && needsEmbedding) { setStartupTaskMessage( `Downloading Whisper ${status?.whisper_model || 'base'} and ${status.selected_embed_model}. This can take a while on first install.` ) } else if (needsWhisper) { setStartupTaskMessage(`Downloading Whisper ${status?.whisper_model || 'base'}. This can take a while on first install.`) } else { setStartupTaskMessage(`Downloading ${status.selected_embed_model} from Ollama. This can take a while on first install.`) } await prepareStartupModels() if (cancelled) return } } catch (error) { if (!cancelled) { console.warn('startup Ollama check failed', error) if (actionStarted) { window.alert(`Startup action failed: ${getErrorText(error)}`) } } } finally { if (!cancelled) { setStartupTaskBusy(false) setStartupTaskMessage('') } } })() }, 1200) return () => { cancelled = true window.clearTimeout(timerId) } }, [backendApiUrl, loading, settingsLoaded]); // Apply color scheme whenever it changes useEffect(() => { applyColorScheme(colorScheme); }, [colorScheme]); const fetchHistory = (sessionId) => { if (!sessionId || !backendApiUrl) return; fetch(`${backendApiUrl}/history?session_id=${encodeURIComponent(sessionId)}`) .then(r => r.json()) .then(data => { setChatSessions(prevSessions => prevSessions.map(session => session.session_id === sessionId ? { ...session, messages: data.messages || [] } : session ) ); }) .catch(() => {}); }; async function refreshLibraries() { if (!backendApiUrl) return; try { const response = await fetch(`${backendApiUrl}/libraries`); const data = await response.json(); const nextLibraries = Array.isArray(data.libraries) ? data.libraries : []; setLibraries(nextLibraries); if (nextLibraries.length === 0) { setActiveLibrarySlug(null); return; } if (!nextLibraries.some(lib => lib.slug === activeLibrarySlug)) { setActiveLibrarySlug(nextLibraries[0].slug); } } catch (error) { console.warn('Failed to load libraries', error); } } async function refreshLibraryJobs() { if (!backendApiUrl) return; try { const response = await fetch(`${backendApiUrl}/jobs`); const data = await response.json(); setLibraryJobs(Array.isArray(data.jobs) ? data.jobs : []); } catch (error) { console.warn('Failed to load library jobs', error); } } async function createLibrary(nameOverride = null) { const rawName = typeof nameOverride === 'string' ? nameOverride : newLibraryName const name = rawName.trim() if (!name) { setLibraryCreateError('Name is required.') return } try { setLibraryCreateError('') const response = await fetch(`${backendApiUrl}/libraries`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); if (!response.ok) { const detail = await response.text() throw new Error(detail || `HTTP ${response.status}`) } const data = await response.json(); setIsCreatingLibrary(false) setNewLibraryName('') await refreshLibraries(); if (data?.slug) { setActiveLibrarySlug(data.slug); } } catch (error) { console.error('Failed to create library', error); setLibraryCreateError(String(error?.message || error)) } } async function handleLibrariesPurged() { setLibraries([]) setLibraryJobs([]) setActiveLibrarySlug(null) setEditingLibrarySlug(null) setIsDbPickerOpen(false) setChatLibraryBySession({}) await refreshLibraries() await refreshLibraryJobs() } // Load chat sessions from backend on initial render useEffect(() => { if (!backendApiUrl) return; setLoading(true); fetch(`${backendApiUrl}/sessions`) .then(r => r.json()) .then(data => { const sessionsWithMessages = data.sessions.map(s => ({ ...s, messages: [] })); setChatSessions(sessionsWithMessages); if (sessionsWithMessages.length > 0) { setActiveSessionId(sessionsWithMessages[0].session_id); } else { setActiveSessionId(null); } setLoading(false); }) .catch(() => { setLoading(false); }); }, [backendApiUrl]); useEffect(() => { if (!backendApiUrl) return; refreshLibraries(); refreshLibraryJobs(); }, [backendApiUrl]); useEffect(() => { try { localStorage.setItem(CHAT_LIBRARY_MAP_KEY, JSON.stringify(chatLibraryBySession || {})); } catch {} }, [chatLibraryBySession]); useEffect(() => { if (!backendApiUrl) return; const interval = setInterval(() => { refreshLibraries(); refreshLibraryJobs(); }, 3000); return () => clearInterval(interval); }, [backendApiUrl, activeSidebarMode, activeLibrarySlug]); // Load messages for the active session useEffect(() => { fetchHistory(activeSessionId); }, [activeSessionId, backendApiUrl]); useEffect(() => { const validSlugs = new Set(libraries.map(library => library.slug)) setChatLibraryBySession(prev => { let changed = false const next = {} for (const [sessionId, slug] of Object.entries(prev || {})) { if (validSlugs.has(slug)) { next[sessionId] = slug } else { changed = true } } return changed ? next : prev }) }, [libraries]) const handleSidebarClick = (mode) => { // Saving happens in the centralized cleanup effect below setActiveSidebarMode(mode); }; const handleSelectChat = (sessionId) => { // Saving happens in the centralized cleanup effect below selectChat(sessionId); }; const messages = useMemo(() => { return chatSessions.find(s => s.session_id === activeSessionId)?.messages || []; }, [activeSessionId, chatSessions]); const activeLibrary = useMemo(() => { return libraries.find(lib => lib.slug === activeLibrarySlug) || null; }, [activeLibrarySlug, libraries]); const chatLibrarySlug = activeSessionId ? (chatLibraryBySession[activeSessionId] || null) : null const chatLibrary = useMemo(() => { return libraries.find(lib => lib.slug === chatLibrarySlug) || null; }, [chatLibrarySlug, libraries]); const chatLibraryHasActiveJob = useMemo(() => { if (!chatLibrarySlug) return false return libraryJobs.some(job => job.slug === chatLibrarySlug && (job.status === 'queued' || job.status === 'running')) }, [chatLibrarySlug, libraryJobs]) const chatLibraryStatusSuffix = useMemo(() => { if (!chatLibrary) return '' if (!chatLibrary.files?.length) return ' (empty)' if (chatLibrary.states?.is_indexed) return '' return chatLibraryHasActiveJob ? ' (syncing)' : ' (needs sync)' }, [chatLibrary, chatLibraryHasActiveJob]) function getChatLibrarySlugForSession(sessionId) { if (!sessionId) return null return chatLibraryBySession[sessionId] || null } function getChatLibraryForSession(sessionId) { const slug = getChatLibrarySlugForSession(sessionId) if (!slug) return null return libraries.find(lib => lib.slug === slug) || null } function isLibrarySyncing(slug) { if (!slug) return false return libraryJobs.some(job => job.slug === slug && (job.status === 'queued' || job.status === 'running')) } function setChatLibraryForSession(sessionId, slug) { if (!sessionId) return setChatLibraryBySession(prev => { const next = { ...(prev || {}) } if (slug) { next[sessionId] = slug } else { delete next[sessionId] } return next }) } function removeLibraryFromChatSelections(slug) { if (!slug) return setChatLibraryBySession(prev => { let changed = false const next = {} for (const [sessionId, librarySlug] of Object.entries(prev || {})) { if (librarySlug === slug) { changed = true continue } next[sessionId] = librarySlug } return changed ? next : prev }) } useEffect(() => { if (!isDbPickerOpen) return const onPointerDown = (event) => { if (!dbPickerRef.current?.contains(event.target)) { setIsDbPickerOpen(false) } } document.addEventListener('mousedown', onPointerDown) return () => document.removeEventListener('mousedown', onPointerDown) }, [isDbPickerOpen]) useEffect(() => { setIsDbPickerOpen(false) }, [activeSessionId, activeSidebarMode]) // Persist the scrollTop of the session we are LEAVING (on chat change or when leaving the chat view) useEffect(() => { const leavingSessionId = activeSessionId; const leavingMode = activeSidebarMode; return () => { if (leavingMode === 'chats' && leavingSessionId) { const top = typeof scrollTopsRef.current[leavingSessionId] === 'number' ? scrollTopsRef.current[leavingSessionId] : (chatRef.current ? chatRef.current.scrollTop : 0); setScrollPositions(prev => { const updated = { ...prev, [leavingSessionId]: top }; window.electronAPI.updateSettings({ scrollPositions: updated }); return updated; }); } }; }, [activeSessionId, activeSidebarMode]); // Track scroll + whether user left bottom useEffect(() => { const chatDiv = chatRef.current; if (!chatDiv) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = chatDiv; const isAtBottom = (scrollHeight - scrollTop - clientHeight) <= BOTTOM_EPSILON; if (activeSessionId) { const prevScrollTop = prevScrollTopsRef.current[activeSessionId]; const scrolledUp = typeof prevScrollTop === 'number' && scrollTop < prevScrollTop; scrollTopsRef.current[activeSessionId] = scrollTop; if (isAtBottom) { setUserScrolledUp(activeSessionId, false); // User is at bottom, enable autoscroll } else if (scrolledUp) { setUserScrolledUp(activeSessionId, true); // User scrolled up, disable autoscroll } // If user scrolled down but not to bottom, maintain current userScrolledUp state prevScrollTopsRef.current[activeSessionId] = scrollTop; } }; chatDiv.addEventListener('scroll', handleScroll); return () => chatDiv.removeEventListener('scroll', handleScroll); }, [activeSessionId, setUserScrolledUp]); // Auto-hide the tip if user returns to bottom in the active chat useEffect(() => { const sid = activeSessionId; if (!sid) return; if (userScrolledUpState[sid] === false) { setNewMsgTip(prev => { if (!(sid in prev)) return prev; const rest = { ...prev }; delete rest[sid]; return rest; }); } }, [activeSessionId, userScrolledUpState]); // --- Robust restoration: do it before paint, exactly once per open --- useLayoutEffect(() => { if (activeSidebarMode !== 'chats' || !activeSessionId) return; const div = chatRef.current; if (!div) return; restoredForRef.current = null; const applyRestore = () => { if (restoredForRef.current === activeSessionId) return; const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number' ? scrollTopsRef.current[activeSessionId] : undefined; const saved = typeof liveSaved === 'number' ? liveSaved : scrollPositions[activeSessionId]; if (typeof saved === 'number') { div.scrollTop = saved; restoredForRef.current = activeSessionId; return; } if (messages.length > 0) { // default: bottom when no saved position div.scrollTop = div.scrollHeight; restoredForRef.current = activeSessionId; } }; // Run immediately (pre-paint) and also schedule a fallback rAF applyRestore(); const r0 = requestAnimationFrame(applyRestore); // If content size/DOM changes after first paint, apply once const onDomChange = () => { if (restoredForRef.current !== activeSessionId) { requestAnimationFrame(applyRestore); } }; const mo = new MutationObserver(onDomChange); mo.observe(div, { childList: true, subtree: true }); const ro = new ResizeObserver(onDomChange); ro.observe(div); return () => { cancelAnimationFrame(r0); mo.disconnect(); ro.disconnect(); }; }, [activeSessionId, activeSidebarMode, messages.length, scrollPositions]); // If there is no saved scroll and content arrives later (e.g., on first app load), // default to bottom exactly once for this open chat. useEffect(() => { if (activeSidebarMode !== 'chats' || !activeSessionId) return; if (restoredForRef.current === activeSessionId) return; // already applied const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number' ? scrollTopsRef.current[activeSessionId] : undefined; const savedScrollTop = typeof liveSaved === 'number' ? liveSaved : scrollPositions[activeSessionId]; // Only when there is no saved position and we now have content if (typeof savedScrollTop !== 'number' && messages.length > 0) { requestAnimationFrame(() => { const div = chatRef.current; if (!div) return; div.scrollTop = div.scrollHeight; restoredForRef.current = activeSessionId; }); } }, [messages.length, activeSessionId, activeSidebarMode, scrollPositions]); // Session-aware scroll helpers const scrollToBottom = (behavior = 'smooth', sessionId = null) => { const chatDiv = chatRef.current; if (!chatDiv) return; const target = sessionId ?? activeSessionIdRef.current; if (activeSessionIdRef.current !== target) return; chatDiv.scrollTo({ top: chatDiv.scrollHeight, behavior }); setUserScrolledUp(target, false); }; const scrollMessageToTop = (msgId, behavior = 'auto', sessionId = null) => { const chatDiv = chatRef.current; if (!chatDiv) return; const target = sessionId ?? activeSessionIdRef.current; if (activeSessionIdRef.current !== target) return; const el = document.getElementById(msgId); if (el) { const top = Math.max(0, el.offsetTop - TOP_ALIGN_OFFSET); chatDiv.scrollTo({ top, behavior }); } }; // Handler for new message tip click const handleNewMsgTipClick = () => { const sid = activeSessionIdRef.current; const msgId = newMsgTip[sid]; if (msgId) { scrollMessageToTop(msgId, 'smooth', sid); setNewMsgTip(prev => { const { [sid]: _omit, ...rest } = prev; return rest; }); } }; const handleChatDragEnter = (event) => { if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return event.preventDefault() imageDragDepthRef.current += 1 if (selectedModelSupportsVision && eventHasImageFiles(event)) { setIsChatDragActive(true) } } const handleChatDragOver = (event) => { if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return event.preventDefault() event.dataTransfer.dropEffect = selectedModelSupportsVision ? 'copy' : 'none' if (selectedModelSupportsVision && eventHasImageFiles(event) && !isChatDragActive) { setIsChatDragActive(true) } } const handleChatDragLeave = (event) => { if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return imageDragDepthRef.current = Math.max(0, imageDragDepthRef.current - 1) if (imageDragDepthRef.current === 0) { setIsChatDragActive(false) } } const handleChatDrop = async (event) => { if (activeSidebarMode !== 'chats' || !hasFilePayload(event)) return event.preventDefault() imageDragDepthRef.current = 0 setIsChatDragActive(false) if (!selectedModelSupportsVision) { return } await appendComposerImageFiles(event.dataTransfer?.files) textareaRef.current?.focus() } async function sendMessage() { const trimmedInput = input.trim() if (isSending || (!trimmedInput && composerAttachments.length === 0) || !model) return if (composerAttachments.length > 0 && !selectedModelSupportsVision) { window.alert('The selected model does not support image inputs.') return } let targetSessionId = activeSessionId let isNewChat = false if (!targetSessionId) { const newSession = await createNewChat() await new Promise(resolve => setTimeout(resolve, 200)) targetSessionId = newSession.session_id isNewChat = true } else { const currentSession = chatSessions.find(s => s.session_id === targetSessionId) isNewChat = currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0 } const outgoingAttachments = composerAttachments.map(({ id, ...attachment }) => ({ ...attachment })) const userMsg = { role: 'user', content: trimmedInput, attachments: outgoingAttachments, id: `msg-${Date.now()}-${Math.random()}` } justSentMessage.current = true lastSentSessionRef.current = targetSessionId setUserScrolledUp(targetSessionId, false) if (activeSessionIdRef.current === targetSessionId) { restoredForRef.current = activeSessionIdRef.current } flushSync(() => { setChatSessions(prevSessions => prevSessions.map(session => session.session_id === targetSessionId ? { ...session, messages: [...(session.messages || []), userMsg] } : session ) ) setInput('') setComposerAttachments([]) }) requestAnimationFrame(() => scrollToBottom('auto', targetSessionId)) const requestController = beginCancelableRequest(targetSessionId) try { let historyForSearch = [] if (userMsg.content) try { const existing = (chatSessions.find(s => s.session_id === targetSessionId)?.messages) || [] const lastFew = existing.slice(-8).map(m => ({ role: m.role, content: m.content || '' })) historyForSearch = [...lastFew, { role: 'user', content: userMsg.content }] } catch {} let enrichedPrompt = userMsg.content || null let citationSources = [] const contextBlocks = [] const selectedLibrary = getChatLibraryForSession(targetSessionId) if (userMsg.content && selectedLibrary?.states?.is_indexed) { try { const localContext = await fetchLocalLibraryContext(selectedLibrary.slug, userMsg.content, requestController.signal) if (localContext.contextBlock) { contextBlocks.push(localContext.contextBlock) } if (Array.isArray(localContext.sources)) { citationSources.push(...localContext.sources) } } catch (error) { if (isAbortError(error)) throw error console.warn('local library enrichment failed', error) } } if (userMsg.content && webSearchEnabled) { try { const resp = await fetch(`${backendApiUrl}/websearch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: requestController.signal, body: JSON.stringify({ prompt: userMsg.content, model, messages: historyForSearch, history_limit: 8, searx_url: searxUrl || null, engines: Array.isArray(searxEngines) ? searxEngines : null, }) }) const data = await resp.json() if (data && typeof data.context_block === 'string' && data.context_block.trim()) { contextBlocks.push(data.context_block.trim()) } if (Array.isArray(data?.sources)) { citationSources.push(...data.sources) } } catch (error) { if (isAbortError(error)) throw error console.warn('web search enrichment failed', error) } } citationSources = [...new Set(citationSources)] if (userMsg.content && contextBlocks.length > 0) { enrichedPrompt = `${userMsg.content}\n\n${contextBlocks.join('\n\n')}` } if (streamOutput) { const assistantMsgId = `msg-${Date.now()}-${Math.random()}` let fullReply = '' const assistantMsg = { role: 'assistant', content: '', id: assistantMsgId, sources: citationSources } setChatSessions(prevSessions => prevSessions.map(session => session.session_id === targetSessionId ? { ...session, messages: [...(session.messages || []), assistantMsg] } : session ) ) try { const res = await fetch(`${backendApiUrl}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: requestController.signal, body: JSON.stringify({ session_id: targetSessionId, model, message: userMsg.content, enriched_message: userMsg.content && contextBlocks.length > 0 ? enrichedPrompt : null, stream: true, sources: citationSources || [], attachments: outgoingAttachments, }) }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const reader = res.body?.getReader() if (!reader) throw new Error('Missing response body') const decoder = new TextDecoder() let pendingMarked = false while (true) { const { value, done } = await reader.read() if (done) { setAssistantMessageContent(targetSessionId, assistantMsgId, fullReply) if (activeSessionIdRef.current === targetSessionId) { if (!userScrolledUpRef.current[targetSessionId]) { requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId)) } else { setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId })) } } else { setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId })) setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]) } break } const chunk = decoder.decode(value, { stream: true }) fullReply += chunk setAssistantMessageContent(targetSessionId, assistantMsgId, fullReply) if (activeSessionIdRef.current === targetSessionId && !userScrolledUpRef.current[targetSessionId]) { scrollToBottom('auto', targetSessionId) } if (activeSessionIdRef.current !== targetSessionId && !pendingMarked) { setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId })) pendingMarked = true } } } catch (error) { if (isAbortError(error)) { setAssistantMessageContent(targetSessionId, assistantMsgId, fullReply, { removeIfEmpty: true }) return } console.error('Failed to send message:', error) setAssistantMessageContent(targetSessionId, assistantMsgId, 'Error: ' + getErrorText(error), { removeIfEmpty: true }) return } } else { const res = await fetch(`${backendApiUrl}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: requestController.signal, body: JSON.stringify({ session_id: targetSessionId, model, message: userMsg.content, enriched_message: userMsg.content && contextBlocks.length > 0 ? enrichedPrompt : null, stream: false, sources: citationSources || [], attachments: outgoingAttachments, }) }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() const assistantMsgId = `msg-${Date.now()}` const assistantMsg = { role: 'assistant', content: data.reply, id: assistantMsgId, sources: citationSources } setChatSessions(prevSessions => prevSessions.map(session => session.session_id === targetSessionId ? { ...session, messages: [...(session.messages || []), assistantMsg] } : session ) ) if (assistantMsgId) { if (activeSessionIdRef.current === targetSessionId) { if (!userScrolledUpRef.current[targetSessionId]) { requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId)) } else { setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId })) } } else { setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId })) } } } if (activeSessionIdRef.current !== targetSessionId) { setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]) } if (isNewChat) { fetch(`${backendApiUrl}/generate-title`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: targetSessionId, message: userMsg.content || (outgoingAttachments.length > 0 ? 'Image attachment' : userMsg.content), model }) }) .then(r => r.json()) .then(data => { const sanitizedTitle = sanitizeGeneratedChatTitle(data.title) setChatSessions(prevSessions => prevSessions.map(session => session.session_id === targetSessionId ? { ...session, name: sanitizedTitle } : session ) ) }) } } catch (error) { if (isAbortError(error)) { finishCancelableRequest(requestController) return } console.error('Failed to send message:', error) const errorMsg = { role: 'assistant', content: 'Error: ' + getErrorText(error), id: `msg-${Date.now()}-${Math.random()}` } setChatSessions(prevSessions => prevSessions.map(session => session.session_id === targetSessionId ? { ...session, messages: [...session.messages, errorMsg] } : session ) ) } finally { finishCancelableRequest(requestController) } } function toggleWebSearch() { setWebSearchEnabled(prev => !prev); } async function createNewChat() { const newSessionId = 'sess-' + Math.random().toString(36).slice(2) + Date.now().toString(36); const res = await fetch(`${backendApiUrl}/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: newSessionId }) }); const newSession = await res.json(); const sessionWithMessages = { ...newSession, messages: [] }; setChatSessions(prevSessions => [sessionWithMessages, ...prevSessions]); setActiveSessionId(newSession.session_id); textareaRef.current?.focus(); return newSession; } function selectChat(sessionId) { setActiveSessionId(sessionId); // Clear unread dot immediately for this chat setUnreadSessions(prev => prev.filter(id => id !== sessionId)); // If we had queued a guided scroll for this chat (from background replies), run it now, smoothly const pendingId = pendingScrollToLastUser[sessionId]; if (pendingId) { // Defer until the chat content renders; restoration is gated by restoredForRef, so won't fight requestAnimationFrame(() => { let tries = 12; // ~200ms @ 60fps const attempt = () => { const chatDiv = chatRef.current; if (!chatDiv) return; let el = document.getElementById(pendingId); if (!el) { const sess = chatSessions.find(s => s.session_id === sessionId); if (sess && Array.isArray(sess.messages)) { for (let i = sess.messages.length - 1; i >= 0; i--) { const m = sess.messages[i]; if (m.role === 'assistant' && m.id) { el = document.getElementById(m.id); break; } } } } if (el) { scrollMessageToTop(el.id, 'smooth', sessionId); setPendingScrollToLastUser(prev => { const { [sessionId]: _omit, ...rest } = prev; return rest; }); } else if (tries-- > 0) { requestAnimationFrame(attempt); } }; requestAnimationFrame(attempt); }); } } function handleRename(sessionId, newName) { fetch(`${backendApiUrl}/sessions/${sessionId}/rename`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: newName }) }) .then(() => { setChatSessions(prevSessions => prevSessions.map(session => session.session_id === sessionId ? { ...session, name: newName } : session ) ); setEditingSessionId(null); }); } function handleLibraryRename(slug, newName) { const name = (newName || '').trim() const library = libraries.find(item => item.slug === slug) if (!library) { setEditingLibrarySlug(null) return } if (!name || name === library.name) { setEditingLibrarySlug(null) return } fetch(`${backendApiUrl}/libraries/${slug}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }) .then(() => { setLibraries(prevLibraries => prevLibraries.map(item => item.slug === slug ? { ...item, name } : item ) ) setEditingLibrarySlug(null) }) } function handleDelete(sessionId) { fetch(`${backendApiUrl}/sessions/${sessionId}`, { method: 'DELETE' }) .then(() => { const newSessions = chatSessions.filter(s => s.session_id !== sessionId); setChatSessions(newSessions); setChatLibraryBySession(prev => { const next = { ...(prev || {}) } delete next[sessionId] return next }) if (activeSessionId === sessionId) { setActiveSessionId(newSessions.length > 0 ? newSessions[0].session_id : null); } }); } function handleLibraryDelete(slug) { fetch(`${backendApiUrl}/libraries/${slug}`, { method: 'DELETE' }) .then(async (response) => { if (!response.ok) { const detail = await response.text() throw new Error(detail || `HTTP ${response.status}`) } const nextLibraries = libraries.filter(library => library.slug !== slug) setLibraries(nextLibraries) setLibraryJobs(prevJobs => prevJobs.filter(job => job.slug !== slug)) setEditingLibrarySlug(current => current === slug ? null : current) if (activeLibrarySlug === slug) { setActiveLibrarySlug(nextLibraries[0]?.slug || null) } removeLibraryFromChatSelections(slug) }) .catch((error) => { console.error('Failed to delete library', error) }) } // Auto-delete empty "New Chat" sessions useEffect(() => { const emptyNewChats = chatSessions.filter( s => s.name === "New Chat" && s.session_id !== activeSessionId && s.messages.length === 0 ); if (emptyNewChats.length > 0) { emptyNewChats.forEach(chat => { handleDelete(chat.session_id); }); } }, [activeSessionId, chatSessions, backendApiUrl]); const handleChatFrameClick = (e) => { const selection = window.getSelection(); if (selection.toString().length > 0) { return; } if (document.activeElement === textareaRef.current) { return; } if (e.target.closest('.msg')) { return; } textareaRef.current?.focus(); }; return (
handleSidebarClick('chats')} > Chats
handleSidebarClick('dbs')} > DBs
handleSidebarClick('settings')} > Settings
{activeSidebarMode === 'chats' && (
{chatSessions.map(session => (
handleSelectChat(session.session_id)} > {editingSessionId === session.session_id ? ( setEditingSessionId(null)} onKeyDown={(e) => { if (e.key === 'Enter') { handleRename(session.session_id, e.target.value); } else if (e.key === 'Escape') { setEditingSessionId(null); } }} autoFocus /> ) : ( <> {session.name}
{unreadSessions.includes(session.session_id) &&
}
)}
))}
)} {activeSidebarMode === 'dbs' && (
{libraries.length === 0 ? (
No databases yet.
) : ( libraries.map(library => (
setActiveLibrarySlug(library.slug)} > {editingLibrarySlug === library.slug ? ( setEditingLibrarySlug(null)} onKeyDown={(e) => { if (e.key === 'Enter') { handleLibraryRename(library.slug, e.target.value) } else if (e.key === 'Escape') { setEditingLibrarySlug(null) } }} autoFocus /> ) : ( <> {library.name}
{chatLibrarySlug === library.slug &&
Chat
} {isLibrarySyncing(library.slug) &&
Syncing
}
)}
)) )}
)} {activeSidebarMode === 'settings' && (
setActiveSettingsSubmenu('General')} > General
setActiveSettingsSubmenu('Interface')} > Interface
setActiveSettingsSubmenu('Websearch')} > Websearch
)}
{activeSidebarMode !== 'settings' && (
{activeSidebarMode === 'chats' && ( )} {activeSidebarMode === 'dbs' && ( isCreatingLibrary ? (
setNewLibraryName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { createLibrary() } else if (e.key === 'Escape') { setIsCreatingLibrary(false) setNewLibraryName('') setLibraryCreateError('') } }} placeholder="Database name" autoFocus /> {libraryCreateError &&
{libraryCreateError}
}
) : ( ) )}
)}
{startupTaskMessage && (
{startupTaskBusy &&
}
{startupTaskMessage}
)} {activeSidebarMode === 'chats' && ( <>
Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'} {chatLibrary && ( {`DB: ${chatLibrary.name}${chatLibraryStatusSuffix}`} )}
{messages.map((m, i) => { const isEditingThis = m.role === 'user' && editingMessageIndex === i; return (
{m.role === 'assistant' ? (
{!isSending && (
)}
) : (
{isEditingThis ? ( <>
setEditText(e.target.value)} onBlur={cancelEditMessage} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); cancelEditMessage(); } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitEditMessage(i); } }} autoFocus minRows={1} />
) : ( (() => { const raw = m.content || ''; const attachments = Array.isArray(m.attachments) ? m.attachments : []; const lines = raw.split(/\r\n|\r|\n/); const needsCollapse = lines.length > 30; const key = collapseKeyFor(m, i, activeSessionId); const isCollapsed = needsCollapse ? (collapsedUserMsgs[key] ?? true) : false; const displayText = isCollapsed ? lines.slice(0, 30).join('\n') + '\n…' : raw; const hasText = Boolean(raw.trim()); return ( <> {hasText &&
{displayText}
} {hasText && needsCollapse && ( )} ); })() )} {!isSending && !isEditingThis && (
)}
)}
); })}
{/* New message tip (active chat only) */} {newMsgTip[activeSessionId] && ( )}
{(isRecordingAudio || isTranscribingAudio) && (
{isRecordingAudio ? ( ) : ( )} {isRecordingAudio ? `Listening ${formatRecordingDuration(audioRecordingMs)}` : 'Transcribing audio…'}
)}
setInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !isRecordingAudio && !isTranscribingAudio) { e.preventDefault(); sendMessage(); } }} placeholder="Ask any question..." maxRows={13} />
{isDbPickerOpen && (
{libraries.length === 0 ? (
No databases yet.
) : ( libraries.map(library => { const selected = chatLibrarySlug === library.slug const syncing = isLibrarySyncing(library.slug) const status = !library.files?.length ? 'Empty' : library.states?.is_indexed ? 'Ready' : syncing ? 'Syncing' : 'Needs sync' return ( ) }) )}
)}
{selectedModelSupportsVision && ( )} {audioInputEnabled && ( )}
)} {activeSidebarMode === 'dbs' && ( <>
{activeLibrary?.name || 'Databases'} {chatLibrary && ( {`Current chat DB: ${chatLibrary.name}${chatLibraryStatusSuffix}`} )}
{ await refreshLibraries(); await refreshLibraryJobs(); }} /> )} {activeSidebarMode === 'settings' && ( <>
{activeSettingsSubmenu} Settings
{activeSettingsSubmenu === 'General' && ( )} {activeSettingsSubmenu === 'Interface' && } {activeSettingsSubmenu === 'Websearch' && ( setSearxEngines(normalizeWebsearchEngines(next))} /> )} )}
) }