Spaces:
Running
Running
Optimize RAG evaluation to unified single LLM call, increase timeouts, and handle 429 quota limits
1d7b287 | import React from 'react'; | |
| import { useEffect, useState } from "react"; | |
| import MessageList from "./MessageList"; | |
| import InputBox from "./InputBox"; | |
| import { BASE_URL } from "../../services/api"; | |
| import EvaluationPanel from "../evaluation/EvaluationPanel"; | |
| import { getEvaluationReport } from "../../services/api"; | |
| export default function ChatInterface({ docId, docIds = [], configId }) { | |
| const [messages, setMessages] = useState([]); | |
| const [loading, setLoading] = useState(false); | |
| const [showEvaluation, setShowEvaluation] = useState(false); | |
| const [selectedAssistantId, setSelectedAssistantId] = useState(""); | |
| const [evaluationReport, setEvaluationReport] = useState(null); | |
| const [evaluationError, setEvaluationError] = useState(""); | |
| const [deepEvaluationLoading, setDeepEvaluationLoading] = useState(false); | |
| useEffect(() => { | |
| let cancelled = false; | |
| const hydrateHistory = async () => { | |
| if (!docId) { | |
| setMessages([]); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${BASE_URL}/api/chat/history/${docId}`, { | |
| method: "GET", | |
| credentials: "include", | |
| }); | |
| if (!response.ok) return; | |
| const rows = await response.json(); | |
| if (cancelled || !Array.isArray(rows)) return; | |
| const filtered = configId | |
| ? rows.filter((item) => String(item.config_id) === String(configId)) | |
| : rows; | |
| const hydrated = filtered.map((item) => ({ | |
| id: item.id, | |
| role: item.role, | |
| content: item.content || "", | |
| chunks: Array.isArray(item.retrieved_chunks) ? item.retrieved_chunks : [], | |
| timings: null, | |
| status: "", | |
| })); | |
| setMessages(hydrated); | |
| } catch (e) { | |
| console.error("Failed to restore chat history", e); | |
| } | |
| }; | |
| void hydrateHistory(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [docId, configId]); | |
| useEffect(() => { | |
| const assistantMessages = messages.filter((item) => item.role === "assistant" && item.id); | |
| if (!assistantMessages.length) { | |
| setSelectedAssistantId(""); | |
| return; | |
| } | |
| if (!selectedAssistantId || !assistantMessages.some((item) => String(item.id) === String(selectedAssistantId))) { | |
| setSelectedAssistantId(String(assistantMessages[assistantMessages.length - 1].id)); | |
| } | |
| }, [messages, selectedAssistantId]); | |
| // Automatic loading removed. Evaluation is now only triggered manually by the user. | |
| const handleSend = async (query) => { | |
| setMessages((prev) => [...prev, { role: "user", content: query }]); | |
| setLoading(true); | |
| let assistantMsg = { id: null, role: "assistant", content: "", chunks: [], timings: null, status: "" }; | |
| let streamDone = false; | |
| setMessages((prev) => [...prev, assistantMsg]); | |
| try { | |
| const response = await fetch(`${BASE_URL}/api/chat/stream`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| credentials: "include", | |
| body: JSON.stringify({ query, doc_id: docId, doc_ids: docIds, config_id: configId }), | |
| }); | |
| if (!response.ok) throw new Error("Stream connection failed"); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split("\n"); | |
| buffer = lines.pop(); | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed.startsWith("data:")) continue; | |
| try { | |
| const data = JSON.parse(trimmed.slice(5).trim()); | |
| if (data.type === "status") { | |
| assistantMsg = { ...assistantMsg, status: data.message }; | |
| } else if (data.type === "metadata") { | |
| assistantMsg = { ...assistantMsg, chunks: data.chunks, status: "" }; | |
| } else if (data.type === "token") { | |
| assistantMsg = { ...assistantMsg, content: assistantMsg.content + data.content, status: "" }; | |
| } else if (data.type === "error") { | |
| const details = data.message || "Unknown stream error"; | |
| assistantMsg = { | |
| ...assistantMsg, | |
| content: `${assistantMsg.content}\n\n[Stream error] ${details}`.trim(), | |
| status: "", | |
| }; | |
| streamDone = true; | |
| setLoading(false); | |
| } else if (data.type === "done") { | |
| assistantMsg = { ...assistantMsg, id: data.message_id || assistantMsg.id, status: "" }; | |
| streamDone = true; | |
| setLoading(false); | |
| } | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| next[next.length - 1] = { ...assistantMsg }; | |
| return next; | |
| }); | |
| if (streamDone) { | |
| reader.cancel().catch(() => {}); | |
| break; | |
| } | |
| } catch (err) { | |
| console.error("JSON parse error in stream", err); | |
| } | |
| } | |
| if (streamDone) { | |
| break; | |
| } | |
| } | |
| if (!streamDone && buffer.trim()) { | |
| const finalLine = buffer.trim(); | |
| if (finalLine.startsWith("data:")) { | |
| try { | |
| const data = JSON.parse(finalLine.slice(5).trim()); | |
| if (data.type === "done") { | |
| assistantMsg = { ...assistantMsg, id: data.message_id || assistantMsg.id, status: "" }; | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| next[next.length - 1] = { ...assistantMsg }; | |
| return next; | |
| }); | |
| } else if (data.type === "error") { | |
| const details = data.message || "Unknown stream error"; | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| next[next.length - 1] = { | |
| ...next[next.length - 1], | |
| content: `${next[next.length - 1].content}\n\n[Stream error] ${details}`.trim(), | |
| status: "", | |
| }; | |
| return next; | |
| }); | |
| } | |
| } catch (err) { | |
| console.error("JSON parse error in final stream buffer", err); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| next[next.length - 1] = { ...next[next.length - 1], content: `Error: ${e.message}`, status: "" }; | |
| return next; | |
| }); | |
| } finally { | |
| if (!streamDone) { | |
| setLoading(false); | |
| } | |
| } | |
| }; | |
| const handleReset = async () => { | |
| if (!window.confirm("This will clear ALL conversation history and vectordb indices. Continue?")) return; | |
| try { | |
| setLoading(true); | |
| await fetch(`${BASE_URL}/api/chat/reset`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| credentials: "include", | |
| body: JSON.stringify({ doc_id: docId, config_id: configId }), | |
| }); | |
| setMessages([]); | |
| alert("System Reset Complete."); | |
| } catch (e) { | |
| alert(`Reset failed: ${e.message}`); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleRunDeepEvaluation = async () => { | |
| if (!selectedAssistantId) return; | |
| setDeepEvaluationLoading(true); | |
| setEvaluationError(""); | |
| try { | |
| const { data } = await getEvaluationReport({ message_id: selectedAssistantId, deep: true }); | |
| setEvaluationReport(data); | |
| } catch (error) { | |
| const detail = error?.response?.data?.detail || error?.message || "Failed to run deep evaluation."; | |
| if (String(detail).toLowerCase().includes("timeout")) { | |
| setEvaluationError("Evaluation took too long and was stopped. Please retry later."); | |
| } else { | |
| setEvaluationError(detail); | |
| } | |
| } finally { | |
| setDeepEvaluationLoading(false); | |
| } | |
| }; | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| <div className="mb-2 flex justify-end gap-2"> | |
| <button | |
| onClick={() => { | |
| setShowEvaluation(true); | |
| setEvaluationReport(null); | |
| setEvaluationError(""); | |
| }} | |
| className="text-xs text-gray-400 hover:text-blue-600 px-2 py-1 rounded hover:bg-gray-100 transition-colors" | |
| title="Open evaluation panel" | |
| > | |
| Evaluation | |
| </button> | |
| <button | |
| onClick={handleReset} | |
| className="text-xs text-gray-400 hover:text-red-600 px-2 py-1 rounded hover:bg-gray-100 transition-colors" | |
| title="Reset session" | |
| > | |
| Reset | |
| </button> | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto pr-1"> | |
| <MessageList messages={messages} /> | |
| {loading && ( | |
| <div className="flex justify-start mt-4"> | |
| <div className="bg-white border border-gray-200 rounded-2xl rounded-bl-sm px-4 py-3 shadow-sm"> | |
| <div className="flex gap-1 items-center"> | |
| <div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> | |
| <div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> | |
| <div className="w-2 h-2 bg-gray-300 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Input */} | |
| <InputBox onSend={handleSend} loading={loading} /> | |
| <EvaluationPanel | |
| open={showEvaluation} | |
| onClose={() => setShowEvaluation(false)} | |
| title="Chat Evaluation" | |
| report={evaluationReport} | |
| loading={deepEvaluationLoading} | |
| error={evaluationError} | |
| onRunDeepEvaluation={handleRunDeepEvaluation} | |
| deepLoading={deepEvaluationLoading} | |
| selector={messages.filter((item) => item.role === "assistant" && item.id).length > 0 ? ( | |
| <label className="block"> | |
| <span className="mb-1 block text-xs font-medium text-gray-500">Assistant turn</span> | |
| <select | |
| value={selectedAssistantId} | |
| onChange={(event) => { | |
| setEvaluationReport(null); | |
| setSelectedAssistantId(event.target.value); | |
| }} | |
| className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-200" | |
| > | |
| {messages | |
| .map((message, index) => ({ ...message, index })) | |
| .filter((item) => item.role === "assistant" && item.id) | |
| .map((item) => ( | |
| <option key={item.id} value={item.id}> | |
| Turn {item.index + 1}: {(item.content || "").slice(0, 72) || "Assistant response"} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| ) : ( | |
| <div className="rounded-xl border border-dashed border-gray-200 px-3 py-3 text-sm text-gray-400"> | |
| Ask at least one question to generate evaluation metrics. | |
| </div> | |
| )} | |
| /> | |
| </div> | |
| ); | |
| } | |