Merge branch 'rust-dev'

This commit is contained in:
Christbru 2025-10-19 11:37:43 -05:00
commit 995cfbd9b0
13 changed files with 958 additions and 123 deletions

View file

@ -2,7 +2,9 @@ import express from 'express';
import path from 'node:path';
import helmet from 'helmet';
import cors from 'cors';
import fetch from 'node-fetch';
import http from 'node:http';
import https from 'node:https';
import { URL } from 'node:url';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
@ -20,24 +22,44 @@ const STORAGE_DIR = path.resolve(process.env.ASTRA_STORAGE || '/app/storage');
app.set('trust proxy', true);
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors());
app.use(express.json());
app.get('/api/healthz', (_req, res) => {
res.json({ status: 'ok', upstream: RUST_ENGINE_BASE });
});
// Proxy minimal API needed by the UI to the rust-engine container
app.post('/api/files/import-demo', async (req, res) => {
try {
const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
const url = `${RUST_ENGINE_BASE}/api/files/import-demo${qs}`;
const upstream = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: req.body ? JSON.stringify(req.body) : undefined });
const text = await upstream.text();
res.status(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
} catch (err) {
console.error('import-demo proxy failed:', err);
res.status(502).json({ error: 'proxy_failed' });
}
// Proxy all /api/* calls (including POST bodies, multipart uploads, etc.)
app.use('/api', (req, res) => {
const targetUrl = new URL(req.originalUrl, RUST_ENGINE_BASE);
const client = targetUrl.protocol === 'https:' ? https : http;
const headers = { ...req.headers, host: targetUrl.host };
const proxyReq = client.request(
targetUrl,
{
method: req.method,
headers,
},
(upstream) => {
res.status(upstream.statusCode || 502);
for (const [key, value] of Object.entries(upstream.headers)) {
if (typeof value !== 'undefined') {
res.setHeader(key, value);
}
}
upstream.pipe(res);
}
);
proxyReq.on('error', (err) => {
console.error('API proxy error:', err);
if (!res.headersSent) {
res.status(502).json({ error: 'proxy_failed' });
} else {
res.end();
}
});
req.pipe(proxyReq);
});
// Serve static frontend

View file

@ -1,48 +1,238 @@
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 addMessage(role, content) {
const msg = { role, content };
setMessages((s) => [...s, msg]);
}
const showError = useCallback((message) => {
setErrorToast(message);
window.setTimeout(() => setErrorToast(""), 5000);
}, []);
function handleSend(text) {
const userMsg = { role: "user", content: text };
setMessages((s) => [...s, userMsg]);
const refreshFiles = useCallback(async () => {
try {
const latest = await listFiles();
setFiles(latest);
} catch (error) {
showError(error.message ?? "Failed to load files");
}
}, [showError]);
// fake assistant reply after short delay
setTimeout(() => {
setMessages((s) => [
...s,
{ role: "assistant", content: `You said: ${text}` },
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,
},
]);
}, 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 onDeleteAll={handleDeleteAll} />
<ChatWindow messages={messages} />
<MessageInput
onSend={handleSend}
onMessage={addMessage}
onDeleteAll={handleDeleteAll}
<ChatHeader
onClear={handleDeleteAll}
busy={isProcessing}
fileSummary={latestFileSummary}
errorMessage={errorToast}
/>
<ChatWindow messages={messages} />
<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,12 +1,15 @@
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 FileList from "src/components/ui/file/file-list";
export default function ChatHeader({
title = "Schematic Spelunker",
onDeleteAll,
title = "Title of Chat",
onClear,
busy = false,
fileSummary,
errorMessage,
}) {
const isDebug = useMemo(() => {
const p = new URLSearchParams(window.location.search);
@ -14,6 +17,14 @@ export default function ChatHeader({
}, []);
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 {
@ -37,29 +48,41 @@ export default function ChatHeader({
<div className="w-full flex justify-center">
<header className="text-slate-100 fixed top-4 max-w-3xl w-full px-4">
<div className="flex justify-between items-center gap-4">
<FileList />
<h1 className=" text-sm lg: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 onClick={onDeleteAll} />
{isDebug && (
<motion.button
onClick={triggerDemoIngest}
className="bg-gray-800 border-2 border-gray-700 rounded-xl px-3 py-2 flex items-center gap-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
disabled={ingesting}
>
<Rocket size={16} />
{ingesting ? "Seeding…" : "Seed Demo Data"}
</motion.button>
)}
<SchematicButton />
<div className="flex items-center gap-3">
<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>
{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}
className="bg-gray-800 border-2 border-gray-700 rounded-xl px-3 py-2 flex items-center gap-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
disabled={ingesting}
>
<Rocket size={16} />
{ingesting ? "Seeding…" : "Seed Demo Data"}
</motion.button>
)}
</div>
</div>
{toast && (
<div className="mt-2 text-xs text-slate-300 bg-gray-800/80 border border-gray-700 rounded px-2 py-1 inline-block">
{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

@ -3,7 +3,7 @@ import DownButton from "src/components/ui/button/down-button";
import { motion } from "motion/react";
import { BotMessageSquare } from "lucide-react";
export default function MessageInput({ onSend, onMessage }) {
export default function MessageInput({ onSend, disabled = false }) {
const [text, setText] = useState("");
const textareaRef = useRef(null);
@ -14,9 +14,7 @@ export default function MessageInput({ onSend, onMessage }) {
async function handleSubmit(e) {
e.preventDefault();
if (!text.trim()) return;
// send user message locally
if (!text.trim() || disabled) return;
onSend(text.trim());
// create query on backend
@ -79,6 +77,7 @@ export default function MessageInput({ onSend, onMessage }) {
ref={textareaRef}
value={text}
onChange={(e) => {
if (disabled) return;
setText(e.target.value);
// auto-resize
const ta = textareaRef.current;
@ -97,12 +96,17 @@ export default function MessageInput({ onSend, onMessage }) {
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);
}