# 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.