feat: implement base styles, TextChat component, and server startup script

This commit is contained in:
techjarves committed 2026-06-21 09:08:07 +05:30
1 parent 9e67084e50
commit 95a860c2f4
3 files changed
+148 -98

No files matched your search

+68 -42
View File
@@ -507,7 +507,7 @@ body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0px;
padding: 20px;
padding: 10px 12px;
min-height: 0;
}
@@ -521,7 +521,7 @@ body {
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: var(--md-shape-corner-large);
border-radius: 12px;
background: var(--md-sys-color-surface);
box-shadow: var(--glass-shadow);
}
@@ -531,7 +531,7 @@ body {
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
}
@@ -569,7 +569,7 @@ body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 28px 24px 12px;
padding: 14px 14px 8px;
display: flex;
flex-direction: column;
gap: 0;
@@ -672,8 +672,8 @@ body {
.chat-message-row {
display: flex;
align-items: flex-end;
gap: 10px;
margin-bottom: 6px;
gap: 8px;
margin-bottom: 4px;
animation: msgSlideIn 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -693,13 +693,13 @@ body {
/* Group spacing: bigger gap between user→ai switches */
.chat-message-row.ai + .chat-message-row.user,
.chat-message-row.user + .chat-message-row.ai {
margin-top: 16px;
margin-top: 10px;
}
/* ─── Avatar Chip ─────────────────────────────────────────────── */
.chat-avatar {
width: 32px;
height: 32px;
width: 30px;
height: 30px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
@@ -727,25 +727,27 @@ body {
.chat-bubble-wrap {
display: flex;
flex-direction: column;
max-width: min(720px, 84%);
max-width: min(1180px, 92%);
gap: 4px;
}
.chat-message-row.user .chat-bubble-wrap {
align-items: flex-end;
max-width: min(780px, 70%);
}
.chat-message-row.ai .chat-bubble-wrap {
align-items: flex-start;
max-width: min(1280px, 92%);
}
.chat-bubble {
padding: 12px 16px;
border-radius: 18px;
line-height: 1.6;
padding: 10px 14px;
border-radius: 14px;
line-height: 1.5;
overflow-wrap: anywhere;
position: relative;
font-size: 0.9rem;
font-size: 0.88rem;
}
/* AI bubble */
@@ -856,7 +858,7 @@ body {
display: flex;
flex-direction: column;
gap: 0;
padding: 12px 20px 16px;
padding: 8px 14px 10px;
border-top: 1px solid var(--border-color);
background: color-mix(in srgb, var(--md-sys-color-surface) 80%, transparent);
backdrop-filter: blur(12px);
@@ -864,12 +866,12 @@ body {
.chat-composer-inner {
display: flex;
align-items: flex-end;
gap: 10px;
align-items: center;
gap: 8px;
background: var(--md-sys-color-surface-variant);
border: 1.5px solid var(--border-color);
border-radius: 20px;
padding: 6px 6px 6px 14px;
border-radius: 16px;
padding: 4px 5px 4px 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
@@ -897,14 +899,14 @@ body {
border: none !important;
background: transparent !important;
color: var(--md-sys-color-on-surface);
padding: 8px 0;
padding: 4px 0;
font: inherit;
font-size: 0.92rem;
outline: none;
resize: none;
min-height: 36px;
max-height: 160px;
line-height: 1.55;
min-height: 28px;
max-height: 104px;
line-height: 1.45;
overflow-y: auto;
}
@@ -914,14 +916,14 @@ body {
}
.chat-composer textarea {
min-height: 48px;
max-height: 160px;
min-height: 34px;
max-height: 104px;
resize: vertical;
}
.chat-composer-send-btn {
width: 40px;
height: 40px;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
flex-shrink: 0;
@@ -952,8 +954,8 @@ body {
}
.chat-composer-stop-btn {
width: 40px;
height: 40px;
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
flex-shrink: 0;
@@ -979,8 +981,8 @@ body {
}
.chat-composer-attach-btn {
width: 34px;
height: 34px;
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
flex-shrink: 0;
@@ -1004,11 +1006,35 @@ body {
}
.chat-composer-hint {
text-align: center;
font-size: 0.7rem;
display: none;
}
.chat-composer-deepthink-btn {
height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--md-sys-color-outline);
margin-top: 6px;
opacity: 0.7;
font-family: Outfit, sans-serif;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.chat-composer-deepthink-btn.active {
background: color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent);
border-color: color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent);
color: var(--md-sys-color-primary);
}
.chat-composer-deepthink-btn:hover {
border-color: color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent);
}
.text-chat-settings {
@@ -2556,7 +2582,7 @@ html[data-theme^="dark"] .m3-segment-item:hover:not(.active):not(.disabled) {
/* Chat Thinking Block (Reasoning Process) */
.chat-thinking-container {
margin-bottom: 8px;
margin-bottom: 6px;
border-radius: 8px;
border: 1px dashed rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.03);
@@ -2569,11 +2595,11 @@ html[data-theme^="dark"] .m3-segment-item:hover:not(.active):not(.disabled) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.05);
border: none;
color: var(--md-sys-color-on-surface-variant);
font-size: 0.8rem;
font-size: 0.76rem;
font-weight: 500;
cursor: pointer;
text-align: left;
@@ -2593,16 +2619,16 @@ html[data-theme^="dark"] .m3-segment-item:hover:not(.active):not(.disabled) {
}
.chat-thinking-content {
padding: 10px 12px;
font-size: 0.8rem;
line-height: 1.5;
padding: 8px 10px;
font-size: 0.76rem;
line-height: 1.45;
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;
max-height: 150px;
overflow-y: auto;
}
+66 -55
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown } from "lucide-react";
import {
getDownloadProgress,
@@ -71,7 +71,7 @@ const processMessageContent = (rawText, apiReasoning = "", enableThinking = true
};
function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) {
const [isExpanded, setIsExpanded] = useState(!isComplete);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (isComplete) {
@@ -152,10 +152,12 @@ function TextChat({
};
const bottomRef = useRef(null);
const textareaRef = useRef(null);
const completedDownloadRef = useRef("");
const loadingModelRef = useRef(null);
const chatMessagesRef = useRef(null);
const prevMessagesLengthRef = useRef(0);
const followGenerationRef = useRef(false);
const abortControllerRef = useRef(null);
// rAF batching: accumulate token updates and flush once per frame
const tokenBufferRef = useRef(null);
@@ -168,6 +170,19 @@ function TextChat({
const supportsVision = Boolean(status.ready && status.settings?.supportsVision);
const supportsThinking = Boolean(status.ready && status.settings?.supportsThinking);
const resizeComposerInput = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const maxHeight = 104;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
}, []);
useLayoutEffect(() => {
resizeComposerInput();
}, [input, resizeComposerInput]);
const isImage = (file) => {
return /\.(jpe?g|png|webp)$/i.test(file.name) || file.type.startsWith("image/");
};
@@ -396,7 +411,7 @@ function TextChat({
const lastMessage = messages[len - 1];
const isNewUserMessage = len > prevLen && lastMessage?.role === "user";
if (isNewUserMessage) {
if (isNewUserMessage || followGenerationRef.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
} else {
const threshold = 150;
@@ -518,6 +533,7 @@ function TextChat({
}
const nextMessages = [...messages, { role: "user", content: userMessageContent }];
followGenerationRef.current = true;
setMessages(nextMessages);
setInput("");
setIsBusy(true);
@@ -722,6 +738,9 @@ function TextChat({
} finally {
setIsBusy(false);
abortControllerRef.current = null;
setTimeout(() => {
followGenerationRef.current = false;
}, 250);
}
};
@@ -734,16 +753,16 @@ function TextChat({
};
return (
<div className="text-chat-layout" style={{ display: "flex", padding: "20px", height: "100%", width: "100%", boxSizing: "border-box", overflow: "hidden" }}>
<div className="text-chat-layout" style={{ display: "flex", height: "100%", width: "100%", boxSizing: "border-box", overflow: "hidden" }}>
<section className="text-chat-main" style={{ flex: 1, minWidth: 0, height: "100%", display: "flex", flexDirection: "column" }}>
{/* ─── Header ─────────────────────────────────────────── */}
<div className="text-chat-header" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div className="text-chat-header" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", minWidth: 0, flex: 1 }}>
<button
onClick={() => setShowHistory(!showHistory)}
className="m3-btn m3-btn-tonal"
style={{
height: "38px", width: "38px", padding: 0,
height: "34px", width: "34px", padding: 0,
display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: "var(--md-shape-corner-medium)", cursor: "pointer",
background: showHistory ? "var(--md-sys-color-primary-container)" : "var(--md-sys-color-surface-variant)",
@@ -752,7 +771,7 @@ function TextChat({
}}
title="Toggle Chat History"
>
<History size={18} />
<History size={17} />
</button>
<select
@@ -760,12 +779,12 @@ function TextChat({
onChange={(e) => handleModelChange(e.target.value)}
disabled={isBusy}
style={{
fontSize: "0.95rem", fontWeight: "600",
fontSize: "0.9rem", fontWeight: "600",
border: "1px solid var(--border-color)",
borderRadius: "var(--md-shape-corner-medium)",
background: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface)",
padding: "8px 16px", outline: "none", cursor: "pointer", minWidth: "220px"
padding: "6px 12px", outline: "none", cursor: "pointer", minWidth: "180px", flex: 1
}}
>
<option value="">No model loaded (Select GGUF)</option>
@@ -795,16 +814,16 @@ function TextChat({
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "20px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px", flexShrink: 0 }}>
{/* Context gauge */}
{(() => {
const maxTokens = status.settings?.contextSize || 4096;
const used = tokenUsage.total_tokens || 0;
const percent = Math.min(100, Math.round((used / maxTokens) * 100));
return (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }} title={`Context Used: ${used} / ${maxTokens} tokens`}>
<div style={{ position: "relative", width: "40px", height: "40px", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<svg width="40" height="40" viewBox="0 0 40 40">
<div style={{ display: "flex", alignItems: "center", gap: "7px" }} title={`Context Used: ${used} / ${maxTokens} tokens`}>
<div style={{ position: "relative", width: "32px", height: "32px", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<svg width="32" height="32" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="16" stroke="var(--border-color)" strokeWidth="3" fill="transparent" />
<circle cx="20" cy="20" r="16" stroke="var(--md-sys-color-primary)" strokeWidth="3" fill="transparent"
strokeDasharray={2 * Math.PI * 16}
@@ -814,12 +833,12 @@ function TextChat({
/>
</svg>
<div style={{ position: "absolute", textAlign: "center" }}>
<span style={{ fontSize: "0.65rem", fontWeight: "700" }}>{percent}%</span>
<span style={{ fontSize: "0.58rem", fontWeight: "700" }}>{percent}%</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", lineHeight: 1.2 }}>
<span style={{ fontSize: "0.65rem", color: "var(--md-sys-color-outline)", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>Context</span>
<span style={{ fontSize: "0.8rem", fontWeight: "600", color: "var(--md-sys-color-on-surface)" }}>{used} / {maxTokens}</span>
<span style={{ fontSize: "0.6rem", color: "var(--md-sys-color-outline)", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em" }}>Context</span>
<span style={{ fontSize: "0.76rem", fontWeight: "600", color: "var(--md-sys-color-on-surface)" }}>{used} / {maxTokens}</span>
</div>
</div>
);
@@ -827,18 +846,27 @@ function TextChat({
<button
className="m3-btn m3-btn-outlined"
style={{ height: "36px", padding: "0 12px", display: "flex", alignItems: "center", gap: "6px", fontSize: "0.82rem", borderRadius: "var(--md-shape-corner-medium)" }}
style={{ height: "32px", padding: "0 10px", display: "flex", alignItems: "center", gap: "6px", fontSize: "0.78rem", borderRadius: "var(--md-shape-corner-medium)" }}
onClick={handleClearChat}
disabled={messages.length === 0}
>
<Trash2 size={15} />
<Trash2 size={14} />
<span>Clear</span>
</button>
</div>
</div>
{/* ─── Messages area ──────────────────────────────────── */}
<div ref={chatMessagesRef} className="chat-messages">
<div
ref={chatMessagesRef}
className="chat-messages"
onWheel={() => {
if (isBusy) followGenerationRef.current = false;
}}
onTouchMove={() => {
if (isBusy) followGenerationRef.current = false;
}}
>
{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" }} />
@@ -1012,14 +1040,15 @@ function TextChat({
className="chat-composer-attach-btn"
onClick={() => fileInputRef.current?.click()}
disabled={!supportsVision || isBusy}
title={supportsVision ? "Attach files or images" : "Image attachment requires a vision model with an mmproj file"}
title={supportsVision ? "Attach files or images" : "Image attachment requires a vision model or matching projector"}
style={{ marginBottom: "2px" }}
>
<Paperclip size={17} />
</button>
<div className="chat-composer-middle" style={{ flex: 1, display: "flex", flexDirection: "column", gap: "6px" }}>
<div className="chat-composer-middle" style={{ flex: 1, display: "flex", minWidth: 0 }}>
<textarea
ref={textareaRef}
className="chat-composer-textarea"
value={input}
onChange={(event) => setInput(event.target.value)}
@@ -1033,41 +1062,23 @@ function TextChat({
disabled={!status.ready || isBusy}
rows={1}
/>
{status.ready && supportsThinking && (
<button
className={`chat-composer-deepthink-btn ${textSettings.enableThinking !== false ? "active" : ""}`}
onClick={handleThinkingToggle}
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>
{status.ready && supportsThinking && (
<button
className={`chat-composer-deepthink-btn ${textSettings.enableThinking !== false ? "active" : ""}`}
onClick={handleThinkingToggle}
title={textSettings.enableThinking !== false ? "Disable DeepThink reasoning" : "Enable DeepThink reasoning"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" 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>
<span>DeepThink</span>
</button>
)}
{isBusy && status.ready ? (
<button className="chat-composer-stop-btn" onClick={handleStopGeneration} title="Stop generation" style={{ marginBottom: "2px" }}>
<Square size={15} fill="currentColor" />