# 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()
setTimeout(abort, 65_000)"]
TICK["setInterval 1s → elapsedMs
(slow notice at 15s)"]
F["fetch /api/chat {content, conversationId: sessionId}"]
OK["append assistant message
onCitationsLoaded(citations)"]
AB{AbortError?}
STOP["user pressed Stop →
'Đã 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 6–40 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.