Correct react implementation of rust backend
This commit is contained in:
parent
9035e00da3
commit
4a2a9a7489
6 changed files with 352 additions and 38 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
75
web-app/src/lib/api.js
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue