docs(06): create phase 6 lab advisor plans (3 plans, 3 waves)

This commit is contained in:
Mikkel Georgsen 2026-04-10 07:28:20 +00:00
parent ef20f8e83f
commit 7a304c8cc4
4 changed files with 704 additions and 3 deletions

View file

@ -122,8 +122,12 @@ Plans:
2. Each conversation automatically includes a pre-loaded NetBox inventory context summary
3. Conversation history persists across browser sessions and is viewable in the chat UI
4. Model can be switched from Opus to any OpenRouter-compatible model via the dropdown without restarting the server
**Plans**: TBD
**UI hint**: yes
**Plans**: 3 plans
Plans:
- [ ] 06-01-PLAN.md — PostgreSQL store: pgx/v5 dep, conversations + messages tables, RunMigrations, typed CRUD methods
- [ ] 06-02-PLAN.md — Advisor backend: InventoryContextBuilder (60s cache), AdvisorHandler SSE streaming, router wiring, main.go wiring
- [ ] 06-03-PLAN.md — Frontend: AdvisorPage at /advisor, conversation sidebar, streaming chat UI, model dropdown, TopBar link
### Phase 7: Research Agent & Search
**Goal**: Items flagged needs_research are automatically enriched by a SearXNG research agent, and any inventory question can be answered via natural language search
@ -149,5 +153,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
| 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 |
| 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 |
| 5. Cable Test Integration | 3/3 | Complete | 2026-04-10 |
| 6. Lab Advisor | 0/TBD | Not started | - |
| 6. Lab Advisor | 0/3 | Not started | - |
| 7. Research Agent & Search | 0/TBD | Not started | - |

View file

