Skip to content

Commit c301c6e

Browse files
[feat](chat): AI Advisor — context-aware, client-side chatbot (Phase 3)
A grounded, offline, rule-based assistant built on the Adapter Pattern. Engine/state (decoupled, src/lib/chat/): - ChatAdapter contract + getChatAdapter() single swap-point; localAdvisorAdapter answers from the FROZEN engine + config (answerText() pure, unit-tested) so it can never contradict the model or fabricate. Network LLM = drop-in later, zero UI change. Streams via AsyncIterable under an AbortSignal. - buildChatContext(): structuredClone deep-clone + dependency-free validation; invalid/partial input → complete moderate baseline (no undefined/crash). - useChat: throttled submits + ignore-while-streaming (no spam), stop/regenerate/ abort, hydration-safe persistence, cross-tab reset via BroadcastChannel. UI (lazy, src/components/chat/): - ChatFab (lazy) → ChatMount → ChatPanel; memoized bubbles render via the existing dependency-free XSS-safe-by-construction markdown renderer, each in a per-bubble ErrorBoundary. Smart-scroll (IntersectionObserver, pauses on manual scroll-up), streaming caret, Stop/Regenerate, suggestions, full a11y (labelled dialog, polite live region, keyboard), EN/ID. Wiring (anti-regression): FAB mounted GLOBALLY so tab-switch closes the Guide but never unmounts chat / disrupts a stream; "Start Over" wipes chat + broadcasts. Bundle: FAB + all chat lazy → INITIAL budget untouched (119.9/120); total 260→268 (documented). Browser-verified: grounded /100 reply, survives nav, reset wipes, EN/ID. 145 unit (+17) + 14 e2e, tsc, lint, all 6 guards green. README Phase 3 section + Mermaid + adapter integration snippet; DECISIONS records the two reconciliations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7e02d7e commit c301c6e

19 files changed

Lines changed: 1385 additions & 1 deletion

