- internal/advisor/handler.go: StreamChat (SSE, token-by-token),
GetConversations, GetConversation; body limited to 64KB, message
truncated to 8000 chars (T-06-02-03); API key never echoed (T-06-02-02)
- internal/api/router.go: /api/advisor/{chat,conversations,conversations/{id}}
with nil-guard returning 503 when DB not configured
- internal/config/config.go: Tier3 defaults + HWLAB_AI_TIER3_* env bindings
- cmd/hwlab/main.go: store init from HWLAB_DATABASE_URL, RunMigrations,
InventoryContextBuilder, AdvisorHandler wired into NewRouter
87 lines
3 KiB
Go
87 lines
3 KiB
Go
package api
|
|
|
|
import (
|
|
"io/fs"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"git.georgsen.dk/hwlab/internal/advisor"
|
|
"git.georgsen.dk/hwlab/internal/api/handlers"
|
|
)
|
|
|
|
// spaHandler serves static files and falls back to index.html for unknown paths,
|
|
// enabling client-side routing in the React SPA.
|
|
type spaHandler struct {
|
|
staticFS fs.FS
|
|
}
|
|
|
|
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Try to open the requested path in the embedded FS.
|
|
f, err := h.staticFS.Open(r.URL.Path)
|
|
if err != nil {
|
|
// File not found — serve index.html so the SPA router handles it.
|
|
r2 := r.Clone(r.Context())
|
|
r2.URL.Path = "/"
|
|
http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r2)
|
|
return
|
|
}
|
|
f.Close()
|
|
http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r)
|
|
}
|
|
|
|
// NewRouter creates the chi router. staticFiles is the fs.FS rooted at web/dist,
|
|
// passed from main.go where the go:embed directive lives.
|
|
// intakeHandler handles POST /api/intake (multipart photo upload).
|
|
// inventoryHandler handles GET /api/inventory and GET /api/inventory/{id}.
|
|
// labelHandler handles POST /api/labels/:deviceID/print.
|
|
// usbEventsHandler handles GET /api/usb/events (SSE stream).
|
|
// testHandler handles POST /api/test/cable, GET /api/test/events, GET /api/test/recent.
|
|
// advisorHandler handles POST /api/advisor/chat, GET /api/advisor/conversations, GET /api/advisor/conversations/{id}.
|
|
func NewRouter(
|
|
staticFiles fs.FS,
|
|
intakeHandler http.Handler,
|
|
inventoryHandler *handlers.InventoryHandler,
|
|
labelHandler *handlers.LabelHandler,
|
|
usbEventsHandler *handlers.USBEventsHandler,
|
|
testHandler *handlers.TestHandler,
|
|
advisorHandler *advisor.AdvisorHandler,
|
|
) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.RealIP)
|
|
|
|
r.Route("/api", func(r chi.Router) {
|
|
r.Get("/health", handlers.Health)
|
|
r.Post("/intake", intakeHandler.ServeHTTP)
|
|
r.Get("/inventory", inventoryHandler.ListInventory)
|
|
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
|
|
r.Post("/labels/{deviceID}/print", labelHandler.PrintLabel)
|
|
r.Get("/usb/events", usbEventsHandler.ServeEvents)
|
|
r.Post("/test/cable", testHandler.SubmitCableTest)
|
|
r.Get("/test/events", testHandler.StreamEvents)
|
|
r.Get("/test/recent", testHandler.RecentTests)
|
|
|
|
r.Route("/advisor", func(r chi.Router) {
|
|
if advisorHandler != nil {
|
|
r.Post("/chat", advisorHandler.StreamChat)
|
|
r.Get("/conversations", advisorHandler.GetConversations)
|
|
r.Get("/conversations/{id}", advisorHandler.GetConversation)
|
|
} else {
|
|
unavailable := func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "advisor unavailable: database not configured", http.StatusServiceUnavailable)
|
|
}
|
|
r.Post("/chat", unavailable)
|
|
r.Get("/conversations", unavailable)
|
|
r.Get("/conversations/{id}", unavailable)
|
|
}
|
|
})
|
|
})
|
|
|
|
// SPA fallback — serve static files; unknown paths fall back to index.html.
|
|
r.Handle("/*", spaHandler{staticFS: staticFiles})
|
|
|
|
return r
|
|
}
|