From 4a2a9a74894f31f6e13088306ab9ae83f6a32fa5 Mon Sep 17 00:00:00 2001 From: Christbru Date: Sun, 19 Oct 2025 11:26:17 -0500 Subject: [PATCH] Correct react implementation of rust backend --- .../src/components/layouts/chat-layout.jsx | 242 ++++++++++++++++-- .../components/ui/button/delete-button.jsx | 12 +- .../src/components/ui/chat/chat-header.jsx | 30 ++- .../src/components/ui/chat/chat-window.jsx | 14 +- .../src/components/ui/chat/message-input.jsx | 17 +- web-app/src/lib/api.js | 75 ++++++ 6 files changed, 352 insertions(+), 38 deletions(-) create mode 100644 web-app/src/lib/api.js diff --git a/web-app/src/components/layouts/chat-layout.jsx b/web-app/src/components/layouts/chat-layout.jsx index c5731d8..3e142d3 100644 --- a/web-app/src/components/layouts/chat-layout.jsx +++ b/web-app/src/components/layouts/chat-layout.jsx @@ -1,39 +1,235 @@ -import React, { useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ChatHeader from "src/components/ui/chat/chat-header"; import ChatWindow from "src/components/ui/chat/chat-window"; import MessageInput from "src/components/ui/chat/message-input"; +import { + createQuery, + getQueryResult, + getQueryStatus, + listFiles, +} from "src/lib/api"; + +const createId = () => + (globalThis.crypto?.randomUUID?.() ?? `id-${Date.now()}-${Math.random()}`); + +const INTRO_MESSAGE = { + id: "intro", + role: "assistant", + content: + "Ask me about the demo PDFs and I'll respond with the best matches pulled from the processed files.", +}; export default function ChatLayout() { - const [messages, setMessages] = useState([ - { - role: "assistant", - content: "Hello — I can help you with code, explanations, and more.", - }, - ]); + const [messages, setMessages] = useState([INTRO_MESSAGE]); + const [isProcessing, setIsProcessing] = useState(false); + const [files, setFiles] = useState([]); + const [errorToast, setErrorToast] = useState(""); + const pollAbortRef = useRef(null); - function handleSend(text) { - const userMsg = { role: "user", content: text }; - setMessages((s) => [...s, userMsg]); + const showError = useCallback((message) => { + setErrorToast(message); + window.setTimeout(() => setErrorToast(""), 5000); + }, []); - // fake assistant reply after short delay - setTimeout(() => { - setMessages((s) => [ - ...s, - { role: "assistant", content: `You said: ${text}` }, + const refreshFiles = useCallback(async () => { + try { + const latest = await listFiles(); + setFiles(latest); + } catch (error) { + showError(error.message ?? "Failed to load files"); + } + }, [showError]); + + useEffect(() => { + refreshFiles(); + }, [refreshFiles]); + + useEffect(() => { + return () => { + if (pollAbortRef.current) { + pollAbortRef.current.aborted = true; + } + }; + }, []); + + const buildAssistantMarkdown = useCallback((result) => { + if (!result || typeof result !== "object") { + return "I could not find a response for that request."; + } + + const finalAnswer = result.final_answer?.trim(); + const relationships = result.relationships?.trim(); + const relatedFiles = Array.isArray(result.related_files) + ? result.related_files + : []; + + const fileLines = relatedFiles + .filter((f) => f && typeof f === "object") + .map((file) => { + const filename = file.filename || file.id || "download"; + const linkTarget = `/storage/${encodeURIComponent(filename)}`; + const description = file.description?.trim(); + const score = + typeof file.score === "number" + ? ` _(score: ${file.score.toFixed(3)})_` + : ""; + const detail = description ? ` — ${description}` : ""; + return `- [${filename}](${linkTarget})${detail}${score}`; + }); + + let content = + finalAnswer || + "I could not determine an answer from the indexed documents yet."; + + if (fileLines.length) { + content += `\n\n**Related Files**\n${fileLines.join("\n")}`; + } + + if (relationships && relationships !== finalAnswer) { + content += `\n\n---\n${relationships}`; + } + + if (!fileLines.length && (!finalAnswer || finalAnswer.length < 10)) { + content += + "\n\n_No analyzed documents matched yet. Try seeding demo data or wait for processing to finish._"; + } + + return content; + }, []); + + const waitForResult = useCallback(async (id) => { + const abortState = { aborted: false }; + pollAbortRef.current = abortState; + const timeoutMs = 120_000; + const intervalMs = 1_500; + const started = Date.now(); + + while (!abortState.aborted) { + if (Date.now() - started > timeoutMs) { + throw new Error("Timed out waiting for the query to finish"); + } + + const statusPayload = await getQueryStatus(id); + const status = statusPayload?.status; + + if (status === "Completed") { + const resultPayload = await getQueryResult(id); + return resultPayload?.result; + } + + if (status === "Failed") { + const resultPayload = await getQueryResult(id); + const reason = resultPayload?.result?.error || "Query failed"; + throw new Error(reason); + } + + if (status === "Cancelled") { + throw new Error("Query was cancelled"); + } + + if (status === "not_found") { + throw new Error("Query was not found"); + } + + await new Promise((resolve) => window.setTimeout(resolve, intervalMs)); + } + + throw new Error("Query polling was aborted"); + }, []); + + const handleSend = useCallback( + async (text) => { + if (isProcessing) { + showError("Please wait for the current response to finish."); + return; + } + + const userEntry = { + id: createId(), + role: "user", + content: text, + }; + setMessages((prev) => [...prev, userEntry]); + + const placeholderId = createId(); + setMessages((prev) => [ + ...prev, + { + id: placeholderId, + role: "assistant", + content: "_Analyzing indexed documents..._", + pending: true, + }, ]); - }, 600); - } - function handleDeleteAll() { - if (!window.confirm("Delete all messages?")) return; - setMessages([]); - } + setIsProcessing(true); + + try { + const payload = { q: text, top_k: 5 }; + const created = await createQuery(payload); + const result = await waitForResult(created.id); + const content = buildAssistantMarkdown(result); + setMessages((prev) => + prev.map((message) => + message.id === placeholderId + ? { ...message, content, pending: false } + : message + ) + ); + } catch (error) { + const message = error?.message || "Something went wrong."; + setMessages((prev) => + prev.map((entry) => + entry.id === placeholderId + ? { + ...entry, + content: `⚠️ ${message}`, + pending: false, + error: true, + } + : entry + ) + ); + showError(message); + } finally { + pollAbortRef.current = null; + setIsProcessing(false); + refreshFiles(); + } + }, + [ + isProcessing, + showError, + refreshFiles, + waitForResult, + buildAssistantMarkdown, + ] + ); + + const handleDeleteAll = useCallback(() => { + if (!window.confirm("Delete all messages?")) { + return; + } + setMessages([INTRO_MESSAGE]); + }, []); + + const latestFileSummary = useMemo(() => { + if (!files.length) return "No files indexed yet."; + const pending = files.filter((f) => f.pending_analysis).length; + const ready = files.length - pending; + return `${ready} ready • ${pending} processing`; + }, [files]); return (
- + - +
); } diff --git a/web-app/src/components/ui/button/delete-button.jsx b/web-app/src/components/ui/button/delete-button.jsx index c75c306..37e008e 100644 --- a/web-app/src/components/ui/button/delete-button.jsx +++ b/web-app/src/components/ui/button/delete-button.jsx @@ -1,13 +1,17 @@ import { Flame } from "lucide-react"; import { motion } from "motion/react"; -export default function FlameButton({ onClick }) { +export default function FlameButton({ onClick, disabled = false }) { return ( diff --git a/web-app/src/components/ui/chat/chat-header.jsx b/web-app/src/components/ui/chat/chat-header.jsx index cffb9c5..7e0a49e 100644 --- a/web-app/src/components/ui/chat/chat-header.jsx +++ b/web-app/src/components/ui/chat/chat-header.jsx @@ -1,16 +1,30 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { motion } from "motion/react"; import { Rocket } from "lucide-react"; import DeleteButton from "src/components/ui/button/delete-button"; import SchematicButton from "../button/schematic-button"; -export default function ChatHeader({ title = "Title of Chat" }) { +export default function ChatHeader({ + title = "Title of Chat", + onClear, + busy = false, + fileSummary, + errorMessage, +}) { const isDebug = useMemo(() => { const p = new URLSearchParams(window.location.search); return p.get("debug") === "1"; }, []); const [ingesting, setIngesting] = useState(false); const [toast, setToast] = useState(""); + const [externalToast, setExternalToast] = useState(""); + + useEffect(() => { + if (!errorMessage) return; + setExternalToast(errorMessage); + const timer = window.setTimeout(() => setExternalToast(""), 5000); + return () => window.clearTimeout(timer); + }, [errorMessage]); async function triggerDemoIngest() { try { @@ -39,7 +53,12 @@ export default function ChatHeader({ title = "Title of Chat" }) {

{title}

- + {fileSummary && ( +
+ {fileSummary} +
+ )} + {isDebug && ( )} + {externalToast && ( +
+ {externalToast} +
+ )} ); diff --git a/web-app/src/components/ui/chat/chat-window.jsx b/web-app/src/components/ui/chat/chat-window.jsx index 2b78ce2..1252d54 100644 --- a/web-app/src/components/ui/chat/chat-window.jsx +++ b/web-app/src/components/ui/chat/chat-window.jsx @@ -4,10 +4,11 @@ import { MARKDOWN_COMPONENTS } from "src/config/markdown"; function MessageBubble({ message }) { const isUser = message.role === "user"; + const isError = !!message.error; return (
{isUser ? (
{message.content}
@@ -22,12 +23,21 @@ function MessageBubble({ message }) { } export default function ChatWindow({ messages }) { + const bottomRef = useRef(null); + + useEffect(() => { + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages]); + return (
{messages.map((m, i) => ( - + ))} +
); diff --git a/web-app/src/components/ui/chat/message-input.jsx b/web-app/src/components/ui/chat/message-input.jsx index 8e48f34..07751a3 100644 --- a/web-app/src/components/ui/chat/message-input.jsx +++ b/web-app/src/components/ui/chat/message-input.jsx @@ -1,10 +1,9 @@ import React, { useState, useRef, useEffect } from "react"; -import DeleteButton from "src/components/ui/button/delete-button"; import DownButton from "src/components/ui/button/down-button"; import { motion } from "motion/react"; import { BotMessageSquare } from "lucide-react"; -export default function MessageInput({ onSend }) { +export default function MessageInput({ onSend, disabled = false }) { const [text, setText] = useState(""); const textareaRef = useRef(null); @@ -15,7 +14,7 @@ export default function MessageInput({ onSend }) { function handleSubmit(e) { e.preventDefault(); - if (!text.trim()) return; + if (!text.trim() || disabled) return; onSend(text.trim()); setText(""); } @@ -38,6 +37,7 @@ export default function MessageInput({ onSend }) { ref={textareaRef} value={text} onChange={(e) => { + if (disabled) return; setText(e.target.value); // auto-resize const ta = textareaRef.current; @@ -49,12 +49,17 @@ export default function MessageInput({ onSend }) { placeholder="Type a message..." rows={1} className="flex-1 mx-2 rounded-md shadow-2sx border-none focus:border-none focus:outline-none resize-none overflow-auto max-h-40" + disabled={disabled} /> diff --git a/web-app/src/lib/api.js b/web-app/src/lib/api.js new file mode 100644 index 0000000..76dce1e --- /dev/null +++ b/web-app/src/lib/api.js @@ -0,0 +1,75 @@ +const JSON_HEADERS = { + Accept: "application/json", +}; + +async function parseJsonResponse(response) { + const text = await response.text(); + const hasBody = text !== ""; + let data; + if (hasBody) { + try { + data = JSON.parse(text); + } catch (error) { + data = { raw: text }; + } + } + + if (!response.ok) { + const message = data?.error || response.statusText || "Request failed"; + const err = new Error(message); + err.status = response.status; + err.body = data; + throw err; + } + + return data ?? {}; +} + +export async function listFiles() { + const response = await fetch("/api/files/list", { + method: "GET", + headers: JSON_HEADERS, + }); + const data = await parseJsonResponse(response); + return Array.isArray(data.files) ? data.files : []; +} + +export async function createQuery(payload) { + const response = await fetch("/api/query/create", { + method: "POST", + headers: { + ...JSON_HEADERS, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const data = await parseJsonResponse(response); + if (!data.id) { + throw new Error("Query creation did not return an id"); + } + return data; +} + +export async function getQueryStatus(id) { + const response = await fetch(`/api/query/status?id=${encodeURIComponent(id)}`, { + method: "GET", + headers: JSON_HEADERS, + }); + return parseJsonResponse(response); +} + +export async function getQueryResult(id) { + const response = await fetch(`/api/query/result?id=${encodeURIComponent(id)}`, { + method: "GET", + headers: JSON_HEADERS, + }); + return parseJsonResponse(response); +} + +export async function cancelQuery(id) { + const response = await fetch(`/api/query/cancel?id=${encodeURIComponent(id)}`, { + method: "GET", + headers: JSON_HEADERS, + }); + return parseJsonResponse(response); +}