feat(frontend): add React chat UI with SSE parsing and source accordion

This commit is contained in:
omp committed 2026-11-01 19:25:35 -03:00
1 parent 7a0396422f
commit 7f4d4bde66
21 files changed
+7220

No files matched your search

@@ -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();
});
});
+55
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}