2025-08-23 16:45:46 +02:00
// /Users/giers/Heimgeist/src/App.jsx
import React , { useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
import { flushSync } from 'react-dom' ;
2025-08-22 23:42:34 +02:00
import TextareaAutosize from 'react-textarea-autosize' ;
import GeneralSettings from './GeneralSettings'
import InterfaceSettings from './InterfaceSettings'
2026-03-19 21:07:22 +01:00
import LibraryManager from './LibraryManager'
2025-08-27 04:27:18 +02:00
import WebsearchSettings from './WebsearchSettings'
2025-08-25 21:13:09 +02:00
import { markdownToHTML } from './markdown' ;
2026-03-19 21:07:22 +01:00
import { applyColorScheme } from './colorSchemes'
2026-03-20 12:55:56 +01:00
import {
loadStoredWebsearchEngines ,
normalizeWebsearchEngines ,
} from './websearchEngines'
2026-04-16 22:03:39 +02:00
import {
getAudioInputConstraints ,
getPreferredAudioRecorderMimeType ,
stopMediaStream ,
supportsAudioInputCapture ,
} from './audioInput'
2026-03-20 16:25:48 +01:00
function sanitizeGeneratedChatTitle ( title ) {
return ( title || '' )
. replace ( /<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi , '' )
. replace ( /[*#]/g , '' )
. replace ( /\s+/g , ' ' )
. trim ( )
}
2026-04-17 08:11:39 +02:00
function explainSingleLineOllama502 ( text ) {
const raw = String ( text || '' )
const trimmed = raw . trim ( )
if ( ! trimmed || /[\r\n]/ . test ( trimmed ) ) {
return raw
}
const lower = trimmed . toLowerCase ( )
const isSingleLine502Error = (
lower . includes ( 'error' ) &&
lower . includes ( 'http' ) &&
lower . includes ( '502' )
)
if ( ! isSingleLine502Error ) {
return raw
}
return ` ${ trimmed } \ n \ nOllama returned HTTP 502. This usually means Ollama stopped responding or crashed while handling the request. \ n \ nIf other models still work, try updating Ollama. Newer models may require a newer Ollama release. `
}
2025-08-25 21:13:09 +02:00
// Extract <think> or <thinking> block (first occurrence) and return { think, answer }
function splitThinkBlocks ( text ) {
if ( ! text ) return { think : null , answer : '' } ;
const openTagRe = /<think(?:ing)?>/i ;
const closeTagRe = /<\/think(?:ing)?>/i ;
const openMatch = text . match ( openTagRe ) ;
if ( ! openMatch ) {
// No opening <think> 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)
2025-08-27 04:27:18 +02:00
function AssistantMessageContent ( { content , streamOutput , sources } ) {
2026-04-17 08:11:39 +02:00
const displayContent = explainSingleLineOllama502 ( content || '' ) ;
const { think , answer } = splitThinkBlocks ( displayContent ) ;
2025-08-25 23:56:26 +02:00
const [ open , setOpen ] = React . useState ( false ) ;
2025-08-25 21:13:09 +02:00
const showThinkButton = ! ! think ;
return (
< div className = "assistant-message" >
{ showThinkButton && (
< div className = "assistant-thoughts" >
< button
className = "think-toggle"
onClick = { ( ) => setOpen ( o => ! o ) }
aria - expanded = { open ? 'true' : 'false' }
aria - controls = "think-content"
>
2025-08-25 23:56:26 +02:00
< span className = "think-toggle-icon" aria - hidden = "true" >
{ open ? '▾' : '▸' }
< / span >
2025-08-25 21:13:09 +02:00
Thoughts
< / button >
{ open && (
2025-08-25 23:56:26 +02:00
< div
id = "think-content"
className = "think-content"
dangerouslySetInnerHTML = { { _ _html : markdownToHTML ( think ) } }
/ >
2025-08-25 21:13:09 +02:00
) }
< / div >
) }
2025-08-25 23:56:26 +02:00
< div
className = "msg-content"
2026-04-17 08:11:39 +02:00
dangerouslySetInnerHTML = { { _ _html : markdownToHTML ( answer || displayContent || '' ) } }
2025-08-25 23:56:26 +02:00
/ >
2025-08-27 04:27:18 +02:00
{ Array . isArray ( sources ) && sources . length > 0 && (
< div className = "msg-sources chips" >
{ sources . map ( ( u , i ) => {
let label = u ;
2026-03-19 21:07:22 +01:00
let isFile = false ;
2025-08-27 04:27:18 +02:00
try {
2026-03-19 21:07:22 +01:00
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 , '' ) ;
}
2025-08-27 04:27:18 +02:00
} catch { }
return (
2026-03-19 21:07:22 +01:00
< a
key = { u + i }
className = "chip"
href = { u }
target = "_blank"
rel = "noreferrer"
title = { u }
onClick = { ( event ) => {
if ( ! isFile ) return ;
event . preventDefault ( ) ;
try {
const parsed = new URL ( u ) ;
window . electronAPI ? . openPath ? . ( decodeURIComponent ( parsed . pathname ) ) ;
} catch { }
} }
>
2025-08-27 04:27:18 +02:00
{ label }
< / a >
) ;
} ) }
< / div >
) }
2025-08-25 21:13:09 +02:00
< / div >
) ;
}
2025-08-22 23:42:34 +02:00
2026-04-16 21:29:32 +02:00
function ImageAttachmentStrip ( { attachments , className = '' , removable = false , onRemove } ) {
if ( ! Array . isArray ( attachments ) || attachments . length === 0 ) {
return null ;
}
return (
< div className = { ` image-attachment-strip ${ className } ` . trim ( ) } >
{ attachments . map ( ( attachment , index ) => {
const src = attachment ? . data _url ;
if ( ! src ) return null ;
const key = attachment . id || ` ${ attachment . name || 'image' } - ${ index } - ${ src . length } ` ;
return (
< div key = { key } className = "image-attachment-card" >
{ removable && (
< button
type = "button"
className = "image-attachment-remove"
onClick = { ( ) => onRemove ? . ( attachment . id ) }
aria - label = { ` Remove ${ attachment . name || 'image' } ` }
title = "Remove image"
>
×
< / button >
) }
< img
className = "image-attachment-thumb"
src = { src }
alt = { attachment . name || ` Attachment ${ index + 1 } ` }
loading = "lazy"
/ >
< / div >
) ;
} ) }
< / div >
) ;
}
2026-03-20 08:16:41 +01:00
const API _URL _KEY = 'backendApiUrl' ;
2025-08-22 23:42:34 +02:00
const COLOR _SCHEME _KEY = 'colorScheme' ;
2025-08-27 04:27:18 +02:00
const WEBSEARCH _URL _KEY = 'websearch.searxUrl' ;
const WEBSEARCH _ENGINES _KEY = 'websearch.engines' ;
2026-03-19 21:35:47 +01:00
const CHAT _LIBRARY _MAP _KEY = 'chat.libraryBySession' ;
2026-03-20 12:00:44 +01:00
const DEFAULT _SEARX _URL = 'http://127.0.0.1:8888' ;
2026-04-16 21:29:32 +02:00
const MAX _IMAGE _ATTACHMENTS = 6 ;
const MAX _IMAGE _ATTACHMENT _BYTES = 20 * 1024 * 1024 ;
2026-04-16 22:03:39 +02:00
const MAX _AUDIO _RECORDING _MS = 5 * 60 * 1000 ;
const AUDIO _RECORDING _TICK _MS = 200 ;
2025-08-22 23:42:34 +02:00
// 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' ;
2025-08-23 16:45:46 +02:00
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
2025-08-22 23:42:34 +02:00
2026-03-20 08:16:41 +01:00
function resolveBackendApiUrl ( settings ) {
return settings . backendApiUrl || settings . ollamaApiUrl || API ;
}
2026-03-20 12:00:44 +01:00
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 ;
}
2026-04-16 21:29:32 +02:00
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 ) ;
} ) ;
}
2026-04-16 22:03:39 +02:00
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' ) } `
}
2025-08-22 23:42:34 +02:00
export default function App ( ) {
const [ chatSessions , setChatSessions ] = useState ( [ ] )
const [ activeSessionId , setActiveSessionId ] = useState ( null )
const [ activeSidebarMode , setActiveSidebarMode ] = useState ( 'chats' ) // 'chats', 'dbs', 'settings'
2026-04-17 08:01:23 +02:00
const activeSidebarModeRef = useRef ( activeSidebarMode )
2025-08-22 23:42:34 +02:00
const [ activeSettingsSubmenu , setActiveSettingsSubmenu ] = useState ( 'General' ) ; // 'General', 'Interface'
const [ editingSessionId , setEditingSessionId ] = useState ( null ) ; // ID of the session being edited
2026-03-19 21:28:01 +01:00
const [ editingLibrarySlug , setEditingLibrarySlug ] = useState ( null )
2026-03-19 21:07:22 +01:00
const [ libraries , setLibraries ] = useState ( [ ] )
const [ libraryJobs , setLibraryJobs ] = useState ( [ ] )
const [ activeLibrarySlug , setActiveLibrarySlug ] = useState ( null )
2026-03-19 21:35:47 +01:00
const [ chatLibraryBySession , setChatLibraryBySession ] = useState ( ( ) => {
try {
const raw = localStorage . getItem ( CHAT _LIBRARY _MAP _KEY )
return raw ? JSON . parse ( raw ) : { }
} catch {
return { }
}
} )
2026-03-19 21:07:22 +01:00
const [ isCreatingLibrary , setIsCreatingLibrary ] = useState ( false )
const [ newLibraryName , setNewLibraryName ] = useState ( '' )
const [ libraryCreateError , setLibraryCreateError ] = useState ( '' )
2026-03-19 21:35:47 +01:00
const [ isDbPickerOpen , setIsDbPickerOpen ] = useState ( false )
2025-08-22 23:42:34 +02:00
// Use currentSessionId for the actual chat operations
const [ model , setModel ] = useState ( '' )
2026-04-16 21:29:32 +02:00
const [ selectedModelSupportsVision , setSelectedModelSupportsVision ] = useState ( false )
2025-08-22 23:42:34 +02:00
const [ input , setInput ] = useState ( '' )
2026-04-16 21:29:32 +02:00
const [ composerAttachments , setComposerAttachments ] = useState ( [ ] )
const [ isChatDragActive , setIsChatDragActive ] = useState ( false )
2025-08-22 23:42:34 +02:00
const chatRef = useRef ( null )
const textareaRef = useRef ( null ) ; // Ref for the textarea
2026-03-19 21:35:47 +01:00
const dbPickerRef = useRef ( null )
2026-04-16 21:29:32 +02:00
const imageInputRef = useRef ( null )
const imageDragDepthRef = useRef ( 0 )
2026-04-16 22:03:45 +02:00
const [ audioInputEnabled , setAudioInputEnabled ] = useState ( false )
const [ audioInputDeviceId , setAudioInputDeviceId ] = useState ( '' )
2026-04-17 04:43:28 +02:00
const [ audioInputLanguage , setAudioInputLanguage ] = useState ( '' )
2026-04-17 04:48:17 +02:00
const [ audioInputRuntimeReady , setAudioInputRuntimeReady ] = useState ( true )
const [ audioInputRuntimeMessage , setAudioInputRuntimeMessage ] = useState ( '' )
2026-04-16 22:03:45 +02:00
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 )
2026-03-20 08:16:41 +01:00
const [ backendApiUrl , setBackendApiUrl ] = useState ( API ) ; // State for Heimgeist backend URL
2025-08-22 23:42:34 +02:00
const [ colorScheme , setColorScheme ] = useState ( 'Default' ) ; // State for color scheme
2025-08-23 16:45:46 +02:00
const [ streamOutput , setStreamOutput ] = useState ( false ) ;
2026-03-20 12:00:44 +01:00
const [ startupTaskMessage , setStartupTaskMessage ] = useState ( '' ) ;
2026-03-20 12:04:40 +01:00
const [ startupTaskBusy , setStartupTaskBusy ] = useState ( false ) ;
2026-03-20 12:00:44 +01:00
const [ searxUrl , setSearxUrl ] = useState ( ( ) => migrateLegacySearxUrl ( localStorage . getItem ( WEBSEARCH _URL _KEY ) ) ) ;
2026-03-20 12:55:56 +01:00
const [ searxEngines , setSearxEngines ] = useState ( ( ) =>
loadStoredWebsearchEngines ( localStorage . getItem ( WEBSEARCH _ENGINES _KEY ) )
) ;
2025-08-27 04:27:18 +02:00
useEffect ( ( ) => {
localStorage . setItem ( WEBSEARCH _URL _KEY , searxUrl || '' ) ;
} , [ searxUrl ] ) ;
useEffect ( ( ) => {
try {
2026-03-20 12:55:56 +01:00
localStorage . setItem (
WEBSEARCH _ENGINES _KEY ,
JSON . stringify ( normalizeWebsearchEngines ( searxEngines ) )
) ;
2025-08-27 04:27:18 +02:00
} catch { }
} , [ searxEngines ] ) ;
const [ webSearchEnabled , setWebSearchEnabled ] = useState ( false ) ;
2025-08-23 16:45:46 +02:00
const [ isSending , setIsSending ] = useState ( false ) ;
2025-08-22 23:42:34 +02:00
const [ loading , setLoading ] = useState ( true ) ; // Loading state for initial session fetch
const [ unreadSessions , setUnreadSessions ] = useState ( [ ] ) ; // Track unread messages
2025-08-23 16:45:46 +02:00
const [ scrollPositions , setScrollPositions ] = useState ( { } ) ; // Store scroll positions for each session
2026-03-20 12:00:44 +01:00
const [ settingsLoaded , setSettingsLoaded ] = useState ( false ) ;
const startupOllamaCheckRanRef = useRef ( false ) ;
2025-08-25 23:56:26 +02:00
// 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)
2025-08-26 05:32:45 +02:00
function getMarkdownForCopy ( message ) {
2025-08-26 03:06:47 +02:00
const raw = message . content || '' ;
2025-08-26 05:32:45 +02:00
if ( message . role === 'assistant' ) {
// Copy the assistant's raw *markdown answer*, not rendered text,
// and strip any <think>...</think> block.
try {
const { answer } = splitThinkBlocks ( raw ) ;
return ( answer || raw ) . trim ( ) ;
} catch {
return raw . trim ( ) ;
}
2025-08-25 23:56:26 +02:00
}
2025-08-26 05:32:45 +02:00
// User messages: copy exactly as typed
return raw ;
2025-08-25 23:56:26 +02:00
}
async function handleCopyMessage ( message ) {
try {
2025-08-26 05:32:45 +02:00
await navigator . clipboard . writeText ( getMarkdownForCopy ( message ) ) ;
2025-08-25 23:56:26 +02:00
} catch ( err ) {
console . error ( 'Failed to copy message:' , err ) ;
}
}
2026-03-19 21:07:22 +01:00
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 )
}
2026-03-20 12:00:44 +01:00
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 } ` )
}
2026-04-16 21:29:49 +02:00
async function fetchModelCapabilities ( modelName , signal ) {
const response = await fetch (
` ${ backendApiUrl } /models/capabilities?name= ${ encodeURIComponent ( modelName ) } ` ,
{ signal }
)
return expectBackendJson ( response )
}
2026-03-20 12:00:44 +01:00
async function fetchStartupOllamaStatus ( ) {
const response = await fetch ( ` ${ backendApiUrl } /ollama/startup-status ` )
return expectBackendJson ( response )
}
2026-03-20 15:43:00 +01:00
async function prepareStartupModels ( ) {
const response = await fetch ( ` ${ backendApiUrl } /startup/prepare-models ` , { method : 'POST' } )
return expectBackendJson ( response )
}
2026-04-17 04:48:22 +02:00
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. ` )
}
2026-03-19 21:36:27 +01:00
async function fetchLocalLibraryContext ( slug , prompt , signal ) {
if ( ! slug ) return { contextBlock : null , sources : [ ] }
2026-03-20 08:16:41 +01:00
const resp = await fetch ( ` ${ backendApiUrl } /libraries/ ${ slug } /context ` , {
2026-03-19 21:36:27 +01:00
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 : [ ] ,
}
}
2026-04-16 21:29:49 +02:00
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 ( )
}
2026-04-16 22:04:19 +02:00
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 )
2026-04-17 04:22:36 +02:00
const commaIndex = dataUrl . indexOf ( ',' )
if ( commaIndex <= 5 ) {
2026-04-16 22:04:19 +02:00
throw new Error ( 'Recorded audio could not be encoded for upload.' )
}
2026-04-17 04:22:36 +02:00
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 ( )
2026-04-16 22:04:19 +02:00
const response = await fetch ( ` ${ backendApiUrl } /audio/transcribe ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
signal : controller . signal ,
body : JSON . stringify ( {
2026-04-17 04:22:36 +02:00
mime _type : mimeType || detectedMimeType || 'audio/webm' ,
audio _base64 : payload ,
2026-04-17 04:43:28 +02:00
language : audioInputLanguage || null ,
2026-04-16 22:04:19 +02:00
} ) ,
} )
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 ( ) {
2026-04-17 04:48:55 +02:00
if ( ! audioInputEnabled || ! audioInputRuntimeReady || isRecordingAudio || isTranscribingAudio || isSending ) {
2026-04-16 22:04:19 +02:00
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 ( )
}
2026-04-16 21:29:49 +02:00
async function handleComposerImageSelection ( event ) {
const files = event . target ? . files
try {
await appendComposerImageFiles ( files )
} finally {
if ( event . target ) {
event . target . value = ''
}
}
}
2025-08-25 23:56:26 +02:00
function startEditMessage ( index , content ) {
setEditingMessageIndex ( index ) ;
setEditText ( content || '' ) ;
}
2025-08-26 04:49:54 +02:00
2025-08-25 23:56:26 +02:00
function cancelEditMessage ( ) {
setEditingMessageIndex ( null ) ;
setEditText ( '' ) ;
}
async function commitEditMessage ( index ) {
const original = ( messages [ index ] ? . content || '' ) . trim ( ) ;
2025-08-26 04:49:54 +02:00
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
2025-08-25 23:56:26 +02:00
if ( next === original ) {
cancelEditMessage ( ) ;
return ;
}
2025-08-26 04:49:54 +02:00
2025-08-25 23:56:26 +02:00
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 } ;
} )
) ;
2025-08-26 04:49:54 +02:00
// Exit edit mode immediately
2025-08-25 23:56:26 +02:00
setEditingMessageIndex ( null ) ;
setEditText ( '' ) ;
// ⬇️ Scroll the chat frame to the bottom after the DOM updates
2025-08-26 04:49:54 +02:00
requestAnimationFrame ( ( ) => scrollToBottom ( 'auto' , sessionId ) ) ;
2025-08-25 23:56:26 +02:00
try {
2026-03-20 08:16:41 +01:00
const resp = await fetch ( ` ${ backendApiUrl } /sessions/ ${ sessionId } /messages/ ${ index } ` , {
2025-08-25 23:56:26 +02:00
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
2025-08-27 04:27:18 +02:00
await regenerateFromIndex ( index , next ) ;
2025-08-25 23:56:26 +02:00
}
2025-08-27 04:27:18 +02:00
async function regenerateFromIndex ( index , overrideUserText = null ) {
2026-03-19 21:07:22 +01:00
const sessionId = activeSessionId
if ( isSending || ! sessionId || typeof index !== 'number' ) return
2025-08-27 04:27:18 +02:00
2026-03-19 21:07:22 +01:00
const msgs = ( chatSessions . find ( s => s . session _id === sessionId ) ? . messages ) || [ ]
let lastUserIdx = index
2025-08-27 04:27:18 +02:00
for ( let i = index ; i >= 0 ; i -- ) {
2026-03-19 21:07:22 +01:00
if ( msgs [ i ] ? . role === 'user' ) {
lastUserIdx = i
break
}
2025-08-27 04:27:18 +02:00
}
setChatSessions ( prev =>
prev . map ( s => s . session _id === sessionId
? { ... s , messages : ( s . messages || [ ] ) . slice ( 0 , lastUserIdx + 1 ) }
: s
)
2026-03-19 21:07:22 +01:00
)
2025-08-25 23:56:26 +02:00
2026-03-19 21:07:22 +01:00
const requestController = beginCancelableRequest ( sessionId )
2025-08-27 04:27:18 +02:00
2026-03-19 21:36:36 +01:00
let enrichedPrompt = overrideUserText != null ? overrideUserText : ( msgs [ lastUserIdx ] ? . content || '' )
2026-03-19 21:07:22 +01:00
let citationSources = [ ]
2026-03-19 21:36:36 +01:00
const contextBlocks = [ ]
2026-03-19 21:07:22 +01:00
try {
2026-03-19 21:36:36 +01:00
const selectedLibrary = getChatLibraryForSession ( sessionId )
const promptText = overrideUserText != null ? overrideUserText : ( msgs [ lastUserIdx ] ? . content || '' )
2026-04-16 21:31:14 +02:00
const hasPromptText = Boolean ( ( promptText || '' ) . trim ( ) )
2026-03-19 21:36:36 +01:00
2026-04-16 21:31:14 +02:00
if ( hasPromptText && selectedLibrary ? . states ? . is _indexed ) {
2026-03-19 21:36:36 +01:00
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 )
}
}
2026-04-16 21:31:14 +02:00
if ( hasPromptText && webSearchEnabled ) {
2026-03-19 21:07:22 +01:00
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 }
}
2025-08-27 04:27:18 +02:00
2026-03-20 08:16:41 +01:00
const resp = await fetch ( ` ${ backendApiUrl } /websearch ` , {
2026-03-19 21:07:22 +01:00
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 ,
} )
2025-08-27 04:27:18 +02:00
} )
2026-03-19 21:07:22 +01:00
const data = await resp . json ( )
2026-03-19 21:36:36 +01:00
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 )
2026-03-19 21:07:22 +01:00
}
} catch ( error ) {
if ( isAbortError ( error ) ) throw error
console . warn ( 'web search enrichment (regenerate) failed' , error )
2025-08-27 04:27:18 +02:00
}
2025-08-25 23:56:26 +02:00
}
2026-03-19 21:36:36 +01:00
citationSources = [ ... new Set ( citationSources ) ]
2026-04-16 21:31:14 +02:00
if ( hasPromptText && contextBlocks . length > 0 ) {
2026-03-19 21:36:36 +01:00
enrichedPrompt = ` ${ promptText } \ n \ n ${ contextBlocks . join ( '\n\n' ) } `
} else {
enrichedPrompt = null
}
2026-03-19 21:07:22 +01:00
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
)
2025-08-25 23:56:26 +02:00
)
2026-03-19 21:07:22 +01:00
try {
2026-03-20 08:16:41 +01:00
const res = await fetch ( ` ${ backendApiUrl } /sessions/ ${ sessionId } /regenerate ` , {
2026-03-19 21:07:22 +01:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
signal : requestController . signal ,
body : JSON . stringify ( {
index ,
model ,
stream : true ,
enriched _message : enrichedPrompt ,
sources : citationSources || [ ]
} )
2025-08-27 04:27:18 +02:00
} )
2026-03-19 21:07:22 +01:00
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
Add streaming unread logic, copy button, and improved Markdown rendering
Implemented robust streaming handling for assistant replies: a placeholder message is inserted, unread sessions are flagged when the user is in a different chat, and scroll‑to‑bottom/ tip logic is applied once the stream completes.
• Added copy‑to‑clipboard support for code blocks with visual feedback.
• Re‑implemented Markdown to clean trailing whitespace in code blocks, preserve formatting, add copy buttons, and refine table & blockquote handling.
• Introduced new CSS classes (.codeblock, .codeblock__header, .codeblock__copy, etc.) to style the code block wrapper, header bar, copy button, and pre/code area.
• Updated the front‑end to wire up the new copy handler, manage pending scroll targets, and keep unread state in sync.
• Minor cleanup of stray tags and comments throughout the renderer.
• Minor fixes to ensure proper scrolling behavior when the user scrolls away from the bottom during streaming.
• Added descriptive comments and ensured cross‑browser copy functionality via navigator.clipboard.
• Updated the markdown renderer to keep raw newlines for copy‑paste fidelity and to escape HTML entities correctly.
• Adjusted CSS to match existing theme variables and provide smooth copy button transitions.
2025-08-26 02:56:56 +02:00
2026-03-19 21:07:22 +01:00
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 ] ) ] )
}
2025-08-27 04:27:18 +02:00
}
2026-03-19 21:07:22 +01:00
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 ) )
Add streaming unread logic, copy button, and improved Markdown rendering
Implemented robust streaming handling for assistant replies: a placeholder message is inserted, unread sessions are flagged when the user is in a different chat, and scroll‑to‑bottom/ tip logic is applied once the stream completes.
• Added copy‑to‑clipboard support for code blocks with visual feedback.
• Re‑implemented Markdown to clean trailing whitespace in code blocks, preserve formatting, add copy buttons, and refine table & blockquote handling.
• Introduced new CSS classes (.codeblock, .codeblock__header, .codeblock__copy, etc.) to style the code block wrapper, header bar, copy button, and pre/code area.
• Updated the front‑end to wire up the new copy handler, manage pending scroll targets, and keep unread state in sync.
• Minor cleanup of stray tags and comments throughout the renderer.
• Minor fixes to ensure proper scrolling behavior when the user scrolls away from the bottom during streaming.
• Added descriptive comments and ensured cross‑browser copy functionality via navigator.clipboard.
• Updated the markdown renderer to keep raw newlines for copy‑paste fidelity and to escape HTML entities correctly.
• Adjusted CSS to match existing theme variables and provide smooth copy button transitions.
2025-08-26 02:56:56 +02:00
} else {
2026-03-19 21:07:22 +01:00
setNewMsgTip ( prev => ( { ... prev , [ sessionId ] : assistantMsgId } ) )
}
} catch ( error ) {
if ( isAbortError ( error ) ) {
setAssistantMessageContent ( sessionId , assistantMsgId , full , { removeIfEmpty : true } )
return
Add streaming unread logic, copy button, and improved Markdown rendering
Implemented robust streaming handling for assistant replies: a placeholder message is inserted, unread sessions are flagged when the user is in a different chat, and scroll‑to‑bottom/ tip logic is applied once the stream completes.
• Added copy‑to‑clipboard support for code blocks with visual feedback.
• Re‑implemented Markdown to clean trailing whitespace in code blocks, preserve formatting, add copy buttons, and refine table & blockquote handling.
• Introduced new CSS classes (.codeblock, .codeblock__header, .codeblock__copy, etc.) to style the code block wrapper, header bar, copy button, and pre/code area.
• Updated the front‑end to wire up the new copy handler, manage pending scroll targets, and keep unread state in sync.
• Minor cleanup of stray tags and comments throughout the renderer.
• Minor fixes to ensure proper scrolling behavior when the user scrolls away from the bottom during streaming.
• Added descriptive comments and ensured cross‑browser copy functionality via navigator.clipboard.
• Updated the markdown renderer to keep raw newlines for copy‑paste fidelity and to escape HTML entities correctly.
• Adjusted CSS to match existing theme variables and provide smooth copy button transitions.
2025-08-26 02:56:56 +02:00
}
2026-03-19 21:07:22 +01:00
console . error ( error )
setAssistantMessageContent ( sessionId , assistantMsgId , ` Error: ${ getErrorText ( error ) } ` , { removeIfEmpty : true } )
return
2025-08-25 23:56:26 +02:00
}
2026-03-19 21:07:22 +01:00
} else {
2026-03-20 08:16:41 +01:00
const res = await fetch ( ` ${ backendApiUrl } /sessions/ ${ sessionId } /regenerate ` , {
2025-08-27 04:27:18 +02:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-03-19 21:07:22 +01:00
signal : requestController . signal ,
2025-08-27 04:27:18 +02:00
body : JSON . stringify ( {
index ,
model ,
stream : false ,
enriched _message : enrichedPrompt ,
sources : citationSources || [ ]
} )
2026-03-19 21:07:22 +01:00
} )
if ( ! res . ok ) throw new Error ( ` HTTP ${ res . status } ` )
const data = await res . json ( )
const assistantMsgId = ` msg- ${ Date . now ( ) } `
2025-08-27 04:27:18 +02:00
setChatSessions ( prev =>
prev . map ( s => s . session _id === sessionId
? { ... s , messages : [ ... ( s . messages || [ ] ) , { role : 'assistant' , content : data . reply , id : assistantMsgId , sources : citationSources } ] }
: s
)
2026-03-19 21:07:22 +01:00
)
2025-08-27 04:27:18 +02:00
if ( activeSessionIdRef . current !== sessionId ) {
2026-03-19 21:07:22 +01:00
setPendingScrollToLastUser ( prev => ( { ... prev , [ sessionId ] : assistantMsgId } ) )
setUnreadSessions ( prev => [ ... new Set ( [ ... prev , sessionId ] ) ] )
} else if ( ! userScrolledUpRef . current [ sessionId ] ) {
requestAnimationFrame ( ( ) => scrollMessageToTop ( assistantMsgId , 'smooth' , sessionId ) )
2025-08-27 04:27:18 +02:00
} else {
2026-03-19 21:07:22 +01:00
setNewMsgTip ( prev => ( { ... prev , [ sessionId ] : assistantMsgId } ) )
2025-08-27 04:27:18 +02:00
}
2025-08-25 23:56:26 +02:00
}
2026-03-19 21:07:22 +01:00
} catch ( error ) {
if ( ! isAbortError ( error ) ) {
console . error ( error )
}
} finally {
finishCancelableRequest ( requestController )
2025-08-25 23:56:26 +02:00
}
2025-08-27 04:27:18 +02:00
}
2025-08-25 23:56:26 +02:00
2025-08-23 16:45:46 +02:00
// 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 ( { } ) ;
2025-08-25 21:13:09 +02:00
// Live per-session previous scrollTop tracker to detect scroll direction
const prevScrollTopsRef = useRef ( { } ) ;
2025-08-23 16:45:46 +02:00
// Tip state: { [sessionId]: messageId }
const [ newMsgTip , setNewMsgTip ] = useState ( { } ) ;
2025-08-26 05:05:43 +02:00
// Collapse state per user message: { [msgKey]: boolean } — true means "collapsed"
const [ collapsedUserMsgs , setCollapsedUserMsgs ] = useState ( { } ) ;
2026-04-17 08:01:23 +02:00
useEffect ( ( ) => {
activeSidebarModeRef . current = activeSidebarMode
} , [ activeSidebarMode ] )
2025-08-26 05:05:43 +02:00
// 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 ) } ) ) ;
}
2025-08-23 16:45:46 +02:00
const setUserScrolledUp = React . useCallback ( ( sessionId , value ) => {
setUserScrolledUpState ( prev => {
const next = { ... prev , [ sessionId ] : value } ;
userScrolledUpRef . current = next ;
return next ;
} ) ;
} , [ ] ) ;
2026-03-19 21:07:22 +01:00
const activeRequestRef = useRef ( null ) ;
2025-08-23 16:45:46 +02:00
const justSentMessage = useRef ( false ) ;
const lastSentSessionRef = useRef ( null ) ;
2025-08-22 23:42:34 +02:00
const activeSessionIdRef = useRef ( activeSessionId ) ;
useEffect ( ( ) => {
activeSessionIdRef . current = activeSessionId ;
} , [ activeSessionId ] ) ;
2026-03-19 21:07:22 +01:00
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 ( )
}
} , [ ] )
2025-08-23 16:45:46 +02:00
// Flag to ensure we only restore once per open of a chat
const restoredForRef = useRef ( null ) ;
2025-08-22 23:42:34 +02:00
// Sidebar resizing state
2025-08-23 16:45:46 +02:00
const [ sidebarWidth , setSidebarWidth ] = useState ( 230 ) ;
2025-08-22 23:42:34 +02:00
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 ) ;
2026-04-17 08:01:23 +02:00
window . addEventListener ( 'blur' , stopResizing ) ;
2025-08-22 23:42:34 +02:00
return ( ) => {
window . removeEventListener ( 'mousemove' , resizeSidebar ) ;
window . removeEventListener ( 'mouseup' , stopResizing ) ;
2026-04-17 08:01:23 +02:00
window . removeEventListener ( 'blur' , stopResizing ) ;
2025-08-22 23:42:34 +02:00
} ;
} , [ resizeSidebar , stopResizing ] ) ;
React . useEffect ( ( ) => {
if ( isResizing ) {
document . body . classList . add ( 'no-select' ) ;
} else {
document . body . classList . remove ( 'no-select' ) ;
}
2026-04-17 08:01:23 +02:00
return ( ) => {
document . body . classList . remove ( 'no-select' ) ;
} ;
2025-08-22 23:42:34 +02:00
} , [ isResizing ] ) ;
Add streaming unread logic, copy button, and improved Markdown rendering
Implemented robust streaming handling for assistant replies: a placeholder message is inserted, unread sessions are flagged when the user is in a different chat, and scroll‑to‑bottom/ tip logic is applied once the stream completes.
• Added copy‑to‑clipboard support for code blocks with visual feedback.
• Re‑implemented Markdown to clean trailing whitespace in code blocks, preserve formatting, add copy buttons, and refine table & blockquote handling.
• Introduced new CSS classes (.codeblock, .codeblock__header, .codeblock__copy, etc.) to style the code block wrapper, header bar, copy button, and pre/code area.
• Updated the front‑end to wire up the new copy handler, manage pending scroll targets, and keep unread state in sync.
• Minor cleanup of stray tags and comments throughout the renderer.
• Minor fixes to ensure proper scrolling behavior when the user scrolls away from the bottom during streaming.
• Added descriptive comments and ensured cross‑browser copy functionality via navigator.clipboard.
• Updated the markdown renderer to keep raw newlines for copy‑paste fidelity and to escape HTML entities correctly.
• Adjusted CSS to match existing theme variables and provide smooth copy button transitions.
2025-08-26 02:56:56 +02:00
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 ) ;
} , [ ] ) ;
2025-08-22 23:42:34 +02:00
useEffect ( ( ) => {
2026-04-17 08:01:23 +02:00
let cancelled = false
2025-08-22 23:42:34 +02:00
window . electronAPI . getSettings ( ) . then ( settings => {
2026-04-17 08:01:23 +02:00
if ( cancelled ) return
2026-03-20 08:16:41 +01:00
setBackendApiUrl ( resolveBackendApiUrl ( settings ) ) ;
2026-03-19 21:07:22 +01:00
setColorScheme ( settings . colorScheme || 'Default' ) ;
2025-08-22 23:42:34 +02:00
setModel ( settings . chatModel || '' ) ; // Load the selected model, with a fallback
2025-08-23 16:45:46 +02:00
setStreamOutput ( settings . streamOutput || false ) ;
2026-04-16 22:04:46 +02:00
setAudioInputEnabled ( settings . audioInputEnabled === true ) ;
setAudioInputDeviceId ( typeof settings . audioInputDeviceId === 'string' ? settings . audioInputDeviceId : '' ) ;
2026-04-17 04:43:28 +02:00
setAudioInputLanguage ( typeof settings . audioInputLanguage === 'string' ? settings . audioInputLanguage : '' ) ;
2025-08-23 16:45:46 +02:00
setScrollPositions ( settings . scrollPositions || { } ) ; // Load scroll positions
2026-03-19 21:07:22 +01:00
applyColorScheme ( settings . colorScheme || 'Default' ) ; // Apply initial scheme
2026-03-20 12:00:44 +01:00
} ) . finally ( ( ) => {
2026-04-17 08:01:23 +02:00
if ( ! cancelled ) {
setSettingsLoaded ( true ) ;
}
2025-08-22 23:42:34 +02:00
} ) ;
2025-08-25 21:13:09 +02:00
2026-04-17 08:01:23 +02:00
return ( ) => {
cancelled = true
} ;
} , [ ] ) ;
useEffect ( ( ) => {
2025-08-25 21:13:09 +02:00
const handleFocus = ( ) => {
2026-04-17 08:01:23 +02:00
if ( activeSidebarModeRef . current === 'chats' ) {
2025-08-25 21:13:09 +02:00
textareaRef . current ? . focus ( ) ;
}
} ;
window . electronAPI . onWindowFocus ( handleFocus ) ;
return ( ) => {
2026-04-17 08:01:23 +02:00
window . electronAPI . offWindowFocus ( handleFocus ) ;
2025-08-25 21:13:09 +02:00
} ;
2026-04-17 08:01:23 +02:00
} , [ ] ) ;
2025-08-22 23:42:34 +02:00
2026-04-16 21:30:33 +02:00
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 ] )
2026-04-16 22:04:46 +02:00
useEffect ( ( ) => {
if ( audioInputEnabled || ! isRecordingAudio ) {
return
}
stopAudioRecording ( { shouldTranscribe : false } )
} , [ audioInputEnabled , isRecordingAudio ] )
2026-04-17 04:48:55 +02:00
useEffect ( ( ) => {
if ( audioInputRuntimeReady || ! isRecordingAudio ) {
return
}
stopAudioRecording ( { shouldTranscribe : false } )
} , [ audioInputRuntimeReady , isRecordingAudio ] )
2026-04-16 22:04:46 +02:00
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 )
}
} , [ ] )
2026-03-20 12:00:44 +01:00
useEffect ( ( ) => {
2026-03-20 12:04:40 +01:00
if ( ! settingsLoaded || loading || ! backendApiUrl || startupOllamaCheckRanRef . current ) return
2026-03-20 12:00:44 +01:00
startupOllamaCheckRanRef . current = true
let cancelled = false
2026-03-20 12:04:40 +01:00
const timerId = window . setTimeout ( ( ) => { ; ( async ( ) => {
2026-03-20 12:00:44 +01:00
let actionStarted = false
try {
let status = await fetchStartupOllamaStatus ( )
if ( cancelled ) return
2026-04-17 04:48:55 +02:00
syncAudioInputRuntimeFromStartupStatus ( status )
2026-03-20 12:00:44 +01:00
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
2026-03-20 12:04:40 +01:00
setStartupTaskBusy ( true )
2026-03-20 12:00:44 +01:00
setStartupTaskMessage ( 'Starting Ollama in the background...' )
const response = await fetch ( ` ${ backendApiUrl } /ollama/start ` , { method : 'POST' } )
status = await expectBackendJson ( response )
if ( cancelled ) return
}
}
2026-03-20 15:43:00 +01:00
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 {
2026-03-20 12:04:40 +01:00
setStartupTaskMessage ( ` Downloading ${ status . selected _embed _model } from Ollama. This can take a while on first install. ` )
2026-03-20 12:00:44 +01:00
}
2026-04-17 04:48:55 +02:00
const prepared = await prepareStartupModels ( )
2026-03-20 15:43:00 +01:00
if ( cancelled ) return
2026-04-17 04:48:55 +02:00
syncAudioInputRuntimeFromStartupStatus ( prepared ? . ollama || status )
2026-03-20 12:00:44 +01:00
}
} catch ( error ) {
if ( ! cancelled ) {
console . warn ( 'startup Ollama check failed' , error )
2026-04-17 04:48:55 +02:00
setAudioInputRuntimeReady ( false )
setAudioInputRuntimeMessage ( ` Whisper availability could not be verified: ${ getErrorText ( error ) } ` )
2026-03-20 12:00:44 +01:00
if ( actionStarted ) {
window . alert ( ` Startup action failed: ${ getErrorText ( error ) } ` )
}
}
} finally {
if ( ! cancelled ) {
2026-03-20 12:04:40 +01:00
setStartupTaskBusy ( false )
2026-03-20 12:00:44 +01:00
setStartupTaskMessage ( '' )
}
}
2026-03-20 12:04:40 +01:00
} ) ( ) } , 1200 )
2026-03-20 12:00:44 +01:00
return ( ) => {
cancelled = true
2026-03-20 12:04:40 +01:00
window . clearTimeout ( timerId )
2026-03-20 12:00:44 +01:00
}
2026-03-20 12:04:40 +01:00
} , [ backendApiUrl , loading , settingsLoaded ] ) ;
2026-03-20 12:00:44 +01:00
2025-08-22 23:42:34 +02:00
// Apply color scheme whenever it changes
useEffect ( ( ) => {
applyColorScheme ( colorScheme ) ;
} , [ colorScheme ] ) ;
const fetchHistory = ( sessionId ) => {
2026-03-20 08:16:41 +01:00
if ( ! sessionId || ! backendApiUrl ) return ;
fetch ( ` ${ backendApiUrl } /history?session_id= ${ encodeURIComponent ( sessionId ) } ` )
2025-08-22 23:42:34 +02:00
. then ( r => r . json ( ) )
. then ( data => {
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
session . session _id === sessionId
? { ... session , messages : data . messages || [ ] }
: session
)
) ;
} )
. catch ( ( ) => { } ) ;
} ;
2026-03-19 21:07:22 +01:00
async function refreshLibraries ( ) {
2026-03-20 08:16:41 +01:00
if ( ! backendApiUrl ) return ;
2026-03-19 21:07:22 +01:00
try {
2026-03-20 08:16:41 +01:00
const response = await fetch ( ` ${ backendApiUrl } /libraries ` ) ;
2026-03-19 21:07:22 +01:00
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 ( ) {
2026-03-20 08:16:41 +01:00
if ( ! backendApiUrl ) return ;
2026-03-19 21:07:22 +01:00
try {
2026-03-20 08:16:41 +01:00
const response = await fetch ( ` ${ backendApiUrl } /jobs ` ) ;
2026-03-19 21:07:22 +01:00
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 ( '' )
2026-03-20 08:16:41 +01:00
const response = await fetch ( ` ${ backendApiUrl } /libraries ` , {
2026-03-19 21:07:22 +01:00
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 ) )
}
}
2026-03-20 10:33:35 +01:00
async function handleLibrariesPurged ( ) {
setLibraries ( [ ] )
setLibraryJobs ( [ ] )
setActiveLibrarySlug ( null )
setEditingLibrarySlug ( null )
setIsDbPickerOpen ( false )
setChatLibraryBySession ( { } )
await refreshLibraries ( )
await refreshLibraryJobs ( )
}
2025-08-22 23:42:34 +02:00
// Load chat sessions from backend on initial render
useEffect ( ( ) => {
2026-03-20 08:16:41 +01:00
if ( ! backendApiUrl ) return ;
2025-08-22 23:42:34 +02:00
setLoading ( true ) ;
2026-03-20 08:16:41 +01:00
fetch ( ` ${ backendApiUrl } /sessions ` )
2025-08-22 23:42:34 +02:00
. 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 ) ;
} ) ;
2026-03-20 08:16:41 +01:00
} , [ backendApiUrl ] ) ;
2025-08-22 23:42:34 +02:00
2026-03-19 21:07:22 +01:00
useEffect ( ( ) => {
2026-03-20 08:16:41 +01:00
if ( ! backendApiUrl ) return ;
2026-03-19 21:07:22 +01:00
refreshLibraries ( ) ;
refreshLibraryJobs ( ) ;
2026-03-20 08:16:41 +01:00
} , [ backendApiUrl ] ) ;
2026-03-19 21:07:22 +01:00
useEffect ( ( ) => {
try {
2026-03-19 21:36:10 +01:00
localStorage . setItem ( CHAT _LIBRARY _MAP _KEY , JSON . stringify ( chatLibraryBySession || { } ) ) ;
2026-03-19 21:07:22 +01:00
} catch { }
2026-03-19 21:36:10 +01:00
} , [ chatLibraryBySession ] ) ;
2026-03-19 21:07:22 +01:00
useEffect ( ( ) => {
2026-03-20 08:16:41 +01:00
if ( ! backendApiUrl ) return ;
2026-03-19 21:07:22 +01:00
const interval = setInterval ( ( ) => {
refreshLibraries ( ) ;
refreshLibraryJobs ( ) ;
} , 3000 ) ;
return ( ) => clearInterval ( interval ) ;
2026-03-20 08:16:41 +01:00
} , [ backendApiUrl , activeSidebarMode , activeLibrarySlug ] ) ;
2026-03-19 21:07:22 +01:00
2025-08-22 23:42:34 +02:00
// Load messages for the active session
useEffect ( ( ) => {
fetchHistory ( activeSessionId ) ;
2026-03-20 08:17:30 +01:00
} , [ activeSessionId , backendApiUrl ] ) ;
2025-08-22 23:42:34 +02:00
2026-03-19 21:36:10 +01:00
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 ] )
2025-08-23 16:45:46 +02:00
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 ) ;
} ;
2025-08-22 23:42:34 +02:00
const messages = useMemo ( ( ) => {
return chatSessions . find ( s => s . session _id === activeSessionId ) ? . messages || [ ] ;
} , [ activeSessionId , chatSessions ] ) ;
2026-03-19 21:07:22 +01:00
const activeLibrary = useMemo ( ( ) => {
return libraries . find ( lib => lib . slug === activeLibrarySlug ) || null ;
} , [ activeLibrarySlug , libraries ] ) ;
2026-03-19 21:36:10 +01:00
const chatLibrarySlug = activeSessionId ? ( chatLibraryBySession [ activeSessionId ] || null ) : null
2026-03-19 21:07:22 +01:00
const chatLibrary = useMemo ( ( ) => {
return libraries . find ( lib => lib . slug === chatLibrarySlug ) || null ;
} , [ chatLibrarySlug , libraries ] ) ;
2026-03-19 21:36:10 +01:00
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 ] )
2026-03-19 21:14:40 +01:00
2026-03-19 21:36:22 +01:00
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 ] )
2025-08-23 16:45:46 +02:00
// Persist the scrollTop of the session we are LEAVING (on chat change or when leaving the chat view)
2025-08-22 23:42:34 +02:00
useEffect ( ( ) => {
2025-08-23 16:45:46 +02:00
const leavingSessionId = activeSessionId ;
const leavingMode = activeSidebarMode ;
2025-08-22 23:42:34 +02:00
2025-08-23 16:45:46 +02:00
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 ;
2025-08-25 21:13:09 +02:00
2025-08-23 16:45:46 +02:00
if ( activeSessionId ) {
2025-08-25 21:13:09 +02:00
const prevScrollTop = prevScrollTopsRef . current [ activeSessionId ] ;
const scrolledUp = typeof prevScrollTop === 'number' && scrollTop < prevScrollTop ;
2025-08-23 16:45:46 +02:00
scrollTopsRef . current [ activeSessionId ] = scrollTop ;
2025-08-25 21:13:09 +02:00
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 ;
2025-08-23 16:45:46 +02:00
}
} ;
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 ) ;
} ;
2025-08-22 23:42:34 +02:00
2025-08-23 16:45:46 +02:00
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 ;
} ) ;
}
} ;
2026-04-16 21:31:04 +02:00
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 ( )
}
2025-08-23 16:45:46 +02:00
2025-08-27 04:27:18 +02:00
async function sendMessage ( ) {
2026-04-16 21:31:04 +02:00
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
}
2025-08-22 23:42:34 +02:00
2026-03-19 21:07:22 +01:00
let targetSessionId = activeSessionId
let isNewChat = false
2025-08-27 04:27:18 +02:00
if ( ! targetSessionId ) {
2026-03-19 21:07:22 +01:00
const newSession = await createNewChat ( )
await new Promise ( resolve => setTimeout ( resolve , 200 ) )
targetSessionId = newSession . session _id
isNewChat = true
2025-08-27 04:27:18 +02:00
} else {
2026-03-19 21:07:22 +01:00
const currentSession = chatSessions . find ( s => s . session _id === targetSessionId )
isNewChat = currentSession && currentSession . name === "New Chat" && currentSession . messages . length === 0
2025-08-27 04:27:18 +02:00
}
2026-04-16 21:31:04 +02:00
const outgoingAttachments = composerAttachments . map ( ( { id , ... attachment } ) => ( { ... attachment } ) )
const userMsg = {
role : 'user' ,
content : trimmedInput ,
attachments : outgoingAttachments ,
id : ` msg- ${ Date . now ( ) } - ${ Math . random ( ) } `
}
2026-03-19 21:07:22 +01:00
justSentMessage . current = true
lastSentSessionRef . current = targetSessionId
setUserScrolledUp ( targetSessionId , false )
2025-08-27 04:27:18 +02:00
if ( activeSessionIdRef . current === targetSessionId ) {
2026-03-19 21:07:22 +01:00
restoredForRef . current = activeSessionIdRef . current
2025-08-27 04:27:18 +02:00
}
flushSync ( ( ) => {
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
session . session _id === targetSessionId
? { ... session , messages : [ ... ( session . messages || [ ] ) , userMsg ] }
: session
)
2026-03-19 21:07:22 +01:00
)
setInput ( '' )
2026-04-16 21:31:04 +02:00
setComposerAttachments ( [ ] )
2026-03-19 21:07:22 +01:00
} )
requestAnimationFrame ( ( ) => scrollToBottom ( 'auto' , targetSessionId ) )
2025-08-27 04:27:18 +02:00
2026-03-19 21:07:22 +01:00
const requestController = beginCancelableRequest ( targetSessionId )
2025-08-27 04:27:18 +02:00
try {
2026-03-19 21:07:22 +01:00
let historyForSearch = [ ]
2026-04-16 21:31:04 +02:00
if ( userMsg . content ) try {
2026-03-19 21:07:22 +01:00
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 } ]
2025-08-27 04:27:18 +02:00
} catch { }
2026-04-16 21:31:04 +02:00
let enrichedPrompt = userMsg . content || null
2026-03-19 21:07:22 +01:00
let citationSources = [ ]
const contextBlocks = [ ]
2026-03-19 21:36:43 +01:00
const selectedLibrary = getChatLibraryForSession ( targetSessionId )
2026-04-16 21:31:04 +02:00
if ( userMsg . content && selectedLibrary ? . states ? . is _indexed ) {
2026-03-19 21:07:22 +01:00
try {
2026-03-19 21:36:43 +01:00
const localContext = await fetchLocalLibraryContext ( selectedLibrary . slug , userMsg . content , requestController . signal )
if ( localContext . contextBlock ) {
contextBlocks . push ( localContext . contextBlock )
2026-03-19 21:07:22 +01:00
}
2026-03-19 21:36:43 +01:00
if ( Array . isArray ( localContext . sources ) ) {
citationSources . push ( ... localContext . sources )
2026-03-19 21:07:22 +01:00
}
} catch ( error ) {
if ( isAbortError ( error ) ) throw error
console . warn ( 'local library enrichment failed' , error )
}
}
2026-04-16 21:31:04 +02:00
if ( userMsg . content && webSearchEnabled ) {
2025-08-27 04:27:18 +02:00
try {
2026-03-20 08:16:41 +01:00
const resp = await fetch ( ` ${ backendApiUrl } /websearch ` , {
2025-08-27 04:27:18 +02:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-03-19 21:07:22 +01:00
signal : requestController . signal ,
2025-08-27 04:27:18 +02:00
body : JSON . stringify ( {
prompt : userMsg . content ,
model ,
messages : historyForSearch ,
history _limit : 8 ,
searx _url : searxUrl || null ,
engines : Array . isArray ( searxEngines ) ? searxEngines : null ,
} )
2026-03-19 21:07:22 +01:00
} )
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 )
2025-08-27 04:27:18 +02:00
}
2026-03-19 21:07:22 +01:00
} catch ( error ) {
if ( isAbortError ( error ) ) throw error
console . warn ( 'web search enrichment failed' , error )
2025-08-27 04:27:18 +02:00
}
2025-08-23 16:45:46 +02:00
}
2025-08-22 23:42:34 +02:00
2026-03-19 21:07:22 +01:00
citationSources = [ ... new Set ( citationSources ) ]
2026-04-16 21:31:04 +02:00
if ( userMsg . content && contextBlocks . length > 0 ) {
2026-03-19 21:07:22 +01:00
enrichedPrompt = ` ${ userMsg . content } \ n \ n ${ contextBlocks . join ( '\n\n' ) } `
}
2025-08-27 04:27:18 +02:00
if ( streamOutput ) {
2026-03-19 21:07:22 +01:00
const assistantMsgId = ` msg- ${ Date . now ( ) } - ${ Math . random ( ) } `
let fullReply = ''
const assistantMsg = { role : 'assistant' , content : '' , id : assistantMsgId , sources : citationSources }
2025-08-22 23:42:34 +02:00
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
session . session _id === targetSessionId
2025-08-27 04:27:18 +02:00
? { ... session , messages : [ ... ( session . messages || [ ] ) , assistantMsg ] }
2025-08-22 23:42:34 +02:00
: session
)
2026-03-19 21:07:22 +01:00
)
2025-08-23 16:45:46 +02:00
2026-03-19 21:07:22 +01:00
try {
2026-03-20 08:16:41 +01:00
const res = await fetch ( ` ${ backendApiUrl } /chat ` , {
2026-03-19 21:07:22 +01:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
signal : requestController . signal ,
body : JSON . stringify ( {
session _id : targetSessionId ,
model ,
message : userMsg . content ,
2026-04-16 21:31:04 +02:00
enriched _message : userMsg . content && contextBlocks . length > 0 ? enrichedPrompt : null ,
2026-03-19 21:07:22 +01:00
stream : true ,
2026-04-16 21:31:04 +02:00
sources : citationSources || [ ] ,
attachments : outgoingAttachments ,
2026-03-19 21:07:22 +01:00
} )
} )
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 ) )
2025-08-27 04:27:18 +02:00
} else {
2026-03-19 21:07:22 +01:00
setNewMsgTip ( prev => ( { ... prev , [ targetSessionId ] : assistantMsgId } ) )
2025-08-23 16:45:46 +02:00
}
2026-03-19 21:07:22 +01:00
} else {
setPendingScrollToLastUser ( prev => ( { ... prev , [ targetSessionId ] : assistantMsgId } ) )
setUnreadSessions ( prev => [ ... new Set ( [ ... prev , targetSessionId ] ) ] )
2025-08-23 16:45:46 +02:00
}
2026-03-19 21:07:22 +01:00
break
2025-08-23 16:45:46 +02:00
}
2026-03-19 21:07:22 +01:00
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
2025-08-27 04:27:18 +02:00
}
2026-03-19 21:07:22 +01:00
console . error ( 'Failed to send message:' , error )
setAssistantMessageContent ( targetSessionId , assistantMsgId , 'Error: ' + getErrorText ( error ) , { removeIfEmpty : true } )
return
}
2025-08-27 04:27:18 +02:00
} else {
2026-03-20 08:16:41 +01:00
const res = await fetch ( ` ${ backendApiUrl } /chat ` , {
2025-08-27 04:27:18 +02:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-03-19 21:07:22 +01:00
signal : requestController . signal ,
2025-08-27 04:27:18 +02:00
body : JSON . stringify ( {
session _id : targetSessionId ,
model ,
message : userMsg . content ,
2026-04-16 21:31:04 +02:00
enriched _message : userMsg . content && contextBlocks . length > 0 ? enrichedPrompt : null ,
2025-08-27 04:27:18 +02:00
stream : false ,
2026-04-16 21:31:04 +02:00
sources : citationSources || [ ] ,
attachments : outgoingAttachments ,
2025-08-27 04:27:18 +02:00
} )
2026-03-19 21:07:22 +01:00
} )
if ( ! res . ok ) throw new Error ( ` HTTP ${ res . status } ` )
const data = await res . json ( )
const assistantMsgId = ` msg- ${ Date . now ( ) } `
2025-08-27 04:27:18 +02:00
const assistantMsg = {
role : 'assistant' ,
content : data . reply ,
id : assistantMsgId ,
sources : citationSources
2026-03-19 21:07:22 +01:00
}
2025-08-27 04:27:18 +02:00
2025-08-22 23:42:34 +02:00
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
session . session _id === targetSessionId
2025-08-27 04:27:18 +02:00
? { ... session , messages : [ ... ( session . messages || [ ] ) , assistantMsg ] }
2025-08-22 23:42:34 +02:00
: session
)
2026-03-19 21:07:22 +01:00
)
2025-08-27 04:27:18 +02:00
if ( assistantMsgId ) {
if ( activeSessionIdRef . current === targetSessionId ) {
if ( ! userScrolledUpRef . current [ targetSessionId ] ) {
2026-03-19 21:07:22 +01:00
requestAnimationFrame ( ( ) => scrollMessageToTop ( assistantMsgId , 'smooth' , targetSessionId ) )
2025-08-27 04:27:18 +02:00
} else {
2026-03-19 21:07:22 +01:00
setNewMsgTip ( prev => ( { ... prev , [ targetSessionId ] : assistantMsgId } ) )
2025-08-27 04:27:18 +02:00
}
} else {
2026-03-19 21:07:22 +01:00
setPendingScrollToLastUser ( prev => ( { ... prev , [ targetSessionId ] : assistantMsgId } ) )
2025-08-27 04:27:18 +02:00
}
}
2025-08-22 23:42:34 +02:00
}
2025-08-27 04:27:18 +02:00
if ( activeSessionIdRef . current !== targetSessionId ) {
2026-03-19 21:07:22 +01:00
setUnreadSessions ( prev => [ ... new Set ( [ ... prev , targetSessionId ] ) ] )
2025-08-27 04:27:18 +02:00
}
if ( isNewChat ) {
2026-03-20 08:16:41 +01:00
fetch ( ` ${ backendApiUrl } /generate-title ` , {
2025-08-27 04:27:18 +02:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
session _id : targetSessionId ,
2026-04-16 21:31:04 +02:00
message : userMsg . content || ( outgoingAttachments . length > 0 ? 'Image attachment' : userMsg . content ) ,
2026-03-19 21:07:22 +01:00
model
2025-08-27 04:27:18 +02:00
} )
} )
. then ( r => r . json ( ) )
. then ( data => {
2026-03-20 16:25:48 +01:00
const sanitizedTitle = sanitizeGeneratedChatTitle ( data . title )
2025-08-27 04:27:18 +02:00
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
session . session _id === targetSessionId ? { ... session , name : sanitizedTitle } : session
)
2026-03-19 21:07:22 +01:00
)
} )
2025-08-27 04:27:18 +02:00
}
2026-03-19 21:07:22 +01:00
} 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 ( ) } ` }
2025-08-27 04:27:18 +02:00
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
session . session _id === targetSessionId
? { ... session , messages : [ ... session . messages , errorMsg ] }
: session
)
2026-03-19 21:07:22 +01:00
)
} finally {
finishCancelableRequest ( requestController )
2025-08-22 23:42:34 +02:00
}
2025-08-27 04:27:18 +02:00
}
function toggleWebSearch ( ) {
setWebSearchEnabled ( prev => ! prev ) ;
}
2025-08-22 23:42:34 +02:00
2025-08-27 04:27:18 +02:00
async function createNewChat ( ) {
2025-08-22 23:42:34 +02:00
const newSessionId = 'sess-' + Math . random ( ) . toString ( 36 ) . slice ( 2 ) + Date . now ( ) . toString ( 36 ) ;
2026-03-20 08:16:41 +01:00
const res = await fetch ( ` ${ backendApiUrl } /sessions ` , {
2025-08-22 23:42:34 +02:00
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 ) ;
2025-08-25 21:13:09 +02:00
textareaRef . current ? . focus ( ) ;
2025-08-22 23:42:34 +02:00
return newSession ;
}
function selectChat ( sessionId ) {
setActiveSessionId ( sessionId ) ;
2025-08-23 16:45:46 +02:00
// Clear unread dot immediately for this chat
2025-08-22 23:42:34 +02:00
setUnreadSessions ( prev => prev . filter ( id => id !== sessionId ) ) ;
2025-08-23 16:45:46 +02:00
// 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 ) ;
} ) ;
}
2025-08-22 23:42:34 +02:00
}
function handleRename ( sessionId , newName ) {
2026-03-20 08:16:41 +01:00
fetch ( ` ${ backendApiUrl } /sessions/ ${ sessionId } /rename ` , {
2025-08-22 23:42:34 +02:00
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 ) ;
} ) ;
}
2026-03-19 21:28:01 +01:00
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
}
2026-03-20 08:16:41 +01:00
fetch ( ` ${ backendApiUrl } /libraries/ ${ slug } ` , {
2026-03-19 21:28:01 +01:00
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 )
} )
}
2025-08-22 23:42:34 +02:00
function handleDelete ( sessionId ) {
2026-03-20 08:16:41 +01:00
fetch ( ` ${ backendApiUrl } /sessions/ ${ sessionId } ` , { method : 'DELETE' } )
2025-08-22 23:42:34 +02:00
. then ( ( ) => {
const newSessions = chatSessions . filter ( s => s . session _id !== sessionId ) ;
setChatSessions ( newSessions ) ;
2026-03-19 21:36:47 +01:00
setChatLibraryBySession ( prev => {
const next = { ... ( prev || { } ) }
delete next [ sessionId ]
return next
} )
2025-08-22 23:42:34 +02:00
if ( activeSessionId === sessionId ) {
setActiveSessionId ( newSessions . length > 0 ? newSessions [ 0 ] . session _id : null ) ;
}
} ) ;
}
2026-03-20 10:18:12 +01:00
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 )
} )
}
2025-08-22 23:42:34 +02:00
// 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 ) ;
} ) ;
}
2026-03-20 08:16:41 +01:00
} , [ activeSessionId , chatSessions , backendApiUrl ] ) ;
2025-08-22 23:42:34 +02:00
2025-08-25 21:13:09 +02:00
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 ( ) ;
} ;
2025-08-22 23:42:34 +02:00
return (
< div className = "app" style = { { gridTemplateColumns : ` ${ sidebarWidth } px 1fr ` } } >
< div className = "sidebar" >
< div className = "sidebar-header" >
< div
className = { ` sidebar-tab ${ activeSidebarMode === 'chats' ? 'active' : '' } ` }
2025-08-23 16:45:46 +02:00
onClick = { ( ) => handleSidebarClick ( 'chats' ) }
2025-08-22 23:42:34 +02:00
>
Chats
< / div >
< div
className = { ` sidebar-tab ${ activeSidebarMode === 'dbs' ? 'active' : '' } ` }
2025-08-23 16:45:46 +02:00
onClick = { ( ) => handleSidebarClick ( 'dbs' ) }
2025-08-22 23:42:34 +02:00
>
DBs
< / div >
< div
className = { ` sidebar-tab ${ activeSidebarMode === 'settings' ? 'active' : '' } ` }
2025-08-23 16:45:46 +02:00
onClick = { ( ) => handleSidebarClick ( 'settings' ) }
2025-08-22 23:42:34 +02:00
>
Settings
< / div >
< / div >
< div className = "sidebar-content" >
{ activeSidebarMode === 'chats' && (
< div className = "chat-list" >
{ chatSessions . map ( session => (
< div
key = { session . session _id }
className = { ` chat-item ${ session . session _id === activeSessionId ? 'active' : '' } ` }
2025-08-23 16:45:46 +02:00
onClick = { ( ) => handleSelectChat ( session . session _id ) }
2025-08-22 23:42:34 +02:00
>
{ editingSessionId === session . session _id ? (
< input
type = "text"
className = "rename-input"
defaultValue = { session . name }
onBlur = { ( ) => setEditingSessionId ( null ) }
onKeyDown = { ( e ) => {
if ( e . key === 'Enter' ) {
handleRename ( session . session _id , e . target . value ) ;
} else if ( e . key === 'Escape' ) {
setEditingSessionId ( null ) ;
}
} }
autoFocus
/ >
) : (
< >
< span > { session . name } < / span >
< div className = "chat-item-buttons" >
{ unreadSessions . includes ( session . session _id ) && < div className = "unread-dot" > < / div > }
< button className = "icon-button" onClick = { ( e ) => { e . stopPropagation ( ) ; setEditingSessionId ( session . session _id ) ; } } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = "feather feather-edit-2" > < path d = "M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" > < / path > < / svg >
< / button >
< button className = "icon-button" onClick = { ( e ) => { e . stopPropagation ( ) ; handleDelete ( session . session _id ) ; } } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = "feather feather-x" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" > < / line > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" > < / line > < / svg >
< / button >
< / div >
< / >
) }
< / div >
) ) }
< / div >
) }
{ activeSidebarMode === 'dbs' && (
< div className = "db-list" >
2026-03-19 21:07:22 +01:00
{ libraries . length === 0 ? (
< div className = "empty-list-message" > No databases yet . < / div >
) : (
libraries . map ( library => (
< div
key = { library . slug }
className = { ` chat-item ${ library . slug === activeLibrarySlug ? 'active' : '' } ` }
onClick = { ( ) => setActiveLibrarySlug ( library . slug ) }
>
2026-03-19 21:28:01 +01:00
{ editingLibrarySlug === library . slug ? (
< input
type = "text"
className = "rename-input"
defaultValue = { library . name }
onBlur = { ( ) => setEditingLibrarySlug ( null ) }
onKeyDown = { ( e ) => {
if ( e . key === 'Enter' ) {
handleLibraryRename ( library . slug , e . target . value )
} else if ( e . key === 'Escape' ) {
setEditingLibrarySlug ( null )
}
} }
autoFocus
/ >
) : (
< >
< span > { library . name } < / span >
< div className = "chat-item-buttons" >
{ chatLibrarySlug === library . slug && < div className = "db-active-badge" > Chat < / div > }
2026-03-19 21:37:12 +01:00
{ isLibrarySyncing ( library . slug ) && < div className = "db-active-badge" > Syncing < / div > }
2026-03-19 21:28:01 +01:00
< button className = "icon-button" onClick = { ( e ) => { e . stopPropagation ( ) ; setEditingLibrarySlug ( library . slug ) } } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = "feather feather-edit-2" > < path d = "M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" > < / path > < / svg >
< / button >
2026-03-20 10:18:12 +01:00
< button className = "icon-button" onClick = { ( e ) => { e . stopPropagation ( ) ; handleLibraryDelete ( library . slug ) } } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" className = "feather feather-x" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" > < / line > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" > < / line > < / svg >
< / button >
2026-03-19 21:28:01 +01:00
< / div >
< / >
) }
2026-03-19 21:07:22 +01:00
< / div >
) )
) }
2025-08-22 23:42:34 +02:00
< / div >
) }
{ activeSidebarMode === 'settings' && (
< div className = "settings-list" >
< div
className = { ` settings-item ${ activeSettingsSubmenu === 'General' ? 'active' : '' } ` }
onClick = { ( ) => setActiveSettingsSubmenu ( 'General' ) }
>
General
< / div >
< div
className = { ` settings-item ${ activeSettingsSubmenu === 'Interface' ? 'active' : '' } ` }
onClick = { ( ) => setActiveSettingsSubmenu ( 'Interface' ) }
>
Interface
< / div >
2025-08-27 04:27:18 +02:00
< div
className = { ` settings-item ${ activeSettingsSubmenu === 'Websearch' ? 'active' : '' } ` }
onClick = { ( ) => setActiveSettingsSubmenu ( 'Websearch' ) }
>
Websearch
< / div >
2025-08-22 23:42:34 +02:00
< / div >
) }
< / div >
{ activeSidebarMode !== 'settings' && (
< div className = "sidebar-footer" >
{ activeSidebarMode === 'chats' && (
< button className = "button new-chat-button" onClick = { createNewChat } > New Chat < / button >
) }
{ activeSidebarMode === 'dbs' && (
2026-03-19 21:07:22 +01:00
isCreatingLibrary ? (
< div className = "new-db-form" >
< input
type = "text"
className = "rename-input"
value = { newLibraryName }
onChange = { ( e ) => 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 && < div className = "form-error" > { libraryCreateError } < / div > }
< div className = "new-db-actions" >
< button className = "button new-db-button" onClick = { ( ) => createLibrary ( ) } > Create < / button >
< button
className = "button ghost"
onClick = { ( ) => {
setIsCreatingLibrary ( false )
setNewLibraryName ( '' )
setLibraryCreateError ( '' )
} }
>
Cancel
< / button >
< / div >
< / div >
) : (
< button
className = "button new-db-button"
onClick = { ( ) => {
setIsCreatingLibrary ( true )
setLibraryCreateError ( '' )
} }
>
New Database
< / button >
)
2025-08-22 23:42:34 +02:00
) }
< / div >
) }
< div className = "resizer" onMouseDown = { startResizing } > < / div >
< / div >
2026-04-16 21:31:34 +02:00
< div
className = { ` main-content ${ activeSidebarMode === 'chats' && isChatDragActive ? ' main-content--drag-active' : '' } ` }
onDragEnter = { handleChatDragEnter }
onDragOver = { handleChatDragOver }
onDragLeave = { handleChatDragLeave }
onDrop = { handleChatDrop }
>
2026-03-20 12:00:44 +01:00
{ startupTaskMessage && (
< div className = "startup-task-banner" role = "status" aria - live = "polite" >
2026-03-20 12:04:40 +01:00
{ startupTaskBusy && < div className = "spinner startup-task-banner__spinner" > < / div > }
< div className = "startup-task-banner__text" > { startupTaskMessage } < / div >
2026-03-20 12:00:44 +01:00
< / div >
) }
2025-08-22 23:42:34 +02:00
{ activeSidebarMode === 'chats' && (
< >
< div className = "header" >
< strong > Chat - { chatSessions . find ( s => s . session _id === activeSessionId ) ? . name || 'New Chat' } < / strong >
2026-03-19 21:37:12 +01:00
{ chatLibrary && (
2026-03-19 21:23:52 +01:00
< span className = "header-subtle" >
2026-03-19 21:37:12 +01:00
{ ` DB: ${ chatLibrary . name } ${ chatLibraryStatusSuffix } ` }
2026-03-19 21:23:52 +01:00
< / span >
) }
2025-08-22 23:42:34 +02:00
< / div >
2026-04-16 21:31:34 +02:00
< div
key = { activeSessionId }
className = { ` chat ${ isChatDragActive ? ' chat--drag-active' : '' } ` }
ref = { chatRef }
onClick = { handleChatFrameClick }
>
2025-08-26 04:49:54 +02:00
{ messages . map ( ( m , i ) => {
const isEditingThis = m . role === 'user' && editingMessageIndex === i ;
return (
< div
key = { m . id || i }
id = { m . id }
className = {
'msg ' +
( m . role === 'user' ? 'user' : 'assistant' ) +
( isEditingThis ? ' editing' : '' )
}
>
{ m . role === 'assistant' ? (
< div className = "assistant-message-wrapper" >
2025-08-27 04:27:18 +02:00
< AssistantMessageContent content = { m . content } streamOutput = { streamOutput } sources = { m . sources } / >
2025-08-26 04:49:54 +02:00
{ ! isSending && (
< div className = "message-options-bar assistant-options" >
< button className = "icon-button" title = "Copy message" onClick = { ( ) => handleCopyMessage ( m ) } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" 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 >
< / button >
< button className = "icon-button" title = "Regenerate response" onClick = { ( ) => regenerateFromIndex ( i ) } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3" > < / path > < / svg >
< / button >
< / div >
) }
< / div >
) : (
< div className = "user-message-wrapper" >
{ isEditingThis ? (
2026-04-16 21:32:01 +02:00
< >
< ImageAttachmentStrip attachments = { m . attachments } className = "message-attachment-strip" / >
< div className = "msg-content msg-content--user editing" >
< div className = "user-edit-shadow" aria - hidden = "true" >
{ editText }
< / div >
< TextareaAutosize
className = "edit-message-input edit-overlay"
value = { editText }
onChange = { ( e ) => 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 }
/ >
2025-08-26 04:49:54 +02:00
< / div >
2026-04-16 21:32:01 +02:00
< / >
2025-08-26 04:49:54 +02:00
) : (
2025-08-26 05:05:43 +02:00
( ( ) => {
const raw = m . content || '' ;
2026-04-16 21:32:01 +02:00
const attachments = Array . isArray ( m . attachments ) ? m . attachments : [ ] ;
2025-08-26 05:05:43 +02:00
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 ;
2026-04-16 21:32:01 +02:00
const hasText = Boolean ( raw . trim ( ) ) ;
2025-08-26 05:05:43 +02:00
return (
< >
2026-04-16 21:32:01 +02:00
< ImageAttachmentStrip attachments = { attachments } className = "message-attachment-strip" / >
{ hasText && < div className = "msg-content msg-content--user" > { displayText } < / div > }
{ hasText && needsCollapse && (
2025-08-26 05:05:43 +02:00
< button
className = "user-msg-expand"
onClick = { ( ) => toggleUserMsgCollapse ( key ) }
aria - expanded = { isCollapsed ? 'false' : 'true' }
>
{ isCollapsed ? 'Show entire message' : 'Collapse' }
< / button >
) }
< / >
) ;
} ) ( )
2025-08-26 04:49:54 +02:00
) }
{ ! isSending && ! isEditingThis && (
< div className = "message-options-bar user-options" >
< button className = "icon-button" title = "Edit message" onClick = { ( ) => startEditMessage ( i , m . content ) } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M12 20h9" > < / path > < path d = "M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" > < / path > < / svg >
< / button >
< button className = "icon-button" title = "Copy message" onClick = { ( ) => handleCopyMessage ( m ) } >
< svg xmlns = "http://www.w3.org/2000/svg" width = "16" height = "16" 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 >
< / button >
< / div >
) }
< / div >
) }
< / div >
) ;
} ) }
2025-08-22 23:42:34 +02:00
< / div >
2025-08-23 16:45:46 +02:00
{ /* New message tip (active chat only) */ }
{ newMsgTip [ activeSessionId ] && (
< button
className = "new-msg-tip"
onClick = { handleNewMsgTipClick }
title = "Jump to the new message"
aria - label = "Jump to the new message"
>
New message < span style = { { marginLeft : 6 } } > ↓ < / span >
< / button >
) }
2025-08-22 23:42:34 +02:00
< div className = "footer" >
2026-04-16 21:32:22 +02:00
< div className = { ` footer-inner ${ isChatDragActive ? ' footer-inner--drag-active' : '' } ` } >
< input
ref = { imageInputRef }
type = "file"
accept = "image/*"
multiple
className = "composer-image-input"
onChange = { handleComposerImageSelection }
tabIndex = { - 1 }
/ >
< ImageAttachmentStrip
attachments = { composerAttachments }
className = "composer-attachment-strip"
removable
onRemove = { removeComposerAttachment }
/ >
2026-04-16 22:05:07 +02:00
{ ( isRecordingAudio || isTranscribingAudio ) && (
< div
className = {
'composer-audio-status' +
( isRecordingAudio ? ' composer-audio-status--recording' : ' composer-audio-status--transcribing' )
}
role = "status"
aria - live = "polite"
>
{ isRecordingAudio ? (
< span className = "composer-audio-status__dot" aria - hidden = "true" > < / span >
) : (
< div className = "spinner composer-audio-status__spinner" aria - hidden = "true" > < / div >
) }
< span >
{ isRecordingAudio
? ` Listening ${ formatRecordingDuration ( audioRecordingMs ) } `
: 'Transcribing audio…' }
< / span >
< / div >
) }
2026-04-16 21:32:22 +02:00
< div className = "footer-content-wrapper" >
2025-08-22 23:42:34 +02:00
< TextareaAutosize
2025-08-25 21:13:09 +02:00
ref = { textareaRef }
2025-08-22 23:42:34 +02:00
className = "input"
value = { input }
onChange = { e => setInput ( e . target . value ) }
onKeyDown = { e => {
2026-04-16 22:05:07 +02:00
if ( e . key === 'Enter' && ! e . shiftKey && ! isRecordingAudio && ! isTranscribingAudio ) {
2025-08-22 23:42:34 +02:00
e . preventDefault ( ) ;
sendMessage ( ) ;
}
} }
placeholder = "Ask any question..."
maxRows = { 13 }
/ >
2026-03-19 21:37:12 +01:00
< div className = "footer-tool-group" ref = { dbPickerRef } >
< button
type = "button"
className = { "db-picker-toggle" + ( chatLibrary ? " active" : "" ) }
onClick = { ( ) => {
if ( ! activeSessionId ) return
setIsDbPickerOpen ( prev => ! prev )
} }
title = { chatLibrary ? ` Database: ${ chatLibrary . name } ${ chatLibraryStatusSuffix } ` : 'Select database for this chat' }
aria - haspopup = "menu"
aria - expanded = { isDbPickerOpen }
disabled = { ! activeSessionId }
>
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none"
stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round"
aria - hidden = "true" >
< ellipse cx = "12" cy = "5" rx = "8" ry = "3" / >
< path d = "M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" / >
< path d = "M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" / >
< / svg >
< / button >
{ isDbPickerOpen && (
< div className = "db-picker-menu" role = "menu" >
< button
type = "button"
className = { "db-picker-option" + ( ! chatLibrarySlug ? " selected" : "" ) }
onClick = { ( ) => {
setChatLibraryForSession ( activeSessionId , null )
setIsDbPickerOpen ( false )
} }
>
< span > No database < / span >
{ ! chatLibrarySlug && < span className = "db-picker-status" > Selected < / span > }
< / button >
{ libraries . length === 0 ? (
< div className = "db-picker-empty" > No databases yet . < / div >
) : (
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 (
< button
key = { library . slug }
type = "button"
className = { "db-picker-option" + ( selected ? " selected" : "" ) }
disabled = { ! library . files ? . length }
onClick = { ( ) => {
setChatLibraryForSession ( activeSessionId , library . slug )
setIsDbPickerOpen ( false )
} }
>
< span > { library . name } < / span >
< span className = "db-picker-status" > { selected ? 'Selected' : status } < / span >
< / button >
)
} )
) }
< / div >
) }
< / div >
2026-04-16 21:32:22 +02:00
{ selectedModelSupportsVision && (
< button
type = "button"
className = { "image-attach-toggle" + ( composerAttachments . length > 0 ? " active" : "" ) }
onClick = { openImagePicker }
title = "Attach images"
aria - label = "Attach images"
>
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none"
stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round"
aria - hidden = "true" >
< rect x = "3" y = "3" width = "18" height = "18" rx = "2" ry = "2" / >
< circle cx = "8.5" cy = "8.5" r = "1.5" / >
< path d = "M21 15l-5-5L5 21" / >
< / svg >
< / button >
) }
2026-04-16 22:05:07 +02:00
{ audioInputEnabled && (
< button
type = "button"
className = {
'audio-input-toggle' +
( isRecordingAudio || isTranscribingAudio ? ' active' : '' ) +
( isRecordingAudio ? ' recording' : '' ) +
( isTranscribingAudio ? ' transcribing' : '' )
}
onClick = { toggleAudioRecording }
title = {
2026-04-17 04:48:55 +02:00
! audioInputRuntimeReady
? ( audioInputRuntimeMessage || 'Whisper is not available for audio input.' )
: isRecordingAudio
2026-04-16 22:05:07 +02:00
? 'Stop voice input'
: ( isTranscribingAudio ? 'Transcribing audio' : 'Start voice input' )
}
aria - label = {
2026-04-17 04:48:55 +02:00
! audioInputRuntimeReady
? ( audioInputRuntimeMessage || 'Whisper is not available for audio input.' )
: isRecordingAudio
2026-04-16 22:05:07 +02:00
? 'Stop voice input'
: ( isTranscribingAudio ? 'Transcribing audio' : 'Start voice input' )
}
aria - pressed = { isRecordingAudio }
2026-04-17 04:48:55 +02:00
disabled = { ! audioInputRuntimeReady || isTranscribingAudio || isSending }
2026-04-16 22:05:07 +02:00
>
{ isTranscribingAudio ? (
< div className = "spinner composer-audio-icon-spinner" aria - hidden = "true" > < / div >
) : (
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none"
stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round"
aria - hidden = "true" >
< path d = "M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3z" / >
< path d = "M19 10a7 7 0 0 1-14 0" / >
< line x1 = "12" y1 = "19" x2 = "12" y2 = "22" / >
< line x1 = "8" y1 = "22" x2 = "16" y2 = "22" / >
< / svg >
) }
< / button >
) }
2025-08-29 13:36:19 +02:00
< button
type = "button"
className = { "websearch-toggle" + ( webSearchEnabled ? " active" : "" ) }
onClick = { toggleWebSearch }
title = "Toggle web search"
aria - pressed = { webSearchEnabled }
>
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none"
stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round"
aria - hidden = "true" >
< circle cx = "12" cy = "12" r = "10" / >
< line x1 = "2" y1 = "12" x2 = "22" y2 = "12" / >
< path d = "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" / >
< / svg >
< / button >
2026-03-19 21:07:22 +01:00
< button
className = "button"
onClick = { isSending ? cancelActiveRequest : sendMessage }
title = { isSending ? 'Cancel generation' : 'Send' }
aria - label = { isSending ? 'Cancel generation' : 'Send' }
2026-04-16 22:05:07 +02:00
disabled = { ! isSending && ( isRecordingAudio || isTranscribingAudio ) }
2026-03-19 21:07:22 +01:00
>
2025-08-23 16:45:46 +02:00
{ isSending ? < div className = "spinner" > < / div > : 'Send' }
< / button >
2026-04-16 21:32:22 +02:00
< / div >
2025-08-22 23:42:34 +02:00
< / div >
< / div >
< / >
) }
{ activeSidebarMode === 'dbs' && (
2026-03-19 21:07:22 +01:00
< >
< div className = "header" >
< strong > { activeLibrary ? . name || 'Databases' } < / strong >
2026-03-19 21:37:12 +01:00
{ chatLibrary && (
2026-03-19 21:23:52 +01:00
< span className = "header-subtle" >
2026-03-19 21:37:12 +01:00
{ ` Current chat DB: ${ chatLibrary . name } ${ chatLibraryStatusSuffix } ` }
2026-03-19 21:23:52 +01:00
< / span >
) }
2026-03-19 21:07:22 +01:00
< / div >
< LibraryManager
2026-03-20 08:16:41 +01:00
apiBase = { backendApiUrl }
2026-03-19 21:07:22 +01:00
library = { activeLibrary }
jobs = { libraryJobs }
onRefresh = { async ( ) => {
await refreshLibraries ( ) ;
await refreshLibraryJobs ( ) ;
} }
/ >
< / >
2025-08-22 23:42:34 +02:00
) }
{ activeSidebarMode === 'settings' && (
< >
< div className = "header" >
< strong > { activeSettingsSubmenu } Settings < / strong >
< / div >
2025-08-23 16:45:46 +02:00
{ activeSettingsSubmenu === 'General' && (
< GeneralSettings
onModelChange = { setModel }
2026-04-16 21:32:22 +02:00
onBackendApiUrlChange = { setBackendApiUrl }
2025-08-23 16:45:46 +02:00
streamOutput = { streamOutput }
onStreamOutputChange = { setStreamOutput }
2026-04-16 22:05:14 +02:00
onAudioInputEnabledChange = { setAudioInputEnabled }
onAudioInputDeviceChange = { setAudioInputDeviceId }
2026-04-17 04:43:28 +02:00
onAudioInputLanguageChange = { setAudioInputLanguage }
2026-03-20 10:33:58 +01:00
onLibrariesPurged = { handleLibrariesPurged }
2025-08-23 16:45:46 +02:00
/ >
) }
2025-08-22 23:42:34 +02:00
{ activeSettingsSubmenu === 'Interface' && < InterfaceSettings / > }
2025-08-27 04:27:18 +02:00
{ activeSettingsSubmenu === 'Websearch' && (
< WebsearchSettings
searxUrl = { searxUrl }
setSearxUrl = { setSearxUrl }
engines = { searxEngines }
2026-03-20 12:55:56 +01:00
setEngines = { ( next ) => setSearxEngines ( normalizeWebsearchEngines ( next ) ) }
2025-08-27 04:27:18 +02:00
/ >
) }
2025-08-22 23:42:34 +02:00
< / >
) }
< / div >
< / div >
)
}