feat: add TextChat component with support for reasoning models, file attachments, and model management functionality

This commit is contained in:
techjarves committed 2026-06-21 14:23:29 +05:30
1 parent 8fa0a670c1
commit 822e9c9481
5 files changed
+133 -22

No files matched your search

+5 -4
View File
@@ -569,7 +569,7 @@ body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 14px 14px 8px;
padding: 14px 12px 8px;
display: flex;
flex-direction: column;
gap: 0;
@@ -733,18 +733,18 @@ body {
.chat-bubble-wrap {
display: flex;
flex-direction: column;
max-width: min(1180px, 92%);
max-width: min(1440px, calc(100% - 42px));
gap: 5px;
}
.chat-message-row.user .chat-bubble-wrap {
align-items: flex-end;
max-width: min(780px, 70%);
max-width: min(920px, 72%);
}
.chat-message-row.ai .chat-bubble-wrap {
align-items: flex-start;
max-width: min(1280px, 92%);
max-width: min(1520px, calc(100% - 42px));
}
.chat-bubble {
@@ -762,6 +762,7 @@ body {
color: var(--md-sys-color-on-surface);
border: 1px solid var(--border-color);
border-bottom-left-radius: 4px;
max-width: 100%;
}
/* User bubble */
+7 -1
View File
@@ -461,9 +461,15 @@ function ModelManager({
if (activeModelType === "image") {
res = await downloadModel(url);
} else {
res = await downloadLlmModel(url, expectedFilename);
res = await downloadLlmModel(url, expectedFilename, companion);
}
if (res && res.ok) {
if (!companion && res.projectorUrl && res.projectorFilename) {
pendingCompanionDownloadRef.current = {
url: res.projectorUrl,
filename: res.projectorFilename,
};
}
startProgressPolling(expectedFilename);
} else {
pendingCompanionDownloadRef.current = null;
+28 -12
View File
@@ -17,9 +17,22 @@ const processMessageContent = (rawText, apiReasoning = "", enableThinking = true
return { content, reasoning };
}
const cleanReasoningControlTags = (value) => String(value || "")
.replace(/<\|channel\|>thought/g, "")
.replace(/<\|channel\|>model/g, "")
.replace(/<\|turn\|>model/g, "")
.replace(/<\|im_start\|>model/g, "")
.replace(/<\|think\|>|<\|thought\|>|<thinking>|<thought>/g, "")
.replace(/<\|\/think\|>|<\|\/thought\|>|<\/thinking>|<\/thought>/g, "")
.trim();
const startTags = ["<|channel|>thought", "<|think|>", "<|thought|>", "<thinking>", "<thought>"];
const endTags = ["<|channel|>model", "<|turn>model", "<|im_start|>model", "</thinking>", "</thought>", "<|/think|>", "<|/thought|>"];
if (!enableThinking) {
return { content: cleanReasoningControlTags(rawText), reasoning: "" };
}
let startIdx = -1;
let matchedStartTag = "";
@@ -48,10 +61,7 @@ const processMessageContent = (rawText, apiReasoning = "", enableThinking = true
const actualEndIdxInRaw = startIdx + matchedStartTag.length + endIdx;
const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length, actualEndIdxInRaw).trim();
// Only extract reasoning if thinking is enabled
if (enableThinking) {
reasoning = (reasoning + "\n" + extractedReasoning).trim();
}
reasoning = (reasoning + "\n" + extractedReasoning).trim();
const afterEndText = rawText.substring(actualEndIdxInRaw + matchedEndTag.length);
content = (rawText.substring(0, startIdx) + afterEndText).trim();
@@ -59,10 +69,7 @@ const processMessageContent = (rawText, apiReasoning = "", enableThinking = true
return processMessageContent(content, reasoning, enableThinking);
} else {
const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length).trim();
// Only extract reasoning if thinking is enabled
if (enableThinking) {
reasoning = (reasoning + "\n" + extractedReasoning).trim();
}
reasoning = (reasoning + "\n" + extractedReasoning).trim();
content = rawText.substring(0, startIdx).trim();
}
}
@@ -70,6 +77,11 @@ const processMessageContent = (rawText, apiReasoning = "", enableThinking = true
return { content, reasoning };
};
const messageContainsImage = (message) => (
Array.isArray(message?.content) &&
message.content.some((item) => item?.type === "image_url" && item?.image_url?.url)
);
function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) {
const [isExpanded, setIsExpanded] = useState(false);
@@ -563,8 +575,10 @@ function TextChat({
});
const imageAttachments = attachments.filter(att => att.type === "image");
const visionInstruction = imageAttachments.length > 0
? "For attached images: answer in natural language, describe the actual image content, and read visible text carefully when relevant. Do not output raw JSON, bounding boxes, OCR layout objects, or detection labels unless the user explicitly asks for that format."
const conversationHasImage = messages.some(messageContainsImage);
const requestHasImage = imageAttachments.length > 0 || conversationHasImage;
const visionInstruction = requestHasImage
? "For images in this conversation: answer in natural language, describe the actual image content, and read visible text carefully when relevant. If the user asks a follow-up like \"what is this\" or \"explain\", treat it as referring to the most recent image unless they say otherwise. Do not output raw JSON, arrays, bounding boxes, OCR layout objects, UI class names, or detection labels unless the user explicitly asks for that format."
: "";
const requestText = imageAttachments.length > 0
? (text && text !== "?" ? text : "Describe what is visible in the image.")
@@ -625,7 +639,7 @@ function TextChat({
setMessages([...nextMessages, {
role: "assistant",
content: "",
generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0, vision: imageAttachments.length > 0 },
generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0, vision: requestHasImage },
}]);
const controller = new AbortController();
@@ -654,6 +668,7 @@ function TextChat({
});
let assistantText = "";
let rawAssistantText = "";
let assistantReasoning = "";
let streamedTokens = 0;
let firstTokenAt = null;
@@ -687,6 +702,7 @@ function TextChat({
? Math.max(0.05, (now - firstTokenAt) / 1000)
: Math.max(0.05, (now - requestStartedAt) / 1000);
rawAssistantText = fullText;
const processed = processMessageContent(fullText, fullReasoning, textSettings?.enableThinking !== false);
assistantText = processed.content;
assistantReasoning = processed.reasoning;
@@ -767,7 +783,7 @@ function TextChat({
truncated: response.finishReason === "length",
};
const processed = processMessageContent(assistantText, response.reasoningContent || assistantReasoning, textSettings?.enableThinking !== false);
const processed = processMessageContent(response.content || rawAssistantText || assistantText, response.reasoningContent || assistantReasoning, textSettings?.enableThinking !== false);
const finalMessages = [...nextMessages, {
role: "assistant",
content: processed.content,
+38 -4
View File
@@ -436,6 +436,23 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
let timings = null;
let finishReason = null;
const normalizeTextDelta = (value) => {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === "string") return item;
if (typeof item?.text === "string") return item.text;
if (typeof item?.content === "string") return item.content;
return "";
}).join("");
}
if (value && typeof value === "object") {
if (typeof value.text === "string") return value.text;
if (typeof value.content === "string") return value.content;
}
return "";
};
const consumeEvent = (eventText) => {
try {
const data = eventText
@@ -448,8 +465,20 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
const parsed = JSON.parse(data);
const choice = parsed.choices?.[0];
const token = choice?.delta?.content || "";
const reasoningToken = choice?.delta?.reasoning_content || "";
const token = normalizeTextDelta(
choice?.delta?.content ??
choice?.message?.content ??
choice?.text ??
parsed.content ??
parsed.response
);
const reasoningToken = normalizeTextDelta(
choice?.delta?.reasoning_content ??
choice?.delta?.reasoning ??
choice?.delta?.thinking ??
choice?.message?.reasoning_content ??
parsed.reasoning_content
);
if (choice?.finish_reason) finishReason = choice.finish_reason;
if (token || reasoningToken) {
@@ -487,11 +516,16 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
return { content, reasoningContent, usage, timings, finishReason };
}
export async function downloadLlmModel(url, filename = null) {
export async function downloadLlmModel(url, filename = null, companion = null) {
const res = await fetch("/api/llm/download-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, filename }),
body: JSON.stringify({
url,
filename,
projectorUrl: companion?.url || "",
projectorFilename: companion?.filename || "",
}),
});
return await readJsonResponse(res, "The local server returned an invalid text download response.");
}
+55 -1
View File
@@ -20,6 +20,7 @@ const llmHttpAgent = new http.Agent({
const HF_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const hfModelCache = new Map();
const hfProjectorCache = new Map();
function readPort(value, fallback) {
const port = parseInt(value, 10);
@@ -2139,6 +2140,50 @@ function selectMmprojFile(siblings = [], modelFilename = "") {
return scored[0]?.name || "";
}
function parseHuggingFaceResolveUrl(fileUrl) {
try {
const parsed = new URL(fileUrl);
if (!/^(?:www\.)?huggingface\.co$/i.test(parsed.hostname)) return null;
const parts = parsed.pathname.split("/").filter(Boolean);
const resolveIndex = parts.findIndex((part) => part === "resolve" || part === "blob");
if (resolveIndex < 2 || resolveIndex + 2 >= parts.length) return null;
const repoId = `${decodeURIComponent(parts[0])}/${decodeURIComponent(parts[1])}`;
const revision = decodeURIComponent(parts[resolveIndex + 1] || "main");
const repositoryFilename = parts.slice(resolveIndex + 2).map(decodeURIComponent).join("/");
return { repoId, revision, repositoryFilename };
} catch (_) {
return null;
}
}
async function resolveHuggingFaceProjector(fileUrl, localModelFilename = "") {
const parsed = parseHuggingFaceResolveUrl(fileUrl);
if (!parsed) return null;
const cacheKey = `${parsed.repoId}|${parsed.revision}|${parsed.repositoryFilename}`;
const cached = hfProjectorCache.get(cacheKey);
if (cached && Date.now() - cached.createdAt < HF_MODEL_CACHE_TTL_MS) return cached.projector;
try {
const treeUrl = `https://huggingface.co/api/models/${parsed.repoId}/tree/${encodeURIComponent(parsed.revision)}?recursive=true`;
const tree = await requestHttpsJson(treeUrl);
const projectorRepositoryFilename = selectMmprojFile(tree, parsed.repositoryFilename || localModelFilename);
const projector = projectorRepositoryFilename
? {
url: `https://huggingface.co/${parsed.repoId}/resolve/${encodeURIComponent(parsed.revision)}/${projectorRepositoryFilename.split("/").map(encodeURIComponent).join("/")}`,
filename: `${parsed.repoId.replace(/\//g, "--")}--${path.basename(projectorRepositoryFilename)}`,
repositoryFilename: projectorRepositoryFilename,
}
: null;
hfProjectorCache.set(cacheKey, { createdAt: Date.now(), projector });
return projector;
} catch (err) {
console.warn(` [download] Could not resolve vision projector for ${parsed.repoId}: ${err.message || err}`);
hfProjectorCache.set(cacheKey, { createdAt: Date.now(), projector: null });
return null;
}
}
function classifyHuggingFaceModel(model, filename) {
const searchable = `${model.id || ""} ${filename || ""} ${(model.tags || []).join(" ")}`.toLowerCase();
const parameters = getModelParameterBillions(searchable);
@@ -4340,8 +4385,17 @@ async function getLlmfitRecommendations(useCase = "chat", limit = 10) {
if (!filename.toLowerCase().endsWith(".gguf")) {
return json(res, 400, { ok: false, error: "Text model URL must point to a .gguf file." });
}
const projector = body.projectorUrl && body.projectorFilename
? { url: String(body.projectorUrl), filename: path.basename(String(body.projectorFilename)) }
: await resolveHuggingFaceProjector(directUrl, filename);
startModelDownload(directUrl, filename, LLM_MODELS, "text");
return json(res, 200, { ok: true, message: "Text model download started", filename });
return json(res, 200, {
ok: true,
message: "Text model download started",
filename,
projectorUrl: projector?.url || "",
projectorFilename: projector?.filename || "",
});
} catch (err) {
return json(res, 400, { ok: false, error: err.message || String(err) });
}