diff --git a/cmd/api/app/api.go b/cmd/api/app/api.go index 46932f1f..e0bb0c46 100644 --- a/cmd/api/app/api.go +++ b/cmd/api/app/api.go @@ -28,6 +28,7 @@ import ( "github.com/karmada-io/dashboard/cmd/api/app/options" "github.com/karmada-io/dashboard/cmd/api/app/router" + _ "github.com/karmada-io/dashboard/cmd/api/app/routes/assistant" // Importing route packages forces route registration _ "github.com/karmada-io/dashboard/cmd/api/app/routes/auth" // Importing route packages forces route registration _ "github.com/karmada-io/dashboard/cmd/api/app/routes/cluster" // Importing route packages forces route registration _ "github.com/karmada-io/dashboard/cmd/api/app/routes/clusteroverridepolicy" // Importing route packages forces route registration diff --git a/cmd/api/app/routes/assistant/assistant.go b/cmd/api/app/routes/assistant/assistant.go new file mode 100644 index 00000000..f34b1aa3 --- /dev/null +++ b/cmd/api/app/routes/assistant/assistant.go @@ -0,0 +1,168 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package assistant + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "github.com/sashabaranov/go-openai" + "k8s.io/klog/v2" + + "github.com/karmada-io/dashboard/cmd/api/app/router" +) + +func init() { + // Register routes + router.V1().POST("/assistant", Answering) + router.V1().POST("/chat", ChatHandler) + router.V1().GET("/chat/tools", GetMCPToolsHandler) +} + +// AnsweringRequest represents the request payload for the legacy assistant endpoint. +type AnsweringRequest struct { + Prompt string `json:"prompt"` + Message string `json:"message"` +} + +// StreamResponse is used for the legacy SSE stream response. +type StreamResponse struct { + Type string `json:"type"` + Content interface{} `json:"content"` +} + +// getOpenAIModel returns the appropriate model based on environment configuration. +func getOpenAIModel() string { + if model := os.Getenv("OPENAI_MODEL"); model != "" { + return model + } + return openai.GPT3Dot5Turbo // Default fallback +} + +// Answering is a handler for the legacy, non-MCP chat endpoint. +func Answering(c *gin.Context) { + session, err := newAnsweringSession(c) + if err != nil { + klog.Errorf("Failed to create answering session: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := session.run(); err != nil { + klog.Errorf("Answering session run failed: %v", err) + } +} + +// answeringSession manages the state for a legacy chat request. +type answeringSession struct { + ctx context.Context + writer http.ResponseWriter + flusher http.Flusher + userInput string + openAIClient *openai.Client +} + +// newAnsweringSession creates a new session for the legacy Answering handler. +func newAnsweringSession(c *gin.Context) (*answeringSession, error) { + var request AnsweringRequest + if err := c.ShouldBindJSON(&request); err != nil { + return nil, fmt.Errorf("invalid request body: %w", err) + } + + userInput := strings.TrimSpace(request.Prompt) + if userInput == "" { + userInput = strings.TrimSpace(request.Message) + } + if userInput == "" { + return nil, errors.New("prompt cannot be empty") + } + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return nil, errors.New("streaming unsupported") + } + + client, err := prepareOpenAIClient() + if err != nil { + return nil, err + } + + return &answeringSession{ + ctx: c.Request.Context(), + writer: c.Writer, + flusher: flusher, + userInput: userInput, + openAIClient: client, + }, nil +} + +// run executes the chat flow for the legacy Answering handler. +func (s *answeringSession) run() error { + setupSSEHeaders(s.writer) + + messages := []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleUser, Content: s.userInput}, + } + + req := openai.ChatCompletionRequest{ + Model: getOpenAIModel(), + Messages: messages, + Stream: true, + } + + stream, err := s.openAIClient.CreateChatCompletionStream(s.ctx, req) + if err != nil { + return fmt.Errorf("could not create chat completion stream: %w", err) + } + defer stream.Close() + + for { + response, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("stream reception error: %w", err) + } + + if len(response.Choices) > 0 && response.Choices[0].Delta.Content != "" { + s.sendStreamEvent("text", response.Choices[0].Delta.Content) + } + } + + s.sendStreamEvent("completion", nil) + return nil +} + +// sendStreamEvent marshals and sends a StreamResponse to the client. +func (s *answeringSession) sendStreamEvent(eventType string, content interface{}) { + msg := StreamResponse{Type: eventType, Content: content} + data, err := json.Marshal(msg) + if err != nil { + klog.Errorf("Failed to marshal stream event: %v", err) + return + } + fmt.Fprintf(s.writer, "data: %s\n\n", data) + s.flusher.Flush() +} diff --git a/cmd/api/app/routes/assistant/chat_handler.go b/cmd/api/app/routes/assistant/chat_handler.go new file mode 100644 index 00000000..a5704758 --- /dev/null +++ b/cmd/api/app/routes/assistant/chat_handler.go @@ -0,0 +1,448 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package assistant + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + + "github.com/gin-gonic/gin" + "github.com/sashabaranov/go-openai" + "k8s.io/klog/v2" +) + +// ChatRequest represents the request payload for chat endpoint +type ChatRequest struct { + Message string `json:"message"` + History []ChatMessage `json:"history,omitempty"` + EnableMCP bool `json:"enableMcp,omitempty"` +} + +// ChatMessage represents a message in the conversation +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// ChatResponse represents the response for chat endpoint +type ChatResponse struct { + Type string `json:"type"` + Content interface{} `json:"content"` + ToolCall *ToolCallInfo `json:"toolCall,omitempty"` +} + +// ToolCallInfo represents information about a tool call +type ToolCallInfo struct { + ToolName string `json:"toolName"` + Args map[string]interface{} `json:"args"` + Result string `json:"result"` +} + +// Global variables for error tracking +var ( + openAIErrorShown bool + openAIErrorMutex sync.Mutex +) + +// ChatHandler handles chat requests with MCP integration +func ChatHandler(c *gin.Context) { + var request ChatRequest + if err := c.ShouldBindJSON(&request); err != nil { + klog.Errorf("Failed to bind request: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + userMessage := strings.TrimSpace(request.Message) + if userMessage == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Message cannot be empty"}) + return + } + + // Check if MCP is enabled via environment or request + enableMCP := request.EnableMCP || os.Getenv("ENABLE_MCP") == "true" + + var mcpClient *MCPClient + + if enableMCP { + var err error + mcpClient, err = GetMCPClient() + if err != nil { + klog.Warningf("Failed to initialize MCP client: %v. Continuing without MCP.", err) + enableMCP = false + } else { + klog.Infof("MCP client initialized successfully") + } + } + + // Prepare OpenAI client + client, err := prepareOpenAIClient() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Prepare messages + messages := prepareMessages(request, enableMCP) + + // Set up SSE headers + setupSSEHeaders(c.Writer) + + // Create chat completion request + chatReq := openai.ChatCompletionRequest{ + Model: getOpenAIModel(), + Messages: messages, + Stream: true, + } + + // Add functions/tools if MCP is enabled + if enableMCP && mcpClient != nil { + addMCPToolsToRequest(&chatReq, mcpClient) + } + + // Handle chat completion + handleChatCompletion(c, client, chatReq, enableMCP, mcpClient) +} + +func addMCPToolsToRequest(chatReq *openai.ChatCompletionRequest, mcpClient *MCPClient) { + tools := mcpClient.FormatToolsForOpenAI() + if len(tools) > 0 { + chatReq.Tools = tools + } +} + +func handleChatCompletion(c *gin.Context, client *openai.Client, chatReq openai.ChatCompletionRequest, enableMCP bool, mcpClient *MCPClient) { + resp, err := client.CreateChatCompletionStream(c.Request.Context(), chatReq) + if err != nil { + klog.Errorf("Failed to create chat completion stream: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get response from LLM"}) + return + } + defer resp.Close() + + // Track tool calls for accumulation + toolCallBuffer := make(map[int]*openai.ToolCall) + + // Stream the response + streamResponse(c, resp, toolCallBuffer, enableMCP, mcpClient) + + // Process completed tool calls + if enableMCP && mcpClient != nil && len(toolCallBuffer) > 0 { + processToolCalls(c, client, chatReq.Messages, toolCallBuffer, mcpClient) + } + + // Send completion signal + sendCompletionSignal(c) +} + +func streamResponse(c *gin.Context, resp *openai.ChatCompletionStream, toolCallBuffer map[int]*openai.ToolCall, enableMCP bool, mcpClient *MCPClient) { + for { + response, err := resp.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + klog.Errorf("Error receiving stream response: %v", err) + break + } + + if len(response.Choices) > 0 { + choice := response.Choices[0] + + // Handle regular content + if choice.Delta.Content != "" { + msg := ChatResponse{ + Type: "content", + Content: choice.Delta.Content, + } + if data, err := json.Marshal(msg); err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + } + } + + // Handle tool calls - accumulate them + if enableMCP && mcpClient != nil && choice.Delta.ToolCalls != nil { + accumulateToolCalls(choice.Delta.ToolCalls, toolCallBuffer) + } + } + } +} + +func processToolCalls(c *gin.Context, client *openai.Client, messages []openai.ChatCompletionMessage, toolCallBuffer map[int]*openai.ToolCall, mcpClient *MCPClient) { + // Send notification that tool processing is starting + processingMsg := ChatResponse{Type: "tool_processing", Content: "Processing tool calls..."} + if data, err := json.Marshal(processingMsg); err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + } + + // Append the assistant's response (tool calls) to the message history + assistantMessage := openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: "", + } + for _, toolCall := range toolCallBuffer { + assistantMessage.ToolCalls = append(assistantMessage.ToolCalls, *toolCall) + } + + // Execute tools and gather results + var toolResponses []openai.ChatCompletionMessage + for _, toolCall := range toolCallBuffer { + if toolCall.Function.Name != "" && toolCall.Function.Arguments != "" { + toolResponse := executeTool(toolCall, mcpClient, c) + if toolResponse.Role != "" { // Only add valid responses + toolResponses = append(toolResponses, toolResponse) + } + } + } + + // Send notification that tool processing is complete + completedMsg := ChatResponse{Type: "tool_processing_complete", Content: "Tool processing complete, generating response..."} + if data, err := json.Marshal(completedMsg); err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + } + + // Make a second call to OpenAI with the tool results + if len(toolResponses) > 0 { + makeFinalCall(c, client, messages, assistantMessage, toolResponses) + } +} + +func executeTool(toolCall *openai.ToolCall, mcpClient *MCPClient, c *gin.Context) openai.ChatCompletionMessage { + toolName := strings.TrimPrefix(toolCall.Function.Name, "mcp_") + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + klog.Errorf("Failed to parse tool arguments: %v, args: %s", err, toolCall.Function.Arguments) + return openai.ChatCompletionMessage{} + } + + // Send tool call start notification to the client + toolStartInfo := ToolCallInfo{ + ToolName: toolName, + Args: args, + Result: "Executing...", + } + msg := ChatResponse{Type: "tool_call_start", ToolCall: &toolStartInfo} + if data, err := json.Marshal(msg); err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + } + + result, err := mcpClient.CallTool(toolName, args) + if err != nil { + klog.Errorf("Failed to execute tool %s: %v", toolName, err) + result = fmt.Sprintf("Error executing tool %s: %v", toolName, err) + } + + // Send tool call completion info to the client for visibility + toolInfo := ToolCallInfo{ + ToolName: toolName, + Args: args, + Result: result, + } + msg = ChatResponse{Type: "tool_call", ToolCall: &toolInfo} + if data, err := json.Marshal(msg); err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + } + + // Return tool result for the next AI call + return openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleTool, + Content: result, + Name: toolCall.Function.Name, + ToolCallID: toolCall.ID, + } +} + +func makeFinalCall(c *gin.Context, client *openai.Client, messages []openai.ChatCompletionMessage, assistantMessage openai.ChatCompletionMessage, toolResponses []openai.ChatCompletionMessage) { + messages = append(messages, assistantMessage) + messages = append(messages, toolResponses...) + + finalChatReq := openai.ChatCompletionRequest{ + Model: getOpenAIModel(), + Messages: messages, + Stream: true, + } + + finalResp, err := client.CreateChatCompletionStream(c.Request.Context(), finalChatReq) + if err != nil { + klog.Errorf("Failed to create final chat completion stream: %v", err) + return + } + defer finalResp.Close() + + for { + response, err := finalResp.Recv() + if err != nil { + break // EOF or error + } + if len(response.Choices) > 0 && response.Choices[0].Delta.Content != "" { + msg := ChatResponse{Type: "content", Content: response.Choices[0].Delta.Content} + if data, err := json.Marshal(msg); err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + } + } + } +} + +func sendCompletionSignal(c *gin.Context) { + completionMsg := ChatResponse{ + Type: "completion", + Content: nil, + } + if data, err := json.Marshal(completionMsg); err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() + } +} + +func prepareMessages(request ChatRequest, enableMCP bool) []openai.ChatCompletionMessage { + var messages []openai.ChatCompletionMessage + + // System message + systemContent := `You are a helpful assistant for Karmada cluster management. + +You can provide guidance about Karmada concepts, best practices, and configuration help. +You can help with topics like: +- Cluster management and federation +- Resource propagation policies +- Scheduling and placement +- Multi-cluster applications +- Karmada installation and configuration + +Please provide clear and practical advice based on your knowledge of Karmada and Kubernetes.` + + if enableMCP { + systemContent += ` + +You have access to Karmada cluster management tools through function calls. When users ask about cluster resources, deployments, namespaces, or other Karmada objects, use the available tools to retrieve real-time information from the cluster. + +IMPORTANT: Use the function calling mechanism provided by the system. Do NOT output raw XML tags or tool syntax in your responses. Simply call the appropriate functions when needed, and then provide a natural language summary of the results to the user. + +Available tools will be provided automatically. Use them when relevant to give accurate, up-to-date information about the Karmada cluster.` + } + + messages = append(messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleSystem, + Content: systemContent, + }) + + // Add conversation history + for _, msg := range request.History { + // skip empty content messages + if strings.TrimSpace(msg.Content) == "" { + continue + } + + role := openai.ChatMessageRoleUser + if msg.Role == "assistant" { + role = openai.ChatMessageRoleAssistant + } + messages = append(messages, openai.ChatCompletionMessage{ + Role: role, + Content: strings.TrimSpace(msg.Content), + }) + } + + // Add current user message + messages = append(messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: request.Message, + }) + + return messages +} + +func accumulateToolCalls(toolCalls []openai.ToolCall, toolCallBuffer map[int]*openai.ToolCall) { + for _, toolCall := range toolCalls { + if toolCall.Index == nil { + continue + } + index := *toolCall.Index + if toolCallBuffer[index] == nil { + toolCallBuffer[index] = &openai.ToolCall{Index: &index} + } + if toolCall.ID != "" { + toolCallBuffer[index].ID = toolCall.ID + } + if toolCall.Type != "" { + toolCallBuffer[index].Type = toolCall.Type + } + if toolCall.Function.Name != "" { + toolCallBuffer[index].Function.Name = toolCall.Function.Name + } + if toolCall.Function.Arguments != "" { + toolCallBuffer[index].Function.Arguments += toolCall.Function.Arguments + } + } +} + +func prepareOpenAIClient() (*openai.Client, error) { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + // Only log the error once + openAIErrorMutex.Lock() + if !openAIErrorShown { + klog.Errorf("OpenAI API key not configured. Please set OPENAI_API_KEY environment variable.") + openAIErrorShown = true + } + openAIErrorMutex.Unlock() + return nil, errors.New("OpenAI API key not configured") + } + config := openai.DefaultConfig(apiKey) + if endpoint := os.Getenv("OPENAI_ENDPOINT"); endpoint != "" { + config.BaseURL = endpoint + } + return openai.NewClientWithConfig(config), nil +} + +func setupSSEHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") +} + +// GetMCPToolsHandler returns available MCP tools +func GetMCPToolsHandler(c *gin.Context) { + enableMCP := os.Getenv("ENABLE_MCP") == "true" + if !enableMCP { + c.JSON(http.StatusOK, gin.H{"tools": []interface{}{}, "enabled": false}) + return + } + + mcpClient, err := GetMCPClient() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "tools": mcpClient.GetTools(), + "enabled": true, + }) +} diff --git a/cmd/api/app/routes/assistant/mcp-doc.md b/cmd/api/app/routes/assistant/mcp-doc.md new file mode 100644 index 00000000..f7c4f6a1 --- /dev/null +++ b/cmd/api/app/routes/assistant/mcp-doc.md @@ -0,0 +1,100 @@ +# Karmada Assistant + +AI-powered chat functionality for Karmada dashboard with MCP (Model Context Protocol) tool integration. + +## 📋 Overview + +The Karmada Assistant provides intelligent chat capabilities that can interact with Karmada clusters through MCP tools. It supports both legacy chat (without tools) and modern chat (with real-time cluster tool integration), enabling users to manage and query their Karmada multi-cluster environments through natural language. + +## 🚀 Quick Start + +### Prerequisites + +1. **Karmada Cluster**: A running Karmada control plane +2. **MCP Server**: Karmada MCP server binary +3. **OpenAI API Key**: For AI model access +4. **Kubeconfig**: Valid Karmada cluster configuration + +## 💬 Usage Examples + +### Basic Cluster Queries + +**List all clusters:** +``` +User: "Show me all clusters" +Assistant: [Executes list_clusters tool and displays cluster information] +``` + +**Get cluster status:** +``` +User: "What's the status of my member clusters?" +Assistant: [Retrieves and displays cluster health and status information] +``` + +**Check resource distribution:** +``` +User: "How are my workloads distributed across clusters?" +Assistant: [Shows workload distribution and resource allocation] +``` + +## ⚙️ Configuration + +### MCP Server Setup + +The assistant requires a Karmada MCP server to provide cluster management tools: +> 📢 Note: The MCP server is currently under active development and not yet published via GitHub Releases. +> You’ll need to build it from source. +```bash +# Clone the MCP server repository +git clone https://github.com/warjiang/karmada-mcp-server.git +cd karmada-mcp-server + +# Build the server +go build -o karmada-mcp-server ./cmd/karmada-mcp-server + +# (Optional) Move it to a directory in your PATH +sudo mv karmada-mcp-server /usr/local/bin/ + +# Verify installation +karmada-mcp-server --help + +# Test MCP server (optional) +./karmada-mcp-server stdio \ + --karmada-kubeconfig=/path/to/karmada.config \ + --karmada-context=karmada-apiserver +``` + +### Environment Variables + +```bash +# === OpenAI Configuration === +export OPENAI_API_KEY="sk-xxxx" +export OPENAI_MODEL="gpt-4" # Optional, defaults to gpt-3.5-turbo +export OPENAI_ENDPOINT="https://api.openai.com/v1" # Optional + +# === Karmada Cluster Config === +export KUBECONFIG="$HOME/.kube/karmada.config" +export KARMADA_CONTEXT="karmada-apiserver" + +# === MCP Integration === +export ENABLE_MCP=true +export MCP_TRANSPORT_MODE="sse" # Optional, defaults to stdio +export KARMADA_MCP_SERVER_PATH="/usr/local/bin/karmada-mcp-server" # Required in STDIO mode +export MCP_SSE_ENDPOINT="http://localhost:1234/mcp/sse" # Required in SSE mode +``` + + +## 🔧 Development + +### Local Development Setup + +1. **Clone and Setup**: +```bash +git clone https://github.com/warjiang/karmada-mcp-server +cd karmada-mcp-server +go run ./cmd/karmada-mcp-server/main.go +``` + +2. **Setup Environment Variables** +3. +4. **Run Development Server** diff --git a/cmd/api/app/routes/assistant/mcp_client.go b/cmd/api/app/routes/assistant/mcp_client.go new file mode 100644 index 00000000..128c36fe --- /dev/null +++ b/cmd/api/app/routes/assistant/mcp_client.go @@ -0,0 +1,665 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package assistant + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "github.com/sashabaranov/go-openai" + "k8s.io/client-go/util/homedir" + "k8s.io/klog/v2" +) + +// Global variables for singleton pattern +var ( + mcpClientInstance *MCPClient + mcpClientMutex sync.Mutex + shutdownChan chan os.Signal +) + +// init sets up signal handling for graceful shutdown +func init() { + shutdownChan = make(chan os.Signal, 1) + signal.Notify(shutdownChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-shutdownChan + klog.Infof("Received shutdown signal, closing MCP client...") + ResetMCPClient() + }() +} + +// TransportMode defines the MCP transport mode +type TransportMode string + +const ( + // TransportModeStdio represents the stdio transport mode for MCP communication + TransportModeStdio TransportMode = "stdio" + // TransportModeSSE represents the Server-Sent Events transport mode. + TransportModeSSE TransportMode = "sse" +) + +// MCPTool represents a tool available from the MCP server. +type MCPTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema struct { + Type string `json:"type"` + Properties map[string]interface{} `json:"properties"` + Required []string `json:"required,omitempty"` + } `json:"inputSchema"` +} + +// MCPConfig holds configuration for initializing the MCP client. +type MCPConfig struct { + // Transport configuration + TransportMode TransportMode + ServerPath string + SSEEndpoint string + + // Kubernetes configuration + KubeconfigPath string + KarmadaContext string + + // Connection settings + ConnectTimeout time.Duration + RequestTimeout time.Duration + MaxRetries int + + // Feature flags + EnableMCP bool +} + +// MCPClient manages the lifecycle and communication with an MCP server. +type MCPClient struct { + client *client.Client + config *MCPConfig + serverInfo *mcp.InitializeResult + availableTools []mcp.Tool + availableResources []mcp.Resource + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex + closed bool +} + +// DefaultMCPConfig returns default configuration +func DefaultMCPConfig() *MCPConfig { + return &MCPConfig{ + TransportMode: TransportModeStdio, + KarmadaContext: "karmada-apiserver", + ConnectTimeout: 45 * time.Second, + RequestTimeout: 60 * time.Second, + MaxRetries: 3, + EnableMCP: true, + } +} + +// loadMCPConfig loads configuration from environment variables and validates them. +func loadMCPConfig() (*MCPConfig, error) { + config := DefaultMCPConfig() + + // Load transport mode + if mode := os.Getenv("MCP_TRANSPORT_MODE"); mode != "" { + switch strings.ToLower(mode) { + case "stdio": + config.TransportMode = TransportModeStdio + case "sse": + config.TransportMode = TransportModeSSE + default: + return nil, fmt.Errorf("unsupported transport mode: %s", mode) + } + } + + // Load server path (required for stdio mode) + if serverPath := os.Getenv("KARMADA_MCP_SERVER_PATH"); serverPath != "" { + config.ServerPath = serverPath + } else if config.TransportMode == TransportModeStdio { + return nil, errors.New("KARMADA_MCP_SERVER_PATH environment variable required for stdio mode") + } + + // Load SSE endpoint (required for SSE mode) + if sseEndpoint := os.Getenv("MCP_SSE_ENDPOINT"); sseEndpoint != "" { + config.SSEEndpoint = sseEndpoint + } else if config.TransportMode == TransportModeSSE { + return nil, errors.New("MCP_SSE_ENDPOINT environment variable required for SSE mode") + } + + // Load kubeconfig path + if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" { + config.KubeconfigPath = kubeconfig + } else { + config.KubeconfigPath = fmt.Sprintf("%s/.kube/karmada.config", homedir.HomeDir()) + } + + // Load karmada context + if context := os.Getenv("KARMADA_CONTEXT"); context != "" { + config.KarmadaContext = context + } + + // Load feature flags + if enableMCP := os.Getenv("ENABLE_MCP"); enableMCP != "" { + config.EnableMCP = enableMCP == "true" + } + + // Validate configuration + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid MCP configuration: %w", err) + } + + klog.Infof("MCP configuration loaded: transport=%s, server=%s, kubeconfig=%s", + config.TransportMode, config.ServerPath, config.KubeconfigPath) + + return config, nil +} + +// Validate checks if the configuration is valid +func (c *MCPConfig) Validate() error { + // Validate transport mode + switch c.TransportMode { + case TransportModeStdio: + if c.ServerPath == "" { + return errors.New("server path is required for stdio transport mode") + } + // Only check if file exists, don't fail if it doesn't (might be in PATH) + if _, err := os.Stat(c.ServerPath); err != nil { + klog.Warningf("MCP server not found at %s, assuming it's in PATH: %v", c.ServerPath, err) + } + case TransportModeSSE: + if c.SSEEndpoint == "" { + return errors.New("SSE endpoint is required for SSE transport mode") + } + default: + return fmt.Errorf("unsupported transport mode: %s", c.TransportMode) + } + + // Only warn about kubeconfig, don't fail + if _, err := os.Stat(c.KubeconfigPath); err != nil { + klog.Warningf("Kubeconfig not found at %s: %v", c.KubeconfigPath, err) + } + + return nil +} + +// GetMCPClient returns a singleton MCP client instance +func GetMCPClient() (*MCPClient, error) { + mcpClientMutex.Lock() + defer mcpClientMutex.Unlock() + + // If instance exists and is not closed, return it + if mcpClientInstance != nil && !mcpClientInstance.closed { + return mcpClientInstance, nil + } + + // Create new instance + klog.Infof("Creating new MCP client instance...") + client, err := NewMCPClient() + if err != nil { + return nil, err + } + + mcpClientInstance = client + return mcpClientInstance, nil +} + +// ResetMCPClient resets the singleton instance (for testing or error recovery) +func ResetMCPClient() { + mcpClientMutex.Lock() + defer mcpClientMutex.Unlock() + + if mcpClientInstance != nil { + mcpClientInstance.Close() + mcpClientInstance = nil + } +} + +// NewMCPClient creates and initializes a new MCP client. +func NewMCPClient() (*MCPClient, error) { + cfg, err := loadMCPConfig() + if err != nil { + return nil, fmt.Errorf("failed to load MCP config: %w", err) + } + + client := &MCPClient{ + config: cfg, + } + + if err := client.initialize(); err != nil { + client.Close() + return nil, err + } + + return client, nil +} + +// initialize sets up the MCP client based on the transport mode +func (c *MCPClient) initialize() error { + var err error + + switch c.config.TransportMode { + case TransportModeStdio: + err = c.initializeStdioClient() + case TransportModeSSE: + err = c.initializeSSEClient() + default: + return fmt.Errorf("unsupported transport mode: %s", c.config.TransportMode) + } + + if err != nil { + return fmt.Errorf("failed to initialize MCP client: %w", err) + } + + klog.Infof("MCP client initialized successfully") + return nil +} + +// initializeStdioClient sets up stdio transport +func (c *MCPClient) initializeStdioClient() error { + klog.Infof("Initializing MCP stdio client with server: %s", c.config.ServerPath) + + // Create a long-lived context for the client's lifecycle + c.ctx, c.cancel = context.WithCancel(context.Background()) + + // Create stdio transport with proper environment and args + stdioTransport := transport.NewStdio( + c.config.ServerPath, + nil, + "stdio", + "--karmada-kubeconfig="+c.config.KubeconfigPath, + "--karmada-context="+c.config.KarmadaContext, + ) + + // Create client with the transport + mcpClient := client.NewClient(stdioTransport) + + // Start the client with the long-lived context + if err := mcpClient.Start(c.ctx); err != nil { + c.cancel() + return fmt.Errorf("failed to start MCP client: %w", err) + } + + c.client = mcpClient + klog.Infof("MCP stdio client started successfully") + + // Initialize the client with a separate, short-lived context for the handshake + initCtx, initCancel := context.WithTimeout(context.Background(), c.config.ConnectTimeout) + defer initCancel() + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "Karmada-Dashboard-MCP-Client", + Version: "0.0.0-dev", + } + initRequest.Params.Capabilities = mcp.ClientCapabilities{} + + serverInfo, err := c.client.Initialize(initCtx, initRequest) + if err != nil { + c.cancel() // Cancel the main context if handshake fails + return fmt.Errorf("failed to initialize MCP client: %w", err) + } + + // Store server info for later use + c.serverInfo = serverInfo + + klog.Infof("Connected to MCP server: %s (version %s)", + serverInfo.ServerInfo.Name, serverInfo.ServerInfo.Version) + + klog.Infof("MCP stdio client connection established successfully") + return nil +} + +// initializeSSEClient sets up SSE transport +func (c *MCPClient) initializeSSEClient() error { + klog.Infof("Initializing MCP SSE client with endpoint: %s", c.config.SSEEndpoint) + + // Create SSE client using the dedicated constructor + mcpClient, err := client.NewSSEMCPClient(c.config.SSEEndpoint) + if err != nil { + return fmt.Errorf("failed to create SSE MCP client: %w", err) + } + + // Set up notification handler to react to server-sent events + mcpClient.OnNotification(func(notification mcp.JSONRPCNotification) { + klog.Infof("Received notification: %s", notification.Method) + // Handle specific notifications, e.g., when the tool list changes + if notification.Method == "tools/listChanged" { + c.ResetToolsState() + go c.loadToolsOnDemand() + } + }) + + // Use a background context for the long-running client connection + c.ctx, c.cancel = context.WithCancel(context.Background()) + + klog.Infof("Starting MCP SSE client connection...") + if err := mcpClient.Start(c.ctx); err != nil { + c.cancel() // Cancel the client's main context if start fails + return fmt.Errorf("failed to start MCP client: %w", err) + } + + c.client = mcpClient + klog.Infof("MCP SSE client started successfully") + + // Initialize the client with a separate, short-lived context for the handshake + klog.Infof("Initializing MCP handshake...") + initCtx, initCancel := context.WithTimeout(context.Background(), c.config.ConnectTimeout) + defer initCancel() + + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "Karmada-Dashboard-MCP-Client", + Version: "0.0.0-dev", + } + initRequest.Params.Capabilities = mcp.ClientCapabilities{} + + serverInfo, err := c.client.Initialize(initCtx, initRequest) + if err != nil { + klog.Errorf("MCP handshake failed: %v", err) + // Don't cancel the main context, just return the error + return fmt.Errorf("failed to initialize MCP client: %w", err) + } + + // Store server capabilities for later use + c.serverInfo = serverInfo + + klog.Infof("Connected to MCP server: %s (version %s)", + serverInfo.ServerInfo.Name, serverInfo.ServerInfo.Version) + + klog.Infof("MCP SSE client connection established successfully") + return nil +} + +// loadToolsOnDemand attempts to load tools if they haven't been loaded yet +func (c *MCPClient) loadToolsOnDemand() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + + // Check if server supports tools + if c.serverInfo == nil || c.serverInfo.Capabilities.Tools == nil { + klog.V(2).Infof("MCP server does not support tools") + return + } + + // Try to load tools with a reasonable timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + klog.V(2).Infof("Loading MCP tools on-demand...") + + if c.client == nil { + klog.Warningf("Cannot load tools: MCP client is nil") + return + } + + request := mcp.ListToolsRequest{} + tools, err := c.client.ListTools(ctx, request) + if err != nil { + klog.Warningf("Failed to load tools on-demand: %v", err) + return + } + + if tools == nil { + klog.Warningf("Cannot load tools: received nil tools response") + return + } + + // Successfully loaded tools + c.availableTools = make([]mcp.Tool, 0, len(tools.Tools)) + c.availableTools = append(c.availableTools, tools.Tools...) + + klog.Infof("Successfully loaded %d MCP tools on-demand", len(c.availableTools)) +} + +// GetTools returns the available MCP tools. +func (c *MCPClient) GetTools() []MCPTool { + c.mu.RLock() + if c.closed { + c.mu.RUnlock() + return nil + } + + // Check if server supports tools + if c.serverInfo == nil || c.serverInfo.Capabilities.Tools == nil { + c.mu.RUnlock() + klog.V(2).Infof("MCP server does not support tools") + return []MCPTool{} + } + + // If tools are already loaded, return them + if len(c.availableTools) > 0 { + tools := make([]MCPTool, 0, len(c.availableTools)) + for _, tool := range c.availableTools { + mcpTool := MCPTool{ + Name: tool.Name, + Description: tool.Description, + } + + // Convert input schema + mcpTool.InputSchema.Type = tool.InputSchema.Type + mcpTool.InputSchema.Properties = tool.InputSchema.Properties + mcpTool.InputSchema.Required = tool.InputSchema.Required + + tools = append(tools, mcpTool) + } + c.mu.RUnlock() + return tools + } + + c.mu.RUnlock() + + // Try to load tools on demand + c.loadToolsOnDemand() + + // Return whatever we have (might be empty if loading failed) + c.mu.RLock() + tools := make([]MCPTool, 0, len(c.availableTools)) + for _, tool := range c.availableTools { + mcpTool := MCPTool{ + Name: tool.Name, + Description: tool.Description, + } + + // Convert input schema + mcpTool.InputSchema.Type = tool.InputSchema.Type + mcpTool.InputSchema.Properties = tool.InputSchema.Properties + mcpTool.InputSchema.Required = tool.InputSchema.Required + + tools = append(tools, mcpTool) + } + c.mu.RUnlock() + return tools +} + +// HasToolsSupport returns true if the server supports tools +func (c *MCPClient) HasToolsSupport() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.serverInfo != nil && c.serverInfo.Capabilities.Tools != nil +} + +// CallTool executes a tool on the MCP server. +func (c *MCPClient) CallTool(toolName string, parameters map[string]interface{}) (string, error) { + c.mu.RLock() + if c.closed { + c.mu.RUnlock() + return "", errors.New("MCP client is closed") + } + c.mu.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), c.config.RequestTimeout) + defer cancel() + + // Create tool call request + request := mcp.CallToolRequest{} + request.Params.Name = toolName + request.Params.Arguments = parameters + + // Execute tool call + result, err := c.client.CallTool(ctx, request) + if err != nil { + return "", fmt.Errorf("failed to call tool %s: %w", toolName, err) + } + + // Extract text content from result + var content strings.Builder + for _, item := range result.Content { + if textContent, ok := mcp.AsTextContent(item); ok && textContent.Text != "" { + content.WriteString(textContent.Text) + } + } + + klog.Infof("Tool call %s completed successfully", toolName) + return content.String(), nil +} + +// Close terminates the MCP client and cleans up resources. +func (c *MCPClient) Close() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + + klog.Infof("Closing MCP client...") + c.closed = true + + // Cancel context first + if c.cancel != nil { + c.cancel() + } + + // Close MCP client with timeout + if c.client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- c.client.Close() + }() + + select { + case err := <-done: + if err != nil { + klog.Warningf("Failed to close MCP client: %v", err) + } else { + klog.Infof("MCP client closed successfully") + } + case <-ctx.Done(): + klog.Warningf("MCP client close timed out") + } + } + + // Clear tools and resources + c.availableTools = nil + c.availableResources = nil +} + +// ResetToolsState resets the tool loading state to allow retry +func (c *MCPClient) ResetToolsState() { + c.mu.Lock() + defer c.mu.Unlock() + + c.availableTools = nil + klog.V(2).Infof("MCP tools state reset") +} + +// FormatToolsForOpenAI converts MCP tools into the format expected by OpenAI. +func (c *MCPClient) FormatToolsForOpenAI() []openai.Tool { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.closed { + return nil + } + + tools := make([]openai.Tool, 0, len(c.availableTools)) + for _, tool := range c.availableTools { + tools = append(tools, openai.Tool{ + Type: openai.ToolTypeFunction, + Function: &openai.FunctionDefinition{ + Name: "mcp_" + tool.Name, + Description: tool.Description, + Parameters: tool.InputSchema, + }, + }) + } + return tools +} + +// ListResources fetches and returns all available resources from the MCP server +func (c *MCPClient) ListResources() ([]mcp.Resource, error) { + c.mu.RLock() + if c.closed { + c.mu.RUnlock() + return nil, errors.New("MCP client is closed") + } + + if c.serverInfo == nil || c.serverInfo.Capabilities.Resources == nil { + c.mu.RUnlock() + return nil, fmt.Errorf("server does not support resources") + } + c.mu.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), c.config.RequestTimeout) + defer cancel() + + resourcesRequest := mcp.ListResourcesRequest{} + resourcesResult, err := c.client.ListResources(ctx, resourcesRequest) + if err != nil { + return nil, fmt.Errorf("failed to list resources: %w", err) + } + + c.mu.Lock() + c.availableResources = resourcesResult.Resources + c.mu.Unlock() + + return resourcesResult.Resources, nil +} + +// GetResources returns the cached list of resources (call ListResources first) +func (c *MCPClient) GetResources() []mcp.Resource { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.closed { + return nil + } + + resources := make([]mcp.Resource, len(c.availableResources)) + copy(resources, c.availableResources) + return resources +} diff --git a/go.mod b/go.mod index 4632b566..7e40df91 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,10 @@ require ( github.com/gobuffalo/flect v1.0.3 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/karmada-io/karmada v1.13.0 + github.com/mark3labs/mcp-go v0.26.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/common v0.55.0 + github.com/sashabaranov/go-openai v1.40.5 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 golang.org/x/net v0.34.0 @@ -108,10 +110,12 @@ require ( github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/samber/lo v1.47.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect diff --git a/go.sum b/go.sum index f5def5bd..58c673ff 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0 github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -179,6 +181,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.26.0 h1:xz/Kv1cHLYovF8txv6btBM39/88q3YOjnxqhi51jB0w= +github.com/mark3labs/mcp-go v0.26.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -234,8 +238,12 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/sashabaranov/go-openai v1.40.5 h1:SwIlNdWflzR1Rxd1gv3pUg6pwPc6cQ2uMoHs8ai+/NY= +github.com/sashabaranov/go-openai v1.40.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -262,6 +270,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= diff --git a/pkg/config/config.go b/pkg/config/config.go index edf31366..5781cda3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -102,10 +102,25 @@ func InitDashboardConfig(k8sClient kubernetes.Interface, stopper <-chan struct{} // GetDashboardConfig returns a copy of the current dashboard configuration. func GetDashboardConfig() DashboardConfig { + dockerRegistries := dashboardConfig.DockerRegistries + chartRegistries := dashboardConfig.ChartRegistries + menuConfigs := dashboardConfig.MenuConfigs + + // Initialize empty slices if nil to prevent frontend errors + if dockerRegistries == nil { + dockerRegistries = []DockerRegistry{} + } + if chartRegistries == nil { + chartRegistries = []ChartRegistry{} + } + if menuConfigs == nil { + menuConfigs = []MenuConfig{} + } + return DashboardConfig{ - DockerRegistries: dashboardConfig.DockerRegistries, - ChartRegistries: dashboardConfig.ChartRegistries, - MenuConfigs: dashboardConfig.MenuConfigs, + DockerRegistries: dockerRegistries, + ChartRegistries: chartRegistries, + MenuConfigs: menuConfigs, PathPrefix: dashboardConfig.PathPrefix, } } diff --git a/ui/apps/dashboard/package.json b/ui/apps/dashboard/package.json index 8f4a4ae8..4925fbf4 100644 --- a/ui/apps/dashboard/package.json +++ b/ui/apps/dashboard/package.json @@ -26,9 +26,12 @@ ] }, "dependencies": { + "@chatui/core": "^3.1.0", + "@karmada/chatui": "workspace:*", "@karmada/i18n-tool": "workspace:*", "@karmada/terminal": "workspace:*", "@karmada/utils": "workspace:*", + "@microsoft/fetch-event-source": "^2.0.1", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.59.8", "@uidotdev/usehooks": "^2.4.1", diff --git a/ui/apps/dashboard/src/components/floating-chat/index.module.less b/ui/apps/dashboard/src/components/floating-chat/index.module.less new file mode 100644 index 00000000..72f6dc6a --- /dev/null +++ b/ui/apps/dashboard/src/components/floating-chat/index.module.less @@ -0,0 +1,177 @@ +.floating-chat { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 9999; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.chat-ball { + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: white; + transition: all 0.3s ease; + outline: none; +} + +.chat-ball:hover { + transform: scale(1.1); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); +} + +.chat-ball.active { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%); +} + +.chat-container { + position: absolute; + bottom: 70px; + right: 0; + width: 360px; + height: 480px; + background: white; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + overflow: hidden; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.chat-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 500; + font-size: 14px; +} + +.close-btn { + background: none; + border: none; + color: white; + font-size: 18px; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s ease; +} + +.close-btn:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.chat-body { + flex: 1; + height: 100%; + overflow: hidden; +} + +/* Override ChatUI styles - 固定输入框在底部 */ +.chat-body .chatui-chat { + height: 100%; + display: flex; + flex-direction: column; +} + +.chat-body .chatui-chat .chatui-chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.chat-body .chatui-chat .chatui-composer { + flex-shrink: 0; + padding: 12px; + border-top: 1px solid #f0f0f0; + background: white; + position: sticky; + bottom: 0; +} + +/* 确保消息容器可以正确滚动 */ +.chat-body .chatui-scroll-view { + flex: 1; + overflow-y: auto; +} + +/* 消息列表样式 */ +.chat-body .chatui-message-list { + display: flex; + flex-direction: column; + min-height: 100%; + padding: 12px; +} + +/* Custom bubble styles */ +.chat-body .chatui-bubble { + max-width: 280px; + font-size: 14px; + line-height: 1.4; +} + +.chat-body .chatui-bubble-right { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.chat-body .chatui-bubble-left { + background: #f5f5f5; + color: #333; +} + +/* Responsive design */ +@media (max-width: 480px) { + .floating-chat { + bottom: 15px; + right: 15px; + } + + .chat-ball { + width: 48px; + height: 48px; + font-size: 20px; + } + + .chat-container { + width: calc(100vw - 30px); + height: 400px; + right: -15px; + bottom: 65px; + } +} + +@media (max-width: 360px) { + .chat-container { + width: calc(100vw - 20px); + right: -10px; + } +} \ No newline at end of file diff --git a/ui/apps/dashboard/src/components/floating-chat/index.tsx b/ui/apps/dashboard/src/components/floating-chat/index.tsx new file mode 100644 index 00000000..fd742bfe --- /dev/null +++ b/ui/apps/dashboard/src/components/floating-chat/index.tsx @@ -0,0 +1,616 @@ +import React, { useState, useRef, useEffect } from 'react'; +import Chat, { Bubble, useMessages } from '@chatui/core'; +import '@chatui/core/dist/index.css'; +import './index.module.less'; + +interface FloatingChatProps { + className?: string; +} + +const initialMessages = [ + { + type: 'system', + content: { text: 'Karmada AI Assistant at your service' }, + }, + { + type: 'text', + content: { text: 'Hi, I am your dedicated AI assistant. Feel free to ask me anything!' }, + user: { + avatar: '/favicon.ico', + name: 'Assistant' + }, + }, +]; + +// Default quick replies +const defaultQuickReplies = [ + { + icon: 'message', + name: 'Help documentation', + isHighlight: true, + }, + { + name: 'List all clusters', + }, + { + name: 'List all namespaces', + }, + { + name: 'Create deployment', + }, +]; + +// Tool name mapping - convert technical names to user-friendly display names (kept for potential future use but currently unused) +// const getToolDisplayName = (toolName: string): string => { +// const toolNames: Record = { +// 'list_clusters': 'Cluster List', +// 'list_namespace': 'Namespace List', +// 'get_cluster_status': 'Cluster Status', +// 'list_deployments': 'Deployment List', +// 'get_pods': 'Pod List', +// 'get_resource_usage': 'Resource Usage', +// 'list_nodes': 'Node List', +// 'get_events': 'Event List', +// }; +// return toolNames[toolName] || toolName; +// }; + +// Format tool call results into user-friendly text +const formatToolCallResult = (toolCall: any): string => { + // Simple format: server : tool_name + return `karmada-mcp-server : ${toolCall.toolName}`; +}; + +const FloatingChat: React.FC = () => { + const [isOpen, setIsOpen] = useState(false); + const [enableMCP, setEnableMCP] = useState(false); + const [mcpAvailable, setMcpAvailable] = useState(false); + const [mcpToolCount, setMcpToolCount] = useState(0); + const chatRef = useRef(null); + const ballRef = useRef(null); + const currentControllerRef = useRef(null); // For interrupting streaming requests + + const { messages, appendMsg, updateMsg } = useMessages(initialMessages); + + // Check MCP availability + useEffect(() => { + const checkMCP = async () => { + try { + const response = await fetch('/api/v1/chat/tools'); + const data = await response.json(); + const available = data.enabled === true && data.tools && data.tools.length > 0; + setMcpAvailable(available); + setMcpToolCount(data.tools?.length || 0); + console.log('MCP available:', data.enabled, 'Tools:', data.tools?.length || 0); + + if (!available) { + setEnableMCP(false); // Force disable switch if MCP is unavailable + } + } catch (error) { + console.error('Failed to check MCP:', error); + setMcpAvailable(false); + setMcpToolCount(0); + setEnableMCP(false); + } + }; + checkMCP(); + }, []); + + // Handle chat ball click + const handleToggle = (event: React.MouseEvent) => { + event.stopPropagation(); // Prevent event bubbling + setIsOpen(!isOpen); + }; + + // Close chat when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + // 如果点击的是悬浮球,不关闭(已经通过stopPropagation处理) + if (ballRef.current && ballRef.current.contains(target)) { + return; + } + + // 如果点击的是聊天容器内部,不关闭 + if (chatRef.current && chatRef.current.contains(target)) { + return; + } + + // 其他情况都关闭聊天框 + setIsOpen(false); + }; + + if (isOpen) { + // 延迟添加事件监听器,避免立即触发 + setTimeout(() => { + document.addEventListener('click', handleClickOutside); + }, 0); + + return () => document.removeEventListener('click', handleClickOutside); + } + }, [isOpen]); + + // 组件卸载时清理控制器 + useEffect(() => { + return () => { + if (currentControllerRef.current) { + currentControllerRef.current.abort(); + currentControllerRef.current = null; + } + }; + }, []); + + // 发送消息并集成MCP - 支持流式更新和中断 + const handleSend = async (type: string, val: string) => { + if (type === 'text' && val.trim()) { + // 中断上一个正在进行的请求 + if (currentControllerRef.current) { + currentControllerRef.current.abort(); + currentControllerRef.current = null; + } + + // 添加用户消息 + appendMsg({ + type: 'text', + content: { text: val }, + position: 'right', + }); + + // 添加空的助手消息用于流式更新 + const botMessageId = appendMsg({ + type: 'text', + content: { text: '' }, + position: 'left', + user: { avatar: '/favicon.ico' } + }); + + try { + const shouldUseMCP = enableMCP && mcpAvailable; + console.log('Chat decision:', { enableMCP, mcpAvailable, shouldUseMCP }); + + if (shouldUseMCP) { + // 使用MCP API + const { getChatStream } = await import('@/services/assistant'); + let streamingText = ''; + + // 创建新的控制器 + const controller = new AbortController(); + currentControllerRef.current = controller; + + getChatStream( + val, + [], // 暂时不保存历史记录 + true, // 启用MCP + (data) => { + // 检查请求是否已被中断 + if (controller.signal.aborted) return; + + // 实时更新消息内容 + streamingText += data; + updateMsg(botMessageId, { + type: 'text', + content: { text: streamingText }, + position: 'left', + user: { avatar: '/favicon.ico' } + }); + }, + (toolCall) => { + // 检查请求是否已被中断 + if (controller.signal.aborted) return; + + // 显示友好的工具调用消息 + appendMsg({ + type: 'tool_call', + content: { + text: formatToolCallResult(toolCall), + toolCall: toolCall // 保存原始数据供详情查看 + }, + position: 'left', + user: { + avatar: '/favicon.ico', + name: 'Assistant' + } + }); + }, + (error) => { + // 如果是用户主动中断,不显示错误 + if (controller.signal.aborted) return; + + console.error('MCP Chat error:', error); + updateMsg(botMessageId, { + type: 'text', + content: { text: `MCP Error: ${error.message || 'Problem processing request'}` }, + position: 'left', + user: { avatar: '❌' } + }); + }, + () => { + // 检查请求是否已被中断 + if (controller.signal.aborted) return; + + console.log('MCP Chat stream finished, final text:', streamingText); + // 清除控制器引用 + if (currentControllerRef.current === controller) { + currentControllerRef.current = null; + } + }, + controller.signal // Pass the abort signal + ); + } else { + // 使用传统API + console.log('Using traditional API'); + const { getAssistantStream } = await import('@/services/assistant'); + let streamingText = ''; + + // 创建新的控制器 + const controller = new AbortController(); + currentControllerRef.current = controller; + + getAssistantStream( + val, + (data) => { + // 检查请求是否已被中断 + if (controller.signal.aborted) return; + + // 实时更新消息内容 + streamingText += data; + updateMsg(botMessageId, { + type: 'text', + content: { text: streamingText }, + position: 'left', + user: { avatar: '/favicon.ico' } + }); + }, + (error) => { + // 如果是用户主动中断,不显示错误 + if (controller.signal.aborted) return; + + console.error('Assistant error:', error); + const errorMessage = error instanceof Error ? error.message : 'Problem processing request'; + updateMsg(botMessageId, { + type: 'text', + content: { text: `API Error: ${errorMessage}` }, + position: 'left', + user: { avatar: '❌' } + }); + }, + () => { + // 检查请求是否已被中断 + if (controller.signal.aborted) return; + + console.log('Assistant stream finished, final text:', streamingText); + // 清除控制器引用 + if (currentControllerRef.current === controller) { + currentControllerRef.current = null; + } + }, + controller.signal // Pass the abort signal + ); + } + } catch (error) { + console.error('Failed to process request:', error); + const errorMessage = error instanceof Error ? error.message : 'Unable to connect to server'; + updateMsg(botMessageId, { + type: 'text', + content: { text: `Network Error: ${errorMessage}` }, + position: 'left', + user: { avatar: '❌' } + }); + } + } + }; + + // 快捷短语回调 + const handleQuickReplyClick = (item: any) => { + handleSend('text', item.name); + }; + + // 渲染消息内容 + const renderMessageContent = (msg: any) => { + const { type, content } = msg; + + switch (type) { + case 'text': + return ; + case 'tool_call': + // 使用Card组件美化工具调用显示 + return ( +
+
+
+
+ {content.text} +
+ {content.toolCall && ( +
+ + View Details + +
+
Tool: {content.toolCall.toolName}
+
+ Parameters: +
+                          {JSON.stringify(content.toolCall.args, null, 2)}
+                        
+
+
+ Result: +
+                          {typeof content.toolCall.result === 'string' 
+                            ? content.toolCall.result.length > 500 
+                              ? content.toolCall.result.substring(0, 500) + '...\n\n[Content too long, truncated]'
+                              : content.toolCall.result
+                            : JSON.stringify(content.toolCall.result, null, 2)
+                          }
+                        
+
+
+
+ )} +
+
+
+ ); + case 'image': + return ( + + + + ); + default: + return null; + } + }; + + return ( +
+ {/* Chat Container */} + {isOpen && ( +
e.stopPropagation()} // 防止点击聊天容器时事件冒泡 + style={{ + position: 'absolute', + bottom: '70px', + right: '0', + width: '400px', + height: '600px', + backgroundColor: 'white', + borderRadius: '16px', + boxShadow: '0 12px 48px rgba(0, 0, 0, 0.2)', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + border: '1px solid #e8e8e8', + }} + > + {/* MCP控制栏 - 增强版 */} +
+ {/* 标题行 */} +
+ Karmada Assistant + + {/* 切换开关 */} +
+ Enable MCP +
mcpAvailable && setEnableMCP(!enableMCP)} + style={{ + width: '36px', + height: '20px', + borderRadius: '10px', + backgroundColor: enableMCP && mcpAvailable ? '#52c41a' : '#d9d9d9', + position: 'relative', + cursor: mcpAvailable ? 'pointer' : 'not-allowed', + opacity: mcpAvailable ? 1 : 0.5, + transition: 'all 0.2s ease', + }} + > +
+
+
+
+ + {/* 状态行 */} +
+ {/* MCP状态和工具数量 */} +
+ + {enableMCP && mcpAvailable + ? `MCP enabled, ${mcpToolCount} tools available` + : mcpAvailable + ? 'MCP available' + : 'MCP unavailable' + } + +
+ + {/* 状态指示器 */} +
+ {mcpAvailable ? 'MCP available' : 'MCP unavailable'} +
+
+
+ + {/* Chat组件容器 - 固定输入框在底部 */} +
+ +
+
+ )} + + {/* Floating Chat Ball - 更优雅的设计 */} + + + {/* 添加脉冲动画的样式 */} + +
+ ); +}; + +export default FloatingChat; \ No newline at end of file diff --git a/ui/apps/dashboard/src/components/icons/index.tsx b/ui/apps/dashboard/src/components/icons/index.tsx index e53141b2..26b522c2 100644 --- a/ui/apps/dashboard/src/components/icons/index.tsx +++ b/ui/apps/dashboard/src/components/icons/index.tsx @@ -54,6 +54,8 @@ import { ChevronUp, ChevronDown, SquareTerminal, + MessageSquare, + } from 'lucide-react'; export type Icon = LucideIcon; @@ -77,7 +79,9 @@ export const Icons = { delete: Trash2, up: ChevronUp, down: ChevronDown, + bot: MessageSquare, terminal: SquareTerminal, + gitHub: ({ ...props }: LucideProps) => (
+
Parameters: {JSON.stringify(toolCall.args, null, 2)}
+
+ Result: +
+              {toolCall.result}
+            
+
+
+ + )); + }; + + return ( +
+ {/* MCP control panel */} + +
+
+

Karmada Assistant

+

+ {mcpEnabled ? `MCP enabled, ${availableTools.length} tools available` : 'MCP not enabled'} +

+
+
+ + Enable MCP + {mcpEnabled && ( + t.name).join(', ')}`}> + MCP available + + )} +
+
+
+ + {/* message list */} + ( + + : } + title={item.sender === 'user' ? 'User' : 'Assistant'} + description={ +
+
{item.text}
+ {item.toolCalls && item.toolCalls.length > 0 && renderToolCalls(item.toolCalls)} +
+ } + /> +
+ )} + /> + + {/* input area */} +
+ setInputValue(e.target.value)} + onPressEnter={handleSendMessage} + placeholder={enableMCP ? "Enter your question, MCP will help query cluster information..." : "Enter your question..."} + style={{ flex: 1 }} + /> + +
+
+ ); +}; + +export default AssistantPage; \ No newline at end of file diff --git a/ui/apps/dashboard/src/services/assistant.ts b/ui/apps/dashboard/src/services/assistant.ts new file mode 100644 index 00000000..b15e7c33 --- /dev/null +++ b/ui/apps/dashboard/src/services/assistant.ts @@ -0,0 +1,186 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fetchEventSource } from '@microsoft/fetch-event-source'; + +interface StreamResponse { + type: string; + content: any; // match the interface{} type in the backend +} + +interface ChatMessage { + role: string; + content: string; +} + +export interface ChatRequest { + message: string; + history?: ChatMessage[]; + enableMcp?: boolean; +} + +interface ChatResponse { + type: string; + content: any; + toolCall?: ToolCall; +} + +export interface ToolCall { + toolName: string; + args: Record; + result: string; +} + +export interface MCPTool { + name: string; + description: string; + inputSchema: any; // Or a more specific type if possible +} + +interface MCPToolsResponse { + tools: MCPTool[]; + enabled: boolean; +} + +export const getAssistantStream = ( + message: string, + onMessage: (data: string) => void, + onError: (error: any) => void, + onClose: () => void, + signal?: AbortSignal, // Add optional abort signal parameter +) => { + console.log('Sending message to assistant:', message); // debug log + + fetchEventSource('/api/v1/assistant', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ prompt: message }), + signal, // Use the passed signal instead of creating a new one + onmessage(ev: { data: string }) { + console.log('Received message:', ev.data); // debug log + try { + const response: StreamResponse = JSON.parse(ev.data); + if (response.type === 'text' && typeof response.content === 'string') { + onMessage(response.content); + } + // ignore completion type messages + } catch (error) { + console.error('Failed to parse stream response:', error); + // if parsing fails, use the original data (backward compatible) + onMessage(ev.data); + } + }, + onerror(err: any) { + console.error('Stream error:', err); + onError(err); + }, + onclose() { + console.log('Stream closed'); + onClose(); + }, + }); +}; + +// new: get MCP tool list +export const getMCPTools = async (): Promise => { + try { + const response = await fetch('/api/v1/chat/tools'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.error('Failed to get MCP tools:', error); + return { tools: [], enabled: false }; + } +}; + +// new: support MCP chat stream +export const getChatStream = ( + message: string, + history: ChatMessage[] = [], + enableMCP: boolean = false, + onMessage: (data: string) => void, + onToolCall: (toolCall: ToolCall) => void, + onError: (error: any) => void, + onClose: () => void, + signal?: AbortSignal, // Add optional abort signal parameter +) => { + console.log('Sending message to chat with MCP:', { message, enableMCP, historyLength: history.length }); + + fetchEventSource('/api/v1/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message, + history, + enableMcp: enableMCP, + }), + signal, // Use the passed signal instead of creating a new one + onmessage(ev: { data: string }) { + console.log('Received chat message:', ev.data); + try { + const response: ChatResponse = JSON.parse(ev.data); + + switch (response.type) { + case 'content': + if (typeof response.content === 'string') { + onMessage(response.content); + } + break; + case 'tool_call': + if (response.toolCall) { + onToolCall(response.toolCall); + } + break; + case 'tool_call_start': + // Tool execution started - we can ignore this or handle if needed + console.log('Tool execution started:', response.toolCall?.toolName); + break; + case 'tool_processing': + // Tool processing notification - we can ignore this + console.log('Tool processing:', response.content); + break; + case 'tool_processing_complete': + // Tool processing completed - we can ignore this + console.log('Tool processing complete:', response.content); + break; + case 'completion': + // ignore completion signal + break; + default: + console.warn('Unknown response type:', response.type); + } + } catch (error) { + console.error('Failed to parse chat stream response:', error); + // if parsing fails, use the original data (backward compatible) + onMessage(ev.data); + } + }, + onerror(err: any) { + console.error('Chat stream error:', err); + onError(err); + }, + onclose() { + console.log('Chat stream closed'); + onClose(); + }, + }); +}; \ No newline at end of file diff --git a/ui/packages/chatui/README.md b/ui/packages/chatui/README.md new file mode 100644 index 00000000..4528872e --- /dev/null +++ b/ui/packages/chatui/README.md @@ -0,0 +1,139 @@ +# @karmada/chatui + +A floating chat UI component with MCP (Model Context Protocol) integration for Karmada. + +## Installation + +```bash +npm install @karmada/chatui +# or +pnpm add @karmada/chatui +# or +yarn add @karmada/chatui +``` + +## Usage + +```tsx +import React from "react"; +import { FloatingChat } from "@karmada/chatui"; + +function App() { + return ( +
+

My App

+ +
+ ); +} + +export default App; +``` + +## Props + +### FloatingChat Props + +| Prop | Type | Default | Description | +| -------------- | -------------------------------------------------------------- | --------------------- | --------------------------------------- | +| `apiConfig` | `ApiConfig` | **Required** | API configuration for chat endpoints | +| `theme` | `'light' \| 'dark'` | `'light'` | Theme of the chat UI | +| `position` | `'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left'` | `'bottom-right'` | Position of the floating chat ball | +| `avatar` | `string` | `'/favicon.ico'` | Avatar image URL for assistant messages | +| `quickReplies` | `QuickReply[]` | Default replies | Quick reply buttons | +| `enableMCP` | `boolean` | `false` | Enable MCP integration | +| `className` | `string` | `''` | Additional CSS class | +| `width` | `number` | `400` | Chat window width in pixels | +| `height` | `number` | `600` | Chat window height in pixels | +| `title` | `string` | `'Karmada Assistant'` | Chat window title | + +### ApiConfig + +```tsx +interface ApiConfig { + chatEndpoint: string; // Main chat API endpoint + toolsEndpoint: string; // MCP tools API endpoint + headers?: Record; // Optional HTTP headers +} +``` + +### QuickReply + +```tsx +interface QuickReply { + icon?: string; // Optional icon + name: string; // Display text and sent message + isHighlight?: boolean; // Whether to highlight this reply +} +``` + +## API Integration + +The component expects your backend to provide two endpoints: + +### Chat Endpoint (`chatEndpoint`) + +**POST** request with the following payload: + +```json +{ + "message": "user message", + "history": [], + "enableMcp": true +} +``` + +**Response**: Server-sent events (SSE) stream with: + +``` +data: {"type": "content", "content": "response text"} +data: {"type": "tool_call", "toolCall": {"toolName": "...", "args": {...}, "result": "..."}} +data: {"type": "completion", "content": null} +``` + +### Tools Endpoint (`toolsEndpoint`) + +**GET** request returning: + +```json +{ + "enabled": true, + "tools": [...] +} +``` + +## Styling + +The component comes with built-in styles, but you can customize it by: + +1. Using the `theme` prop for built-in light/dark themes +2. Adding custom CSS classes via the `className` prop +3. Overriding CSS custom properties in your global styles + +## TypeScript + +Full TypeScript support is included. Import types as needed: + +```tsx +import { ChatUIProps, ApiConfig, QuickReply } from "@karmada/chatui"; +``` + +## License + +Apache-2.0 + +Copyright 2024 The Karmada Authors. diff --git a/ui/packages/chatui/package.json b/ui/packages/chatui/package.json new file mode 100644 index 00000000..f76eb61f --- /dev/null +++ b/ui/packages/chatui/package.json @@ -0,0 +1,52 @@ +{ + "name": "@karmada/chatui", + "version": "1.0.0", + "publishConfig": { + "access": "public" + }, + "description": "A floating chat UI component with MCP (Model Context Protocol) integration for Karmada", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + } + }, + "types": "dist/index.d.ts", + "scripts": { + "prepublish": "tsup --config ./tsup.config.ts", + "build": "tsup --config ./tsup.config.ts", + "dev": "tsup --config ./tsup.config.ts --watch" + }, + "lint-staged": { + "**/*.{js,jsx,ts,tsx,json,css,md}": [ + "prettier --write" + ] + }, + "keywords": [ + "chat", + "ui", + "react", + "chatui", + "mcp", + "karmada", + "floating" + ], + "author": "Karmada Authors", + "license": "Apache-2.0", + "dependencies": { + "@chatui/core": "^2.4.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0" + }, + "peerDependencies": { + "react": "^18", + "react-dom": "^18" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ] +} diff --git a/ui/packages/chatui/src/FloatingChat.tsx b/ui/packages/chatui/src/FloatingChat.tsx new file mode 100644 index 00000000..eea35e63 --- /dev/null +++ b/ui/packages/chatui/src/FloatingChat.tsx @@ -0,0 +1,613 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState, useRef, useEffect } from "react"; +import Chat, { Bubble, useMessages } from "@chatui/core"; +import "@chatui/core/dist/index.css"; +import "./styles.css"; +import { ChatAPI } from "./api"; +import { ChatUIProps, QuickReply } from "./types"; + +// Constants +const DEFAULT_QUICK_REPLIES: QuickReply[] = [ + { icon: "message", name: "Help documentation", isHighlight: true }, + { name: "List all clusters" }, + { name: "List all namespaces" }, + { name: "Create deployment" }, +]; + +const DEFAULT_MESSAGES = [ + { type: "system", content: { text: "Karmada AI Assistant at your service" } }, + { + type: "text", + content: { + text: "Hi, I am your dedicated AI assistant. Feel free to ask me anything!", + }, + user: { avatar: "/favicon.ico", name: "Assistant" }, + }, +]; + +// Styles +const STYLES = { + container: { + backgroundColor: "#f8f9fa", + borderRadius: "16px", + boxShadow: "0 12px 48px rgba(0, 0, 0, 0.2)", + border: "1px solid #e8e8e8", + overflow: "hidden", + display: "flex", + flexDirection: "column" as const, + }, + controlBar: { + padding: "12px 16px", + backgroundColor: "#f8f9fa", + borderBottom: "1px solid #e8e8e8", + display: "flex", + flexDirection: "column" as const, + gap: "8px", + fontSize: "12px", + flexShrink: 0, + }, + toolCall: { + background: "#f0f9ff", + border: "1px solid #bfdbfe", + borderRadius: "8px", + marginBottom: "4px", + padding: "12px", + }, + button: { + width: "64px", + height: "64px", + borderRadius: "50%", + border: "none", + boxShadow: "0 8px 24px rgba(24, 144, 255, 0.3)", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "28px", + color: "white", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + outline: "none", + position: "relative" as const, + }, +}; + +const formatToolCall = (toolCall: any) => + `karmada-mcp-server : ${toolCall.toolName}`; + +export const FloatingChat: React.FC = ({ + apiConfig, + theme = "light", + position = "bottom-right", + avatar = "/favicon.ico", + quickReplies = DEFAULT_QUICK_REPLIES, + enableMCP: initialEnableMCP = false, + className = "", + width = 400, + height = 600, + title = "Karmada Assistant", +}) => { + const [isOpen, setIsOpen] = useState(false); + const [enableMCP, setEnableMCP] = useState(initialEnableMCP); + const [mcpAvailable, setMcpAvailable] = useState(false); + const [mcpToolCount, setMcpToolCount] = useState(0); + const chatRef = useRef(null); + const ballRef = useRef(null); + const currentControllerRef = useRef(null); + const apiRef = useRef(new ChatAPI(apiConfig)); + + const { messages, appendMsg, updateMsg } = useMessages(DEFAULT_MESSAGES); + + // Update API config when props change + useEffect(() => { + apiRef.current = new ChatAPI(apiConfig); + }, [apiConfig]); + + // Check MCP availability + useEffect(() => { + const checkMCP = async () => { + try { + const data = await apiRef.current.checkMCPAvailability(); + const available = + data.enabled === true && data.tools && data.tools.length > 0; + setMcpAvailable(available); + setMcpToolCount(data.tools?.length || 0); + + if (!available) { + setEnableMCP(false); + } + } catch (error) { + console.error("Failed to check MCP:", error); + setMcpAvailable(false); + setMcpToolCount(0); + setEnableMCP(false); + } + }; + checkMCP(); + }, []); + + // Handle chat ball click + const handleToggle = (event: React.MouseEvent) => { + event.stopPropagation(); + setIsOpen(!isOpen); + }; + + // Close chat when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + if (ballRef.current && ballRef.current.contains(target)) { + return; + } + + if (chatRef.current && chatRef.current.contains(target)) { + return; + } + + setIsOpen(false); + }; + + if (isOpen) { + setTimeout(() => { + document.addEventListener("click", handleClickOutside); + }, 0); + + return () => document.removeEventListener("click", handleClickOutside); + } + }, [isOpen]); + + // Cleanup controller on unmount + useEffect(() => { + return () => { + if (currentControllerRef.current) { + currentControllerRef.current.abort(); + currentControllerRef.current = null; + } + }; + }, []); + + // Unified stream handler + const handleStream = async (val: string, shouldUseMCP: boolean) => { + const controller = new AbortController(); + currentControllerRef.current = controller; + + const botMessage = { + type: "text", + content: { text: "" }, + position: "left" as const, + user: { avatar, name: "Assistant" }, + }; + const botMessageId = appendMsg(botMessage); + let streamingText = ""; + + const onData = (data: string) => { + if (controller.signal.aborted) return; + streamingText += data; + updateMsg(botMessageId, { + ...botMessage, + content: { text: streamingText }, + }); + }; + + const onError = (error: any) => { + if (controller.signal.aborted) return; + const errorMessage = + error instanceof Error ? error.message : "Problem processing request"; + const prefix = shouldUseMCP ? "MCP Error" : "API Error"; + updateMsg(botMessageId, { + type: "text", + content: { text: `${prefix}: ${errorMessage}` }, + position: "left", + user: { avatar: "❌", name: "Error" }, + }); + }; + + const onComplete = () => { + if (controller.signal.aborted) return; + if (currentControllerRef.current === controller) { + currentControllerRef.current = null; + } + }; + + try { + if (shouldUseMCP) { + await apiRef.current.getChatStream( + { message: val, history: [], enableMcp: true }, + onData, + (toolCall) => { + if (controller.signal.aborted) return; + appendMsg({ + type: "tool_call", + content: { text: formatToolCall(toolCall), toolCall }, + position: "left", + user: { avatar, name: "Assistant" }, + }); + }, + onError, + onComplete, + controller.signal, + ); + } else { + await apiRef.current.getLegacyChatStream( + val, + onData, + onError, + onComplete, + controller.signal, + ); + } + } catch (error) { + onError(error); + } + }; + + const handleSend = async (type: string, val: string) => { + if (type !== "text" || !val.trim()) return; + + // Abort previous request + currentControllerRef.current?.abort(); + currentControllerRef.current = null; + + // Add user message + appendMsg({ + type: "text", + content: { text: val }, + position: "right", + }); + + const shouldUseMCP = enableMCP && mcpAvailable; + await handleStream(val, shouldUseMCP); + }; + + const handleQuickReplyClick = (item: any) => { + handleSend("text", item.name); + }; + + // Render message content + const renderMessageContent = (msg: any) => { + const { type, content } = msg; + + switch (type) { + case "text": + return ; + case "tool_call": + return ( +
+
+
+
+ {content.text} +
+ {content.toolCall && ( +
+ + View Details + +
+
+ Tool: {content.toolCall.toolName} +
+
+ Parameters: +
+                          {JSON.stringify(content.toolCall.args, null, 2)}
+                        
+
+
+ Result: +
+                          {typeof content.toolCall.result === "string"
+                            ? content.toolCall.result.length > 500
+                              ? content.toolCall.result.substring(0, 500) +
+                                "...\n\n[Content too long, truncated]"
+                              : content.toolCall.result
+                            : JSON.stringify(content.toolCall.result, null, 2)}
+                        
+
+
+
+ )} +
+
+
+ ); + case "image": + return ( + + + + ); + default: + return null; + } + }; + + // Calculate position styles + const getPositionStyles = () => { + const [vertical, horizontal] = position.split("-"); + return { + position: "fixed" as const, + [vertical]: "20px", + [horizontal]: "20px", + zIndex: 9999, + }; + }; + + const getChatPositionStyles = () => { + const [vertical, horizontal] = position.split("-"); + return { + position: "absolute" as const, + [vertical === "bottom" ? "bottom" : "top"]: "70px", + [horizontal]: "0", + }; + }; + + return ( +
+ {/* Chat Container */} + {isOpen && ( +
e.stopPropagation()} + style={{ + ...getChatPositionStyles(), + width: `${width}px`, + height: `${height}px`, + ...STYLES.container, + }} + > + {/* MCP Control Bar */} +
+ {/* Title row */} +
+ + {title} + + + {/* Toggle switch */} +
+ + Enable MCP + +
mcpAvailable && setEnableMCP(!enableMCP)} + style={{ + width: "36px", + height: "20px", + borderRadius: "10px", + backgroundColor: + enableMCP && mcpAvailable ? "#52c41a" : "#d9d9d9", + position: "relative", + cursor: mcpAvailable ? "pointer" : "not-allowed", + opacity: mcpAvailable ? 1 : 0.5, + transition: "all 0.2s ease", + }} + > +
+
+
+
+ + {/* Status row */} +
+ {/* MCP status and tool count */} +
+ + {enableMCP && mcpAvailable + ? `MCP enabled, ${mcpToolCount} tools available` + : mcpAvailable + ? "MCP available" + : "MCP unavailable"} + +
+ + {/* Status indicator */} +
+ {mcpAvailable ? "MCP available" : "MCP unavailable"} +
+
+
+ + {/* Chat Body */} +
+ +
+
+ )} + + {/* Floating Chat Ball */} + + + {/* Pulse Animation Keyframes */} + +
+ ); +}; diff --git a/ui/packages/chatui/src/api.ts b/ui/packages/chatui/src/api.ts new file mode 100644 index 00000000..58f8524d --- /dev/null +++ b/ui/packages/chatui/src/api.ts @@ -0,0 +1,175 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + ApiConfig, + ChatRequest, + MCPToolsResponse, + StreamResponse, +} from "./types"; + +export class ChatAPI { + private config: ApiConfig; + + constructor(config: ApiConfig) { + this.config = config; + } + + async checkMCPAvailability(): Promise { + try { + const response = await fetch(this.config.toolsEndpoint, { + headers: this.config.headers, + }); + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to check MCP:", error); + return { tools: [], enabled: false }; + } + } + + private async processStream( + response: Response, + onData: (content: string) => void, + onToolCall?: (toolCall: any) => void, + onError?: (error: Error) => void, + onComplete?: () => void, + signal?: AbortSignal, + isMCP = false, + ): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const jsonStr = line.slice(6); + if (jsonStr.trim() === "") continue; + + const data = JSON.parse(jsonStr); + + if (isMCP) { + switch (data.type) { + case "content": + if (typeof data.content === "string") onData(data.content); + break; + case "tool_call": + if (onToolCall && data.toolCall) onToolCall(data.toolCall); + break; + case "tool_call_start": + console.log("Tool call started:", data.toolCall?.toolName); + break; + case "completion": + onComplete?.(); + return; + case "error": + onError?.(new Error(data.content)); + return; + } + } else { + if (data.type === "text" && data.content) { + onData(data.content); + } else if (data.type === "completion") { + onComplete?.(); + return; + } + } + } catch (parseError) { + console.warn("Failed to parse SSE data:", parseError); + } + } + } + } + + onComplete?.(); + } + + async getChatStream( + request: ChatRequest, + onData: (content: string) => void, + onToolCall?: (toolCall: any) => void, + onError?: (error: Error) => void, + onComplete?: () => void, + signal?: AbortSignal, + ): Promise { + try { + const response = await fetch(this.config.chatEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.config.headers }, + body: JSON.stringify(request), + signal, + }); + + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`); + + await this.processStream( + response, + onData, + onToolCall, + onError, + onComplete, + signal, + true, + ); + } catch (error) { + if (signal?.aborted) return; + onError?.(error instanceof Error ? error : new Error(String(error))); + } + } + + async getLegacyChatStream( + message: string, + onData: (content: string) => void, + onError?: (error: Error) => void, + onComplete?: () => void, + signal?: AbortSignal, + ): Promise { + try { + const response = await fetch("/api/v1/assistant", { + method: "POST", + headers: { "Content-Type": "application/json", ...this.config.headers }, + body: JSON.stringify({ prompt: message }), + signal, + }); + + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`); + + await this.processStream( + response, + onData, + undefined, + onError, + onComplete, + signal, + false, + ); + } catch (error) { + if (signal?.aborted) return; + onError?.(error instanceof Error ? error : new Error(String(error))); + } + } +} diff --git a/ui/packages/chatui/src/index.tsx b/ui/packages/chatui/src/index.tsx new file mode 100644 index 00000000..aedd96ce --- /dev/null +++ b/ui/packages/chatui/src/index.tsx @@ -0,0 +1,28 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { FloatingChat } from "./FloatingChat"; +export { ChatAPI } from "./api"; +export type { + ChatUIProps, + ApiConfig, + QuickReply, + ChatMessage, + ToolCallInfo, + StreamResponse, + ChatRequest, + MCPToolsResponse, +} from "./types"; diff --git a/ui/packages/chatui/src/styles.css b/ui/packages/chatui/src/styles.css new file mode 100644 index 00000000..49d8bbb1 --- /dev/null +++ b/ui/packages/chatui/src/styles.css @@ -0,0 +1,17 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* Empty CSS file as all styles use inline styles */ diff --git a/ui/packages/chatui/src/types.ts b/ui/packages/chatui/src/types.ts new file mode 100644 index 00000000..dfc57b17 --- /dev/null +++ b/ui/packages/chatui/src/types.ts @@ -0,0 +1,68 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface QuickReply { + icon?: string; + name: string; + isHighlight?: boolean; +} + +export interface ChatMessage { + role: string; + content: string; +} + +export interface ToolCallInfo { + toolName: string; + args: Record; + result: string; +} + +export interface ApiConfig { + chatEndpoint: string; + toolsEndpoint: string; + headers?: Record; +} + +export interface ChatUIProps { + apiConfig: ApiConfig; + theme?: "light" | "dark"; + position?: "bottom-right" | "bottom-left" | "top-right" | "top-left"; + avatar?: string; + quickReplies?: QuickReply[]; + enableMCP?: boolean; + className?: string; + width?: number; + height?: number; + title?: string; +} + +export interface StreamResponse { + type: string; + content: any; + toolCall?: ToolCallInfo; +} + +export interface ChatRequest { + message: string; + history?: ChatMessage[]; + enableMcp?: boolean; +} + +export interface MCPToolsResponse { + tools: any[]; + enabled: boolean; +} diff --git a/ui/packages/chatui/tsconfig.json b/ui/packages/chatui/tsconfig.json new file mode 100644 index 00000000..eb37e8aa --- /dev/null +++ b/ui/packages/chatui/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + + /* Declaration */ + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/packages/chatui/tsup.config.ts b/ui/packages/chatui/tsup.config.ts new file mode 100644 index 00000000..1304da68 --- /dev/null +++ b/ui/packages/chatui/tsup.config.ts @@ -0,0 +1,46 @@ +/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { defineConfig } from "tsup"; + +const license = `/* +Copyright 2024 The Karmada Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/`; + +export default defineConfig({ + entry: ["src/index.tsx"], + splitting: false, + sourcemap: true, + clean: true, + format: ["cjs", "esm"], + external: ["react", "react-dom"], + dts: false, // 暂时禁用类型定义生成 + banner: { + js: license, + }, +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 0242a69f..489a99dc 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -41,6 +41,12 @@ importers: apps/dashboard: dependencies: + '@chatui/core': + specifier: ^3.1.0 + version: 3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@karmada/chatui': + specifier: workspace:* + version: link:../../packages/chatui '@karmada/i18n-tool': specifier: workspace:* version: link:../../packages/i18n-tool @@ -50,6 +56,9 @@ importers: '@karmada/utils': specifier: workspace:* version: link:../../packages/utils + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 '@monaco-editor/react': specifier: ^4.6.0 version: 4.6.0(monaco-editor@0.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -166,6 +175,25 @@ importers: specifier: ^4.2.0 version: 4.2.0(rollup@4.24.0)(typescript@5.6.3)(vite@5.4.8(@types/node@20.12.11)(less@4.2.0)) + packages/chatui: + dependencies: + '@chatui/core': + specifier: ^2.4.2 + version: 2.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18 + version: 18.3.1 + react-dom: + specifier: ^18 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.3 + version: 18.3.11 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 + packages/eslint-config-ts: dependencies: '@typescript-eslint/eslint-plugin': @@ -562,6 +590,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime-corejs3@7.28.3': + resolution: {integrity: sha512-LKYxD2CIfocUFNREQ1yk+dW+8OH8CRqmgatBZYXb+XhuObO8wsDpEoCNri5bKld9cnj8xukqZjxSX8p1YiRF8Q==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.24.5': resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==} engines: {node: '>=6.9.0'} @@ -641,6 +673,18 @@ packages: '@changesets/write@0.3.1': resolution: {integrity: sha512-SyGtMXzH3qFqlHKcvFY2eX+6b0NGiFcNav8AFsYwy5l8hejOeoeTDemu5Yjmke2V5jpzY+pBvM0vCCQ3gdZpfw==} + '@chatui/core@2.4.2': + resolution: {integrity: sha512-YyimOhHIMHQr0KjzaA9HZNWbOWa5atbG4rMYJwa7K/8je49wntI1OHLzQXl1npZfHWEKNNW4DkKf08xPkQoN7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@chatui/core@3.1.0': + resolution: {integrity: sha512-XY30AZmAqPaA/PxkV//vSsQibL/JrJ8CnPfwSxjix13Y4QINtcflVgIj40xVGedI9z7/6tvD+nMjnmn0tbCT2Q==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@ctrl/tinycolor@3.6.1': resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} engines: {node: '>=10'} @@ -998,6 +1042,9 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@microsoft/fetch-event-source@2.0.1': + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@monaco-editor/loader@1.4.0': resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==} peerDependencies: @@ -1962,6 +2009,10 @@ packages: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2009,6 +2060,12 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + core-js-pure@3.45.1: + resolution: {integrity: sha512-OHnWFKgTUshEU8MK+lOs1H8kC8GkTi9Z1tvNkxrCcw9wl3MJIO7q2ld77wjWn4/xuGrVu2X+nME1iIIPBSdyEQ==} + + core-js@3.45.1: + resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} + cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -2148,6 +2205,9 @@ packages: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} + dompurify@2.5.8: + resolution: {integrity: sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -2697,6 +2757,9 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + intersection-observer@0.12.2: + resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -4744,6 +4807,10 @@ snapshots: '@babel/core': 7.25.7 '@babel/helper-plugin-utils': 7.25.7 + '@babel/runtime-corejs3@7.28.3': + dependencies: + core-js-pure: 3.45.1 + '@babel/runtime@7.24.5': dependencies: regenerator-runtime: 0.14.1 @@ -4938,6 +5005,28 @@ snapshots: human-id: 1.0.2 prettier: 2.8.8 + '@chatui/core@2.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.7 + '@babel/runtime-corejs3': 7.28.3 + clsx: 1.2.1 + core-js: 3.45.1 + dompurify: 2.5.8 + intersection-observer: 0.12.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@chatui/core@3.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.7 + '@babel/runtime-corejs3': 7.28.3 + clsx: 1.2.1 + core-js: 3.45.1 + dompurify: 2.5.8 + intersection-observer: 0.12.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@ctrl/tinycolor@3.6.1': {} '@emotion/hash@0.8.0': {} @@ -5176,6 +5265,8 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@microsoft/fetch-event-source@2.0.1': {} + '@monaco-editor/loader@1.4.0(monaco-editor@0.48.0)': dependencies: monaco-editor: 0.48.0 @@ -6187,6 +6278,8 @@ snapshots: slice-ansi: 5.0.0 string-width: 7.1.0 + clsx@1.2.1: {} + clsx@2.1.1: {} color-convert@1.9.3: @@ -6225,6 +6318,10 @@ snapshots: dependencies: toggle-selection: 1.0.6 + core-js-pure@3.45.1: {} + + core-js@3.45.1: {} + cosmiconfig@8.3.6(typescript@5.6.3): dependencies: import-fresh: 3.3.0 @@ -6350,6 +6447,8 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@2.5.8: {} + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -7089,6 +7188,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.0.6 + intersection-observer@0.12.2: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0