238 lines
6.8 KiB
JavaScript
238 lines
6.8 KiB
JavaScript
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([INTRO_MESSAGE]);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [files, setFiles] = useState([]);
|
|
const [errorToast, setErrorToast] = useState("");
|
|
const pollAbortRef = useRef(null);
|
|
|
|
const showError = useCallback((message) => {
|
|
setErrorToast(message);
|
|
window.setTimeout(() => setErrorToast(""), 5000);
|
|
}, []);
|
|
|
|
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 storageUrl = file.storage_url || `/storage/${filename}`;
|
|
const linkTarget = storageUrl.startsWith("/storage/")
|
|
? `/storage/${encodeURIComponent(storageUrl.replace("/storage/", ""))}`
|
|
: storageUrl;
|
|
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,
|
|
},
|
|
]);
|
|
|
|
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
|
|
onClear={handleDeleteAll}
|
|
busy={isProcessing}
|
|
fileSummary={latestFileSummary}
|
|
errorMessage={errorToast}
|
|
/>
|
|
<ChatWindow messages={messages} />
|
|
<MessageInput onSend={handleSend} disabled={isProcessing} />
|
|
</div>
|
|
);
|
|
}
|