diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index d9b5226db..079e64613 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -43,6 +43,11 @@ func main() { prometheusURL := flag.String("prometheus-url", "", "Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)") // MCP server noMCP := flag.Bool("no-mcp", false, "Disable MCP (Model Context Protocol) server for AI tools") + // AI investigation + aiProvider := flag.String("ai-provider", "", "AI provider for investigations: openai or anthropic") + aiAPIKey := flag.String("ai-api-key", "", "API key for the AI provider") + aiBaseURL := flag.String("ai-base-url", "", "Base URL for OpenAI-compatible endpoints (e.g. http://localhost:11434/v1 for Ollama)") + aiModel := flag.String("ai-model", "", "Model override for AI provider (default: provider-specific)") flag.Parse() if *showVersion { @@ -80,6 +85,10 @@ func main() { PrometheusURL: *prometheusURL, MCPEnabled: !*noMCP, Version: version, + AIProvider: *aiProvider, + AIAPIKey: *aiAPIKey, + AIBaseURL: *aiBaseURL, + AIModel: *aiModel, } // Set global flags diff --git a/go.mod b/go.mod index 111352e6d..c44f33fae 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -115,6 +116,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/openai/openai-go/v3 v3.24.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect @@ -134,6 +136,10 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect diff --git a/go.sum b/go.sum index c646f1d3f..a8c468a20 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= @@ -283,6 +285,8 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/openai/openai-go/v3 v3.24.0 h1:08x6GnYiB+AAejTo6yzPY8RkZMJQ8NpreiOyM5QfyYU= +github.com/openai/openai-go/v3 v3.24.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -355,6 +359,16 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/internal/ai/investigate/engine.go b/internal/ai/investigate/engine.go new file mode 100644 index 000000000..ddfcbead5 --- /dev/null +++ b/internal/ai/investigate/engine.go @@ -0,0 +1,233 @@ +package investigate + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + + aicontext "github.com/skyhook-io/radar/internal/ai/context" + "github.com/skyhook-io/radar/internal/ai/llm" + "github.com/skyhook-io/radar/internal/k8s" + "github.com/skyhook-io/radar/internal/timeline" +) + +// Engine orchestrates AI-powered investigations. +type Engine struct { + provider llm.Provider +} + +// NewEngine creates an investigation engine with the given LLM provider. +func NewEngine(provider llm.Provider) *Engine { + return &Engine{provider: provider} +} + +// InvestigateParams defines what to investigate. +type InvestigateParams struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Question string `json:"question,omitempty"` +} + +// Event represents a streamed investigation progress event. +type Event struct { + Type string `json:"type"` // "status", "tool_call", "tool_result", "analysis", "error", "done" + Content string `json:"content"` // human-readable content + Tool string `json:"tool,omitempty"` // tool name for tool_call events + Args string `json:"args,omitempty"` // tool args for tool_call events + ToolCallID string `json:"toolCallId,omitempty"` // unique ID for correlating tool calls with results +} + +// Investigate runs an AI investigation on the specified resource. +// Progress is streamed via the onEvent callback. +func (e *Engine) Investigate(ctx context.Context, params InvestigateParams, onEvent func(Event)) error { + onEvent(Event{Type: "status", Content: "Assembling resource context..."}) + + // Build initial context from Radar's data + initialContext, err := assembleInitialContext(ctx, params) + if err != nil { + return fmt.Errorf("failed to assemble context: %w", err) + } + + userPrompt := buildUserPrompt(params.Kind, params.Namespace, params.Name, initialContext, params.Question) + + // Build tools for the investigation + tools := buildTools() + + // Bridge investigation events to the caller (engine sends its own "done") + llmOnEvent := func(ev llm.StreamEvent) { + switch ev.Type { + case "step_start": + onEvent(Event{Type: "step_start"}) + case "tool_call": + onEvent(Event{ + Type: "tool_call", + Content: fmt.Sprintf("Calling %s", ev.Tool), + Tool: ev.Tool, + Args: ev.Args, + ToolCallID: ev.ToolCallID, + }) + case "tool_result": + onEvent(Event{ + Type: "tool_result", + Content: ev.Content, + Tool: ev.Tool, + ToolCallID: ev.ToolCallID, + }) + case "text": + onEvent(Event{Type: "analysis", Content: ev.Content}) + case "thinking": + onEvent(Event{Type: "status", Content: ev.Content}) + case "error": + onEvent(Event{Type: "error", Content: ev.Content}) + case "done": + // Handled by engine after Investigate returns + } + } + + req := llm.InvestigateRequest{ + SystemPrompt: systemPrompt, + UserPrompt: userPrompt, + Tools: tools, + } + + _, err = e.provider.Investigate(ctx, req, llmOnEvent) + if err != nil { + onEvent(Event{Type: "error", Content: err.Error()}) + return fmt.Errorf("investigation failed: %w", err) + } + + onEvent(Event{Type: "done", Content: ""}) + return nil +} + +// assembleInitialContext gathers resource data to provide as the starting context. +func assembleInitialContext(ctx context.Context, params InvestigateParams) (string, error) { + cache := k8s.GetResourceCache() + if cache == nil { + return "", fmt.Errorf("not connected to cluster") + } + + kind := strings.ToLower(params.Kind) + sections := aicontext.ContextSections{ + ResourceKind: params.Kind, + ResourceNamespace: params.Namespace, + ResourceName: params.Name, + } + + // 1. Minified resource + obj, err := k8s.FetchResource(cache, kind, params.Namespace, params.Name) + if err == nil { + k8s.SetTypeMeta(obj) + if minified, minErr := aicontext.Minify(obj, aicontext.LevelDetail); minErr == nil { + data, _ := json.MarshalIndent(minified, "", " ") + sections.MinifiedResource = string(data) + } + } else if err == k8s.ErrUnknownKind { + u, dynErr := cache.GetDynamicWithGroup(ctx, kind, params.Namespace, params.Name, "") + if dynErr == nil { + data, _ := json.MarshalIndent(aicontext.MinifyUnstructured(u, aicontext.LevelDetail), "", " ") + sections.MinifiedResource = string(data) + } + } + + // 2. Events for this resource + if eventLister := cache.Events(); eventLister != nil { + var events []*corev1.Event + if params.Namespace != "" { + events, _ = eventLister.Events(params.Namespace).List(labels.Everything()) + } else { + events, _ = eventLister.List(labels.Everything()) + } + var matched []corev1.Event + for _, e := range events { + if e.Type != "Warning" { + continue + } + if strings.EqualFold(e.InvolvedObject.Kind, params.Kind) && e.InvolvedObject.Name == params.Name { + matched = append(matched, *e) + } + } + if len(matched) > 0 { + deduplicated := aicontext.DeduplicateEvents(matched) + if len(deduplicated) > 10 { + deduplicated = deduplicated[:10] + } + data, _ := json.MarshalIndent(deduplicated, "", " ") + sections.Events = string(data) + } + } + + // 3. Logs (if pod) + if isPodKind(kind) { + if client := k8s.GetClient(); client != nil { + tailLines := int64(100) + opts := &corev1.PodLogOptions{TailLines: &tailLines} + stream, logErr := client.CoreV1().Pods(params.Namespace).GetLogs(params.Name, opts).Stream(ctx) + if logErr == nil { + defer stream.Close() + data, readErr := io.ReadAll(stream) + if readErr == nil { + filtered := aicontext.FilterLogs(string(data)) + jsonData, _ := json.MarshalIndent(filtered, "", " ") + sections.Logs = string(jsonData) + } + } + } + } + + // 4. Recent changes + if store := timeline.GetStore(); store != nil { + queryOpts := timeline.QueryOptions{ + Since: time.Now().Add(-1 * time.Hour), + FilterPreset: "workloads", + Limit: 10, + } + if params.Namespace != "" { + queryOpts.Namespaces = []string{params.Namespace} + } + changes, queryErr := store.Query(ctx, queryOpts) + if queryErr == nil && len(changes) > 0 { + type change struct { + Kind string `json:"kind"` + Name string `json:"name"` + ChangeType string `json:"changeType"` + Summary string `json:"summary"` + Timestamp string `json:"timestamp"` + } + var changeSummaries []change + for _, c := range changes { + summary := "" + if c.Diff != nil && c.Diff.Summary != "" { + summary = c.Diff.Summary + } else if c.Message != "" { + summary = k8s.Truncate(c.Message, 100) + } + changeSummaries = append(changeSummaries, change{ + Kind: c.Kind, + Name: c.Name, + ChangeType: string(c.EventType), + Summary: summary, + Timestamp: c.Timestamp.Format(time.RFC3339), + }) + } + data, _ := json.MarshalIndent(changeSummaries, "", " ") + sections.Metrics = string(data) // Reuse metrics slot for changes in initial context + } + } + + assembled := aicontext.AssembleContext(sections, aicontext.BudgetCloud) + log.Printf("[ai] Assembled initial context: %d chars", len(assembled)) + return assembled, nil +} + +func isPodKind(kind string) bool { + return kind == "pod" || kind == "pods" +} diff --git a/internal/ai/investigate/prompt.go b/internal/ai/investigate/prompt.go new file mode 100644 index 000000000..82544597c --- /dev/null +++ b/internal/ai/investigate/prompt.go @@ -0,0 +1,44 @@ +package investigate + +const systemPrompt = `You are an expert Kubernetes SRE investigating a problem in a live cluster. Your goal is to identify the root cause and suggest actionable fixes. + +## Communication style +- Think out loud. Before calling a tool, briefly say what you're checking and why. +- After getting results, share what you learned in 1-2 sentences before moving on. +- Use markdown: ## for section headers, **bold** for emphasis, ` + "`" + `code` + "`" + ` for resource names. +- Sound like a helpful colleague, not a report generator. + +## Investigation approach +1. Review the resource context provided — status, conditions, obvious issues +2. Check events for warnings or errors +3. If Pod-related, check logs for error patterns +4. Check recent changes that correlate with the problem +5. Check related resources for upstream issues + +## Final analysis format +When you have enough evidence, summarize with: + +## Root cause +Clear statement of what went wrong, with evidence. + +## Why this happened +Underlying cause explanation. + +## Recommended fix +Specific actionable steps. Mention Radar actions (restart, rollback, scale) when applicable. + +## Guidelines +- Don't repeat raw JSON data — summarize what you found in plain language. +- Stop investigating when you have enough evidence — don't make unnecessary tool calls. +- If the resource looks healthy, say so and check if the problem has self-resolved.` + +func buildUserPrompt(kind, namespace, name string, initialContext string, question string) string { + prompt := "Investigate this Kubernetes resource that appears to have a problem:\n\n" + prompt += initialContext + + if question != "" { + prompt += "\n\nAdditional context from the user: " + question + } + + return prompt +} diff --git a/internal/ai/investigate/tools.go b/internal/ai/investigate/tools.go new file mode 100644 index 000000000..9bed7a368 --- /dev/null +++ b/internal/ai/investigate/tools.go @@ -0,0 +1,421 @@ +package investigate + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + + aicontext "github.com/skyhook-io/radar/internal/ai/context" + "github.com/skyhook-io/radar/internal/ai/llm" + "github.com/skyhook-io/radar/internal/k8s" + "github.com/skyhook-io/radar/internal/timeline" + "github.com/skyhook-io/radar/internal/topology" +) + +// buildTools returns the set of tools available during an AI investigation. +// These call Radar's internal functions directly (not via HTTP). +func buildTools() []llm.Tool { + return []llm.Tool{ + { + Name: "get_resource", + Description: "Get detailed information about a Kubernetes resource including its spec, status, and conditions.", + Parameters: mustJSON(map[string]any{ + "type": "object", + "properties": map[string]any{ + "kind": map[string]any{"type": "string", "description": "resource kind (e.g. pod, deployment, service)"}, + "namespace": map[string]any{"type": "string", "description": "resource namespace"}, + "name": map[string]any{"type": "string", "description": "resource name"}, + }, + "required": []string{"kind", "namespace", "name"}, + }), + Execute: executeGetResource, + }, + { + Name: "get_events", + Description: "Get Kubernetes warning events, optionally filtered to a specific resource. Events show scheduling failures, image pull errors, OOM kills, etc.", + Parameters: mustJSON(map[string]any{ + "type": "object", + "properties": map[string]any{ + "namespace": map[string]any{"type": "string", "description": "filter to a specific namespace"}, + "kind": map[string]any{"type": "string", "description": "filter to events involving this resource kind (e.g. Pod, Deployment)"}, + "name": map[string]any{"type": "string", "description": "filter to events involving this resource name"}, + }, + }), + Execute: executeGetEvents, + }, + { + Name: "get_pod_logs", + Description: "Get filtered log lines from a pod, prioritizing errors and warnings. Returns diagnostically relevant lines.", + Parameters: mustJSON(map[string]any{ + "type": "object", + "properties": map[string]any{ + "namespace": map[string]any{"type": "string", "description": "pod namespace"}, + "name": map[string]any{"type": "string", "description": "pod name"}, + "container": map[string]any{"type": "string", "description": "container name (defaults to first container)"}, + }, + "required": []string{"namespace", "name"}, + }), + Execute: executeGetPodLogs, + }, + { + Name: "get_changes", + Description: "Get recent resource changes (creates, updates, deletes) from the cluster timeline. Use to investigate what changed before an incident.", + Parameters: mustJSON(map[string]any{ + "type": "object", + "properties": map[string]any{ + "namespace": map[string]any{"type": "string", "description": "filter to a specific namespace"}, + "kind": map[string]any{"type": "string", "description": "filter to a resource kind (e.g. Deployment, Pod)"}, + "name": map[string]any{"type": "string", "description": "filter to a specific resource name"}, + "since": map[string]any{"type": "string", "description": "duration to look back, e.g. 1h, 30m (default 1h)"}, + }, + }), + Execute: executeGetChanges, + }, + { + Name: "get_related_resources", + Description: "Get resources related to a specific resource — parents, children, services, config, scalers, etc.", + Parameters: mustJSON(map[string]any{ + "type": "object", + "properties": map[string]any{ + "kind": map[string]any{"type": "string", "description": "resource kind"}, + "namespace": map[string]any{"type": "string", "description": "resource namespace"}, + "name": map[string]any{"type": "string", "description": "resource name"}, + }, + "required": []string{"kind", "namespace", "name"}, + }), + Execute: executeGetRelatedResources, + }, + { + Name: "list_resources", + Description: "List Kubernetes resources of a given kind with minified summaries. Use to see what pods/deployments/etc exist in a namespace.", + Parameters: mustJSON(map[string]any{ + "type": "object", + "properties": map[string]any{ + "kind": map[string]any{"type": "string", "description": "resource kind (e.g. pods, deployments, services)"}, + "namespace": map[string]any{"type": "string", "description": "filter to a specific namespace"}, + }, + "required": []string{"kind"}, + }), + Execute: executeListResources, + }, + } +} + +// Tool execution functions + +func executeGetResource(ctx context.Context, params json.RawMessage) (string, error) { + var input struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + } + if err := json.Unmarshal(params, &input); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + cache := k8s.GetResourceCache() + if cache == nil { + return "", fmt.Errorf("not connected to cluster") + } + + kind := strings.ToLower(input.Kind) + obj, err := k8s.FetchResource(cache, kind, input.Namespace, input.Name) + if err == k8s.ErrUnknownKind { + u, dynErr := cache.GetDynamicWithGroup(ctx, kind, input.Namespace, input.Name, "") + if dynErr != nil { + return "", fmt.Errorf("resource not found: %w", dynErr) + } + return toJSON(aicontext.MinifyUnstructured(u, aicontext.LevelDetail)) + } + if err != nil { + return "", fmt.Errorf("resource not found: %w", err) + } + + k8s.SetTypeMeta(obj) + minified, err := aicontext.Minify(obj, aicontext.LevelDetail) + if err != nil { + return "", fmt.Errorf("failed to minify: %w", err) + } + return toJSON(minified) +} + +func executeGetEvents(ctx context.Context, params json.RawMessage) (string, error) { + var input struct { + Namespace string `json:"namespace"` + Kind string `json:"kind"` + Name string `json:"name"` + } + if err := json.Unmarshal(params, &input); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + cache := k8s.GetResourceCache() + if cache == nil { + return "", fmt.Errorf("not connected to cluster") + } + + eventLister := cache.Events() + if eventLister == nil { + return "[]", nil + } + + var events []*corev1.Event + var err error + if input.Namespace != "" { + events, err = eventLister.Events(input.Namespace).List(labels.Everything()) + } else { + events, err = eventLister.List(labels.Everything()) + } + if err != nil { + return "", fmt.Errorf("failed to list events: %w", err) + } + + // Filter to warning events involving the specified resource + var warnings []corev1.Event + for _, e := range events { + if e.Type != "Warning" { + continue + } + if input.Kind != "" && !strings.EqualFold(e.InvolvedObject.Kind, input.Kind) { + continue + } + if input.Name != "" && e.InvolvedObject.Name != input.Name { + continue + } + warnings = append(warnings, *e) + } + + if len(warnings) == 0 { + return "[]", nil + } + + deduplicated := aicontext.DeduplicateEvents(warnings) + if len(deduplicated) > 15 { + deduplicated = deduplicated[:15] + } + return toJSON(deduplicated) +} + +func executeGetPodLogs(ctx context.Context, params json.RawMessage) (string, error) { + var input struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Container string `json:"container"` + } + if err := json.Unmarshal(params, &input); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + client := k8s.GetClient() + if client == nil { + return "", fmt.Errorf("not connected to cluster") + } + + tailLines := int64(200) + opts := &corev1.PodLogOptions{TailLines: &tailLines} + if input.Container != "" { + opts.Container = input.Container + } + + stream, err := client.CoreV1().Pods(input.Namespace).GetLogs(input.Name, opts).Stream(ctx) + if err != nil { + return "", fmt.Errorf("failed to get logs: %w", err) + } + defer stream.Close() + + data, err := io.ReadAll(stream) + if err != nil { + return "", fmt.Errorf("failed to read logs: %w", err) + } + + filtered := aicontext.FilterLogs(string(data)) + return toJSON(filtered) +} + +func executeGetChanges(ctx context.Context, params json.RawMessage) (string, error) { + var input struct { + Namespace string `json:"namespace"` + Kind string `json:"kind"` + Name string `json:"name"` + Since string `json:"since"` + } + if err := json.Unmarshal(params, &input); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + store := timeline.GetStore() + if store == nil { + return "[]", nil + } + + since := 1 * time.Hour + if input.Since != "" { + parsed, err := time.ParseDuration(input.Since) + if err != nil { + return "", fmt.Errorf("invalid duration %q: %w", input.Since, err) + } + since = parsed + } + + queryOpts := timeline.QueryOptions{ + Since: time.Now().Add(-since), + FilterPreset: "default", + Limit: 20, + } + if input.Namespace != "" { + queryOpts.Namespaces = []string{input.Namespace} + } + if input.Kind != "" { + queryOpts.Kinds = []string{input.Kind} + } + if input.Name != "" { + queryOpts.Limit = 200 // fetch more to compensate for name filter + } + + events, err := store.Query(ctx, queryOpts) + if err != nil { + return "", fmt.Errorf("failed to query timeline: %w", err) + } + + // Post-filter by name + if input.Name != "" { + filtered := events[:0] + for _, e := range events { + if e.Name == input.Name { + filtered = append(filtered, e) + } + } + events = filtered + if len(events) > 20 { + events = events[:20] + } + } + + type change struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + ChangeType string `json:"changeType"` + Summary string `json:"summary"` + Timestamp string `json:"timestamp"` + } + + changes := make([]change, 0, len(events)) + for _, e := range events { + summary := "" + if e.Diff != nil && e.Diff.Summary != "" { + summary = e.Diff.Summary + } else if e.Message != "" { + summary = k8s.Truncate(e.Message, 100) + } + changes = append(changes, change{ + Kind: e.Kind, + Namespace: e.Namespace, + Name: e.Name, + ChangeType: string(e.EventType), + Summary: summary, + Timestamp: e.Timestamp.Format(time.RFC3339), + }) + } + + return toJSON(changes) +} + +func executeGetRelatedResources(ctx context.Context, params json.RawMessage) (string, error) { + var input struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + } + if err := json.Unmarshal(params, &input); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + opts := topology.DefaultBuildOptions() + if input.Namespace != "" { + opts.Namespaces = []string{input.Namespace} + } + + builder := topology.NewBuilder() + topo, err := builder.Build(opts) + if err != nil { + return "", fmt.Errorf("failed to build topology: %w", err) + } + + rels := topology.GetRelationships(input.Kind, input.Namespace, input.Name, topo) + if rels == nil { + return `{"message": "no relationships found"}`, nil + } + + return toJSON(rels) +} + +func executeListResources(ctx context.Context, params json.RawMessage) (string, error) { + var input struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + } + if err := json.Unmarshal(params, &input); err != nil { + return "", fmt.Errorf("invalid parameters: %w", err) + } + + cache := k8s.GetResourceCache() + if cache == nil { + return "", fmt.Errorf("not connected to cluster") + } + + kind := strings.ToLower(input.Kind) + var namespaces []string + if input.Namespace != "" { + namespaces = []string{input.Namespace} + } + + objs, err := k8s.FetchResourceList(cache, kind, namespaces) + if err == k8s.ErrUnknownKind { + // Try dynamic cache for CRDs + var allItems []any + items, dynErr := cache.ListDynamicWithGroup(ctx, kind, input.Namespace, "") + if dynErr != nil { + return "", fmt.Errorf("failed to list %s: %w", kind, dynErr) + } + for _, item := range items { + allItems = append(allItems, aicontext.MinifyUnstructured(item, aicontext.LevelSummary)) + } + return toJSON(allItems) + } + if err != nil { + return "", fmt.Errorf("failed to list %s: %w", kind, err) + } + + results, err := aicontext.MinifyList(objs, aicontext.LevelSummary) + if err != nil { + return "", fmt.Errorf("failed to minify: %w", err) + } + + return toJSON(results) +} + +// Helpers + +func mustJSON(v any) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + log.Fatalf("[ai] Failed to marshal JSON schema: %v", err) + } + return data +} + +func toJSON(v any) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + return string(data), nil +} diff --git a/internal/ai/llm/anthropic.go b/internal/ai/llm/anthropic.go new file mode 100644 index 000000000..9e7e5d521 --- /dev/null +++ b/internal/ai/llm/anthropic.go @@ -0,0 +1,171 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" +) + +const anthropicDefaultModel = "claude-sonnet-4-6" + +type anthropicProvider struct { + client anthropic.Client + model string +} + +func newAnthropicProvider(cfg Config) (*anthropicProvider, error) { + opts := []option.RequestOption{ + option.WithAPIKey(cfg.APIKey), + } + client := anthropic.NewClient(opts...) + + model := cfg.Model + if model == "" { + model = anthropicDefaultModel + } + + return &anthropicProvider{client: client, model: model}, nil +} + +func (p *anthropicProvider) Investigate(ctx context.Context, req InvestigateRequest, onEvent func(StreamEvent)) (*InvestigateResult, error) { + tools := make([]anthropic.ToolUnionParam, len(req.Tools)) + toolMap := make(map[string]Tool, len(req.Tools)) + for i, t := range req.Tools { + var schema map[string]interface{} + if t.Parameters != nil { + if err := json.Unmarshal(t.Parameters, &schema); err != nil { + return nil, fmt.Errorf("invalid tool parameters for %s: %w", t.Name, err) + } + } + inputSchema := anthropic.ToolInputSchemaParam{ + Properties: schema["properties"], + } + if reqList, ok := schema["required"]; ok { + if sl, ok := reqList.([]interface{}); ok { + strs := make([]string, len(sl)) + for i, v := range sl { + strs[i] = fmt.Sprintf("%v", v) + } + inputSchema.Required = strs + } + } + + tools[i] = anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: t.Name, + Description: anthropic.String(t.Description), + InputSchema: inputSchema, + }, + } + toolMap[t.Name] = t + } + + messages := []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock(req.UserPrompt)), + } + + var allToolCalls []ToolCallRecord + var finalText strings.Builder + + for iteration := range maxToolIterations { + onEvent(StreamEvent{Type: "step_start"}) + + callCtx, cancel := context.WithTimeout(ctx, llmCallTimeout) + stream := p.client.Messages.NewStreaming(callCtx, anthropic.MessageNewParams{ + Model: anthropic.Model(p.model), + MaxTokens: 4096, + System: []anthropic.TextBlockParam{ + {Text: req.SystemPrompt}, + }, + Messages: messages, + Tools: tools, + }) + + // Accumulate the full message while streaming text deltas + message := anthropic.Message{} + for stream.Next() { + event := stream.Current() + if err := message.Accumulate(event); err != nil { + log.Printf("[ai] accumulate error: %v", err) + } + + // Stream text deltas in real time + switch variant := event.AsAny().(type) { + case anthropic.ContentBlockDeltaEvent: + switch delta := variant.Delta.AsAny().(type) { + case anthropic.TextDelta: + onEvent(StreamEvent{Type: "text", Content: delta.Text}) + finalText.WriteString(delta.Text) + } + } + } + cancel() + + if err := stream.Err(); err != nil { + onEvent(StreamEvent{Type: "error", Content: err.Error()}) + return nil, fmt.Errorf("anthropic streaming failed (iteration %d): %w", iteration, err) + } + + // Process accumulated content blocks for tool calls + hasToolUse := false + var toolResults []anthropic.ContentBlockParamUnion + + for _, block := range message.Content { + switch variant := block.AsAny().(type) { + case anthropic.ToolUseBlock: + hasToolUse = true + argsJSON, _ := json.Marshal(variant.Input) + toolArgs := string(argsJSON) + + log.Printf("\033[1;36m[ai]\033[0m tool_call: %s %s", variant.Name, toolArgs) + onEvent(StreamEvent{Type: "tool_call", Tool: variant.Name, Args: toolArgs, ToolCallID: variant.ID}) + + tool, ok := toolMap[variant.Name] + if !ok { + errMsg := fmt.Sprintf("unknown tool: %s", variant.Name) + log.Printf("\033[1;36m[ai]\033[0m tool_error: %s", errMsg) + onEvent(StreamEvent{Type: "tool_result", Tool: variant.Name, Content: errMsg, ToolCallID: variant.ID}) + toolResults = append(toolResults, anthropic.NewToolResultBlock(variant.ID, errMsg, true)) + allToolCalls = append(allToolCalls, ToolCallRecord{Tool: variant.Name, Args: toolArgs, Result: errMsg}) + continue + } + + result, execErr := tool.Execute(ctx, json.RawMessage(toolArgs)) + if execErr != nil { + result = fmt.Sprintf("error: %v", execErr) + } + + log.Printf("\033[1;36m[ai]\033[0m tool_result: %s (%d chars)", variant.Name, len(result)) + onEvent(StreamEvent{Type: "tool_result", Tool: variant.Name, Content: truncateResult(result, 200), ToolCallID: variant.ID}) + + toolResults = append(toolResults, anthropic.NewToolResultBlock(variant.ID, result, false)) + allToolCalls = append(allToolCalls, ToolCallRecord{Tool: variant.Name, Args: toolArgs, Result: result}) + } + } + + // If no tool use, we're done + if !hasToolUse { + onEvent(StreamEvent{Type: "done"}) + break + } + + // Append assistant message and tool results to continue conversation + messages = append(messages, message.ToParam()) + messages = append(messages, anthropic.NewUserMessage(toolResults...)) + + // Check if this was the last iteration + if iteration == maxToolIterations-1 { + onEvent(StreamEvent{Type: "error", Content: "max tool iterations reached"}) + } + } + + return &InvestigateResult{ + Analysis: finalText.String(), + ToolCalls: allToolCalls, + }, nil +} diff --git a/internal/ai/llm/config.go b/internal/ai/llm/config.go new file mode 100644 index 000000000..adc1e0cc5 --- /dev/null +++ b/internal/ai/llm/config.go @@ -0,0 +1,50 @@ +package llm + +import "fmt" + +const ( + ProviderOpenAI = "openai" + ProviderAnthropic = "anthropic" + ProviderOllama = "ollama" // alias for openai with custom base URL +) + +// Config holds the LLM provider configuration. +type Config struct { + Provider string // "openai", "anthropic", or "ollama" (OpenAI-compatible) + APIKey string + BaseURL string // for OpenAI-compatible endpoints (Ollama, LM Studio, OpenRouter, etc.) + Model string // override default model +} + +// IsConfigured returns true if a provider is set with required credentials. +// Ollama doesn't require an API key. +func (c Config) IsConfigured() bool { + if c.Provider == "" { + return false + } + if c.Provider == ProviderOllama { + return true // Ollama doesn't need an API key + } + return c.APIKey != "" +} + +// NewProvider creates a Provider from the given configuration. +func NewProvider(cfg Config) (Provider, error) { + switch cfg.Provider { + case ProviderOpenAI: + return newOpenAIProvider(cfg) + case ProviderOllama: + // Ollama uses the OpenAI-compatible API + if cfg.BaseURL == "" { + cfg.BaseURL = "http://localhost:11434/v1" + } + if cfg.APIKey == "" { + cfg.APIKey = "ollama" // Ollama requires a non-empty key but doesn't validate it + } + return newOpenAIProvider(cfg) + case ProviderAnthropic: + return newAnthropicProvider(cfg) + default: + return nil, fmt.Errorf("unknown AI provider: %q (supported: openai, anthropic, ollama)", cfg.Provider) + } +} diff --git a/internal/ai/llm/openai.go b/internal/ai/llm/openai.go new file mode 100644 index 000000000..141240b8b --- /dev/null +++ b/internal/ai/llm/openai.go @@ -0,0 +1,162 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" +) + +const ( + openaiDefaultModel = "gpt-5-mini" + maxToolIterations = 10 + llmCallTimeout = 60 * time.Second +) + +type openaiProvider struct { + client openai.Client + model string +} + +func newOpenAIProvider(cfg Config) (*openaiProvider, error) { + opts := []option.RequestOption{ + option.WithAPIKey(cfg.APIKey), + } + if cfg.BaseURL != "" { + opts = append(opts, option.WithBaseURL(cfg.BaseURL)) + } + client := openai.NewClient(opts...) + + model := cfg.Model + if model == "" { + model = openaiDefaultModel + } + + return &openaiProvider{client: client, model: model}, nil +} + +func (p *openaiProvider) Investigate(ctx context.Context, req InvestigateRequest, onEvent func(StreamEvent)) (*InvestigateResult, error) { + tools := make([]openai.ChatCompletionToolUnionParam, len(req.Tools)) + toolMap := make(map[string]Tool, len(req.Tools)) + for i, t := range req.Tools { + var params openai.FunctionParameters + if t.Parameters != nil { + if err := json.Unmarshal(t.Parameters, ¶ms); err != nil { + return nil, fmt.Errorf("invalid tool parameters for %s: %w", t.Name, err) + } + } + tools[i] = openai.ChatCompletionFunctionTool(openai.FunctionDefinitionParam{ + Name: t.Name, + Description: openai.String(t.Description), + Parameters: params, + }) + toolMap[t.Name] = t + } + + messages := []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(req.SystemPrompt), + openai.UserMessage(req.UserPrompt), + } + + var allToolCalls []ToolCallRecord + var finalText strings.Builder + + for iteration := range maxToolIterations { + onEvent(StreamEvent{Type: "step_start"}) + + callCtx, cancel := context.WithTimeout(ctx, llmCallTimeout) + stream := p.client.Chat.Completions.NewStreaming(callCtx, openai.ChatCompletionNewParams{ + Messages: messages, + Model: openai.ChatModel(p.model), + Tools: tools, + }) + + acc := openai.ChatCompletionAccumulator{} + for stream.Next() { + chunk := stream.Current() + acc.AddChunk(chunk) + + // Emit text deltas as they arrive for real-time streaming + if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { + delta := chunk.Choices[0].Delta.Content + onEvent(StreamEvent{Type: "text", Content: delta}) + finalText.WriteString(delta) + } + } + cancel() + + if err := stream.Err(); err != nil { + onEvent(StreamEvent{Type: "error", Content: err.Error()}) + return nil, fmt.Errorf("openai streaming failed (iteration %d): %w", iteration, err) + } + + if len(acc.Choices) == 0 { + onEvent(StreamEvent{Type: "error", Content: "no choices returned"}) + return nil, fmt.Errorf("openai returned empty choices") + } + + msg := acc.Choices[0].Message + + // No tool calls → done + if len(msg.ToolCalls) == 0 { + onEvent(StreamEvent{Type: "done"}) + break + } + + // Append the assistant message (with tool calls) to conversation + messages = append(messages, msg.ToParam()) + + // Execute each tool call + for _, tc := range msg.ToolCalls { + toolName := tc.Function.Name + toolArgs := tc.Function.Arguments + + log.Printf("\033[1;36m[ai]\033[0m tool_call: %s %s", toolName, toolArgs) + onEvent(StreamEvent{Type: "tool_call", Tool: toolName, Args: toolArgs, ToolCallID: tc.ID}) + + tool, ok := toolMap[toolName] + if !ok { + errMsg := fmt.Sprintf("unknown tool: %s", toolName) + log.Printf("\033[1;36m[ai]\033[0m tool_error: %s", errMsg) + onEvent(StreamEvent{Type: "tool_result", Tool: toolName, Content: errMsg, ToolCallID: tc.ID}) + messages = append(messages, openai.ToolMessage(errMsg, tc.ID)) + allToolCalls = append(allToolCalls, ToolCallRecord{Tool: toolName, Args: toolArgs, Result: errMsg}) + continue + } + + result, execErr := tool.Execute(ctx, json.RawMessage(toolArgs)) + if execErr != nil { + result = fmt.Sprintf("error: %v", execErr) + } + + log.Printf("\033[1;36m[ai]\033[0m tool_result: %s (%d chars)", toolName, len(result)) + onEvent(StreamEvent{Type: "tool_result", Tool: toolName, Content: truncateResult(result, 200), ToolCallID: tc.ID}) + + messages = append(messages, openai.ToolMessage(result, tc.ID)) + allToolCalls = append(allToolCalls, ToolCallRecord{Tool: toolName, Args: toolArgs, Result: result}) + } + + // Check if this was the last iteration + if iteration == maxToolIterations-1 { + onEvent(StreamEvent{Type: "error", Content: "max tool iterations reached"}) + } + } + + return &InvestigateResult{ + Analysis: finalText.String(), + ToolCalls: allToolCalls, + }, nil +} + +// truncateResult returns the first n characters of s for display purposes. +func truncateResult(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/ai/llm/provider.go b/internal/ai/llm/provider.go new file mode 100644 index 000000000..9c825c46f --- /dev/null +++ b/internal/ai/llm/provider.go @@ -0,0 +1,50 @@ +package llm + +import ( + "context" + "encoding/json" +) + +// Provider is the interface for LLM backends. +type Provider interface { + // Investigate runs a multi-turn investigation with tool calling. + // The onEvent callback streams progress events to the caller. + Investigate(ctx context.Context, req InvestigateRequest, onEvent func(StreamEvent)) (*InvestigateResult, error) +} + +// InvestigateRequest contains the prompts and tools for an investigation. +type InvestigateRequest struct { + SystemPrompt string + UserPrompt string + Tools []Tool +} + +// Tool defines a callable tool that the LLM can invoke during investigation. +type Tool struct { + Name string + Description string + Parameters json.RawMessage // JSON Schema + Execute func(ctx context.Context, params json.RawMessage) (string, error) +} + +// StreamEvent is sent via the onEvent callback to report investigation progress. +type StreamEvent struct { + Type string `json:"type"` // "step_start", "thinking", "tool_call", "tool_result", "text", "error", "done" + Content string `json:"content"` // text content or error message + Tool string `json:"tool,omitempty"` // tool name for tool_call/tool_result events + Args string `json:"args,omitempty"` // tool arguments for tool_call events + ToolCallID string `json:"toolCallId,omitempty"` // unique ID for correlating tool calls with results +} + +// InvestigateResult is the final output of an investigation. +type InvestigateResult struct { + Analysis string `json:"analysis"` + ToolCalls []ToolCallRecord `json:"toolCalls"` +} + +// ToolCallRecord logs a single tool invocation during investigation. +type ToolCallRecord struct { + Tool string `json:"tool"` + Args string `json:"args"` + Result string `json:"result"` +} diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index c057a2714..bdcf29ba3 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "time" + "github.com/skyhook-io/radar/internal/ai/llm" "github.com/skyhook-io/radar/internal/helm" "github.com/skyhook-io/radar/internal/k8s" mcppkg "github.com/skyhook-io/radar/internal/mcp" @@ -39,6 +40,12 @@ type AppConfig struct { PrometheusURL string Version string MCPEnabled bool + + // AI investigation + AIProvider string + AIAPIKey string + AIBaseURL string + AIModel string } // SetGlobals applies debug/test flags to global state. @@ -158,6 +165,14 @@ func CreateServer(cfg AppConfig) *server.Server { log.Printf("MCP server enabled at http://localhost:%d/mcp", cfg.Port) } + // Initialize AI provider from CLI flags + saved settings + server.LoadAIConfigFromSettings(llm.Config{ + Provider: cfg.AIProvider, + APIKey: cfg.AIAPIKey, + BaseURL: cfg.AIBaseURL, + Model: cfg.AIModel, + }) + return server.New(serverCfg) } diff --git a/internal/server/investigate.go b/internal/server/investigate.go new file mode 100644 index 000000000..2631f20cf --- /dev/null +++ b/internal/server/investigate.go @@ -0,0 +1,357 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/skyhook-io/radar/internal/ai/investigate" + "github.com/skyhook-io/radar/internal/ai/llm" + "github.com/skyhook-io/radar/internal/settings" +) + +// AI provider management — package-level with mutex protection. +var ( + aiMu sync.RWMutex + aiProvider llm.Provider + aiConfig llm.Config +) + +// SetAIConfig updates the AI provider configuration and recreates the provider. +func SetAIConfig(cfg llm.Config) error { + aiMu.Lock() + defer aiMu.Unlock() + + if !cfg.IsConfigured() { + aiProvider = nil + aiConfig = cfg + return nil + } + + provider, err := llm.NewProvider(cfg) + if err != nil { + return err + } + + aiProvider = provider + aiConfig = cfg + return nil +} + +// GetAIProvider returns the current AI provider (nil if not configured). +func GetAIProvider() llm.Provider { + aiMu.RLock() + defer aiMu.RUnlock() + return aiProvider +} + +// GetAIConfig returns the current AI config. +func GetAIConfig() llm.Config { + aiMu.RLock() + defer aiMu.RUnlock() + return aiConfig +} + +// AIConfigResponse is returned by GET /api/ai/config. +type AIConfigResponse struct { + Provider string `json:"provider"` + BaseURL string `json:"baseUrl"` + Model string `json:"model"` + Configured bool `json:"configured"` +} + +// handleAIConfig returns the current AI configuration status. +// GET /api/ai/config +func (s *Server) handleAIConfig(w http.ResponseWriter, r *http.Request) { + cfg := GetAIConfig() + s.writeJSON(w, AIConfigResponse{ + Provider: cfg.Provider, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + Configured: cfg.IsConfigured(), + }) +} + +// AIConfigUpdateRequest is the body for PUT /api/ai/config. +type AIConfigUpdateRequest struct { + Provider string `json:"provider"` + APIKey string `json:"apiKey,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + Model string `json:"model,omitempty"` +} + +// handleUpdateAIConfig updates the AI configuration. +// PUT /api/ai/config +func (s *Server) handleUpdateAIConfig(w http.ResponseWriter, r *http.Request) { + var req AIConfigUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + cfg := llm.Config{ + Provider: req.Provider, + APIKey: req.APIKey, + BaseURL: req.BaseURL, + Model: req.Model, + } + + // If no API key provided, keep the existing one (UI may omit it for security) + if req.APIKey == "" { + existing := GetAIConfig() + if existing.Provider == req.Provider { + cfg.APIKey = existing.APIKey + } + } + + if err := SetAIConfig(cfg); err != nil { + s.writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Persist to settings file + if _, err := settings.Update(func(s *settings.Settings) { + s.AIProvider = cfg.Provider + s.AIBaseURL = cfg.BaseURL + s.AIAPIKey = cfg.APIKey + s.AIModel = cfg.Model + }); err != nil { + log.Printf("[ai] Failed to save settings: %v", err) + } + + s.writeJSON(w, AIConfigResponse{ + Provider: cfg.Provider, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + Configured: cfg.IsConfigured(), + }) +} + +// aiStreamWriter emits AI SDK UI Message Stream Protocol events as SSE. +type aiStreamWriter struct { + w http.ResponseWriter + flusher http.Flusher +} + +func (sw *aiStreamWriter) emit(event map[string]any) { + data, err := json.Marshal(event) + if err != nil { + log.Printf("[ai] Failed to marshal SSE event: %v", err) + return + } + fmt.Fprintf(sw.w, "data: %s\n\n", data) + sw.flusher.Flush() +} + +func (sw *aiStreamWriter) done() { + fmt.Fprintf(sw.w, "data: [DONE]\n\n") + sw.flusher.Flush() +} + +// investigateRequestBody supports both direct params and AI SDK useChat transport. +type investigateRequestBody struct { + Kind string `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Question string `json:"question,omitempty"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages,omitempty"` +} + +// handleInvestigate starts an AI investigation and streams progress via SSE. +// Emits the AI SDK UI Message Stream Protocol for compatibility with useChat. +// POST /api/ai/investigate +func (s *Server) handleInvestigate(w http.ResponseWriter, r *http.Request) { + if !s.requireConnected(w) { + return + } + + provider := GetAIProvider() + if provider == nil { + s.writeError(w, http.StatusBadRequest, "AI provider not configured. Set up an AI provider in Settings > AI.") + return + } + + var raw investigateRequestBody + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + s.writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + params := investigate.InvestigateParams{ + Kind: raw.Kind, + Namespace: raw.Namespace, + Name: raw.Name, + Question: raw.Question, + } + + // Extract question from latest user message if using useChat format + if params.Question == "" && len(raw.Messages) > 1 { + for i := len(raw.Messages) - 1; i >= 0; i-- { + if raw.Messages[i].Role == "user" { + params.Question = raw.Messages[i].Content + break + } + } + } + + if params.Kind == "" || params.Name == "" { + s.writeError(w, http.StatusBadRequest, "kind and name are required") + return + } + + // Set up SSE streaming with AI SDK UI Message Stream Protocol + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("x-vercel-ai-ui-message-stream", "v1") + + flusher, ok := w.(http.Flusher) + if !ok { + s.writeError(w, http.StatusInternalServerError, "streaming not supported") + return + } + + ctx := r.Context() + sw := &aiStreamWriter{w: w, flusher: flusher} + + messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano()) + sw.emit(map[string]any{"type": "start", "messageId": messageID}) + + engine := investigate.NewEngine(provider) + + textPartOpen := false + textCounter := 0 + currentTextID := "" + stepOpen := false + + engine.Investigate(ctx, params, func(event investigate.Event) { + switch event.Type { + case "status": + sw.emit(map[string]any{ + "type": "data-status", + "data": map[string]any{"content": event.Content}, + }) + + case "step_start": + // Close any open text part from the previous step + if textPartOpen { + sw.emit(map[string]any{"type": "text-end", "id": currentTextID}) + textPartOpen = false + } + if stepOpen { + sw.emit(map[string]any{"type": "finish-step"}) + } + sw.emit(map[string]any{"type": "start-step"}) + stepOpen = true + + case "tool_call": + // Close any open text part before tool events + if textPartOpen { + sw.emit(map[string]any{"type": "text-end", "id": currentTextID}) + textPartOpen = false + } + sw.emit(map[string]any{ + "type": "tool-input-start", + "toolCallId": event.ToolCallID, + "toolName": event.Tool, + "dynamic": true, + }) + var input any + if json.Unmarshal([]byte(event.Args), &input) != nil { + input = event.Args + } + sw.emit(map[string]any{ + "type": "tool-input-available", + "toolCallId": event.ToolCallID, + "toolName": event.Tool, + "input": input, + "dynamic": true, + }) + + case "tool_result": + var output any + if json.Unmarshal([]byte(event.Content), &output) != nil { + output = event.Content + } + sw.emit(map[string]any{ + "type": "tool-output-available", + "toolCallId": event.ToolCallID, + "output": output, + "dynamic": true, + }) + + case "analysis": + // Open a new text part if one isn't already open + if !textPartOpen { + textCounter++ + currentTextID = fmt.Sprintf("text-%d", textCounter) + sw.emit(map[string]any{"type": "text-start", "id": currentTextID}) + textPartOpen = true + } + sw.emit(map[string]any{ + "type": "text-delta", + "id": currentTextID, + "delta": event.Content, + }) + + case "error": + sw.emit(map[string]any{ + "type": "error", + "errorText": event.Content, + }) + + case "done": + if textPartOpen { + sw.emit(map[string]any{"type": "text-end", "id": currentTextID}) + } + if stepOpen { + sw.emit(map[string]any{"type": "finish-step"}) + } + sw.emit(map[string]any{"type": "finish"}) + sw.done() + } + }) +} + +// LoadAIConfigFromSettings loads AI config from the persisted settings file. +// Called at startup to restore previously saved configuration. +func LoadAIConfigFromSettings(cliCfg llm.Config) { + s := settings.Load() + + // CLI flags take precedence over saved settings + cfg := llm.Config{ + Provider: cliCfg.Provider, + APIKey: cliCfg.APIKey, + BaseURL: cliCfg.BaseURL, + Model: cliCfg.Model, + } + + // Fill in from settings where CLI didn't specify + if cfg.Provider == "" { + cfg.Provider = s.AIProvider + } + if cfg.APIKey == "" { + cfg.APIKey = s.AIAPIKey + } + if cfg.BaseURL == "" { + cfg.BaseURL = s.AIBaseURL + } + if cfg.Model == "" { + cfg.Model = s.AIModel + } + + if cfg.IsConfigured() { + if err := SetAIConfig(cfg); err != nil { + log.Printf("[ai] Failed to initialize AI provider from settings: %v", err) + } else { + log.Printf("[ai] AI provider initialized: %s (model: %s)", cfg.Provider, cfg.Model) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d38cee537..6aaf4f896 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -123,6 +123,7 @@ func (s *Server) setupRoutes() { r.Get("/pods/{namespace}/{name}/exec", s.handlePodExec) r.Get("/pods/{namespace}/{name}/files/download", s.handlePodFileDownload) r.Get("/workloads/{kind}/{namespace}/{name}/logs/stream", s.handleWorkloadLogsStream) + r.Post("/ai/investigate", s.handleInvestigate) // AI investigation SSE stream // All other API routes get a 60-second timeout r.Group(func(r chi.Router) { @@ -220,6 +221,10 @@ func (s *Server) setupRoutes() { r.Get("/ai/resources/{kind}", s.handleAIListResources) r.Get("/ai/resources/{kind}/{namespace}/{name}", s.handleAIGetResource) + // AI investigation configuration + r.Get("/ai/config", s.handleAIConfig) + r.Put("/ai/config", s.handleUpdateAIConfig) + // Debug routes (for event pipeline diagnostics) r.Get("/debug/events", s.handleDebugEvents) r.Get("/debug/events/diagnose", s.handleDebugEventsDiagnose) diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 2bfe36a22..b275bc434 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -19,6 +19,12 @@ type PinnedKind struct { type Settings struct { Theme string `json:"theme,omitempty"` PinnedKinds []PinnedKind `json:"pinnedKinds,omitempty"` + + // AI investigation provider configuration + AIProvider string `json:"aiProvider,omitempty"` // "openai" or "anthropic" + AIBaseURL string `json:"aiBaseUrl,omitempty"` // for OpenAI-compatible endpoints (Ollama, etc.) + AIAPIKey string `json:"aiApiKey,omitempty"` // API key (stored locally in ~/.radar/settings.json) + AIModel string `json:"aiModel,omitempty"` // model override (empty = provider default) } // mu serializes Load-Decode-Save cycles to prevent concurrent PUTs from diff --git a/web/package-lock.json b/web/package-lock.json index 437908dfe..0af89762c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,8 @@ "name": "radar", "version": "0.1.0", "dependencies": { + "@ai-sdk/react": "^3.0.107", + "@ai-sdk/ui-utils": "^1.2.11", "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/dm-mono": "^5.2.7", "@monaco-editor/react": "^4.7.0", @@ -18,6 +20,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.10.1", + "ai": "^6.0.105", "clsx": "^2.1.1", "diff": "^8.0.3", "elkjs": "^0.11.0", @@ -45,6 +48,116 @@ "vite": "^6.4.1" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.59", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.59.tgz", + "integrity": "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.16", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.16.tgz", + "integrity": "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "3.0.107", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.107.tgz", + "integrity": "sha512-IBuSTOFm3xNVH7rNv/IGPy4mUgM0y7PWw58hTPwL2Iat6gjIi9DquS3efHPbLSbgkvCmLuEgM2OjERzCT/3V2w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.16", + "ai": "6.0.105", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/ui-utils/node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/ui-utils/node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -829,6 +942,15 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -1277,6 +1399,12 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -1835,6 +1963,15 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -1909,6 +2046,24 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/ai": { + "version": "6.0.105", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.105.tgz", + "integrity": "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.59", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.16", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2381,6 +2536,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2591,6 +2755,12 @@ "node": ">=6" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4184,6 +4354,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4276,6 +4452,19 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -4305,6 +4494,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4670,6 +4871,25 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/web/package.json b/web/package.json index a5336be6c..cda0e7761 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,8 @@ "lint": "eslint src --ext ts,tsx" }, "dependencies": { + "@ai-sdk/react": "^3.0.107", + "@ai-sdk/ui-utils": "^1.2.11", "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/dm-mono": "^5.2.7", "@monaco-editor/react": "^4.7.0", @@ -22,6 +24,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.10.1", + "ai": "^6.0.105", "clsx": "^2.1.1", "diff": "^8.0.3", "elkjs": "^0.11.0", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d94c4e6e9..b80482f65 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2231,3 +2231,46 @@ export function useDiagnostics(enabled: boolean) { gcTime: 0, }) } + +// ============================================================================ +// AI Investigation +// ============================================================================ + +export interface AIConfig { + provider: string + baseUrl: string + model: string + configured: boolean +} + +export function useAIConfig() { + return useQuery({ + queryKey: ['ai-config'], + queryFn: () => fetchJSON('/ai/config'), + }) +} + +export function useUpdateAIConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (config: { provider: string; apiKey?: string; baseUrl?: string; model?: string }) => { + const response = await fetch(`${API_BASE}/ai/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new ApiError(err.error || `HTTP ${response.status}`, response.status) + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ai-config'] }) + }, + meta: { + errorMessage: 'Failed to update AI configuration', + successMessage: 'AI configuration updated', + }, + }) +} diff --git a/web/src/components/ai/AISettingsDialog.tsx b/web/src/components/ai/AISettingsDialog.tsx new file mode 100644 index 000000000..32b51d9c4 --- /dev/null +++ b/web/src/components/ai/AISettingsDialog.tsx @@ -0,0 +1,292 @@ +import { useState, useEffect, useRef, memo } from 'react' +import { createPortal } from 'react-dom' +import { X, Settings, Eye, EyeOff } from 'lucide-react' +import { clsx } from 'clsx' +import { useAIConfig, useUpdateAIConfig } from '../../api/client' +import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount' +import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation' + +interface AISettingsDialogProps { + open: boolean + onClose: () => void +} + +const PROVIDERS = [ + { value: 'anthropic', label: 'Anthropic' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'ollama', label: 'Ollama / Custom' }, +] as const + +const PROVIDER_MODELS: Record = { + anthropic: [ + { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 (fast, cheap)' }, + ], + openai: [ + { value: 'gpt-5-mini', label: 'GPT-5 Mini' }, + { value: 'gpt-5.2', label: 'GPT-5.2' }, + { value: 'gpt-5', label: 'GPT-5' }, + { value: 'gpt-5-nano', label: 'GPT-5 Nano (fast, cheap)' }, + ], + ollama: [ + { value: 'qwen3', label: 'Qwen 3' }, + { value: 'llama3.3', label: 'Llama 3.3' }, + { value: 'deepseek-r1', label: 'DeepSeek R1' }, + { value: 'mistral', label: 'Mistral' }, + ], +} + +const PROVIDER_DEFAULTS: Record = { + anthropic: { baseUrl: '', model: 'claude-sonnet-4-6' }, + openai: { baseUrl: '', model: 'gpt-5-mini' }, + ollama: { baseUrl: 'http://localhost:11434/v1', model: 'qwen3' }, +} + +export const AISettingsDialog = memo(function AISettingsDialog({ + open, + onClose, +}: AISettingsDialogProps) { + const dialogRef = useRef(null) + const { shouldRender, isOpen } = useAnimatedUnmount(open, 200) + const { data: config } = useAIConfig() + const updateConfig = useUpdateAIConfig() + + const [provider, setProvider] = useState('anthropic') + const [apiKey, setApiKey] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const [model, setModel] = useState('') + const [customModel, setCustomModel] = useState(false) + const [showKey, setShowKey] = useState(false) + + // Sync form state when config loads + useEffect(() => { + if (config?.configured) { + const p = config.provider || 'anthropic' + setProvider(p) + setBaseUrl(config.baseUrl || '') + const m = config.model || '' + setModel(m) + // Check if the saved model is in the known list + const knownModels = PROVIDER_MODELS[p] || [] + setCustomModel(m !== '' && !knownModels.some(km => km.value === m)) + } + }, [config]) + + // Handle ESC key + useEffect(() => { + if (!open) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [open, onClose]) + + // Focus trap + useEffect(() => { + if (open && dialogRef.current) { + dialogRef.current.focus() + } + }, [open]) + + const handleProviderChange = (newProvider: string) => { + setProvider(newProvider) + const defaults = PROVIDER_DEFAULTS[newProvider] + if (defaults) { + setBaseUrl(defaults.baseUrl) + setModel(defaults.model) + setCustomModel(false) + } + } + + const handleModelChange = (value: string) => { + if (value === '__custom__') { + setCustomModel(true) + setModel('') + } else { + setCustomModel(false) + setModel(value) + } + } + + const handleSave = () => { + updateConfig.mutate( + { + provider, + apiKey: apiKey || undefined, + baseUrl: baseUrl || undefined, + model: model || undefined, + }, + { onSuccess: () => { setApiKey(''); onClose() } } + ) + } + + const showBaseUrl = provider === 'openai' || provider === 'ollama' + const showApiKey = provider !== 'ollama' + const models = PROVIDER_MODELS[provider] || [] + + if (!shouldRender) return null + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+ +

AI Configuration

+
+ +
+ + {/* Form */} +
+ {/* Provider */} +
+ + +
+ + {/* API Key */} + {showApiKey && ( +
+ +
+ setApiKey(e.target.value)} + placeholder={config?.configured ? '(unchanged)' : 'sk-...'} + className="w-full bg-theme-base border border-theme-border rounded-md px-3 py-2 pr-10 text-sm text-theme-text-primary outline-none focus:border-purple-500 transition-colors font-mono" + /> + +
+

+ Stored locally in ~/.radar/settings.json. Never sent anywhere except the selected provider. +

+
+ )} + + {/* Base URL */} + {showBaseUrl && ( +
+ + setBaseUrl(e.target.value)} + placeholder={provider === 'ollama' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} + className="w-full bg-theme-base border border-theme-border rounded-md px-3 py-2 text-sm text-theme-text-primary outline-none focus:border-purple-500 transition-colors font-mono" + /> +
+ )} + + {/* Model */} +
+ + {!customModel ? ( + + ) : ( +
+ setModel(e.target.value)} + placeholder="Model name" + autoFocus + className="flex-1 bg-theme-base border border-theme-border rounded-md px-3 py-2 text-sm text-theme-text-primary outline-none focus:border-purple-500 transition-colors font-mono" + /> + +
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ) +}) diff --git a/web/src/components/ai/ChatMessage.tsx b/web/src/components/ai/ChatMessage.tsx new file mode 100644 index 000000000..10f134e0c --- /dev/null +++ b/web/src/components/ai/ChatMessage.tsx @@ -0,0 +1,75 @@ +import { memo } from 'react' +import { Markdown } from '../ui/Markdown' +import { ToolCall } from './ToolCall' +import type { UIMessage } from 'ai' + +interface ChatMessageProps { + message: UIMessage +} + +export const ChatMessage = memo(function ChatMessage({ + message, +}: ChatMessageProps) { + if (message.role === 'user') { + return ( +
+
+

+ {message.parts.map((part, i) => + part.type === 'text' ? {part.text} : null + )} +

+
+
+ ) + } + + // Assistant message — render parts + return ( +
+ {message.parts.map((part, i) => { + // Text parts + if (part.type === 'text') { + if (!part.text) return null + return ( +
+ {part.text} + {part.state === 'streaming' && ( + + )} +
+ ) + } + + // Dynamic tool calls (our tools are untyped) + if (part.type === 'dynamic-tool') { + return ( + + ) + } + + // Data parts (status events) — rendered by InvestigationPanel as a bottom indicator + if (typeof part.type === 'string' && part.type.startsWith('data-')) { + return null + } + + // Step start parts + if (part.type === 'step-start') { + return null // Invisible, just marks step boundaries + } + + return null + })} + +
+ ) +}) + diff --git a/web/src/components/ai/InvestigateButton.tsx b/web/src/components/ai/InvestigateButton.tsx new file mode 100644 index 000000000..fd04a90ae --- /dev/null +++ b/web/src/components/ai/InvestigateButton.tsx @@ -0,0 +1,79 @@ +import { useState, memo } from 'react' +import { Sparkles } from 'lucide-react' +import { useAIConfig } from '../../api/client' +import { InvestigationPanel } from './InvestigationPanel' +import { AISettingsDialog } from './AISettingsDialog' + +interface InvestigateButtonProps { + kind: string + namespace: string + name: string + variant?: 'icon' | 'button' + /** Called when AI is configured and button is clicked. + * If provided, no standalone panel is opened — caller handles the UI. */ + onInvestigate?: () => void +} + +export const InvestigateButton = memo(function InvestigateButton({ + kind, + namespace, + name, + variant = 'icon', + onInvestigate, +}: InvestigateButtonProps) { + const [open, setOpen] = useState(false) + const [hasOpened, setHasOpened] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const { data: config } = useAIConfig() + + const configured = config?.configured ?? false + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (configured) { + if (onInvestigate) { + onInvestigate() + } else { + setOpen(true) + setHasOpened(true) + } + } else { + setSettingsOpen(true) + } + } + + return ( + <> + {variant === 'icon' ? ( + + ) : ( + + )} + {hasOpened && !onInvestigate && ( + setOpen(false)} + /> + )} + {settingsOpen && ( + setSettingsOpen(false)} /> + )} + + ) +}) diff --git a/web/src/components/ai/InvestigationChat.tsx b/web/src/components/ai/InvestigationChat.tsx new file mode 100644 index 000000000..9bcd997c7 --- /dev/null +++ b/web/src/components/ai/InvestigationChat.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react' +import { + Sparkles, + Settings, + Square, + Loader2, + Send, + Copy, + Download, + Check, +} from 'lucide-react' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' +import { ChatMessage } from './ChatMessage' +import { AISettingsDialog } from './AISettingsDialog' +import { useExportInvestigation } from './useExportInvestigation' +import type { UIMessage } from 'ai' + +interface InvestigationChatProps { + kind: string + namespace: string + name: string +} + +export const InvestigationChat = memo(function InvestigationChat({ + kind, + namespace, + name, +}: InvestigationChatProps) { + const [settingsOpen, setSettingsOpen] = useState(false) + const [copied, setCopied] = useState(false) + const scrollRef = useRef(null) + const inputRef = useRef(null) + const autoTriggered = useRef(false) + + const { messages, sendMessage, status, stop } = useChat({ + transport: new DefaultChatTransport({ + api: '/api/ai/investigate', + prepareSendMessagesRequest: ({ messages: msgs, body }) => ({ + body: { + ...body, + kind, + namespace, + name, + ...(msgs.length > 1 + ? { question: msgs[msgs.length - 1]?.parts?.find(p => p.type === 'text')?.text } + : {}), + }, + }), + }), + }) + + const { copyToClipboard, downloadAsFile } = useExportInvestigation(messages) + + const isStreaming = status === 'streaming' || status === 'submitted' + const isComplete = status === 'ready' && messages.length > 0 + const hasAssistantMessages = messages.some(m => m.role === 'assistant') + + // Auto-scroll to bottom as new content arrives + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messages]) + + // Auto-trigger investigation on mount + useEffect(() => { + if (autoTriggered.current) return + autoTriggered.current = true + sendMessage({ text: `Investigate ${kind} ${namespace ? namespace + '/' : ''}${name}` }) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Focus input when investigation completes + useEffect(() => { + if (isComplete && inputRef.current) { + inputRef.current.focus() + } + }, [isComplete]) + + const [followUp, setFollowUp] = useState('') + + const handleFollowUp = useCallback(() => { + if (!followUp.trim()) return + sendMessage({ text: followUp.trim() }) + setFollowUp('') + }, [followUp, sendMessage]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleFollowUp() + } + }, + [handleFollowUp] + ) + + const handleCopy = useCallback(async () => { + await copyToClipboard() + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [copyToClipboard]) + + return ( +
+ {/* Header */} +
+
+ +

+ AI Investigation +

+
+ +
+ + {/* Messages */} +
+ {messages.map((msg) => ( + + ))} + {isStreaming && } +
+ + {/* Action bar — export buttons when complete */} + {isComplete && hasAssistantMessages && ( +
+ + +
+ )} + + {/* Footer — input or stop button */} +
+ {isStreaming ? ( + + ) : ( +
+ setFollowUp(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask a follow-up question..." + className="flex-1 bg-theme-base border border-theme-border rounded-md px-3 py-2 text-sm text-theme-text-primary outline-none focus:border-purple-500 transition-colors placeholder:text-theme-text-tertiary" + /> + +
+ )} +
+ + {settingsOpen && ( + setSettingsOpen(false)} /> + )} +
+ ) +}) + +// -- Streaming status indicator ----------------------------------------------- + +const TOOL_LABELS: Record = { + get_resource: 'Get Resource', + get_events: 'Get Events', + get_pod_logs: 'Get Pod Logs', + get_changes: 'Get Changes', + get_related_resources: 'Get Related Resources', + list_resources: 'List Resources', +} + +function getStreamingStatus(messages: UIMessage[]): string | null { + const lastMsg = [...messages].reverse().find(m => m.role === 'assistant') + if (!lastMsg || lastMsg.parts.length === 0) return 'Starting investigation...' + + for (let i = lastMsg.parts.length - 1; i >= 0; i--) { + const part = lastMsg.parts[i] + + if (part.type === 'text' && 'state' in part && part.state === 'streaming') { + return null + } + + if (part.type === 'dynamic-tool') { + if ('state' in part && part.state !== 'output-available') { + const label = TOOL_LABELS[part.toolName] ?? part.toolName + return `Running ${label}...` + } + return 'Analyzing findings...' + } + + if (typeof part.type === 'string' && part.type.startsWith('data-')) { + const data = 'data' in part ? (part.data as Record) : null + if (data?.content) return String(data.content) + } + + if (part.type === 'step-start') { + return 'Thinking...' + } + } + + return 'Thinking...' +} + +const StreamingStatus = memo(function StreamingStatus({ messages }: { messages: UIMessage[] }) { + const status = useMemo(() => getStreamingStatus(messages), [messages]) + if (!status) return null + + return ( +
+ + {status} +
+ ) +}) diff --git a/web/src/components/ai/InvestigationPanel.tsx b/web/src/components/ai/InvestigationPanel.tsx new file mode 100644 index 000000000..7f04a357a --- /dev/null +++ b/web/src/components/ai/InvestigationPanel.tsx @@ -0,0 +1,66 @@ +import { memo } from 'react' +import { createPortal } from 'react-dom' +import { clsx } from 'clsx' +import { X } from 'lucide-react' +import { InvestigationChat } from './InvestigationChat' +import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount' +import { TRANSITION_BACKDROP, TRANSITION_DRAWER } from '../../utils/animation' + +interface InvestigationPanelProps { + kind: string + namespace: string + name: string + isOpen: boolean + onClose: () => void +} + +/** + * Standalone portal-based investigation panel. + * Used from ResourceDetailPage, HomeView, and other non-drawer contexts. + * For the drawer context, InvestigationChat is rendered inline instead. + */ +export const InvestigationPanel = memo(function InvestigationPanel({ + kind, + namespace, + name, + isOpen, + onClose, +}: InvestigationPanelProps) { + const { shouldRender, isOpen: animIsOpen } = useAnimatedUnmount(isOpen, 300) + + if (!shouldRender) return null + + return createPortal( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Close button overlay in top-right corner */} + + + +
+ , + document.body + ) +}) diff --git a/web/src/components/ai/ToolCall.tsx b/web/src/components/ai/ToolCall.tsx new file mode 100644 index 000000000..be41863f0 --- /dev/null +++ b/web/src/components/ai/ToolCall.tsx @@ -0,0 +1,161 @@ +import { useState, memo } from 'react' +import { + ChevronRight, + Box, + Activity, + ScrollText, + GitCommit, + Network, + List, + Loader2, + CheckCircle2, + AlertCircle, +} from 'lucide-react' +import { clsx } from 'clsx' +import type { DynamicToolUIPart } from 'ai' + +const TOOL_ICONS: Record = { + get_resource: Box, + get_events: Activity, + get_pod_logs: ScrollText, + get_changes: GitCommit, + get_related_resources: Network, + list_resources: List, +} + +const TOOL_LABELS: Record = { + get_resource: 'Get Resource', + get_events: 'Get Events', + get_pod_logs: 'Get Pod Logs', + get_changes: 'Get Changes', + get_related_resources: 'Get Related Resources', + list_resources: 'List Resources', +} + +type ToolState = DynamicToolUIPart['state'] + +interface ToolCallProps { + toolName: string + toolCallId: string + state: ToolState + input?: unknown + output?: unknown + errorText?: string +} + +export const ToolCall = memo(function ToolCall({ + toolName, + state, + input, + output, + errorText, +}: ToolCallProps) { + const [expanded, setExpanded] = useState(false) + + const Icon = TOOL_ICONS[toolName] || Box + const label = TOOL_LABELS[toolName] || toolName + + const isRunning = state === 'input-streaming' || state === 'input-available' + const isComplete = state === 'output-available' + const isError = state === 'output-error' + + const hasContent = (isComplete && output != null) || (input != null) || isError + + return ( +
+ + + {/* Expandable content */} + {expanded && hasContent && ( +
+ {/* Input args */} + {input != null && ( +
+
+ Input +
+
+                {formatJSON(input)}
+              
+
+ )} + + {/* Output */} + {isComplete && output != null && ( +
+
+ Output +
+
+                {truncate(formatJSON(output), 3000)}
+              
+
+ )} + + {/* Error */} + {isError && errorText && ( +
+ + {errorText} +
+ )} +
+ )} +
+ ) +}) + +function formatJSON(value: unknown): string { + if (typeof value === 'string') { + try { + return JSON.stringify(JSON.parse(value), null, 2) + } catch { + return value + } + } + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s + return s.slice(0, max) + '\n...(truncated)' +} diff --git a/web/src/components/ai/useExportInvestigation.ts b/web/src/components/ai/useExportInvestigation.ts new file mode 100644 index 000000000..d73dba8e9 --- /dev/null +++ b/web/src/components/ai/useExportInvestigation.ts @@ -0,0 +1,66 @@ +import { useCallback } from 'react' +import type { UIMessage } from 'ai' + +/** + * Serializes investigation messages to markdown for clipboard copy and file download. + */ +export function useExportInvestigation(messages: UIMessage[]) { + const toMarkdown = useCallback(() => { + const lines: string[] = [] + + for (const msg of messages) { + if (msg.role === 'user') { + const text = msg.parts + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map(p => p.text) + .join('') + if (text) { + lines.push(`**User:** ${text}`) + lines.push('') + } + continue + } + + // Assistant message + for (const part of msg.parts) { + if (part.type === 'text' && part.text) { + lines.push(part.text) + lines.push('') + } + + if (part.type === 'dynamic-tool') { + lines.push(`> **Tool:** ${part.toolName}`) + if ('input' in part && part.input != null) { + const inputStr = typeof part.input === 'string' + ? part.input + : JSON.stringify(part.input, null, 2) + lines.push('> ```json') + lines.push(`> ${inputStr.split('\n').join('\n> ')}`) + lines.push('> ```') + } + lines.push('') + } + } + } + + return lines.join('\n').trim() + }, [messages]) + + const copyToClipboard = useCallback(async () => { + const md = toMarkdown() + await navigator.clipboard.writeText(md) + }, [toMarkdown]) + + const downloadAsFile = useCallback(() => { + const md = toMarkdown() + const blob = new Blob([md], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `investigation-${new Date().toISOString().slice(0, 10)}.md` + a.click() + URL.revokeObjectURL(url) + }, [toMarkdown]) + + return { copyToClipboard, downloadAsFile } +} diff --git a/web/src/components/home/HomeView.tsx b/web/src/components/home/HomeView.tsx index 89440ad29..e0a415c6c 100644 --- a/web/src/components/home/HomeView.tsx +++ b/web/src/components/home/HomeView.tsx @@ -10,6 +10,7 @@ import { CertificateHealthCard } from './CertificateHealthCard' import { CostCard } from './CostCard' import { ClusterHealthCard } from './ClusterHealthCard' import { AlertTriangle, Loader2 } from 'lucide-react' +import { InvestigateButton } from '../ai/InvestigateButton' import { clsx } from 'clsx' interface HomeViewProps { @@ -136,7 +137,7 @@ function ProblemsPanel({ problems, onResourceClick }: ProblemsPanelProps) {
{problems.map((p, i) => ( -
- + + +
))}
diff --git a/web/src/components/ui/Markdown.tsx b/web/src/components/ui/Markdown.tsx index 43dc47adf..da3eb6311 100644 --- a/web/src/components/ui/Markdown.tsx +++ b/web/src/components/ui/Markdown.tsx @@ -14,13 +14,13 @@ export function Markdown({ children, className }: MarkdownProps) { remarkPlugins={[remarkGfm]} components={{ h1: ({ children }) => ( -

{children}

+

{children}

), h2: ({ children }) => ( -

{children}

+

{children}

), h3: ({ children }) => ( -

{children}

+

{children}

), h4: ({ children }) => (

{children}

@@ -39,10 +39,10 @@ export function Markdown({ children, className }: MarkdownProps) { ), ul: ({ children }) => ( -
    {children}
+
    {children}
), ol: ({ children }) => ( -
    {children}
+
    {children}
), li: ({ children }) => (
  • {children}
  • diff --git a/web/src/components/workload/WorkloadView.tsx b/web/src/components/workload/WorkloadView.tsx index 69eb4aa91..6042241e6 100644 --- a/web/src/components/workload/WorkloadView.tsx +++ b/web/src/components/workload/WorkloadView.tsx @@ -47,6 +47,7 @@ import { LogsViewer } from '../logs/LogsViewer' import { getKindColor, formatKindName } from '../resources/drawer-components' import { useCanUpdateSecrets } from '../../contexts/CapabilitiesContext' import { useUpdateResource } from '../../api/client' +import { InvestigateButton } from '../ai/InvestigateButton' type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml' @@ -357,6 +358,7 @@ export function WorkloadView({ )}
    + {onExpand && (
    + + {/* Refresh */}