- {showThinkButton && (
-
-
- {open && (
-
- )}
-
- )}
-
- {Array.isArray(sources) && sources.length > 0 && (
-
- )}
-
- );
-}
-
-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 && (
-
- )}
-

-
- )
- }
-
- 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)