6.9 KiB
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
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
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.
Rate limiting lives here
middleware.ts implements the only rate limiting in the system. See
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/pdfreads a 37 MB file into memory per request with no range support and no caching headers. It is not rate limited:middleware.tsmatches/api/:path*butmatchRulesonly has entries for/api/chatand/api/suggest, so/api/pdfand/api/feedbackfall through toNextResponse.next().mobile/is a placeholder README.