auto-git:

[change] dist/index.html
 [change] src/App.jsx
This commit is contained in:
2026-05-06 03:28:55 +02:00
parent 5e2b6eb57a
commit b142e58723
2 changed files with 1 additions and 452 deletions

2
dist/index.html vendored
View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LLM Desktop</title>
<script type="module" crossorigin src="/assets/index-dwMvWjUk.js"></script>
<script type="module" crossorigin src="/assets/index-B7swDoGl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-53cjgfdY.css">
</head>
<body>

View File

@@ -49,443 +49,6 @@ import {
supportsAudioInputCapture,
} from './audioInput'
function sanitizeChatTitle(title) {
let cleanedTitle = String(title || '')
.replace(/<think(?:ing)?>[\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 <model>'."
)
}
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 <think> or <thinking> block (first occurrence) and return { think, answer }
function splitThinkBlocks(text) {
if (!text) return { think: null, answer: '' };
const openTagRe = /<think(?:ing)?>/i;
const closeTagRe = /<\/think(?:ing)?>/i;
const openMatch = text.match(openTagRe);
if (!openMatch) {
// No opening <think> 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 (
<div className="assistant-message">
{showThinkButton && (
<div className="assistant-thoughts">
<button
className="think-toggle"
onClick={() => setOpen(o => !o)}
aria-expanded={open ? 'true' : 'false'}
aria-controls="think-content"
>
<span className="think-toggle-icon" aria-hidden="true">
{open ? '▾' : '▸'}
</span>
Thoughts
</button>
{open && (
<div
id="think-content"
className="think-content"
dangerouslySetInnerHTML={{ __html: markdownToHTML(think) }}
/>
)}
</div>
)}
<div
className="msg-content"
dangerouslySetInnerHTML={{ __html: markdownToHTML(answer || displayContent || '') }}
/>
{Array.isArray(sources) && sources.length > 0 && (
<div className="msg-sources chips">
{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 (
<a
key={u + i}
className="chip"
href={u}
target="_blank"
rel="noreferrer"
title={u}
onClick={(event) => {
if (!isFile) return;
event.preventDefault();
try {
const parsed = new URL(u);
window.electronAPI?.openPath?.(decodeURIComponent(parsed.pathname));
} catch {}
}}
>
{label}
</a>
);
})}
</div>
)}
</div>
);
}
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 (
<div className={`attachment-strip ${className}`.trim()}>
{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 (
<div key={key} className="image-attachment-card">
{removable && (
<button
type="button"
className="attachment-remove"
onClick={() => onRemove?.(attachment.id)}
aria-label={`Remove ${getAttachmentDisplayName(attachment, 'image')}`}
title="Remove attachment"
>
×
</button>
)}
<img
className="image-attachment-thumb"
src={src}
alt={getAttachmentDisplayName(attachment, `Attachment ${index + 1}`)}
loading="lazy"
/>
</div>
)
}
const label = getAttachmentDisplayName(attachment, `Attachment ${index + 1}`)
return (
<div
key={key}
className="file-attachment-card"
title={attachment?.source_path || label}
>
{removable && (
<button
type="button"
className="attachment-remove attachment-remove--file"
onClick={() => onRemove?.(attachment.id)}
aria-label={`Remove ${label}`}
title="Remove attachment"
>
×
</button>
)}
<span className="file-attachment-badge">{getFileAttachmentBadge(attachment)}</span>
<span className="file-attachment-name">{label}</span>
</div>
)
})}
</div>
);
}
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)