diff --git a/app/frontend/src/App.css b/app/frontend/src/App.css index 55df099..ab7dcd1 100644 --- a/app/frontend/src/App.css +++ b/app/frontend/src/App.css @@ -281,72 +281,268 @@ body { border: 1px solid color-mix(in srgb, var(--md-sys-color-success) 45%, transparent); } +/* ─── Chat Messages Container ─────────────────────────────────── */ .chat-messages { flex: 1; - overflow: auto; - padding: 20px; + overflow-y: auto; + overflow-x: hidden; + padding: 28px 24px 12px; display: flex; flex-direction: column; - gap: 14px; + gap: 0; + scroll-behavior: smooth; } +.chat-messages::-webkit-scrollbar { + width: 5px; +} +.chat-messages::-webkit-scrollbar-track { + background: transparent; +} +.chat-messages::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--md-sys-color-outline) 35%, transparent); + border-radius: 99px; +} + +/* ─── Empty State ─────────────────────────────────────────────── */ .chat-empty { margin: auto; - max-width: 440px; + max-width: 460px; text-align: center; color: var(--md-sys-color-on-surface-variant); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 40px 24px; +} + +.chat-empty-icon { + width: 68px; + height: 68px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, + color-mix(in srgb, var(--md-sys-color-primary) 18%, transparent), + color-mix(in srgb, var(--md-sys-color-tertiary, #7c3aed) 12%, transparent)); + border: 1.5px solid color-mix(in srgb, var(--md-sys-color-primary) 25%, transparent); + color: var(--md-sys-color-primary); + animation: emptyPulse 3s ease-in-out infinite; +} + +@keyframes emptyPulse { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--md-sys-color-primary) 25%, transparent); } + 50% { box-shadow: 0 0 0 12px color-mix(in srgb, var(--md-sys-color-primary) 0%, transparent); } } .chat-empty h3 { - margin-top: 10px; + margin: 0; + font-size: 1.15rem; + font-weight: 700; color: var(--md-sys-color-on-surface); + letter-spacing: -0.01em; } -.chat-message { - align-self: flex-start; - max-width: min(780px, 88%); - padding: 12px 14px; - border-radius: 4px var(--md-shape-corner-large) var(--md-shape-corner-large) var(--md-shape-corner-large); +.chat-empty p { + margin: 0; + font-size: 0.875rem; + line-height: 1.6; + max-width: 340px; +} + +.chat-suggestions { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: 380px; + margin-top: 8px; +} + +.chat-suggestion-chip { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: var(--md-shape-corner-medium); + border: 1px solid var(--border-color); background: var(--md-sys-color-surface-variant); - line-height: 1.55; - white-space: pre-wrap; + color: var(--md-sys-color-on-surface-variant); + font-size: 0.82rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: all 0.18s ease; + line-height: 1.4; +} + +.chat-suggestion-chip:hover { + border-color: var(--md-sys-color-primary); + background: color-mix(in srgb, var(--md-sys-color-primary) 8%, var(--md-sys-color-surface-variant)); + color: var(--md-sys-color-on-surface); + transform: translateX(3px); +} + +/* ─── Message Row (avatar + bubble) ──────────────────────────── */ +.chat-message-row { + display: flex; + align-items: flex-end; + gap: 10px; + margin-bottom: 6px; + animation: msgSlideIn 0.22s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes msgSlideIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.chat-message-row.user { + flex-direction: row-reverse; +} + +.chat-message-row + .chat-message-row { + margin-top: 2px; +} + +/* Group spacing: bigger gap between user→ai switches */ +.chat-message-row.ai + .chat-message-row.user, +.chat-message-row.user + .chat-message-row.ai { + margin-top: 16px; +} + +/* ─── Avatar Chip ─────────────────────────────────────────────── */ +.chat-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.02em; + user-select: none; +} + +.chat-avatar.ai { + background: linear-gradient(135deg, var(--md-sys-color-primary), color-mix(in srgb, var(--md-sys-color-primary) 70%, #7c3aed)); + color: var(--md-sys-color-on-primary); + box-shadow: 0 2px 8px color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent); +} + +.chat-avatar.user { + background: color-mix(in srgb, var(--md-sys-color-primary-container) 80%, var(--md-sys-color-surface-variant)); + color: var(--md-sys-color-on-primary-container); + border: 1.5px solid color-mix(in srgb, var(--md-sys-color-primary) 30%, transparent); +} + +/* ─── Bubble ──────────────────────────────────────────────────── */ +.chat-bubble-wrap { + display: flex; + flex-direction: column; + max-width: min(720px, 84%); + gap: 4px; +} + +.chat-message-row.user .chat-bubble-wrap { + align-items: flex-end; +} + +.chat-message-row.ai .chat-bubble-wrap { + align-items: flex-start; +} + +.chat-bubble { + padding: 12px 16px; + border-radius: 18px; + line-height: 1.6; overflow-wrap: anywhere; + position: relative; + font-size: 0.9rem; } -.chat-message.user { - align-self: flex-end; - border-radius: var(--md-shape-corner-large) 4px var(--md-shape-corner-large) var(--md-shape-corner-large); - background: var(--md-sys-color-primary-container); +/* AI bubble */ +.chat-message-row.ai .chat-bubble { + background: var(--md-sys-color-surface-variant); + color: var(--md-sys-color-on-surface); + border: 1px solid var(--border-color); + border-bottom-left-radius: 4px; } -.chat-message.error { +/* User bubble */ +.chat-message-row.user .chat-bubble { + background: linear-gradient(135deg, + var(--md-sys-color-primary), + color-mix(in srgb, var(--md-sys-color-primary) 80%, #7c3aed 20%)); + color: var(--md-sys-color-on-primary); + border: none; + border-bottom-right-radius: 4px; + box-shadow: 0 2px 12px color-mix(in srgb, var(--md-sys-color-primary) 30%, transparent); +} + +.chat-bubble.error { + background: color-mix(in srgb, var(--md-sys-color-error) 12%, var(--md-sys-color-surface-variant)); + border-color: color-mix(in srgb, var(--md-sys-color-error) 35%, transparent); color: var(--md-sys-color-error); } -.chat-message strong { - display: block; - margin-bottom: 5px; - color: var(--md-sys-color-primary); - font-size: 0.78rem; +/* Sender label */ +.chat-sender-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--md-sys-color-outline); + padding: 0 2px; } +/* ─── Generation Stats ────────────────────────────────────────── */ .chat-generation-stats { - display: flex; + display: inline-flex; align-items: center; gap: 6px; - margin-top: 9px; - padding-top: 7px; - border-top: 1px solid color-mix(in srgb, var(--md-sys-color-outline) 22%, transparent); + margin-top: 4px; + padding: 3px 10px; + border-radius: 99px; + background: color-mix(in srgb, var(--md-sys-color-surface-variant) 70%, transparent); + border: 1px solid var(--border-color); color: var(--md-sys-color-on-surface-variant); - font-size: 0.72rem; + font-size: 0.7rem; font-weight: 600; - line-height: 1.2; + backdrop-filter: blur(4px); + line-height: 1.4; +} + +.chat-generation-stats.streaming { + color: var(--md-sys-color-primary); + border-color: color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent); + background: color-mix(in srgb, var(--md-sys-color-primary) 8%, var(--md-sys-color-surface-variant)); + animation: statsGlow 1.5s ease-in-out infinite; +} + +@keyframes statsGlow { + 0%, 100% { box-shadow: none; } + 50% { box-shadow: 0 0 8px color-mix(in srgb, var(--md-sys-color-primary) 30%, transparent); } } .chat-generation-stats.complete { color: var(--md-sys-color-primary); } +.chat-generation-stats.starting { + animation: statsBlink 1.1s ease-in-out infinite; +} + +@keyframes statsBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +/* ─── Thinking / Progress ─────────────────────────────────────── */ .chat-thinking, .text-progress { display: flex; @@ -360,11 +556,31 @@ body { color: var(--md-sys-color-error); } +/* ─── Composer ────────────────────────────────────────────────── */ .chat-composer { display: flex; - gap: 10px; - padding: 14px; + flex-direction: column; + gap: 0; + padding: 12px 20px 16px; border-top: 1px solid var(--border-color); + background: color-mix(in srgb, var(--md-sys-color-surface) 80%, transparent); + backdrop-filter: blur(12px); +} + +.chat-composer-inner { + display: flex; + align-items: flex-end; + gap: 10px; + background: var(--md-sys-color-surface-variant); + border: 1.5px solid var(--border-color); + border-radius: 20px; + padding: 6px 6px 6px 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.chat-composer-inner:focus-within { + border-color: var(--md-sys-color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent); } .chat-composer textarea, @@ -381,12 +597,125 @@ body { outline: none; } +.chat-composer-textarea { + flex: 1; + border: none !important; + background: transparent !important; + color: var(--md-sys-color-on-surface); + padding: 8px 0; + font: inherit; + font-size: 0.92rem; + outline: none; + resize: none; + min-height: 36px; + max-height: 160px; + line-height: 1.55; + overflow-y: auto; +} + +.chat-composer-textarea::placeholder { + color: var(--md-sys-color-outline); + font-weight: 400; +} + .chat-composer textarea { min-height: 48px; max-height: 160px; resize: vertical; } +.chat-composer-send-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.18s ease; + background: linear-gradient(135deg, var(--md-sys-color-primary), color-mix(in srgb, var(--md-sys-color-primary) 75%, #7c3aed)); + color: var(--md-sys-color-on-primary); + box-shadow: 0 2px 8px color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent); +} + +.chat-composer-send-btn:hover:not(:disabled) { + transform: scale(1.08); + box-shadow: 0 4px 16px color-mix(in srgb, var(--md-sys-color-primary) 50%, transparent); +} + +.chat-composer-send-btn:active:not(:disabled) { + transform: scale(0.95); +} + +.chat-composer-send-btn:disabled { + background: var(--md-sys-color-surface-variant); + color: var(--md-sys-color-outline); + box-shadow: none; + cursor: not-allowed; +} + +.chat-composer-stop-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.18s ease; + background: color-mix(in srgb, var(--md-sys-color-error) 15%, var(--md-sys-color-surface-variant)); + color: var(--md-sys-color-error); + border: 1.5px solid color-mix(in srgb, var(--md-sys-color-error) 40%, transparent); + animation: stopPulse 1.5s ease-in-out infinite; +} + +@keyframes stopPulse { + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--md-sys-color-error) 30%, transparent); } + 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--md-sys-color-error) 0%, transparent); } +} + +.chat-composer-stop-btn:hover { + background: color-mix(in srgb, var(--md-sys-color-error) 22%, var(--md-sys-color-surface-variant)); + transform: scale(1.08); +} + +.chat-composer-attach-btn { + width: 34px; + height: 34px; + border-radius: 50%; + border: none; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.18s ease; + background: transparent; + color: var(--md-sys-color-outline); +} + +.chat-composer-attach-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent); + color: var(--md-sys-color-primary); +} + +.chat-composer-attach-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.chat-composer-hint { + text-align: center; + font-size: 0.7rem; + color: var(--md-sys-color-outline); + margin-top: 6px; + opacity: 0.7; +} + .text-chat-settings { display: flex; flex-direction: column; diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx index abf5a43..4e6fee1 100644 --- a/app/frontend/src/components/TextChat.jsx +++ b/app/frontend/src/components/TextChat.jsx @@ -1,940 +1,889 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X } from "lucide-react"; -import { - getDownloadProgress, - getLlmStatus, - listLlmModels, - streamChatWithLlm, - startLlm, - stopLlm, -} from "../services/api"; - -function TextChat({ - specs, - showAlert, - showConfirm, - textSettings, - setTextSettings, - setActiveModel, - setServerRunning, - conversations, - setConversations, - activeConversationId, - setActiveConversationId, - showHistory, - setShowHistory, - saveConversationState -}) { - const formatGenerationTime = (seconds) => { - const value = Number(seconds) || 0; - if (value < 1) return `${Math.round(value * 1000)} ms`; - return `${value.toFixed(value < 10 ? 2 : 1)} s`; - }; - - const [models, setModels] = useState([]); - const [status, setStatus] = useState({ ready: false, running: false, settings: {} }); - const [selectedModel, setSelectedModel] = useState(""); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [isBusy, setIsBusy] = useState(false); - const [loadingModel, setLoadingModel] = useState(null); - const [tokenUsage, setTokenUsage] = useState({ - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0 - }); - - const bottomRef = useRef(null); - const completedDownloadRef = useRef(""); - const loadingModelRef = useRef(null); - const chatMessagesRef = useRef(null); - const prevMessagesLengthRef = useRef(0); - const abortControllerRef = useRef(null); - - const [attachments, setAttachments] = useState([]); - const fileInputRef = useRef(null); - const supportsVision = Boolean(status.ready && status.settings?.supportsVision); - - const isImage = (file) => { - return /\.(jpe?g|png|webp)$/i.test(file.name) || file.type.startsWith("image/"); - }; - - const isTextFile = (file) => { - return /\.(txt|md|csv|js|jsx|ts|tsx|py|json|css|html|java|cpp|c|h|rs|go|sh|bat|xml|yaml|yml)$/i.test(file.name) || file.type.startsWith("text/"); - }; - - const handleFileChange = (e) => { - const files = Array.from(e.target.files); - if (!files || files.length === 0) return; - - files.forEach((file) => { - if (isImage(file)) { - const reader = new FileReader(); - reader.onload = (event) => { - setAttachments((prev) => [ - ...prev, - { - id: Math.random().toString(36).substring(7), - file, - type: "image", - name: file.name, - dataUrl: event.target.result, - }, - ]); - }; - reader.readAsDataURL(file); - } else if (isTextFile(file)) { - const reader = new FileReader(); - reader.onload = (event) => { - setAttachments((prev) => [ - ...prev, - { - id: Math.random().toString(36).substring(7), - file, - type: "document", - name: file.name, - content: event.target.result, - }, - ]); - }; - reader.readAsText(file); - } else { - showAlert({ - title: "Unsupported File", - message: `File "${file.name}" is not supported. Please select an image (JPEG, PNG, WEBP) or a text/code file.`, - danger: true, - }); - } - }); - - e.target.value = ""; - }; - - useEffect(() => { - loadingModelRef.current = loadingModel; - }, [loadingModel]); - - useEffect(() => { - if (!supportsVision) { - setAttachments((current) => current.filter((attachment) => attachment.type !== "image")); - } - }, [supportsVision]); - - // Load conversation messages when activeConversationId changes - useEffect(() => { - if (isBusy) return; - if (activeConversationId) { - const conv = conversations.find(c => c.id === activeConversationId); - if (conv) { - setMessages(conv.messages); - if (conv.model && models.some(m => m.filename === conv.model)) { - setSelectedModel(conv.model); - } - const total = conv.messages.reduce((sum, m) => { - const text = Array.isArray(m.content) - ? m.content.map(c => c.text || "").join(" ") - : (m.content || ""); - return sum + text.split(/\s+/).length; - }, 0); - setTokenUsage({ - prompt_tokens: Math.round(total * 0.7), - completion_tokens: Math.round(total * 0.3), - total_tokens: total - }); - } - } else { - setMessages([]); - setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); - } - }, [activeConversationId, conversations, models, isBusy]); - - const refresh = useCallback(async () => { - const [nextModels, nextStatus] = await Promise.all([listLlmModels(), getLlmStatus()]); - setModels(nextModels); - setStatus(nextStatus); - const active = nextStatus.settings?.model; - setSelectedModel((current) => { - const saved = localStorage.getItem("selectedLlmModel"); - const savedExists = nextModels.some((m) => m.filename === saved); - return active || current || (savedExists ? saved : "") || nextModels[0]?.filename || ""; - }); - }, []); - - useEffect(() => { - refresh().catch(() => {}); - const timer = setInterval(() => { - getLlmStatus().then((nextStatus) => { - setStatus(nextStatus); - // If it suddenly loaded or became ready externally, update selection and reset loading states - if (nextStatus.ready && nextStatus.settings?.model) { - setSelectedModel(nextStatus.settings.model); - setLoadingModel(null); - } - }).catch(() => {}); - getDownloadProgress().then((state) => { - if (state.kind === "text" && (state.active || state.error || state.progress === 100)) { - const completionKey = `${state.filename || ""}:${state.downloadedBytes || 0}`; - if (!state.active && !state.error && completedDownloadRef.current !== completionKey) { - completedDownloadRef.current = completionKey; - refresh().catch(() => {}); - } - } - }).catch(() => {}); - }, 1500); - return () => clearInterval(timer); - }, [refresh]); - - const handleStopGeneration = () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }; - - useEffect(() => { - const container = chatMessagesRef.current; - if (!container) return; - - const len = messages.length; - const prevLen = prevMessagesLengthRef.current; - prevMessagesLengthRef.current = len; - - const lastMessage = messages[len - 1]; - const isNewUserMessage = len > prevLen && lastMessage?.role === "user"; - - if (isNewUserMessage) { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - } else { - const threshold = 150; - const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; - if (isNearBottom) { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - } - } - }, [messages, isBusy, loadingModel]); - - const handleModelChange = async (filename) => { - if (!filename) { - if (status.ready) { - setIsBusy(true); - try { - await stopLlm(); - setStatus((prev) => ({ ...prev, ready: false, running: false })); - setSelectedModel(""); - setMessages([]); - setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); - } catch (err) { - showAlert({ title: "Unload Failed", message: err.message || String(err), danger: true }); - } finally { - setIsBusy(false); - } - } - return; - } - - setSelectedModel(filename); - localStorage.setItem("selectedLlmModel", filename); - setIsBusy(true); - setLoadingModel(filename); - try { - // Unload active image engine if running - if (setActiveModel) setActiveModel(null); - if (setServerRunning) setServerRunning(false); - - const result = await startLlm(filename, { - threads: textSettings?.threads || specs?.cpu_cores_physical || 4, - contextSize: textSettings?.contextSize || 4096, - gpuLayers: -1, - }); - setStatus({ ...status, ...result, ready: true, running: true, settings: result.settings }); - setMessages([]); - setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); - } catch (err) { - if (loadingModelRef.current === filename) { - showAlert({ title: "Text Model Load Failed", message: err.message, danger: true }); - } - } finally { - setLoadingModel(null); - setIsBusy(false); - } - }; - - const handleCancelLlmLoad = async () => { - try { - await stopLlm(); - } catch (_) {} - setLoadingModel(null); - setIsBusy(false); - setSelectedModel(""); - }; - - const handleNewChat = () => { - setActiveConversationId(null); - }; - - const sendMessage = async () => { - const text = input.trim(); - const hasAttachments = attachments.length > 0; - if ((!text && !hasAttachments) || isBusy || !status.ready) return; - - let convId = activeConversationId; - let isNew = false; - if (!convId) { - convId = "chat_" + Date.now(); - setActiveConversationId(convId); - isNew = true; - } - - // Delimited context blocks for document attachments - let documentContext = ""; - attachments.filter(att => att.type === "document").forEach((att) => { - documentContext += `\n[Attached File: ${att.name}]\n${att.content}\n`; - }); - - const combinedText = documentContext ? `${text}\n\n${documentContext}`.trim() : text; - const imageAttachments = attachments.filter(att => att.type === "image"); - - let userMessageContent; - if (imageAttachments.length > 0) { - userMessageContent = [ - { - type: "text", - text: combinedText - }, - ...imageAttachments.map((img) => ({ - type: "image_url", - image_url: { - url: img.dataUrl - } - })) - ]; - } else { - userMessageContent = combinedText; - } - - const nextMessages = [...messages, { role: "user", content: userMessageContent }]; - setMessages(nextMessages); - setInput(""); - setIsBusy(true); - - const displayTitleText = text || (imageAttachments.length > 0 ? "Sent Image" : "Sent File"); - const firstTitle = isNew ? (displayTitleText.slice(0, 26) + (displayTitleText.length > 26 ? "..." : "")) : null; - saveConversationState(convId, nextMessages, selectedModel, firstTitle); - - const requestStartedAt = performance.now(); - setMessages([...nextMessages, { - role: "assistant", - content: "", - generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0 }, - }]); - - const controller = new AbortController(); - abortControllerRef.current = controller; - - try { - const systemPrompt = textSettings?.systemPrompt || "You are a helpful local AI assistant."; - const requestMessages = [ - ...(systemPrompt.trim() ? [{ role: "system", content: systemPrompt.trim() }] : []), - ...nextMessages, - ]; - - let assistantText = ""; - let streamedTokens = 0; - let firstTokenAt = null; - - const response = await streamChatWithLlm(requestMessages, { - temperature: textSettings?.temperature || 0.7, - maxTokens: 768, - signal: controller.signal, - }, (_token, fullText) => { - const now = performance.now(); - if (streamedTokens === 0) { - firstTokenAt = now; - } - streamedTokens += 1; - const generationSeconds = firstTokenAt - ? Math.max(0.05, (now - firstTokenAt) / 1000) - : Math.max(0.05, (now - requestStartedAt) / 1000); - assistantText = fullText; - setMessages((prev) => { - const updated = [...prev]; - if (updated.length > 0 && updated[updated.length - 1].role === "assistant") { - updated[updated.length - 1] = { - ...updated[updated.length - 1], - content: fullText, - generationStats: { - status: "streaming", - tokens: streamedTokens, - tokensPerSecond: streamedTokens / generationSeconds, - seconds: (now - requestStartedAt) / 1000, - }, - }; - } - return updated; - }); - }); - - const completedAt = performance.now(); - const exactTokens = Number(response.timings?.predicted_n) || streamedTokens; - const backendTotalMs = Number(response.timings?.prompt_ms || 0) + Number(response.timings?.predicted_ms || 0); - const exactSeconds = backendTotalMs > 0 - ? backendTotalMs / 1000 - : (completedAt - requestStartedAt) / 1000; - const exactTokensPerSecond = Number(response.timings?.predicted_per_second) - || (exactTokens / Math.max(0.001, exactSeconds)); - const generationStats = { - status: "complete", - tokens: exactTokens, - tokensPerSecond: exactTokensPerSecond, - seconds: exactSeconds, - }; - const finalMessages = [...nextMessages, { - role: "assistant", - content: assistantText, - generationStats, - }]; - setMessages(finalMessages); - saveConversationState(convId, finalMessages, selectedModel); - if (response.usage) setTokenUsage(response.usage); - - // Clean up attached files on success - setAttachments([]); - } catch (err) { - if (err.name === "AbortError") { - setMessages((prev) => { - const updated = [...prev]; - if (updated.length > 0 && updated[updated.length - 1].role === "assistant") { - const lastMsg = updated[updated.length - 1]; - updated[updated.length - 1] = { - ...lastMsg, - generationStats: lastMsg.generationStats ? { - ...lastMsg.generationStats, - status: "complete", - } : null - }; - saveConversationState(convId, updated, selectedModel); - } - return updated; - }); - } else { - const finalMessages = [...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }]; - setMessages(finalMessages); - saveConversationState(convId, finalMessages, selectedModel); - } - } finally { - setIsBusy(false); - abortControllerRef.current = null; - } - }; - - const handleClearChat = () => { - setMessages([]); - setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); - if (activeConversationId) { - saveConversationState(activeConversationId, [], selectedModel); - } - }; - - return ( -
- {loadingModel}
-
- - Initializing llama.cpp server and loading the model weights into memory. This can take up to 30 seconds depending on model size and hardware speed. -
- -Choose a GGUF text model above to load it. Your conversation history stays completely private on this machine.
-{part.slice(1, -1)};
- }
- if (part.startsWith("*") && part.endsWith("*")) {
- return {part.slice(1, -1)};
- }
- if (part.startsWith("[") && part.includes("](") && part.endsWith(")")) {
- const match = part.match(/\[(.*?)\]\((.*?)\)/);
- if (match) {
- return (
-
- {match[1]}
-
- );
- }
- }
- return part;
- });
-}
-
-export function MarkdownRenderer({ content }) {
- if (typeof content !== 'string') return null;
-
- const parts = content.split(/(```[\s\S]*?```)/g);
-
- return (
-
- {lang && {lang}}
- {code.trim()}
-
- );
- } else {
- const rawLines = part.split(/\r?\n/);
- const blocks = [];
- let currentBlock = null;
-
- for (let i = 0; i < rawLines.length; i++) {
- const line = rawLines[i];
- const trimmed = line.trim();
-
- if (trimmed.startsWith("### ")) {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "h4", content: trimmed.slice(4) });
- } else if (trimmed.startsWith("## ")) {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "h3", content: trimmed.slice(3) });
- } else if (trimmed.startsWith("# ")) {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "h2", content: trimmed.slice(2) });
- } else if (trimmed.startsWith("* ") || trimmed.startsWith("- ")) {
- const itemContent = trimmed.slice(2);
- if (currentBlock && currentBlock.type === "ul") {
- currentBlock.items.push(itemContent);
- } else {
- if (currentBlock) { blocks.push(currentBlock); }
- currentBlock = { type: "ul", items: [itemContent] };
- }
- } else {
- const numMatch = trimmed.match(/^(\d+)\.\s+(.*)/);
- if (numMatch) {
- const itemContent = numMatch[2];
- if (currentBlock && currentBlock.type === "ol") {
- currentBlock.items.push(itemContent);
- } else {
- if (currentBlock) { blocks.push(currentBlock); }
- currentBlock = { type: "ol", items: [itemContent] };
- }
- } else if (trimmed === "") {
- if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
- blocks.push({ type: "spacer" });
- } else {
- if (currentBlock && currentBlock.type === "p") {
- currentBlock.lines.push(line);
- } else {
- if (currentBlock) { blocks.push(currentBlock); }
- currentBlock = { type: "p", lines: [line] };
- }
- }
- }
- }
- if (currentBlock) {
- blocks.push(currentBlock);
- }
-
- return blocks.map((block, blockIdx) => {
- switch (block.type) {
- case "h4":
- return {parseInlineMarkdown(block.lines.join(" "))}
; - default: - return null; - } - }); - } - })} -
+ {loadingModel}
+
+ + Initializing llama.cpp server and loading the model weights into memory. This can take up to 30 seconds depending on model size and hardware speed. +
+ +Your private, offline AI assistant. Choose a GGUF model above and start a conversation — everything stays on your machine.
+ {status.ready && ( +{part.slice(1, -1)};
+ }
+ if (part.startsWith("*") && part.endsWith("*")) {
+ return {part.slice(1, -1)};
+ }
+ if (part.startsWith("[") && part.includes("](") && part.endsWith(")")) {
+ const match = part.match(/\[(.*?)\]\((.*?)\)/);
+ if (match) {
+ return (
+
+ {match[1]}
+
+ );
+ }
+ }
+ return part;
+ });
+}
+
+export function MarkdownRenderer({ content }) {
+ if (typeof content !== 'string') return null;
+
+ const parts = content.split(/(```[\s\S]*?```)/g);
+
+ return (
+
+ {lang && {lang}}
+ {code.trim()}
+
+ );
+ } else {
+ const rawLines = part.split(/\r?\n/);
+ const blocks = [];
+ let currentBlock = null;
+
+ for (let i = 0; i < rawLines.length; i++) {
+ const line = rawLines[i];
+ const trimmed = line.trim();
+
+ if (trimmed.startsWith("### ")) {
+ if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
+ blocks.push({ type: "h4", content: trimmed.slice(4) });
+ } else if (trimmed.startsWith("## ")) {
+ if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
+ blocks.push({ type: "h3", content: trimmed.slice(3) });
+ } else if (trimmed.startsWith("# ")) {
+ if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
+ blocks.push({ type: "h2", content: trimmed.slice(2) });
+ } else if (trimmed.startsWith("* ") || trimmed.startsWith("- ")) {
+ const itemContent = trimmed.slice(2);
+ if (currentBlock && currentBlock.type === "ul") {
+ currentBlock.items.push(itemContent);
+ } else {
+ if (currentBlock) { blocks.push(currentBlock); }
+ currentBlock = { type: "ul", items: [itemContent] };
+ }
+ } else {
+ const numMatch = trimmed.match(/^(\d+)\.\s+(.*)/);
+ if (numMatch) {
+ const itemContent = numMatch[2];
+ if (currentBlock && currentBlock.type === "ol") {
+ currentBlock.items.push(itemContent);
+ } else {
+ if (currentBlock) { blocks.push(currentBlock); }
+ currentBlock = { type: "ol", items: [itemContent] };
+ }
+ } else if (trimmed === "") {
+ if (currentBlock) { blocks.push(currentBlock); currentBlock = null; }
+ blocks.push({ type: "spacer" });
+ } else {
+ if (currentBlock && currentBlock.type === "p") {
+ currentBlock.lines.push(line);
+ } else {
+ if (currentBlock) { blocks.push(currentBlock); }
+ currentBlock = { type: "p", lines: [line] };
+ }
+ }
+ }
+ }
+ if (currentBlock) {
+ blocks.push(currentBlock);
+ }
+
+ return blocks.map((block, blockIdx) => {
+ switch (block.type) {
+ case "h4":
+ return {parseInlineMarkdown(block.lines.join(" "))}
; + default: + return null; + } + }); + } + })} +