feat(frontend): add React chat UI with SSE parsing and source accordion
This commit is contained in:
1 parent
7a0396422f
commit
7f4d4bde66
21 files changed
+7220
No files matched your search
@@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY tsconfig.json vite.config.ts index.html ./
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost/ >/dev/null || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Pastor Online — acompanhamento pastoral ancorado no texto biblico indexado."
|
||||
/>
|
||||
<title>Pastor Online</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# O backend responde o chat em SSE; o proxy não deve bufferizar a resposta.
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
chunked_transfer_encoding on;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|woff2?|png|svg|ico)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
Generated
+5768
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "pastor-online-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchStatus,
|
||||
reindex,
|
||||
sendMessage,
|
||||
type ChatMessage,
|
||||
type Source,
|
||||
type Status,
|
||||
} from "./api";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { MessageBubble } from "./components/MessageBubble";
|
||||
import { StatusPanel } from "./components/StatusPanel";
|
||||
|
||||
interface Turn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
sources?: Source[];
|
||||
citations?: string[];
|
||||
}
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
const THEME_KEY = "pastor-online-theme";
|
||||
|
||||
export default function App() {
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(THEME_KEY) as Theme | null) ?? "dark",
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await fetchStatus());
|
||||
} catch {
|
||||
setStatus(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
const timer = window.setInterval(() => void refreshStatus(), 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [turns]);
|
||||
|
||||
async function handleSend(message: string) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
const history: ChatMessage[] = turns.map((turn) => ({
|
||||
role: turn.role,
|
||||
content: turn.content,
|
||||
}));
|
||||
|
||||
setTurns((current) => [...current, { role: "user", content: message }]);
|
||||
|
||||
try {
|
||||
let answer = "";
|
||||
let sources: Source[] = [];
|
||||
let citations: string[] = [];
|
||||
|
||||
for await (const event of sendMessage(message, history)) {
|
||||
if (event.event === "sources") {
|
||||
sources = (event.data.sources as Source[]) ?? [];
|
||||
} else if (event.event === "token") {
|
||||
answer = String(event.data.text ?? "");
|
||||
} else if (event.event === "done") {
|
||||
citations = (event.data.citations as string[]) ?? [];
|
||||
} else if (event.event === "error") {
|
||||
throw new Error(String(event.data.message ?? "Erro desconhecido"));
|
||||
}
|
||||
}
|
||||
|
||||
setTurns((current) => [
|
||||
...current,
|
||||
{ role: "assistant", content: answer, sources, citations },
|
||||
]);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : "Erro ao falar com o servidor";
|
||||
setError(message);
|
||||
setTurns((current) => [
|
||||
...current,
|
||||
{ role: "assistant", content: `Não consegui responder agora: ${message}` },
|
||||
]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
void refreshStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReindex() {
|
||||
setReindexing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await reindex();
|
||||
await refreshStatus();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Falha ao reindexar");
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__brand">
|
||||
<span className="sidebar__mark" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="sidebar__title">Pastor Online</h1>
|
||||
<p className="sidebar__subtitle">
|
||||
Acompanhamento ancorado no texto bíblico indexado
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusPanel status={status} onReindex={handleReindex} reindexing={reindexing} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__theme"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{theme === "dark" ? "☀ Tema claro" : "☾ Tema escuro"}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="chat">
|
||||
<div className="chat__scroll" ref={scrollRef}>
|
||||
{turns.length === 0 && (
|
||||
<div className="chat__empty">
|
||||
<h2>Olá, estou aqui para acompanhar você.</h2>
|
||||
<p>
|
||||
Faça uma pergunta ou compartilhe uma angústia. Minhas respostas se
|
||||
apoiam apenas nos versículos do texto bíblico indexado, com as
|
||||
referências sempre à vista.
|
||||
</p>
|
||||
<ul className="chat__suggestions">
|
||||
<li>“O que a Palavra diz sobre a esperança?”</li>
|
||||
<li>“Como lidar com um momento de ansiedade?”</li>
|
||||
<li>“O que diz João 3:16?”</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{turns.map((turn, index) => (
|
||||
<MessageBubble
|
||||
key={index}
|
||||
role={turn.role}
|
||||
content={turn.content}
|
||||
sources={turn.sources}
|
||||
citations={turn.citations}
|
||||
/>
|
||||
))}
|
||||
|
||||
{busy && (
|
||||
<div className="message message--assistant">
|
||||
<div className="message__bubble message__bubble--pending">
|
||||
Consultando o texto bíblico indexado…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="chat__error">{error}</div>}
|
||||
|
||||
<ChatPanel onSend={handleSend} busy={busy} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { parseSSE, type SSEEvent } from "./lib/sse";
|
||||
|
||||
export interface Source {
|
||||
reference: string;
|
||||
text: string;
|
||||
book: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
page: number;
|
||||
file: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
state: "ready" | "indexing" | "error";
|
||||
pdfs_total: number;
|
||||
pdfs_indexed: number;
|
||||
chunks: number;
|
||||
model: string;
|
||||
last_indexed_at: string | null;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = `Erro ${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as { detail?: string };
|
||||
if (body.detail) {
|
||||
detail = body.detail;
|
||||
}
|
||||
} catch {
|
||||
/* resposta sem corpo JSON */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function fetchStatus(): Promise<Status> {
|
||||
return request<Status>("/api/status");
|
||||
}
|
||||
|
||||
export function reindex(): Promise<{ state: string }> {
|
||||
return request<{ state: string }>("/api/reindex", { method: "POST" });
|
||||
}
|
||||
|
||||
/** Envia a mensagem e devolve os eventos do stream. */
|
||||
export async function* sendMessage(
|
||||
message: string,
|
||||
history: ChatMessage[],
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const response = await fetch(`${API_BASE}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message, history }),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
let detail = `Erro ${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as { detail?: string };
|
||||
if (body.detail) {
|
||||
detail = body.detail;
|
||||
}
|
||||
} catch {
|
||||
/* resposta sem corpo JSON */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
yield* parseSSE(response.body);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
|
||||
describe("ChatPanel", () => {
|
||||
it("sends the message when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<ChatPanel onSend={onSend} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await user.type(textarea, "Quem criou o mundo?");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
expect(onSend).toHaveBeenCalledWith("Quem criou o mundo?");
|
||||
});
|
||||
|
||||
it("does not send when Shift+Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<ChatPanel onSend={onSend} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await user.type(textarea, "linha um");
|
||||
await user.keyboard("{Shift>}{Enter}{/Shift}");
|
||||
await user.type(textarea, "linha dois");
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(textarea).toHaveValue("linha um\nlinha dois");
|
||||
});
|
||||
|
||||
it("does not send an empty message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<ChatPanel onSend={onSend} />);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), " ");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the input after sending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<ChatPanel onSend={onSend} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await user.type(textarea, "Olá");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(textarea).toHaveValue("");
|
||||
});
|
||||
|
||||
it("disables the input while waiting for the answer", () => {
|
||||
render(<ChatPanel onSend={vi.fn()} busy />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
|
||||
interface ChatPanelProps {
|
||||
onSend: (message: string) => Promise<void>;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Área de entrada do chat.
|
||||
*
|
||||
* Enter envia a mensagem; Shift+Enter insere uma nova linha.
|
||||
*/
|
||||
export function ChatPanel({ onSend, busy = false }: ChatPanelProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
async function handleSubmit() {
|
||||
const message = value.trim();
|
||||
if (!message || busy) {
|
||||
return;
|
||||
}
|
||||
setValue("");
|
||||
await onSend(message);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="composer">
|
||||
<textarea
|
||||
className="composer__input"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escreva sua pergunta, dúvida ou angústia…"
|
||||
aria-label="Mensagem"
|
||||
rows={1}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="composer__send"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={busy || value.trim().length === 0}
|
||||
aria-label="Enviar mensagem"
|
||||
>
|
||||
{busy ? "Preparando…" : "Enviar"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import type { Source } from "../api";
|
||||
import { SourcesAccordion } from "./SourcesAccordion";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
sources?: Source[];
|
||||
citations?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderiza uma mensagem. As respostas do assistente são formatadas em
|
||||
* Markdown e acompanhadas do accordion com os trechos consultados.
|
||||
*/
|
||||
export function MessageBubble({
|
||||
role,
|
||||
content,
|
||||
sources = [],
|
||||
citations = [],
|
||||
}: MessageBubbleProps) {
|
||||
if (role === "user") {
|
||||
return (
|
||||
<div className="message message--user">
|
||||
<div className="message__bubble">{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message message--assistant">
|
||||
<div className="message__bubble message__bubble--markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
{sources.length > 0 && (
|
||||
<SourcesAccordion sources={sources} citations={citations} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Source } from "../api";
|
||||
|
||||
interface SourcesAccordionProps {
|
||||
sources: Source[];
|
||||
citations?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Accordion retrátil com os trechos bíblicos consultados na resposta.
|
||||
*/
|
||||
export function SourcesAccordion({ sources, citations = [] }: SourcesAccordionProps) {
|
||||
return (
|
||||
<details className="sources">
|
||||
<summary className="sources__summary">
|
||||
Trechos Bíblicos Consultados ({sources.length})
|
||||
</summary>
|
||||
<div className="sources__body">
|
||||
{citations.length > 0 && (
|
||||
<p className="sources__citations">
|
||||
<strong>Referências citadas:</strong> {citations.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{sources.map((source) => (
|
||||
<blockquote key={source.reference} className="sources__item">
|
||||
<cite className="sources__reference">{source.reference}</cite>
|
||||
<p className="sources__text">{source.text}</p>
|
||||
<footer className="sources__meta">
|
||||
página {source.page} · {source.file}
|
||||
</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Status } from "../api";
|
||||
|
||||
interface StatusPanelProps {
|
||||
status: Status | null;
|
||||
onReindex: () => void;
|
||||
reindexing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Painel de status da indexação: quantos PDFs foram lidos, quantos vetores
|
||||
* estão indexados e um botão para forçar a reindexação.
|
||||
*/
|
||||
export function StatusPanel({ status, onReindex, reindexing }: StatusPanelProps) {
|
||||
const state = status?.state ?? "indexing";
|
||||
const stateLabel =
|
||||
state === "ready"
|
||||
? "Pronto"
|
||||
: state === "indexing"
|
||||
? "Indexando"
|
||||
: "Erro";
|
||||
|
||||
return (
|
||||
<section className="status" aria-label="Status da indexação">
|
||||
<header className="status__header">
|
||||
<h2 className="status__title">Indexação</h2>
|
||||
<span className={`status__badge status__badge--${state}`}>{stateLabel}</span>
|
||||
</header>
|
||||
|
||||
<dl className="status__grid">
|
||||
<div className="status__item">
|
||||
<dt>PDFs lidos</dt>
|
||||
<dd>
|
||||
{status?.pdfs_indexed ?? 0}
|
||||
{status ? ` / ${status.pdfs_total}` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="status__item">
|
||||
<dt>Vetores indexados</dt>
|
||||
<dd>{status?.chunks ?? 0}</dd>
|
||||
</div>
|
||||
<div className="status__item">
|
||||
<dt>Modelo</dt>
|
||||
<dd className="status__model">{status?.model ?? "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{status && status.errors.length > 0 && (
|
||||
<ul className="status__errors">
|
||||
{status.errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="status__reindex"
|
||||
onClick={onReindex}
|
||||
disabled={reindexing || state === "indexing"}
|
||||
>
|
||||
{reindexing ? "Reindexando…" : "Reindexar PDFs"}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSSE, type SSEEvent } from "./sse";
|
||||
|
||||
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("parseSSE", () => {
|
||||
it("parses a sources event", async () => {
|
||||
const stream = streamOf([
|
||||
'event: sources\ndata: {"sources":[{"reference":"Gênesis 1:1"}]}\n\n',
|
||||
]);
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
for await (const event of parseSSE(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].event).toBe("sources");
|
||||
const sources = events[0].data.sources as { reference: string }[];
|
||||
expect(sources[0].reference).toBe("Gênesis 1:1");
|
||||
});
|
||||
|
||||
it("parses the token event", async () => {
|
||||
const stream = streamOf(['event: token\ndata: {"text":"Olá"}\n\n']);
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
for await (const event of parseSSE(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events[0].event).toBe("token");
|
||||
expect(events[0].data.text).toBe("Olá");
|
||||
});
|
||||
|
||||
it("parses the done event with citations", async () => {
|
||||
const stream = streamOf(['event: done\ndata: {"citations":["Gênesis 1:1"]}\n\n']);
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
for await (const event of parseSSE(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events[0].event).toBe("done");
|
||||
expect(events[0].data.citations).toEqual(["Gênesis 1:1"]);
|
||||
});
|
||||
|
||||
it("parses the error event", async () => {
|
||||
const stream = streamOf(['event: error\ndata: {"message":"falhou"}\n\n']);
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
for await (const event of parseSSE(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events[0].event).toBe("error");
|
||||
expect(events[0].data.message).toBe("falhou");
|
||||
});
|
||||
|
||||
it("handles events split across chunks", async () => {
|
||||
const stream = streamOf([
|
||||
'event: token\ndata: {"tex',
|
||||
't":"Olá"}\n\n',
|
||||
]);
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
for await (const event of parseSSE(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].data.text).toBe("Olá");
|
||||
});
|
||||
|
||||
it("handles multiple events in one chunk", async () => {
|
||||
const stream = streamOf([
|
||||
'event: sources\ndata: {"sources":[]}\n\nevent: token\ndata: {"text":"Oi"}\n\n',
|
||||
]);
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
for await (const event of parseSSE(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events.map((event) => event.event)).toEqual(["sources", "token"]);
|
||||
});
|
||||
|
||||
it("ignores comment lines", async () => {
|
||||
const stream = streamOf([": keep-alive\n\nevent: token\ndata: {\"text\":\"Oi\"}\n\n"]);
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
for await (const event of parseSSE(stream)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].event).toBe("token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Parser de eventos Server-Sent Events.
|
||||
*
|
||||
* O backend responde a `POST /api/chat` com `text/event-stream`. Como o
|
||||
* `EventSource` do navegador só aceita `GET`, o consumo é feito com `fetch` e o
|
||||
* corpo lido como `ReadableStream`.
|
||||
*/
|
||||
|
||||
export interface SSEEvent {
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_EVENT = "message";
|
||||
|
||||
/** Divide o corpo do stream em eventos SSE completos. */
|
||||
export async function* parseSSE(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary !== -1) {
|
||||
const raw = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const parsed = parseEventBlock(raw);
|
||||
if (parsed) {
|
||||
yield parsed;
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = parseEventBlock(buffer);
|
||||
if (trailing) {
|
||||
yield trailing;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Converte um bloco `event:`/`data:` em um evento tipado. */
|
||||
function parseEventBlock(block: string): SSEEvent | null {
|
||||
let event = DEFAULT_EVENT;
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of block.split("\n")) {
|
||||
const line = rawLine.replace(/\r$/, "");
|
||||
if (!line || line.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).replace(/^ /, ""));
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = dataLines.join("\n");
|
||||
try {
|
||||
return { event, data: JSON.parse(payload) as Record<string, unknown> };
|
||||
} catch {
|
||||
return { event, data: { raw: payload } };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,560 @@
|
||||
/* Pastor Online — tema claro/escuro com tokens de cor. */
|
||||
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--surface: #171a21;
|
||||
--surface-raised: #1e222b;
|
||||
--fg: #e8eaf0;
|
||||
--muted: #9aa2b1;
|
||||
--border: #2a2f3a;
|
||||
--accent: #7aa2f7;
|
||||
--accent-contrast: #0b0d11;
|
||||
--success: #7bd88f;
|
||||
--warning: #e0af68;
|
||||
--error: #f7768e;
|
||||
--radius: 12px;
|
||||
--radius-lg: 18px;
|
||||
--shadow: 0 8px 30px rgb(0 0 0 / 35%);
|
||||
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-serif: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f6f7fb;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #f0f2f8;
|
||||
--fg: #1a1d24;
|
||||
--muted: #5c6474;
|
||||
--border: #dfe3ec;
|
||||
--accent: #3b6fd4;
|
||||
--accent-contrast: #ffffff;
|
||||
--success: #2f9e56;
|
||||
--warning: #b5761f;
|
||||
--error: #cc3b5c;
|
||||
--shadow: 0 8px 24px rgb(20 30 60 / 10%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Barra lateral */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 24px 20px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar__brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sidebar__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
border-radius: 11px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 21px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.sidebar__subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sidebar__theme {
|
||||
margin-top: auto;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar__theme:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Painel de status */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.status {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.status__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.status__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status__badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status__badge--ready {
|
||||
background: color-mix(in srgb, var(--success) 18%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status__badge--indexing {
|
||||
background: color-mix(in srgb, var(--warning) 18%, transparent);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status__badge--error {
|
||||
background: color-mix(in srgb, var(--error) 18%, transparent);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status__grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status__item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status__item dt {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.status__item dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.status__model {
|
||||
font-size: 11px !important;
|
||||
font-weight: 400 !important;
|
||||
color: var(--muted);
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status__errors {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 16px;
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status__reindex {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.status__reindex:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.status__reindex:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Chat */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px 24px 8px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.chat__empty {
|
||||
max-width: 640px;
|
||||
margin: 8vh auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat__empty h2 {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat__empty p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.chat__suggestions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 26px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chat__suggestions li {
|
||||
padding: 11px 15px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 13.5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat__error {
|
||||
margin: 0 24px 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--error) 45%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mensagens */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.message--user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message__bubble {
|
||||
max-width: min(760px, 86%);
|
||||
padding: 13px 17px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message--user .message__bubble {
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--surface-raised));
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
|
||||
.message__bubble--markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message__bubble--markdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message__bubble--markdown strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.message__bubble--markdown blockquote {
|
||||
margin: 14px 0;
|
||||
padding: 10px 16px;
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message__bubble--markdown code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.message__bubble--pending {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Accordion de fontes */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.sources {
|
||||
max-width: min(760px, 86%);
|
||||
margin-top: -8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.sources__summary {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sources__summary:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.sources__body {
|
||||
padding: 4px 15px 15px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sources__citations {
|
||||
margin: 12px 0;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sources__item {
|
||||
margin: 12px 0 0;
|
||||
padding: 11px 14px;
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.sources__reference {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sources__text {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.sources__meta {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compositor */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
padding: 16px 24px 22px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.composer__input {
|
||||
flex: 1;
|
||||
min-height: 50px;
|
||||
max-height: 180px;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.composer__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.composer__input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.composer__send {
|
||||
padding: 13px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.composer__send:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Responsivo */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.status__grid {
|
||||
grid-template-columns: repeat(3, auto);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.status__item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.status__reindex {
|
||||
width: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.sidebar__theme {
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat__scroll {
|
||||
padding: 20px 16px 4px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.message__bubble,
|
||||
.sources {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sidebar__subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status__grid {
|
||||
grid-template-columns: repeat(2, auto);
|
||||
}
|
||||
|
||||
.composer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.composer__send {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/test-setup.ts","./src/vite-env.d.ts","./src/components/ChatPanel.test.tsx","./src/components/ChatPanel.tsx","./src/components/MessageBubble.tsx","./src/components/SourcesAccordion.tsx","./src/components/StatusPanel.tsx","./src/lib/sse.test.ts","./src/lib/sse.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test-setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user