diff --git a/app/frontend/src/App.css b/app/frontend/src/App.css index ab7aa7c..319ddf8 100644 --- a/app/frontend/src/App.css +++ b/app/frontend/src/App.css @@ -1474,3 +1474,97 @@ html[data-theme="dark"] .m3-segment-item:hover:not(.active):not(.disabled) { 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; + color: var(--md-sys-color-outline); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s, background-color 0.2s; + flex-shrink: 0; + margin-left: 6px; +} + +.history-item-delete:hover { + color: var(--md-sys-color-error); + background: rgba(239, 68, 68, 0.1); +} diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx index 8f2c7f9..c03edc3 100644 --- a/app/frontend/src/components/TextChat.jsx +++ b/app/frontend/src/components/TextChat.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Bot, LoaderCircle, Send, Trash2, Square } from "lucide-react"; +import { Bot, LoaderCircle, Send, Trash2, Square, History, Plus } from "lucide-react"; import { chatWithLlm, getDownloadProgress, @@ -17,6 +17,9 @@ 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, @@ -31,6 +34,16 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings loadingModelRef.current = loadingModel; }, [loadingModel]); + // Load conversations on mount + useEffect(() => { + const saved = localStorage.getItem("chat_conversations"); + if (saved) { + try { + setConversations(JSON.parse(saved)); + } catch (_) {} + } + }, []); + const refresh = useCallback(async () => { const [nextModels, nextStatus] = await Promise.all([listLlmModels(), getLlmStatus()]); setModels(nextModels); @@ -126,13 +139,89 @@ 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) => sum + m.content.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 () => { const text = input.trim(); if (!text || isBusy || !status.ready) return; + + let convId = activeConversationId; + let isNew = false; + if (!convId) { + convId = "chat_" + Date.now(); + setActiveConversationId(convId); + isNew = true; + } + const nextMessages = [...messages, { role: "user", content: text }]; setMessages(nextMessages); setInput(""); setIsBusy(true); + + const firstTitle = isNew ? (text.slice(0, 26) + (text.length > 26 ? "..." : "")) : null; + saveConversationState(convId, nextMessages, selectedModel, firstTitle); + try { const systemPrompt = textSettings?.systemPrompt || "You are a helpful local AI assistant."; const requestMessages = [ @@ -143,12 +232,16 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings temperature: textSettings?.temperature || 0.7, maxTokens: 768 }); - setMessages([...nextMessages, { role: "assistant", content: response.content }]); + const finalMessages = [...nextMessages, { role: "assistant", content: response.content }]; + setMessages(finalMessages); if (response.usage) { setTokenUsage(response.usage); } + saveConversationState(convId, finalMessages, selectedModel); } catch (err) { - setMessages([...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }]); + const finalMessages = [...nextMessages, { role: "assistant", content: `Error: ${err.message}`, error: true }]; + setMessages(finalMessages); + saveConversationState(convId, finalMessages, selectedModel); } finally { setIsBusy(false); } @@ -157,13 +250,102 @@ function TextChat({ specs, showAlert, showConfirm, textSettings, setTextSettings const handleClearChat = () => { setMessages([]); setTokenUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }); + if (activeConversationId) { + saveConversationState(activeConversationId, [], selectedModel); + } }; return ( -