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

6.9 KiB
Raw Blame History

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