fix(repo): we start over, its really that time
Some checks failed
Build and Deploy / Build Images and Deploy to Server (push) Has been cancelled
Some checks failed
Build and Deploy / Build Images and Deploy to Server (push) Has been cancelled
This commit is contained in:
parent
8ed519b89a
commit
d6378b8eb1
73 changed files with 1 additions and 6205 deletions
9
frontend/Dockerfile
Normal file
9
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
FROM node:23-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm i
|
||||
COPY . .
|
||||
RUN npm run format && npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.mjs"]
|
||||
1
frontend/README.md
Normal file
1
frontend/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks"; // Or import { configs as reactHooks } from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*{js,jsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>codered-astra</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9234
frontend/package-lock.json
generated
Normal file
9234
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
54
frontend/package.json
Normal file
54
frontend/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "codered-astra",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"host": "vite --host 0.0.0.0 --port 3000",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"clean-dist": "find apps/ -type d -name 'dist' -print0 | xargs -r0 -- rm -r",
|
||||
"clean-all": "find apps/ -type d -name 'dist' -print0 | xargs -r0 -- rm -r && find . -path ./node_modules -prune -o -name 'node_modules' | xargs rm -rf "
|
||||
},
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.25.0",
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"helmet": "^8.1.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.4",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"shadcn-ui": "^0.9.5",
|
||||
"vite-jsconfig-paths": "^2.0.1"
|
||||
},
|
||||
"packageManager": ">=npm@10.9.0",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.38.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.4.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"vite": "^7.1.10"
|
||||
}
|
||||
}
|
||||
13
frontend/src/app/index.jsx
Normal file
13
frontend/src/app/index.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from "react";
|
||||
import ChatLayout from "src/components/layouts/chat-layout";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="dark min-h-screen bg-gray-950 text-white flex justify-center pt-12">
|
||||
<ChatLayout />
|
||||
<div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
238
frontend/src/components/layouts/chat-layout.jsx
Normal file
238
frontend/src/components/layouts/chat-layout.jsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/ui/button/delete-button.jsx
Normal file
19
frontend/src/components/ui/button/delete-button.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Flame } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export default function FlameButton({ onClick, disabled = false }) {
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/ui/button/down-button.jsx
Normal file
24
frontend/src/components/ui/button/down-button.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export default function DownButton({ onClick }) {
|
||||
function handleClick(e) {
|
||||
if (onClick) return onClick(e);
|
||||
// default behavior: scroll to bottom of page smoothly
|
||||
const doc = document.documentElement;
|
||||
const top = Math.max(doc.scrollHeight, document.body.scrollHeight);
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={handleClick}
|
||||
className="bg-gray-700 p-2 rounded-2xl file-input border-2 border-gray-600"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<ArrowDown />
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/ui/button/schematic-button.jsx
Normal file
33
frontend/src/components/ui/button/schematic-button.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React, { forwardRef, useRef } from "react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
// Hidden file input that exposes an open() method via ref
|
||||
const SchematicButton = forwardRef(function SchematicButton({ onFiles }, ref) {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: () => inputRef.current && inputRef.current.click(),
|
||||
}));
|
||||
|
||||
function handleFiles(e) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
if (onFiles) onFiles(files);
|
||||
if (inputRef.current) inputRef.current.value = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
multiple
|
||||
onChange={handleFiles}
|
||||
className="file-input hidden"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default SchematicButton;
|
||||
89
frontend/src/components/ui/chat/chat-header.jsx
Normal file
89
frontend/src/components/ui/chat/chat-header.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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 "src/components/ui/button/schematic-button";
|
||||
|
||||
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 {
|
||||
setIngesting(true);
|
||||
const res = await fetch("/api/files/import-demo", { method: "POST" });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
const imported = json.imported ?? "?";
|
||||
const skipped = json.skipped ?? "?";
|
||||
const summary = `Imported: ${imported}, Skipped: ${skipped}`;
|
||||
setToast(json.error ? `${summary} - ${json.error}` : summary);
|
||||
setTimeout(() => setToast(""), 4000);
|
||||
} catch (e) {
|
||||
setToast("Import failed");
|
||||
setTimeout(() => setToast(""), 4000);
|
||||
} finally {
|
||||
setIngesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/ui/chat/chat-window.jsx
Normal file
44
frontend/src/components/ui/chat/chat-window.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React, { useRef, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
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"} ${isError ? "border border-red-500/60 bg-red-900/50" : ""}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<div className="text-sm">{message.content}</div>
|
||||
) : (
|
||||
<ReactMarkdown components={MARKDOWN_COMPONENTS}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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={m.id ?? i} message={m} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/components/ui/chat/message-input.jsx
Normal file
119
frontend/src/components/ui/chat/message-input.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import DownButton from "src/components/ui/button/down-button";
|
||||
import { motion } from "motion/react";
|
||||
import { BotMessageSquare } from "lucide-react";
|
||||
|
||||
export default function MessageInput({ onSend, disabled = false }) {
|
||||
const [text, setText] = useState("");
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// ensure correct initial height
|
||||
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!text.trim() || disabled) return;
|
||||
onSend(text.trim());
|
||||
|
||||
// create query on backend
|
||||
try {
|
||||
if (onMessage)
|
||||
onMessage("assistant", "Queued: sending request to server...");
|
||||
const createRes = await fetch(`/api/query/create`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ q: text, top_k: 5 }),
|
||||
});
|
||||
const createJson = await createRes.json();
|
||||
const id = createJson.id;
|
||||
if (!id) throw new Error("no id returned");
|
||||
|
||||
// poll status
|
||||
let status = "Queued";
|
||||
if (onMessage) onMessage("assistant", `Status: ${status}`);
|
||||
while (status !== "Completed" && status !== "Failed") {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const sRes = await fetch(`/api/query/status?id=${id}`);
|
||||
const sJson = await sRes.json();
|
||||
status = sJson.status;
|
||||
if (onMessage) onMessage("assistant", `Status: ${status}`);
|
||||
if (status === "Cancelled") break;
|
||||
}
|
||||
|
||||
if (status === "Completed") {
|
||||
const resultRes = await fetch(`/api/query/result?id=${id}`);
|
||||
const resultJson = await resultRes.json();
|
||||
const final =
|
||||
resultJson?.result?.final_answer ||
|
||||
JSON.stringify(resultJson?.result || {});
|
||||
if (onMessage) onMessage("assistant", final);
|
||||
} else {
|
||||
if (onMessage)
|
||||
onMessage("assistant", `Query status ended as: ${status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (onMessage) onMessage("assistant", `Error: ${err.message}`);
|
||||
}
|
||||
|
||||
setText("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-center">
|
||||
<footer className="fixed bottom-6 max-w-3xl w-full px-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<DownButton></DownButton>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-gray-900 rounded-2xl border-2 border-gray-800 shadow-lg shadow-indigo-600"
|
||||
>
|
||||
<div className="flex p-2 shadow-xl items-center">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
if (disabled) return;
|
||||
setText(e.target.value);
|
||||
// auto-resize
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter to submit, Shift+Enter for newline
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
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 ${
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/ui/file/file-list.jsx
Normal file
83
frontend/src/components/ui/file/file-list.jsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
import SchematicButton from "src/components/ui/button/schematic-button";
|
||||
import { motion } from "motion/react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { FilePlus2 } from "lucide-react";
|
||||
|
||||
export default function FileList() {
|
||||
const pickerRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
function handleAdd() {
|
||||
if (pickerRef.current && pickerRef.current.open) pickerRef.current.open();
|
||||
}
|
||||
|
||||
function handleFiles(selected) {
|
||||
setFiles((s) => [...s, ...selected]);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function removeFile(i) {
|
||||
setFiles((s) => s.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<motion.button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="p-2 rounded-xl bg-gray-700 border-2 border-gray-600"
|
||||
aria-expanded={open}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTab={{ scale: 0.9 }}
|
||||
>
|
||||
{open ? <X /> : <Menu />}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed left-1/2 top-24 transform -translate-x-1/2 z-50 w-full max-w-3xl px-4">
|
||||
<div className="bg-gray-900 border-2 border-gray-800 rounded-2xl p-4 shadow-lg overflow-auto">
|
||||
<div className="flex items-center justify-between mb-2 pr-1">
|
||||
<div className="text-lg font-medium">Files</div>
|
||||
<div>
|
||||
<motion.button
|
||||
onClick={handleAdd}
|
||||
className="w-full bg-gray-700 text-sm p-2 rounded-full border-2 border-gray-600"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<FilePlus2 />
|
||||
</motion.button>
|
||||
<SchematicButton ref={pickerRef} onFiles={handleFiles} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-md text-slate-400">No files added</div>
|
||||
) : (
|
||||
files.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between bg-gray-800 p-2 rounded-lg text-sm"
|
||||
>
|
||||
<span className="truncate max-w-[24rem]">{f.name}</span>
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="text-xs bg-gray-700 rounded-full p-2"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/config/markdown.jsx
Normal file
60
frontend/src/config/markdown.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
export const MARKDOWN_COMPONENTS = {
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-xl font-semibold mt-2 mb-1" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-lg font-semibold mt-2 mb-1" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-md font-semibold mt-2 mb-1" {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => (
|
||||
<p className="text-sm leading-relaxed mb-2" {...props} />
|
||||
),
|
||||
a: ({ node, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-indigo-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className={`bg-slate-800 px-1 py-0.5 rounded text-sm ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre
|
||||
className="bg-slate-800 p-2 rounded overflow-auto text-sm"
|
||||
{...props}
|
||||
>
|
||||
<code className={className || ""}>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-2 border-slate-600 pl-4 italic text-slate-200 my-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc list-inside ml-4 mb-2 text-sm" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal list-inside ml-4 mb-2 text-sm" {...props} />
|
||||
),
|
||||
li: ({ node, ...props }) => <li className="mb-1 text-sm" {...props} />,
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-semibold" {...props} />
|
||||
),
|
||||
em: ({ node, ...props }) => <em className="italic" {...props} />,
|
||||
};
|
||||
47
frontend/src/features/gemini/gemini.js
Normal file
47
frontend/src/features/gemini/gemini.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { GoogleGenAI } from "@google/genai"
|
||||
import fs from "fs"
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: import.meta.env.GEMINI_API_KEY })
|
||||
|
||||
async function uploadLocalPDFs() {
|
||||
var pdfList = fs.readdirSync("public/pdfs")
|
||||
|
||||
// Upload each file in /public
|
||||
pdfList.forEach(async (path) => {
|
||||
console.log("file names: " + path)
|
||||
console.log("file names: " + path.slice(0, path.length - 4))
|
||||
|
||||
console.log("UPLOADING")
|
||||
const file = await ai.files.upload({
|
||||
file: "public/pdfs/" + path,
|
||||
config: {
|
||||
displayName: path.slice(0, path.length - 4)
|
||||
}
|
||||
})
|
||||
|
||||
console.log("FETCHING: public/pdfs/" + path)
|
||||
|
||||
// Wait for the file to be processed
|
||||
let getFile = await ai.files.get({
|
||||
name: file.name
|
||||
})
|
||||
|
||||
while (getFile.state === "PROCESSING") {
|
||||
let getFile = await ai.files.get({
|
||||
name: file.name
|
||||
})
|
||||
console.log(`Current file status: ${getFile.state}`)
|
||||
console.log("File is currently processing, retrying in 5 seconds")
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 5000) // Checks every 5 seconds
|
||||
})
|
||||
|
||||
// Error handling
|
||||
if (getFile.state === "FAILED") {
|
||||
throw new Error("File has failed to process!")
|
||||
}
|
||||
return file
|
||||
}
|
||||
})
|
||||
}
|
||||
22
frontend/src/index.css
Normal file
22
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
.dark {
|
||||
--paragraph: 235, 236, 239;
|
||||
--background: 15, 16, 26;
|
||||
--primary: 158, 166, 214;
|
||||
--secondary: 35, 50, 133;
|
||||
--accent: 52, 75, 223;
|
||||
background: rgba(var(--background));
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./app/index.jsx";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
5
frontend/tailwind.config.ts
Normal file
5
frontend/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
|
||||
} satisfies Config;
|
||||
26
frontend/vite.config.js
Normal file
26
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import jsconfigPaths from "vite-jsconfig-paths";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
try {
|
||||
process.loadEnvFile(".env")
|
||||
} catch (error) {
|
||||
console.log("Env file not found!\n" + error)
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react(), jsconfigPaths()],
|
||||
resolve: {
|
||||
alias: {
|
||||
src: "/src",
|
||||
},
|
||||
},
|
||||
// Defines envrionmental files across all src code b/c prefix is usually "VITE"
|
||||
define: {
|
||||
'import.meta.env.GEMINI_API_KEY': JSON.stringify(process.env.GEMINI_API_KEY),
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: ["astrachat.christbru.services"]
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue