feat: initialize frontend application structure with TextChat and Sidebar components
This commit is contained in:
1 parent
2e24396b35
commit
49725362c5
3 files changed
+235
-149
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
Reference in new issue
Block a user