feat: add TextChat component with support for reasoning models, file attachments, and model management functionality
This commit is contained in:
1 parent
8fa0a670c1
commit
822e9c9481
5 files changed
+133
-22
No files matched your search
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user