feat: initialize frontend application scaffold with core layout, chat components, and API service

This commit is contained in:
techjarves committed 2026-06-15 15:53:09 +05:30
1 parent ac406f9cd0
commit 0a181873e6
7 files changed
+633 -83

No files matched your search

+272
View File
@@ -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;
}
+7 -2
View File
@@ -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" },
];
+24
View File
@@ -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">
+246 -61
View File
@@ -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>
+9 -4
View File
@@ -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) {