feat: initialize frontend application structure with TextChat and Sidebar components

This commit is contained in:
techjarves committed 2026-06-15 06:46:46 +05:30
1 parent 2e24396b35
commit 49725362c5
3 files changed
+235 -149

No files matched your search

+66 -1
View File
@@ -133,6 +133,58 @@ function App() {
localStorage.setItem("textSettings", JSON.stringify(textSettings));
}, [textSettings]);
// Lifted Chat History States
const [conversations, setConversations] = useState([]);
const [activeConversationId, setActiveConversationId] = useState(null);
const [showHistory, setShowHistory] = useState(false); // Default hide
useEffect(() => {
const saved = localStorage.getItem("chat_conversations");
if (saved) {
try {
setConversations(JSON.parse(saved));
} catch (_) {}
}
}, []);
const saveConversationState = useCallback((id, msgs, modelName, newTitle = null) => {
setConversations((prev) => {
const list = [...prev];
const idx = list.findIndex(c => c.id === id);
if (idx !== -1) {
list[idx] = {
...list[idx],
messages: msgs,
timestamp: Date.now(),
model: modelName,
...(newTitle ? { title: newTitle } : {})
};
} else {
list.unshift({
id,
title: newTitle || "Chat Session",
model: modelName,
messages: msgs,
timestamp: Date.now()
});
}
localStorage.setItem("chat_conversations", JSON.stringify(list));
return list;
});
}, []);
const handleDeleteConversation = useCallback((id, e) => {
if (e) e.stopPropagation();
setConversations((prev) => {
const filtered = prev.filter((c) => c.id !== id);
localStorage.setItem("chat_conversations", JSON.stringify(filtered));
return filtered;
});
if (activeConversationId === id) {
setActiveConversationId(null);
}
}, [activeConversationId]);
// Load hardware specifications on mount
useEffect(() => {
async function loadSpecs() {
@@ -371,8 +423,14 @@ function App() {
activeTab={activeTab}
setActiveTab={setActiveTab}
specs={specs}
conversations={conversations}
activeConversationId={activeConversationId}
setActiveConversationId={setActiveConversationId}
showHistory={showHistory}
setShowHistory={setShowHistory}
onDeleteConversation={handleDeleteConversation}
/>
), [activeTab, specs]);
), [activeTab, specs, conversations, activeConversationId, showHistory, handleDeleteConversation]);
const handleStopServer = useCallback(async () => {
if (!serverRunning || isStoppingServer) return;
@@ -513,6 +571,13 @@ function App() {
setTextSettings={setTextSettings}
setActiveModel={setActiveModel}
setServerRunning={setServerRunning}
conversations={conversations}
setConversations={setConversations}
activeConversationId={activeConversationId}
setActiveConversationId={setActiveConversationId}
showHistory={showHistory}
setShowHistory={setShowHistory}
saveConversationState={saveConversationState}
/>
</div>
+127 -8
View File
@@ -1,7 +1,17 @@
import React, { memo } from "react";
import { Image, FolderDown, MessageSquare, Settings, Shield, Terminal } from "lucide-react";
import { Image, FolderDown, MessageSquare, Settings, Shield, Terminal, ChevronDown, ChevronUp, Trash2 } from "lucide-react";
function Sidebar({ activeTab, setActiveTab, specs }) {
function Sidebar({
activeTab,
setActiveTab,
specs,
conversations = [],
activeConversationId,
setActiveConversationId,
showHistory,
setShowHistory,
onDeleteConversation
}) {
return (
<div className="sidebar">
<div>
@@ -21,12 +31,121 @@ function Sidebar({ activeTab, setActiveTab, specs }) {
<span>Image Generator</span>
</div>
<div
className={`nav-item ${activeTab === "chat" ? "active" : ""}`}
onClick={() => setActiveTab("chat")}
>
<MessageSquare size={20} />
<span>Text Chat</span>
<div className="nav-item-wrapper" style={{ display: "flex", flexDirection: "column" }}>
<div
className={`nav-item ${activeTab === "chat" ? "active" : ""}`}
onClick={() => setActiveTab("chat")}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%", boxSizing: "border-box" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<MessageSquare size={20} />
<span>Text Chat</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowHistory(!showHistory);
}}
style={{
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
transition: "background-color 0.2s"
}}
className="history-toggle-arrow"
title={showHistory ? "Hide Chat History" : "Show Chat History"}
>
{showHistory ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{/* Sidebar Chat History List */}
{showHistory && (
<div
className="sidebar-history-list"
style={{
paddingLeft: "14px",
marginTop: "6px",
marginBottom: "6px",
display: "flex",
flexDirection: "column",
gap: "4px",
maxHeight: "220px",
overflowY: "auto",
borderLeft: "2px solid var(--border-color)"
}}
>
{conversations.length === 0 ? (
<div style={{ padding: "8px 12px", fontSize: "0.78rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
No saved chats
</div>
) : (
conversations.map((conv) => {
const isActive = activeConversationId === conv.id;
return (
<div
key={conv.id}
onClick={() => {
setActiveConversationId(conv.id);
setActiveTab("chat");
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 8px 6px 10px",
borderRadius: "var(--md-shape-corner-small)",
fontSize: "0.78rem",
cursor: "pointer",
background: isActive ? "var(--md-sys-color-secondary-container)" : "transparent",
color: isActive ? "var(--md-sys-color-on-secondary-container)" : "var(--md-sys-color-on-surface-variant)",
border: isActive ? "1px solid var(--md-sys-color-outline-variant)" : "1px solid transparent",
transition: "background 0.2s"
}}
className="sidebar-history-item"
title={conv.title}
>
<span style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
fontWeight: isActive ? 600 : 400
}}>
{conv.title}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteConversation(conv.id, e);
}}
style={{
background: "none",
border: "none",
color: "var(--md-sys-color-outline)",
cursor: "pointer",
padding: "2px",
marginLeft: "6px",
display: "flex",
alignItems: "center"
}}
className="sidebar-history-delete"
title="Delete Conversation"
>
<Trash2 size={12} />
</button>
</div>
);
})
)}
</div>
)}
</div>
<div
+42 -140
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus, Paperclip, X } from "lucide-react";
import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X } from "lucide-react";
import {
chatWithLlm,
getDownloadProgress,
@@ -9,7 +9,22 @@ import {
stopLlm,
} from "../services/api";
function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings, setActiveModel, setServerRunning }) {
function TextChat({
specs,
showAlert,
showConfirm,
textSettings,
setTextSettings,
setActiveModel,
setServerRunning,
conversations,
setConversations,
activeConversationId,
setActiveConversationId,
showHistory,
setShowHistory,
saveConversationState
}) {
const [models, setModels] = useState([]);
const [status, setStatus] = useState({ ready: false, running: false, settings: {} });
const [selectedModel, setSelectedModel] = useState("");
@@ -17,9 +32,6 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
const [input, setInput] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [loadingModel, setLoadingModel] = useState(null);
const [conversations, setConversations] = useState([]);
const [activeConversationId, setActiveConversationId] = useState(null);
const [showHistory, setShowHistory] = useState(true);
const [tokenUsage, setTokenUsage] = useState({
prompt_tokens: 0,
completion_tokens: 0,
@@ -92,15 +104,32 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
loadingModelRef.current = loadingModel;
}, [loadingModel]);
// Load conversations on mount
// Load conversation messages when activeConversationId changes
useEffect(() => {
const saved = localStorage.getItem("chat_conversations");
if (saved) {
try {
setConversations(JSON.parse(saved));
} catch (_) {}
if (activeConversationId) {
const conv = conversations.find(c => c.id === activeConversationId);
if (conv) {
setMessages(conv.messages);
if (conv.model && models.some(m => m.filename === conv.model)) {
setSelectedModel(conv.model);
}
const total = conv.messages.reduce((sum, m) => {
const text = Array.isArray(m.content)
? m.content.map(c => c.text || "").join(" ")
: (m.content || "");
return sum + text.split(/\s+/).length;
}, 0);
setTokenUsage({
prompt_tokens: Math.round(total * 0.7),
completion_tokens: Math.round(total * 0.3),
total_tokens: total
});
}
} else {
setMessages([]);
setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
}
}, []);
}, [activeConversationId, conversations, models]);
const refresh = useCallback(async () => {
const [nextModels, nextStatus] = await Promise.all([listLlmModels(), getLlmStatus()]);
@@ -197,72 +226,8 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
setSelectedModel("");
};
const saveConversationState = (id, msgs, modelName, newTitle = null) => {
const saved = localStorage.getItem("chat_conversations");
let list = [];
if (saved) {
try {
list = JSON.parse(saved);
} catch (_) {}
}
const idx = list.findIndex(c => c.id === id);
if (idx !== -1) {
list[idx].messages = msgs;
list[idx].timestamp = Date.now();
list[idx].model = modelName;
if (newTitle) list[idx].title = newTitle;
} else {
list.unshift({
id,
title: newTitle || "Chat Session",
model: modelName,
messages: msgs,
timestamp: Date.now()
});
}
localStorage.setItem("chat_conversations", JSON.stringify(list));
setConversations(list);
};
const handleNewChat = () => {
setActiveConversationId(null);
setMessages([]);
setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
};
const handleSelectConversation = (conv) => {
setActiveConversationId(conv.id);
setMessages(conv.messages);
if (conv.model && models.some(m => m.filename === conv.model)) {
setSelectedModel(conv.model);
}
// Try to compute approximate token usage from messages
const total = conv.messages.reduce((sum, m) => {
const text = Array.isArray(m.content)
? m.content.map(c => c.text || "").join(" ")
: (m.content || "");
return sum + text.split(/\s+/).length;
}, 0);
setTokenUsage({
prompt_tokens: Math.round(total * 0.7),
completion_tokens: Math.round(total * 0.3),
total_tokens: total
});
};
const handleDeleteConversation = (id, e) => {
e.stopPropagation();
const saved = localStorage.getItem("chat_conversations");
if (!saved) return;
try {
const list = JSON.parse(saved);
const filtered = list.filter(c => c.id !== id);
localStorage.setItem("chat_conversations", JSON.stringify(filtered));
setConversations(filtered);
if (activeConversationId === id) {
handleNewChat();
}
} catch (_) {}
};
const sendMessage = async () => {
@@ -351,70 +316,7 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings
};
return (
<div className="text-chat-layout" style={{ display: "flex", gap: "16px", padding: "20px", height: "100%", width: "100%", boxSizing: "border-box", overflow: "hidden" }}>
{/* Collapsible Chat History Sidebar */}
{showHistory && (
<aside className="chat-history-sidebar">
<button
className="m3-btn m3-btn-tonal"
onClick={handleNewChat}
style={{
width: "100%",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
fontSize: "0.85rem",
fontWeight: "600",
borderRadius: "var(--md-shape-corner-medium)",
cursor: "pointer",
border: "1px solid var(--border-color)"
}}
>
<Plus size={16} />
<span>New Chat</span>
</button>
<div className="history-items-list">
{conversations.length === 0 ? (
<div style={{ padding: "20px 10px", textAlign: "center", fontSize: "0.8rem", color: "var(--md-sys-color-outline)", opacity: 0.8 }}>
No saved chats
</div>
) : (
conversations.map((conv) => {
const isActive = activeConversationId === conv.id;
const formattedDate = new Date(conv.timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
return (
<div
key={conv.id}
className={`history-item ${isActive ? "active" : ""}`}
onClick={() => handleSelectConversation(conv)}
>
<div className="history-item-info">
<span className="history-item-title">{conv.title}</span>
<span className="history-item-meta">{formattedDate} • {conv.model?.split(/[\\/]/).pop() || "GGUF"}</span>
</div>
<button
className="history-item-delete"
onClick={(e) => handleDeleteConversation(conv.id, e)}
title="Delete Conversation"
>
<Trash2 size={13} />
</button>
</div>
);
})
)}
</div>
</aside>
)}
<div className="text-chat-layout" style={{ display: "flex", padding: "20px", 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" }}>
<div className="text-chat-header" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>