diff --git a/dist/index.html b/dist/index.html index 981e46a..fc83c1e 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,7 +5,7 @@ LLM Desktop - + diff --git a/src/App.jsx b/src/App.jsx index 19f5a85..bb3fc6f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -49,443 +49,6 @@ import { supportsAudioInputCapture, } from './audioInput' -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}` -} - -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 -} - -// Extract or block (first occurrence) and return { think, answer } -function splitThinkBlocks(text) { - if (!text) return { think: null, answer: '' }; - - const openTagRe = //i; - const closeTagRe = /<\/think(?:ing)?>/i; - - const openMatch = text.match(openTagRe); - - if (!openMatch) { - // No opening tag found, so all content is answer - return { think: null, answer: text }; - } - - const openTagIndex = openMatch.index; - const openTagLength = openMatch[0].length; - - const answerPartBeforeThink = text.substring(0, openTagIndex).trim(); - let contentAfterOpenTag = text.substring(openTagIndex + openTagLength); - - const closeMatch = contentAfterOpenTag.match(closeTagRe); - - let thinkInner = null; - let finalAnswer = answerPartBeforeThink; - - if (closeMatch) { - // Both open and close tags are present - thinkInner = contentAfterOpenTag.substring(0, closeMatch.index).trim(); - finalAnswer += contentAfterOpenTag.substring(closeMatch.index + closeMatch[0].length); - } else { - // Only open tag found (streaming case), take everything after it as think - thinkInner = contentAfterOpenTag.trim(); - } - - return { think: thinkInner || null, answer: finalAnswer.trim() }; -} - -// Renders assistant message with a collapsible "Thoughts" block (if present) -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 && ( -
- {sources.map((u, i) => { - let label = u; - let isFile = false; - try { - const parsed = new URL(u); - if (parsed.protocol === 'file:') { - isFile = true; - const parts = parsed.pathname.split('/').filter(Boolean); - label = decodeURIComponent(parts[parts.length - 1] || u); - } else { - const host = parsed.hostname || u; - label = host.replace(/^www\./i, ''); - } - } catch {} - return ( - { - if (!isFile) return; - event.preventDefault(); - try { - const parsed = new URL(u); - window.electronAPI?.openPath?.(decodeURIComponent(parsed.pathname)); - } catch {} - }} - > - {label} - - ); - })} -
- )} -
- ); -} - -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']) -const CHAT_FILE_PICKER_FILTERS = [ - { name: 'Documents and Media', extensions: CHAT_FILE_EXTENSION_LIST.map(extension => extension.slice(1)) }, -] - -function getFileExtension(value) { - const text = String(value || '').trim() - const match = /(?:\.([A-Za-z0-9]+))$/.exec(text) - return match ? `.${match[1].toLowerCase()}` : '' -} - -function getFileName(value, fallback = 'attachment') { - const text = String(value || '').trim() - if (!text) return fallback - const parts = text.split(/[\\/]/) - return parts[parts.length - 1] || fallback -} - -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 '' - } -} - -function attachmentIsImage(attachment) { - return Boolean(attachment?.data_url) || String(attachment?.kind || '').toLowerCase() === 'image' -} - -function attachmentIsFile(attachment) { - return Boolean(attachment) && !attachmentIsImage(attachment) -} - -function isSupportedChatFilePath(value) { - return CHAT_FILE_EXTENSION_SET.has(getFileExtension(value)) -} - -function isSupportedChatImagePath(value) { - return CHAT_IMAGE_EXTENSION_SET.has(getFileExtension(value)) -} - -function isSupportedChatFile(file) { - if (!file || isImageFile(file)) return false - return isSupportedChatFilePath(file.name || '') -} - -function getAttachmentDisplayName(attachment, fallback = 'attachment') { - return String(attachment?.name || '').trim() || getFileName(attachment?.source_path, fallback) -} - -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' -} - -function buildComposerAttachmentId(prefix = 'attachment') { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` -} - -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, - } -} - -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 && ( - - )} - {getAttachmentDisplayName(attachment, -
- ) - } - - const label = getAttachmentDisplayName(attachment, `Attachment ${index + 1}`) - return ( -
- {removable && ( - - )} - {getFileAttachmentBadge(attachment)} - {label} -
- ) - })} -
- ); -} - -const API_URL_KEY = 'backendApiUrl'; -const COLOR_SCHEME_KEY = 'colorScheme'; -const WEBSEARCH_URL_KEY = 'websearch.searxUrl'; -const WEBSEARCH_ENGINES_KEY = 'websearch.engines'; -const CHAT_LIBRARY_MAP_KEY = 'chat.libraryBySession'; -const DEFAULT_SEARX_URL = 'http://127.0.0.1:8888'; -const MAX_IMAGE_ATTACHMENTS = 6; -const MAX_IMAGE_ATTACHMENT_BYTES = 20 * 1024 * 1024; -const MAX_AUDIO_RECORDING_MS = 5 * 60 * 1000; -const AUDIO_RECORDING_TICK_MS = 200; - -// Initial API value will be set by useEffect after settings are loaded -let API = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'; -const TOP_ALIGN_OFFSET = 48; // match .chat padding + header height for exact top alignment (should be more dynamic depending on header height) -const BOTTOM_EPSILON = 24; // px tolerance for treating as bottom - -function resolveBackendApiUrl(settings) { - return settings.backendApiUrl || settings.ollamaApiUrl || API; -} - -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; -} - -function hasFilePayload(event) { - const types = Array.from(event?.dataTransfer?.types || []); - return types.includes('Files'); -} - -function isImageFile(file) { - if (!file) return false; - if (typeof file.type === 'string' && file.type.toLowerCase().startsWith('image/')) { - return true; - } - return /\.(png|jpe?g|gif|bmp|webp|tiff?|heic|avif)$/i.test(file.name || ''); -} - -function eventHasImageFiles(event) { - const items = Array.from(event?.dataTransfer?.items || []); - if (items.length > 0) { - return items.some(item => item.kind === 'file' && isImageFile(item.getAsFile?.())); - } - const files = Array.from(event?.dataTransfer?.files || []); - return files.some(isImageFile); -} - -function readFileAsDataUrl(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(reader.error || new Error(`Failed to read ${file?.name || 'image'}`)); - reader.onload = () => resolve(String(reader.result || '')); - reader.readAsDataURL(file); - }); -} - function appendTranscriptToComposer(currentInput, transcript) { const nextTranscript = String(transcript || '').trim() if (!nextTranscript) { @@ -508,20 +71,6 @@ function formatRecordingDuration(milliseconds) { return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } -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 -} - export default function App() { const [chatSessions, setChatSessions] = useState([]) const [activeSessionId, setActiveSessionId] = useState(null)