auto-git:
[add] src/AssistantMessageContent.jsx [add] src/appConfig.js [add] src/attachments.jsx [add] src/chatText.js [add] src/modelPicker.js
This commit is contained in:
80
src/AssistantMessageContent.jsx
Normal file
80
src/AssistantMessageContent.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/appConfig.js
Normal file
24
src/appConfig.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
203
src/attachments.jsx
Normal file
203
src/attachments.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
src/chatText.js
Normal file
124
src/chatText.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
export 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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <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
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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) {
|
||||||
|
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() }
|
||||||
|
}
|
||||||
13
src/modelPicker.js
Normal file
13
src/modelPicker.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user