From 7a304c8cc458f4bf6c71f10bd54ce06ad6674173 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 07:28:20 +0000 Subject: [PATCH] docs(06): create phase 6 lab advisor plans (3 plans, 3 waves) --- .planning/ROADMAP.md | 10 +- .planning/phases/06-lab-advisor/06-01-PLAN.md | 195 +++++++++++++ .planning/phases/06-lab-advisor/06-02-PLAN.md | 246 +++++++++++++++++ .planning/phases/06-lab-advisor/06-03-PLAN.md | 256 ++++++++++++++++++ 4 files changed, 704 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/06-lab-advisor/06-01-PLAN.md create mode 100644 .planning/phases/06-lab-advisor/06-02-PLAN.md create mode 100644 .planning/phases/06-lab-advisor/06-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index a998625..6dcbe52 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/06-lab-advisor/06-01-PLAN.md b/.planning/phases/06-lab-advisor/06-01-PLAN.md new file mode 100644 index 0000000..1d3a743 --- /dev/null +++ b/.planning/phases/06-lab-advisor/06-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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) + + + + + + Task 1: Add pgx/v5 dep and create Store with RunMigrations + internal/store/store.go, internal/store/migrations.go, go.mod, go.sum + + - 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() + + +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). + + + 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 + + TestNewStore passes; go build ./... succeeds; pgx/v5 present in go.mod + + + + Task 2: Conversation and message CRUD methods + internal/store/conversations.go, internal/store/store_test.go + + - 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 + + +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). + + + 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 + + All store integration tests pass; GetConversation returns ErrNotFound for unknown ID; ListConversations returns summaries with message counts; go build ./... passes + + + + + +## 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 | + + + +- `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 + + + +- 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 + + + +After completion, create .planning/phases/06-lab-advisor/06-01-SUMMARY.md + diff --git a/.planning/phases/06-lab-advisor/06-02-PLAN.md b/.planning/phases/06-lab-advisor/06-02-PLAN.md new file mode 100644 index 0000000..0af9f8a --- /dev/null +++ b/.planning/phases/06-lab-advisor/06-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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") + + + + + + Task 1: InventoryContextBuilder with 60s cache + internal/advisor/context.go + +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 + + + cd /home/mikkel/homelabby && go build ./internal/advisor/... + + internal/advisor/context.go compiles; BuildContext returns a non-empty string when given a mock netbox client returning sample devices + + + + Task 2: AdvisorHandler (SSE streaming + persistence) and router wiring + internal/advisor/handler.go, internal/api/router.go, cmd/hwlab/main.go + +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 + + + 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 + + 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 + + + + + +## 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 | + + + +- `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 + + + +- 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 + + + +After completion, create .planning/phases/06-lab-advisor/06-02-SUMMARY.md + diff --git a/.planning/phases/06-lab-advisor/06-03-PLAN.md b/.planning/phases/06-lab-advisor/06-03-PLAN.md new file mode 100644 index 0000000..87a94ce --- /dev/null +++ b/.planning/phases/06-lab-advisor/06-03-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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: () => }> }) + +# 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: +```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 +— 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