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

+23
View File
@@ -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;"]
+16
View File
@@ -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>
+33
View File
@@ -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;
}
}
+5768
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -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"
}
}
+187
View File
@@ -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>
);
}
+86
View File
@@ -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();
});
});
+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>
);
}
+109
View File
@@ -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");
});
});
+80
View File
@@ -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 } };
}
}
+11
View File
@@ -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>,
);
+560
View File
@@ -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%;
}
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -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"]
}
+1
View File
@@ -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"}
+20
View File
@@ -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"],
},
});