From 4c7878fb3f124e10aa8f391ca843b326ae3bd499 Mon Sep 17 00:00:00 2001 From: Zzde Date: Thu, 5 Mar 2026 21:24:37 +0800 Subject: [PATCH 1/2] fix ai agent Signed-off-by: Zzde --- pkg/ai/anthropic.go | 12 +- pkg/ai/openai.go | 12 +- pkg/ai/tools.go | 164 ++++++++++++++-- pkg/ai/tools_test.go | 57 ++++++ ui/src/components/ai-chat/ai-chatbox.tsx | 227 ++++++++++++++++++----- ui/src/hooks/use-ai-chat.ts | 17 +- ui/src/i18n/locales/en.json | 45 +++-- ui/src/i18n/locales/zh.json | 45 +++-- ui/src/styles/base.css | 36 ++++ 9 files changed, 509 insertions(+), 106 deletions(-) create mode 100644 pkg/ai/tools_test.go diff --git a/pkg/ai/anthropic.go b/pkg/ai/anthropic.go index a6468db6..f2be9711 100644 --- a/pkg/ai/anthropic.go +++ b/pkg/ai/anthropic.go @@ -36,7 +36,7 @@ func (a *Agent) processChatAnthropic(c *gin.Context, req *ChatRequest, sendEvent messages := toAnthropicMessages(req.Messages) tools := AnthropicToolDefs() - maxIterations := 10 + maxIterations := 100 for i := 0; i < maxIterations; i++ { stream := a.anthropicClient.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ Model: anthropic.Model(a.model), @@ -91,8 +91,9 @@ func (a *Agent) processChatAnthropic(c *gin.Context, req *ChatRequest, sendEvent sendEvent(SSEEvent{ Event: "tool_result", Data: map[string]interface{}{ - "tool": toolName, - "result": result, + "tool": toolName, + "result": result, + "is_error": true, }, }) toolResults = append(toolResults, anthropic.NewToolResultBlock(tc.ID, "Tool error: "+result, true)) @@ -113,8 +114,9 @@ func (a *Agent) processChatAnthropic(c *gin.Context, req *ChatRequest, sendEvent sendEvent(SSEEvent{ Event: "tool_result", Data: map[string]interface{}{ - "tool": toolName, - "result": result, + "tool": toolName, + "result": result, + "is_error": isError, }, }) diff --git a/pkg/ai/openai.go b/pkg/ai/openai.go index 79203d98..800c0695 100644 --- a/pkg/ai/openai.go +++ b/pkg/ai/openai.go @@ -40,7 +40,7 @@ func (a *Agent) processChatOpenAI(c *gin.Context, req *ChatRequest, sendEvent fu tools := OpenAIToolDefs() - maxIterations := 10 + maxIterations := 100 for i := 0; i < maxIterations; i++ { stream := a.openaiClient.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ Model: a.model, @@ -99,8 +99,9 @@ func (a *Agent) processChatOpenAI(c *gin.Context, req *ChatRequest, sendEvent fu sendEvent(SSEEvent{ Event: "tool_result", Data: map[string]interface{}{ - "tool": toolName, - "result": result, + "tool": toolName, + "result": result, + "is_error": true, }, }) messages = append(messages, openai.ToolMessage("Tool error: "+result, tc.ID)) @@ -121,8 +122,9 @@ func (a *Agent) processChatOpenAI(c *gin.Context, req *ChatRequest, sendEvent fu sendEvent(SSEEvent{ Event: "tool_result", Data: map[string]interface{}{ - "tool": toolName, - "result": result, + "tool": toolName, + "result": result, + "is_error": isError, }, }) diff --git a/pkg/ai/tools.go b/pkg/ai/tools.go index 562e549c..9889f0dd 100644 --- a/pkg/ai/tools.go +++ b/pkg/ai/tools.go @@ -18,10 +18,12 @@ import ( "github.com/zxh326/kite/pkg/rbac" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/discovery" "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" @@ -226,7 +228,7 @@ type resourceInfo struct { ClusterScoped bool } -func resolveResourceInfo(kind string) resourceInfo { +func resolveStaticResourceInfo(kind string) resourceInfo { switch strings.ToLower(strings.TrimSpace(kind)) { case "pod", "pods": return resourceInfo{Kind: "Pod", Resource: "pods", Version: "v1"} @@ -264,6 +266,8 @@ func resolveResourceInfo(kind string) resourceInfo { return resourceInfo{Kind: "NetworkPolicy", Resource: "networkpolicies", Group: "networking.k8s.io", Version: "v1"} case "storageclass", "storageclasses", "sc": return resourceInfo{Kind: "StorageClass", Resource: "storageclasses", Group: "storage.k8s.io", Version: "v1", ClusterScoped: true} + case "customresourcedefinition", "customresourcedefinitions", "crd", "crds": + return resourceInfo{Kind: "CustomResourceDefinition", Resource: "customresourcedefinitions", Group: "apiextensions.k8s.io", Version: "v1", ClusterScoped: true} case "event", "events": return resourceInfo{Kind: "Event", Resource: "events", Version: "v1"} default: @@ -284,6 +288,131 @@ func resolveResourceInfo(kind string) resourceInfo { } } +func resolveResourceInfo(ctx context.Context, cs *cluster.ClientSet, kind string) resourceInfo { + if info, ok := resolveResourceInfoFromDiscovery(ctx, cs, kind, ""); ok { + return info + } + return resolveStaticResourceInfo(kind) +} + +func resolveResourceInfoForObject(ctx context.Context, cs *cluster.ClientSet, obj *unstructured.Unstructured) resourceInfo { + if info, ok := resolveResourceInfoFromDiscovery(ctx, cs, obj.GetKind(), obj.GetAPIVersion()); ok { + return info + } + return resolveStaticResourceInfo(obj.GetKind()) +} + +func resolveResourceInfoFromDiscovery(ctx context.Context, cs *cluster.ClientSet, kind, apiVersion string) (resourceInfo, bool) { + input := strings.ToLower(strings.TrimSpace(kind)) + if input == "" || cs == nil || cs.K8sClient == nil || cs.K8sClient.ClientSet == nil { + return resourceInfo{}, false + } + if ctx != nil { + select { + case <-ctx.Done(): + return resourceInfo{}, false + default: + } + } + discoveryClient := cs.K8sClient.ClientSet.Discovery() + + if gv, ok := parseGroupVersion(apiVersion); ok { + resourceList, err := discoveryClient.ServerResourcesForGroupVersion(gv.String()) + if err != nil { + klog.V(2).Infof("AI tool discovery failed for %s: %v", gv.String(), err) + } else if info, found := findResourceInfoInList(input, gv, resourceList.APIResources); found { + return info, true + } + } + + resourceLists, err := discoveryClient.ServerPreferredResources() + if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { + klog.V(2).Infof("AI tool preferred discovery failed: %v", err) + return resourceInfo{}, false + } + + for _, resourceList := range resourceLists { + if resourceList == nil { + continue + } + gv, err := schema.ParseGroupVersion(resourceList.GroupVersion) + if err != nil { + continue + } + if info, found := findResourceInfoInList(input, gv, resourceList.APIResources); found { + return info, true + } + } + + return resourceInfo{}, false +} + +func parseGroupVersion(apiVersion string) (schema.GroupVersion, bool) { + apiVersion = strings.TrimSpace(apiVersion) + if apiVersion == "" { + return schema.GroupVersion{}, false + } + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersion{}, false + } + return gv, true +} + +func findResourceInfoInList(input string, gv schema.GroupVersion, apiResources []metav1.APIResource) (resourceInfo, bool) { + group := strings.ToLower(gv.Group) + for _, apiResource := range apiResources { + if strings.Contains(apiResource.Name, "/") { + continue + } + if !resourceMatchesInput(input, group, apiResource) { + continue + } + return resourceInfo{ + Kind: apiResource.Kind, + Resource: apiResource.Name, + Group: gv.Group, + Version: gv.Version, + ClusterScoped: !apiResource.Namespaced, + }, true + } + return resourceInfo{}, false +} + +func resourceMatchesInput(input, group string, apiResource metav1.APIResource) bool { + candidates := make([]string, 0, 3+len(apiResource.ShortNames)) + if kind := strings.ToLower(strings.TrimSpace(apiResource.Kind)); kind != "" { + candidates = append(candidates, kind) + } + if name := strings.ToLower(strings.TrimSpace(apiResource.Name)); name != "" { + candidates = append(candidates, name) + } + if singular := strings.ToLower(strings.TrimSpace(apiResource.SingularName)); singular != "" { + candidates = append(candidates, singular) + } + for _, shortName := range apiResource.ShortNames { + if shortName = strings.ToLower(strings.TrimSpace(shortName)); shortName != "" { + candidates = append(candidates, shortName) + } + } + + for _, candidate := range candidates { + if input == candidate { + return true + } + if !strings.HasSuffix(candidate, "s") && input == candidate+"s" { + return true + } + if group != "" && input == candidate+"."+group { + return true + } + if group != "" && !strings.HasSuffix(candidate, "s") && input == candidate+"s."+group { + return true + } + } + return false +} + func (r resourceInfo) GVK() schema.GroupVersionKind { return schema.GroupVersionKind{Group: r.Group, Version: r.Version, Kind: r.Kind} } @@ -299,8 +428,7 @@ func normalizeNamespace(r resourceInfo, namespace string) string { return namespace } -func buildObjectForKind(kind string) *unstructured.Unstructured { - resource := resolveResourceInfo(kind) +func buildObjectForResource(resource resourceInfo) *unstructured.Unstructured { obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(resource.GVK()) return obj @@ -356,7 +484,7 @@ func permissionNamespace(resource resourceInfo, namespace string) string { return namespace } -func requiredToolPermissions(toolName string, args map[string]interface{}) ([]toolPermission, error) { +func requiredToolPermissions(ctx context.Context, cs *cluster.ClientSet, toolName string, args map[string]interface{}) ([]toolPermission, error) { switch toolName { case "get_resource": kind, err := getRequiredString(args, "kind") @@ -364,7 +492,7 @@ func requiredToolPermissions(toolName string, args map[string]interface{}) ([]to return nil, err } namespace, _ := args["namespace"].(string) - resource := resolveResourceInfo(kind) + resource := resolveResourceInfo(ctx, cs, kind) return []toolPermission{{ Resource: resource.Resource, Verb: string(common.VerbGet), @@ -376,7 +504,7 @@ func requiredToolPermissions(toolName string, args map[string]interface{}) ([]to return nil, err } namespace, _ := args["namespace"].(string) - resource := resolveResourceInfo(kind) + resource := resolveResourceInfo(ctx, cs, kind) return []toolPermission{{ Resource: resource.Resource, Verb: string(common.VerbGet), @@ -407,7 +535,7 @@ func requiredToolPermissions(toolName string, args map[string]interface{}) ([]to if err != nil { return nil, err } - resource := resolveResourceInfo(obj.GetKind()) + resource := resolveResourceInfoForObject(ctx, cs, obj) return []toolPermission{{ Resource: resource.Resource, Verb: string(common.VerbCreate), @@ -418,7 +546,7 @@ func requiredToolPermissions(toolName string, args map[string]interface{}) ([]to if err != nil { return nil, err } - resource := resolveResourceInfo(obj.GetKind()) + resource := resolveResourceInfoForObject(ctx, cs, obj) return []toolPermission{{ Resource: resource.Resource, Verb: string(common.VerbUpdate), @@ -433,7 +561,7 @@ func requiredToolPermissions(toolName string, args map[string]interface{}) ([]to return nil, err } namespace, _ := args["namespace"].(string) - resource := resolveResourceInfo(kind) + resource := resolveResourceInfo(ctx, cs, kind) return []toolPermission{{ Resource: resource.Resource, Verb: string(common.VerbUpdate), @@ -448,7 +576,7 @@ func requiredToolPermissions(toolName string, args map[string]interface{}) ([]to return nil, err } namespace, _ := args["namespace"].(string) - resource := resolveResourceInfo(kind) + resource := resolveResourceInfo(ctx, cs, kind) return []toolPermission{{ Resource: resource.Resource, Verb: string(common.VerbDelete), @@ -480,7 +608,7 @@ func AuthorizeTool(c *gin.Context, cs *cluster.ClientSet, toolName string, args return "Error: authenticated user not found in context", true } - permissions, err := requiredToolPermissions(toolName, args) + permissions, err := requiredToolPermissions(c.Request.Context(), cs, toolName, args) if err != nil { return "Error: " + err.Error(), true } @@ -533,8 +661,8 @@ func executeGetResource(ctx context.Context, cs *cluster.ClientSet, args map[str } namespace, _ := args["namespace"].(string) - resource := resolveResourceInfo(kind) - obj := buildObjectForKind(kind) + resource := resolveResourceInfo(ctx, cs, kind) + obj := buildObjectForResource(resource) key := k8stypes.NamespacedName{ Name: name, Namespace: normalizeNamespace(resource, namespace), @@ -596,7 +724,7 @@ func executeListResources(ctx context.Context, cs *cluster.ClientSet, args map[s namespace, _ := args["namespace"].(string) labelSelector, _ := args["label_selector"].(string) - resource := resolveResourceInfo(kind) + resource := resolveResourceInfo(ctx, cs, kind) namespace = normalizeNamespace(resource, namespace) list := &unstructured.UnstructuredList{} list.SetGroupVersionKind(resource.ListGVK()) @@ -1072,8 +1200,8 @@ func executePatchResource(ctx context.Context, cs *cluster.ClientSet, args map[s return "Error: patch must be valid JSON", true } - resource := resolveResourceInfo(kind) - obj := buildObjectForKind(kind) + resource := resolveResourceInfo(ctx, cs, kind) + obj := buildObjectForResource(resource) key := k8stypes.NamespacedName{ Name: name, @@ -1104,8 +1232,8 @@ func executeDeleteResource(ctx context.Context, cs *cluster.ClientSet, args map[ } namespace, _ := args["namespace"].(string) - resource := resolveResourceInfo(kind) - obj := buildObjectForKind(kind) + resource := resolveResourceInfo(ctx, cs, kind) + obj := buildObjectForResource(resource) key := k8stypes.NamespacedName{ Name: name, diff --git a/pkg/ai/tools_test.go b/pkg/ai/tools_test.go new file mode 100644 index 00000000..a2fa321c --- /dev/null +++ b/pkg/ai/tools_test.go @@ -0,0 +1,57 @@ +package ai + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestResolveStaticResourceInfoCRD(t *testing.T) { + info := resolveStaticResourceInfo("crds") + if info.Kind != "CustomResourceDefinition" { + t.Fatalf("unexpected kind: %s", info.Kind) + } + if info.Resource != "customresourcedefinitions" { + t.Fatalf("unexpected resource: %s", info.Resource) + } + if info.Group != "apiextensions.k8s.io" { + t.Fatalf("unexpected group: %s", info.Group) + } + if info.Version != "v1" { + t.Fatalf("unexpected version: %s", info.Version) + } + if !info.ClusterScoped { + t.Fatalf("expected cluster scoped") + } +} + +func TestResourceMatchesInputCRDVariants(t *testing.T) { + resource := metav1.APIResource{ + Name: "customresourcedefinitions", + SingularName: "customresourcedefinition", + Namespaced: false, + Kind: "CustomResourceDefinition", + ShortNames: []string{"crd"}, + } + + cases := []string{ + "crd", + "crds", + "customresourcedefinition", + "customresourcedefinitions", + "customresourcedefinition.apiextensions.k8s.io", + "customresourcedefinitions.apiextensions.k8s.io", + "crd.apiextensions.k8s.io", + "crds.apiextensions.k8s.io", + } + + for _, input := range cases { + if !resourceMatchesInput(input, "apiextensions.k8s.io", resource) { + t.Fatalf("expected match for input %s", input) + } + } + + if resourceMatchesInput("crd.apps", "apiextensions.k8s.io", resource) { + t.Fatalf("expected no match for crd.apps") + } +} diff --git a/ui/src/components/ai-chat/ai-chatbox.tsx b/ui/src/components/ai-chat/ai-chatbox.tsx index bd2c3143..71179712 100644 --- a/ui/src/components/ai-chat/ai-chatbox.tsx +++ b/ui/src/components/ai-chat/ai-chatbox.tsx @@ -20,6 +20,7 @@ import remarkGfm from 'remark-gfm' import { useAIStatus } from '@/lib/api' import { ChatMessage, useAIChat } from '@/hooks/use-ai-chat' +import { useIsMobile } from '@/hooks/use-mobile' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { @@ -29,9 +30,11 @@ import { } from '@/components/ui/tooltip' const MIN_HEIGHT = 200 -const DEFAULT_HEIGHT = 500 +const DESKTOP_DEFAULT_HEIGHT_RATIO = 0.62 const MIN_WIDTH = 320 const DEFAULT_WIDTH = 420 +const DESKTOP_MARGIN = 16 +const MOBILE_DEFAULT_HEIGHT_RATIO = 0.62 /** Build a human-readable summary from tool name + args. */ function describeAction(tool: string, args: Record): string { @@ -246,10 +249,15 @@ function MessageBubble({ } function SuggestedPrompts({ - page, + pageContext, onSelect, }: { - page: string + pageContext: { + page: string + namespace: string + resourceName: string + resourceKind: string + } onSelect: (prompt: string) => void }) { const { t } = useTranslation() @@ -258,26 +266,61 @@ function SuggestedPrompts({ overview: [ 'aiChat.suggestedPrompts.overview.clusterHealth', 'aiChat.suggestedPrompts.overview.errorPods', - 'aiChat.suggestedPrompts.overview.listNamespaces', + 'aiChat.suggestedPrompts.overview.namespaceSummary', ], 'pod-detail': [ - 'aiChat.suggestedPrompts.podDetail.showLogs', - 'aiChat.suggestedPrompts.podDetail.status', - 'aiChat.suggestedPrompts.podDetail.issues', + 'aiChat.suggestedPrompts.podDetail.rootCause', + 'aiChat.suggestedPrompts.podDetail.riskCheck', + 'aiChat.suggestedPrompts.podDetail.troubleshoot', ], 'deployment-detail': [ - 'aiChat.suggestedPrompts.deploymentDetail.rolloutStatus', - 'aiChat.suggestedPrompts.deploymentDetail.runningReplicas', + 'aiChat.suggestedPrompts.deploymentDetail.releaseCheck', + 'aiChat.suggestedPrompts.deploymentDetail.replicaGap', 'aiChat.suggestedPrompts.deploymentDetail.recentEvents', ], + 'node-detail': [ + 'aiChat.suggestedPrompts.nodeDetail.health', + 'aiChat.suggestedPrompts.nodeDetail.workloadRisk', + 'aiChat.suggestedPrompts.nodeDetail.actions', + ], + detail: [ + 'aiChat.suggestedPrompts.detail.summary', + 'aiChat.suggestedPrompts.detail.anomaly', + 'aiChat.suggestedPrompts.detail.nextSteps', + ], + list: [ + 'aiChat.suggestedPrompts.list.anomalies', + 'aiChat.suggestedPrompts.list.namespaceHotspots', + 'aiChat.suggestedPrompts.list.nextActions', + ], default: [ - 'aiChat.suggestedPrompts.default.clusterOverview', - 'aiChat.suggestedPrompts.default.listPods', - 'aiChat.suggestedPrompts.default.issues', + 'aiChat.suggestedPrompts.default.healthCheck', + 'aiChat.suggestedPrompts.default.workloadIssues', + 'aiChat.suggestedPrompts.default.runbook', ], } - const pagePrompts = prompts[page] || prompts['default'] + const promptSetKey = + prompts[pageContext.page] != null + ? pageContext.page + : pageContext.page.endsWith('-detail') + ? 'detail' + : pageContext.page.endsWith('-list') + ? 'list' + : 'default' + + const templateValues = { + resourceKind: + pageContext.resourceKind || + t('aiChat.suggestedPrompts.fallback.resource'), + resourceName: + pageContext.resourceName || + t('aiChat.suggestedPrompts.fallback.resource'), + namespace: + pageContext.namespace || t('aiChat.suggestedPrompts.fallback.namespace'), + } + + const pagePrompts = prompts[promptSetKey] return (
@@ -290,9 +333,9 @@ function SuggestedPrompts({ ))}
@@ -302,6 +345,7 @@ function SuggestedPrompts({ export function AIChatbox() { const { i18n, t } = useTranslation() + const isMobile = useIsMobile() const { isOpen, isMinimized, @@ -321,7 +365,12 @@ export function AIChatbox() { } = useAIChat() const [input, setInput] = useState('') - const [height, setHeight] = useState(DEFAULT_HEIGHT) + const [height, setHeight] = useState(() => + Math.round( + (window.visualViewport?.height ?? window.innerHeight) * + DESKTOP_DEFAULT_HEIGHT_RATIO + ) + ) const [width, setWidth] = useState(DEFAULT_WIDTH) const { data: { enabled: aiEnabled } = { enabled: false } } = useAIStatus() const messagesEndRef = useRef(null) @@ -333,6 +382,56 @@ export function AIChatbox() { const startX = useRef(0) const startW = useRef(0) + const getViewportSize = useCallback(() => { + return { + width: window.visualViewport?.width ?? window.innerWidth, + height: window.visualViewport?.height ?? window.innerHeight, + } + }, []) + + const [{ width: viewportWidth, height: viewportHeight }, setViewportSize] = + useState(() => getViewportSize()) + + const getDesktopBounds = useCallback((vw: number, vh: number) => { + const maxWidth = Math.max(MIN_WIDTH, Math.min(720, vw - DESKTOP_MARGIN)) + const minWidth = Math.min(MIN_WIDTH, maxWidth) + const maxHeight = Math.max(MIN_HEIGHT, vh * 0.85) + const minHeight = Math.min(MIN_HEIGHT, maxHeight) + return { minWidth, maxWidth, minHeight, maxHeight } + }, []) + + useEffect(() => { + const updateViewport = () => setViewportSize(getViewportSize()) + updateViewport() + window.addEventListener('resize', updateViewport) + window.visualViewport?.addEventListener('resize', updateViewport) + return () => { + window.removeEventListener('resize', updateViewport) + window.visualViewport?.removeEventListener('resize', updateViewport) + } + }, [getViewportSize]) + + useEffect(() => { + if (isMobile) return + const bounds = getDesktopBounds(viewportWidth, viewportHeight) + setWidth((prev) => + Math.min(bounds.maxWidth, Math.max(bounds.minWidth, prev)) + ) + setHeight((prev) => + Math.min(bounds.maxHeight, Math.max(bounds.minHeight, prev)) + ) + }, [getDesktopBounds, isMobile, viewportHeight, viewportWidth]) + + const desktopBounds = getDesktopBounds(viewportWidth, viewportHeight) + const desktopWidth = Math.min( + desktopBounds.maxWidth, + Math.max(desktopBounds.minWidth, width) + ) + const desktopHeight = Math.min( + desktopBounds.maxHeight, + Math.max(desktopBounds.minHeight, height) + ) + // Scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -371,24 +470,30 @@ export function AIChatbox() { const onPointerDown = useCallback( (e: React.PointerEvent) => { - if (isMinimized) return + if (isMinimized || isMobile) return heightDragging.current = true startY.current = e.clientY startH.current = height ;(e.target as HTMLElement).setPointerCapture(e.pointerId) }, - [height, isMinimized] + [height, isMinimized, isMobile] ) - const onPointerMove = useCallback((e: React.PointerEvent) => { - if (!heightDragging.current) return - const maxHeight = window.innerHeight * 0.8 - const newH = Math.min( - maxHeight, - Math.max(MIN_HEIGHT, startH.current + (startY.current - e.clientY)) - ) - setHeight(newH) - }, []) + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!heightDragging.current || isMobile) return + const { minHeight, maxHeight } = getDesktopBounds( + window.innerWidth, + window.innerHeight + ) + const newH = Math.min( + maxHeight, + Math.max(minHeight, startH.current + (startY.current - e.clientY)) + ) + setHeight(newH) + }, + [getDesktopBounds, isMobile] + ) const onPointerUp = useCallback(() => { heightDragging.current = false @@ -396,24 +501,30 @@ export function AIChatbox() { const onWidthPointerDown = useCallback( (e: React.PointerEvent) => { - if (isMinimized) return + if (isMinimized || isMobile) return widthDragging.current = true startX.current = e.clientX startW.current = width ;(e.target as HTMLElement).setPointerCapture(e.pointerId) }, - [isMinimized, width] + [isMinimized, isMobile, width] ) - const onWidthPointerMove = useCallback((e: React.PointerEvent) => { - if (!widthDragging.current) return - const maxWidth = window.innerWidth * 0.4 - const newW = Math.min( - maxWidth, - Math.max(MIN_WIDTH, startW.current + (startX.current - e.clientX)) - ) - setWidth(newW) - }, []) + const onWidthPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!widthDragging.current || isMobile) return + const { minWidth, maxWidth } = getDesktopBounds( + window.innerWidth, + window.innerHeight + ) + const newW = Math.min( + maxWidth, + Math.max(minWidth, startW.current + (startX.current - e.clientX)) + ) + setWidth(newW) + }, + [getDesktopBounds, isMobile] + ) const onWidthPointerUp = useCallback(() => { widthDragging.current = false @@ -431,6 +542,9 @@ export function AIChatbox() { className="fixed bottom-6 right-6 z-50 h-12 w-12 rounded-full shadow-lg" size="icon" onClick={openChat} + style={{ + bottom: `calc(env(safe-area-inset-bottom, 0px) + 1.5rem)`, + }} > @@ -442,14 +556,25 @@ export function AIChatbox() { return (
{/* Resize handle */} - {!isMinimized && ( + {!isMinimized && !isMobile && (
)} - {!isMinimized && ( + {!isMinimized && !isMobile && (
{messages.length === 0 ? ( { setInput(prompt) setTimeout(() => inputRef.current?.focus(), 50) @@ -554,9 +679,13 @@ export function AIChatbox() { ))} {isLoading && !messages.find((m) => m.role === 'tool' && !m.toolResult) && ( -
- - Thinking... +
+ + + + + +
)}
diff --git a/ui/src/hooks/use-ai-chat.ts b/ui/src/hooks/use-ai-chat.ts index 6d989317..88c2aba4 100644 --- a/ui/src/hooks/use-ai-chat.ts +++ b/ui/src/hooks/use-ai-chat.ts @@ -182,11 +182,22 @@ export function useAIChat() { break } case 'tool_result': { - const { tool, result } = data as { tool: string; result: string } + const { tool, result, is_error } = data as { + tool: string + result: unknown + is_error?: boolean + } + const toolResult = + typeof result === 'string' ? result : JSON.stringify(result ?? '') + const inferredError = + typeof is_error === 'boolean' + ? is_error + : /^(error:|forbidden:|tool error:)/i.test(toolResult.trim()) updateLatestToolMessage(tool, (message) => ({ ...message, - content: `${tool} completed`, - toolResult: result, + content: `${tool} ${inferredError ? 'failed' : 'completed'}`, + toolResult, + actionStatus: inferredError ? 'error' : 'confirmed', })) break } diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index ff7b9d6f..a2bc32f1 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -643,26 +643,45 @@ "aiChat": { "disclaimer": "AI may make mistakes. Please verify carefully before use.", "suggestedPrompts": { - "hint": "Ask me anything about your cluster", + "hint": "Start with a focused check", + "fallback": { + "resource": "this resource", + "namespace": "this namespace" + }, "overview": { - "clusterHealth": "What is the overall cluster health?", - "errorPods": "Are there any pods in error state?", - "listNamespaces": "List all namespaces" + "clusterHealth": "Run a cluster health check and highlight top risks", + "errorPods": "List error pods with probable causes and priority", + "namespaceSummary": "Summarize health and anomalies by namespace" }, "podDetail": { - "showLogs": "Show me the logs for this pod", - "status": "What is the status of this pod?", - "issues": "Are there any issues with this pod?" + "rootCause": "Analyze {{resourceKind}}/{{resourceName}} using status, events, and logs", + "riskCheck": "Check restart, OOM, and probe failures for {{resourceKind}}/{{resourceName}}", + "troubleshoot": "Give a prioritized troubleshooting path for {{resourceKind}}/{{resourceName}} in {{namespace}}" }, "deploymentDetail": { - "rolloutStatus": "What is the rollout status?", - "runningReplicas": "How many replicas are running?", - "recentEvents": "Show recent events" + "releaseCheck": "Check release progress and rollout blockers for {{resourceKind}}/{{resourceName}}", + "replicaGap": "Explain desired vs available replica gap for {{resourceKind}}/{{resourceName}}", + "recentEvents": "Review recent events for {{resourceKind}}/{{resourceName}} and suggest fixes" + }, + "nodeDetail": { + "health": "Check node health, pressure conditions, and critical alerts", + "workloadRisk": "Analyze workload risks on {{resourceKind}}/{{resourceName}}", + "actions": "Provide prioritized remediation actions for {{resourceKind}}/{{resourceName}}" + }, + "detail": { + "summary": "Summarize current status and key conditions for {{resourceKind}}/{{resourceName}}", + "anomaly": "Identify anomalies and likely root causes for {{resourceKind}}/{{resourceName}}", + "nextSteps": "Give minimal-impact next actions for {{resourceKind}}/{{resourceName}} in {{namespace}}" + }, + "list": { + "anomalies": "Find abnormal resources on this page and sort by impact", + "namespaceHotspots": "Highlight namespaces with the most issues on this page", + "nextActions": "Give a short action list for the current resource list" }, "default": { - "clusterOverview": "Show cluster overview", - "listPods": "List all pods", - "issues": "Are there any issues?" + "healthCheck": "Run a quick cluster check and list top 3 risks", + "workloadIssues": "List problematic workloads and probable root causes", + "runbook": "Provide a prioritized troubleshooting runbook" } } }, diff --git a/ui/src/i18n/locales/zh.json b/ui/src/i18n/locales/zh.json index 53c8ab8b..7d134ffe 100644 --- a/ui/src/i18n/locales/zh.json +++ b/ui/src/i18n/locales/zh.json @@ -607,26 +607,45 @@ "aiChat": { "disclaimer": "AI 可能会出错,使用时请仔细确认。", "suggestedPrompts": { - "hint": "你可以问我任何与集群相关的问题", + "hint": "从一个明确的检查开始", + "fallback": { + "resource": "当前资源", + "namespace": "当前命名空间" + }, "overview": { - "clusterHealth": "当前集群整体健康状态怎么样?", - "errorPods": "是否有处于错误状态的 Pod?", - "listNamespaces": "列出所有命名空间" + "clusterHealth": "做一次集群健康检查,并标出最高风险项", + "errorPods": "列出处于异常状态的 Pod,并给出可能原因和优先级", + "namespaceSummary": "按命名空间汇总健康度和异常情况" }, "podDetail": { - "showLogs": "查看这个 Pod 的日志", - "status": "这个 Pod 的状态怎么样?", - "issues": "这个 Pod 是否存在问题?" + "rootCause": "结合状态、事件和日志分析 {{resourceKind}}/{{resourceName}} 的根因", + "riskCheck": "检查 {{resourceKind}}/{{resourceName}} 的重启、OOM 和探针失败风险", + "troubleshoot": "给出 {{namespace}} 中 {{resourceKind}}/{{resourceName}} 的优先级排障路径" }, "deploymentDetail": { - "rolloutStatus": "当前 rollout 状态如何?", - "runningReplicas": "现在有多少副本在运行?", - "recentEvents": "查看最近事件" + "releaseCheck": "检查 {{resourceKind}}/{{resourceName}} 的发布进度和阻塞点", + "replicaGap": "分析 {{resourceKind}}/{{resourceName}} 期望副本与可用副本差异", + "recentEvents": "梳理 {{resourceKind}}/{{resourceName}} 最近事件并给出修复建议" + }, + "nodeDetail": { + "health": "检查节点健康状态、资源压力和关键告警", + "workloadRisk": "分析 {{resourceKind}}/{{resourceName}} 上工作负载风险", + "actions": "给出 {{resourceKind}}/{{resourceName}} 的优先级修复动作" + }, + "detail": { + "summary": "汇总 {{resourceKind}}/{{resourceName}} 当前状态和关键条件", + "anomaly": "识别 {{resourceKind}}/{{resourceName}} 的异常与可能根因", + "nextSteps": "给出 {{namespace}} 中 {{resourceKind}}/{{resourceName}} 的最小影响下一步操作" + }, + "list": { + "anomalies": "找出当前页面资源中的异常对象并按影响排序", + "namespaceHotspots": "指出当前页面中问题最集中的命名空间", + "nextActions": "给出当前资源列表的快速处理建议" }, "default": { - "clusterOverview": "展示集群概览", - "listPods": "列出所有 Pod", - "issues": "当前是否有异常问题?" + "healthCheck": "做一次集群快速体检并列出 Top 3 风险", + "workloadIssues": "列出异常工作负载及可能根因", + "runbook": "给出按优先级排序的排障清单" } } }, diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 73c3dfdd..7bb547d5 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -143,6 +143,42 @@ samp { } /* AI Chat markdown styles */ +@keyframes ai-dot-wave { + 0%, + 60%, + 100% { + transform: translateY(0); + opacity: 0.3; + } + 30% { + transform: translateY(-4px); + opacity: 1; + } +} + +.ai-thinking-dots { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.ai-thinking-dots > span { + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background-color: var(--muted-foreground); + animation: ai-dot-wave 1.8s ease-in-out infinite; +} + +.ai-thinking-dots > span:nth-child(2) { + animation-delay: 0.2s; +} + +.ai-thinking-dots > span:nth-child(3) { + animation-delay: 0.4s; +} + .ai-markdown { line-height: 1.5; } From 2b17f5ec9a9e1230bfb62b712dbf66718073cbb0 Mon Sep 17 00:00:00 2001 From: Zzde Date: Thu, 5 Mar 2026 23:37:05 +0800 Subject: [PATCH 2/2] fix Signed-off-by: Zzde --- ui/src/components/ai-chat/ai-chatbox.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/src/components/ai-chat/ai-chatbox.tsx b/ui/src/components/ai-chat/ai-chatbox.tsx index 71179712..1c64e68a 100644 --- a/ui/src/components/ai-chat/ai-chatbox.tsx +++ b/ui/src/components/ai-chat/ai-chatbox.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react' import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' +import { useLocation } from 'react-router-dom' import remarkGfm from 'remark-gfm' import { useAIStatus } from '@/lib/api' @@ -364,6 +365,9 @@ export function AIChatbox() { stopGeneration, } = useAIChat() + const { pathname } = useLocation() + const shouldShowAIChatbox = !/^\/settings\/?$/.test(pathname) + const [input, setInput] = useState('') const [height, setHeight] = useState(() => Math.round( @@ -530,6 +534,8 @@ export function AIChatbox() { widthDragging.current = false }, []) + if (!shouldShowAIChatbox) return null + // Don't render if AI is not enabled if (aiEnabled === false) return null