169 lines
6.9 KiB
Markdown
169 lines
6.9 KiB
Markdown
# 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 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.
|