DECISIONS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,35 @@ Both are chosen precisely to preserve the Zero-Mismatch invariant.
380380
keep surfacing as individual PRs to be evaluated one at a time. This decision is the *evaluation
381381
outcome* ("not yet"); if Tailwind's footprint grows or a security need appears, revisit as a
382382
scoped migration (CSS-first config + a full visual/e2e/gate pass) rather than an auto-merge.
383+
384+
## Phase 3 — "AI Advisor" chat (Context-Aware, Client-Side), 2026-07-19
385+
386+
Implements the Phase-3 blueprint while honoring two enforced invariants (building it literally
387+
would have tripped both — the very regressions the mandate forbids):
388+
389+
- **LLM-agnostic adapter, local implementation (no external API).** The `ChatService` is the
390+
Adapter Pattern (`src/lib/chat/`): `getChatAdapter()` is the single swap-point. Today it returns
391+
the **local, offline, rule-based `localAdvisorAdapter`** — grounded in the *frozen engine* + the
392+
same config the app renders (`answerText()` is pure + unit-tested), so the chat can never
393+
contradict the model or fabricate a fact. Keeps the product's core promise (100% client-side,
394+
free, offline, no keys, no telemetry) intact. A network LLM is a drop-in: implement `ChatAdapter`
395+
and return it from that one function — **zero** UI/hook/state changes. "AI Advisor" is the
396+
product name; the UI says "computed from the model, not a language model."
397+
- **Dependency-free rich rendering (no react-markdown / DOMPurify / Mermaid / Zod).** Bubbles use
398+
the existing `lib/markdown.tsx` (React elements, **never** `dangerouslySetInnerHTML`) — XSS-safe
399+
*by construction*, a stronger guarantee than sanitizing an HTML string, and adds no dep. A
400+
per-bubble `ChatErrorBoundary` is the belt-and-braces. Context is validated by a small runtime
401+
guard (the repo's no-Zod stance), not a schema library.
402+
- **Bundle: initial budget UNTOUCHED.** The FAB **and** everything behind it are `lazy()` — initial
403+
JS stays 119.9 kB / 120 kB. Only the lazy chunk grew the total; budget raised 260 → 268
404+
(documented in `check-bundle-size.mjs`), well under the 300 kB NFR cap.
405+
- **State safety (Phase 3.1).** `buildChatContext()` **deep-clones** (`structuredClone`) the live
406+
pipeline payload so chat can never mutate app state, and falls back to a complete moderate
407+
baseline on invalid/partial input (no `undefined`/null-pointer). `useChat` **throttles** submits +
408+
ignores sends while streaming (no compute/API spam), streams via an `AbortSignal`
409+
(stop/regenerate/unmount cancel cleanly), and persists messages (hydration-safe: pure client SPA,
410+
no SSR step to mismatch).
411+
- **Anti-contamination + harmony (Phase 2.2/3.1).** "Start Over" wipes chat state + persistence and
412+
**BroadcastChannels a reset** so other tabs clear silently. The FAB mounts **globally** (never
413+
per-view), so switching tabs closes the Guide but **never** unmounts the chat or disrupts an
414+
active stream. Verified in-browser: reply grounded to `/100`, survives nav, reset wipes, EN/ID.

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,40 @@ flowchart LR
120120
- **④ Strategic output** — the recommendation with a trade-off radar, sensitivity, anti-pattern
121121
warnings and migration paths; export an ADR (MADR), a full report, CSV/JSON, or a share link.
122122

123+
## AI Advisor chat (Phase 3)
124+
125+
A floating **AI Advisor** (bottom-right) answers questions grounded in *your* scenario —
126+
"what do you recommend?", "what is microservices?", "monolith vs microservices", "why?". It is a
127+
**client-side, offline, rule-based** assistant computed from the frozen engine + the same config
128+
the app renders, so it can never contradict the model or fabricate a fact (the UI says
129+
*"computed from the model, not a language model"*). It is built on the **Adapter Pattern**, so a
130+
network LLM is a drop-in later with zero UI changes.
131+
132+
```mermaid
133+
flowchart LR
134+
U["User message"] --> H["useChat hook<br/>(throttle · abort · persist)"]
135+
CTX["Live pipeline state"] -->|"buildChatContext()<br/>deep-clone + validate"| H
136+
H --> A["getChatAdapter()<br/><b>ChatService (Adapter)</b>"]
137+
A --> L["localAdvisorAdapter<br/>(offline, rule-based)"]
138+
L -->|"reads"| E["Frozen engine<br/>rank() · contributions()"]
139+
L -->|"async stream"| H
140+
H --> V["ChatPanel<br/>(memoized bubbles · smart-scroll · a11y)"]
141+
R["Start Over"] -.->|"reset + BroadcastChannel"| H
142+
A -. "future" .-> N["networkLlmAdapter<br/>(drop-in, same contract)"]
143+
```
144+
145+
- **Zero-Mismatch handoff**`buildChatContext()` deep-clones (`structuredClone`) the pipeline
146+
payload, so the chat can never mutate the app's scenario; invalid input degrades to a moderate
147+
baseline (no `undefined`/crash).
148+
- **No spam, clean cancel** — submissions are throttled and ignored while streaming; every turn
149+
streams under an `AbortSignal` (stop / regenerate / unmount all cancel cleanly).
150+
- **Anti-contamination** — "Start Over" wipes chat state + persistence and broadcasts a reset so
151+
other tabs clear silently. The launcher is mounted **globally**, so switching tabs closes the
152+
Guide but never disrupts an active chat stream.
153+
- **Lean & safe** — the launcher **and** everything behind it are lazy-loaded (initial JS budget
154+
untouched); bubbles render with the dependency-free, XSS-safe-by-construction Markdown renderer
155+
(React elements, never `dangerouslySetInnerHTML`), each wrapped in a per-bubble error boundary.
156+
123157
## Run it locally
124158

125159
> **Prerequisite:** Node **24** (LTS) — the version is pinned in [`.nvmrc`](.nvmrc) and used by all
@@ -251,11 +285,37 @@ real pipeline steps) and the total duration short; the component already renders
251285
`prefers-reduced-motion`. It is triggered by `analysisRun` in `App.tsx`, which increments on an
252286
explicit analyze action (preset/wizard apply) — never on a live factor edit.
253287

