homelabby/.planning/phases/06-lab-advisor/06-03-PLAN.md

256 lines
12 KiB
Markdown

---
phase: 06-lab-advisor
plan: "03"
type: execute
wave: 3
depends_on: [06-02]
files_modified:
- web/src/pages/AdvisorPage.tsx
- web/src/api/advisor.ts
- web/src/router.tsx
- web/src/components/layout/TopBar.tsx
autonomous: true
requirements: [ADV-01, ADV-03, ADV-04]
must_haves:
truths:
- "User can navigate to /advisor from the TopBar"
- "Left sidebar lists all past conversations ordered newest-first"
- "User can start a new conversation or select a prior one from the sidebar"
- "User types a message, presses Send, and sees streaming tokens appear in real time"
- "Conversation history (prior messages) is displayed above the input"
- "Model dropdown shows current model; changing it takes effect on the next message"
- "conversation_id is tracked across messages so all turns land in the same thread"
artifacts:
- path: "web/src/api/advisor.ts"
provides: "Typed API wrappers: streamChat (SSE EventSource), fetchConversations, fetchConversation"
exports: [streamChat, fetchConversations, fetchConversation, Conversation, ConversationSummary, ChatMessage]
- path: "web/src/pages/AdvisorPage.tsx"
provides: "AdvisorPage component: two-panel layout (sidebar + chat)"
- path: "web/src/router.tsx"
provides: "/advisor route added"
- path: "web/src/components/layout/TopBar.tsx"
provides: "Advisor nav link added to header"
key_links:
- from: "AdvisorPage.tsx — Send button"
to: "POST /api/advisor/chat via EventSource/fetch SSE"
via: "streamChat() in api/advisor.ts"
pattern: "EventSource|fetch.*text/event-stream"
- from: "AdvisorPage.tsx — sidebar"
to: "GET /api/advisor/conversations"
via: "useQuery + fetchConversations"
pattern: "fetchConversations"
- from: "AdvisorPage.tsx — conversation selection"
to: "GET /api/advisor/conversations/:id"
via: "useQuery + fetchConversation keyed by selected ID"
pattern: "fetchConversation"
---
<objective>
Build the AdvisorPage React component at /advisor: two-panel chat UI with a conversation
sidebar, streaming message display, and model selection dropdown.
Purpose: ADV-01 (streaming chat UI), ADV-03 (history viewable in UI), ADV-04 (model
dropdown, conversation list).
Output: AdvisorPage.tsx, api/advisor.ts, router + TopBar updates.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/06-lab-advisor/06-02-SUMMARY.md
# Design system: ClickHouse-inspired dark theme (see ~/.claude/DESIGN.md)
# Canvas: #000000, accent/volt: #faff69, charcoal border: #222, muted text: #888
# Tailwind tokens already in project: bg-canvas, text-volt, border-charcoal
# All existing pages use: AppShell wrapping, lucide-react icons, shadcn/ui Button/Input
# Router: TanStack Router v1 — lazy import pattern from router.tsx (see below)
# API pattern: useQuery from @tanstack/react-query for GET, fetch/EventSource for SSE
# Backend endpoints (from Plan 02):
# POST /api/advisor/chat
# Body: { conversation_id?: string, message: string, model?: string }
# Response: SSE stream, each event: data: {"conversation_id":"...","token":"..."}\n\n
# Final event: data: [DONE]\n\n
# GET /api/advisor/conversations
# Response: [{id, started_at, model, message_count}]
# GET /api/advisor/conversations/:id
# Response: {id, started_at, model, messages: [{id, role, content, created_at}]}
# Router lazy import pattern (match existing pages):
# const AdvisorPage = lazy(() => import('./pages/AdvisorPage').then((m) => ({ default: m.AdvisorPage })))
# createRoute({ path: '/advisor', component: () => <Suspense fallback={<Spinner />}><AdvisorPage /></Suspense> })
# TopBar pattern: Button variant="outline" size="sm" asChild wrapping Link to="/advisor"
# Icon to use: MessageSquare from lucide-react
# SSE streaming: use fetch() with ReadableStream (not EventSource — POST body needed)
# Pattern: const res = await fetch('/api/advisor/chat', {method:'POST', body:JSON.stringify(...)})
# const reader = res.body!.getReader(); const decoder = new TextDecoder()
# loop: read chunk, decode, split on \n\n, parse data: lines
# Available OpenRouter models for dropdown (hardcoded list, no API call needed):
# ["anthropic/claude-opus-4", "anthropic/claude-sonnet-4-5", "anthropic/claude-3-5-haiku", "openai/gpt-4o"]
# Default: "anthropic/claude-opus-4"
</context>
<tasks>
<task type="auto">
<name>Task 1: API wrappers for advisor endpoints</name>
<files>web/src/api/advisor.ts</files>
<action>
Create web/src/api/advisor.ts with typed wrappers.
Types:
```typescript
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export interface ConversationSummary {
id: string
started_at: string
model: string
message_count: number
}
export interface Conversation {
id: string
started_at: string
model: string
messages: ChatMessage[]
}
```
fetchConversations(): Promise<ConversationSummary[]>
— GET /api/advisor/conversations; returns [] on 404
fetchConversation(id: string): Promise<Conversation>
— GET /api/advisor/conversations/${id}
streamChat(params: { conversationId?: string; message: string; model: string },
onToken: (token: string, conversationId: string) => void,
onDone: () => void,
onError: (err: string) => void): Promise<void>
— POST /api/advisor/chat with Content-Type: application/json
— Read response.body via getReader() + TextDecoder
— Split chunks by '\n\n'; each 'data: ' prefixed line is an event
— If data === '[DONE]' call onDone() and return
— Else parse JSON { token, conversation_id }; call onToken(token, conversation_id)
— On JSON parse error or fetch error call onError(message)
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | grep advisor</automated>
</verify>
<done>web/src/api/advisor.ts compiles with no TypeScript errors; all three exported functions present</done>
</task>
<task type="auto">
<name>Task 2: AdvisorPage component, route, and TopBar link</name>
<files>web/src/pages/AdvisorPage.tsx, web/src/router.tsx, web/src/components/layout/TopBar.tsx</files>
<action>
Create web/src/pages/AdvisorPage.tsx.
Layout: AppShell > two-panel flex (lg: sidebar 280px fixed + main flex-1; mobile: main only, sidebar hidden):
- Left sidebar (bg-[#0a0a0a] border-r border-charcoal/60):
- "Lab Advisor" heading (text-volt font-bold)
- "New Chat" button (full width, variant outline)
- Conversation list from useQuery(['conversations'], fetchConversations, { refetchInterval: 5000 })
Each item: truncated first message time + model name; selected item highlighted with bg-[#1a1a1a]
Click: set selectedConversationId, load messages via useQuery(['conversation', id], ...)
- Main chat panel (flex-col):
- Messages area (flex-1 overflow-y-auto, scroll to bottom on new message via useEffect + ref):
- Empty state: centered text "Ask anything about your homelab"
- User messages: right-aligned, bg-[#1a1a1a] rounded-lg p-3 max-w-[80%]
- Assistant messages: left-aligned, bg-[#111] rounded-lg p-3 max-w-[80%], text-[#e0e0e0]
- Streaming assistant message shown inline as tokens arrive (append to streamingContent state)
- Input row (border-t border-charcoal/60 p-4 flex gap-2):
- Model select (shadcn/ui Select or native <select> styled): 4 options (see context)
- Textarea (rows=2, resize-none, flex-1): onKeyDown Enter (no shift) triggers send
- Send button (variant forest, disabled when streaming or empty input): Send icon
State management (local useState):
- messages: ChatMessage[] — loaded from selected conversation or built from streaming turns
- streamingContent: string — accumulates tokens during active stream
- isStreaming: boolean — disables input during stream
- currentConversationId: string | undefined — tracks active thread
- selectedModel: string — default "anthropic/claude-opus-4"
- input: string — textarea value
handleSend():
1. If input empty or isStreaming, return
2. Append user message to messages immediately (optimistic)
3. Clear input, set isStreaming=true, streamingContent=""
4. Call streamChat({conversationId: currentConversationId, message: input, model: selectedModel},
onToken: (token, convId) => { setStreamingContent(p => p + token); setCurrentConversationId(convId) },
onDone: () => { setMessages(p => [...p, {role:'assistant', content: streamingContent, ...}]); setStreamingContent(''); setIsStreaming(false) },
onError: (err) => { toast.error(err); setIsStreaming(false) })
New Chat button: reset messages=[], currentConversationId=undefined, streamingContent="", input=""
Update web/src/router.tsx:
- Add lazy import for AdvisorPage (same pattern as other pages)
- Add advisorRoute: createRoute({ path: '/advisor', component: ... })
- Add advisorRoute to routeTree
Update web/src/components/layout/TopBar.tsx:
- Add MessageSquare import from lucide-react
- Add Button variant="outline" size="sm" asChild wrapping Link to="/advisor" with MessageSquare icon
- Place it between the Test button and Scan button
</action>
<verify>
<automated>cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>npx tsc --noEmit passes with no errors; /advisor route registered in router; TopBar shows Advisor link; AdvisorPage renders without runtime errors</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| User input → POST /api/advisor/chat | Free-text message; sanitized by backend before OpenRouter |
| SSE token stream → DOM | Tokens rendered as text content, not innerHTML |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-03-01 | Injection (XSS) | AdvisorPage — assistant message rendering | mitigate | Render message content as React text node (JSX children), never dangerouslySetInnerHTML; streaming tokens appended to string state, displayed as text |
| T-06-03-02 | Information Disclosure | advisor.ts — fetch error details | accept | Error messages from backend shown in toast; backend returns generic messages, not stack traces; single-operator tool |
| T-06-03-03 | Spoofing | model dropdown — arbitrary model string | accept | Value sent to backend which passes to OpenRouter; single-operator tool controls their own spend; no impact beyond the operator |
</threat_model>
<verification>
- `cd web && npx tsc --noEmit` exits 0
- Visit http://localhost:8080/advisor — page renders with sidebar + chat panel
- TopBar shows Advisor link (MessageSquare icon)
- "New Chat" → type message → Send → streaming tokens appear token by token
- After stream completes, message appears in history
- Reload page, select prior conversation from sidebar → messages load
- Change model in dropdown → next message uses selected model (verify in psql: SELECT model FROM conversations ORDER BY started_at DESC LIMIT 1)
</verification>
<success_criteria>
- TypeScript compiles cleanly
- /advisor route accessible from TopBar
- Streaming works: tokens appear progressively, not all at once
- Conversation persists: prior conversations visible in sidebar on page reload
- Model dropdown changes the model used for next message
- No XSS vector: assistant content rendered as text, not HTML
</success_criteria>
<output>
After completion, create .planning/phases/06-lab-advisor/06-03-SUMMARY.md
</output>