Correct react implementation of rust backend

This commit is contained in:
Christbru 2025-10-19 11:26:17 -05:00
commit 4a2a9a7489
6 changed files with 352 additions and 38 deletions

View file

@ -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 (
<div className="flex flex-col flex-start w-full max-w-3xl gap-4 p-4">
<ChatHeader />
<ChatHeader
onClear={handleDeleteAll}
busy={isProcessing}
fileSummary={latestFileSummary}
errorMessage={errorToast}
/>
<ChatWindow messages={messages} />
<MessageInput onSend={handleSend} onDeleteAll={handleDeleteAll} />
<MessageInput onSend={handleSend} disabled={isProcessing} />
</div>
);
}

View file

@ -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 (
<motion.button
onClick={onClick}
className="bg-gray-700 cursor-pointer p-2 rounded-2xl border-2 border-gray-600"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className={`bg-gray-700 p-2 rounded-2xl border-2 border-gray-600 ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
}`}
whileHover={disabled ? undefined : { scale: 1.1 }}
whileTap={disabled ? undefined : { scale: 0.9 }}
disabled={disabled}
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Flame />
</motion.button>

View file

@ -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" }) {
<h1 className="text-lg font-semibold shadow-md shadow-indigo-600 bg-gray-900 px-6 py-2 rounded-4xl border-2 border-gray-800">
{title}
</h1>
<DeleteButton />
{fileSummary && (
<div className="text-xs text-slate-300 bg-gray-800/80 border border-gray-700 rounded px-3 py-1">
{fileSummary}
</div>
)}
<DeleteButton onClick={onClear} disabled={busy} />
{isDebug && (
<motion.button
onClick={triggerDemoIngest}
@ -59,6 +78,11 @@ export default function ChatHeader({ title = "Title of Chat" }) {
{toast}
</div>
)}
{externalToast && (
<div className="mt-2 text-xs text-red-300 bg-red-900/40 border border-red-700 rounded px-2 py-1 inline-block">
{externalToast}
</div>
)}
</header>
</div>
);

View file

@ -4,10 +4,11 @@ import { MARKDOWN_COMPONENTS } from "src/config/markdown";
function MessageBubble({ message }) {
const isUser = message.role === "user";
const isError = !!message.error;
return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"} py-2`}>
<div
className={`p-3 rounded-xl ${isUser ? "bg-indigo-600 text-white rounded-tr-sm" : "bg-gray-700 text-slate-100 rounded-tl-sm"}`}
className={`p-3 rounded-xl ${isUser ? "bg-indigo-600 text-white rounded-tr-sm" : "bg-gray-700 text-slate-100 rounded-tl-sm"} ${isError ? "border border-red-500/60 bg-red-900/50" : ""}`}
>
{isUser ? (
<div className="text-sm">{message.content}</div>
@ -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 (
<div className="flex-1 overflow-auto px-2 pt-4 pb-32">
<div className="">
{messages.map((m, i) => (
<MessageBubble key={i} message={m} />
<MessageBubble key={m.id ?? i} message={m} />
))}
<div ref={bottomRef} />
</div>
</div>
);

View file

@ -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}
/>
<motion.button
type="submit"
className="flex gap-2 px-4 py-2 bg-gray-700 rounded-xl ml-4 items-center"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className={`flex gap-2 px-4 py-2 bg-gray-700 rounded-xl ml-4 items-center ${
disabled ? "cursor-not-allowed" : ""
}`}
whileHover={disabled ? undefined : { scale: 1.1 }}
whileTap={disabled ? undefined : { scale: 0.9 }}
disabled={disabled}
style={{ opacity: disabled ? 0.5 : 1 }}
>
<BotMessageSquare />
</motion.button>

75
web-app/src/lib/api.js Normal file
View file

@ -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);
}