feat: initialize frontend application structure with theme-based CSS design system and initial components

This commit is contained in:
techjarves committed 2026-06-23 06:29:14 +05:30
1 parent e81cce9b32
commit ed28426b86
4 files changed
+304 -118

No files matched your search

+129
View File
@@ -1118,6 +1118,135 @@ body {
padding: 8px;
}
/* Custom Chat Model Dropdown Selection */
.chat-model-select-trigger {
height: 34px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-radius: var(--md-shape-corner-medium);
background: var(--md-sys-color-surface-variant);
border: 1px solid var(--border-color);
color: var(--md-sys-color-on-surface);
padding: 0 12px;
font-family: Outfit, sans-serif;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
outline: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-model-select-trigger:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--md-sys-color-primary) 40%, var(--border-color));
background: color-mix(in srgb, var(--md-sys-color-surface-variant) 95%, var(--md-sys-color-primary));
}
.chat-model-select-trigger.active {
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--md-sys-color-primary) 15%, transparent);
background: color-mix(in srgb, var(--md-sys-color-surface-variant) 95%, var(--md-sys-color-primary));
}
.chat-model-select-trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chat-model-select-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
text-align: left;
}
.chat-model-select-arrow {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: var(--md-sys-color-outline);
flex-shrink: 0;
}
.chat-model-select-arrow.open {
transform: rotate(180deg);
color: var(--md-sys-color-primary);
}
.chat-model-dropdown-menu {
position: absolute;
top: 40px;
left: 0;
width: 100%;
min-width: 280px;
max-height: 280px;
overflow-y: auto;
background: color-mix(in srgb, var(--md-sys-color-surface) 95%, black);
backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25);
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
z-index: 9999;
animation: theme-dropdown-fade 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-model-dropdown-item {
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--md-sys-color-on-surface);
font-family: Outfit, sans-serif;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.chat-model-dropdown-item:hover {
background: color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent);
color: var(--md-sys-color-primary);
}
.chat-model-dropdown-item.selected {
background: color-mix(in srgb, var(--md-sys-color-primary) 8%, var(--md-sys-color-surface-variant));
font-weight: 600;
}
.chat-model-dropdown-item.active {
border-left: 3px solid var(--md-sys-color-primary);
padding-left: 9px;
}
.chat-model-name-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.chat-model-active-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-primary);
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.05em;
}
@keyframes rotateThinking {
0% {
transform: rotate(0deg);
+3
View File
@@ -58,6 +58,7 @@ function App() {
const [activeModel, setActiveModel] = useState(null);
const [serverRunning, setServerRunning] = useState(false);
const [isStoppingServer, setIsStoppingServer] = useState(false);
const [isLlmLoaded, setIsLlmLoaded] = useState(false);
const [backendOptions, setBackendOptions] = useState({
options: [{ id: "cpu", label: "CPU", available: true }],
cudaAvailable: false,
@@ -673,6 +674,7 @@ function App() {
telemetry={telemetry}
serverRunning={serverRunning}
activeModel={activeModel}
isLlmLoaded={isLlmLoaded}
onStopServer={handleStopServer}
isStoppingServer={isStoppingServer}
theme={theme}
@@ -737,6 +739,7 @@ function App() {
showHistory={showHistory}
setShowHistory={setShowHistory}
saveConversationState={saveConversationState}
setIsLlmLoaded={setIsLlmLoaded}
/>
</div>
+168 -117
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown, Globe2 } from "lucide-react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown, Globe2, Plus } from "lucide-react";
import {
getDownloadProgress,
getLlmStatus,
@@ -10,11 +10,8 @@ import {
} from "../services/api";
const processMessageContent = (rawText, apiReasoning = "", enableThinking = true) => {
let content = rawText;
let reasoning = apiReasoning || "";
if (typeof rawText !== "string") {
return { content, reasoning };
return { content: rawText, reasoning: apiReasoning || "" };
}
const cleanReasoningControlTags = (value) => String(value || "")
@@ -44,56 +41,38 @@ const processMessageContent = (rawText, apiReasoning = "", enableThinking = true
}
}
if (startIdx !== -1) {
let endIdx = -1;
let matchedEndTag = "";
const searchArea = rawText.substring(startIdx + matchedStartTag.length);
if (startIdx === -1) {
return { content: cleanReasoningControlTags(rawText), reasoning: "" };
}
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).trim();
return processMessageContent(content, reasoning, enableThinking);
} else {
const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length).trim();
reasoning = (reasoning + "\n" + extractedReasoning).trim();
content = rawText.substring(0, startIdx).trim();
let endIdx = -1;
for (const tag of endTags) {
const idx = rawText.indexOf(tag, startIdx + matchedStartTag.length);
if (idx !== -1 && (endIdx === -1 || idx < endIdx)) {
endIdx = idx;
}
}
return { content, reasoning };
if (endIdx === -1) {
const rawReasoning = rawText.substring(startIdx + matchedStartTag.length);
return { content: "", reasoning: cleanReasoningControlTags(rawReasoning) };
}
const rawReasoning = rawText.substring(startIdx + matchedStartTag.length, endIdx);
const rawContent = rawText.substring(endIdx);
return {
content: cleanReasoningControlTags(rawContent),
reasoning: cleanReasoningControlTags(rawReasoning)
};
};
const messageContainsImage = (message) => (
Array.isArray(message?.content) &&
message.content.some((item) => item?.type === "image_url" && item?.image_url?.url)
);
function ChatThinkingSection({ reasoning, timeElapsed, isComplete }) {
const [isExpanded, setIsExpanded] = useState(true);
function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) {
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (isComplete) {
setIsExpanded(false);
}
}, [isComplete]);
if (!reasoning) return null;
const formattedTime = thinkingDuration > 0 ? ` (${thinkingDuration.toFixed(1)}s)` : "";
const formattedTime = timeElapsed > 0
? ` (${timeElapsed.toFixed(timeElapsed < 10 ? 1 : 0)}s)`
: "";
return (
<div className="chat-thinking-container">
@@ -121,6 +100,11 @@ function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) {
);
}
const messageContainsImage = (message) => (
Array.isArray(message?.content) &&
message.content.some((item) => item?.type === "image_url" && item?.image_url?.url)
);
function TextChat({
specs,
showAlert,
@@ -135,7 +119,8 @@ function TextChat({
setActiveConversationId,
showHistory,
setShowHistory,
saveConversationState
saveConversationState,
setIsLlmLoaded
}) {
const formatGenerationTime = (seconds) => {
const value = Number(seconds) || 0;
@@ -158,6 +143,25 @@ function TextChat({
total_tokens: 0
});
useEffect(() => {
if (setIsLlmLoaded) {
setIsLlmLoaded(status.ready);
}
}, [status.ready, setIsLlmLoaded]);
const [showModelMenu, setShowModelMenu] = useState(false);
const modelMenuRef = useRef(null);
useEffect(() => {
function handleClickOutside(event) {
if (modelMenuRef.current && !modelMenuRef.current.contains(event.target)) {
setShowModelMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const estimateTokens = (text) => {
const value = String(text || "").trim();
if (!value) return 0;
@@ -882,26 +886,68 @@ function TextChat({
<History size={17} />
</button>
<select
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={isBusy}
<button
onClick={handleNewChat}
className="m3-btn m3-btn-tonal"
style={{
fontSize: "0.9rem", fontWeight: "600",
border: "1px solid var(--border-color)",
borderRadius: "var(--md-shape-corner-medium)",
height: "34px", width: "34px", padding: 0,
display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: "var(--md-shape-corner-medium)", cursor: "pointer",
background: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface)",
padding: "6px 12px", outline: "none", cursor: "pointer", minWidth: "180px", flex: 1
border: "1px solid var(--border-color)", flexShrink: 0
}}
title="Start New Chat"
disabled={isBusy}
>
<option value="">No model loaded (Select GGUF)</option>
{models.map((m) => (
<option key={m.filename} value={m.filename}>
{m.filename} {m.filename === status.settings?.model && status.ready ? "• Active" : ""}
</option>
))}
</select>
<Plus size={17} />
</button>
<div ref={modelMenuRef} style={{ position: "relative", display: "inline-block", flex: "0 1 280px", maxWidth: "280px", width: "100%" }}>
<button
onClick={() => !isBusy && setShowModelMenu(!showModelMenu)}
className={`chat-model-select-trigger ${showModelMenu ? "active" : ""}`}
disabled={isBusy}
title="Select GGUF Model"
>
<span className="chat-model-select-label">
{selectedModel ? selectedModel : "No model loaded (Select GGUF)"}
</span>
<ChevronDown size={14} className={`chat-model-select-arrow ${showModelMenu ? "open" : ""}`} />
</button>
{showModelMenu && (
<div className="chat-model-dropdown-menu">
<button
className={`chat-model-dropdown-item ${!selectedModel ? "selected" : ""}`}
onClick={() => {
handleModelChange("");
setShowModelMenu(false);
}}
>
No model loaded (Select GGUF)
</button>
{models.map((m) => {
const isActive = m.filename === status.settings?.model && status.ready;
const isSelected = m.filename === selectedModel;
return (
<button
key={m.filename}
className={`chat-model-dropdown-item ${isSelected ? "selected" : ""} ${isActive ? "active" : ""}`}
onClick={() => {
handleModelChange(m.filename);
setShowModelMenu(false);
}}
title={m.filename}
>
<span className="chat-model-name-text">{m.filename}</span>
{isActive && <span className="chat-model-active-badge">Active</span>}
</button>
);
})}
</div>
)}
</div>
{isBusy && !loadingModel && <LoaderCircle className="progress-spinner" size={16} />}
{selectedModel && (!status.ready || status.settings?.model !== selectedModel) && !loadingModel && (
<button
@@ -940,8 +986,8 @@ function TextChat({
style={{ transition: "stroke-dashoffset 0.35s" }}
/>
</svg>
<div style={{ position: "absolute", textAlign: "center" }}>
<span style={{ fontSize: "0.58rem", fontWeight: "700" }}>{percent}%</span>
<div style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontSize: "0.58rem", fontWeight: "700", lineHeight: 1 }}>{percent}%</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", lineHeight: 1.2 }}>
@@ -1174,44 +1220,9 @@ function TextChat({
</div>
)}
{/* Input row */}
{/* Input container with vertical layout */}
<div className="chat-composer-inner">
<input type="file" ref={fileInputRef} style={{ display: "none" }} multiple onChange={handleFileChange} />
<button
className="chat-composer-attach-btn"
onClick={() => fileInputRef.current?.click()}
disabled={!supportsVision || isBusy}
title={supportsVision ? "Attach files or images" : visionStatus}
>
<Paperclip size={17} />
</button>
<button
className={`chat-composer-deepthink-btn web-search-btn ${useWebSearch ? "active" : ""}`}
onClick={() => setUseWebSearch((value) => !value)}
disabled={!status.ready || isBusy}
title={useWebSearch ? "Disable web search" : "Enable web search"}
>
<Globe2 size={14} />
<span>Web</span>
</button>
{status.ready && supportsThinking && (
<button
className={`chat-composer-deepthink-btn deepthink-btn ${deepThinkEnabled ? "active" : ""}`}
onClick={handleThinkingToggle}
title={deepThinkEnabled ? "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={deepThinkEnabled ? "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>
)}
<div className="chat-composer-middle" style={{ flex: 1, display: "flex", minWidth: 0 }}>
<div className="chat-composer-textarea-container">
<textarea
ref={textareaRef}
className="chat-composer-textarea"
@@ -1229,20 +1240,60 @@ function TextChat({
/>
</div>
{isBusy && status.ready ? (
<button className="chat-composer-stop-btn" onClick={handleStopGeneration} title="Stop generation">
<Square size={15} fill="currentColor" />
</button>
) : (
<button
className="chat-composer-send-btn"
onClick={sendMessage}
disabled={(!input.trim() && attachments.length === 0) || !status.ready}
title="Send message"
>
<Send size={17} />
</button>
)}
<div className="chat-composer-toolbar">
<div className="chat-composer-toolbar-left">
<input type="file" ref={fileInputRef} style={{ display: "none" }} multiple onChange={handleFileChange} />
<button
className="chat-composer-attach-btn"
onClick={() => fileInputRef.current?.click()}
disabled={!supportsVision || isBusy}
title={supportsVision ? "Attach files or images" : visionStatus}
>
<Paperclip size={17} />
</button>
<button
className={`chat-composer-deepthink-btn web-search-btn ${useWebSearch ? "active" : ""}`}
onClick={() => setUseWebSearch((value) => !value)}
disabled={!status.ready || isBusy}
title={useWebSearch ? "Disable web search" : "Enable web search"}
>
<Globe2 size={14} />
<span>Web</span>
</button>
{status.ready && supportsThinking && (
<button
className={`chat-composer-deepthink-btn deepthink-btn ${deepThinkEnabled ? "active" : ""}`}
onClick={handleThinkingToggle}
title={deepThinkEnabled ? "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={deepThinkEnabled ? "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>
)}
</div>
<div className="chat-composer-toolbar-right">
{isBusy && status.ready ? (
<button className="chat-composer-stop-btn" onClick={handleStopGeneration} title="Stop generation">
<Square size={15} fill="currentColor" />
</button>
) : (
<button
className="chat-composer-send-btn"
onClick={sendMessage}
disabled={(!input.trim() && attachments.length === 0) || !status.ready}
title="Send message"
>
<Send size={17} />
</button>
)}
</div>
</div>
</div>
<div className="chat-composer-hint">Enter to send &nbsp;·&nbsp; Shift+Enter for new line</div>
</div>
+4 -1
View File
@@ -2,7 +2,7 @@ import React, { memo, useState, useRef, useEffect } from "react";
import { Cpu, HardDrive, Database, Square, RefreshCw, Sun, Moon, Palette, Check } from "lucide-react";
import { THEMES } from "../themes";
function TopStatusBar({ telemetry, serverRunning, activeModel, onStopServer, isStoppingServer = false, theme, setTheme }) {
function TopStatusBar({ telemetry, serverRunning, activeModel, isLlmLoaded = false, onStopServer, isStoppingServer = false, theme, setTheme }) {
const [showThemeMenu, setShowThemeMenu] = useState(false);
const menuRef = useRef(null);
@@ -24,11 +24,14 @@ function TopStatusBar({ telemetry, serverRunning, activeModel, onStopServer, isS
};
const getStatusText = () => {
if (isLlmLoaded) return "Model Loaded (Text)";
if (activeModel) return "Model Loaded (Image)";
if (serverRunning) return "Server Active";
return "Local Mode";
};
const getStatusClass = () => {
if (activeModel || isLlmLoaded) return "status-indicator";
if (serverRunning) return "status-indicator busy";
return "status-indicator offline";
};