From ac2e42538660ccf1b0b1ca47e322938a60a47475 Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Fri, 21 Nov 2025 18:03:42 -0800 Subject: [PATCH 1/2] GML-1971 GML-2018 Show chat history and use it for question --- common/embeddings/embedding_services.py | 4 +- .../embeddings/tigergraph_embedding_store.py | 2 +- common/llm_services/base_llm.py | 6 +- .../chatbot_response.txt | 8 +- .../google_gemini/chatbot_response.txt | 8 +- .../prompts/openai_gpt4/chatbot_response.txt | 8 +- common/utils/token_calculator.py | 27 ++ graphrag-ui/.eslintrc.cjs | 19 - graphrag-ui/src/actions/ActionProvider.tsx | 81 +++- graphrag-ui/src/components/SideMenu.tsx | 374 ++++++++++++++---- graphrag/app/agent/agent.py | 2 +- graphrag/app/agent/agent_generation.py | 4 +- graphrag/app/agent/agent_graph.py | 57 ++- graphrag/app/agent/agent_router.py | 8 +- graphrag/app/routers/ui.py | 6 +- .../app/supportai/retrievers/BaseRetriever.py | 4 +- 16 files changed, 468 insertions(+), 150 deletions(-) delete mode 100755 graphrag-ui/.eslintrc.cjs diff --git a/common/embeddings/embedding_services.py b/common/embeddings/embedding_services.py index 8a24265..3606ff6 100644 --- a/common/embeddings/embedding_services.py +++ b/common/embeddings/embedding_services.py @@ -11,7 +11,7 @@ from common.logs.log import req_id_cv from common.logs.logwriter import LogWriter from common.metrics.prometheus_metrics import metrics -from common.utils.token_calculator import TokenCalculator +from common.utils.token_calculator import get_token_calculator logger = logging.getLogger(__name__) @@ -33,7 +33,7 @@ def __init__(self, config: dict, model_name: str): self.embeddings = None self.model_name = model_name self.dimensions = config.get("dimensions", 1536) - self.token_calculator = TokenCalculator(token_limit=config.get("token_limit", 8192), model_name=model_name) + self.token_calculator = get_token_calculator(token_limit=config.get("token_limit", 8192), model_name=model_name) LogWriter.info( f"request_id={req_id_cv.get()} instantiated AI model_name={model_name} with dimensions={self.dimensions}" ) diff --git a/common/embeddings/tigergraph_embedding_store.py b/common/embeddings/tigergraph_embedding_store.py index 0baf79b..982ffcd 100644 --- a/common/embeddings/tigergraph_embedding_store.py +++ b/common/embeddings/tigergraph_embedding_store.py @@ -335,7 +335,7 @@ def retrieve_similar_with_score(self, query_embedding, top_k=10, similarity_thre } ) end_time = time() - logger.info(f"Got {top_k} similar entries: {verts}") + # logger.info(f"Got {top_k} similar entries: {verts}") similar = [] for r in verts: if "results" in r: diff --git a/common/llm_services/base_llm.py b/common/llm_services/base_llm.py index 5dc8dcd..84f6b80 100644 --- a/common/llm_services/base_llm.py +++ b/common/llm_services/base_llm.py @@ -107,14 +107,16 @@ def generate_gsql_prompt(self): def route_response_prompt(self): """Property to get the prompt for the RouteResponse tool.""" prompt = """\ -You are an expert at routing a user question to a vectorstore or function calls. +You are an expert at routing a user question to a vectorstore, function calls, or purely conversation history. +Use the conversation history for questions that are directly related to the conversation history. Use the vectorstore for questions on that would be best suited by text documents. Use the function calls for questions that ask about structured data, or operations on structured data. Keep in mind that some questions about documents such as "how many documents are there?" can be answered by function calls. The function calls can be used to answer questions about these entities: {v_types} and relationships: {e_types}. -Otherwise, use vectorstore. Give a binary choice 'functions' or 'vectorstore' based on the question. +Otherwise, use vectorstore. Choose one of 'functions', 'vectorstore', or 'history' based on the question and conversation history. Return the a JSON with a single key 'datasource' and no premable or explaination. Question to route: {question} +Conversation history: {conversation} Format: {format_instructions}\ """ return prompt diff --git a/common/prompts/aws_bedrock_claude3haiku/chatbot_response.txt b/common/prompts/aws_bedrock_claude3haiku/chatbot_response.txt index bad5086..6acdaf5 100644 --- a/common/prompts/aws_bedrock_claude3haiku/chatbot_response.txt +++ b/common/prompts/aws_bedrock_claude3haiku/chatbot_response.txt @@ -1,9 +1,9 @@ You are a highly efficient and empathetic AI-powered knowledge graph assistant. Your goal is to provide accurate, helpful, and friendly response while maintaining professionalism. Follow these guidelines: -- Give the context in JSON format contains key-context pairs, combine and rephrase it to answer the question. -- Use mostly the provided information in context without adding any reasoning or additional logic. -- Make sure all information in the provided context are covered in the generated answer, especially image references providing critical visual information. +- Give the contexts in JSON format contains key-context pairs, combine and rephrase it to answer the question. +- Score the contexts for their relevance to the question and use only the information of the high-scoring contexts without adding extra logic. +- Make sure most relevant information in the provided contexts are covered in the generated answer, especially image references providing critical visual information. - Make sure to preserve the image links in markdown syntax "![description](url)" with its orignal format in the final answer if the context contains the links are used in the response. Do NOT modify or omit these image references. - Use markdown syntax to geneate the answer, including title, paragraphs, bulleted or numbered list, images and tables if any, and place images or tables below the related text section. - Ensure that each row of every table, including the header row, starts on a new line. @@ -12,6 +12,6 @@ Follow these guidelines: - Use the keys of the contexts used as citations if asked, DO NOT include citations in the final answer Question: {question} -Context: {context} +Contexts: {context} Query: {query} Format: {format_instructions} diff --git a/common/prompts/google_gemini/chatbot_response.txt b/common/prompts/google_gemini/chatbot_response.txt index bad5086..6acdaf5 100644 --- a/common/prompts/google_gemini/chatbot_response.txt +++ b/common/prompts/google_gemini/chatbot_response.txt @@ -1,9 +1,9 @@ You are a highly efficient and empathetic AI-powered knowledge graph assistant. Your goal is to provide accurate, helpful, and friendly response while maintaining professionalism. Follow these guidelines: -- Give the context in JSON format contains key-context pairs, combine and rephrase it to answer the question. -- Use mostly the provided information in context without adding any reasoning or additional logic. -- Make sure all information in the provided context are covered in the generated answer, especially image references providing critical visual information. +- Give the contexts in JSON format contains key-context pairs, combine and rephrase it to answer the question. +- Score the contexts for their relevance to the question and use only the information of the high-scoring contexts without adding extra logic. +- Make sure most relevant information in the provided contexts are covered in the generated answer, especially image references providing critical visual information. - Make sure to preserve the image links in markdown syntax "![description](url)" with its orignal format in the final answer if the context contains the links are used in the response. Do NOT modify or omit these image references. - Use markdown syntax to geneate the answer, including title, paragraphs, bulleted or numbered list, images and tables if any, and place images or tables below the related text section. - Ensure that each row of every table, including the header row, starts on a new line. @@ -12,6 +12,6 @@ Follow these guidelines: - Use the keys of the contexts used as citations if asked, DO NOT include citations in the final answer Question: {question} -Context: {context} +Contexts: {context} Query: {query} Format: {format_instructions} diff --git a/common/prompts/openai_gpt4/chatbot_response.txt b/common/prompts/openai_gpt4/chatbot_response.txt index bad5086..6acdaf5 100644 --- a/common/prompts/openai_gpt4/chatbot_response.txt +++ b/common/prompts/openai_gpt4/chatbot_response.txt @@ -1,9 +1,9 @@ You are a highly efficient and empathetic AI-powered knowledge graph assistant. Your goal is to provide accurate, helpful, and friendly response while maintaining professionalism. Follow these guidelines: -- Give the context in JSON format contains key-context pairs, combine and rephrase it to answer the question. -- Use mostly the provided information in context without adding any reasoning or additional logic. -- Make sure all information in the provided context are covered in the generated answer, especially image references providing critical visual information. +- Give the contexts in JSON format contains key-context pairs, combine and rephrase it to answer the question. +- Score the contexts for their relevance to the question and use only the information of the high-scoring contexts without adding extra logic. +- Make sure most relevant information in the provided contexts are covered in the generated answer, especially image references providing critical visual information. - Make sure to preserve the image links in markdown syntax "![description](url)" with its orignal format in the final answer if the context contains the links are used in the response. Do NOT modify or omit these image references. - Use markdown syntax to geneate the answer, including title, paragraphs, bulleted or numbered list, images and tables if any, and place images or tables below the related text section. - Ensure that each row of every table, including the header row, starts on a new line. @@ -12,6 +12,6 @@ Follow these guidelines: - Use the keys of the contexts used as citations if asked, DO NOT include citations in the final answer Question: {question} -Context: {context} +Contexts: {context} Query: {query} Format: {format_instructions} diff --git a/common/utils/token_calculator.py b/common/utils/token_calculator.py index 7bcd1ef..2157515 100644 --- a/common/utils/token_calculator.py +++ b/common/utils/token_calculator.py @@ -19,6 +19,33 @@ logger = logging.getLogger(__name__) +# Cache for TokenCalculator instances to avoid re-initialization +_token_calculator_cache: dict[tuple[str, int], 'TokenCalculator'] = {} + +def get_token_calculator(token_limit: int = 0, model_name: str = None) -> 'TokenCalculator': + """ + Factory function to get or create a TokenCalculator instance. + Reuses existing instances with the same model_name and token_limit to avoid re-initialization. + + Args: + token_limit: Maximum number of tokens allowed for retrieved context + model_name: Name of the model to use for token counting + + Returns: + TokenCalculator instance (cached if parameters match) + """ + model_name = model_name if model_name else "gpt-4" + token_limit = token_limit if token_limit else 0 + cache_key = (model_name, token_limit) + + if cache_key not in _token_calculator_cache: + _token_calculator_cache[cache_key] = TokenCalculator(token_limit=token_limit, model_name=model_name) + logger.debug(f"Created new TokenCalculator instance for model={model_name}, token_limit={token_limit}") + else: + logger.debug(f"Reusing cached TokenCalculator instance for model={model_name}, token_limit={token_limit}") + + return _token_calculator_cache[cache_key] + class TokenCalculator: """Utility class for token counting and text truncation operations.""" diff --git a/graphrag-ui/.eslintrc.cjs b/graphrag-ui/.eslintrc.cjs deleted file mode 100755 index 21b1b5f..0000000 --- a/graphrag-ui/.eslintrc.cjs +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - ], - ignorePatterns: ["dist", ".eslintrc.cjs"], - parser: "@typescript-eslint/parser", - plugins: ["react-refresh"], - rules: { - "@typescript-eslint/ban-ts-comment": "off", - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - }, -}; diff --git a/graphrag-ui/src/actions/ActionProvider.tsx b/graphrag-ui/src/actions/ActionProvider.tsx index 968635f..d12c9f7 100644 --- a/graphrag-ui/src/actions/ActionProvider.tsx +++ b/graphrag-ui/src/actions/ActionProvider.tsx @@ -91,10 +91,10 @@ const ActionProvider: React.FC = ({ const creds = localStorage.getItem("creds"); console.log("Sending credentials, length:", creds ? creds.length : 0); queryGraphragWs2(creds!); - + // Send RAG pattern //sendMessage(selectedRagPattern); - + // Send conversation ID (or "new" for new conversation) const conversationId = conversationManager.getCurrentConversationId(); const conversationIdToSend = conversationId || "new"; @@ -113,22 +113,76 @@ const ActionProvider: React.FC = ({ }, }); - // Initialize conversation manager with any existing conversation data + // Initialize conversation manager and load conversation messages useEffect(() => { const selectedConversationData = localStorage.getItem('selectedConversationData'); if (selectedConversationData) { try { const data = JSON.parse(selectedConversationData); - // Extract conversation ID from the first message - if (data.messages && data.messages.length > 0) { - const conversationId = data.messages[0].conversation_id; + + // Handle different data structures + let messages: any[] = []; + let conversationId: string | null = null; + + if (Array.isArray(data) && data.length > 0) { + // Direct array of messages from API + messages = data; + conversationId = data[0].conversation_id; + } else if (data.messages && Array.isArray(data.messages)) { + // Wrapped in messages property + messages = data.messages; + conversationId = data.messages[0]?.conversation_id; + } else if (data.content && Array.isArray(data.content)) { + // Wrapped in content property (from fetchHistory2) + messages = data.content; + conversationId = data.conversation_id || data.content[0]?.conversation_id; + } + + if (conversationId) { conversationManager.setCurrentConversationId(conversationId); } + + // Load conversation messages into the chat UI + // Sort messages by timestamp if available to maintain chronological order + const sortedMessages = [...messages].sort((a: any, b: any) => { + const timeA = a.create_ts ? new Date(a.create_ts).getTime() : 0; + const timeB = b.create_ts ? new Date(b.create_ts).getTime() : 0; + return timeA - timeB; // Oldest first + }); + + const loadedMessages: any[] = []; + + sortedMessages.forEach((msg: any) => { + if (msg.role === "user") { + // Create user message + const userMessage = createClientMessage(msg.content || "", { + delay: 0, + }); + loadedMessages.push(userMessage); + } else if (msg.role === "system") { + // Create bot message + const botMessage = createChatBotMessage({ + content: msg.content || "", + response_type: msg.response_type || "text", + query_sources: msg.query_sources, + answered_question: msg.answered_question, + }); + loadedMessages.push(botMessage); + } + }); + + // Set the loaded messages in the chat state + if (loadedMessages.length > 0) { + setState((prev: any) => ({ + ...prev, + messages: loadedMessages, + })); + } } catch (error) { - console.error("Error parsing conversation data:", error); + // Silently handle error parsing conversation data } } - }, []); + }, [createChatBotMessage, createClientMessage, setState]); // eslint-disable-next-line // @ts-ignore @@ -168,6 +222,10 @@ const ActionProvider: React.FC = ({ ...prev, messages: [...prev.messages, loading], })); + + // Dispatch event to refresh conversation list when user sends a question + // This ensures the side menu updates when a new message is sent + window.dispatchEvent(new CustomEvent('conversationUpdated')); }; // FOR REFERENCE @@ -200,16 +258,17 @@ const ActionProvider: React.FC = ({ useEffect(() => { if (lastMessage !== null) { setMessageHistory((prev) => prev.concat(lastMessage)); - + try { const messageData = JSON.parse(lastMessage.data); - + // Check if this is a conversation ID message (first message from backend) if (messageData.conversation_id && !messageData.content) { conversationManager.setCurrentConversationId(messageData.conversation_id); + // Don't dispatch refresh event here - refresh happens when user sends the question return; // Don't create a bot message for conversation ID } - + // Handle regular bot messages const botMessage = createChatBotMessage(messageData); setState((prev) => { diff --git a/graphrag-ui/src/components/SideMenu.tsx b/graphrag-ui/src/components/SideMenu.tsx index f37e477..a1980b5 100644 --- a/graphrag-ui/src/components/SideMenu.tsx +++ b/graphrag-ui/src/components/SideMenu.tsx @@ -6,7 +6,7 @@ import { IoCartOutline } from "react-icons/io5"; import { FiKey } from "react-icons/fi"; import { IoIosHelpCircleOutline } from "react-icons/io"; import { HiOutlineChatBubbleOvalLeft } from "react-icons/hi2"; -import { MdKeyboardArrowDown } from "react-icons/md"; +import { MdKeyboardArrowDown, MdKeyboardArrowUp } from "react-icons/md"; import { IoIosArrowForward } from "react-icons/io"; import { useTheme } from "@/components/ThemeProvider"; import { GoGear } from "react-icons/go"; @@ -51,7 +51,7 @@ import { } from "@/components/ui/select" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { FaPaperclip } from "react-icons/fa6"; -import { useEffect } from "react"; +import { useEffect, useCallback } from "react"; import { conversationManager } from "../actions/ActionProvider"; import { useNavigate } from "react-router-dom"; @@ -62,16 +62,27 @@ const WS_CONVO_URL = "/ui/conversation"; const SideMenu = ({ height, setGetConversationId }: { height?: string, setGetConversationId?: any }) => { const getTheme = useTheme().theme; // const [conhistory, setConHistory] = useState([]); - const [conversationId, setConversationId] = useState([]); - const [conversationId2, setConversationId2] = useState([]); - const [newSet, setNewSet] = useState([]); + const [conversationId, setConversationId] = useState([]); + const [conversationId2, setConversationId2] = useState([]); + const [newSet, setNewSet] = useState([]); + const [expandedConversations, setExpandedConversations] = useState>(new Set()); + const [activeConversationId, setActiveConversationId] = useState(null); const navigate = useNavigate(); - - const fetchHistory2 = async () => { + + const fetchHistory2 = useCallback(async () => { setConversationId([]); const creds = localStorage.getItem("creds"); const username = localStorage.getItem("username"); + + if (!username) { + return; + } + + if (!creds) { + return; + } + const settings = { method: 'GET', headers: { @@ -79,22 +90,72 @@ const SideMenu = ({ height, setGetConversationId }: { height?: string, setGetCon "Content-Type": "application/json", } } - const response = await fetch(`${WS_HISTORY_URL}/${username}`, settings); - const data = await response.json(); - data.map(async (item: any) => { - const response2 = await fetch(`${WS_CONVO_URL}/${item.conversation_id}`, settings); - const obj = { - conversation_id: item.conversation_id, - content: await response2.json(), - date: formatDate(item.create_ts) - // date: item.create_ts.filter((val,id,array) => array.indexOf(val) == id) + try { + const response = await fetch(`${WS_HISTORY_URL}/${username}`, settings); + + if (!response.ok) { + setConversationId([]); + return; } - // eslint-disable-next-line - // @ts-ignore - setConversationId(prev => [...prev, obj]); - // console.log('resp', conversationId); - }); - } + + const data = await response.json(); + + if (!Array.isArray(data) || data.length === 0) { + setConversationId([]); + return; + } + + // Sort conversations by update_ts (most recently updated first), fallback to create_ts + const sortedData = [...data].sort((a: any, b: any) => { + // Use update_ts if available, otherwise use create_ts + const timeA = new Date(a.update_ts || a.create_ts).getTime(); + const timeB = new Date(b.update_ts || b.create_ts).getTime(); + return timeB - timeA; // Most recently updated first + }); + + // Wait for all conversation details to be fetched + const conversationPromises = sortedData.map(async (item: any) => { + try { + const response2 = await fetch(`${WS_CONVO_URL}/${item.conversation_id}`, settings); + if (!response2.ok) { + return null; + } + const content = await response2.json(); + + // Get the most recent message timestamp for sorting + let lastUpdateTime = item.update_ts || item.create_ts; + if (Array.isArray(content) && content.length > 0) { + // Find the most recent message timestamp + const messageTimes = content + .map((msg: any) => msg.create_ts || msg.update_ts) + .filter((ts: any) => ts != null) + .map((ts: any) => new Date(ts).getTime()); + if (messageTimes.length > 0) { + const latestMessageTime = Math.max(...messageTimes); + lastUpdateTime = new Date(latestMessageTime).toISOString(); + } + } + + return { + conversation_id: item.conversation_id, + content: content, + date: formatDate(item.create_ts), + create_ts: item.create_ts, + update_ts: lastUpdateTime // Use for sorting by most recent activity + }; + } catch (error) { + return null; + } + }); + + const conversations = await Promise.all(conversationPromises); + // Filter out any null values from failed requests + const validConversations = conversations.filter(conv => conv !== null); + setConversationId(validConversations as any); + } catch (error) { + setConversationId([]); + } + }, []); const formatDate = (dateString) => { const options = { year: "numeric" as const, month: "long" as const, day: "numeric" as const} @@ -103,65 +164,176 @@ const SideMenu = ({ height, setGetConversationId }: { height?: string, setGetCon const handleNewChat = () => { conversationManager.startNewConversation(); - navigate("/chat"); - //window.location.reload(); + // Clear any selected conversation data + localStorage.removeItem('selectedConversationData'); + // Force navigation by reloading if already on chat page + if (window.location.pathname === "/chat") { + window.location.reload(); + } else { + navigate("/chat"); + } }; // eslint-disable-next-line // @ts-ignore const resumeConvo = async (id):any => { - // Load conversation into conversation manager - conversationManager.loadConversation(id); - - // Store conversation data for the chat component - const creds = localStorage.getItem("creds"); - const settings = { - method: 'GET', - headers: { - Authorization: `Basic ${creds}`, - "Content-Type": "application/json", + try { + // Load conversation into conversation manager + conversationManager.loadConversation(id); + + // Set as active conversation and expand it + setActiveConversationId(id); + setExpandedConversations(prev => new Set([...prev, id])); + + // Store conversation data for the chat component + const creds = localStorage.getItem("creds"); + if (!creds) { + return; + } + + const settings = { + method: 'GET', + headers: { + Authorization: `Basic ${creds}`, + "Content-Type": "application/json", + } + } + + const response = await fetch(`${WS_CONVO_URL}/${id}`, settings); + if (!response.ok) { + return; + } + + const data = await response.json(); + setConversationId2(data); + + // Store the conversation data in localStorage for the chat component + localStorage.setItem('selectedConversationData', JSON.stringify(data)); + + // Force reload to restart the WebSocket connection with the conversation ID + // This ensures the Bot component re-initializes and loads the conversation messages + if (window.location.pathname === "/chat") { + window.location.reload(); + } else { + navigate("/chat"); } + } catch (error) { + // Silently handle error } - const response = await fetch(`${WS_CONVO_URL}/${id}`, settings); - const data = await response.json(); - setConversationId2(data); - - // Store the conversation data in localStorage for the chat component - localStorage.setItem('selectedConversationData', JSON.stringify(data)); - - // Reload the page to restart the WebSocket connection with the new conversation ID - navigate("/chat"); - //window.location.reload(); + } + + const toggleConversation = (conversationId: string) => { + setExpandedConversations(prev => { + const newSet = new Set(prev); + if (newSet.has(conversationId)) { + newSet.delete(conversationId); + } else { + newSet.add(conversationId); + } + return newSet; + }); } const renderConvoHistory = () => { + if (newSet.length === 0) { + return ( +
+