288+
### Swap the chat AI backend (ChatService adapter)
289+
290+
The chat depends only on the `ChatAdapter` contract, so the whole UI/state layer is backend-agnostic.
291+
`getChatAdapter()` in [`src/lib/chat/index.ts`](src/lib/chat/index.ts) is the **single** swap-point —
292+
return a different adapter and nothing else changes:
293+
294+
```ts
295+
// src/lib/chat/types.ts (the contract every backend implements)
296+
export interface ChatAdapter {
297+
readonly id: string;
298+
readonly network: boolean; // drives offline UX + resiliency paths
299+
reply(history: readonly ChatMessage[], context: ChatContext, signal: AbortSignal): AsyncIterable<ChatChunk>;
300+
}
301+
302+
// src/lib/chat/index.ts — swap here, zero UI/hook changes:
303+
export function getChatAdapter(): ChatAdapter {
304+
return localAdvisorAdapter; // today: offline, rule-based, grounded in the frozen engine
305+
// return networkLlmAdapter; // future: a streaming LLM — same contract, drop-in
306+
}
307+
```
308+
309+
`buildChatContext()` deep-clones + validates the pipeline payload before it reaches any adapter, so
310+
a new backend can never mutate app state or receive an `undefined`.
311+
254312
### Component map (Advisor tab)
255313

256314
| Area | Component | Notes |
257315
|---|---|---|
258316
| Scenario gallery + wizard entry | [`components/advisor/PresetBar.tsx`](src/components/advisor/PresetBar.tsx) | search, tag filters, dominant custom card |
317+
| AI Advisor chat (lazy) | [`components/chat/ChatFab.tsx`](src/components/chat/ChatFab.tsx) · `ChatPanel.tsx` · [`hooks/useChat.ts`](src/hooks/useChat.ts) | launcher + panel + state bridge |
318+
| Chat service (Adapter) | [`lib/chat/`](src/lib/chat/) | `getChatAdapter()` · `localAdvisorAdapter` · `buildChatContext()` |
259319
| Custom wizard (lazy modal) | [`components/advisor/CustomWizard.tsx`](src/components/advisor/CustomWizard.tsx) | iterates the wizard config |
260320
| Wizard → engine bridge (pure) | [`lib/customWizard.ts`](src/lib/customWizard.ts) | `wizardToLevels()` — the only mapping |
261321
| ① Project factors | [`components/advisor/FactorInputs.tsx`](src/components/advisor/FactorInputs.tsx) · `FactorField.tsx` | 14 factors, per-level examples |

scripts/check-bundle-size.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { gzipSync } from 'node:zlib';
1717
// shared async chunk can't silently be mis-counted. Headroom catches a real regression; raise the
1818
// budgets deliberately (with a note) if the app grows.
1919
const JS_INITIAL_BUDGET_KB = 120;
20-
const JS_TOTAL_BUDGET_KB = 260; // raised 200→260 for full Insights bilingualisation 2026-07-15 (6 datasets + 18 article bodies now carry EN+ID; all in the lazy content chunk — initial budget untouched; NFR cap is 300)
20+
const JS_TOTAL_BUDGET_KB = 268; // raised 200→260 (Insights bilingualisation 2026-07-15); 260→268 for the Phase 3 AI Advisor chat 2026-07-19 (adapter + hook + panel, all in a LAZY chunk — the FAB is lazy too, so the initial budget is untouched; NFR cap is 300)
2121
const CSS_BUDGET_KB = 27; // raised 25→27 for Fase 2g UI/UX polish 2026-07-18 (preset dropdown, uniform export buttons, 2×2 step-rail wrap grid, footer stacking, modern app-bar controls); still well under the ~30kB NFR ceiling
2222

2323
const dir = 'dist/assets';

