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-23 16:45:46 +02:00
|
|
|
import { flushSync } from 'react-dom';
|
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-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,
|
|
|
|
|
getAttachmentDisplayName,
|
|
|
|
|
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,
|
|
|
|
|
readBackendErrorText,
|
|
|
|
|
} from './backendApi'
|
2026-05-06 03:25:39 +02:00
|
|
|
import { sanitizeChatTitle, splitThinkBlocks } from './chatText'
|
|
|
|
|
import { buildModelPickerOptions } from './modelPicker'
|
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-05-06 03:45:14 +02:00
|
|
|
import { useChatScroll } from './useChatScroll'
|
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)
|
|
|
|
|
const [activeSidebarMode, setActiveSidebarMode] = useState('chats') // 'chats', 'dbs', '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-05-06 03:39:50 +02:00
|
|
|
const {
|
|
|
|
|
chatLibrary,
|
|
|
|
|
chatLibrarySlug,
|
|
|
|
|
chatLibraryStatusSuffix,
|
|
|
|
|
clearChatLibrarySelections,
|
|
|
|
|
getChatLibraryForSession,
|
|
|
|
|
isLibrarySyncing,
|
|
|
|
|
removeLibraryFromChatSelections,
|
|
|
|
|
setChatLibraryForSession,
|
|
|
|
|
} = useChatLibrarySelection({ activeSessionId, libraries, libraryJobs })
|
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-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-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('');
|
|
|
|
|
// 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 }
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 08:38:07 +02:00
|
|
|
function messageHasImageAttachments(message) {
|
2026-04-17 13:01:27 +02:00
|
|
|
return Array.isArray(message?.attachments) && message.attachments.some(attachmentIsImage)
|
2026-04-17 08:38:07 +02:00
|
|
|
}
|
|
|
|
|
|
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-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)
|
|
|
|
|
window.electronAPI.setSetting('visionModel', nextModel)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!allowCapabilityLookup || !backendApiUrl) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const data = await fetchModelCapabilities(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)
|
|
|
|
|
window.electronAPI.setSetting('visionModel', nextModel)
|
|
|
|
|
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)
|
|
|
|
|
window.electronAPI.setSetting('chatModel', nextModel)
|
|
|
|
|
await syncVisionModelFromChatModel(nextModel)
|
|
|
|
|
}
|
|
|
|
|
|
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-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-17 13:01:27 +02:00
|
|
|
function buildAttachmentTitleSeed(text, attachments = []) {
|
|
|
|
|
const trimmed = String(text || '').trim()
|
|
|
|
|
if (trimmed) {
|
|
|
|
|
return trimmed
|
|
|
|
|
}
|
|
|
|
|
const firstAttachment = Array.isArray(attachments) ? attachments[0] : null
|
|
|
|
|
const firstName = getAttachmentDisplayName(firstAttachment, '')
|
|
|
|
|
return firstName || 'Attachment'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
const pickedPaths = await window.electronAPI?.pickPaths?.({
|
|
|
|
|
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-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-04-17 08:38:23 +02:00
|
|
|
const conversationNeedsVision = msgs
|
|
|
|
|
.slice(0, lastUserIdx + 1)
|
|
|
|
|
.some(messageHasImageAttachments)
|
2026-04-17 13:02:39 +02:00
|
|
|
if (conversationNeedsVision && !canAttachImages) {
|
|
|
|
|
window.alert(imageAttachmentUnavailableReason)
|
2026-04-17 08:38:23 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-04-17 13:02:39 +02:00
|
|
|
model,
|
2026-03-19 21:07:22 +01:00
|
|
|
stream: true,
|
|
|
|
|
enriched_message: enrichedPrompt,
|
2026-04-17 13:02:39 +02:00
|
|
|
sources: citationSources || [],
|
|
|
|
|
vision_model: visionModel || null,
|
|
|
|
|
transcription_model: transcriptionModel || null,
|
2026-03-19 21:07:22 +01:00
|
|
|
})
|
2025-08-27 04:27:18 +02:00
|
|
|
})
|
2026-04-17 08:19:43 +02:00
|
|
|
if (!res.ok) throw new Error(await readBackendErrorText(res))
|
2026-03-19 21:07:22 +01:00
|
|
|
|
|
|
|
|
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,
|
2026-04-17 13:02:39 +02:00
|
|
|
model,
|
2025-08-27 04:27:18 +02:00
|
|
|
stream: false,
|
|
|
|
|
enriched_message: enrichedPrompt,
|
2026-04-17 13:02:39 +02:00
|
|
|
sources: citationSources || [],
|
|
|
|
|
vision_model: visionModel || null,
|
|
|
|
|
transcription_model: transcriptionModel || null,
|
2025-08-27 04:27:18 +02:00
|
|
|
})
|
2026-03-19 21:07:22 +01:00
|
|
|
})
|
2026-04-17 08:19:43 +02:00
|
|
|
if (!res.ok) throw new Error(await readBackendErrorText(res))
|
2026-03-19 21:07:22 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
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-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
|
2026-04-17 08:38:07 +02:00
|
|
|
setVisionModel(settings.visionModel || settings.chatModel || '');
|
|
|
|
|
setTranscriptionModel(settings.transcriptionModel || 'base');
|
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) {
|
|
|
|
|
window.electronAPI.setSetting('audioInputEnabled', true)
|
|
|
|
|
}
|
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-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();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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-17 13:01:45 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
|
|
|
|
if (!backendApiUrl || !model) {
|
|
|
|
|
setSelectedChatModelSupportsVision(false)
|
|
|
|
|
return () => {
|
|
|
|
|
controller.abort()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
;(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await fetchModelCapabilities(model, controller.signal)
|
|
|
|
|
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-04-17 08:38:07 +02:00
|
|
|
const data = await fetchModelCapabilities(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 {
|
|
|
|
|
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)
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
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-04-17 10:48:22 +02:00
|
|
|
const chatModelPickerOptions = useMemo(() => {
|
|
|
|
|
return buildModelPickerOptions(availableChatModels, model, 'saved model unavailable')
|
|
|
|
|
}, [availableChatModels, model])
|
|
|
|
|
|
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
|
|
|
async function sendMessage() {
|
2026-04-16 21:31:04 +02:00
|
|
|
const trimmedInput = input.trim()
|
|
|
|
|
if (isSending || (!trimmedInput && composerAttachments.length === 0) || !model) return
|
2026-04-17 13:02:48 +02:00
|
|
|
if (composerAttachments.some(attachmentIsImage) && !canAttachImages) {
|
|
|
|
|
window.alert(imageAttachmentUnavailableReason)
|
2026-04-16 21:31:04 +02:00
|
|
|
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-17 13:07:52 +02:00
|
|
|
const existingMessages = (chatSessions.find(s => s.session_id === targetSessionId)?.messages) || []
|
2026-04-16 21:31:04 +02:00
|
|
|
const outgoingAttachments = composerAttachments.map(({ id, ...attachment }) => ({ ...attachment }))
|
2026-04-17 13:08:03 +02:00
|
|
|
const historyNeedsVision = existingMessages.some(messageHasImageAttachments)
|
|
|
|
|
if (historyNeedsVision && !canAttachImages) {
|
2026-04-17 13:07:52 +02:00
|
|
|
window.alert(imageAttachmentUnavailableReason)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-17 13:02:48 +02:00
|
|
|
const composerSnapshot = input
|
|
|
|
|
const attachmentSnapshot = composerAttachments.map(attachment => ({ ...attachment }))
|
2026-04-16 21:31:04 +02:00
|
|
|
const userMsg = {
|
|
|
|
|
role: 'user',
|
|
|
|
|
content: trimmedInput,
|
|
|
|
|
attachments: outgoingAttachments,
|
|
|
|
|
id: `msg-${Date.now()}-${Math.random()}`
|
|
|
|
|
}
|
2026-04-17 13:02:48 +02:00
|
|
|
setIsAttachmentMenuOpen(false)
|
2026-03-19 21:07:22 +01:00
|
|
|
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,
|
2026-04-17 13:03:00 +02:00
|
|
|
model,
|
2026-03-19 21:07:22 +01:00
|
|
|
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-04-17 13:03:00 +02:00
|
|
|
vision_model: visionModel || null,
|
|
|
|
|
transcription_model: transcriptionModel || null,
|
2026-03-19 21:07:22 +01:00
|
|
|
})
|
|
|
|
|
})
|
2026-04-17 13:03:00 +02:00
|
|
|
if (!res.ok) {
|
|
|
|
|
const error = new Error(await readBackendErrorText(res))
|
|
|
|
|
error.status = res.status
|
|
|
|
|
throw error
|
|
|
|
|
}
|
2026-03-19 21:07:22 +01:00
|
|
|
|
|
|
|
|
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)
|
2026-04-17 13:06:02 +02:00
|
|
|
if (Number(error?.status) >= 400 && Number(error?.status) < 500) {
|
|
|
|
|
setAssistantMessageContent(targetSessionId, assistantMsgId, fullReply, { removeIfEmpty: true })
|
|
|
|
|
setChatSessions(prevSessions =>
|
|
|
|
|
prevSessions.map(session =>
|
|
|
|
|
session.session_id === targetSessionId
|
|
|
|
|
? {
|
|
|
|
|
...session,
|
|
|
|
|
messages: (session.messages || []).filter(message => message.id !== userMsg.id),
|
|
|
|
|
}
|
|
|
|
|
: session
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
setInput(composerSnapshot)
|
|
|
|
|
setComposerAttachments(attachmentSnapshot)
|
|
|
|
|
window.alert(getErrorText(error))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-19 21:07:22 +01:00
|
|
|
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,
|
2026-04-17 13:03:00 +02:00
|
|
|
model,
|
2025-08-27 04:27:18 +02:00
|
|
|
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,
|
2026-04-17 13:03:00 +02:00
|
|
|
vision_model: visionModel || null,
|
|
|
|
|
transcription_model: transcriptionModel || null,
|
2025-08-27 04:27:18 +02:00
|
|
|
})
|
2026-03-19 21:07:22 +01:00
|
|
|
})
|
2026-04-17 13:03:00 +02:00
|
|
|
if (!res.ok) {
|
|
|
|
|
const error = new Error(await readBackendErrorText(res))
|
|
|
|
|
error.status = res.status
|
|
|
|
|
throw error
|
|
|
|
|
}
|
2026-03-19 21:07:22 +01:00
|
|
|
|
|
|
|
|
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-17 13:03:09 +02:00
|
|
|
message: buildAttachmentTitleSeed(userMsg.content, outgoingAttachments),
|
2026-03-19 21:07:22 +01:00
|
|
|
model
|
2025-08-27 04:27:18 +02:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(data => {
|
2026-04-17 10:33:24 +02:00
|
|
|
const sanitizedTitle = sanitizeChatTitle(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)
|
2026-04-17 13:03:09 +02:00
|
|
|
if (Number(error?.status) >= 400 && Number(error?.status) < 500) {
|
|
|
|
|
setChatSessions(prevSessions =>
|
|
|
|
|
prevSessions.map(session =>
|
|
|
|
|
session.session_id === targetSessionId
|
|
|
|
|
? {
|
|
|
|
|
...session,
|
|
|
|
|
messages: (session.messages || []).filter(message => message.id !== userMsg.id),
|
|
|
|
|
}
|
|
|
|
|
: session
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
setInput(composerSnapshot)
|
|
|
|
|
setComposerAttachments(attachmentSnapshot)
|
2026-04-17 13:03:59 +02:00
|
|
|
window.alert(getErrorText(error))
|
|
|
|
|
return
|
2026-04-17 13:03:09 +02:00
|
|
|
}
|
2026-03-19 21:07:22 +01:00
|
|
|
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();
|
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>
|
|
|
|
|
<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
|
|
|
)}
|
|
|
|
|
</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' && (
|
|
|
|
|
<>
|
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>
|
|
|
|
|
)}
|
|
|
|
|
</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}
|
|
|
|
|
>
|
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
|
|
|
<>
|
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>
|
|
|
|
|
</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-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}
|
|
|
|
|
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}
|
|
|
|
|
searxEngines={searxEngines}
|
|
|
|
|
searxUrl={searxUrl}
|
|
|
|
|
setSearxEngines={setSearxEngines}
|
|
|
|
|
setSearxUrl={setSearxUrl}
|
|
|
|
|
streamOutput={streamOutput}
|
|
|
|
|
/>
|
2025-08-22 23:42:34 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|