12 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-lab-advisor | 03 | execute | 3 |
|
|
true |
|
|
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.mdDesign 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
- Messages area (flex-1 overflow-y-auto, scroll to bottom on new message via useEffect + ref):