+ No chat history yet. Start a new conversation to see it here. +

+
+ ); + } + + // Group conversations by date + const groupedByDate = newSet.reduce((acc: Record, item: any) => { + const date = item.date; + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(item); + return acc; + }, {} as Record); + + // Sort dates (most recently updated first) - convert to array and sort by the first conversation's timestamp + const sortedDates = Object.entries(groupedByDate).sort(([, convsA], [, convsB]) => { + const timeA = new Date(convsA[0]?.update_ts || convsA[0]?.create_ts || convsA[0]?.date || 0).getTime(); + const timeB = new Date(convsB[0]?.update_ts || convsB[0]?.create_ts || convsB[0]?.date || 0).getTime(); + return timeB - timeA; // Most recently updated first + }); + return (
- {newSet.map((item: any, i) => { + {sortedDates.map(([date, conversations]) => { return ( -
+

- {item.date} + {date}

- )} - )} + ); + })}
) } @@ -169,22 +341,70 @@ const SideMenu = ({ height, setGetConversationId }: { height?: string, setGetCon useEffect(() => { fetchHistory2(); + }, [fetchHistory2]); + + // Refresh history when component becomes visible (user returns to chat page) + useEffect(() => { + const handleVisibilityChange = () => { + if (!document.hidden) { + fetchHistory2(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [fetchHistory2]); + + // Listen for conversation creation/update events to refresh the history + useEffect(() => { + const handleConversationEvent = () => { + // Debounce to avoid too many refreshes + setTimeout(() => { + fetchHistory2(); + }, 500); + }; + + window.addEventListener('conversationCreated', handleConversationEvent); + window.addEventListener('conversationUpdated', handleConversationEvent); + + return () => { + window.removeEventListener('conversationCreated', handleConversationEvent); + window.removeEventListener('conversationUpdated', handleConversationEvent); + }; + }, [fetchHistory2]); + + useEffect(() => { setGetConversationId(conversationId); - const groupByDate = array => array.reduce((results, item) => { - const current = results.find(i => i.date === item.date); - if (current) { - for (let property in item) { - if (property !== 'date') { - current[property] = item[property]; - } - } - } else { - results.push({...item}); + // Sort by update_ts (most recently updated first), fallback to create_ts + const sorted = [...conversationId].sort((a, b) => { + const timeA = new Date(a.update_ts || a.create_ts || a.date).getTime(); + const timeB = new Date(b.update_ts || b.create_ts || b.date).getTime(); + return timeB - timeA; // Most recently updated first + }); + setNewSet(sorted); + }, [conversationId]) + + // Track active conversation from conversationManager + useEffect(() => { + const checkActiveConversation = () => { + const currentId = conversationManager.getCurrentConversationId(); + if (currentId && currentId !== activeConversationId) { + setActiveConversationId(currentId); + // Auto-expand the active conversation + setExpandedConversations(prev => new Set([...prev, currentId])); + } else if (!currentId) { + setActiveConversationId(null); } - return results; - }, []); - setNewSet(groupByDate(conversationId)); - }, []) + }; + + // Check immediately + checkActiveConversation(); + + // Check periodically (every 500ms) to catch changes + const interval = setInterval(checkActiveConversation, 500); + + return () => clearInterval(interval); + }, [activeConversationId]); return (
dict: """Generate an answer based on the question and context. diff --git a/graphrag/app/agent/agent_graph.py b/graphrag/app/agent/agent_graph.py index b827a45..2eefa2b 100644 --- a/graphrag/app/agent/agent_graph.py +++ b/graphrag/app/agent/agent_graph.py @@ -112,17 +112,16 @@ def route_question(self, state): logger.debug_pii( f"request_id={req_id_cv.get()} Routing question: {state['question']}" ) - if self.supportai_enabled: - source = step.route_question(state["question"]) - logger.debug_pii( - f"request_id={req_id_cv.get()} Routing question to: {source}" - ) - if source.datasource == "vectorstore": - return "supportai_lookup" - elif source.datasource == "functions": - return "inquiryai_lookup" - else: + source = step.route_question(state["question"], state["conversation"]) + logger.debug_pii( + f"request_id={req_id_cv.get()} Routing question to: {source}" + ) + if self.supportai_enabled and source.datasource == "vectorstore": + return "supportai_lookup" + elif source.datasource == "functions": return "inquiryai_lookup" + else: + return "history_lookup" def apologize(self, state): """ @@ -363,6 +362,9 @@ def generate_answer(self, state): f"request_id={req_id_cv.get()} Generating answer for question: {state['question']}" ) + if "lookup_source" not in state: + state["lookup_source"] = "history" + if state["lookup_source"] == "supportai": logger.debug_pii( f"""request_id={req_id_cv.get()} Got result: {state["context"]["result"]}""" @@ -398,6 +400,20 @@ def generate_answer(self, state): f"""request_id={req_id_cv.get()} Got result: {state["context"]["answer"]}""" ) answer = step.generate_answer(state["question"], state["context"]["answer"], state["context"]["cypher"]) + + elif state["lookup_source"] == "history": + state["context"] = { + "result": state["conversation"], + "reasoning": "The following conversation history was used to answer the question. {}".format( + state["conversation"] + ), + } + + logger.debug_pii( + f"""request_id={req_id_cv.get()} Got result: {state["context"]["result"]}""" + ) + answer = step.generate_answer(state["question"], state["context"]["result"]) + logger.debug_pii( f"request_id={req_id_cv.get()} Generated answer: {answer.generated_answer}" ) @@ -629,11 +645,20 @@ def create_graph(self): self.check_state_for_generation_error, {"error": "generate_cypher", "success": "generate_answer"}, ) - self.workflow.add_conditional_edges( - "generate_cypher", - self.check_state_for_generation_error, - {"error": "apologize", "success": "generate_answer"}, - ) + + if self.supportai_enabled: + self.workflow.add_conditional_edges( + "generate_cypher", + self.check_state_for_generation_error, + {"error": "supportai", "success": "generate_answer"}, + ) + else: + self.workflow.add_conditional_edges( + "generate_cypher", + self.check_state_for_generation_error, + {"error": "apologize", "success": "generate_answer"}, + ) + # remove hallucination and usefulness check if self.supportai_enabled: self.workflow.add_conditional_edges( @@ -699,6 +724,7 @@ def create_graph(self): { "supportai_lookup": "supportai", "inquiryai_lookup": "map_question_to_schema", + "history_lookup": "generate_answer", "apologize": "apologize", }, ) @@ -708,6 +734,7 @@ def create_graph(self): self.route_question, { "inquiryai_lookup": "map_question_to_schema", + "history_lookup": "generate_answer", "apologize": "apologize", }, ) diff --git a/graphrag/app/agent/agent_router.py b/graphrag/app/agent/agent_router.py index 08c0c12..6a1bacd 100644 --- a/graphrag/app/agent/agent_router.py +++ b/graphrag/app/agent/agent_router.py @@ -32,7 +32,7 @@ def __init__(self, llm_model, db_conn: TigerGraphConnection): self.llm = llm_model self.db_conn = db_conn - def route_question(self, question: str) -> str: + def route_question(self, question: str, conversation: list[dict[str, str]] = None) -> str: """Route a question to the appropriate datasource. Args: @@ -45,11 +45,11 @@ def route_question(self, question: str) -> str: v_types = self.db_conn.getVertexTypes() e_types = self.db_conn.getEdgeTypes() - router_parser = PydanticOutputParser(pydantic_object=RouterResponse) + router_parser = PydanticOutputParser[RouterResponse](pydantic_object=RouterResponse) prompt = PromptTemplate( template=self.llm.route_response_prompt, - input_variables=["question", "v_types", "e_types"], + input_variables=["question", "v_types", "e_types", "conversation"], partial_variables={ "format_instructions": router_parser.get_format_instructions() } @@ -58,7 +58,7 @@ def route_question(self, question: str) -> str: question_router = prompt | self.llm.model | router_parser usage_data = {} with get_openai_callback() as cb: - res = question_router.invoke({"question": question, "v_types": v_types, "e_types": e_types}) + res = question_router.invoke({"question": question, "v_types": v_types, "e_types": e_types, "conversation": conversation}) usage_data["input_tokens"] = cb.prompt_tokens usage_data["output_tokens"] = cb.completion_tokens diff --git a/graphrag/app/routers/ui.py b/graphrag/app/routers/ui.py index bce198e..9637347 100644 --- a/graphrag/app/routers/ui.py +++ b/graphrag/app/routers/ui.py @@ -613,7 +613,7 @@ async def run_agent( async def load_conversation_history(conversation_id: str, usr_auth: str) -> list[dict[str, str]]: """ Load conversation history from the chat history service. - Returns a list of dicts with 'query' and 'response' keys. + Returns a list of dicts with 'query', 'response', 'create_ts', and 'update_ts' keys. """ if not conversation_id or conversation_id == "new": return [] @@ -642,7 +642,9 @@ async def load_conversation_history(conversation_id: str, usr_auth: str) -> list response_msg.get("parent_id") == msg.get("message_id")): history.append({ "query": msg.get("content", ""), - "response": response_msg.get("content", "") + "response": response_msg.get("content", ""), + "create_ts": response_msg.get("create_ts"), + "update_ts": response_msg.get("update_ts"), }) break diff --git a/graphrag/app/supportai/retrievers/BaseRetriever.py b/graphrag/app/supportai/retrievers/BaseRetriever.py index 4451b14..3e1e556 100644 --- a/graphrag/app/supportai/retrievers/BaseRetriever.py +++ b/graphrag/app/supportai/retrievers/BaseRetriever.py @@ -3,7 +3,7 @@ from common.metrics.tg_proxy import TigerGraphConnectionProxy from common.llm_services.base_llm import LLM_Model from common.py_schemas import CandidateScore, CandidateGenerator, GraphRAGAnswerOutput -from common.utils.token_calculator import TokenCalculator +from common.utils.token_calculator import get_token_calculator from common.config import completion_config from langchain_core.output_parsers import StrOutputParser, PydanticOutputParser @@ -29,7 +29,7 @@ def __init__( self.embedding_store = embedding_store self.embedding_store.set_graphname(connection.graphname) self.logger = logging.getLogger(__name__) - self.token_calculator = TokenCalculator(token_limit=completion_config.get("token_limit"), model_name=completion_config.get("llm_model")) + self.token_calculator = get_token_calculator(token_limit=completion_config.get("token_limit"), model_name=completion_config.get("llm_model")) def _install_query(self, query_name): self.logger.info(f"Installing query {query_name}") From 76e28f6da739b9de8181c57cfda9a1aebec7e0d9 Mon Sep 17 00:00:00 2001 From: Chengbiao Jin Date: Fri, 21 Nov 2025 18:05:08 -0800 Subject: [PATCH 2/2] restore graphrag-ui/.eslintrc.cjs --- graphrag-ui/.eslintrc.cjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100755 graphrag-ui/.eslintrc.cjs diff --git a/graphrag-ui/.eslintrc.cjs b/graphrag-ui/.eslintrc.cjs new file mode 100755 index 0000000..696a85c --- /dev/null +++ b/graphrag-ui/.eslintrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + ], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], + rules: { + "@typescript-eslint/ban-ts-comment": "off", + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "no-trailing-spaces": ["error", { "skipBlankLines": false }], + }, +};