diff --git a/src/App.jsx b/src/App.jsx index e09a626..d120fe0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're import { flushSync } from 'react-dom'; import TextareaAutosize from 'react-textarea-autosize'; import AssistantMessageContent from './AssistantMessageContent' +import ChatDatabasePicker from './ChatDatabasePicker' import LibraryManager from './LibraryManager' import { SettingsPanel, SettingsSidebar } from './SettingsPanels' import { applyColorScheme } from './colorSchemes' @@ -23,7 +24,6 @@ import { import { AUDIO_RECORDING_TICK_MS, BOTTOM_EPSILON, - CHAT_LIBRARY_MAP_KEY, DEFAULT_BACKEND_API_URL, MAX_AUDIO_RECORDING_MS, MAX_IMAGE_ATTACHMENT_BYTES, @@ -52,6 +52,7 @@ import { stopMediaStream, supportsAudioInputCapture, } from './audioInput' +import { useChatLibrarySelection } from './useChatLibrarySelection' function appendTranscriptToComposer(currentInput, transcript) { const nextTranscript = String(transcript || '').trim() diff --git a/src/ChatDatabasePicker.jsx b/src/ChatDatabasePicker.jsx new file mode 100644 index 0000000..f26aedc --- /dev/null +++ b/src/ChatDatabasePicker.jsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from 'react' + +export default function ChatDatabasePicker({ + activeSessionId, + chatLibrary, + chatLibrarySlug, + chatLibraryStatusSuffix, + isLibrarySyncing, + libraries, + setChatLibraryForSession, +}) { + const dbPickerRef = useRef(null) + const [isDbPickerOpen, setIsDbPickerOpen] = useState(false) + + 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]) + + return ( +
+ + {isDbPickerOpen && ( +
+ + {libraries.length === 0 ? ( +
No databases yet.
+ ) : ( + libraries.map(library => { + const selected = chatLibrarySlug === library.slug + const syncing = isLibrarySyncing(library.slug) + const status = !library.files?.length + ? 'Empty' + : library.states?.is_indexed + ? 'Ready' + : syncing + ? 'Syncing' + : 'Needs sync' + + return ( + + ) + }) + )} +
+ )} +
+ ) +} diff --git a/src/useChatLibrarySelection.js b/src/useChatLibrarySelection.js new file mode 100644 index 0000000..6f023bf --- /dev/null +++ b/src/useChatLibrarySelection.js @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useState } from 'react' +import { CHAT_LIBRARY_MAP_KEY } from './appConfig' + +function libraryJobIsActive(job) { + return job?.status === 'queued' || job?.status === 'running' +} + +function getLibraryStatusSuffix(library, hasActiveJob) { + if (!library) return '' + if (!library.files?.length) return ' (empty)' + if (library.states?.is_indexed) return '' + return hasActiveJob ? ' (syncing)' : ' (needs sync)' +} + +function loadChatLibraryMap() { + try { + const raw = localStorage.getItem(CHAT_LIBRARY_MAP_KEY) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} + +export function useChatLibrarySelection({ activeSessionId, libraries, libraryJobs }) { + const [chatLibraryBySession, setChatLibraryBySession] = useState(loadChatLibraryMap) + + useEffect(() => { + try { + localStorage.setItem(CHAT_LIBRARY_MAP_KEY, JSON.stringify(chatLibraryBySession || {})) + } catch {} + }, [chatLibraryBySession]) + + useEffect(() => { + const validSlugs = new Set(libraries.map(library => library.slug)) + setChatLibraryBySession(prev => { + let changed = false + const next = {} + for (const [sessionId, slug] of Object.entries(prev || {})) { + if (validSlugs.has(slug)) { + next[sessionId] = slug + } else { + changed = true + } + } + return changed ? next : prev + }) + }, [libraries]) + + const chatLibrarySlug = activeSessionId ? (chatLibraryBySession[activeSessionId] || null) : null + + const chatLibrary = useMemo(() => { + return libraries.find(lib => lib.slug === chatLibrarySlug) || null + }, [chatLibrarySlug, libraries]) + + const chatLibraryHasActiveJob = useMemo(() => { + if (!chatLibrarySlug) return false + return libraryJobs.some(job => job.slug === chatLibrarySlug && libraryJobIsActive(job)) + }, [chatLibrarySlug, libraryJobs]) + + const chatLibraryStatusSuffix = useMemo(() => { + return getLibraryStatusSuffix(chatLibrary, chatLibraryHasActiveJob) + }, [chatLibrary, chatLibraryHasActiveJob]) + + function getChatLibrarySlugForSession(sessionId) { + if (!sessionId) return null + return chatLibraryBySession[sessionId] || null + } + + function getChatLibraryForSession(sessionId) { + const slug = getChatLibrarySlugForSession(sessionId) + if (!slug) return null + return libraries.find(lib => lib.slug === slug) || null + } + + function isLibrarySyncing(slug) { + if (!slug) return false + return libraryJobs.some(job => job.slug === slug && libraryJobIsActive(job)) + } + + 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 + }) + } + + function clearChatLibrarySelections() { + setChatLibraryBySession({}) + } + + return { + chatLibrary, + chatLibraryBySession, + chatLibrarySlug, + chatLibraryStatusSuffix, + clearChatLibrarySelections, + getChatLibraryForSession, + isLibrarySyncing, + removeLibraryFromChatSelections, + setChatLibraryForSession, + } +}