@ -0,0 +1,195 @@
---
phase: 06-lab-advisor
plan: "01"
type: execute
wave: 1
depends_on: []
files_modified:
- internal/store/store.go
- internal/store/migrations.go
- internal/store/conversations.go
- internal/store/store_test.go
- go.mod
- go.sum
autonomous: true
requirements: [ADV-03]
must_haves:
truths:
- "PostgreSQL connection is established and verified at startup"
- "conversations table exists with id, started_at, model columns"
- "messages table exists with id, conversation_id, role, content, created_at columns"
- "Store can create a conversation, append messages, and retrieve full thread"
- "Store can list all conversations ordered by started_at DESC"
artifacts:
- path: "internal/store/store.go"
provides: "Store struct, NewStore constructor, DB connection via pgx/v5"
exports: [Store, NewStore]
- path: "internal/store/migrations.go"
provides: "RunMigrations — idempotent CREATE TABLE IF NOT EXISTS for conversations + messages"
exports: [RunMigrations]
- path: "internal/store/conversations.go"
provides: "CreateConversation, AddMessage, GetConversation, ListConversations"
exports: [CreateConversation, AddMessage, GetConversation, ListConversations]
- path: "internal/store/store_test.go"
provides: "Integration tests against real PostgreSQL"
key_links:
- from: "internal/store/conversations.go"
to: "PostgreSQL homelabby DB"
via: "pgx/v5 pgxpool"
pattern: "pgxpool.New"
- from: "internal/store/migrations.go"
to: "conversations, messages tables"
via: "CREATE TABLE IF NOT EXISTS"
pattern: "CREATE TABLE IF NOT EXISTS conversations"
---
<objective>
Create the internal/store package that manages chat persistence in PostgreSQL.
RunMigrations creates conversations and messages tables idempotently; the Store
struct exposes typed methods for the advisor handler to use.
Purpose: ADV-03 requires conversation history to persist across browser sessions.
SQLite is already used for other local state but PostgreSQL is the designated store
here (already running at 10.5.0.109, connection string in HWLAB_DATABASE_URL env).
Output: internal/store package with pgx/v5, auto-migrating tables, typed CRUD methods,
integration tests.
</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/ROADMAP.md
@.planning/STATE.md
# Module path: git.georgsen.dk/hwlab
# PostgreSQL DSN is in env var HWLAB_DATABASE_URL (set in .env):
# postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby
# pgx/v5 driver: github.com/jackc/pgx/v5
# Pattern: use pgxpool for the connection pool (pgxpool.New), not single conn
# Pattern: all other packages use database/sql + sqlx — advisor uses pgx directly
# (pgx/v5 is preferred for new code, avoids cgo unlike mattn/go-sqlite3)
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add pgx/v5 dep and create Store with RunMigrations</name>
<files>internal/store/store.go, internal/store/migrations.go, go.mod, go.sum</files>
<behavior>
- NewStore("invalid-dsn") returns non-nil error
- NewStore(validDSN) returns *Store with open pool, no error
- Store.Close() does not panic
- RunMigrations(ctx, pool) on empty DB creates conversations and messages tables
- RunMigrations(ctx, pool) called twice is idempotent (no error, no duplicate tables)
- conversations schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), started_at TIMESTAMPTZ NOT NULL DEFAULT now(), model TEXT NOT NULL DEFAULT ''
- messages schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user','assistant','system')), content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
</behavior>
<action>
Run `go get github.com/jackc/pgx/v5` to add the dependency.
Create internal/store/store.go:
- Package store
- Import pgxpool "github.com/jackc/pgx/v5/pgxpool"
- Store struct holds *pgxpool.Pool
- NewStore(ctx context.Context, dsn string) (*Store, error) — calls pgxpool.New(ctx, dsn), pings pool, returns error on failure
- Close() — calls pool.Close()
- Expose Pool() *pgxpool.Pool accessor for use by repository methods
Create internal/store/migrations.go:
- RunMigrations(ctx context.Context, pool *pgxpool.Pool) error
- Execute the two CREATE TABLE IF NOT EXISTS statements in sequence using pool.Exec
- conversations first (messages references it)
- Return first non-nil error encountered
Write tests first (RED), confirm they fail, then implement (GREEN).
</action>
<verify>
<automated>cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -run TestNewStore -v</automated>
</verify>
<done>TestNewStore passes; go build ./... succeeds; pgx/v5 present in go.mod</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Conversation and message CRUD methods</name>
<files>internal/store/conversations.go, internal/store/store_test.go</files>
<behavior>
- CreateConversation(ctx, model string) returns (conversationID string, err error)
- AddMessage(ctx, conversationID, role, content string) returns (messageID string, err error)
- AddMessage with invalid role returns error (CHECK constraint violation from DB)
- GetConversation(ctx, conversationID string) returns (*Conversation, error) where Conversation has ID, StartedAt, Model, Messages []Message
- GetConversation on unknown ID returns nil, ErrNotFound (sentinel)
- ListConversations(ctx) returns []ConversationSummary ordered by started_at DESC (no messages, just id + started_at + model + message_count)
- Full round-trip: create conv → add 2 messages → GetConversation → Messages has 2 items in order
</behavior>
<action>
Create internal/store/conversations.go:
Types:
- Message struct { ID, ConversationID, Role, Content string; CreatedAt time.Time }
- Conversation struct { ID, Model string; StartedAt time.Time; Messages []Message }
- ConversationSummary struct { ID, Model string; StartedAt time.Time; MessageCount int }
- var ErrNotFound = errors.New("store: not found")
Methods on *Store:
- CreateConversation(ctx, model) — INSERT INTO conversations(model) VALUES($1) RETURNING id
- AddMessage(ctx, conversationID, role, content) — INSERT INTO messages(conversation_id, role, content) VALUES($1,$2,$3) RETURNING id
- GetConversation(ctx, id) — SELECT from conversations JOIN messages ORDER BY messages.created_at ASC; if 0 rows return nil, ErrNotFound
- ListConversations(ctx) — SELECT c.id, c.started_at, c.model, COUNT(m.id) AS message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id GROUP BY c.id ORDER BY c.started_at DESC
Write tests in internal/store/store_test.go using build tag `//go:build integration`
so they run only with -tags integration. Tests connect to the real PostgreSQL.
Each test creates its own conversation and cleans up (DELETE FROM conversations WHERE id = $1).
Write tests first (RED), confirm compile error or test failure, then implement (GREEN).
</action>
<verify>
<automated>cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -tags integration -v</automated>
</verify>
<done>All store integration tests pass; GetConversation returns ErrNotFound for unknown ID; ListConversations returns summaries with message counts; go build ./... passes</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| App → PostgreSQL | DSN from env; pool executes parameterized queries |
| Messages table | role column has CHECK constraint; content is free text |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01-01 | Injection | conversations.go — all queries | mitigate | All queries use $1/$2 parameterized args via pgx; no string interpolation in SQL |
| T-06-01-02 | Information Disclosure | store.go — DSN in logs | mitigate | NewStore logs only connection error text, not the DSN; DSN sourced from env, not config file |
| T-06-01-03 | Tampering | messages.role column | mitigate | DB-level CHECK (role IN ('user','assistant','system')) — invalid role returns pgx constraint error |
| T-06-01-04 | Denial of Service | ListConversations with unbounded growth | accept | Single-operator homelab tool; conversation count bounded in practice; add LIMIT 100 as soft guard |
</threat_model>
<verification>
- `go build ./...` passes
- `go test ./internal/store/...` passes (non-integration tests)
- `go test ./internal/store/... -tags integration` passes against live PostgreSQL
- `psql $HWLAB_DATABASE_URL -c "\d conversations"` shows the schema
- `psql $HWLAB_DATABASE_URL -c "\d messages"` shows the schema with FK to conversations
</verification>
<success_criteria>
- pgx/v5 added to go.mod
- internal/store package compiles with no errors
- RunMigrations creates both tables idempotently
- Full CRUD round-trip (create conv, add messages, get conv, list convs) passes integration tests
- ErrNotFound returned for unknown conversation ID
</success_criteria>
<output>
After completion, create .planning/phases/06-lab-advisor/06-01-SUMMARY.md
</output>

