diff --git a/app/frontend/src/components/ModelManager.jsx b/app/frontend/src/components/ModelManager.jsx index 0719bac..3eadc55 100644 --- a/app/frontend/src/components/ModelManager.jsx +++ b/app/frontend/src/components/ModelManager.jsx @@ -456,7 +456,7 @@ function ModelManager({ if (activeModelType === "image") { res = await downloadModel(url); } else { - res = await downloadLlmModel(url); + res = await downloadLlmModel(url, expectedFilename); } if (res && res.ok) { startProgressPolling(expectedFilename); @@ -1241,13 +1241,17 @@ function ModelManager({ )} - {model.format} • approx. {model.approxSize} {model.resolution !== "N/A" && `• ${model.resolution}`} + {model.format} {model.resolution && model.resolution !== "N/A" && `• ${model.resolution}`} - {model.size && model.size !== "Unknown" && ( + {model.size && model.size !== "Unknown" ? ( File size: {model.size} - )} + ) : model.approxSize ? ( + + File size: approx. {model.approxSize} + + ) : null}
{model.notes} diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx index 2627105..59e53f8 100644 --- a/app/frontend/src/components/TextChat.jsx +++ b/app/frontend/src/components/TextChat.jsx @@ -47,6 +47,9 @@ function TextChat({ 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); @@ -181,8 +184,33 @@ function TextChat({ return () => clearInterval(timer); }, [refresh]); + const handleStopGeneration = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; + useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + 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) => { @@ -300,6 +328,9 @@ function TextChat({ 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 = [ @@ -309,14 +340,21 @@ function TextChat({ 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 = Math.max(0.05, (now - requestStartedAt) / 1000); + const generationSeconds = firstTokenAt + ? Math.max(0.05, (now - firstTokenAt) / 1000) + : Math.max(0.05, (now - requestStartedAt) / 1000); assistantText = fullText; setMessages((prev) => { const updated = [...prev]; @@ -362,11 +400,30 @@ function TextChat({ // Clean up attached files on success setAttachments([]); } catch (err) { - const finalMessages = [...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }]; - setMessages(finalMessages); - saveConversationState(convId, finalMessages, selectedModel); + 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; } }; @@ -520,7 +577,7 @@ function TextChat({ -
{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; + } + }); + } + })} +