package web import ( "context" "embed" "encoding/json" "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/local/glpi-neural-brain/internal/activity" "github.com/local/glpi-neural-brain/internal/engine" "github.com/local/glpi-neural-brain/internal/graph" "github.com/local/glpi-neural-brain/internal/model" ) //go:embed static/* var assets embed.FS type Server struct { Engine *engine.Engine Graph *graph.Store Broker *activity.Broker APIKey string } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/status", s.handleStatus) mux.HandleFunc("GET /api/graph", s.handleGraph) mux.HandleFunc("GET /api/analysis", s.handleAnalysis) mux.HandleFunc("GET /api/analysis/dashboard", s.handleAnalysisDashboard) mux.HandleFunc("GET /api/analysis/export", s.handleAnalysisExport) mux.HandleFunc("GET /api/runtime-settings", s.handleGetRuntimeSettings) mux.HandleFunc("PUT /api/runtime-settings", s.handleSetRuntimeSettings) mux.HandleFunc("GET /api/sources", s.handleSources) mux.HandleFunc("GET /api/research/status", s.handleResearchStatus) mux.HandleFunc("POST /api/research/test", s.handleResearchTest) mux.HandleFunc("GET /api/research/tasks", s.handleResearchTasks) mux.HandleFunc("POST /api/research/tasks", s.handleCreateResearchTask) mux.HandleFunc("POST /api/research/tasks/{id}/cancel", s.handleCancelResearchTask) mux.HandleFunc("POST /api/research/autonomous/scan", s.handleAutonomousResearchScan) mux.HandleFunc("POST /api/research/autonomous/run", s.handleAutonomousResearchRun) mux.HandleFunc("GET /api/stream", s.Broker.ServeSSE) mux.HandleFunc("POST /api/query", s.handleQuery) mux.HandleFunc("POST /api/events", s.handleEvent) mux.HandleFunc("POST /api/reindex", s.handleReindex) mux.HandleFunc("POST /api/enrich", s.handleEnrich) mux.HandleFunc("POST /api/glpi-kb/sync", s.handleGLPIKBSync) mux.HandleFunc("POST /api/flush", s.handleFlush) mux.HandleFunc("GET /api/state/export", s.handleStateExport) sub, _ := fs.Sub(assets, "static") mux.HandleFunc("GET /analysis", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/analysis.html", http.StatusTemporaryRedirect) }) mux.Handle("GET /", http.FileServer(http.FS(sub))) return s.headers(mux) } func (s *Server) headers(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") next.ServeHTTP(w, r) }) } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.Engine.Status()) } func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.Graph.Snapshot()) } func (s *Server) handleAnalysis(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.Graph.Analyze()) } func (s *Server) analysisDashboardPayload(r *http.Request) (map[string]any, error) { hours := 24 if raw := strings.TrimSpace(r.URL.Query().Get("hours")); raw != "" { if value, err := strconv.Atoi(raw); err == nil && value >= 1 && value <= 24*90 { hours = value } } limit := 250 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if value, err := strconv.Atoi(raw); err == nil && value >= 20 && value <= 1000 { limit = value } } ctx, cancel := contextTimeout(r, 60*time.Second) defer cancel() history, err := s.Graph.AnalysisHistory(ctx, time.Now().UTC().Add(-time.Duration(hours)*time.Hour), limit) if err != nil { return nil, err } return map[string]any{ "generated_at": history.GeneratedAt, "range_hours": hours, "graph": s.Graph.DetailedAnalysis(), "history": history, "system": s.Engine.Status(), }, nil } func (s *Server) handleAnalysisDashboard(w http.ResponseWriter, r *http.Request) { payload, err := s.analysisDashboardPayload(r) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, payload) } func (s *Server) handleAnalysisExport(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } payload, err := s.analysisDashboardPayload(r) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="brain-analysis.json"`) w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) encoder.SetIndent("", " ") _ = encoder.Encode(payload) } func (s *Server) handleGetRuntimeSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Engine.RuntimeSettingsView()) } func (s *Server) handleSetRuntimeSettings(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } _, err = s.Engine.ApplyRuntimeSettingsJSON(data) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, s.Engine.RuntimeSettingsView()) } func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"sources": s.Engine.Sources()}) } func (s *Server) handleResearchStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Engine.ResearchStatus()) } func (s *Server) handleResearchTest(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } var request struct { Query string `json:"query"` Limit int `json:"limit"` } if r.Body != nil { err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&request) if err != nil && err != io.EOF { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } } ctx, cancel := contextTimeout(r, 60*time.Second) defer cancel() result, err := s.Engine.TestResearch(ctx, request.Query, request.Limit) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": err.Error(), "query": result.Query, "diagnostic": result.Diagnostic, }) return } writeJSON(w, http.StatusOK, result) } func (s *Server) handleResearchTasks(w http.ResponseWriter, r *http.Request) { limit := 50 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if value, err := strconv.Atoi(raw); err == nil && value > 0 && value <= 500 { limit = value } } ctx, cancel := contextTimeout(r, 15*time.Second) defer cancel() tasks, err := s.Engine.ResearchTasks(ctx, limit) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "status": s.Engine.AutonomousResearchStatus(ctx)}) } func (s *Server) handleCreateResearchTask(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } var request model.ResearchTaskRequest if err := decode(r, &request); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } ctx, cancel := contextTimeout(r, 15*time.Second) defer cancel() task, created, err := s.Engine.QueueResearchTask(ctx, request) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } status := http.StatusAccepted if !created { status = http.StatusOK } writeJSON(w, status, map[string]any{"task": task, "created": created}) } func (s *Server) handleCancelResearchTask(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } ctx, cancel := contextTimeout(r, 15*time.Second) defer cancel() cancelled, err := s.Engine.CancelResearchTask(ctx, r.PathValue("id")) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !cancelled { writeJSON(w, http.StatusConflict, map[string]string{"error": "task is already running or completed"}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "cancelled": true}) } func (s *Server) handleAutonomousResearchScan(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } if !s.Engine.AutonomousResearchEnabled() { writeJSON(w, http.StatusConflict, map[string]string{"error": "autonomous research is disabled"}) return } if !s.Engine.ThinkingEnabled() || !s.Engine.ResearchEnabledForRuntime() { writeJSON(w, http.StatusConflict, map[string]string{"error": "thinking and SearXNG must be available"}) return } if !s.Engine.RequestAutonomousResearchScan("manual") { writeJSON(w, http.StatusConflict, map[string]string{"error": "autonomous research scan is already queued"}) return } writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "queued": true}) } func (s *Server) handleAutonomousResearchRun(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } if !s.Engine.AutonomousResearchEnabled() { writeJSON(w, http.StatusConflict, map[string]string{"error": "autonomous research is disabled"}) return } if !s.Engine.ThinkingEnabled() || !s.Engine.ResearchEnabledForRuntime() { writeJSON(w, http.StatusConflict, map[string]string{"error": "thinking and SearXNG must be available"}) return } s.Engine.WakeAutonomousResearch() writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "queued": true}) } func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, 401, map[string]string{"error": "unauthorized"}) return } var req model.QueryRequest if err := decode(r, &req); err != nil { writeJSON(w, 400, map[string]string{"error": err.Error()}) return } ctx, cancel := contextTimeout(r, 8*time.Minute) defer cancel() out, err := s.Engine.Query(ctx, req.Query) if err != nil { writeJSON(w, 400, map[string]string{"error": err.Error()}) return } writeJSON(w, 200, out) } func (s *Server) handleEvent(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, 401, map[string]string{"error": "unauthorized"}) return } var in model.ExternalEvent if err := decode(r, &in); err != nil { writeJSON(w, 400, map[string]string{"error": err.Error()}) return } var ids []string for _, h := range in.Hits { if n, ok := s.Graph.LookupExternal(h.ID); ok { ids = append(ids, n.ID) } } s.Broker.Publish(model.Activity{Type: nonempty(in.Type, "external.event"), Source: nonempty(in.Source, "external"), Phase: "external", Query: in.Query, Message: in.Message, NodeIDs: ids, EdgeIDs: s.Graph.ConnectingEdges(ids), Strength: .9, Metadata: in.Metadata}) queued := false if s.Engine.Cfg.AutonomousResearchQueryTriggers && s.Engine.AutonomousResearchEnabled() && (in.Type == "knowledge.answer_insufficient" || in.Type == "knowledge.search.empty" || in.Type == "agent.answer.uncertain") { priority := .9 if value, ok := in.Metadata["priority"].(float64); ok && value > 0 { priority = value } request := model.ResearchTaskRequest{Topic: nonempty(in.Query, in.Message), Question: in.Query, SeedNodeIDs: ids, Priority: priority, RequestedBy: nonempty(in.Source, "external"), Reason: in.Type, Metadata: in.Metadata} ctx, cancel := contextTimeout(r, 15*time.Second) defer cancel() _, queued, _ = s.Engine.QueueResearchTask(ctx, request) } writeJSON(w, 202, map[string]any{"ok": true, "resolved_nodes": len(ids), "research_task_queued": queued}) } func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, 401, map[string]string{"error": "unauthorized"}) return } ctx, cancel := contextTimeout(r, 10*time.Minute) defer cancel() if err := s.Engine.Scan(ctx); err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}) return } writeJSON(w, 200, s.Engine.Status()) } func (s *Server) handleGLPIKBSync(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, 401, map[string]string{"error": "unauthorized"}) return } ctx, cancel := contextTimeout(r, 10*time.Minute) defer cancel() if err := s.Engine.SyncGLPIKB(ctx); err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}) return } writeJSON(w, 200, s.Engine.Status()) } func (s *Server) handleStateExport(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } dir, err := os.MkdirTemp("", "brain-export-*") if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer os.RemoveAll(dir) path := filepath.Join(dir, "graph.db") ctx, cancel := contextTimeout(r, 10*time.Minute) defer cancel() if err := s.Engine.ExportGraph(ctx, path); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } file, err := os.Open(path) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } defer file.Close() info, err := file.Stat() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } w.Header().Set("Content-Type", "application/vnd.sqlite3") w.Header().Set("Content-Disposition", `attachment; filename="graph.db"`) w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) http.ServeContent(w, r, "graph.db", info.ModTime(), file) } func (s *Server) handleFlush(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, 401, map[string]string{"error": "unauthorized"}) return } ctx, cancel := contextTimeout(r, 2*time.Minute) defer cancel() if err := s.Engine.Flush(ctx); err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}) return } writeJSON(w, 200, s.Engine.Status()) } func (s *Server) handleEnrich(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, 401, map[string]string{"error": "unauthorized"}) return } if r.URL.Query().Get("async") == "1" { if !s.Engine.ThinkingEnabled() { writeJSON(w, http.StatusConflict, map[string]any{"error": "AI-THINK is disabled by runtime settings", "status": s.Engine.Status()}) return } if !s.Engine.RequestEnrich("manual") { writeJSON(w, http.StatusConflict, map[string]any{"error": "AI-THINK is already running or queued", "status": s.Engine.Status()}) return } writeJSON(w, http.StatusAccepted, map[string]any{"ok": true, "queued": true, "status": s.Engine.Status()}) return } ctx, cancel := contextTimeout(r, 10*time.Minute) defer cancel() if err := s.Engine.EnrichOne(ctx); err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}) return } writeJSON(w, 200, s.Engine.Status()) } func (s *Server) authorized(r *http.Request) bool { if s.APIKey == "" { return true } v := strings.TrimSpace(r.Header.Get("Authorization")) return v == "Bearer "+s.APIKey || r.Header.Get("X-Brain-Key") == s.APIKey } func decode(r *http.Request, v any) error { return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func nonempty(a, b string) string { if strings.TrimSpace(a) != "" { return a } return b } func contextTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) { return context.WithTimeout(r.Context(), d) }