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 && ( +
+ {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} + + ) + })} +
+ )} +
+ ) +} 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 && ( + + )} + {getAttachmentDisplayName(attachment, +
+ ) + } + + 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 +}