diff --git a/client/src/api.js b/client/src/api.js index b1c45da..e698d6f 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -331,10 +331,13 @@ export async function stopModelInference() { } } -export async function queryChatBot(query) { +export async function queryChatBot(query, conversationId) { try { - const res = await axios.post(`${BASE_URL}/chat/query`, { query }); - return res.data?.response; + const res = await axios.post(`${BASE_URL}/chat/query`, { + query, + conversationId, + }); + return res.data; } catch (error) { handleError(error); } @@ -348,6 +351,75 @@ export async function clearChat() { } } +// ── Conversation history endpoints ─────────────────────────────────────────── + +export async function listConversations() { + try { + const res = await axios.get(`${BASE_URL}/chat/conversations`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function createConversation() { + try { + const res = await axios.post(`${BASE_URL}/chat/conversations`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getConversation(convoId) { + try { + const res = await axios.get(`${BASE_URL}/chat/conversations/${convoId}`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function deleteConversation(convoId) { + try { + await axios.delete(`${BASE_URL}/chat/conversations/${convoId}`); + } catch (error) { + handleError(error); + } +} + +export async function updateConversationTitle(convoId, title) { + try { + const res = await axios.patch(`${BASE_URL}/chat/conversations/${convoId}`, { + title, + }); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function queryHelperChat(taskKey, query, fieldContext) { + try { + const res = await axios.post(`${BASE_URL}/chat/helper/query`, { + taskKey, + query, + fieldContext, + }); + return res.data?.response; + } catch (error) { + handleError(error); + } +} + +export async function clearHelperChat(taskKey) { + try { + await axios.post(`${BASE_URL}/chat/helper/clear`, { taskKey }); + } catch (error) { + handleError(error); + } +} + export async function getConfigPresets() { return makeApiRequest("pytc/configs", "get"); } diff --git a/client/src/components/Chatbot.js b/client/src/components/Chatbot.js index 0aa8a25..1787503 100644 --- a/client/src/components/Chatbot.js +++ b/client/src/components/Chatbot.js @@ -1,70 +1,148 @@ -import React, { useEffect, useState, useRef } from "react"; -import { Button, Input, List, Typography, Space, Spin, Popconfirm } from "antd"; -import { SendOutlined, CloseOutlined, DeleteOutlined } from "@ant-design/icons"; -import { queryChatBot, clearChat } from "../api"; +import React, { useCallback, useEffect, useState, useRef } from "react"; +import { + Button, + Input, + List, + Typography, + Space, + Spin, + Popconfirm, + Tooltip, +} from "antd"; +import { + SendOutlined, + CloseOutlined, + DeleteOutlined, + PlusOutlined, + MessageOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, +} from "@ant-design/icons"; +import { + queryChatBot, + clearChat, + listConversations, + getConversation, + deleteConversation, +} from "../api"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; const { TextArea } = Input; const { Text } = Typography; -const initialMessage = [ - { - id: 1, - text: "Hello! I'm your AI assistant, built to help you navigate PyTC Client. How can I help you today?", - isUser: false, - }, -]; + +const GREETING = { + role: "assistant", + content: + "Hello! I'm your AI assistant, built to help you navigate PyTC Client. How can I help you today?", +}; + +/* ─── helper: truncate a string to `n` chars ─────────────────────────────── */ +const truncate = (str, n = 50) => + str.length > n ? str.slice(0, n).trimEnd() + "…" : str; + +/* ═══════════════════════════════════════════════════════════════════════════ */ function Chatbot({ onClose }) { - const [messages, setMessages] = useState(() => { - const saved = localStorage.getItem("chatMessages"); - return saved ? JSON.parse(saved) : initialMessage; - }); + /* ── state ─────────────────────────────────────────────────────────────── */ + const [conversations, setConversations] = useState([]); + const [activeConvoId, setActiveConvoId] = useState(null); + const [messages, setMessages] = useState([GREETING]); const [inputValue, setInputValue] = useState(""); const [isSending, setIsSending] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [isLoadingConvo, setIsLoadingConvo] = useState(false); + const lastMessageRef = useRef(null); - const scrollToLastMessage = () => { + /* ── scroll ────────────────────────────────────────────────────────────── */ + const scrollToBottom = useCallback(() => { setTimeout(() => { - if (lastMessageRef.current) { - lastMessageRef.current.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - } + lastMessageRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); }, 0); - }; + }, []); useEffect(() => { - localStorage.setItem("chatMessages", JSON.stringify(messages)); - }, [messages]); + scrollToBottom(); + }, [messages, isSending, scrollToBottom]); + /* ── load conversation list on mount ────────────────────────────────────── */ useEffect(() => { - scrollToLastMessage(); - }, [messages, isSending]); + let cancelled = false; + (async () => { + try { + const convos = await listConversations(); + if (!cancelled && convos) setConversations(convos); + } catch { + // server may not be ready yet + } + })(); + return () => { + cancelled = true; + }; + }, []); + + /* ── switch conversation ───────────────────────────────────────────────── */ + const loadConversation = async (convoId) => { + if (convoId === activeConvoId) return; + setIsLoadingConvo(true); + try { + const convo = await getConversation(convoId); + if (!convo) return; + await clearChat(); // reset LangChain in-memory state + setActiveConvoId(convo.id); + const dbMessages = + convo.messages?.map((m) => ({ role: m.role, content: m.content })) ?? + []; + setMessages([GREETING, ...dbMessages]); + } finally { + setIsLoadingConvo(false); + } + }; + + /* ── new chat ──────────────────────────────────────────────────────────── */ + const handleNewChat = async () => { + await clearChat(); + setActiveConvoId(null); + setMessages([GREETING]); + setInputValue(""); + }; + /* ── send message ──────────────────────────────────────────────────────── */ const handleSendMessage = async () => { if (!inputValue.trim() || isSending) return; const query = inputValue; setInputValue(""); - const userMessage = { id: messages.length + 1, text: query, isUser: true }; - setMessages((prev) => [...prev, userMessage]); + setMessages((prev) => [...prev, { role: "user", content: query }]); setIsSending(true); try { - const responseText = await queryChatBot(query); - const botMessage = { - id: userMessage.id + 1, - text: responseText || "Sorry, I could not generate a response.", - isUser: false, - }; - setMessages((prev) => [...prev, botMessage]); + const data = await queryChatBot(query, activeConvoId); + const response = + data?.response || "Sorry, I could not generate a response."; + const returnedConvoId = data?.conversationId ?? activeConvoId; + + setMessages((prev) => [ + ...prev, + { role: "assistant", content: response }, + ]); + + // If this was the first message in a brand-new chat, we now have a convoId + if (!activeConvoId && returnedConvoId) { + setActiveConvoId(returnedConvoId); + } + + // Refresh sidebar so the new / updated conversation appears + const convos = await listConversations(); + if (convos) setConversations(convos); } catch (e) { setMessages((prev) => [ ...prev, { - id: prev.length + 1, - text: e.message || "Error contacting chatbot.", - isUser: false, + role: "assistant", + content: e.message || "Error contacting chatbot.", }, ]); } finally { @@ -79,190 +157,335 @@ function Chatbot({ onClose }) { } }; - const handleClearChat = async () => { - try { - await clearChat(); - setMessages(initialMessage); - localStorage.setItem("chatMessages", JSON.stringify(initialMessage)); - } catch (e) { - console.error("Failed to clear chat:", e); + /* ── delete conversation ───────────────────────────────────────────────── */ + const handleDeleteConvo = async (convoId, e) => { + if (e) e.stopPropagation(); + await deleteConversation(convoId); + setConversations((prev) => prev.filter((c) => c.id !== convoId)); + if (activeConvoId === convoId) { + await handleNewChat(); } }; - return ( -
-
( + + ), + ol: ({ children }) => ( +
    {children}
+ ), + table: ({ children }) => ( +
+ + {children} +
+
+ ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + - AI Assistant - - + ), + td: ({ children }) => ( + + {children} + + ), + code: ({ inline, children }) => + inline ? ( + + {children} + + ) : ( +
+          {children}
+        
+ ), + pre: ({ children }) => <>{children}, + }; + + /* ═══════════════════════════════════════════════════════════════════════ */ + /* RENDER */ + /* ═══════════════════════════════════════════════════════════════════════ */ + return ( +
+ {/* ── Sidebar ─────────────────────────────────────────────────────── */} + {sidebarOpen && ( +
+ {/* header */} +
-
+ + Chats + + + +
+ + {/* conversation list */} +
+ {conversations.length === 0 && ( + + No past chats yet + + )} + {conversations.map((c) => ( +
loadConversation(c.id)} + style={{ + padding: "8px", + margin: "2px 0", + borderRadius: 6, + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 8, + background: + c.id === activeConvoId ? "#e6f4ff" : "transparent", + border: + c.id === activeConvoId + ? "1px solid #91caff" + : "1px solid transparent", + transition: "background 0.15s", + }} + onMouseEnter={(e) => { + if (c.id !== activeConvoId) + e.currentTarget.style.background = "#f0f0f0"; + }} + onMouseLeave={(e) => { + if (c.id !== activeConvoId) + e.currentTarget.style.background = "transparent"; + }} + > + + + {truncate(c.title)} + + handleDeleteConvo(c.id, e)} + onCancel={(e) => e?.stopPropagation()} + okText="Delete" + cancelText="Cancel" + > +
+ ))} +
+
+ )} + + {/* ── Main chat area ──────────────────────────────────────────────── */}
- { - const isLastMessage = index === messages.length - 1; - return ( - -
- {message.isUser ? ( - {message.text} - ) : ( - ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - table: ({ children }) => ( -
- - {children} -
-
- ), - thead: ({ children }) => ( - - {children} - - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - code: ({ inline, children }) => - inline ? ( - - {children} - - ) : ( -
-                              {children}
-                            
- ), - pre: ({ children }) => <>{children}, + {/* header */} +
+ + {!sidebarOpen && ( + +
+ + {/* messages */} +
+ {isLoadingConvo ? ( +
+ +
+ ) : ( + { + const isLast = index === messages.length - 1; + const isUser = message.role === "user"; + return ( + +
- {message.text} - - )} -
-
- ); - }} - /> - {isSending && } -
-
- -