View file

@ -0,0 +1,246 @@
---
phase: 06-lab-advisor
plan: "02"
type: execute
wave: 2
depends_on: [06-01]
files_modified:
- internal/advisor/handler.go
- internal/advisor/context.go
- internal/api/handlers/advisor.go
- internal/api/router.go
- cmd/hwlab/main.go
autonomous: true
requirements: [ADV-01, ADV-02, ADV-03, ADV-05]
must_haves:
truths:
- "POST /api/advisor/chat streams tokens from Claude Opus via SSE"
- "Each chat request includes a pre-assembled NetBox inventory summary in the system prompt"
- "Inventory context is cached 60s to avoid hammering NetBox on every message"
- "Every message and conversation is persisted in PostgreSQL via the store package"
- "GET /api/advisor/conversations returns list of past conversations"
- "GET /api/advisor/conversations/:id returns full message thread"
- "Model is configurable per-request via model field; defaults to anthropic/claude-opus-4"
- "Model switch takes effect on the next request without restarting the server"
artifacts:
- path: "internal/advisor/context.go"
provides: "InventoryContextBuilder — assembles compact NetBox summary with 60s cache"
exports: [InventoryContextBuilder, NewInventoryContextBuilder, BuildContext]
- path: "internal/advisor/handler.go"
provides: "AdvisorHandler — POST /chat streaming SSE, GET /conversations, GET /conversations/:id"
exports: [AdvisorHandler, NewAdvisorHandler]
- path: "internal/api/handlers/advisor.go"
provides: "Thin glue wiring AdvisorHandler to chi routes (or inline in router.go)"
- path: "internal/api/router.go"
provides: "Routes for /api/advisor/chat, /api/advisor/conversations, /api/advisor/conversations/:id"
key_links:
- from: "internal/advisor/handler.go"
to: "go-openai CreateChatCompletionStream"
via: "TierClient extended with StreamChat method or direct openai.Client use"
pattern: "CreateChatCompletionStream"
- from: "internal/advisor/context.go"
to: "internal/netbox.Client.ListDevices"
via: "sync.Mutex + time.Time expiry for 60s cache"
pattern: "ListDevices"
- from: "internal/advisor/handler.go"
to: "internal/store.Store"
via: "CreateConversation + AddMessage on each turn"
pattern: "store.CreateConversation"
---
<objective>
Build the advisor backend: context assembly with NetBox inventory, SSE streaming chat
endpoint backed by Claude Opus via OpenRouter, and PostgreSQL persistence of every
conversation and message.
Purpose: ADV-01 (streaming chat), ADV-02 (inventory context), ADV-03 (persistence),
ADV-05 (model switch without restart).
Output: internal/advisor package + router wiring exposing three endpoints.
</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/ROADMAP.md
@.planning/phases/06-lab-advisor/06-01-SUMMARY.md
# Key interfaces from prior plans (no exploration needed):
# internal/ai/client.go — AIClient + TierClient
# TierClient has: client *openai.Client, model string, timeout time.Duration
# NewTierClient(cfg TierConfig) *TierClient
# TierConfig: BaseURL, APIKey, Model, TimeoutSeconds
# go-openai streaming: client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{Stream: true, ...})
# returns (openai.ChatCompletionStream, error) — call .Recv() in loop until io.EOF
# internal/netbox/client.go
# ListDevices(ctx, limit int) ([]Device, error)
# Device struct has: ID int, Name string, AssetTag string, CustomFields map
# internal/store (from Plan 01):
# NewStore(ctx, dsn) (*Store, error)
# Store.CreateConversation(ctx, model) (string, error)
# Store.AddMessage(ctx, conversationID, role, content) (string, error)
# Store.GetConversation(ctx, id) (*Conversation, error) — returns ErrNotFound if missing
# Store.ListConversations(ctx) ([]ConversationSummary, error)
# ai_config.json tier3 config (OpenRouter):
# BaseURL: https://openrouter.ai/api/v1
# Default model: anthropic/claude-opus-4
# APIKey: from ai_config.json (loaded by config package)
# SSE pattern (from Phase 4/5 USBEventsHandler):
# w.Header().Set("Content-Type", "text/event-stream")
# w.Header().Set("Cache-Control", "no-cache")
# w.Header().Set("Connection", "keep-alive")
# fmt.Fprintf(w, "data: %s\n\n", token)
# flusher.Flush()
# Module path: git.georgsen.dk/hwlab
# Router uses chi, parameter extraction: chi.URLParam(r, "id")
</context>
<tasks>
<task type="auto">
<name>Task 1: InventoryContextBuilder with 60s cache</name>
<files>internal/advisor/context.go</files>
<action>
Create internal/advisor/context.go.
InventoryContextBuilder struct:
- nb *netbox.Client
- mu sync.Mutex
- cached string
- cachedAt time.Time
- ttl time.Duration (default 60s)
NewInventoryContextBuilder(nb *netbox.Client) *InventoryContextBuilder
BuildContext(ctx context.Context) (string, error):
- Under mutex: if time.Since(cachedAt) < ttl, return cached
- Call nb.ListDevices(ctx, 200)
- Build compact text summary:
- First line: "Inventory: N items total"
- Count by category (use CustomFields["category"] if present)
- List recent 20 items: "- HW-ID name (category)" from device.AssetTag + device.Name + CustomFields["category"]
- Aim for < 2000 chars; truncate item list if needed
- Store in cached + set cachedAt = time.Now()
- Return the summary string
The system prompt prefix for each chat: "You are a homelab advisor. Here is the current inventory:\n\n" + context
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./internal/advisor/...</automated>
</verify>
<done>internal/advisor/context.go compiles; BuildContext returns a non-empty string when given a mock netbox client returning sample devices</done>
</task>
<task type="auto">
<name>Task 2: AdvisorHandler (SSE streaming + persistence) and router wiring</name>
<files>internal/advisor/handler.go, internal/api/router.go, cmd/hwlab/main.go</files>
<action>
Create internal/advisor/handler.go.
ChatRequest struct (JSON):
- ConversationID string `json:"conversation_id"` — empty = new conversation
- Message string `json:"message"`
- Model string `json:"model"` — empty = "anthropic/claude-opus-4"
AdvisorHandler struct:
- store *store.Store
- ctx *InventoryContextBuilder
- aiCfg ai.AIConfig (for Tier3 config: BaseURL + APIKey)
NewAdvisorHandler(s *store.Store, ctxBuilder *InventoryContextBuilder, aiCfg ai.AIConfig) *AdvisorHandler
StreamChat(w http.ResponseWriter, r *http.Request):
1. Decode ChatRequest from body
2. If ConversationID empty: call store.CreateConversation(r.Context(), model) to get new ID
3. Call store.AddMessage(r.Context(), convID, "user", req.Message) to persist user turn
4. Call ctxBuilder.BuildContext to get inventory summary
5. Build []openai.ChatCompletionMessage: system (inventory summary), user (req.Message)
6. Build openai.ClientConfig from aiCfg.Tier3 (BaseURL, APIKey) but override Model to req.Model
— use openai.DefaultConfig(apiKey); cfg.BaseURL = baseURL; openai.NewClientWithConfig(cfg)
7. Call client.CreateChatCompletionStream(ctx, req{Model: model, Stream: true, Messages: msgs})
8. Set SSE headers; write "data: {"conversation_id":"...","token":"..."}\n\n" for each Recv() token
9. Flush after each write (cast w to http.Flusher)
10. On io.EOF: write final "data: [DONE]\n\n"; collect full response text from accumulated tokens
11. Call store.AddMessage(r.Context(), convID, "assistant", fullContent) to persist assistant turn
12. Error during stream: write "data: {"error":"..."}\n\n" and return
GetConversations(w http.ResponseWriter, r *http.Request):
- Call store.ListConversations(r.Context()); JSON encode list; 200
GetConversation(w http.ResponseWriter, r *http.Request):
- id := chi.URLParam(r, "id")
- Call store.GetConversation; if errors.Is(err, store.ErrNotFound) → 404; else JSON 200
Update internal/api/router.go:
- Add *advisor.AdvisorHandler parameter to NewRouter signature
- Add routes under r.Route("/api/advisor", ...):
- POST /chat → advisorHandler.StreamChat
- GET /conversations → advisorHandler.GetConversations
- GET /conversations/{id} → advisorHandler.GetConversation
Update cmd/hwlab/main.go:
- Read HWLAB_DATABASE_URL from env (already loaded via godotenv)
- Call store.NewStore(ctx, os.Getenv("HWLAB_DATABASE_URL"))
- Call store.RunMigrations(ctx, s.Pool())
- Create InventoryContextBuilder with netboxClient
- Create AdvisorHandler
- Pass to NewRouter
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./... && curl -s -N -X POST http://localhost:8080/api/advisor/chat -H "Content-Type: application/json" -d '{"message":"hello"}' | head -5</automated>
</verify>
<done>go build ./... passes; POST /api/advisor/chat returns SSE stream with data: lines; GET /api/advisor/conversations returns JSON array; GET /api/advisor/conversations/:id returns 404 for unknown ID</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP client → POST /api/advisor/chat | Untrusted JSON body; message content forwarded to OpenRouter |
| AdvisorHandler → OpenRouter API | API key in memory from ai_config.json; never echoed to client |
| SSE stream → browser | Token data flows back; no user data echoed except conversation_id |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-02-01 | Injection | StreamChat — message forwarded to OpenRouter | mitigate | Message is passed as user role content — OpenRouter's model, not our SQL. No SQL built from message content. |
| T-06-02-02 | Information Disclosure | StreamChat — OpenRouter APIKey | mitigate | Key read from ai_config.json / ai_config.local.json; never logged, never written to SSE stream |
| T-06-02-03 | Denial of Service | StreamChat — unbounded message length | mitigate | Truncate req.Message to 8000 chars before sending to OpenRouter; return 400 if body > 64KB |
| T-06-02-04 | Information Disclosure | SSE stream — conversation_id exposed | accept | Single-operator homelab; no multi-user auth; all conversations are the operator's own |
| T-06-02-05 | Spoofing | Model field in ChatRequest — caller can specify arbitrary model | accept | Single-operator tool; operator controls their OpenRouter account/spend; no model allowlist needed for homelab |
</threat_model>
<verification>
- `go build ./...` passes
- POST /api/advisor/chat with `{"message":"ping"}` returns SSE with at least one `data:` line
- GET /api/advisor/conversations returns `[]` or list of prior conversations
- GET /api/advisor/conversations/nonexistent returns HTTP 404
- psql confirms a row in conversations and rows in messages after a chat request
</verification>
<success_criteria>
- Three /api/advisor/* endpoints registered and responding
- Streaming response delivers tokens as SSE `data:` events
- NetBox inventory context appears in the system prompt (verify via log or test)
- Conversation and messages rows created in PostgreSQL on each chat
- Model override works: pass "anthropic/claude-3-5-sonnet" and it uses that model without restart
</success_criteria>
<output>
After completion, create .planning/phases/06-lab-advisor/06-02-SUMMARY.md
</output>

View file

@ -0,0 +1,256 @@
---
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>