diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml index 7c268bc49..2a0982cba 100644 --- a/.github/workflows/deploy-demo-server.yml +++ b/.github/workflows/deploy-demo-server.yml @@ -47,7 +47,7 @@ jobs: cp -r frontend-modern/dist internal/api/frontend-modern/ - name: Build static binary - run: CGO_ENABLED=0 go build -ldflags="-s -w" -o pulse cmd/pulse/main.go + run: CGO_ENABLED=0 go build -ldflags="-s -w" -o pulse ./cmd/pulse/ - name: Tailscale uses: tailscale/github-action@v2 diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index d9f3bb581..b359c0943 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -48,7 +48,7 @@ import { TokenRevealDialog } from './components/TokenRevealDialog'; import { useAlertsActivation } from './stores/alertsActivation'; import { UpdateProgressModal } from './components/UpdateProgressModal'; import type { UpdateStatus } from './api/updates'; -import { AIChat } from './components/AI/AIChat'; +import { AIChat } from './components/AI/Chat'; import { AIStatusIndicator } from './components/AI/AIStatusIndicator'; import { aiChatStore } from './stores/aiChat'; import { useResourcesAsLegacy } from './hooks/useResources'; diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index 4d13efc33..8e52b6748 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -14,7 +14,7 @@ const backendPort = Number( process.env.PULSE_DEV_API_PORT ?? process.env.FRONTEND_PORT ?? process.env.PORT ?? - 7654, + 7655, ); const backendUrl = diff --git a/internal/ai/service.go b/internal/ai/service.go index 5b6c5afd2..ad6bd7916 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -970,6 +970,13 @@ func (s *Service) GetConfig() *config.AIConfig { return &cfg } +// GetCommandPolicy returns the command policy for security checks +func (s *Service) GetCommandPolicy() CommandPolicy { + s.mu.RLock() + defer s.mu.RUnlock() + return s.policy +} + // GetDebugContext returns debug information about what context would be sent to the AI func (s *Service) GetDebugContext(req ExecuteRequest) map[string]interface{} { s.mu.RLock() @@ -3225,6 +3232,12 @@ This is a 3-command job. Don't over-investigate.` return prompt } +// BuildSystemPromptForOpenCode builds a system prompt for use with OpenCode integration. +// This is a public wrapper around the internal buildSystemPrompt for the OpenCode service. +func (s *Service) BuildSystemPromptForOpenCode(req ExecuteRequest) string { + return s.buildSystemPrompt(req) +} + // formatContextKey converts snake_case keys to readable labels func formatContextKey(key string) string { replacements := map[string]string{ diff --git a/internal/api/router.go b/internal/api/router.go index 15b371b9b..bb2bac3e9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -57,6 +57,7 @@ type Router struct { temperatureProxyHandlers *TemperatureProxyHandlers systemSettingsHandler *SystemSettingsHandler aiSettingsHandler *AISettingsHandler + aiHandler *AIHandler resourceHandlers *ResourceHandlers reportingHandlers *ReportingHandlers configProfileHandler *ConfigProfileHandler @@ -1331,6 +1332,62 @@ func (r *Router) setupRoutes() { }) } } + + // OpenCode-powered AI handler for chat + r.aiHandler = NewAIHandler(r.config, r.persistence, r.agentExecServer) + + // OpenCode chat endpoints (new SSE-based chat) + r.mux.HandleFunc("/api/ai/chat", RequireAuth(r.config, r.aiHandler.HandleChat)) + r.mux.HandleFunc("/api/ai/status", RequireAuth(r.config, r.aiHandler.HandleStatus)) + r.mux.HandleFunc("/api/ai/sessions", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: + r.aiHandler.HandleSessions(w, req) + case http.MethodPost: + r.aiHandler.HandleCreateSession(w, req) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + })) + r.mux.HandleFunc("/api/ai/sessions/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { + // Extract session ID from path: /api/ai/sessions/{id}[/messages|/abort] + path := strings.TrimPrefix(req.URL.Path, "/api/ai/sessions/") + parts := strings.SplitN(path, "/", 2) + sessionID := parts[0] + if sessionID == "" { + http.Error(w, "Missing session ID", http.StatusBadRequest) + return + } + + if len(parts) == 1 { + // /api/ai/sessions/{id} + if req.Method == http.MethodDelete { + r.aiHandler.HandleDeleteSession(w, req, sessionID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + return + } + + // /api/ai/sessions/{id}/messages or /api/ai/sessions/{id}/abort + switch parts[1] { + case "messages": + if req.Method == http.MethodGet { + r.aiHandler.HandleMessages(w, req, sessionID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case "abort": + if req.Method == http.MethodPost { + r.aiHandler.HandleAbort(w, req, sessionID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + default: + http.Error(w, "Not found", http.StatusNotFound) + } + })) + r.mux.HandleFunc("/api/settings/ai", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceSettings, RequireScope(config.ScopeSettingsRead, r.aiSettingsHandler.HandleGetAISettings))) r.mux.HandleFunc("/api/settings/ai/update", RequirePermission(r.config, r.authorizer, auth.ActionWrite, auth.ResourceSettings, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleUpdateAISettings))) r.mux.HandleFunc("/api/ai/test", RequirePermission(r.config, r.authorizer, auth.ActionWrite, auth.ResourceSettings, RequireScope(config.ScopeSettingsWrite, r.aiSettingsHandler.HandleTestAIConnection))) @@ -1870,6 +1927,24 @@ func (r *Router) StopPatrol() { } } +// StartOpenCode starts the OpenCode-powered AI service +func (r *Router) StartOpenCode(ctx context.Context) { + if r.aiHandler != nil && r.monitor != nil { + if err := r.aiHandler.Start(ctx, r.monitor); err != nil { + log.Error().Err(err).Msg("Failed to start OpenCode AI service") + } + } +} + +// StopOpenCode stops the OpenCode AI service +func (r *Router) StopOpenCode(ctx context.Context) { + if r.aiHandler != nil { + if err := r.aiHandler.Stop(ctx); err != nil { + log.Error().Err(err).Msg("Failed to stop OpenCode AI service") + } + } +} + // startBaselineLearning runs a background loop that learns baselines from metrics history // This enables anomaly detection by understanding what "normal" looks like for each resource func (r *Router) startBaselineLearning(ctx context.Context, store *ai.BaselineStore, metricsHistory *monitoring.MetricsHistory) { diff --git a/internal/config/ai.go b/internal/config/ai.go index bce3e542c..bdb0df4dc 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -61,6 +61,12 @@ type AIConfig struct { // AI cost controls // Budget is expressed as an estimated USD amount over a 30-day window (pro-rated in UI for other ranges). CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"` + + // OpenCode integration - experimental feature to use OpenCode as the AI backend + // When enabled, chat and patrol use OpenCode instead of the built-in provider system + UseOpenCode bool `json:"use_opencode,omitempty"` // Enable OpenCode backend (experimental) + OpenCodeDataDir string `json:"opencode_data_dir,omitempty"` // Data directory for OpenCode (default: ~/.opencode) + OpenCodePort int `json:"opencode_port,omitempty"` // Port for OpenCode server (0 = auto-assign) } // AIProvider constants diff --git a/pkg/server/server.go b/pkg/server/server.go index c24592e84..bd9a0027e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -289,6 +289,9 @@ func Run(ctx context.Context, version string) error { // Start AI patrol service for background infrastructure monitoring router.StartPatrol(ctx) + // Start OpenCode-powered AI chat service + router.StartOpenCode(ctx) + // Wire alert-triggered AI analysis router.WireAlertTriggeredAI()