feat: implement text chat functionality with model management and file attachments support

This commit is contained in:
techjarves committed 2026-06-15 09:09:01 +05:30
1 parent ed79db3032
commit ed154d66d4
4 files changed
+260 -26

No files matched your search

+8 -4
View File
@@ -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({
)}
</span>
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
{model.format} • approx. {model.approxSize} {model.resolution !== "N/A" && `• ${model.resolution}`}
{model.format} {model.resolution && model.resolution !== "N/A" && `• ${model.resolution}`}
</span>
{model.size && model.size !== "Unknown" && (
{model.size && model.size !== "Unknown" ? (
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-on-surface-variant)", fontWeight: 600 }}>
File size: {model.size}
</span>
)}
) : model.approxSize ? (
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-on-surface-variant)", fontWeight: 600 }}>
File size: approx. {model.approxSize}
</span>
) : null}
</div>
<p style={{ fontSize: "0.78rem", color: "var(--md-sys-color-on-surface-variant)", lineHeight: 1.35, margin: 0 }}>
{model.notes}
+238 -16
View File
@@ -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({
</div>
</div>
<div className="chat-messages">
<div ref={chatMessagesRef} className="chat-messages">
{loadingModel ? (
<div className="chat-empty" style={{ maxWidth: "480px", margin: "auto", textAlign: "center", padding: "60px 20px" }}>
<LoaderCircle className="progress-spinner" size={48} style={{ color: "var(--md-sys-color-primary)", marginBottom: "16px" }} />
@@ -575,7 +632,7 @@ function TextChat({
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{message.content.map((item, idx) => {
if (item.type === "text") {
return <span key={idx} style={{ whiteSpace: "pre-wrap" }}>{item.text}</span>;
return <MarkdownRenderer key={idx} content={item.text} />;
}
if (item.type === "image_url") {
return (
@@ -591,7 +648,7 @@ function TextChat({
})}
</div>
) : (
<span style={{ whiteSpace: "pre-wrap" }}>{message.content}</span>
<MarkdownRenderer content={message.content} />
)}
</div>
{message.role === "assistant" && message.generationStats && !message.error && (
@@ -700,14 +757,24 @@ function TextChat({
disabled={!status.ready || isBusy}
style={{ flex: 1 }}
/>
<button
className="m3-btn m3-btn-filled"
onClick={sendMessage}
disabled={(!input.trim() && attachments.length === 0) || !status.ready || isBusy}
style={{ height: "48px" }}
>
<Send size={17} /> Send
</button>
{isBusy && status.ready ? (
<button
className="m3-btn m3-btn-error"
onClick={handleStopGeneration}
style={{ height: "48px", display: "flex", alignItems: "center", gap: "6px" }}
>
<Square size={14} fill="currentColor" /> Stop
</button>
) : (
<button
className="m3-btn m3-btn-filled"
onClick={sendMessage}
disabled={(!input.trim() && attachments.length === 0) || !status.ready}
style={{ height: "48px" }}
>
<Send size={17} /> Send
</button>
)}
</div>
</div>
</section>
@@ -715,4 +782,159 @@ function TextChat({
);
}
function parseInlineMarkdown(text) {
const regex = /(\*\*.*?\*\*|`.*?`|\*.*?\*|\[.*?\]\(.*?\))/g;
const parts = text.split(regex);
return parts.map((part, idx) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={idx} style={{ fontWeight: 700 }}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("`") && part.endsWith("`")) {
return <code key={idx} style={{
fontFamily: "monospace",
background: "var(--md-sys-color-surface-variant)",
padding: "2px 4px",
borderRadius: "4px",
fontSize: "0.85rem"
}}>{part.slice(1, -1)}</code>;
}
if (part.startsWith("*") && part.endsWith("*")) {
return <em key={idx} style={{ fontStyle: "italic" }}>{part.slice(1, -1)}</em>;
}
if (part.startsWith("[") && part.includes("](") && part.endsWith(")")) {
const match = part.match(/\[(.*?)\]\((.*?)\)/);
if (match) {
return (
<a key={idx} href={match[2]} target="_blank" rel="noopener noreferrer" style={{ color: "var(--md-sys-color-primary)", textDecoration: "underline" }}>
{match[1]}
</a>
);
}
}
return part;
});
}
export function MarkdownRenderer({ content }) {
if (typeof content !== 'string') return null;
const parts = content.split(/(```[\s\S]*?```)/g);
return (
<div className="markdown-body" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{parts.map((part, index) => {
if (part.startsWith("```") && part.endsWith("```")) {
const match = part.match(/```(\w*)\n([\s\S]*?)```/);
const lang = match ? match[1] : "";
const code = match ? match[2] : part.slice(3, -3);
return (
<pre key={index} style={{
background: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface-variant)",
padding: "12px",
borderRadius: "6px",
fontFamily: "monospace",
fontSize: "0.85rem",
overflowX: "auto",
margin: "6px 0",
border: "1px solid var(--border-color)",
whiteSpace: "pre-wrap",
wordBreak: "break-all"
}}>
{lang && <div style={{ fontSize: "0.72rem", color: "var(--md-sys-color-outline)", marginBottom: "4px", textTransform: "uppercase", fontWeight: 600 }}>{lang}</div>}
<code>{code.trim()}</code>
</pre>
);
} 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 <h4 key={blockIdx} style={{ fontSize: "1.05rem", fontWeight: 700, margin: "10px 0 4px 0", color: "var(--md-sys-color-primary)" }}>{parseInlineMarkdown(block.content)}</h4>;
case "h3":
return <h3 key={blockIdx} style={{ fontSize: "1.2rem", fontWeight: 700, margin: "14px 0 6px 0", color: "var(--md-sys-color-primary)" }}>{parseInlineMarkdown(block.content)}</h3>;
case "h2":
return <h2 key={blockIdx} style={{ fontSize: "1.35rem", fontWeight: 700, margin: "18px 0 8px 0", color: "var(--md-sys-color-primary)" }}>{parseInlineMarkdown(block.content)}</h2>;
case "ul":
return (
<ul key={blockIdx} style={{ margin: "4px 0 4px 20px", padding: 0, listStyleType: "disc", display: "flex", flexDirection: "column", gap: "4px" }}>
{block.items.map((item, itemIdx) => (
<li key={itemIdx} style={{ fontSize: "0.9rem", lineHeight: 1.5 }}>{parseInlineMarkdown(item)}</li>
))}
</ul>
);
case "ol":
return (
<ol key={blockIdx} style={{ margin: "4px 0 4px 20px", padding: 0, listStyleType: "decimal", display: "flex", flexDirection: "column", gap: "4px" }}>
{block.items.map((item, itemIdx) => (
<li key={itemIdx} style={{ fontSize: "0.9rem", lineHeight: 1.5 }}>{parseInlineMarkdown(item)}</li>
))}
</ol>
);
case "spacer":
return <div key={blockIdx} style={{ height: "6px" }} />;
case "p":
return <p key={blockIdx} style={{ margin: "2px 0", fontSize: "0.9rem", lineHeight: 1.5 }}>{parseInlineMarkdown(block.lines.join(" "))}</p>;
default:
return null;
}
});
}
})}
</div>
);
}
export default TextChat;
+8 -4
View File
@@ -376,7 +376,7 @@ export async function chatWithLlm(messages, options = {}) {
}
export async function streamChatWithLlm(messages, options = {}, onToken = () => {}) {
const res = await fetch("/api/llm/chat", {
const fetchOpts = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -385,7 +385,11 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
max_tokens: options.maxTokens,
stream: true,
}),
});
};
if (options.signal) {
fetchOpts.signal = options.signal;
}
const res = await fetch("/api/llm/chat", fetchOpts);
if (!res.ok) {
const data = await readJsonResponse(res, `Chat request failed (HTTP ${res.status}).`);
@@ -441,11 +445,11 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
return { content, usage, timings };
}
export async function downloadLlmModel(url) {
export async function downloadLlmModel(url, filename = null) {
const res = await fetch("/api/llm/download-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
body: JSON.stringify({ url, filename }),
});
return await readJsonResponse(res, "The local server returned an invalid text download response.");
}
+6 -2
View File
@@ -1848,7 +1848,7 @@ async function searchHuggingFaceModels(query, filters) {
models.push({
id: model.id,
name: String(model.id || "").split("/").pop().replace(/[-_]+/g, " "),
filename: path.basename(filename),
filename: `${model.id.replace(/\//g, "--")}--${path.basename(filename)}`,
repositoryFilename: filename,
format: "GGUF",
approxSize: traits.parameters ? `~${traits.parameters}B parameters` : "Size shown before download",
@@ -2202,6 +2202,9 @@ async function startLlm(settings = {}) {
"--ctx-size", String(llmSettings.contextSize),
"--threads", String(llmSettings.threads),
"--n-gpu-layers", String(llmSettings.gpuLayers),
"--parallel", "1",
"--cache-ram", "0",
"--no-warmup",
];
if (mmprojPath) {
args.push("--mmproj", mmprojPath);
@@ -3604,7 +3607,8 @@ async function doLlmChat(req, res, body, retryCount = 0) {
if (downloadState.active) return json(res, 409, { ok: false, error: "Another model download is already active." });
try {
const directUrl = url.includes("huggingface.co") ? url.replace("/blob/", "/resolve/") : url;
const filename = path.basename(new URL(directUrl).pathname);
const overrideFilename = body.filename ? path.basename(String(body.filename)) : null;
const filename = overrideFilename || path.basename(new URL(directUrl).pathname);
if (!filename.toLowerCase().endsWith(".gguf")) {
return json(res, 400, { ok: false, error: "Text model URL must point to a .gguf file." });
}