diff --git a/src/AssistantMessageContent.jsx b/src/AssistantMessageContent.jsx
new file mode 100644
index 0000000..48129d3
--- /dev/null
+++ b/src/AssistantMessageContent.jsx
@@ -0,0 +1,80 @@
+import React from 'react'
+import { markdownToHTML } from './markdown'
+import { enrichOllamaErrorText, splitThinkBlocks } from './chatText'
+
+export default function AssistantMessageContent({ content, streamOutput, sources }) {
+ const displayContent = enrichOllamaErrorText(content || '')
+ const { think, answer } = splitThinkBlocks(displayContent)
+ const [open, setOpen] = React.useState(false)
+ const showThinkButton = !!think
+
+ return (
+
+ {showThinkButton && (
+
+
+ {open && (
+
+ )}
+
+ )}
+
+ {Array.isArray(sources) && sources.length > 0 && (
+
+ )}
+
+ )
+}
diff --git a/src/appConfig.js b/src/appConfig.js
new file mode 100644
index 0000000..83d9d5e
--- /dev/null
+++ b/src/appConfig.js
@@ -0,0 +1,24 @@
+export const API_URL_KEY = 'backendApiUrl'
+export const COLOR_SCHEME_KEY = 'colorScheme'
+export const WEBSEARCH_URL_KEY = 'websearch.searxUrl'
+export const WEBSEARCH_ENGINES_KEY = 'websearch.engines'
+export const CHAT_LIBRARY_MAP_KEY = 'chat.libraryBySession'
+export const DEFAULT_SEARX_URL = 'http://127.0.0.1:8888'
+export const DEFAULT_BACKEND_API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'
+export const MAX_IMAGE_ATTACHMENTS = 6
+export const MAX_IMAGE_ATTACHMENT_BYTES = 20 * 1024 * 1024
+export const MAX_AUDIO_RECORDING_MS = 5 * 60 * 1000
+export const AUDIO_RECORDING_TICK_MS = 200
+export const TOP_ALIGN_OFFSET = 48
+export const BOTTOM_EPSILON = 24
+
+export function resolveBackendApiUrl(settings) {
+ return settings?.backendApiUrl || settings?.ollamaApiUrl || DEFAULT_BACKEND_API_URL
+}
+
+export function migrateLegacySearxUrl(value) {
+ const trimmed = typeof value === 'string' ? value.trim() : ''
+ if (!trimmed) return DEFAULT_SEARX_URL
+ if (trimmed === 'http://localhost:8888') return DEFAULT_SEARX_URL
+ return trimmed
+}
diff --git a/src/attachments.jsx b/src/attachments.jsx
new file mode 100644
index 0000000..e6e08ac
--- /dev/null
+++ b/src/attachments.jsx
@@ -0,0 +1,203 @@
+const CHAT_IMAGE_EXTENSION_LIST = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tif', '.tiff', '.heic', '.avif']
+const CHAT_FILE_EXTENSION_LIST = [
+ '.pdf', '.html', '.htm', '.txt', '.md', '.rst', '.epub',
+ '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac',
+ '.mp4', '.mkv', '.mov', '.webm', '.avi', '.ts',
+]
+const CHAT_IMAGE_EXTENSION_SET = new Set(CHAT_IMAGE_EXTENSION_LIST)
+const CHAT_FILE_EXTENSION_SET = new Set(CHAT_FILE_EXTENSION_LIST)
+const CHAT_AUDIO_EXTENSION_SET = new Set(['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac'])
+const CHAT_VIDEO_EXTENSION_SET = new Set(['.mp4', '.mkv', '.mov', '.webm', '.avi', '.ts'])
+const CHAT_TEXT_EXTENSION_SET = new Set(['.txt', '.md', '.rst'])
+
+export const CHAT_FILE_PICKER_FILTERS = [
+ { name: 'Documents and Media', extensions: CHAT_FILE_EXTENSION_LIST.map(extension => extension.slice(1)) },
+]
+
+export function getFileExtension(value) {
+ const text = String(value || '').trim()
+ const match = /(?:\.([A-Za-z0-9]+))$/.exec(text)
+ return match ? `.${match[1].toLowerCase()}` : ''
+}
+
+export function getFileName(value, fallback = 'attachment') {
+ const text = String(value || '').trim()
+ if (!text) return fallback
+ const parts = text.split(/[\\/]/)
+ return parts[parts.length - 1] || fallback
+}
+
+export function guessMimeTypeFromName(value) {
+ const extension = getFileExtension(value)
+ switch (extension) {
+ case '.pdf': return 'application/pdf'
+ case '.html':
+ case '.htm': return 'text/html'
+ case '.txt': return 'text/plain'
+ case '.md': return 'text/markdown'
+ case '.rst': return 'text/x-rst'
+ case '.epub': return 'application/epub+zip'
+ case '.mp3': return 'audio/mpeg'
+ case '.wav': return 'audio/wav'
+ case '.m4a': return 'audio/mp4'
+ case '.flac': return 'audio/flac'
+ case '.ogg': return 'audio/ogg'
+ case '.opus': return 'audio/opus'
+ case '.aac': return 'audio/aac'
+ case '.mp4': return 'video/mp4'
+ case '.mkv': return 'video/x-matroska'
+ case '.mov': return 'video/quicktime'
+ case '.webm': return 'video/webm'
+ case '.avi': return 'video/x-msvideo'
+ case '.ts': return 'video/mp2t'
+ default: return ''
+ }
+}
+
+export function attachmentIsImage(attachment) {
+ return Boolean(attachment?.data_url) || String(attachment?.kind || '').toLowerCase() === 'image'
+}
+
+export function attachmentIsFile(attachment) {
+ return Boolean(attachment) && !attachmentIsImage(attachment)
+}
+
+export function isSupportedChatFilePath(value) {
+ return CHAT_FILE_EXTENSION_SET.has(getFileExtension(value))
+}
+
+export function isSupportedChatImagePath(value) {
+ return CHAT_IMAGE_EXTENSION_SET.has(getFileExtension(value))
+}
+
+export function isImageFile(file) {
+ if (!file) return false
+ if (typeof file.type === 'string' && file.type.toLowerCase().startsWith('image/')) {
+ return true
+ }
+ return /\.(png|jpe?g|gif|bmp|webp|tiff?|heic|avif)$/i.test(file.name || '')
+}
+
+export function isSupportedChatFile(file) {
+ if (!file || isImageFile(file)) return false
+ return isSupportedChatFilePath(file.name || '')
+}
+
+export function getAttachmentDisplayName(attachment, fallback = 'attachment') {
+ return String(attachment?.name || '').trim() || getFileName(attachment?.source_path, fallback)
+}
+
+export function getFileAttachmentBadge(attachment) {
+ const mimeType = String(attachment?.mime_type || '').toLowerCase()
+ const extension = getFileExtension(attachment?.name || attachment?.source_path || '')
+ if (mimeType.startsWith('audio/') || CHAT_AUDIO_EXTENSION_SET.has(extension)) return 'AUDIO'
+ if (mimeType.startsWith('video/') || CHAT_VIDEO_EXTENSION_SET.has(extension)) return 'VIDEO'
+ if (CHAT_TEXT_EXTENSION_SET.has(extension)) return 'TEXT'
+ return 'DOC'
+}
+
+export function buildComposerAttachmentId(prefix = 'attachment') {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`
+}
+
+export function buildComposerFileAttachment({ sourcePath, name, mimeType, size }) {
+ const displayName = String(name || '').trim() || getFileName(sourcePath, 'file')
+ return {
+ id: buildComposerAttachmentId('file'),
+ kind: 'file',
+ name: displayName,
+ mime_type: String(mimeType || '').trim() || guessMimeTypeFromName(displayName || sourcePath),
+ source_path: sourcePath,
+ size: Number.isFinite(Number(size)) ? Number(size) : undefined,
+ }
+}
+
+export function hasFilePayload(event) {
+ const types = Array.from(event?.dataTransfer?.types || [])
+ return types.includes('Files')
+}
+
+export function eventHasImageFiles(event) {
+ const items = Array.from(event?.dataTransfer?.items || [])
+ if (items.length > 0) {
+ return items.some(item => item.kind === 'file' && isImageFile(item.getAsFile?.()))
+ }
+ const files = Array.from(event?.dataTransfer?.files || [])
+ return files.some(isImageFile)
+}
+
+export function readFileAsDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onerror = () => reject(reader.error || new Error(`Failed to read ${file?.name || 'image'}`))
+ reader.onload = () => resolve(String(reader.result || ''))
+ reader.readAsDataURL(file)
+ })
+}
+
+export function AttachmentStrip({ attachments, className = '', removable = false, onRemove }) {
+ if (!Array.isArray(attachments) || attachments.length === 0) {
+ return null
+ }
+
+ return (
+
+ {attachments.map((attachment, index) => {
+ const isImage = attachmentIsImage(attachment)
+ const keySeed = isImage
+ ? attachment?.data_url?.length || attachment?.name || index
+ : attachment?.source_path || attachment?.name || index
+ const key = attachment.id || `${getAttachmentDisplayName(attachment, 'attachment')}-${index}-${keySeed}`
+
+ if (isImage) {
+ const src = attachment?.data_url
+ if (!src) return null
+ return (
+
+ {removable && (
+
+ )}
+

+
+ )
+ }
+
+ const label = getAttachmentDisplayName(attachment, `Attachment ${index + 1}`)
+ return (
+
+ {removable && (
+
+ )}
+ {getFileAttachmentBadge(attachment)}
+ {label}
+
+ )
+ })}
+
+ )
+}
diff --git a/src/chatText.js b/src/chatText.js
new file mode 100644
index 0000000..6dc7904
--- /dev/null
+++ b/src/chatText.js
@@ -0,0 +1,124 @@
+export function sanitizeChatTitle(title) {
+ let cleanedTitle = String(title || '')
+ .replace(/[\s\S]*?<\/think(?:ing)?>/gi, '')
+ .trim()
+
+ let previousTitle = null
+ while (cleanedTitle && cleanedTitle !== previousTitle) {
+ previousTitle = cleanedTitle
+ cleanedTitle = cleanedTitle
+ .replace(/^\s*#+\s*/, '')
+ .replace(/^\s*\*{1,2}\s*/, '')
+ .replace(/\s*\*{1,2}\s*$/, '')
+ .trim()
+ }
+
+ return cleanedTitle.replace(/\s+/g, ' ').trim()
+}
+
+function appendOllamaErrorHint(text, marker, block) {
+ if (text.includes(marker)) {
+ return text
+ }
+ return `${text}\n\n${block}`
+}
+
+export function enrichOllamaErrorText(text) {
+ const raw = String(text || '')
+ const trimmed = raw.trim()
+ if (!trimmed) {
+ return raw
+ }
+
+ const lower = trimmed.toLowerCase()
+ const looksLikeOllamaError = (
+ lower.startsWith('error:') ||
+ lower.startsWith('ollama error:') ||
+ lower.includes("client error '") ||
+ lower.includes("server error '") ||
+ (lower.includes('http') && lower.includes('error')) ||
+ lower.includes('unknown model architecture') ||
+ lower.includes('out of memory')
+ )
+
+ if (!looksLikeOllamaError) {
+ return raw
+ }
+
+ if (lower.includes('unknown model architecture')) {
+ return appendOllamaErrorHint(
+ raw,
+ '[ERROR - Unsupported Model]',
+ '[ERROR - Unsupported Model]\nThis Ollama version does not support the model.\nUpdate Ollama.'
+ )
+ }
+ if (lower.includes('out of memory')) {
+ return appendOllamaErrorHint(
+ raw,
+ '[ERROR - Out of Memory]',
+ '[ERROR - Out of Memory]\nThe model is too large for available memory.\nUse a smaller or quantized model.'
+ )
+ }
+ if (lower.includes('502')) {
+ return appendOllamaErrorHint(
+ raw,
+ '[ERROR 502 - Bad Gateway]',
+ '[ERROR 502 - Bad Gateway]\nOllama did not return a valid response.\nTry restarting or updating Ollama.'
+ )
+ }
+ if (lower.includes('500')) {
+ return appendOllamaErrorHint(
+ raw,
+ '[ERROR 500 - Internal Server Error]',
+ "[ERROR 500 - Internal Server Error]\nOllama crashed while processing the request.\nCheck 'ollama logs' and memory usage."
+ )
+ }
+ if (lower.includes('404')) {
+ return appendOllamaErrorHint(
+ raw,
+ '[ERROR 404 - Not Found]',
+ "[ERROR 404 - Not Found]\nThe model or endpoint was not found.\nCheck the model name or run 'ollama pull '."
+ )
+ }
+ if (lower.includes('400')) {
+ return appendOllamaErrorHint(
+ raw,
+ '[ERROR 400 - Bad Request]',
+ '[ERROR 400 - Bad Request]\nThe request sent to Ollama was invalid.\nCheck parameters or payload format.'
+ )
+ }
+ return raw
+}
+
+export function splitThinkBlocks(text) {
+ if (!text) return { think: null, answer: '' }
+
+ const openTagRe = //i
+ const closeTagRe = /<\/think(?:ing)?>/i
+
+ const openMatch = text.match(openTagRe)
+
+ if (!openMatch) {
+ return { think: null, answer: text }
+ }
+
+ const openTagIndex = openMatch.index
+ const openTagLength = openMatch[0].length
+
+ const answerPartBeforeThink = text.substring(0, openTagIndex).trim()
+ const contentAfterOpenTag = text.substring(openTagIndex + openTagLength)
+
+ const closeMatch = contentAfterOpenTag.match(closeTagRe)
+
+ let thinkInner = null
+ let finalAnswer = answerPartBeforeThink
+
+ if (closeMatch) {
+ thinkInner = contentAfterOpenTag.substring(0, closeMatch.index).trim()
+ finalAnswer += contentAfterOpenTag.substring(closeMatch.index + closeMatch[0].length)
+ } else {
+ thinkInner = contentAfterOpenTag.trim()
+ }
+
+ return { think: thinkInner || null, answer: finalAnswer.trim() }
+}
diff --git a/src/modelPicker.js b/src/modelPicker.js
new file mode 100644
index 0000000..fee88ec
--- /dev/null
+++ b/src/modelPicker.js
@@ -0,0 +1,13 @@
+export function buildModelPickerOptions(values, currentValue, missingLabel) {
+ const uniqueValues = [...new Set((Array.isArray(values) ? values : []).filter(Boolean))]
+ const options = uniqueValues.map(value => ({ value, label: value }))
+
+ if (currentValue && !uniqueValues.includes(currentValue)) {
+ options.unshift({
+ value: currentValue,
+ label: `${currentValue} (${missingLabel})`,
+ })
+ }
+
+ return options
+}