Files
duocthu/docs/13-frontend-architecture.md
T

169 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 13 — Frontend architecture
`apps/web` — Next.js 14 App Router, React 18, TypeScript, Tailwind,
framer-motion, lucide-react. Vietnamese-only UI.
## Structure
```
apps/web/
├── middleware.ts rate limiting on /api/*
├── app/
│ ├── layout.tsx root layout + ThemeProvider
│ ├── globals.css Tailwind + design tokens
│ ├── page.tsx chat page
│ ├── tra-cuu/page.tsx "lookup" page
│ ├── api/{chat,suggest,feedback,pdf}/route.ts BFF (see doc 12)
│ └── _components/
│ ├── ChatPanel.tsx (445 lines) chat state + fetch + timeouts
│ ├── Composer.tsx (231) input + autocomplete
│ ├── Sidebar.tsx (237) sessions / navigation
│ ├── EvidencePanel.tsx (102) citation cards
│ ├── AnswerFeedback.tsx (111) thumbs → /api/feedback
│ └── NavTabs.tsx (39)
```
Shared packages: `@duoc-thu/ui` (`ChatBubble`, `CitationCard`,
`CitationBeamOverlay`, `DisclaimerBanner`, `ThemeContext`, `ThemeSelector`, and
shadcn-style `alert`/`badge`/`button`/`card`/`input` primitives) and
`@duoc-thu/shared-types`.
`@duoc-thu/api-client` is declared as a dependency and exports
`sendChatMessage` / `getDrugSuggestions` / `mockFixtures`, but the live chat
path in `ChatPanel.tsx` calls `fetch("/api/chat")` directly. It is effectively
unused by the running app.
## Rendering model
Server Components by default; `ChatPanel` and the other interactive components
are `"use client"`. There is no SSR data fetching for chat — the page renders
empty and the first turn is a client `fetch`. No state library: `useState` +
props.
## The request lifecycle in `ChatPanel`
```mermaid
flowchart TD
S["handleSendMessage(text)"]
G{empty or already loading?}
U["append user message; isLoading = true"]
AC["new AbortController()<br/>setTimeout(abort, 65_000)"]
TICK["setInterval 1s → elapsedMs<br/>(slow notice at 15s)"]
F["fetch /api/chat {content, conversationId: sessionId}"]
OK["append assistant message<br/>onCitationsLoaded(citations)"]
AB{AbortError?}
STOP["user pressed Stop →<br/>'Đã dừng chờ trên giao diện…'"]
TO["timeout → 'Hệ thống xử lý quá 65 giây…'"]
ERR["other → 'Không thể kết nối đến máy chủ AI Service…'"]
FIN["clear timers; isLoading = false"]
S --> G -->|yes| FIN
G -->|no| U --> AC --> TICK --> F
F -->|ok| OK --> FIN
F -->|throw| AB
AB -->|yes + stopRequested| STOP --> FIN
AB -->|yes| TO --> FIN
AB -->|no| ERR --> FIN
```
### The two timing constants
```ts
const REQUEST_TIMEOUT_MS = 65_000;
const SLOW_REQUEST_NOTICE_MS = 15_000;
```
The 65 s value is derived, and the derivation is in the source comment: the
backend budget is 40 s and is only checked *between* model calls, so the real
worst case is ~40 s plus one in-flight call bounded by `read_timeout=20` ≈ 60 s.
Measured production latencies (n=8, 2026-08-11, one user, sequential):
`6.2 / 6.4 / 8.4 / 10.9 / 12.4 / 21.7 / 25.1 / 40.3` s. The earlier 25 s limit
cut off two of those eight — including a 25.1 s case that had returned a correct
grounded answer with two citations.
`SLOW_REQUEST_NOTICE_MS` only changes the wording of the wait; the comment is
explicit that it is a stopgap for the real fix (streaming verified claims as they
land) and does not make anything faster.
### React 18 Strict Mode guard
`initialQuerySentRef` exists because Strict Mode replays effects in development,
which sent every starter-question click as **two identical live requests**
found in the trace as duplicate turns.
## Rendering an answer
The UI does not parse prose. It renders what the backend verified:
| Backend field | UI use |
|---|---|
| `blocks[]` | Sections with a title and a `kind` (`fact_list` / `warning` / `dosage`) that drives styling |
| `claims[].sourceIds` | Resolved against `message.citations` to link a claim to its card |
| `citations[]` | `EvidencePanel` cards: drug, section, printed page range, exact `snippet` |
| `isQuarantined` + `quarantineNotice` | A distinct card telling the reader to check the source page and not infer numbers |
| `generated` | Distinguishes an LLM paraphrase from a verbatim quote |
| `quickReplies` | Tappable chips on a `clarify` turn |
| `disclaimer` | `DisclaimerBanner` |
| `traceId` | Sent back with feedback |
`CitationBeamOverlay` draws the visual link between a claim and its citation
card.
Starter questions in `ChatPanel` are hard-coded and each targets a different
retrieval route: `Chỉ Định` (Levetiracetam), `Chống Chỉ Định` (Metformin),
`ADR Theo Tần Suất` (Zolpidem), `Thời Kỳ Mang Thai` (Fluoxetin).
## Sessions
`sessionId` is a client-side value passed as `conversationId`. There is no
session API, no login, and no server-side session record beyond the
`rag_conversation_turn` rows keyed by whatever string the client sends. Anyone
who guesses a `conversation_id` can read its history into their own turn's LLM
context — see [16-security.md](16-security.md).
## Rate limiting lives here
`middleware.ts` implements the only rate limiting in the system. See
[16-security.md](16-security.md) for the rules and their stated limitations.
## Configuration
| Variable | Default | Use |
|---|---|---|
| `API_GATEWAY_URL` | — | Preferred upstream base URL |
| `AI_SERVICE_URL` | `http://localhost:8000` | Fallback; set to `http://ai-service:8000` in `docker-compose.prod.yml` |
Both accept either a base URL or a full `/v1/rag/...` URL — the handlers check
`.includes("/v1/rag")` and rewrite accordingly.
## Build
Three-stage Dockerfile: `pnpm install --frozen-lockfile` over the workspace
manifests, then `pnpm --filter @duoc-thu/web build`, then `next start -p 3000 -H
0.0.0.0`. The runtime stage copies the **whole** `/repo` (not a standalone
output), so the image carries source and `node_modules`.
`next.config.js`, `tailwind.config.ts`, `postcss.config.js`, `components.json`
(shadcn) and `.eslintrc.json` are all present.
## Frontend testing
**Not found.** `apps/web/package.json` has no `test` script and no test
dependency; there are no `*.test.tsx` / `*.spec.ts` files, no Jest/Vitest
config, and no Playwright/Cypress setup. `turbo run test` therefore does nothing
for `web`. Everything above — the timeout derivation, the abort handling, the
Strict Mode guard, the reason-code mapping, the citation grouping — is
uncovered by automated tests.
## Known frontend gaps
- No streaming, so the UI shows a spinner for the full 640 s.
- No virtualised message list.
- No error boundary around `ChatPanel`.
- `/api/pdf` reads a 37 MB file into memory per request with no range support
and no caching headers. It is **not rate limited**: `middleware.ts` matches
`/api/:path*` but `matchRules` only has entries for `/api/chat` and
`/api/suggest`, so `/api/pdf` and `/api/feedback` fall through to
`NextResponse.next()`.
- `mobile/` is a placeholder README.