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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user