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

12 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
06-lab-advisor 03 execute 3
06-02
web/src/pages/AdvisorPage.tsx
web/src/api/advisor.ts
web/src/router.tsx
web/src/components/layout/TopBar.tsx
true
ADV-01
ADV-03
ADV-04
truths artifacts key_links
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
path provides exports
web/src/api/advisor.ts Typed API wrappers: streamChat (SSE EventSource), fetchConversations, fetchConversation
streamChat
fetchConversations
fetchConversation
Conversation
ConversationSummary
ChatMessage
path provides
web/src/pages/AdvisorPage.tsx AdvisorPage component: two-panel layout (sidebar + chat)
path provides
web/src/router.tsx /advisor route added
path provides
web/src/components/layout/TopBar.tsx Advisor nav link added to header
from to via pattern
AdvisorPage.tsx — Send button POST /api/advisor/chat via EventSource/fetch SSE streamChat() in api/advisor.ts EventSource|fetch.*text/event-stream
from to via pattern
AdvisorPage.tsx — sidebar GET /api/advisor/conversations useQuery + fetchConversations fetchConversations
from to via pattern
AdvisorPage.tsx — conversation selection GET /api/advisor/conversations/:id useQuery + fetchConversation keyed by selected ID fetchConversation
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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={}> })

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"

Task 1: API wrappers for advisor endpoints web/src/api/advisor.ts Create web/src/api/advisor.ts with typed wrappers.

Types:

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 — 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 — 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) cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | grep advisor web/src/api/advisor.ts compiles with no TypeScript errors; all three exported functions present

Task 2: AdvisorPage component, route, and TopBar link web/src/pages/AdvisorPage.tsx, web/src/router.tsx, web/src/components/layout/TopBar.tsx 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 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(): If input empty or isStreaming, return Append user message to messages immediately (optimistic) Clear input, set isStreaming=true, streamingContent="" 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 cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | head -20 npx tsc --noEmit passes with no errors; /advisor route registered in router; TopBar shows Advisor link; AdvisorPage renders without runtime errors <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> - `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) <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> After completion, create .planning/phases/06-lab-advisor/06-03-SUMMARY.md