2025-08-23 16:45:46 +02:00
// /Users/giers/Heimgeist/src/App.jsx
2026-05-06 03:45:14 +02:00
import React , { useEffect , useMemo , useRef , useState } from 'react'
2025-08-22 23:42:34 +02:00
import TextareaAutosize from 'react-textarea-autosize' ;
2026-05-06 03:25:39 +02:00
import AssistantMessageContent from './AssistantMessageContent'
2026-05-06 03:39:28 +02:00
import ChatDatabasePicker from './ChatDatabasePicker'
2026-03-19 21:07:22 +01:00
import LibraryManager from './LibraryManager'
2026-06-15 03:53:10 +02:00
import SaveMessageToKnowledgeDialog from './SaveMessageToKnowledgeDialog'
2026-05-06 03:38:04 +02:00
import { SettingsPanel , SettingsSidebar } from './SettingsPanels'
2026-03-19 21:07:22 +01:00
import { applyColorScheme } from './colorSchemes'
2026-05-06 03:25:39 +02:00
import {
AttachmentStrip ,
CHAT _FILE _PICKER _FILTERS ,
attachmentIsImage ,
buildComposerFileAttachment ,
getFileName ,
guessMimeTypeFromName ,
hasFilePayload ,
isImageFile ,
isSupportedChatFile ,
isSupportedChatFilePath ,
readFileAsDataUrl ,
} from './attachments'
import {
DEFAULT _BACKEND _API _URL ,
MAX _IMAGE _ATTACHMENT _BYTES ,
MAX _IMAGE _ATTACHMENTS ,
WEBSEARCH _ENGINES _KEY ,
WEBSEARCH _URL _KEY ,
migrateLegacySearxUrl ,
resolveBackendApiUrl ,
} from './appConfig'
2026-05-06 03:30:40 +02:00
import {
expectBackendJson ,
getErrorText ,
isAbortError ,
} from './backendApi'
2026-05-06 03:25:39 +02:00
import { sanitizeChatTitle , splitThinkBlocks } from './chatText'
import { buildModelPickerOptions } from './modelPicker'
2026-05-06 03:47:52 +02:00
import {
fetchModelCapabilities ,
fetchStartupOllamaStatus ,
prepareStartupModels ,
} from './chatApi'
2026-05-06 03:51:17 +02:00
import { createChatGenerationHandlers } from './chatGeneration'
2026-03-20 12:55:56 +01:00
import {
loadStoredWebsearchEngines ,
normalizeWebsearchEngines ,
} from './websearchEngines'
2026-05-06 03:42:03 +02:00
import { formatRecordingDuration , useAudioInput } from './useAudioInput'
2026-05-06 03:39:28 +02:00
import { useChatLibrarySelection } from './useChatLibrarySelection'
2026-06-15 15:18:00 +02:00
import { useChatWorkflowSelection } from './useChatWorkflowSelection'
2026-05-06 03:45:14 +02:00
import { useChatScroll } from './useChatScroll'
2026-05-06 04:38:57 +02:00
import desktopApi from './desktop/desktopApi'
2026-06-15 15:18:00 +02:00
import WorkflowsArea from './workflows/WorkflowsArea'
import WorkflowConfirmationDialog from './workflows/WorkflowConfirmationDialog'
import WorkflowExecutionPanel from './workflows/WorkflowExecutionPanel'
2026-06-15 16:53:19 +02:00
import WorkflowList from './workflows/WorkflowList'
2026-06-15 15:18:00 +02:00
import WorkflowSelector from './workflows/WorkflowSelector'
2026-06-15 16:53:19 +02:00
import {
cancelWorkflowRun ,
createWorkflow ,
deleteWorkflow ,
duplicateWorkflow ,
fetchTools ,
fetchWorkflow ,
fetchWorkflows ,
respondToConfirmation ,
} from './workflows/workflowApi'
const DEFAULT _WORKFLOW _GRAPH = {
schema _version : 1 ,
inputs : { prompt : { type : 'string' } , session _id : { type : [ 'string' , 'null' ] } } ,
outputs : { content : { type : 'string' } } ,
nodes : [
{ id : 'input' , type : 'input' , position : { x : 80 , y : 120 } , config : { } } ,
{ id : 'output' , type : 'output' , position : { x : 440 , y : 120 } , config : { value : { content : { $ref : 'input.prompt' } , sources : [ ] , usage : { } } } } ,
] ,
edges : [ { id : 'input-output' , source : 'input' , source _handle : 'output' , target : 'output' , target _handle : 'input' } ] ,
limits : { maximum _nodes : 40 , maximum _tool _calls : 20 , maximum _llm _calls : 8 , maximum _runtime _seconds : 300 , maximum _result _chars : 120000 , maximum _concurrency : 4 } ,
}
2026-03-20 16:25:48 +01:00
2025-08-22 23:42:34 +02:00
export default function App ( ) {
const [ chatSessions , setChatSessions ] = useState ( [ ] )
const [ activeSessionId , setActiveSessionId ] = useState ( null )
2026-06-15 15:18:00 +02:00
const [ activeSidebarMode , setActiveSidebarMode ] = useState ( 'chats' ) // 'chats', 'dbs', 'workflows', 'settings'
2026-04-17 08:01:23 +02:00
const activeSidebarModeRef = useRef ( activeSidebarMode )
2026-04-17 09:06:15 +02:00
const [ activeSettingsSubmenu , setActiveSettingsSubmenu ] = useState ( 'General' ) ;
2025-08-22 23:42:34 +02:00
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 )
const [ isCreatingLibrary , setIsCreatingLibrary ] = useState ( false )
const [ newLibraryName , setNewLibraryName ] = useState ( '' )
const [ libraryCreateError , setLibraryCreateError ] = useState ( '' )
2026-06-15 15:18:23 +02:00
const [ workflows , setWorkflows ] = useState ( [ ] )
2026-06-15 16:53:19 +02:00
const [ workflowTools , setWorkflowTools ] = useState ( [ ] )
const [ activeWorkflowId , setActiveWorkflowId ] = useState ( null )
const [ activeWorkflow , setActiveWorkflow ] = useState ( null )
const [ workflowError , setWorkflowError ] = useState ( '' )
2026-05-06 03:39:50 +02:00
const {
chatLibrary ,
chatLibrarySlug ,
chatLibraryStatusSuffix ,
clearChatLibrarySelections ,
getChatLibraryForSession ,
isLibrarySyncing ,
removeLibraryFromChatSelections ,
setChatLibraryForSession ,
} = useChatLibrarySelection ( { activeSessionId , libraries , libraryJobs } )
2026-06-15 15:18:08 +02:00
const {
getSelectionForSession : getChatWorkflowForSession ,
selectedWorkflow ,
selection : chatWorkflowSelection ,
setSelectionForSession : setChatWorkflowForSession ,
} = useChatWorkflowSelection ( { activeSessionId , workflows } )
2026-04-17 10:47:46 +02:00
const [ isChatModelPickerOpen , setIsChatModelPickerOpen ] = useState ( false )
const [ availableChatModels , setAvailableChatModels ] = useState ( [ ] )
const [ availableVisionModels , setAvailableVisionModels ] = useState ( [ ] )
const [ isLoadingModelCatalog , setIsLoadingModelCatalog ] = useState ( false )
2025-08-22 23:42:34 +02:00
// Use currentSessionId for the actual chat operations
const [ model , setModel ] = useState ( '' )
2026-04-17 08:38:07 +02:00
const [ visionModel , setVisionModel ] = useState ( '' )
const [ transcriptionModel , setTranscriptionModel ] = useState ( 'base' )
2026-06-15 15:18:00 +02:00
const [ workflowRouterModel , setWorkflowRouterModel ] = useState ( '' )
2026-04-17 13:01:07 +02:00
const [ selectedChatModelSupportsVision , setSelectedChatModelSupportsVision ] = useState ( false )
2026-04-17 08:38:07 +02:00
const [ selectedVisionModelSupportsVision , setSelectedVisionModelSupportsVision ] = 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 ( [ ] )
2026-04-17 13:01:07 +02:00
const [ isAttachmentMenuOpen , setIsAttachmentMenuOpen ] = useState ( false )
2026-04-16 21:29:32 +02:00
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-04-17 10:50:12 +02:00
const modelRef = useRef ( model )
2026-04-17 10:47:46 +02:00
const chatModelPickerRef = useRef ( null )
2026-04-17 13:01:07 +02:00
const attachmentMenuRef = useRef ( null )
2026-04-16 21:29:32 +02:00
const imageInputRef = useRef ( null )
const imageDragDepthRef = useRef ( 0 )
2026-04-17 08:59:25 +02:00
const [ audioInputEnabled , setAudioInputEnabled ] = useState ( true )
2026-04-16 22:03:45 +02:00
const [ audioInputDeviceId , setAudioInputDeviceId ] = useState ( '' )
2026-04-17 04:43:28 +02:00
const [ audioInputLanguage , setAudioInputLanguage ] = useState ( '' )
2026-05-06 03:32:06 +02:00
const [ backendApiUrl , setBackendApiUrl ] = useState ( DEFAULT _BACKEND _API _URL ) ; // 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 ) ;
2026-06-15 15:18:00 +02:00
const [ workflowExecutions , setWorkflowExecutions ] = useState ( { } )
const [ pendingWorkflowConfirmation , setPendingWorkflowConfirmation ] = useState ( null )
2026-05-06 03:42:20 +02:00
const {
audioInputRuntimeMessage ,
audioInputRuntimeReady ,
audioRecordingMs ,
isRecordingAudio ,
isTranscribingAudio ,
setAudioInputRuntimeMessage ,
setAudioInputRuntimeReady ,
syncAudioInputRuntimeFromStartupStatus ,
toggleAudioRecording ,
} = useAudioInput ( {
activeSidebarMode ,
audioInputDeviceId ,
audioInputEnabled ,
audioInputLanguage ,
backendApiUrl ,
isSending ,
setInput ,
textareaRef ,
transcriptionModel ,
} )
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
2026-03-20 12:00:44 +01:00
const [ settingsLoaded , setSettingsLoaded ] = useState ( false ) ;
2026-04-17 13:01:07 +02:00
const canAttachImages = selectedChatModelSupportsVision || selectedVisionModelSupportsVision
const imageAttachmentUnavailableReason = 'Image attachments require a vision-capable chat model or configured vision model.'
2026-03-20 12:00:44 +01:00
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 ( '' ) ;
2026-06-15 03:53:10 +02:00
const [ knowledgeSaveTarget , setKnowledgeSaveTarget ] = useState ( null )
const [ knowledgeSaveToast , setKnowledgeSaveToast ] = useState ( '' )
const knowledgeJumpTargetRef = useRef ( null )
2025-08-25 23:56:26 +02:00
// 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-04-17 10:48:02 +02:00
async function syncVisionModelFromChatModel ( nextModel , options = { } ) {
const { allowCapabilityLookup = true } = options
if ( ! nextModel ) {
return false
}
if ( availableVisionModels . includes ( nextModel ) ) {
setVisionModel ( nextModel )
2026-05-06 04:39:03 +02:00
desktopApi . setSetting ( 'visionModel' , nextModel )
2026-04-17 10:48:02 +02:00
return true
}
if ( ! allowCapabilityLookup || ! backendApiUrl ) {
return false
}
try {
2026-05-06 03:48:15 +02:00
const data = await fetchModelCapabilities ( backendApiUrl , nextModel )
2026-04-17 10:50:12 +02:00
if ( ! data ? . supports _vision || modelRef . current !== nextModel ) {
2026-04-17 10:48:02 +02:00
return false
}
setVisionModel ( nextModel )
2026-05-06 04:39:03 +02:00
desktopApi . setSetting ( 'visionModel' , nextModel )
2026-04-17 10:48:02 +02:00
return true
} catch ( error ) {
if ( ! isAbortError ( error ) ) {
console . warn ( 'Failed to check chat model vision capabilities' , error )
}
return false
}
}
async function handleChatModelSelect ( nextModel ) {
if ( ! nextModel || nextModel === model ) {
setIsChatModelPickerOpen ( false )
return
}
setIsChatModelPickerOpen ( false )
setModel ( nextModel )
2026-05-06 04:39:03 +02:00
desktopApi . setSetting ( 'chatModel' , nextModel )
2026-04-17 10:48:02 +02:00
await syncVisionModelFromChatModel ( nextModel )
}
2026-04-17 13:01:27 +02:00
function appendComposerFileAttachments ( attachments ) {
if ( ! Array . isArray ( attachments ) || attachments . length === 0 ) {
return
}
setComposerAttachments ( prev => [ ... prev , ... attachments ] )
}
async function appendComposerFilePaths ( paths ) {
const nextAttachments = [ ]
const rejected = [ ]
for ( const rawPath of Array . from ( paths || [ ] ) ) {
const sourcePath = String ( rawPath || '' ) . trim ( )
const label = getFileName ( sourcePath , 'file' )
if ( ! sourcePath ) {
rejected . push ( 'One selected file had no usable local path.' )
continue
}
if ( ! isSupportedChatFilePath ( sourcePath ) ) {
rejected . push ( ` ${ label } : unsupported file type for chat attachments. ` )
continue
}
nextAttachments . push ( buildComposerFileAttachment ( {
sourcePath ,
name : getFileName ( sourcePath , 'file' ) ,
mimeType : guessMimeTypeFromName ( sourcePath ) ,
} ) )
}
if ( nextAttachments . length > 0 ) {
appendComposerFileAttachments ( nextAttachments )
}
if ( rejected . length > 0 ) {
window . alert ( rejected . join ( '\n' ) )
}
}
2026-04-16 21:29:49 +02:00
async function appendComposerImageFiles ( fileList ) {
const incoming = Array . from ( fileList || [ ] ) . filter ( isImageFile )
if ( ! incoming . length ) {
return
}
2026-04-17 13:01:27 +02:00
if ( ! canAttachImages ) {
window . alert ( imageAttachmentUnavailableReason )
2026-04-16 21:29:49 +02:00
return
}
2026-04-17 13:01:27 +02:00
const currentImageCount = composerAttachments . filter ( attachmentIsImage ) . length
const remainingSlots = Math . max ( 0 , MAX _IMAGE _ATTACHMENTS - currentImageCount )
2026-04-16 21:29:49 +02:00
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 ( ) {
2026-04-17 13:01:27 +02:00
if ( ! canAttachImages ) {
2026-04-16 21:29:49 +02:00
return
}
imageInputRef . current ? . click ( )
}
2026-04-17 13:01:27 +02:00
async function openFilePicker ( ) {
try {
2026-05-06 04:39:09 +02:00
const pickedPaths = await desktopApi . pickPaths ( {
2026-04-17 13:01:27 +02:00
title : 'Select files for chat' ,
filters : CHAT _FILE _PICKER _FILTERS ,
} )
await appendComposerFilePaths ( pickedPaths )
} catch ( error ) {
console . error ( 'Failed to open file picker' , error )
window . alert ( ` File selection failed: ${ getErrorText ( error ) } ` )
}
}
async function appendDroppedChatFiles ( fileList ) {
const incoming = Array . from ( fileList || [ ] )
if ( ! incoming . length ) {
return
}
const imageFiles = [ ]
const fileAttachments = [ ]
const rejected = [ ]
for ( const file of incoming ) {
if ( isImageFile ( file ) ) {
if ( ! canAttachImages ) {
rejected . push ( ` ${ file . name || 'image' } : ${ imageAttachmentUnavailableReason } ` )
continue
}
imageFiles . push ( file )
continue
}
if ( ! isSupportedChatFile ( file ) ) {
rejected . push ( ` ${ file ? . name || 'file' } : unsupported file type for chat attachments. ` )
continue
}
const sourcePath = String ( file ? . path || '' ) . trim ( )
if ( ! sourcePath ) {
rejected . push ( ` ${ file . name || 'file' } : local file paths are required for drag and drop in the desktop app. ` )
continue
}
fileAttachments . push ( buildComposerFileAttachment ( {
sourcePath ,
name : file . name || getFileName ( sourcePath , 'file' ) ,
mimeType : file . type || guessMimeTypeFromName ( file . name || sourcePath ) ,
size : file . size ,
} ) )
}
if ( imageFiles . length > 0 ) {
await appendComposerImageFiles ( imageFiles )
}
if ( fileAttachments . length > 0 ) {
appendComposerFileAttachments ( fileAttachments )
}
if ( rejected . length > 0 ) {
window . alert ( rejected . join ( '\n' ) )
}
}
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-23 16:45:46 +02:00
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 ) } ) ) ;
}
2026-03-19 21:07:22 +01:00
const activeRequestRef = useRef ( null ) ;
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 )
} , [ ] )
2026-06-15 15:18:51 +02:00
const attachWorkflowRunToRequest = React . useCallback ( ( controller , runId ) => {
if ( activeRequestRef . current ? . controller !== controller ) return
activeRequestRef . current . runId = runId
} , [ ] )
2026-03-19 21:07:22 +01:00
const cancelActiveRequest = React . useCallback ( ( ) => {
const activeRequest = activeRequestRef . current
if ( ! activeRequest ) return
activeRequestRef . current = null
2026-06-15 15:18:51 +02:00
if ( activeRequest . runId ) {
cancelWorkflowRun ( backendApiUrl , activeRequest . runId ) . catch ( error => {
console . warn ( 'Workflow cancellation failed' , error )
} )
}
2026-03-19 21:07:22 +01:00
activeRequest . controller . abort ( )
setIsSending ( false )
2026-06-15 15:18:51 +02:00
} , [ backendApiUrl ] )
2026-03-19 21:07:22 +01:00
useEffect ( ( ) => {
return ( ) => {
activeRequestRef . current ? . controller . abort ( )
}
} , [ ] )
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
2026-05-06 04:39:09 +02:00
desktopApi . 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
2026-04-17 08:38:07 +02:00
setVisionModel ( settings . visionModel || settings . chatModel || '' ) ;
setTranscriptionModel ( settings . transcriptionModel || 'base' ) ;
2026-06-15 15:18:34 +02:00
setWorkflowRouterModel ( settings . workflowRouterModel || '' ) ;
2025-08-23 16:45:46 +02:00
setStreamOutput ( settings . streamOutput || false ) ;
2026-04-17 08:59:25 +02:00
setAudioInputEnabled ( true ) ;
if ( settings . audioInputEnabled !== true ) {
2026-05-06 04:39:09 +02:00
desktopApi . setSetting ( 'audioInputEnabled' , true )
2026-04-17 08:59:25 +02:00
}
2026-04-16 22:04:46 +02:00
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
} ;
} , [ ] ) ;
2026-06-15 16:53:47 +02:00
async function refreshWorkflows ( preferredId = null ) {
if ( ! backendApiUrl ) return
try {
const [ workflowData , toolData ] = await Promise . all ( [
fetchWorkflows ( backendApiUrl ) ,
fetchTools ( backendApiUrl ) ,
] )
const nextWorkflows = Array . isArray ( workflowData . workflows ) ? workflowData . workflows : [ ]
const nextTools = Array . isArray ( toolData . tools ) ? toolData . tools : [ ]
const fallbackId = activeWorkflowId && nextWorkflows . some ( workflow => workflow . id === activeWorkflowId )
? activeWorkflowId
: nextWorkflows [ 0 ] ? . id || null
const nextId = preferredId && nextWorkflows . some ( workflow => workflow . id === preferredId )
? preferredId
: fallbackId
setWorkflows ( nextWorkflows )
setWorkflowTools ( nextTools )
setActiveWorkflowId ( nextId )
setActiveWorkflow ( nextId ? await fetchWorkflow ( backendApiUrl , nextId ) : null )
setWorkflowError ( '' )
} catch ( error ) {
console . warn ( 'Failed to load workflows' , error )
setWorkflowError ( String ( error ? . message || error ) )
}
}
2026-06-15 15:18:34 +02:00
useEffect ( ( ) => {
2026-06-15 16:53:47 +02:00
if ( ! backendApiUrl ) {
setWorkflows ( [ ] )
setWorkflowTools ( [ ] )
setActiveWorkflowId ( null )
setActiveWorkflow ( null )
return
}
refreshWorkflows ( )
2026-06-15 15:18:34 +02:00
} , [ backendApiUrl ] )
2026-04-17 10:50:12 +02:00
useEffect ( ( ) => {
modelRef . current = model
} , [ model ] )
2026-04-17 10:48:06 +02:00
useEffect ( ( ) => {
let cancelled = false
const controller = new AbortController ( )
if ( ! backendApiUrl ) {
setAvailableChatModels ( [ ] )
setAvailableVisionModels ( [ ] )
setIsLoadingModelCatalog ( false )
return ( ) => {
controller . abort ( )
}
}
setIsLoadingModelCatalog ( true )
; ( async ( ) => {
try {
const response = await fetch ( ` ${ backendApiUrl } /models ` , { signal : controller . signal } )
const data = await expectBackendJson ( response )
if ( cancelled ) {
return
}
setAvailableChatModels ( Array . isArray ( data ? . chat _models ) ? data . chat _models . filter ( Boolean ) : [ ] )
setAvailableVisionModels ( Array . isArray ( data ? . vision _models ) ? data . vision _models . filter ( Boolean ) : [ ] )
} catch ( error ) {
if ( ! cancelled && ! isAbortError ( error ) ) {
console . warn ( 'Failed to load chat model catalog' , error )
}
} finally {
if ( ! cancelled ) {
setIsLoadingModelCatalog ( false )
}
}
} ) ( )
return ( ) => {
cancelled = true
controller . abort ( )
}
} , [ backendApiUrl ] )
2026-04-17 08:01:23 +02:00
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 ( ) ;
}
} ;
2026-05-06 04:39:39 +02:00
desktopApi . onWindowFocus ( handleFocus ) ;
2025-08-25 21:13:09 +02:00
return ( ) => {
2026-05-06 04:39:39 +02:00
desktopApi . 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-17 13:01:45 +02:00
useEffect ( ( ) => {
let cancelled = false
const controller = new AbortController ( )
if ( ! backendApiUrl || ! model ) {
setSelectedChatModelSupportsVision ( false )
return ( ) => {
controller . abort ( )
}
}
; ( async ( ) => {
try {
2026-05-06 03:48:15 +02:00
const data = await fetchModelCapabilities ( backendApiUrl , model , controller . signal )
2026-04-17 13:01:45 +02:00
if ( ! cancelled ) {
setSelectedChatModelSupportsVision ( Boolean ( data ? . supports _vision ) )
}
} catch ( error ) {
if ( ! cancelled && ! isAbortError ( error ) ) {
console . warn ( 'Failed to load chat model capabilities' , error )
setSelectedChatModelSupportsVision ( false )
}
}
} ) ( )
return ( ) => {
cancelled = true
controller . abort ( )
}
} , [ backendApiUrl , model ] )
2026-04-16 21:30:33 +02:00
useEffect ( ( ) => {
let cancelled = false
const controller = new AbortController ( )
2026-04-17 08:38:07 +02:00
if ( ! backendApiUrl || ! visionModel ) {
setSelectedVisionModelSupportsVision ( false )
2026-04-16 21:30:33 +02:00
return ( ) => {
controller . abort ( )
}
}
; ( async ( ) => {
try {
2026-05-06 03:48:15 +02:00
const data = await fetchModelCapabilities ( backendApiUrl , visionModel , controller . signal )
2026-04-16 21:30:33 +02:00
if ( ! cancelled ) {
2026-04-17 08:38:07 +02:00
setSelectedVisionModelSupportsVision ( Boolean ( data ? . supports _vision ) )
2026-04-16 21:30:33 +02:00
}
} catch ( error ) {
if ( ! cancelled && ! isAbortError ( error ) ) {
console . warn ( 'Failed to load model capabilities' , error )
2026-04-17 08:38:07 +02:00
setSelectedVisionModelSupportsVision ( false )
2026-04-16 21:30:33 +02:00
}
}
} ) ( )
return ( ) => {
cancelled = true
controller . abort ( )
}
2026-04-17 08:38:07 +02:00
} , [ backendApiUrl , visionModel ] )
2026-04-16 21:30:33 +02:00
useEffect ( ( ) => {
imageDragDepthRef . current = 0
setIsChatDragActive ( false )
2026-04-17 13:01:45 +02:00
} , [ canAttachImages , activeSidebarMode ] )
2026-04-16 21:30:33 +02:00
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 {
2026-05-06 03:48:15 +02:00
let status = await fetchStartupOllamaStatus ( backendApiUrl )
2026-03-20 12:00:44 +01:00
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-05-06 03:48:15 +02:00
const prepared = await prepareStartupModels ( backendApiUrl )
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 ] ) ;
2026-06-15 03:53:10 +02:00
const fetchHistory = async ( sessionId ) => {
if ( ! sessionId || ! backendApiUrl ) return null ;
try {
const response = await fetch ( ` ${ backendApiUrl } /history?session_id= ${ encodeURIComponent ( sessionId ) } ` )
const data = await response . json ( )
const historyMessages = ( data . messages || [ ] ) . map ( message => ( {
... message ,
id : message . message _id || message . id ,
} ) )
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
session . session _id === sessionId
? { ... session , messages : historyMessages }
: session
)
) ;
return historyMessages
} catch {
return null
}
2025-08-22 23:42:34 +02:00
} ;
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 )
2026-05-06 03:40:34 +02:00
clearChatLibrarySelections ( )
2026-03-20 10:33:35 +01:00
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 => {
2026-04-17 10:33:24 +02:00
const sessionsWithMessages = data . sessions . map ( s => ( {
... s ,
name : sanitizeChatTitle ( s . name ) ,
messages : [ ] ,
} ) ) ;
2025-08-22 23:42:34 +02:00
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 ( ( ) => {
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
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 ) ;
} ;
2026-06-15 16:53:57 +02:00
async function handleSelectWorkflow ( workflowId ) {
if ( ! backendApiUrl || ! workflowId ) return
setActiveWorkflowId ( workflowId )
try {
setActiveWorkflow ( await fetchWorkflow ( backendApiUrl , workflowId ) )
setWorkflowError ( '' )
} catch ( error ) {
console . warn ( 'Failed to load workflow' , error )
setWorkflowError ( String ( error ? . message || error ) )
}
}
async function handleCreateWorkflow ( ) {
if ( ! backendApiUrl ) return
try {
const created = await createWorkflow ( backendApiUrl , { name : 'Untitled Workflow' , graph : DEFAULT _WORKFLOW _GRAPH } )
await refreshWorkflows ( created . id )
} catch ( error ) {
console . warn ( 'Failed to create workflow' , error )
setWorkflowError ( String ( error ? . message || error ) )
}
}
async function handleDuplicateWorkflow ( workflowId ) {
if ( ! backendApiUrl || ! workflowId ) return
try {
const created = await duplicateWorkflow ( backendApiUrl , workflowId )
await refreshWorkflows ( created . id )
} catch ( error ) {
console . warn ( 'Failed to duplicate workflow' , error )
setWorkflowError ( String ( error ? . message || error ) )
}
}
async function handleDeleteWorkflow ( workflowId ) {
if ( ! backendApiUrl || ! workflowId ) return
if ( ! window . confirm ( 'Delete this workflow and its revisions?' ) ) return
try {
await deleteWorkflow ( backendApiUrl , workflowId )
await refreshWorkflows ( activeWorkflowId === workflowId ? null : activeWorkflowId )
} catch ( error ) {
console . warn ( 'Failed to delete workflow' , error )
setWorkflowError ( String ( error ? . message || error ) )
}
}
2025-08-22 23:42:34 +02:00
const messages = useMemo ( ( ) => {
return chatSessions . find ( s => s . session _id === activeSessionId ) ? . messages || [ ] ;
} , [ activeSessionId , chatSessions ] ) ;
2026-05-06 03:45:39 +02:00
const {
activeSessionIdRef ,
handleNewMsgTipClick ,
newMsgTip ,
restoredForRef ,
scrollMessageToTop ,
scrollPendingMessageForSession ,
scrollToBottom ,
setNewMsgTip ,
setPendingScrollToLastUser ,
setScrollPositions ,
setUserScrolledUp ,
userScrolledUpRef ,
} = useChatScroll ( {
activeSessionId ,
activeSidebarMode ,
chatRef ,
messagesLength : messages . length ,
} )
2026-04-17 10:48:22 +02:00
const activeChatSession = useMemo ( ( ) => {
return chatSessions . find ( session => session . session _id === activeSessionId ) || null
} , [ activeSessionId , chatSessions ] )
2026-03-19 21:07:22 +01:00
const activeLibrary = useMemo ( ( ) => {
return libraries . find ( lib => lib . slug === activeLibrarySlug ) || null ;
} , [ activeLibrarySlug , libraries ] ) ;
2026-06-15 03:53:10 +02:00
function showKnowledgeSaveToast ( message ) {
setKnowledgeSaveToast ( message )
window . setTimeout ( ( ) => setKnowledgeSaveToast ( current => current === message ? '' : current ) , 4200 )
}
function openKnowledgeSaveDialog ( message , index ) {
if ( ! message ? . message _id ) {
showKnowledgeSaveToast ( 'This message is still being written to chat history. Try again in a moment.' )
return
}
let precedingUserMessage = null
if ( message . role === 'assistant' ) {
for ( let cursor = index - 1 ; cursor >= 0 ; cursor -= 1 ) {
if ( messages [ cursor ] ? . role === 'user' ) {
precedingUserMessage = messages [ cursor ]
break
}
}
}
setKnowledgeSaveTarget ( { message , precedingUserMessage } )
}
async function handleKnowledgeSaved ( result ) {
setKnowledgeSaveTarget ( null )
await refreshLibraries ( )
await refreshLibraryJobs ( )
showKnowledgeSaveToast ( result ? . already _saved ? 'This message is already saved in that database.' : 'Message saved to knowledge.' )
}
function openChatMessageSource ( item ) {
const sessionId = String ( item ? . source _session _id || '' )
const messageId = String ( item ? . source _message _id || '' )
if ( ! sessionId || ! chatSessions . some ( session => session . session _id === sessionId ) ) {
showKnowledgeSaveToast ( 'The original chat is no longer available. The saved knowledge remains intact.' )
return
}
knowledgeJumpTargetRef . current = messageId
setActiveSidebarMode ( 'chats' )
selectChat ( sessionId )
}
2026-04-17 10:48:22 +02:00
const chatModelPickerOptions = useMemo ( ( ) => {
return buildModelPickerOptions ( availableChatModels , model , 'saved model unavailable' )
} , [ availableChatModels , model ] )
2026-06-15 15:19:00 +02:00
const handleWorkflowRunEvent = React . useCallback ( ( sessionId , event ) => {
2026-06-15 15:49:34 +02:00
if (
event . type === 'tool_result'
&& String ( event . payload ? . tool || '' ) . startsWith ( 'heimgeist.save_' )
&& event . payload ? . result ? . status === 'saved'
) {
void refreshLibraries ( )
void refreshLibraryJobs ( )
}
2026-06-15 15:19:00 +02:00
setWorkflowExecutions ( previous => {
const current = previous [ sessionId ] || {
runId : event . run _id ,
workflowName : event . payload ? . workflow ? . name || 'Workflow' ,
status : 'running' ,
startedAt : Date . now ( ) ,
events : [ ] ,
}
2026-06-15 15:45:09 +02:00
if ( event . type === 'client_run_started' && current . runId !== event . run _id ) {
return {
... previous ,
[ sessionId ] : {
runId : event . run _id ,
workflowName : event . payload ? . workflow ? . name || 'Workflow' ,
status : 'running' ,
startedAt : Date . now ( ) ,
events : [ event ] ,
} ,
}
}
2026-06-15 15:19:00 +02:00
let status = current . status
let finishedAt = current . finishedAt
if ( event . type === 'client_run_started' ) {
status = 'running'
} else if ( event . type === 'run_completed' ) {
status = 'completed'
finishedAt = Date . now ( )
} else if ( event . type === 'run_failed' ) {
status = 'failed'
finishedAt = Date . now ( )
} else if ( event . type === 'run_cancelled' ) {
status = 'cancelled'
finishedAt = Date . now ( )
} else if ( event . type === 'confirmation_required' ) {
status = 'waiting_confirmation'
setPendingWorkflowConfirmation ( { ... event , sessionId } )
}
return {
... previous ,
[ sessionId ] : {
... current ,
runId : event . run _id || current . runId ,
workflowName : event . payload ? . workflow ? . name || current . workflowName ,
status ,
finishedAt ,
events : [ ... ( current . events || [ ] ) , event ] . slice ( - 120 ) ,
} ,
}
} )
2026-06-15 15:49:34 +02:00
} , [ backendApiUrl ] )
2026-06-15 15:19:00 +02:00
2026-05-06 03:52:43 +02:00
const { regenerateFromIndex , sendMessage } = createChatGenerationHandlers ( {
activeSessionId ,
activeSessionIdRef ,
backendApiUrl ,
2026-06-15 15:19:00 +02:00
attachWorkflowRunToRequest ,
2026-05-06 03:52:43 +02:00
beginCancelableRequest ,
canAttachImages ,
chatSessions ,
composerAttachments ,
createNewChat ,
finishCancelableRequest ,
getChatLibraryForSession ,
2026-06-15 15:19:00 +02:00
getChatWorkflowForSession ,
2026-05-06 03:52:43 +02:00
imageAttachmentUnavailableReason ,
input ,
isSending ,
model ,
2026-06-15 03:53:10 +02:00
onMessagesPersisted : fetchHistory ,
2026-06-15 15:19:00 +02:00
onWorkflowRunEvent : handleWorkflowRunEvent ,
2026-05-06 03:52:43 +02:00
restoredForRef ,
scrollMessageToTop ,
scrollToBottom ,
searxEngines ,
searxUrl ,
setChatSessions ,
setComposerAttachments ,
setInput ,
setIsAttachmentMenuOpen ,
setNewMsgTip ,
setPendingScrollToLastUser ,
setUnreadSessions ,
setUserScrolledUp ,
streamOutput ,
transcriptionModel ,
userScrolledUpRef ,
visionModel ,
webSearchEnabled ,
2026-06-15 15:22:19 +02:00
workflowRouterModel ,
2026-05-06 03:52:43 +02:00
} )
2026-06-15 03:53:10 +02:00
useEffect ( ( ) => {
const messageId = knowledgeJumpTargetRef . current
if ( activeSidebarMode !== 'chats' || ! messageId ) return
if ( ! messages . some ( message => message . message _id === messageId ) ) return
knowledgeJumpTargetRef . current = null
requestAnimationFrame ( ( ) => scrollMessageToTop ( messageId , 'smooth' , activeSessionId ) )
} , [ activeSessionId , activeSidebarMode , messages , scrollMessageToTop ] )
2026-04-17 10:48:13 +02:00
useEffect ( ( ) => {
if ( ! isChatModelPickerOpen ) return
const onPointerDown = ( event ) => {
if ( ! chatModelPickerRef . current ? . contains ( event . target ) ) {
setIsChatModelPickerOpen ( false )
}
}
document . addEventListener ( 'mousedown' , onPointerDown )
return ( ) => document . removeEventListener ( 'mousedown' , onPointerDown )
} , [ isChatModelPickerOpen ] )
useEffect ( ( ) => {
setIsChatModelPickerOpen ( false )
} , [ activeSessionId , activeSidebarMode ] )
2026-04-17 13:01:51 +02:00
useEffect ( ( ) => {
if ( ! isAttachmentMenuOpen ) return
const onPointerDown = ( event ) => {
if ( ! attachmentMenuRef . current ? . contains ( event . target ) ) {
setIsAttachmentMenuOpen ( false )
}
}
document . addEventListener ( 'mousedown' , onPointerDown )
return ( ) => document . removeEventListener ( 'mousedown' , onPointerDown )
} , [ isAttachmentMenuOpen ] )
useEffect ( ( ) => {
setIsAttachmentMenuOpen ( false )
} , [ activeSessionId , activeSidebarMode , canAttachImages ] )
2026-04-16 21:31:04 +02:00
const handleChatDragEnter = ( event ) => {
if ( activeSidebarMode !== 'chats' || ! hasFilePayload ( event ) ) return
event . preventDefault ( )
imageDragDepthRef . current += 1
2026-04-17 13:04:19 +02:00
setIsChatDragActive ( true )
2026-04-16 21:31:04 +02:00
}
const handleChatDragOver = ( event ) => {
if ( activeSidebarMode !== 'chats' || ! hasFilePayload ( event ) ) return
event . preventDefault ( )
2026-04-17 13:02:26 +02:00
event . dataTransfer . dropEffect = 'copy'
if ( ! isChatDragActive ) {
2026-04-16 21:31:04 +02:00
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 )
2026-04-17 13:02:26 +02:00
await appendDroppedChatFiles ( event . dataTransfer ? . files )
2026-04-16 21:31:04 +02:00
textareaRef . current ? . focus ( )
}
2025-08-23 16:45:46 +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 ( ) ;
2026-04-17 10:33:24 +02:00
const sessionWithMessages = { ... newSession , name : sanitizeChatTitle ( newSession . name ) , messages : [ ] } ;
2025-08-22 23:42:34 +02:00
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 ) ) ;
2026-05-06 03:46:55 +02:00
scrollPendingMessageForSession ( sessionId , chatSessions )
2025-08-22 23:42:34 +02:00
}
function handleRename ( sessionId , newName ) {
2026-04-17 10:33:24 +02:00
const sanitizedName = sanitizeChatTitle ( 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' } ,
2026-04-17 10:33:24 +02:00
body : JSON . stringify ( { title : sanitizedName } )
2025-08-22 23:42:34 +02:00
} )
. then ( ( ) => {
setChatSessions ( prevSessions =>
prevSessions . map ( session =>
2026-04-17 10:33:24 +02:00
session . session _id === sessionId ? { ... session , name : sanitizedName } : session
2025-08-22 23:42:34 +02:00
)
) ;
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-05-06 03:40:34 +02:00
setChatLibraryForSession ( sessionId , null )
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 >
2026-06-15 15:19:15 +02:00
< div
className = { ` sidebar-tab ${ activeSidebarMode === 'workflows' ? 'active' : '' } ` }
onClick = { ( ) => handleSidebarClick ( 'workflows' ) }
>
Flows
< / div >
2025-08-22 23:42:34 +02:00
< 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' && (
2026-05-06 03:38:23 +02:00
< SettingsSidebar
activeSection = { activeSettingsSubmenu }
onSelect = { setActiveSettingsSubmenu }
/ >
2025-08-22 23:42:34 +02:00
) }
2026-06-15 15:19:15 +02:00
{ activeSidebarMode === 'workflows' && (
2026-06-15 16:54:10 +02:00
< WorkflowList
activeId = { activeWorkflowId }
onCreate = { handleCreateWorkflow }
onDelete = { handleDeleteWorkflow }
onDuplicate = { handleDuplicateWorkflow }
onSelect = { handleSelectWorkflow }
workflows = { workflows }
/ >
2026-06-15 15:19:15 +02:00
) }
2025-08-22 23:42:34 +02:00
< / div >
2026-06-15 15:19:15 +02:00
{ ( activeSidebarMode === 'chats' || activeSidebarMode === 'dbs' ) && (
2025-08-22 23:42:34 +02:00
< 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' && (
< >
2026-04-17 10:48:37 +02:00
< div className = "header header--chat" >
< div className = "header-main" >
< strong className = "header-title" >
Chat - { activeChatSession ? . name || 'New Chat' }
< / strong >
{ chatLibrary && (
< span className = "header-subtle" >
{ ` DB: ${ chatLibrary . name } ${ chatLibraryStatusSuffix } ` }
< / span >
) }
2026-06-15 15:19:15 +02:00
< span className = "header-subtle" >
{ chatWorkflowSelection . mode === 'automatic'
? 'Flow: Automatic'
: ` Flow: ${ selectedWorkflow ? . name || 'Direct' } ` }
< / span >
2026-04-17 10:48:37 +02:00
< / div >
< div className = "header-actions" >
< div className = "model-picker" ref = { chatModelPickerRef } >
< button
type = "button"
className = "model-picker-toggle"
onClick = { ( ) => setIsChatModelPickerOpen ( prev => ! prev ) }
aria - haspopup = "menu"
aria - expanded = { isChatModelPickerOpen }
title = { model ? ` Current chat model: ${ model } ` : 'Select chat model' }
disabled = { ! model && chatModelPickerOptions . length === 0 }
>
< span className = "model-picker-label" >
{ model || ( isLoadingModelCatalog ? 'Loading models…' : 'Select model' ) }
< / span >
< span className = "model-picker-caret" aria - hidden = "true" >
{ isChatModelPickerOpen ? '▴' : '▾' }
< / span >
< / button >
{ isChatModelPickerOpen && (
< div className = "model-picker-menu" role = "menu" >
{ chatModelPickerOptions . length === 0 ? (
< div className = "model-picker-empty" >
{ isLoadingModelCatalog ? 'Loading models…' : 'No chat models available.' }
< / div >
) : (
chatModelPickerOptions . map ( option => {
const selected = option . value === model
return (
< button
key = { option . value }
type = "button"
className = { ` model-picker-option ${ selected ? ' selected' : '' } ` }
onClick = { ( ) => handleChatModelSelect ( option . value ) }
>
< span className = "model-picker-option-label" > { option . label } < / span >
{ selected && < span className = "model-picker-status" > Selected < / span > }
< / button >
)
} )
) }
< / div >
) }
< / div >
< / div >
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 }
>
2026-06-15 15:19:15 +02:00
< WorkflowExecutionPanel execution = { workflowExecutions [ activeSessionId ] } / >
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 >
2026-06-15 03:53:10 +02:00
< button className = "icon-button knowledge-save-button" title = "Save message to knowledge" aria - label = "Save message to knowledge" onClick = { ( ) => openKnowledgeSaveDialog ( m , i ) } >
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M9 18h6" / > < path d = "M10 22h4" / > < path d = "M8.5 14.5A7 7 0 1 1 15.5 14.5C14.6 15.2 14 16.1 14 17h-4c0-.9-.6-1.8-1.5-2.5Z" / > < / svg >
< / button >
2025-08-26 04:49:54 +02:00
< 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
< >
2026-04-17 13:03:27 +02:00
< AttachmentStrip attachments = { m . attachments } className = "message-attachment-strip" / >
2026-04-16 21:32:01 +02:00
< 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-17 13:03:27 +02:00
< AttachmentStrip attachments = { attachments } className = "message-attachment-strip" / >
2026-04-16 21:32:01 +02:00
{ 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 >
2026-06-15 03:53:10 +02:00
< button className = "icon-button knowledge-save-button" title = "Save message to knowledge" aria - label = "Save message to knowledge" onClick = { ( ) => openKnowledgeSaveDialog ( m , i ) } >
< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M9 18h6" / > < path d = "M10 22h4" / > < path d = "M8.5 14.5A7 7 0 1 1 15.5 14.5C14.6 15.2 14 16.1 14 17h-4c0-.9-.6-1.8-1.5-2.5Z" / > < / svg >
< / button >
2025-08-26 04:49:54 +02:00
< / 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 }
/ >
2026-04-17 13:03:27 +02:00
< AttachmentStrip
2026-04-16 21:32:22 +02:00
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-05-06 03:40:34 +02:00
< ChatDatabasePicker
activeSessionId = { activeSessionId }
chatLibrary = { chatLibrary }
chatLibrarySlug = { chatLibrarySlug }
chatLibraryStatusSuffix = { chatLibraryStatusSuffix }
isLibrarySyncing = { isLibrarySyncing }
libraries = { libraries }
setChatLibraryForSession = { setChatLibraryForSession }
2026-06-15 15:19:15 +02:00
/ >
< WorkflowSelector
disabled = { isSending }
selection = { chatWorkflowSelection }
workflows = { workflows }
onChange = { ( nextSelection ) => setChatWorkflowForSession ( activeSessionId , nextSelection ) }
2026-05-06 03:40:34 +02:00
/ >
2026-04-17 13:03:45 +02:00
< div className = "footer-tool-group" ref = { attachmentMenuRef } >
2026-04-16 21:32:22 +02:00
< button
type = "button"
2026-04-17 13:03:45 +02:00
className = { "attachment-toggle" + ( composerAttachments . length > 0 ? " active" : "" ) }
onClick = { ( ) => setIsAttachmentMenuOpen ( prev => ! prev ) }
title = "Add attachments"
aria - label = "Add attachments"
aria - haspopup = "menu"
aria - expanded = { isAttachmentMenuOpen }
2026-04-16 21:32:22 +02:00
>
< 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" >
2026-04-17 13:03:45 +02:00
< path d = "M12 5v14" / >
< path d = "M5 12h14" / >
2026-04-16 21:32:22 +02:00
< / svg >
< / button >
2026-04-17 13:03:45 +02:00
{ isAttachmentMenuOpen && (
< div className = "attachment-menu" role = "menu" >
< button
type = "button"
className = "attachment-menu-option"
onClick = { ( ) => {
setIsAttachmentMenuOpen ( false )
openImagePicker ( )
} }
disabled = { ! canAttachImages }
title = { canAttachImages ? 'Add one or more image files' : imageAttachmentUnavailableReason }
>
< span > Add Image ( s ) < / span >
{ ! canAttachImages && < span className = "attachment-menu-status" > Unavailable < / span > }
< / button >
< button
type = "button"
className = "attachment-menu-option"
onClick = { async ( ) => {
setIsAttachmentMenuOpen ( false )
await openFilePicker ( )
} }
title = "Add supported document, text, audio, or video files"
>
< span > Add File ( s ) < / span >
< / button >
{ ! canAttachImages && (
< div className = "attachment-menu-hint" >
{ imageAttachmentUnavailableReason }
< / div >
) }
< / div >
) }
< / div >
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 }
2026-06-15 03:53:10 +02:00
onOpenChatMessage = { openChatMessageSource }
2026-03-19 21:07:22 +01:00
onRefresh = { async ( ) => {
await refreshLibraries ( ) ;
await refreshLibraryJobs ( ) ;
} }
/ >
< / >
2025-08-22 23:42:34 +02:00
) }
{ activeSidebarMode === 'settings' && (
2026-05-06 03:38:23 +02:00
< SettingsPanel
activeSection = { activeSettingsSubmenu }
onAudioInputDeviceChange = { setAudioInputDeviceId }
onAudioInputLanguageChange = { setAudioInputLanguage }
onBackendApiUrlChange = { setBackendApiUrl }
onLibrariesPurged = { handleLibrariesPurged }
onModelChange = { setModel }
onStreamOutputChange = { setStreamOutput }
onTranscriptionModelChange = { setTranscriptionModel }
onVisionModelChange = { setVisionModel }
2026-06-15 15:19:51 +02:00
onWorkflowRouterModelChange = { setWorkflowRouterModel }
2026-05-06 03:38:23 +02:00
searxEngines = { searxEngines }
searxUrl = { searxUrl }
setSearxEngines = { setSearxEngines }
setSearxUrl = { setSearxUrl }
streamOutput = { streamOutput }
/ >
2025-08-22 23:42:34 +02:00
) }
2026-06-15 15:19:15 +02:00
{ activeSidebarMode === 'workflows' && (
< WorkflowsArea
apiBase = { backendApiUrl }
2026-06-15 16:54:10 +02:00
error = { workflowError }
2026-06-15 15:19:15 +02:00
model = { model }
2026-06-15 16:54:10 +02:00
onChanged = { refreshWorkflows }
tools = { workflowTools }
workflow = { activeWorkflow }
2026-06-15 15:19:15 +02:00
/ >
) }
2026-06-15 03:53:10 +02:00
{ knowledgeSaveTarget && (
< SaveMessageToKnowledgeDialog
apiBase = { backendApiUrl }
defaultLibrarySlug = { chatLibrarySlug || activeLibrarySlug }
libraries = { libraries }
message = { knowledgeSaveTarget . message }
onClose = { ( ) => setKnowledgeSaveTarget ( null ) }
onSaved = { handleKnowledgeSaved }
precedingUserMessage = { knowledgeSaveTarget . precedingUserMessage }
/ >
) }
{ knowledgeSaveToast && < div className = "knowledge-save-toast" role = "status" > { knowledgeSaveToast } < / div > }
2026-06-15 15:19:15 +02:00
< WorkflowConfirmationDialog
confirmation = { pendingWorkflowConfirmation }
onRespond = { async ( approved ) => {
const confirmation = pendingWorkflowConfirmation
if ( ! confirmation ) return
try {
await respondToConfirmation (
backendApiUrl ,
confirmation . run _id ,
confirmation . payload ? . confirmation _id ,
approved ,
)
setPendingWorkflowConfirmation ( null )
setWorkflowExecutions ( previous => ( {
... previous ,
[ confirmation . sessionId ] : {
... ( previous [ confirmation . sessionId ] || { } ) ,
status : 'running' ,
} ,
} ) )
} catch ( error ) {
window . alert ( error . message )
}
} }
/ >
2025-08-22 23:42:34 +02:00
< / div >
< / div >
)
}