src/App.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
22
import { IconBulb, IconCompass, IconHome } from '@tabler/icons-react';
33
import { BrandMark } from './components/chrome/BrandMark';
44
import { AuroraBackground } from './components/chrome/AuroraBackground';
5+
import { resetChatPersistence } from './lib/chat/persist';
56
import { MobileChrome } from './components/chrome/MobileChrome';
67
import { AdvisorMobileBar } from './components/chrome/AdvisorMobileBar';
78
import { LandingView } from './components/landing/LandingView';
@@ -57,6 +58,11 @@ const ManualBook = lazy(() => import('./components/overlays/ManualBook'));
5758
// the Advisor's initial bundle. The Advisor remains the default view.
5859
const LearnView = lazy(() => import('./components/insights/LearnView'));
5960

61+
// AI Advisor chat (Phase 3) — lazy so NOTHING chat-related (FAB, panel, hook, adapter, renderer)
62+
// touches the initial bundle; it loads on first idle. Only `resetChatPersistence` (tiny, engine-free)
63+
// is imported eagerly, for "Start Over".
64+
const ChatFab = lazy(() => import('./components/chat/ChatFab'));
65+
6066
type Selections = Partial<Record<DimensionId, string>>;
6167

6268
export default function App() {
@@ -111,6 +117,8 @@ export default function App() {
111117
const [currentDim, setCurrentDim] = useState<DimensionId>('D1');
112118
const [migKey, setMigKey] = useState<MigrationKey>('big');
113119
const undoRef = useRef<{ levels: Levels; selections: Selections; overrides: Overrides } | null>(null);
120+
// Registered by the chat panel when open, so "Start Over" can reset it in the same tab.
121+
const chatResetRef = useRef<(() => void) | null>(null);
114122

115123
const scenario: ScenarioState = { v: 1, mode, lang, levels, selections, overrides };
116124
const exportInput: ExportInput = { levels, overrides, selections: effective, lang };
@@ -129,6 +137,10 @@ export default function App() {
129137
const resetAll = () => {
130138
undoRef.current = { levels, selections, overrides };
131139
applyPreset(DEFAULT_LEVELS);
140+
// "Start Over" wipes the chat too (anti-contamination, Phase 3.1): in-tab via the registered
141+
// reset if the panel was opened, else just the persistence + cross-tab broadcast.
142+
if (chatResetRef.current) chatResetRef.current();
143+
else resetChatPersistence();
132144
};
133145
const undoReset = () => {
134146
const snap = undoRef.current;
@@ -220,6 +232,11 @@ export default function App() {
220232
return (
221233
<>
222234
<AuroraBackground />
235+
{/* AI Advisor chat (Phase 3) — mounted GLOBALLY (never per-view) so navigating tabs closes the
236+
Guide but never unmounts the chat or disrupts an active stream (Phase 2.2 harmony). */}
237+
<Suspense fallback={null}>
238+
<ChatFab contextInput={{ levels, overrides, mode, lang }} registerReset={(fn) => (chatResetRef.current = fn)} />
239+
</Suspense>
223240
<MobileChrome mainView={mainView} onNavigate={navigate} theme={theme} onToggleTheme={toggleTheme} mode={mode} onSetMode={setMode} />
224241
{mainView === 'advisor' && <AdvisorMobileBar />}
225242
<div className={'screen-only aa-page' + (mainView === 'advisor' ? ' has-actionbar' : '')}>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Component, type ErrorInfo, type ReactNode } from 'react';
2+
3+
// Per-bubble crash isolation (Blueprint Phase 2.2). If a single message fails to render (e.g. a
4+
// future LLM emits markup the renderer chokes on), THIS boundary shows a localized fallback in that
5+
// one bubble — the chat and the whole app keep running. Local markdown is React-elements-only so it
6+
// can't inject HTML; the boundary is the belt-and-braces for any future rich renderer.
7+
interface Props {
8+
fallback: ReactNode;
9+
children: ReactNode;
10+
}
11+
interface State {
12+
failed: boolean;
13+
}
14+
15+
export class ChatErrorBoundary extends Component<Props, State> {
16+
constructor(props: Props) {
17+
super(props);
18+
this.state = { failed: false };
19+
}
20+
21+
static getDerivedStateFromError(): State {
22+
return { failed: true };
23+
}
24+
25+
componentDidCatch(error: Error, info: ErrorInfo): void {
26+
// Non-fatal: keep a console breadcrumb for debugging without surfacing a global crash.
27+
if (import.meta.env.DEV) console.warn('[chat] message render failed', error, info);
28+
}
29+
30+
render(): ReactNode {
31+
return this.state.failed ? this.props.fallback : this.props.children;
32+
}
33+
}

src/components/chat/ChatFab.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
2+
import { IconMessageChatbot, IconX } from '@tabler/icons-react';
3+
import { useI18n } from '../../i18n/I18nContext';
4+
import type { ChatContextInput } from '../../hooks/useChat';
5+
6+
// Everything heavy (useChat + adapter + Markdown renderer) is behind this lazy import, so the FAB
7+
// is the ONLY chat code in the initial bundle (Blueprint Phase 2.1 — FCP protected).
8+
const ChatMount = lazy(() => import('./ChatMount'));
9+
10+
interface Props {
11+
contextInput: ChatContextInput;
12+
/** App registers the mounted chat's reset() so "Start Over" wipes it in-tab. */
13+
registerReset: (reset: (() => void) | null) => void;
14+
}
15+
16+
// The floating chat launcher (Advisor "AI Advisor"). Toggles the lazy panel; keeps its own open
17+
// state. Esc closes. Opening never resets chat; closing keeps the session (persisted).
18+
export function ChatFab({ contextInput, registerReset }: Readonly<Props>) {
19+
const { t } = useI18n();
20+
const [open, setOpen] = useState(false);
21+
const [everOpened, setEverOpened] = useState(false);
22+
const btnRef = useRef<HTMLButtonElement>(null);
23+
24+
useEffect(() => {
25+
if (!open) return;
26+
const onKey = (e: globalThis.KeyboardEvent) => {
27+
if (e.key === 'Escape') setOpen(false);
28+
};
29+
window.addEventListener('keydown', onKey);
30+
return () => window.removeEventListener('keydown', onKey);
31+
}, [open]);
32+
33+
const toggle = () => {
34+
setOpen((v) => !v);
35+
setEverOpened(true);
36+
};
37+
38+
return (
39+
<div className="aa-chat-root screen-only">
40+
{/* Mounted once opened, then kept mounted (display-toggled) so the session/stream survives
41+
closing the panel — closing must not reset the chat (Blueprint Phase 2.2 harmony). */}
42+
{everOpened && (
43+
<div style={{ display: open ? 'block' : 'none' }}>
44+
<Suspense fallback={null}>
45+
<ChatMount contextInput={contextInput} onClose={() => setOpen(false)} registerReset={registerReset} />
46+
</Suspense>
47+
</div>
48+
)}
49+
<button
50+
ref={btnRef}
51+
type="button"
52+
className={'aa-chat-fab' + (open ? ' open' : '')}
53+
onClick={toggle}
54+
aria-label={open ? t('chat.close') : t('chat.open')}
55+
aria-expanded={open}
56+
title={open ? t('chat.close') : t('chat.open')}
57+
>
58+
{open ? <IconX size={22} aria-hidden /> : <IconMessageChatbot size={22} aria-hidden />}
59+
</button>
60+
</div>
61+
);
62+
}
63+
64+
export default ChatFab;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { memo } from 'react';
2+
import { IconRefresh, IconAlertTriangle } from '@tabler/icons-react';
3+
import { renderMarkdown } from '../../lib/markdown';
4+
import { ChatErrorBoundary } from './ChatErrorBoundary';
5+
import type { ChatMessage } from '../../lib/chat';
6+
7+
interface Props {
8+
msg: ChatMessage;
9+
/** Localized "Failed to render output" fallback text. */
10+
renderErrorText: string;
11+
/** Localized "Regenerate" label; shown on the last errored assistant turn. */
12+
regenerateLabel: string;
13+
onRegenerate: () => void;
14+
showRegenerate: boolean;
15+
}
16+
17+
// One chat bubble — MEMOIZED (Blueprint Phase 2.2: 60fps with 100+ messages; only the streaming
18+
// bubble re-renders because its `text` prop changes). Assistant markdown is rendered with the app's
19+
// dependency-free, XSS-safe-by-construction renderer (React elements, never dangerouslySetInnerHTML),
20+
// wrapped in a per-bubble ErrorBoundary.
21+
function ChatMessageItemBase({ msg, renderErrorText, regenerateLabel, onRegenerate, showRegenerate }: Readonly<Props>) {
22+
const isUser = msg.role === 'user';
23+
return (
24+
<div className={`aa-chat-msg ${isUser ? 'user' : 'bot'}`}>
25+
<div className="aa-chat-bubble">
26+
{isUser ? (
27+
msg.text
28+
) : msg.error ? (
29+
<span className="aa-chat-error">
30+
<IconAlertTriangle size={14} aria-hidden /> {renderErrorText}
31+
</span>
32+
) : (
33+
<ChatErrorBoundary
34+
fallback={
35+
<span className="aa-chat-error">
36+
<IconAlertTriangle size={14} aria-hidden /> {renderErrorText}
37+
</span>
38+
}
39+
>
40+
<div className="learn-prose aa-chat-prose">{renderMarkdown(msg.text)}</div>
41+
</ChatErrorBoundary>
42+
)}
43+
{msg.streaming && <span className="aa-chat-caret" aria-hidden />}
44+
</div>
45+
{!isUser && (msg.error || showRegenerate) && !msg.streaming && (
46+
<button type="button" className="aa-chat-regen" onClick={onRegenerate}>
47+
<IconRefresh size={13} aria-hidden />
48+
{regenerateLabel}
49+
</button>
50+
)}
51+
</div>
52+
);
53+
}
54+
55+
// Re-render only when the visible content/flags of THIS message change.
56+
export const ChatMessageItem = memo(
57+
ChatMessageItemBase,
58+
(a, b) =>
59+
a.msg.text === b.msg.text &&
60+
a.msg.streaming === b.msg.streaming &&
61+
a.msg.error === b.msg.error &&
62+
a.showRegenerate === b.showRegenerate &&
63+
a.renderErrorText === b.renderErrorText &&
64+
a.regenerateLabel === b.regenerateLabel,
65+
);

src/components/chat/ChatMount.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useEffect } from 'react';
2+
import { useChat, type ChatContextInput } from '../../hooks/useChat';
3+
import { ChatPanel } from './ChatPanel';
4+
5+
interface Props {
6+
contextInput: ChatContextInput;
7+
onClose: () => void;
8+
/** App registers the chat's reset() so "Start Over" can wipe it in the same tab (null on unmount). */
9+
registerReset: (reset: (() => void) | null) => void;
10+
}
11+
12+
// Owns the chat STATE (useChat) and renders the panel. Lives only in the lazy chunk, so useChat +
13+
// the adapter + the Markdown renderer never touch the initial bundle (Blueprint Phase 2.1 FCP).
14+
export function ChatMount({ contextInput, onClose, registerReset }: Readonly<Props>) {
15+
const chat = useChat(contextInput);
16+
17+
useEffect(() => {
18+
registerReset(chat.reset);
19+
return () => registerReset(null);
20+
}, [chat.reset, registerReset]);
21+
22+
return (
23+
<ChatPanel
24+
messages={chat.messages}
25+
streaming={chat.streaming}
26+
onSend={chat.send}
27+
onStop={chat.stop}
28+
onRegenerate={chat.regenerate}
29+
onReset={chat.reset}
30+
onClose={onClose}
31+
/>
32+
);
33+
}
34+
35+
export default ChatMount;

0 commit comments

Comments
 (0)