feat: initialize frontend application scaffold with core layout, chat components, and API service
This commit is contained in:
1 parent
ac406f9cd0
commit
0a181873e6
7 files changed
+633
-83
No files matched your search
@@ -2004,6 +2004,201 @@ html[data-theme="dark"] .m3-segment-item:hover:not(.active):not(.disabled) {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.history-item-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--md-sys-color-outline);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--md-transition-duration) var(--md-transition-easing);
|
||||
}
|
||||
|
||||
.gallery-item:hover .gallery-thumb {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.gallery-select-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.app-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(8, 12, 24, 0.64);
|
||||
backdrop-filter: blur(4px) saturate(0.86);
|
||||
}
|
||||
|
||||
.app-dialog {
|
||||
width: min(460px, 100%);
|
||||
background: color-mix(in srgb, var(--md-sys-color-surface) 96%, white 4%);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
border: 1px solid color-mix(in srgb, var(--md-sys-color-outline) 55%, transparent);
|
||||
border-radius: var(--md-shape-corner-large);
|
||||
box-shadow:
|
||||
0 22px 60px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app-dialog-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.app-dialog-message {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.app-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gallery-empty {
|
||||
text-align: center;
|
||||
padding: 36px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.55; transform: scale(1.06); }
|
||||
}
|
||||
|
||||
/* Sidebar specs classes */
|
||||
.sidebar-specs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--md-sys-color-on-background);
|
||||
}
|
||||
|
||||
.sidebar-specs-item {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 2px;
|
||||
color: var(--md-sys-color-on-background);
|
||||
}
|
||||
|
||||
.sidebar-specs-os {
|
||||
color: var(--md-sys-color-primary);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Chat History Sidebar styling */
|
||||
.chat-history-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
opacity: 0.95;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--md-shape-corner-large);
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.history-items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--md-shape-corner-medium);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: rgba(120, 120, 120, 0.1);
|
||||
}
|
||||
|
||||
.history-item.active {
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
border-color: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.history-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.history-item-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.history-item-meta {
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.7;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 2px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.history-item-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -2023,3 +2218,80 @@ html[data-theme="dark"] .m3-segment-item:hover:not(.active):not(.disabled) {
|
||||
color: var(--md-sys-color-error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* Chat Thinking Block (Reasoning Process) */
|
||||
.chat-thinking-container {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-thinking-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: none;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-thinking-header:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.chat-thinking-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-thinking-content {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
opacity: 0.75;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* DeepThink button animations and interactions */
|
||||
.chat-composer-deepthink-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.08) !important;
|
||||
color: var(--md-sys-color-primary) !important;
|
||||
border-color: color-mix(in srgb, var(--md-sys-color-primary) 50%, transparent) !important;
|
||||
}
|
||||
|
||||
.chat-composer-deepthink-btn.active:hover {
|
||||
background: rgba(99, 102, 241, 0.22) !important;
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-anim {
|
||||
animation: spin-slow 8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -118,14 +118,19 @@ function App() {
|
||||
const saved = localStorage.getItem("textSettings");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
const parsed = JSON.parse(saved);
|
||||
return {
|
||||
enableThinking: true,
|
||||
...parsed
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
return {
|
||||
contextSize: 0,
|
||||
temperature: 0.7,
|
||||
systemPrompt: "You are a helpful local AI assistant.",
|
||||
threads: 4
|
||||
threads: 4,
|
||||
enableThinking: true
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "../services/api";
|
||||
|
||||
const MODEL_FILTERS = [
|
||||
{ id: "potato", label: "Potato PC" },
|
||||
{ id: "vision", label: "Vision" },
|
||||
{ id: "uncensored", label: "Uncensored" },
|
||||
];
|
||||
|
||||
@@ -523,6 +523,30 @@ function Settings({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Show Thinking Process toggle */}
|
||||
<div style={{ marginTop: "16px" }}>
|
||||
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontSize: "0.85rem" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={textSettings.enableThinking !== false}
|
||||
onChange={(e) => updateTextSetting("enableThinking", e.target.checked)}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
marginTop: "3px",
|
||||
accentColor: "var(--md-sys-color-primary)",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ color: "var(--md-sys-color-on-surface)" }}>Show Thinking Process</strong>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "2px", lineHeight: 1.35 }}>
|
||||
Enables the collapsible dropdown for models that support reasoning/thinking process (e.g. DeepSeek-R1, Gemma-4-E2B). If turned off, reasoning is hidden and stripped from the conversation view.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Temperature slider */}
|
||||
<div className="m3-slider-group" style={{ marginTop: "16px" }}>
|
||||
<div className="m3-slider-header">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X } from "lucide-react";
|
||||
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
getDownloadProgress,
|
||||
getLlmStatus,
|
||||
@@ -9,6 +9,99 @@ import {
|
||||
stopLlm,
|
||||
} from "../services/api";
|
||||
|
||||
const processMessageContent = (rawText, apiReasoning = "") => {
|
||||
let content = rawText;
|
||||
let reasoning = apiReasoning || "";
|
||||
|
||||
if (typeof rawText !== "string") {
|
||||
return { content, reasoning };
|
||||
}
|
||||
|
||||
const startTags = ["<|channel|>thought", "<|think|>", "<|thought|>", "<think>", "<thought>"];
|
||||
const endTags = ["<|channel|>model", "<|turn>model", "<|im_start|>model", "</think>", "</thought>", "</|think>", "</|thought>"];
|
||||
|
||||
let startIdx = -1;
|
||||
let matchedStartTag = "";
|
||||
|
||||
for (const tag of startTags) {
|
||||
const idx = rawText.indexOf(tag);
|
||||
if (idx !== -1 && (startIdx === -1 || idx < startIdx)) {
|
||||
startIdx = idx;
|
||||
matchedStartTag = tag;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx !== -1) {
|
||||
let endIdx = -1;
|
||||
let matchedEndTag = "";
|
||||
const searchArea = rawText.substring(startIdx + matchedStartTag.length);
|
||||
|
||||
for (const tag of endTags) {
|
||||
const idx = searchArea.indexOf(tag);
|
||||
if (idx !== -1 && (endIdx === -1 || idx < endIdx)) {
|
||||
endIdx = idx;
|
||||
matchedEndTag = tag;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIdx !== -1) {
|
||||
const actualEndIdxInRaw = startIdx + matchedStartTag.length + endIdx;
|
||||
const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length, actualEndIdxInRaw).trim();
|
||||
reasoning = (reasoning + "\n" + extractedReasoning).trim();
|
||||
|
||||
const afterEndText = rawText.substring(actualEndIdxInRaw + matchedEndTag.length);
|
||||
content = rawText.substring(0, startIdx) + afterEndText;
|
||||
|
||||
return processMessageContent(content, reasoning);
|
||||
} else {
|
||||
const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length).trim();
|
||||
reasoning = (reasoning + "\n" + extractedReasoning).trim();
|
||||
content = rawText.substring(0, startIdx);
|
||||
}
|
||||
}
|
||||
|
||||
return { content, reasoning };
|
||||
};
|
||||
|
||||
function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) {
|
||||
const [isExpanded, setIsExpanded] = useState(!isComplete);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [isComplete]);
|
||||
|
||||
if (!reasoning) return null;
|
||||
|
||||
const formattedTime = thinkingDuration > 0 ? ` (${thinkingDuration.toFixed(1)}s)` : "";
|
||||
|
||||
return (
|
||||
<div className="chat-thinking-container">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="chat-thinking-header"
|
||||
>
|
||||
<span className="chat-thinking-title">
|
||||
{isComplete ? `Thought process${formattedTime}` : `Thinking...${formattedTime}`}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="chat-thinking-content">
|
||||
{reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextChat({
|
||||
specs,
|
||||
showAlert,
|
||||
@@ -245,6 +338,7 @@ function TextChat({
|
||||
threads: textSettings?.threads || specs?.cpu_cores_physical || 4,
|
||||
contextSize: textSettings?.contextSize || 4096,
|
||||
gpuLayers: -1,
|
||||
enableThinking: textSettings?.enableThinking !== false,
|
||||
});
|
||||
setStatus({ ...status, ...result, ready: true, running: true, settings: result.settings });
|
||||
setMessages([]);
|
||||
@@ -339,14 +433,18 @@ function TextChat({
|
||||
];
|
||||
|
||||
let assistantText = "";
|
||||
let assistantReasoning = "";
|
||||
let streamedTokens = 0;
|
||||
let firstTokenAt = null;
|
||||
let thinkingStartedAt = null;
|
||||
let thinkingEndedAt = null;
|
||||
let thinkingDuration = 0;
|
||||
|
||||
const response = await streamChatWithLlm(requestMessages, {
|
||||
temperature: textSettings?.temperature || 0.7,
|
||||
maxTokens: 768,
|
||||
signal: controller.signal,
|
||||
}, (_token, fullText) => {
|
||||
}, (_token, fullText, _reasoningToken, fullReasoning) => {
|
||||
const now = performance.now();
|
||||
if (streamedTokens === 0) {
|
||||
firstTokenAt = now;
|
||||
@@ -355,13 +453,31 @@ function TextChat({
|
||||
const generationSeconds = firstTokenAt
|
||||
? Math.max(0.05, (now - firstTokenAt) / 1000)
|
||||
: Math.max(0.05, (now - requestStartedAt) / 1000);
|
||||
assistantText = fullText;
|
||||
|
||||
const processed = processMessageContent(fullText, fullReasoning);
|
||||
assistantText = processed.content;
|
||||
assistantReasoning = processed.reasoning;
|
||||
|
||||
// Calculate thinking duration
|
||||
if (processed.reasoning && !thinkingStartedAt) {
|
||||
thinkingStartedAt = now;
|
||||
}
|
||||
if (processed.content && thinkingStartedAt && !thinkingEndedAt) {
|
||||
thinkingEndedAt = now;
|
||||
thinkingDuration = (thinkingEndedAt - thinkingStartedAt) / 1000;
|
||||
}
|
||||
const currentThinkingDuration = thinkingEndedAt
|
||||
? thinkingDuration
|
||||
: (thinkingStartedAt ? (now - thinkingStartedAt) / 1000 : 0);
|
||||
|
||||
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,
|
||||
content: processed.content,
|
||||
reasoning: processed.reasoning,
|
||||
thinkingDuration: currentThinkingDuration,
|
||||
generationStats: {
|
||||
status: "streaming",
|
||||
tokens: streamedTokens,
|
||||
@@ -375,6 +491,12 @@ function TextChat({
|
||||
});
|
||||
|
||||
const completedAt = performance.now();
|
||||
let finalThinkingDuration = thinkingDuration;
|
||||
if (thinkingStartedAt && !thinkingEndedAt) {
|
||||
thinkingEndedAt = completedAt;
|
||||
finalThinkingDuration = (thinkingEndedAt - thinkingStartedAt) / 1000;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -388,9 +510,13 @@ function TextChat({
|
||||
tokensPerSecond: exactTokensPerSecond,
|
||||
seconds: exactSeconds,
|
||||
};
|
||||
|
||||
const processed = processMessageContent(assistantText, response.reasoningContent || assistantReasoning);
|
||||
const finalMessages = [...nextMessages, {
|
||||
role: "assistant",
|
||||
content: assistantText,
|
||||
content: processed.content,
|
||||
reasoning: processed.reasoning,
|
||||
thinkingDuration: finalThinkingDuration,
|
||||
generationStats,
|
||||
}];
|
||||
setMessages(finalMessages);
|
||||
@@ -594,54 +720,70 @@ function TextChat({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={`chat-message-row ${message.role === "user" ? "user" : "ai"}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`chat-avatar ${message.role === "user" ? "user" : "ai"}`}>
|
||||
{message.role === "user" ? "You" : "AI"}
|
||||
</div>
|
||||
{messages.map((message, index) => {
|
||||
const processed = processMessageContent(
|
||||
Array.isArray(message.content) ? "" : (message.content || ""),
|
||||
message.reasoning || ""
|
||||
);
|
||||
const displayContent = Array.isArray(message.content) ? message.content : processed.content;
|
||||
const displayReasoning = processed.reasoning;
|
||||
|
||||
{/* Bubble + stats */}
|
||||
<div className="chat-bubble-wrap">
|
||||
<span className="chat-sender-label">
|
||||
{message.role === "user" ? "You" : "Local AI"}
|
||||
</span>
|
||||
<div className={`chat-bubble ${message.error ? "error" : ""}`}>
|
||||
{Array.isArray(message.content) ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{message.content.map((item, idx) => {
|
||||
if (item.type === "text") return <MarkdownRenderer key={idx} content={item.text} />;
|
||||
if (item.type === "image_url") return (
|
||||
<img key={idx} src={item.image_url.url} alt="Attached image"
|
||||
style={{ maxWidth: "240px", maxHeight: "180px", objectFit: "contain", borderRadius: "8px", marginTop: "4px" }}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownRenderer content={message.content} />
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
key={`${message.role}-${index}`}
|
||||
className={`chat-message-row ${message.role === "user" ? "user" : "ai"}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`chat-avatar ${message.role === "user" ? "user" : "ai"}`}>
|
||||
{message.role === "user" ? "You" : "AI"}
|
||||
</div>
|
||||
|
||||
{/* Generation stats pill */}
|
||||
{message.role === "assistant" && message.generationStats && !message.error && (
|
||||
<div className={`chat-generation-stats ${message.generationStats.status}`}>
|
||||
{message.generationStats.status === "starting" ? (
|
||||
<><LoaderCircle size={11} className="progress-spinner" /> Waiting for first token...</>
|
||||
) : message.generationStats.status === "streaming" ? (
|
||||
<><span style={{ opacity: 0.7 }}>⚡</span> {message.generationStats.tokensPerSecond.toFixed(1)} tok/s</>
|
||||
{/* Bubble + stats */}
|
||||
<div className="chat-bubble-wrap">
|
||||
<span className="chat-sender-label">
|
||||
{message.role === "user" ? "You" : "Local AI"}
|
||||
</span>
|
||||
{message.role === "assistant" && displayReasoning && textSettings?.enableThinking !== false && (
|
||||
<ThinkingBlock
|
||||
reasoning={displayReasoning}
|
||||
thinkingDuration={message.thinkingDuration}
|
||||
isComplete={!message.generationStats || message.generationStats.status === "complete"}
|
||||
/>
|
||||
)}
|
||||
<div className={`chat-bubble ${message.error ? "error" : ""}`}>
|
||||
{Array.isArray(displayContent) ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{displayContent.map((item, idx) => {
|
||||
if (item.type === "text") return <MarkdownRenderer key={idx} content={item.text} />;
|
||||
if (item.type === "image_url") return (
|
||||
<img key={idx} src={item.image_url.url} alt="Attached image"
|
||||
style={{ maxWidth: "240px", maxHeight: "180px", objectFit: "contain", borderRadius: "8px", marginTop: "4px" }}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>{message.generationStats.tokens} tokens <span style={{ opacity: 0.5 }}>•</span> {formatGenerationTime(message.generationStats.seconds)}</>
|
||||
<MarkdownRenderer content={displayContent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation stats pill */}
|
||||
{message.role === "assistant" && message.generationStats && !message.error && (
|
||||
<div className={`chat-generation-stats ${message.generationStats.status}`}>
|
||||
{message.generationStats.status === "starting" ? (
|
||||
<><LoaderCircle size={11} className="progress-spinner" /> Waiting for first token...</>
|
||||
) : message.generationStats.status === "streaming" ? (
|
||||
<><span style={{ opacity: 0.7 }}>⚡</span> {message.generationStats.tokensPerSecond.toFixed(1)} tok/s</>
|
||||
) : (
|
||||
<>{message.generationStats.tokens} tokens <span style={{ opacity: 0.5 }}>•</span> {formatGenerationTime(message.generationStats.seconds)}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
@@ -691,27 +833,69 @@ function TextChat({
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!supportsVision || isBusy}
|
||||
title={supportsVision ? "Attach files or images" : "Image attachment requires a vision model with an mmproj file"}
|
||||
style={{ marginBottom: "2px" }}
|
||||
>
|
||||
<Paperclip size={17} />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
className="chat-composer-textarea"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={status.ready ? "Message your local model... (Shift+Enter for new line)" : "Select and load a GGUF model above to begin"}
|
||||
disabled={!status.ready || isBusy}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="chat-composer-middle" style={{ flex: 1, display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
<textarea
|
||||
className="chat-composer-textarea"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder={status.ready ? "Message your local model... (Shift+Enter for new line)" : "Select and load a GGUF model above to begin"}
|
||||
disabled={!status.ready || isBusy}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{status.ready && (
|
||||
<button
|
||||
className={`chat-composer-deepthink-btn ${textSettings.enableThinking !== false ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
const newVal = textSettings.enableThinking === false;
|
||||
setTextSettings(prev => ({
|
||||
...prev,
|
||||
enableThinking: newVal
|
||||
}));
|
||||
}}
|
||||
title={textSettings.enableThinking !== false ? "Disable DeepThink reasoning" : "Enable DeepThink reasoning"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "14px",
|
||||
border: "1px solid var(--border-color)",
|
||||
background: textSettings.enableThinking !== false ? "rgba(99, 102, 241, 0.15)" : "transparent",
|
||||
color: textSettings.enableThinking !== false ? "var(--md-sys-color-primary)" : "var(--md-sys-color-outline)",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: "4px",
|
||||
transition: "all 0.2s ease"
|
||||
}}
|
||||
>
|
||||
{/* SVG Atom Icon */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ transition: "transform 0.5s ease" }} className={textSettings.enableThinking !== false ? "rotate-anim" : ""}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<ellipse cx="12" cy="12" rx="3" ry="9" />
|
||||
<ellipse cx="12" cy="12" rx="9" ry="3" />
|
||||
</svg>
|
||||
DeepThink
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isBusy && status.ready ? (
|
||||
<button className="chat-composer-stop-btn" onClick={handleStopGeneration} title="Stop generation">
|
||||
<button className="chat-composer-stop-btn" onClick={handleStopGeneration} title="Stop generation" style={{ marginBottom: "2px" }}>
|
||||
<Square size={15} fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
@@ -720,6 +904,7 @@ function TextChat({
|
||||
onClick={sendMessage}
|
||||
disabled={(!input.trim() && attachments.length === 0) || !status.ready}
|
||||
title="Send message"
|
||||
style={{ marginBottom: "2px" }}
|
||||
>
|
||||
<Send size={17} />
|
||||
</button>
|
||||
|
||||
@@ -346,6 +346,7 @@ export async function startLlm(model, options = {}) {
|
||||
threads: options.threads,
|
||||
contextSize: options.contextSize,
|
||||
gpuLayers: options.gpuLayers,
|
||||
enableThinking: options.enableThinking,
|
||||
}),
|
||||
});
|
||||
return await readJsonResponse(res, "The local server returned an invalid text backend response.");
|
||||
@@ -401,6 +402,7 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
let content = "";
|
||||
let reasoningContent = "";
|
||||
let usage = null;
|
||||
let timings = null;
|
||||
|
||||
@@ -416,9 +418,12 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
const token = parsed.choices?.[0]?.delta?.content || "";
|
||||
if (token) {
|
||||
const reasoningToken = parsed.choices?.[0]?.delta?.reasoning_content || "";
|
||||
|
||||
if (token || reasoningToken) {
|
||||
content += token;
|
||||
onToken(token, content);
|
||||
reasoningContent += reasoningToken;
|
||||
onToken(token, content, reasoningToken, reasoningContent);
|
||||
}
|
||||
if (parsed.usage) usage = parsed.usage;
|
||||
if (parsed.timings) timings = parsed.timings;
|
||||
@@ -446,8 +451,8 @@ export async function streamChatWithLlm(messages, options = {}, onToken = () =>
|
||||
}
|
||||
|
||||
if (!finished && buffer.trim()) consumeEvent(buffer);
|
||||
if (!content) throw new Error("The text model returned an empty streamed response.");
|
||||
return { content, usage, timings };
|
||||
if (!content && !reasoningContent) throw new Error("The text model returned an empty streamed response.");
|
||||
return { content, reasoningContent, usage, timings };
|
||||
}
|
||||
|
||||
export async function downloadLlmModel(url, filename = null) {
|
||||
|
||||
Reference in new issue
Block a user