diff --git a/server/api/v1/example/enter.go b/server/api/v1/example/enter.go index 68263dfe2e..4d753ec5c0 100644 --- a/server/api/v1/example/enter.go +++ b/server/api/v1/example/enter.go @@ -4,12 +4,14 @@ import "github.com/flipped-aurora/gin-vue-admin/server/service" type ApiGroup struct { CustomerApi - FileUploadAndDownloadApi + AttachmentCategoryApi + FileUploadAndDownloadApi } var ( - customerService = service.ServiceGroupApp.ExampleServiceGroup.CustomerService - fileUploadAndDownloadService = service.ServiceGroupApp.ExampleServiceGroup.FileUploadAndDownloadService + customerService = service.ServiceGroupApp.ExampleServiceGroup.CustomerService + attachmentCategoryService = service.ServiceGroupApp.ExampleServiceGroup.AttachmentCategoryService + fileUploadAndDownloadService = service.ServiceGroupApp.ExampleServiceGroup.FileUploadAndDownloadService ) diff --git a/server/api/v1/system/ai_workflow_session.go b/server/api/v1/system/ai_workflow_session.go new file mode 100644 index 0000000000..037fa8cb99 --- /dev/null +++ b/server/api/v1/system/ai_workflow_session.go @@ -0,0 +1,102 @@ +package system + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" + "github.com/flipped-aurora/gin-vue-admin/server/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type AIWorkflowSessionApi struct{} + +func (a *AIWorkflowSessionApi) Save(c *gin.Context) { + var info systemReq.SysAIWorkflowSessionUpsert + if err := c.ShouldBindJSON(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + session, err := aiWorkflowSessionService.Save(c.Request.Context(), utils.GetUserID(c), info) + if err != nil { + global.GVA_LOG.Error("保存 AI 工作流会话失败", zap.Error(err)) + response.FailWithMessage("保存会话失败", c) + return + } + + response.OkWithDetailed(gin.H{"session": session}, "保存成功", c) +} + +func (a *AIWorkflowSessionApi) GetList(c *gin.Context) { + var info systemReq.SysAIWorkflowSessionSearch + if err := c.ShouldBindJSON(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + list, total, err := aiWorkflowSessionService.GetList(c.Request.Context(), utils.GetUserID(c), info) + if err != nil { + global.GVA_LOG.Error("获取 AI 工作流会话列表失败", zap.Error(err)) + response.FailWithMessage("获取会话列表失败", c) + return + } + + response.OkWithDetailed(response.PageResult{ + List: list, + Total: total, + Page: info.Page, + PageSize: info.PageSize, + }, "获取成功", c) +} + +func (a *AIWorkflowSessionApi) GetDetail(c *gin.Context) { + var info commonReq.GetById + if err := c.ShouldBindJSON(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + session, err := aiWorkflowSessionService.GetDetail(c.Request.Context(), utils.GetUserID(c), info.Uint()) + if err != nil { + global.GVA_LOG.Error("获取 AI 工作流会话详情失败", zap.Error(err)) + response.FailWithMessage("获取会话详情失败", c) + return + } + + response.OkWithDetailed(gin.H{"session": session}, "获取成功", c) +} + +func (a *AIWorkflowSessionApi) Delete(c *gin.Context) { + var info commonReq.GetById + if err := c.ShouldBindJSON(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + if err := aiWorkflowSessionService.Delete(c.Request.Context(), utils.GetUserID(c), info.Uint()); err != nil { + global.GVA_LOG.Error("删除 AI 工作流会话失败", zap.Error(err)) + response.FailWithMessage("删除会话失败", c) + return + } + + response.OkWithMessage("删除成功", c) +} + +func (a *AIWorkflowSessionApi) DumpMarkdown(c *gin.Context) { + var info systemReq.SysAIWorkflowMarkdownDump + if err := c.ShouldBindJSON(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + result, err := aiWorkflowSessionService.DumpMarkdown(c.Request.Context(), utils.GetUserID(c), info) + if err != nil { + global.GVA_LOG.Error("AI 工作流 Markdown 落盘失败", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + response.OkWithDetailed(gin.H{"result": result}, "落盘成功", c) +} diff --git a/server/api/v1/system/auto_code_mcp.go b/server/api/v1/system/auto_code_mcp.go index 3549aeee47..671b8d5554 100644 --- a/server/api/v1/system/auto_code_mcp.go +++ b/server/api/v1/system/auto_code_mcp.go @@ -1,8 +1,10 @@ package system import ( - "fmt" + "strings" + "github.com/flipped-aurora/gin-vue-admin/server/global" + mcpTool "github.com/flipped-aurora/gin-vue-admin/server/mcp" "github.com/flipped-aurora/gin-vue-admin/server/mcp/client" "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" @@ -10,19 +12,9 @@ import ( "github.com/mark3labs/mcp-go/mcp" ) -// Create -// @Tags mcp -// @Summary 自动McpTool -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Param data body request.AutoMcpTool true "创建自动代码" -// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}" -// @Router /autoCode/mcp [post] func (a *AutoCodeTemplateApi) MCP(c *gin.Context) { var info request.AutoMcpTool - err := c.ShouldBindJSON(&info) - if err != nil { + if err := c.ShouldBindJSON(&info); err != nil { response.FailWithMessage(err.Error(), c) return } @@ -36,109 +28,146 @@ func (a *AutoCodeTemplateApi) MCP(c *gin.Context) { response.OkWithMessage("创建成功,MCP Tool路径:"+toolFilePath, c) } -// Create -// @Tags mcp -// @Summary 自动McpTool -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Param data body request.AutoMcpTool true "创建自动代码" -// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}" -// @Router /autoCode/mcpList [post] -func (a *AutoCodeTemplateApi) MCPList(c *gin.Context) { +func (a *AutoCodeTemplateApi) MCPStatus(c *gin.Context) { + response.OkWithData(gin.H{ + "status": mcpTool.GetManagedStandaloneStatus(c.Request.Context()), + "mcpServerConfig": buildMCPServerConfig(), + }, c) +} - baseUrl := fmt.Sprintf("http://127.0.0.1:%d%s", global.GVA_CONFIG.System.Addr, global.GVA_CONFIG.MCP.SSEPath) +func (a *AutoCodeTemplateApi) MCPStart(c *gin.Context) { + status, err := mcpTool.StartManagedStandalone(c.Request.Context()) + if err != nil { + response.FailWithDetailed(gin.H{ + "status": status, + "mcpServerConfig": buildMCPServerConfig(), + }, err.Error(), c) + return + } - testClient, err := client.NewClient(baseUrl, "testClient", "v1.0.0", global.GVA_CONFIG.MCP.Name) - defer testClient.Close() - toolsRequest := mcp.ListToolsRequest{} + response.OkWithDetailed(gin.H{ + "status": status, + "mcpServerConfig": buildMCPServerConfig(), + }, "MCP独立服务已启动", c) +} - list, err := testClient.ListTools(c.Request.Context(), toolsRequest) +func (a *AutoCodeTemplateApi) MCPStop(c *gin.Context) { + status, err := mcpTool.StopManagedStandalone(c.Request.Context()) + if err != nil { + response.FailWithDetailed(gin.H{ + "status": status, + "mcpServerConfig": buildMCPServerConfig(), + }, err.Error(), c) + return + } + response.OkWithDetailed(gin.H{ + "status": status, + "mcpServerConfig": buildMCPServerConfig(), + }, "MCP独立服务已停用", c) +} + +func (a *AutoCodeTemplateApi) MCPList(c *gin.Context) { + baseURL := mcpTool.ResolveMCPServiceURL() + testClient, err := client.NewClient(baseURL, "testClient", "v1.0.0", mcpServerName(), incomingMCPHeaders(c)) if err != nil { - response.FailWithMessage("创建失败", c) - global.GVA_LOG.Error(err.Error()) + response.FailWithDetailed(gin.H{ + "status": mcpTool.GetManagedStandaloneStatus(c.Request.Context()), + "mcpServerConfig": buildMCPServerConfig(), + }, "连接MCP服务失败:"+err.Error(), c) return } + defer testClient.Close() - mcpServerConfig := map[string]interface{}{ - "mcpServers": map[string]interface{}{ - global.GVA_CONFIG.MCP.Name: map[string]string{ - "url": baseUrl, - }, - }, + list, err := testClient.ListTools(c.Request.Context(), mcp.ListToolsRequest{}) + if err != nil { + response.FailWithDetailed(gin.H{ + "status": mcpTool.GetManagedStandaloneStatus(c.Request.Context()), + "mcpServerConfig": buildMCPServerConfig(), + }, "获取工具列表失败:"+err.Error(), c) + return } + response.OkWithData(gin.H{ - "mcpServerConfig": mcpServerConfig, + "status": mcpTool.GetManagedStandaloneStatus(c.Request.Context()), + "mcpServerConfig": buildMCPServerConfig(), "list": list, }, c) } -// Create -// @Tags mcp -// @Summary 测试McpTool -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Param data body object true "调用MCP Tool的参数" -// @Success 200 {object} response.Response "{"success":true,"data":{},"msg":"测试成功"}" -// @Router /autoCode/mcpTest [post] +func (a *AutoCodeTemplateApi) MCPRoutes(c *gin.Context) { + response.OkWithData(gin.H{ + "routes": global.GVA_ROUTERS, + }, c) +} + func (a *AutoCodeTemplateApi) MCPTest(c *gin.Context) { - // 定义接口请求结构 var testRequest struct { - Name string `json:"name" binding:"required"` // 工具名称 - Arguments map[string]interface{} `json:"arguments" binding:"required"` // 工具参数 + Name string `json:"name" binding:"required"` + Arguments map[string]interface{} `json:"arguments" binding:"required"` } - - // 绑定JSON请求体 if err := c.ShouldBindJSON(&testRequest); err != nil { response.FailWithMessage("参数解析失败:"+err.Error(), c) return } - // 创建MCP客户端 - baseUrl := fmt.Sprintf("http://127.0.0.1:%d%s", global.GVA_CONFIG.System.Addr, global.GVA_CONFIG.MCP.SSEPath) - testClient, err := client.NewClient(baseUrl, "testClient", "v1.0.0", global.GVA_CONFIG.MCP.Name) + baseURL := mcpTool.ResolveMCPServiceURL() + testClient, err := client.NewClient(baseURL, "testClient", "v1.0.0", mcpServerName(), incomingMCPHeaders(c)) if err != nil { - response.FailWithMessage("创建MCP客户端失败:"+err.Error(), c) + response.FailWithMessage("连接MCP服务失败:"+err.Error(), c) return } defer testClient.Close() - ctx := c.Request.Context() - - // 初始化MCP连接 - initRequest := mcp.InitializeRequest{} - initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION - initRequest.Params.ClientInfo = mcp.Implementation{ - Name: "testClient", - Version: "v1.0.0", - } - - _, err = testClient.Initialize(ctx, initRequest) - if err != nil { - response.FailWithMessage("初始化MCP连接失败:"+err.Error(), c) - return - } - - // 构建工具调用请求 - request := mcp.CallToolRequest{} - request.Params.Name = testRequest.Name - request.Params.Arguments = testRequest.Arguments + callRequest := mcp.CallToolRequest{} + callRequest.Params.Name = testRequest.Name + callRequest.Params.Arguments = testRequest.Arguments - // 调用工具 - result, err := testClient.CallTool(ctx, request) + result, err := testClient.CallTool(c.Request.Context(), callRequest) if err != nil { response.FailWithMessage("工具调用失败:"+err.Error(), c) return } - - // 处理响应结果 if len(result.Content) == 0 { response.FailWithMessage("工具未返回任何内容", c) return } - // 返回结果 response.OkWithData(result.Content, c) } + +func incomingMCPHeaders(c *gin.Context) map[string]string { + headerName := mcpTool.ConfiguredAuthHeader() + headerValue := c.GetHeader(headerName) + if headerValue == "" { + return nil + } + + return map[string]string{ + headerName: headerValue, + } +} + +func buildMCPServerConfig() map[string]interface{} { + baseURL := mcpTool.ResolveMCPServiceURL() + authHeader := mcpTool.ConfiguredAuthHeader() + serverName := mcpServerName() + + return map[string]interface{}{ + "mcpServers": map[string]interface{}{ + serverName: map[string]interface{}{ + "url": baseURL, + "headers": map[string]string{ + authHeader: "${YOUR_GVA_TOKEN}", + }, + }, + }, + } +} + +func mcpServerName() string { + if name := strings.TrimSpace(global.GVA_CONFIG.MCP.Name); name != "" { + return name + } + return "gva" +} diff --git a/server/api/v1/system/enter.go b/server/api/v1/system/enter.go index cde513b57f..aceac88bdf 100644 --- a/server/api/v1/system/enter.go +++ b/server/api/v1/system/enter.go @@ -27,31 +27,33 @@ type ApiGroup struct { LoginLogApi ApiTokenApi SkillsApi + AIWorkflowSessionApi } var ( - apiService = service.ServiceGroupApp.SystemServiceGroup.ApiService - jwtService = service.ServiceGroupApp.SystemServiceGroup.JwtService - menuService = service.ServiceGroupApp.SystemServiceGroup.MenuService - userService = service.ServiceGroupApp.SystemServiceGroup.UserService - initDBService = service.ServiceGroupApp.SystemServiceGroup.InitDBService - casbinService = service.ServiceGroupApp.SystemServiceGroup.CasbinService - baseMenuService = service.ServiceGroupApp.SystemServiceGroup.BaseMenuService - authorityService = service.ServiceGroupApp.SystemServiceGroup.AuthorityService - dictionaryService = service.ServiceGroupApp.SystemServiceGroup.DictionaryService - authorityBtnService = service.ServiceGroupApp.SystemServiceGroup.AuthorityBtnService - systemConfigService = service.ServiceGroupApp.SystemServiceGroup.SystemConfigService - sysParamsService = service.ServiceGroupApp.SystemServiceGroup.SysParamsService - operationRecordService = service.ServiceGroupApp.SystemServiceGroup.OperationRecordService - dictionaryDetailService = service.ServiceGroupApp.SystemServiceGroup.DictionaryDetailService - autoCodeService = service.ServiceGroupApp.SystemServiceGroup.AutoCodeService - autoCodePluginService = service.ServiceGroupApp.SystemServiceGroup.AutoCodePlugin - autoCodePackageService = service.ServiceGroupApp.SystemServiceGroup.AutoCodePackage - autoCodeHistoryService = service.ServiceGroupApp.SystemServiceGroup.AutoCodeHistory - autoCodeTemplateService = service.ServiceGroupApp.SystemServiceGroup.AutoCodeTemplate - sysVersionService = service.ServiceGroupApp.SystemServiceGroup.SysVersionService - sysErrorService = service.ServiceGroupApp.SystemServiceGroup.SysErrorService - loginLogService = service.ServiceGroupApp.SystemServiceGroup.LoginLogService - apiTokenService = service.ServiceGroupApp.SystemServiceGroup.ApiTokenService - skillsService = service.ServiceGroupApp.SystemServiceGroup.SkillsService + apiService = service.ServiceGroupApp.SystemServiceGroup.ApiService + jwtService = service.ServiceGroupApp.SystemServiceGroup.JwtService + menuService = service.ServiceGroupApp.SystemServiceGroup.MenuService + userService = service.ServiceGroupApp.SystemServiceGroup.UserService + initDBService = service.ServiceGroupApp.SystemServiceGroup.InitDBService + casbinService = service.ServiceGroupApp.SystemServiceGroup.CasbinService + baseMenuService = service.ServiceGroupApp.SystemServiceGroup.BaseMenuService + authorityService = service.ServiceGroupApp.SystemServiceGroup.AuthorityService + dictionaryService = service.ServiceGroupApp.SystemServiceGroup.DictionaryService + authorityBtnService = service.ServiceGroupApp.SystemServiceGroup.AuthorityBtnService + systemConfigService = service.ServiceGroupApp.SystemServiceGroup.SystemConfigService + sysParamsService = service.ServiceGroupApp.SystemServiceGroup.SysParamsService + operationRecordService = service.ServiceGroupApp.SystemServiceGroup.OperationRecordService + dictionaryDetailService = service.ServiceGroupApp.SystemServiceGroup.DictionaryDetailService + autoCodeService = service.ServiceGroupApp.SystemServiceGroup.AutoCodeService + aiWorkflowSessionService = service.ServiceGroupApp.SystemServiceGroup.AIWorkflowSession + autoCodePluginService = service.ServiceGroupApp.SystemServiceGroup.AutoCodePlugin + autoCodePackageService = service.ServiceGroupApp.SystemServiceGroup.AutoCodePackage + autoCodeHistoryService = service.ServiceGroupApp.SystemServiceGroup.AutoCodeHistory + autoCodeTemplateService = service.ServiceGroupApp.SystemServiceGroup.AutoCodeTemplate + sysVersionService = service.ServiceGroupApp.SystemServiceGroup.SysVersionService + sysErrorService = service.ServiceGroupApp.SystemServiceGroup.SysErrorService + loginLogService = service.ServiceGroupApp.SystemServiceGroup.LoginLogService + apiTokenService = service.ServiceGroupApp.SystemServiceGroup.ApiTokenService + skillsService = service.ServiceGroupApp.SystemServiceGroup.SkillsService ) diff --git a/server/api/v1/system/sys_auto_code.go b/server/api/v1/system/sys_auto_code.go index 1283b0208b..e0505d51b8 100644 --- a/server/api/v1/system/sys_auto_code.go +++ b/server/api/v1/system/sys_auto_code.go @@ -1,52 +1,43 @@ package system import ( - "github.com/flipped-aurora/gin-vue-admin/server/model/common" + "errors" + "fmt" + "io" + "net/http" + "strings" "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/common" "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" "github.com/gin-gonic/gin" + "github.com/goccy/go-json" "go.uber.org/zap" ) type AutoCodeApi struct{} -// GetDB -// @Tags AutoCode -// @Summary 获取当前所有数据库 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {object} response.Response{data=map[string]interface{},msg=string} "获取当前所有数据库" -// @Router /autoCode/getDB [get] func (autoApi *AutoCodeApi) GetDB(c *gin.Context) { businessDB := c.Query("businessDB") dbs, err := autoCodeService.Database(businessDB).GetDB(businessDB) var dbList []map[string]interface{} for _, db := range global.GVA_CONFIG.DBList { - var item = make(map[string]interface{}) - item["aliasName"] = db.AliasName - item["dbName"] = db.Dbname - item["disable"] = db.Disable - item["dbtype"] = db.Type + item := map[string]interface{}{ + "aliasName": db.AliasName, + "dbName": db.Dbname, + "disable": db.Disable, + "dbtype": db.Type, + } dbList = append(dbList, item) } if err != nil { global.GVA_LOG.Error("获取失败!", zap.Error(err)) response.FailWithMessage("获取失败", c) - } else { - response.OkWithDetailed(gin.H{"dbs": dbs, "dbList": dbList}, "获取成功", c) + return } + response.OkWithDetailed(gin.H{"dbs": dbs, "dbList": dbList}, "获取成功", c) } -// GetTables -// @Tags AutoCode -// @Summary 获取当前数据库所有表 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {object} response.Response{data=map[string]interface{},msg=string} "获取当前数据库所有表" -// @Router /autoCode/getTables [get] func (autoApi *AutoCodeApi) GetTables(c *gin.Context) { dbName := c.Query("dbName") businessDB := c.Query("businessDB") @@ -65,19 +56,11 @@ func (autoApi *AutoCodeApi) GetTables(c *gin.Context) { if err != nil { global.GVA_LOG.Error("查询table失败!", zap.Error(err)) response.FailWithMessage("查询table失败", c) - } else { - response.OkWithDetailed(gin.H{"tables": tables}, "获取成功", c) + return } + response.OkWithDetailed(gin.H{"tables": tables}, "获取成功", c) } -// GetColumn -// @Tags AutoCode -// @Summary 获取当前表所有字段 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {object} response.Response{data=map[string]interface{},msg=string} "获取当前表所有字段" -// @Router /autoCode/getColumn [get] func (autoApi *AutoCodeApi) GetColumn(c *gin.Context) { businessDB := c.Query("businessDB") dbName := c.Query("dbName") @@ -96,9 +79,9 @@ func (autoApi *AutoCodeApi) GetColumn(c *gin.Context) { if err != nil { global.GVA_LOG.Error("获取失败!", zap.Error(err)) response.FailWithMessage("获取失败", c) - } else { - response.OkWithDetailed(gin.H{"columns": columns}, "获取成功", c) + return } + response.OkWithDetailed(gin.H{"columns": columns}, "获取成功", c) } func (autoApi *AutoCodeApi) LLMAuto(c *gin.Context) { @@ -107,11 +90,130 @@ func (autoApi *AutoCodeApi) LLMAuto(c *gin.Context) { response.FailWithMessage(err.Error(), c) return } + + if shouldStreamLLM(c, llm) { + if err := autoApi.proxyLLMStream(c, llm); err != nil { + global.GVA_LOG.Error("大模型流式代理失败!", zap.Error(err)) + if c.Writer.Written() { + writeLLMStreamError(c, err) + return + } + response.FailWithMessage(err.Error(), c) + } + return + } + data, err := autoCodeService.LLMAuto(c.Request.Context(), llm) if err != nil { global.GVA_LOG.Error("大模型生成失败!", zap.Error(err)) - response.FailWithMessage("大模型生成失败"+err.Error(), c) + response.FailWithMessage(err.Error(), c) return } response.OkWithData(data, c) } + +func shouldStreamLLM(c *gin.Context, llm common.JSONMap) bool { + responseMode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", llm["response_mode"]))) + if responseMode == "streaming" || responseMode == "sse" { + return true + } + if stream, ok := llm["stream"].(bool); ok && stream { + return true + } + return strings.Contains(strings.ToLower(c.GetHeader("Accept")), "text/event-stream") +} + +func (autoApi *AutoCodeApi) proxyLLMStream(c *gin.Context, llm common.JSONMap) error { + res, err := autoCodeService.LLMAutoStream(c.Request.Context(), llm) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + return fmt.Errorf("上游大模型流式服务返回非 2xx: status=%d content-type=%s read-body-err=%w", res.StatusCode, res.Header.Get("Content-Type"), readErr) + } + return fmt.Errorf("上游大模型流式服务返回非 2xx: status=%d content-type=%s body=%s", res.StatusCode, res.Header.Get("Content-Type"), previewResponseBody(body)) + } + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return errors.New("当前响应不支持流式输出") + } + + copyLLMStreamHeaders(c.Writer.Header(), res.Header) + if c.Writer.Header().Get("Content-Type") == "" { + c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + } + if c.Writer.Header().Get("Cache-Control") == "" { + c.Writer.Header().Set("Cache-Control", "no-cache") + } + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Status(res.StatusCode) + flusher.Flush() + + buf := make([]byte, 32*1024) + for { + n, readErr := res.Body.Read(buf) + if n > 0 { + if _, writeErr := c.Writer.Write(buf[:n]); writeErr != nil { + return fmt.Errorf("向客户端写入流式响应失败: %w", writeErr) + } + flusher.Flush() + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return fmt.Errorf("读取上游流式响应失败: %w", readErr) + } + } +} + +func copyLLMStreamHeaders(dst, src http.Header) { + for _, key := range []string{ + "Content-Type", + "Cache-Control", + "Content-Encoding", + "Content-Language", + "X-Accel-Buffering", + } { + if value := src.Get(key); value != "" { + dst.Set(key, value) + } + } +} + +func writeLLMStreamError(c *gin.Context, err error) { + payload, marshalErr := json.Marshal(gin.H{ + "message": err.Error(), + }) + if marshalErr != nil { + payload = []byte(`{"message":"流式代理失败"}`) + } + _, _ = c.Writer.WriteString("event: error\n") + _, _ = c.Writer.WriteString("data: ") + _, _ = c.Writer.Write(payload) + _, _ = c.Writer.WriteString("\n\n") + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } +} + +func previewResponseBody(body []byte) string { + text := strings.TrimSpace(string(body)) + text = strings.ReplaceAll(text, "\r", " ") + text = strings.ReplaceAll(text, "\n", " ") + text = strings.Join(strings.Fields(text), " ") + if text == "" { + return "" + } + runes := []rune(text) + if len(runes) > 300 { + return string(runes[:300]) + "..." + } + return text +} diff --git a/server/api/v1/system/sys_auto_code_sse.go b/server/api/v1/system/sys_auto_code_sse.go new file mode 100644 index 0000000000..1acb96d943 --- /dev/null +++ b/server/api/v1/system/sys_auto_code_sse.go @@ -0,0 +1,210 @@ +package system + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/common" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-contrib/sse" + "github.com/gin-gonic/gin" + "github.com/goccy/go-json" + "go.uber.org/zap" +) + +func (autoApi *AutoCodeApi) LLMAutoSSE(c *gin.Context) { + var llm common.JSONMap + if err := c.ShouldBindJSON(&llm); err != nil { + global.GVA_LOG.Error("LLMAutoSSE 参数绑定失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + if llm == nil { + llm = common.JSONMap{} + } + llm["response_mode"] = "streaming" + global.GVA_LOG.Info("LLMAutoSSE 收到请求", zap.Any("mode", llm["mode"])) + + if err := autoApi.streamLLMAsSSE(c, llm); err != nil { + global.GVA_LOG.Error("大模型 SSE 代理失败!", zap.Error(err)) + if c.Writer.Written() { + writeLLMStreamError(c, err) + return + } + response.FailWithMessage(err.Error(), c) + } +} + +func (autoApi *AutoCodeApi) streamLLMAsSSE(c *gin.Context, llm common.JSONMap) error { + res, err := autoCodeService.LLMAutoStream(c.Request.Context(), llm) + if err != nil { + return fmt.Errorf("调用上游大模型失败: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + return fmt.Errorf("上游大模型流式服务返回非 2xx: status=%d content-type=%s read-body-err=%w", res.StatusCode, res.Header.Get("Content-Type"), readErr) + } + return fmt.Errorf("上游大模型流式服务返回非 2xx: status=%d content-type=%s body=%s", res.StatusCode, res.Header.Get("Content-Type"), previewResponseBody(body)) + } + + ct := res.Header.Get("Content-Type") + global.GVA_LOG.Info("LLMAutoSSE 上游返回成功,开始 SSE 流式转发", + zap.Int("status", res.StatusCode), + zap.String("content-type", ct)) + + // 如果上游返回的不是 SSE 流(可能是 blocking 模式返回的 JSON),直接读取并转发 + if !strings.Contains(ct, "text/event-stream") && !strings.Contains(ct, "text/plain") { + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + return fmt.Errorf("读取上游非流式响应失败: %w", readErr) + } + global.GVA_LOG.Warn("LLMAutoSSE 上游返回非 SSE 流,Content-Type: "+ct+", 将以单次事件转发", + zap.String("body_preview", previewResponseBody(body))) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return errors.New("当前响应不支持流式输出") + } + prepareSSEHeaders(c) + c.Status(http.StatusOK) + + var payload any + if err := json.Unmarshal(body, &payload); err != nil { + payload = string(body) + } + if err := renderSSE(c, sse.Event{Event: "message", Data: payload}); err != nil { + return err + } + if err := renderSSE(c, sse.Event{Event: "done", Data: gin.H{"done": true}}); err != nil { + return err + } + flusher.Flush() + return nil + } + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return errors.New("当前响应不支持流式输出") + } + + prepareSSEHeaders(c) + c.Status(http.StatusOK) + flusher.Flush() + + reader := bufio.NewReader(res.Body) + lines := make([]string, 0, 8) + blockCount := 0 + + global.GVA_LOG.Info("LLMAutoSSE 开始读取上游流数据...") + + for { + global.GVA_LOG.Debug("LLMAutoSSE 等待读取下一行...") + line, readErr := reader.ReadString('\n') + if readErr != nil && !errors.Is(readErr, io.EOF) { + global.GVA_LOG.Error("LLMAutoSSE 读取上游流失败", zap.Int("已转发块数", blockCount), zap.Error(readErr)) + return fmt.Errorf("读取上游流式响应失败: %w", readErr) + } + + line = strings.TrimRight(line, "\r\n") + if line == "" { + if len(lines) > 0 { + blockCount++ + if blockCount <= 3 { + global.GVA_LOG.Debug("LLMAutoSSE 转发 SSE 块", zap.Int("block", blockCount), zap.Strings("lines", lines)) + } + } + if err := emitSSEBlock(c, lines); err != nil { + return err + } + lines = lines[:0] + } else { + lines = append(lines, line) + } + + if errors.Is(readErr, io.EOF) { + if err := emitSSEBlock(c, lines); err != nil { + return err + } + if err := renderSSE(c, sse.Event{ + Event: "done", + Data: gin.H{"done": true}, + }); err != nil { + return err + } + flusher.Flush() + global.GVA_LOG.Info("LLMAutoSSE 流式转发完成", zap.Int("总块数", blockCount)) + return nil + } + } +} + +func prepareSSEHeaders(c *gin.Context) { + header := c.Writer.Header() + header.Set("Content-Type", "text/event-stream; charset=utf-8") + header.Set("Cache-Control", "no-cache, no-transform") + header.Set("Connection", "keep-alive") + header.Set("X-Accel-Buffering", "no") +} + +func emitSSEBlock(c *gin.Context, lines []string) error { + if len(lines) == 0 { + return nil + } + + eventName := "message" + eventID := "" + dataLines := make([]string, 0, len(lines)) + + for _, line := range lines { + switch { + case strings.HasPrefix(line, "event:"): + eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "id:"): + eventID = strings.TrimSpace(strings.TrimPrefix(line, "id:")) + case strings.HasPrefix(line, "data:"): + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + + rawData := strings.TrimSpace(strings.Join(dataLines, "\n")) + if rawData == "" { + return nil + } + if rawData == "[DONE]" { + return renderSSE(c, sse.Event{ + Id: eventID, + Event: "done", + Data: gin.H{"done": true}, + }) + } + + var payload interface{} + if err := json.Unmarshal([]byte(rawData), &payload); err != nil { + payload = rawData + } + + return renderSSE(c, sse.Event{ + Id: eventID, + Event: eventName, + Data: payload, + }) +} + +func renderSSE(c *gin.Context, event sse.Event) error { + if err := event.Render(c.Writer); err != nil { + return fmt.Errorf("写入 SSE 事件失败: %w", err) + } + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } + return nil +} diff --git a/server/cmd/mcp/config.go b/server/cmd/mcp/config.go new file mode 100644 index 0000000000..cc1d906c46 --- /dev/null +++ b/server/cmd/mcp/config.go @@ -0,0 +1,192 @@ +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/flipped-aurora/gin-vue-admin/server/config" + "github.com/flipped-aurora/gin-vue-admin/server/global" + "gopkg.in/yaml.v3" +) + +type standaloneConfig struct { + MCP config.MCP `yaml:"mcp"` + AutoCode config.Autocode `yaml:"autocode"` +} + +func loadStandaloneConfig() (string, error) { + configPath, err := resolveConfigPath() + if err != nil { + return "", err + } + + content, err := os.ReadFile(configPath) + if err != nil { + return "", fmt.Errorf("读取 MCP 配置失败: %w", err) + } + + var cfg standaloneConfig + if err := yaml.Unmarshal(content, &cfg); err != nil { + return "", fmt.Errorf("解析 MCP 配置失败: %w", err) + } + + applyStandaloneDefaults(configPath, &cfg) + + global.GVA_CONFIG.MCP = cfg.MCP + global.GVA_CONFIG.AutoCode = cfg.AutoCode + + return configPath, nil +} + +func resolveConfigPath() (string, error) { + explicit, err := parseConfigFlag(os.Args[1:]) + if err != nil { + return "", err + } + if explicit != "" { + return filepath.Abs(explicit) + } + + if envPath := strings.TrimSpace(os.Getenv("GVA_MCP_CONFIG")); envPath != "" { + return filepath.Abs(envPath) + } + + wd, _ := os.Getwd() + exe, _ := os.Executable() + exeDir := filepath.Dir(exe) + + candidates := []string{ + filepath.Join(wd, "config.yaml"), + filepath.Join(wd, "mcp.yaml"), + filepath.Join(wd, "cmd", "mcp", "config.yaml"), + filepath.Join(wd, "server", "cmd", "mcp", "config.yaml"), + filepath.Join(exeDir, "config.yaml"), + filepath.Join(exeDir, "mcp.yaml"), + } + + for _, candidate := range candidates { + if candidate == "" { + continue + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return filepath.Abs(candidate) + } + } + + return "", errors.New("未找到 MCP 独立配置文件,请在当前目录、cmd/mcp 目录或通过 -config / GVA_MCP_CONFIG 指定 config.yaml") +} + +func parseConfigFlag(args []string) (string, error) { + fs := flag.NewFlagSet("gva-mcp", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var configPath string + fs.StringVar(&configPath, "c", "", "MCP config file path") + fs.StringVar(&configPath, "config", "", "MCP config file path") + if err := fs.Parse(args); err != nil { + return "", err + } + + return strings.TrimSpace(configPath), nil +} + +func applyStandaloneDefaults(configPath string, cfg *standaloneConfig) { + if cfg.MCP.Name == "" { + cfg.MCP.Name = "GVA_MCP" + } + if cfg.MCP.Version == "" { + cfg.MCP.Version = "v1.0.0" + } + if cfg.MCP.Path == "" { + cfg.MCP.Path = "/mcp" + } + if cfg.MCP.Addr == 0 { + cfg.MCP.Addr = 8889 + } + if cfg.MCP.AuthHeader == "" { + cfg.MCP.AuthHeader = "x-token" + } + if cfg.MCP.RequestTimeout <= 0 { + cfg.MCP.RequestTimeout = 15 + } + if cfg.MCP.UpstreamBaseURL == "" { + cfg.MCP.UpstreamBaseURL = "http://127.0.0.1:8888" + } + if cfg.MCP.BaseURL == "" { + cfg.MCP.BaseURL = fmt.Sprintf("http://127.0.0.1:%d%s", cfg.MCP.Addr, cfg.MCP.Path) + } + + configDir := filepath.Dir(configPath) + if cfg.AutoCode.Server == "" { + cfg.AutoCode.Server = "server" + } + if cfg.AutoCode.Web == "" { + cfg.AutoCode.Web = "web/src" + } + if cfg.AutoCode.Root == "" { + if root, err := detectProjectRoot(configDir); err == nil { + cfg.AutoCode.Root = root + } + } else if !filepath.IsAbs(cfg.AutoCode.Root) { + cfg.AutoCode.Root = filepath.Clean(filepath.Join(configDir, cfg.AutoCode.Root)) + } + + if cfg.AutoCode.Module == "" && cfg.AutoCode.Root != "" { + goModPath := filepath.Join(cfg.AutoCode.Root, cfg.AutoCode.Server, "go.mod") + if module, err := detectGoModule(goModPath); err == nil { + cfg.AutoCode.Module = module + } + } +} + +func detectProjectRoot(startDir string) (string, error) { + dir := startDir + for { + serverDir := filepath.Join(dir, "server") + webDir := filepath.Join(dir, "web") + if isDir(serverDir) && isDir(webDir) { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", errors.New("未能自动识别项目根目录,请在 MCP 配置中设置 autocode.root") +} + +func detectGoModule(goModPath string) (string, error) { + file, err := os.Open(goModPath) + if err != nil { + return "", err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil + } + } + + if err := scanner.Err(); err != nil { + return "", err + } + + return "", errors.New("go.mod 中未找到 module 定义") +} + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/server/cmd/mcp/config.yaml b/server/cmd/mcp/config.yaml new file mode 100644 index 0000000000..84fd08217b --- /dev/null +++ b/server/cmd/mcp/config.yaml @@ -0,0 +1,14 @@ +mcp: + name: GVA_MCP + version: v1.0.0 + path: /mcp + addr: 8889 + base_url: http://127.0.0.1:8889/mcp + upstream_base_url: http://127.0.0.1:8888 + auth_header: x-token + request_timeout: 15 + +autocode: + root: ../../.. + server: server + web: web/src diff --git a/server/cmd/mcp/logger.go b/server/cmd/mcp/logger.go new file mode 100644 index 0000000000..ebeaf7c1a1 --- /dev/null +++ b/server/cmd/mcp/logger.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "go.uber.org/zap" +) + +func initializeStandaloneLogger() error { + logger, err := zap.NewDevelopment() + if err != nil { + return err + } + + global.GVA_LOG = logger + zap.ReplaceGlobals(logger) + return nil +} diff --git a/server/cmd/mcp/main.go b/server/cmd/mcp/main.go new file mode 100644 index 0000000000..ef7c353959 --- /dev/null +++ b/server/cmd/mcp/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + mcpTool "github.com/flipped-aurora/gin-vue-admin/server/mcp" + _ "go.uber.org/automaxprocs" + "go.uber.org/zap" +) + +func main() { + configPath, err := loadStandaloneConfig() + if err != nil { + panic(err) + } + + if err := initializeStandaloneLogger(); err != nil { + panic(err) + } + + addr := fmt.Sprintf(":%d", global.GVA_CONFIG.MCP.Addr) + server := mcpTool.NewStreamableHTTPServer() + + global.GVA_LOG.Info("mcp独立服务启动", + zap.String("config", configPath), + zap.String("addr", addr), + zap.String("path", global.GVA_CONFIG.MCP.Path), + zap.String("upstream", global.GVA_CONFIG.MCP.UpstreamBaseURL), + ) + + if err := server.Start(addr); err != nil { + global.GVA_LOG.Fatal("mcp独立服务启动失败", zap.Error(err)) + } +} diff --git a/server/config.docker.yaml b/server/config.docker.yaml index 3a3368205d..42d1124c51 100644 --- a/server/config.docker.yaml +++ b/server/config.docker.yaml @@ -278,8 +278,5 @@ cors: mcp: name: GVA_MCP version: v1.0.0 - sse_path: /sse - message_path: /message - url_prefix: '' addr: 8889 separate: false diff --git a/server/config.yaml b/server/config.yaml index 29a5ee10e3..c4574f0427 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -279,8 +279,5 @@ cors: mcp: name: GVA_MCP version: v1.0.0 - sse_path: /sse - message_path: /message - url_prefix: '' addr: 8889 separate: false diff --git a/server/config/mcp.go b/server/config/mcp.go index 15a78760d0..87028f36fd 100644 --- a/server/config/mcp.go +++ b/server/config/mcp.go @@ -1,11 +1,18 @@ package config type MCP struct { - Name string `mapstructure:"name" json:"name" yaml:"name"` // MCP名称 - Version string `mapstructure:"version" json:"version" yaml:"version"` // MCP版本 - SSEPath string `mapstructure:"sse_path" json:"sse_path" yaml:"sse_path"` // SSE路径 - MessagePath string `mapstructure:"message_path" json:"message_path" yaml:"message_path"` // 消息路径 - UrlPrefix string `mapstructure:"url_prefix" json:"url_prefix" yaml:"url_prefix"` // URL前缀 - Addr int `mapstructure:"addr" json:"addr" yaml:"addr"` // 独立MCP服务端口 - Separate bool `mapstructure:"separate" json:"separate" yaml:"separate"` // 是否独立运行MCP服务 + Name string `mapstructure:"name" json:"name" yaml:"name"` + Version string `mapstructure:"version" json:"version" yaml:"version"` + Path string `mapstructure:"path" json:"path" yaml:"path"` + Addr int `mapstructure:"addr" json:"addr" yaml:"addr"` + BaseURL string `mapstructure:"base_url" json:"base_url" yaml:"base_url"` + UpstreamBaseURL string `mapstructure:"upstream_base_url" json:"upstream_base_url" yaml:"upstream_base_url"` + AuthHeader string `mapstructure:"auth_header" json:"auth_header" yaml:"auth_header"` + RequestTimeout int `mapstructure:"request_timeout" json:"request_timeout" yaml:"request_timeout"` + + // Deprecated fields kept for backward compatibility with older configs. + SSEPath string `mapstructure:"sse_path" json:"sse_path" yaml:"sse_path"` + MessagePath string `mapstructure:"message_path" json:"message_path" yaml:"message_path"` + UrlPrefix string `mapstructure:"url_prefix" json:"url_prefix" yaml:"url_prefix"` + Separate bool `mapstructure:"separate" json:"separate" yaml:"separate"` } diff --git a/server/core/server.go b/server/core/server.go index 4ffba5c3ef..6c36f33455 100644 --- a/server/core/server.go +++ b/server/core/server.go @@ -2,16 +2,17 @@ package core import ( "fmt" + "time" + "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/initialize" + mcpTool "github.com/flipped-aurora/gin-vue-admin/server/mcp" "github.com/flipped-aurora/gin-vue-admin/server/service/system" "go.uber.org/zap" - "time" ) func RunServer() { if global.GVA_CONFIG.System.UseRedis { - // 初始化redis服务 initialize.Redis() if global.GVA_CONFIG.System.UseMultipoint { initialize.RedisList() @@ -19,36 +20,29 @@ func RunServer() { } if global.GVA_CONFIG.System.UseMongo { - err := initialize.Mongo.Initialization() - if err != nil { + if err := initialize.Mongo.Initialization(); err != nil { zap.L().Error(fmt.Sprintf("%+v", err)) } } - // 从db加载jwt数据 + if global.GVA_DB != nil { system.LoadAll() } Router := initialize.Routers() - address := fmt.Sprintf(":%d", global.GVA_CONFIG.System.Addr) + mcpBaseURL := mcpTool.ResolveMCPServiceURL() fmt.Printf(` 欢迎使用 gin-vue-admin 当前版本:%s - 加群方式:微信号:shouzi_1994 QQ群:470239250 - 项目地址:https://github.com/flipped-aurora/gin-vue-admin + 项目地址:https://github.com/flipped-aurora/gin-vue-admin 插件市场:https://plugin.gin-vue-admin.com - GVA讨论社区:https://support.qq.com/products/371961 默认自动化文档地址:http://127.0.0.1%s/swagger/index.html - 默认MCP SSE地址:http://127.0.0.1%s%s - 默认MCP Message地址:http://127.0.0.1%s%s + MCP 独立服务请手动启动: go run ./cmd/mcp -config ./cmd/mcp/config.yaml + 默认MCP StreamHTTP地址:%s 默认前端文件运行地址:http://127.0.0.1:8080 - --------------------------------------版权声明-------------------------------------- - ** 版权所有方:flipped-aurora开源团队 ** - ** 版权持有公司:北京翻转极光科技有限责任公司 ** - ** 剔除授权标识需购买商用授权:https://plugin.gin-vue-admin.com/license ** - ** 感谢您对Gin-Vue-Admin的支持与关注 合法授权使用更有利于项目的长久发展** -`, global.Version, address, address, global.GVA_CONFIG.MCP.SSEPath, address, global.GVA_CONFIG.MCP.MessagePath) +`, global.Version, address, mcpBaseURL) + initServer(address, Router, 10*time.Minute, 10*time.Minute) } diff --git a/server/go.mod b/server/go.mod index 42326d060a..fc2d09d922 100644 --- a/server/go.mod +++ b/server/go.mod @@ -15,6 +15,7 @@ require ( github.com/casbin/gorm-adapter/v3 v3.32.0 github.com/dzwvip/gorm-oracle v0.1.2 github.com/fsnotify/fsnotify v1.8.0 + github.com/gin-contrib/sse v1.0.0 github.com/gin-gonic/gin v1.10.0 github.com/glebarez/sqlite v1.11.0 github.com/go-sql-driver/mysql v1.8.1 @@ -100,7 +101,6 @@ require ( github.com/emirpasic/gods v1.12.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gammazero/toposort v0.1.1 // indirect - github.com/gin-contrib/sse v1.0.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect diff --git a/server/initialize/ensure_tables.go b/server/initialize/ensure_tables.go index d9e6cccbbc..13fb50f228 100644 --- a/server/initialize/ensure_tables.go +++ b/server/initialize/ensure_tables.go @@ -43,6 +43,7 @@ func (e *ensureTables) MigrateTable(ctx context.Context) (context.Context, error sysModel.JwtBlacklist{}, sysModel.SysDictionary{}, sysModel.SysAutoCodeHistory{}, + sysModel.SysAIWorkflowSession{}, sysModel.SysOperationRecord{}, sysModel.SysDictionaryDetail{}, sysModel.SysBaseMenuParameter{}, @@ -88,6 +89,7 @@ func (e *ensureTables) TableCreated(ctx context.Context) bool { sysModel.JwtBlacklist{}, sysModel.SysDictionary{}, sysModel.SysAutoCodeHistory{}, + sysModel.SysAIWorkflowSession{}, sysModel.SysOperationRecord{}, sysModel.SysDictionaryDetail{}, sysModel.SysBaseMenuParameter{}, diff --git a/server/initialize/mcp.go b/server/initialize/mcp.go deleted file mode 100644 index 5e03f2940f..0000000000 --- a/server/initialize/mcp.go +++ /dev/null @@ -1,25 +0,0 @@ -package initialize - -import ( - "github.com/flipped-aurora/gin-vue-admin/server/global" - mcpTool "github.com/flipped-aurora/gin-vue-admin/server/mcp" - "github.com/mark3labs/mcp-go/server" -) - -func McpRun() *server.SSEServer { - config := global.GVA_CONFIG.MCP - - s := server.NewMCPServer( - config.Name, - config.Version, - ) - - global.GVA_MCP_SERVER = s - - mcpTool.RegisterAllTools(s) - - return server.NewSSEServer(s, - server.WithSSEEndpoint(config.SSEPath), - server.WithMessageEndpoint(config.MessagePath), - server.WithBaseURL(config.UrlPrefix)) -} diff --git a/server/initialize/router.go b/server/initialize/router.go index 5455f4c28d..48e885ee98 100644 --- a/server/initialize/router.go +++ b/server/initialize/router.go @@ -24,7 +24,7 @@ func (fs justFilesFilesystem) Open(name string) (http.File, error) { } stat, err := f.Stat() - if stat.IsDir() { + if err == nil && stat.IsDir() { return nil, os.ErrPermission } @@ -41,20 +41,6 @@ func Routers() *gin.Engine { Router.Use(gin.Logger()) } - if !global.GVA_CONFIG.MCP.Separate { - - sseServer := McpRun() - - // 注册mcp服务 - Router.GET(global.GVA_CONFIG.MCP.SSEPath, func(c *gin.Context) { - sseServer.SSEHandler().ServeHTTP(c.Writer, c.Request) - }) - - Router.POST(global.GVA_CONFIG.MCP.MessagePath, func(c *gin.Context) { - sseServer.MessageHandler().ServeHTTP(c.Writer, c.Request) - }) - } - systemRouter := router.RouterGroupApp.System exampleRouter := router.RouterGroupApp.Example // 如果想要不使用nginx代理前端网页,可以修改 web/.env.production 下的 @@ -65,7 +51,8 @@ func Routers() *gin.Engine { // Router.Static("/assets", "./dist/assets") // dist里面的静态资源 // Router.StaticFile("/", "./dist/index.html") // 前端网页入口页面 - Router.StaticFS(global.GVA_CONFIG.Local.StorePath, justFilesFilesystem{http.Dir(global.GVA_CONFIG.Local.StorePath)}) // Router.Use(middleware.LoadTls()) // 如果需要使用https 请打开此中间件 然后前往 core/server.go 将启动模式 更变为 Router.RunTLS("端口","你的cre/pem文件","你的key文件") + Router.StaticFS(global.GVA_CONFIG.Local.StorePath, justFilesFilesystem{http.Dir(global.GVA_CONFIG.Local.StorePath)}) + // Router.Use(middleware.LoadTls()) // 如果需要使用https 请打开此中间件 然后前往 core/server.go 将启动模式 更变为 Router.RunTLS("端口","你的cre/pem文件","你的key文件") // 跨域,如需跨域可以打开下面的注释 // Router.Use(middleware.Cors()) // 直接放行全部跨域请求 // Router.Use(middleware.CorsByRules()) // 按照配置的规则放行跨域请求 @@ -87,35 +74,34 @@ func Routers() *gin.Engine { }) } { - systemRouter.InitBaseRouter(PublicGroup) // 注册基础功能路由 不做鉴权 - systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关 + systemRouter.InitBaseRouter(PublicGroup) // 注册基础功能路由 不做鉴权 + systemRouter.InitInitRouter(PublicGroup) // 自动初始化相关 } { - systemRouter.InitApiRouter(PrivateGroup, PublicGroup) // 注册功能api路由 - systemRouter.InitJwtRouter(PrivateGroup) // jwt相关路由 - systemRouter.InitUserRouter(PrivateGroup) // 注册用户路由 - systemRouter.InitMenuRouter(PrivateGroup) // 注册menu路由 - systemRouter.InitSystemRouter(PrivateGroup) // system相关路由 - systemRouter.InitSysVersionRouter(PrivateGroup) // 发版相关路由 - systemRouter.InitCasbinRouter(PrivateGroup) // 权限相关路由 - systemRouter.InitAutoCodeRouter(PrivateGroup, PublicGroup) // 创建自动化代码 - systemRouter.InitAuthorityRouter(PrivateGroup) // 注册角色路由 - systemRouter.InitSysDictionaryRouter(PrivateGroup) // 字典管理 - systemRouter.InitAutoCodeHistoryRouter(PrivateGroup) // 自动化代码历史 - systemRouter.InitSysOperationRecordRouter(PrivateGroup) // 操作记录 - systemRouter.InitSysDictionaryDetailRouter(PrivateGroup) // 字典详情管理 - systemRouter.InitAuthorityBtnRouterRouter(PrivateGroup) // 按钮权限管理 - systemRouter.InitSysExportTemplateRouter(PrivateGroup, PublicGroup) // 导出模板 - systemRouter.InitSysParamsRouter(PrivateGroup, PublicGroup) // 参数管理 - systemRouter.InitSysErrorRouter(PrivateGroup, PublicGroup) // 错误日志 - systemRouter.InitLoginLogRouter(PrivateGroup) // 登录日志 - systemRouter.InitApiTokenRouter(PrivateGroup) // apiToken签发 - systemRouter.InitSkillsRouter(PrivateGroup,PublicGroup) // Skills 定义器 - exampleRouter.InitCustomerRouter(PrivateGroup) // 客户路由 - exampleRouter.InitFileUploadAndDownloadRouter(PrivateGroup) // 文件上传下载功能路由 - exampleRouter.InitAttachmentCategoryRouterRouter(PrivateGroup) // 文件上传下载分类 - + systemRouter.InitApiRouter(PrivateGroup, PublicGroup) // 注册功能api路由 + systemRouter.InitJwtRouter(PrivateGroup) // jwt相关路由 + systemRouter.InitUserRouter(PrivateGroup) // 注册用户路由 + systemRouter.InitMenuRouter(PrivateGroup) // 注册menu路由 + systemRouter.InitSystemRouter(PrivateGroup) // system相关路由 + systemRouter.InitSysVersionRouter(PrivateGroup) // 发版相关路由 + systemRouter.InitCasbinRouter(PrivateGroup) // 权限相关路由 + systemRouter.InitAutoCodeRouter(PrivateGroup, PublicGroup) // 创建自动化代码 + systemRouter.InitAuthorityRouter(PrivateGroup) // 注册角色路由 + systemRouter.InitSysDictionaryRouter(PrivateGroup) // 字典管理 + systemRouter.InitAutoCodeHistoryRouter(PrivateGroup) // 自动化代码历史 + systemRouter.InitSysOperationRecordRouter(PrivateGroup) // 操作记录 + systemRouter.InitSysDictionaryDetailRouter(PrivateGroup) // 字典详情管理 + systemRouter.InitAuthorityBtnRouterRouter(PrivateGroup) // 按钮权限管理 + systemRouter.InitSysExportTemplateRouter(PrivateGroup, PublicGroup) // 导出模板 + systemRouter.InitSysParamsRouter(PrivateGroup, PublicGroup) // 参数管理 + systemRouter.InitSysErrorRouter(PrivateGroup, PublicGroup) // 错误日志 + systemRouter.InitLoginLogRouter(PrivateGroup) // 登录日志 + systemRouter.InitApiTokenRouter(PrivateGroup) // apiToken签发 + systemRouter.InitSkillsRouter(PrivateGroup, PublicGroup) // Skills 定义器 + exampleRouter.InitCustomerRouter(PrivateGroup) // 客户路由 + exampleRouter.InitFileUploadAndDownloadRouter(PrivateGroup) // 文件上传下载功能路由 + exampleRouter.InitAttachmentCategoryRouterRouter(PrivateGroup) // 文件上传下载分类 } //插件路由安装 diff --git a/server/mcp/api_creator.go b/server/mcp/api_creator.go index 22009f6cac..9016a50d36 100644 --- a/server/mcp/api_creator.go +++ b/server/mcp/api_creator.go @@ -6,27 +6,23 @@ import ( "errors" "fmt" - "github.com/flipped-aurora/gin-vue-admin/server/global" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" "github.com/flipped-aurora/gin-vue-admin/server/model/system" - "github.com/flipped-aurora/gin-vue-admin/server/service" + systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" "github.com/mark3labs/mcp-go/mcp" - "go.uber.org/zap" ) -// 注册工具 func init() { RegisterTool(&ApiCreator{}) } -// ApiCreateRequest API创建请求结构 type ApiCreateRequest struct { - Path string `json:"path"` // API路径 - Description string `json:"description"` // API中文描述 - ApiGroup string `json:"apiGroup"` // API组 - Method string `json:"method"` // HTTP方法 + Path string `json:"path"` + Description string `json:"description"` + ApiGroup string `json:"apiGroup"` + Method string `json:"method"` } -// ApiCreateResponse API创建响应结构 type ApiCreateResponse struct { Success bool `json:"success"` Message string `json:"message"` @@ -35,10 +31,8 @@ type ApiCreateResponse struct { Method string `json:"method"` } -// ApiCreator API创建工具 type ApiCreator struct{} -// New 创建API创建工具 func (a *ApiCreator) New() mcp.Tool { return mcp.NewTool("create_api", mcp.WithDescription(`创建后端API记录,用于AI编辑器自动添加API接口时自动创建对应的API权限记录。 @@ -68,37 +62,31 @@ func (a *ApiCreator) New() mcp.Tool { ) } -// Handle 处理API创建请求 func (a *ApiCreator) Handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := request.GetArguments() var apis []ApiCreateRequest - - // 检查是否是批量创建 if apisStr, ok := args["apis"].(string); ok && apisStr != "" { if err := json.Unmarshal([]byte(apisStr), &apis); err != nil { - return nil, fmt.Errorf("apis 参数格式错误: %v", err) + return nil, fmt.Errorf("apis 参数格式错误: %w", err) } } else { - // 单个API创建 path, ok := args["path"].(string) if !ok || path == "" { return nil, errors.New("path 参数是必需的") } - description, ok := args["description"].(string) if !ok || description == "" { return nil, errors.New("description 参数是必需的") } - apiGroup, ok := args["apiGroup"].(string) if !ok || apiGroup == "" { return nil, errors.New("apiGroup 参数是必需的") } method := "POST" - if val, ok := args["method"].(string); ok && val != "" { - method = val + if value, ok := args["method"].(string); ok && value != "" { + method = value } apis = append(apis, ApiCreateRequest{ @@ -113,79 +101,59 @@ func (a *ApiCreator) Handle(ctx context.Context, request mcp.CallToolRequest) (* return nil, errors.New("没有要创建的API") } - // 创建API记录 - apiService := service.ServiceGroupApp.SystemServiceGroup.ApiService - var responses []ApiCreateResponse + responses := make([]ApiCreateResponse, 0, len(apis)) successCount := 0 for _, apiReq := range apis { - api := system.SysApi{ + _, err := postUpstream[map[string]any](ctx, "/api/createApi", system.SysApi{ Path: apiReq.Path, Description: apiReq.Description, ApiGroup: apiReq.ApiGroup, Method: apiReq.Method, - } - - err := apiService.CreateApi(api) + }) if err != nil { - global.GVA_LOG.Warn("创建API失败", - zap.String("path", apiReq.Path), - zap.String("method", apiReq.Method), - zap.Error(err)) - responses = append(responses, ApiCreateResponse{ Success: false, Message: fmt.Sprintf("创建API失败: %v", err), Path: apiReq.Path, Method: apiReq.Method, }) - } else { - // 获取创建的API ID - var createdApi system.SysApi - err = global.GVA_DB.Where("path = ? AND method = ?", apiReq.Path, apiReq.Method).First(&createdApi).Error - if err != nil { - global.GVA_LOG.Warn("获取创建的API ID失败", zap.Error(err)) - } + continue + } - responses = append(responses, ApiCreateResponse{ - Success: true, - Message: fmt.Sprintf("成功创建API %s %s", apiReq.Method, apiReq.Path), - ApiID: createdApi.ID, - Path: apiReq.Path, - Method: apiReq.Method, - }) - successCount++ + lookupResp, lookupErr := postUpstream[pageResultData[[]system.SysApi]](ctx, "/api/getApiList", systemReq.SearchApiParams{ + SysApi: system.SysApi{ + Path: apiReq.Path, + Method: apiReq.Method, + }, + PageInfo: commonReq.PageInfo{ + Page: 1, + PageSize: 1, + }, + }) + + var apiID uint + if lookupErr == nil && len(lookupResp.Data.List) > 0 { + apiID = lookupResp.Data.List[0].ID } - } - // 构建总体响应 - var resultMessage string - if len(apis) == 1 { - resultMessage = responses[0].Message - } else { - resultMessage = fmt.Sprintf("批量创建API完成,成功 %d 个,失败 %d 个", successCount, len(apis)-successCount) + responses = append(responses, ApiCreateResponse{ + Success: true, + Message: fmt.Sprintf("成功创建API %s %s", apiReq.Method, apiReq.Path), + ApiID: apiID, + Path: apiReq.Path, + Method: apiReq.Method, + }) + successCount++ } - result := map[string]interface{}{ + result := map[string]any{ "success": successCount > 0, - "message": resultMessage, "totalCount": len(apis), "successCount": successCount, "failedCount": len(apis) - successCount, "details": responses, } - resultJSON, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %v", err) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("API创建结果:\n\n%s", string(resultJSON)), - }, - }, - }, nil + return textResultWithJSON("API创建结果:", result) } diff --git a/server/mcp/api_lister.go b/server/mcp/api_lister.go index ba0a8e169a..0357a617ef 100644 --- a/server/mcp/api_lister.go +++ b/server/mcp/api_lister.go @@ -2,44 +2,39 @@ package mcpTool import ( "context" - "encoding/json" - "fmt" - "github.com/flipped-aurora/gin-vue-admin/server/global" - "github.com/flipped-aurora/gin-vue-admin/server/model/system" + systemRes "github.com/flipped-aurora/gin-vue-admin/server/model/system/response" + "github.com/gin-gonic/gin" "github.com/mark3labs/mcp-go/mcp" - "go.uber.org/zap" ) -// 注册工具 func init() { - // 注册工具将在enter.go中统一处理 RegisterTool(&ApiLister{}) } -// ApiInfo API信息结构 type ApiInfo struct { - ID uint `json:"id,omitempty"` // 数据库ID(仅数据库API有) - Path string `json:"path"` // API路径 - Description string `json:"description,omitempty"` // API描述 - ApiGroup string `json:"apiGroup,omitempty"` // API组 - Method string `json:"method"` // HTTP方法 - Source string `json:"source"` // 来源:database 或 gin + ID uint `json:"id,omitempty"` + Path string `json:"path"` + Description string `json:"description,omitempty"` + ApiGroup string `json:"apiGroup,omitempty"` + Method string `json:"method"` + Source string `json:"source"` } -// ApiListResponse API列表响应结构 type ApiListResponse struct { Success bool `json:"success"` Message string `json:"message"` - DatabaseApis []ApiInfo `json:"databaseApis"` // 数据库中的API - GinApis []ApiInfo `json:"ginApis"` // gin框架中的API - TotalCount int `json:"totalCount"` // 总数量 + DatabaseApis []ApiInfo `json:"databaseApis"` + GinApis []ApiInfo `json:"ginApis"` + TotalCount int `json:"totalCount"` +} + +type mcpRoutesResponse struct { + Routes gin.RoutesInfo `json:"routes"` } -// ApiLister API列表工具 type ApiLister struct{} -// New 创建API列表工具 func (a *ApiLister) New() mcp.Tool { return mcp.NewTool("list_all_apis", mcp.WithDescription(`获取系统中所有的API接口,分为两组: @@ -54,92 +49,24 @@ func (a *ApiLister) New() mcp.Tool { - ginApis: gin路由中的API(仅包含路径和方法),需要AI根据路径自行揣摩路径的业务含义,例如:/api/user/:id 表示根据用户ID获取用户信息`), mcp.WithString("_placeholder", mcp.Description("占位符,防止json schema校验失败"), - ), + ), ) } -// Handle 处理API列表请求 -func (a *ApiLister) Handle(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - - // 获取数据库中的API - databaseApis, err := a.getDatabaseApis() +func (a *ApiLister) Handle(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + apiResp, err := postUpstream[systemRes.SysAPIListResponse](ctx, "/api/getAllApis", map[string]any{}) if err != nil { - global.GVA_LOG.Error("获取数据库API失败", zap.Error(err)) - errorResponse := ApiListResponse{ - Success: false, - Message: "获取数据库API失败: " + err.Error(), - } - resultJSON, _ := json.Marshal(errorResponse) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: string(resultJSON), - }, - }, - }, nil - } - - // 获取gin路由中的API - ginApis, err := a.getGinApis() - if err != nil { - global.GVA_LOG.Error("获取gin路由API失败", zap.Error(err)) - errorResponse := ApiListResponse{ - Success: false, - Message: "获取gin路由API失败: " + err.Error(), - } - resultJSON, _ := json.Marshal(errorResponse) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: string(resultJSON), - }, - }, - }, nil - } - - // 构建响应 - response := ApiListResponse{ - Success: true, - Message: "获取API列表成功", - DatabaseApis: databaseApis, - GinApis: ginApis, - TotalCount: len(databaseApis) + len(ginApis), - } - - global.GVA_LOG.Info("API列表获取成功", - zap.Int("数据库API数量", len(databaseApis)), - zap.Int("gin路由API数量", len(ginApis)), - zap.Int("总数量", response.TotalCount)) - - resultJSON, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %v", err) + return nil, err } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: string(resultJSON), - }, - }, - }, nil -} - -// getDatabaseApis 获取数据库中的所有API -func (a *ApiLister) getDatabaseApis() ([]ApiInfo, error) { - var apis []system.SysApi - err := global.GVA_DB.Model(&system.SysApi{}).Order("api_group ASC, path ASC").Find(&apis).Error + routeResp, err := postUpstream[mcpRoutesResponse](ctx, "/autoCode/mcpRoutes", map[string]any{}) if err != nil { return nil, err } - // 转换为ApiInfo格式 - var result []ApiInfo - for _, api := range apis { - result = append(result, ApiInfo{ + databaseApis := make([]ApiInfo, 0, len(apiResp.Data.Apis)) + for _, api := range apiResp.Data.Apis { + databaseApis = append(databaseApis, ApiInfo{ ID: api.ID, Path: api.Path, Description: api.Description, @@ -149,20 +76,20 @@ func (a *ApiLister) getDatabaseApis() ([]ApiInfo, error) { }) } - return result, nil -} - -// getGinApis 获取gin路由中的所有API(包含被忽略的API) -func (a *ApiLister) getGinApis() ([]ApiInfo, error) { - // 从gin路由信息中获取所有API - var result []ApiInfo - for _, route := range global.GVA_ROUTERS { - result = append(result, ApiInfo{ + ginApis := make([]ApiInfo, 0, len(routeResp.Data.Routes)) + for _, route := range routeResp.Data.Routes { + ginApis = append(ginApis, ApiInfo{ Path: route.Path, Method: route.Method, Source: "gin", }) } - return result, nil + return textResultWithJSON("", ApiListResponse{ + Success: true, + Message: "获取API列表成功", + DatabaseApis: databaseApis, + GinApis: ginApis, + TotalCount: len(databaseApis) + len(ginApis), + }) } diff --git a/server/mcp/autocode_http.go b/server/mcp/autocode_http.go new file mode 100644 index 0000000000..7e66dcba2b --- /dev/null +++ b/server/mcp/autocode_http.go @@ -0,0 +1,48 @@ +package mcpTool + +import ( + "context" + + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" + model "github.com/flipped-aurora/gin-vue-admin/server/model/system" + systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" +) + +func fetchAutoCodePackages(ctx context.Context) ([]model.SysAutoCodePackage, error) { + resp, err := postUpstream[map[string][]model.SysAutoCodePackage](ctx, "/autoCode/getPackage", map[string]any{}) + if err != nil { + return nil, err + } + return resp.Data["pkgs"], nil +} + +func fetchAutoCodeHistories(ctx context.Context) ([]model.SysAutoCodeHistory, error) { + resp, err := postUpstream[pageResultData[[]model.SysAutoCodeHistory]](ctx, "/autoCode/getSysHistory", commonReq.PageInfo{ + Page: 1, + PageSize: 10000, + }) + if err != nil { + return nil, err + } + return resp.Data.List, nil +} + +func createAutoCodePackage(ctx context.Context, info *systemReq.SysAutoCodePackageCreate) error { + _, err := postUpstream[map[string]any](ctx, "/autoCode/createPackage", info) + return err +} + +func createAutoCodeModule(ctx context.Context, info systemReq.AutoCode) error { + _, err := postUpstream[map[string]any](ctx, "/autoCode/createTemp", info) + return err +} + +func deleteAutoCodePackage(ctx context.Context, id uint) error { + _, err := postUpstream[map[string]any](ctx, "/autoCode/delPackage", commonReq.GetById{ID: int(id)}) + return err +} + +func deleteAutoCodeHistory(ctx context.Context, id uint) error { + _, err := postUpstream[map[string]any](ctx, "/autoCode/delSysHistory", commonReq.GetById{ID: int(id)}) + return err +} diff --git a/server/mcp/client/client.go b/server/mcp/client/client.go index 7e5db1ef6d..ff02459c31 100644 --- a/server/mcp/client/client.go +++ b/server/mcp/client/client.go @@ -3,24 +3,28 @@ package client import ( "context" "errors" + mcpClient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" ) -func NewClient(baseUrl, name, version, serverName string) (*mcpClient.Client, error) { - client, err := mcpClient.NewSSEMCPClient(baseUrl) +func NewClient(baseURL, name, version, serverName string, headers ...map[string]string) (*mcpClient.Client, error) { + options := make([]transport.StreamableHTTPCOption, 0, 1) + if len(headers) > 0 && len(headers[0]) > 0 { + options = append(options, transport.WithHTTPHeaders(headers[0])) + } + + client, err := mcpClient.NewStreamableHttpClient(baseURL, options...) if err != nil { return nil, err } ctx := context.Background() - - // 启动client if err := client.Start(ctx); err != nil { return nil, err } - // 初始化 initRequest := mcp.InitializeRequest{} initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{ @@ -35,5 +39,6 @@ func NewClient(baseUrl, name, version, serverName string) (*mcpClient.Client, er if result.ServerInfo.Name != serverName { return nil, errors.New("server name mismatch") } + return client, nil } diff --git a/server/mcp/context.go b/server/mcp/context.go new file mode 100644 index 0000000000..932aa12fab --- /dev/null +++ b/server/mcp/context.go @@ -0,0 +1,66 @@ +package mcpTool + +import ( + "context" + "net/http" + "strings" + + "github.com/flipped-aurora/gin-vue-admin/server/global" +) + +type mcpContextKey string + +const authTokenContextKey mcpContextKey = "mcp-auth-token" + +func WithHTTPRequestContext(ctx context.Context, r *http.Request) context.Context { + token := extractIncomingAuthToken(r.Header) + return context.WithValue(ctx, authTokenContextKey, token) +} + +func configuredAuthHeader() string { + if header := strings.TrimSpace(global.GVA_CONFIG.MCP.AuthHeader); header != "" { + return header + } + return "x-token" +} + +func ConfiguredAuthHeader() string { + return configuredAuthHeader() +} + +func authTokenFromContext(ctx context.Context) string { + token, _ := ctx.Value(authTokenContextKey).(string) + return strings.TrimSpace(token) +} + +func extractIncomingAuthToken(headers http.Header) string { + candidates := []string{ + configuredAuthHeader(), + "x-token", + "token", + "authorization", + } + + seen := make(map[string]struct{}, len(candidates)) + for _, name := range candidates { + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + value := strings.TrimSpace(headers.Get(name)) + if value == "" { + continue + } + if key == "authorization" { + return strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) + } + return value + } + + return "" +} diff --git a/server/mcp/dictionary_generator.go b/server/mcp/dictionary_generator.go index f5a32c29a1..7fa5b26c2a 100644 --- a/server/mcp/dictionary_generator.go +++ b/server/mcp/dictionary_generator.go @@ -6,38 +6,30 @@ import ( "errors" "fmt" - "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/model/system" - "github.com/flipped-aurora/gin-vue-admin/server/service" "github.com/mark3labs/mcp-go/mcp" - "go.uber.org/zap" - "gorm.io/gorm" ) func init() { RegisterTool(&DictionaryOptionsGenerator{}) } -// DictionaryOptionsGenerator 字典选项生成器 type DictionaryOptionsGenerator struct{} -// DictionaryOption 字典选项结构 type DictionaryOption struct { Label string `json:"label"` Value string `json:"value"` Sort int `json:"sort"` } -// DictionaryGenerateRequest 字典生成请求 type DictionaryGenerateRequest struct { - DictType string `json:"dictType"` // 字典类型 - FieldDesc string `json:"fieldDesc"` // 字段描述 - Options []DictionaryOption `json:"options"` // AI生成的字典选项 - DictName string `json:"dictName"` // 字典名称(可选) - Description string `json:"description"` // 字典描述(可选) + DictType string `json:"dictType"` + FieldDesc string `json:"fieldDesc"` + Options []DictionaryOption `json:"options"` + DictName string `json:"dictName"` + Description string `json:"description"` } -// DictionaryGenerateResponse 字典生成响应 type DictionaryGenerateResponse struct { Success bool `json:"success"` Message string `json:"message"` @@ -45,7 +37,6 @@ type DictionaryGenerateResponse struct { OptionsCount int `json:"optionsCount"` } -// New 返回工具注册信息 func (d *DictionaryOptionsGenerator) New() mcp.Tool { return mcp.NewTool("generate_dictionary_options", mcp.WithDescription("智能生成字典选项并自动创建字典和字典详情"), @@ -70,79 +61,52 @@ func (d *DictionaryOptionsGenerator) New() mcp.Tool { ) } -// Handle 处理工具调用 func (d *DictionaryOptionsGenerator) Handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 解析请求参数 args := request.GetArguments() dictType, ok := args["dictType"].(string) if !ok || dictType == "" { return nil, errors.New("dictType 参数是必需的") } - fieldDesc, ok := args["fieldDesc"].(string) if !ok || fieldDesc == "" { return nil, errors.New("fieldDesc 参数是必需的") } - optionsStr, ok := args["options"].(string) if !ok || optionsStr == "" { return nil, errors.New("options 参数是必需的") } - // 解析options JSON字符串 var options []DictionaryOption if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { return nil, fmt.Errorf("options 参数格式错误: %v", err) } - if len(options) == 0 { return nil, errors.New("options 不能为空") } - dictName, _ := args["dictName"].(string) - description, _ := args["description"].(string) - - // 构建请求对象 req := &DictionaryGenerateRequest{ DictType: dictType, FieldDesc: fieldDesc, Options: options, - DictName: dictName, - Description: description, + DictName: stringValue(args["dictName"]), + Description: stringValue(args["description"]), } - // 创建字典 - response, err := d.createDictionaryWithOptions(ctx, req) + result, err := d.createDictionaryWithOptions(ctx, req) if err != nil { return nil, err } - // 构建响应 - resultJSON, err := json.MarshalIndent(response, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %v", err) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("字典选项生成结果:\n\n%s", string(resultJSON)), - }, - }, - }, nil + return textResultWithJSON("字典选项生成结果:", result) } -// createDictionaryWithOptions 创建字典和字典选项 func (d *DictionaryOptionsGenerator) createDictionaryWithOptions(ctx context.Context, req *DictionaryGenerateRequest) (*DictionaryGenerateResponse, error) { - // 检查字典是否已存在 - exists, err := d.checkDictionaryExists(req.DictType) + existingDict, err := findDictionaryByType(ctx, req.DictType) if err != nil { return nil, fmt.Errorf("检查字典是否存在失败: %v", err) } - - if exists { + if existingDict != nil { return &DictionaryGenerateResponse{ Success: false, Message: fmt.Sprintf("字典 %s 已存在,跳过创建", req.DictType), @@ -151,50 +115,38 @@ func (d *DictionaryOptionsGenerator) createDictionaryWithOptions(ctx context.Con }, nil } - // 生成字典名称 dictName := req.DictName if dictName == "" { dictName = d.generateDictionaryName(req.DictType, req.FieldDesc) } - // 创建字典 - dictionaryService := service.ServiceGroupApp.SystemServiceGroup.DictionaryService - dictionary := system.SysDictionary{ + if err := createDictionary(ctx, system.SysDictionary{ Name: dictName, Type: req.DictType, - Status: &[]bool{true}[0], // 默认启用 + Status: enabledBoolPointer(), Desc: req.Description, - } - - err = dictionaryService.CreateSysDictionary(dictionary) - if err != nil { + }); err != nil { return nil, fmt.Errorf("创建字典失败: %v", err) } - // 获取刚创建的字典ID - var createdDict system.SysDictionary - err = global.GVA_DB.Where("type = ?", req.DictType).First(&createdDict).Error + createdDict, err := findDictionaryByType(ctx, req.DictType) if err != nil { return nil, fmt.Errorf("获取创建的字典失败: %v", err) } + if createdDict == nil { + return nil, fmt.Errorf("获取创建的字典失败") + } - // 创建字典详情项 - dictionaryDetailService := service.ServiceGroupApp.SystemServiceGroup.DictionaryDetailService successCount := 0 - for _, option := range req.Options { - dictionaryDetail := system.SysDictionaryDetail{ + err := createDictionaryDetail(ctx, system.SysDictionaryDetail{ Label: option.Label, Value: option.Value, - Status: &[]bool{true}[0], // 默认启用 + Status: enabledBoolPointer(), Sort: option.Sort, SysDictionaryID: int(createdDict.ID), - } - - err = dictionaryDetailService.CreateSysDictionaryDetail(dictionaryDetail) - if err != nil { - global.GVA_LOG.Warn("创建字典详情项失败", zap.Error(err)) - } else { + }) + if err == nil { successCount++ } } @@ -207,23 +159,16 @@ func (d *DictionaryOptionsGenerator) createDictionaryWithOptions(ctx context.Con }, nil } -// checkDictionaryExists 检查字典是否存在 -func (d *DictionaryOptionsGenerator) checkDictionaryExists(dictType string) (bool, error) { - var dictionary system.SysDictionary - err := global.GVA_DB.Where("type = ?", dictType).First(&dictionary).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil // 字典不存在 - } - return false, err // 其他错误 - } - return true, nil // 字典存在 -} - -// generateDictionaryName 生成字典名称 func (d *DictionaryOptionsGenerator) generateDictionaryName(dictType, fieldDesc string) string { if fieldDesc != "" { return fmt.Sprintf("%s字典", fieldDesc) } return fmt.Sprintf("%s字典", dictType) } + +func stringValue(value any) string { + if str, ok := value.(string); ok { + return str + } + return "" +} diff --git a/server/mcp/dictionary_http.go b/server/mcp/dictionary_http.go new file mode 100644 index 0000000000..96c8c03d9e --- /dev/null +++ b/server/mcp/dictionary_http.go @@ -0,0 +1,73 @@ +package mcpTool + +import ( + "context" + "net/url" + "strconv" + + "github.com/flipped-aurora/gin-vue-admin/server/model/system" + "github.com/flipped-aurora/gin-vue-admin/server/utils" +) + +type exportedDictionary struct { + Name string `json:"name"` + Type string `json:"type"` + Status *bool `json:"status"` + Desc string `json:"desc"` + SysDictionaryDetails []system.SysDictionaryDetail `json:"sysDictionaryDetails"` +} + +func fetchDictionaryList(ctx context.Context, keyword string) ([]system.SysDictionary, error) { + query := url.Values{} + if keyword != "" { + query.Set("name", keyword) + } + + resp, err := getUpstream[[]system.SysDictionary](ctx, "/sysDictionary/getSysDictionaryList", query) + if err != nil { + return nil, err + } + return resp.Data, nil +} + +func findDictionaryByType(ctx context.Context, dictType string) (*system.SysDictionary, error) { + dictionaries, err := fetchDictionaryList(ctx, dictType) + if err != nil { + return nil, err + } + + for _, dictionary := range dictionaries { + if dictionary.Type == dictType { + dict := dictionary + return &dict, nil + } + } + + return nil, nil +} + +func exportDictionary(ctx context.Context, id uint) (*exportedDictionary, error) { + query := url.Values{} + query.Set("id", strconv.FormatUint(uint64(id), 10)) + + resp, err := getUpstream[exportedDictionary](ctx, "/sysDictionary/exportSysDictionary", query) + if err != nil { + return nil, err + } + + return &resp.Data, nil +} + +func createDictionary(ctx context.Context, dictionary system.SysDictionary) error { + _, err := postUpstream[map[string]any](ctx, "/sysDictionary/createSysDictionary", dictionary) + return err +} + +func createDictionaryDetail(ctx context.Context, detail system.SysDictionaryDetail) error { + _, err := postUpstream[map[string]any](ctx, "/sysDictionaryDetail/createSysDictionaryDetail", detail) + return err +} + +func enabledBoolPointer() *bool { + return utils.Pointer(true) +} diff --git a/server/mcp/dictionary_query.go b/server/mcp/dictionary_query.go index 0e0f175513..9d704cd719 100644 --- a/server/mcp/dictionary_query.go +++ b/server/mcp/dictionary_query.go @@ -2,48 +2,38 @@ package mcpTool import ( "context" - "encoding/json" - "fmt" - "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/model/system" - "github.com/flipped-aurora/gin-vue-admin/server/service" "github.com/mark3labs/mcp-go/mcp" - "go.uber.org/zap" - "gorm.io/gorm" ) -// 注册工具 func init() { RegisterTool(&DictionaryQuery{}) } type DictionaryPre struct { - Type string `json:"type"` // 字典名(英) - Desc string `json:"desc"` // 描述 + Type string `json:"type"` + Desc string `json:"desc"` } -// DictionaryInfo 字典信息结构 type DictionaryInfo struct { ID uint `json:"id"` - Name string `json:"name"` // 字典名(中) - Type string `json:"type"` // 字典名(英) - Status *bool `json:"status"` // 状态 - Desc string `json:"desc"` // 描述 - Details []DictionaryDetailInfo `json:"details"` // 字典详情 + Name string `json:"name"` + Type string `json:"type"` + Status *bool `json:"status"` + Desc string `json:"desc"` + Details []DictionaryDetailInfo `json:"details"` } -// DictionaryDetailInfo 字典详情信息结构 type DictionaryDetailInfo struct { ID uint `json:"id"` - Label string `json:"label"` // 展示值 - Value string `json:"value"` // 字典值 - Extend string `json:"extend"` // 扩展值 - Status *bool `json:"status"` // 启用状态 - Sort int `json:"sort"` // 排序标记 + Label string `json:"label"` + Value string `json:"value"` + Extend string `json:"extend"` + Status *bool `json:"status"` + Sort int `json:"sort"` } -// DictionaryQueryResponse 字典查询响应结构 type DictionaryQueryResponse struct { Success bool `json:"success"` Message string `json:"message"` @@ -51,10 +41,8 @@ type DictionaryQueryResponse struct { Dictionaries []DictionaryInfo `json:"dictionaries"` } -// DictionaryQuery 字典查询工具 type DictionaryQuery struct{} -// New 创建字典查询工具 func (d *DictionaryQuery) New() mcp.Tool { return mcp.NewTool("query_dictionaries", mcp.WithDescription("查询系统中所有的字典和字典属性,用于AI生成逻辑时了解可用的字典选项"), @@ -70,170 +58,82 @@ func (d *DictionaryQuery) New() mcp.Tool { ) } -// Handle 处理字典查询请求 func (d *DictionaryQuery) Handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := request.GetArguments() - // 获取参数 - dictType := "" - if val, ok := args["dictType"].(string); ok { - dictType = val - } - - includeDisabled := false - if val, ok := args["includeDisabled"].(bool); ok { - includeDisabled = val - } + dictType := stringValue(args["dictType"]) + includeDisabled, _ := args["includeDisabled"].(bool) + detailsOnly, _ := args["detailsOnly"].(bool) - detailsOnly := false - if val, ok := args["detailsOnly"].(bool); ok { - detailsOnly = val + dictionaries, err := fetchDictionaryList(ctx, dictType) + if err != nil { + return nil, err } - // 获取字典服务 - dictionaryService := service.ServiceGroupApp.SystemServiceGroup.DictionaryService - - var dictionaries []DictionaryInfo - var err error - - if dictType != "" { - // 查询指定类型的字典 - var status *bool - if !includeDisabled { - status = &[]bool{true}[0] - } - - sysDictionary, err := dictionaryService.GetSysDictionary(dictType, 0, status) - if err != nil { - global.GVA_LOG.Error("查询字典失败", zap.Error(err)) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(fmt.Sprintf(`{"success": false, "message": "查询字典失败: %v", "total": 0, "dictionaries": []}`, err.Error())), - }, - }, nil - } - - // 转换为响应格式 - dictInfo := DictionaryInfo{ - ID: sysDictionary.ID, - Name: sysDictionary.Name, - Type: sysDictionary.Type, - Status: sysDictionary.Status, - Desc: sysDictionary.Desc, + result := make([]DictionaryInfo, 0) + for _, dictionary := range dictionaries { + if dictType != "" && dictionary.Type != dictType { + continue } - - // 获取字典详情 - for _, detail := range sysDictionary.SysDictionaryDetails { - if includeDisabled || (detail.Status != nil && *detail.Status) { - dictInfo.Details = append(dictInfo.Details, DictionaryDetailInfo{ - ID: detail.ID, - Label: detail.Label, - Value: detail.Value, - Extend: detail.Extend, - Status: detail.Status, - Sort: detail.Sort, - }) - } - } - - dictionaries = append(dictionaries, dictInfo) - } else { - // 查询所有字典 - var sysDictionaries []system.SysDictionary - db := global.GVA_DB.Model(&system.SysDictionary{}) - - if !includeDisabled { - db = db.Where("status = ?", true) + if !includeDisabled && dictionary.Status != nil && !*dictionary.Status { + continue } - err = db.Preload("SysDictionaryDetails", func(db *gorm.DB) *gorm.DB { - if includeDisabled { - return db.Order("sort") - } else { - return db.Where("status = ?", true).Order("sort") - } - }).Find(&sysDictionaries).Error - + dictInfo, err := buildDictionaryInfo(ctx, dictionary, includeDisabled) if err != nil { - global.GVA_LOG.Error("查询字典列表失败", zap.Error(err)) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(fmt.Sprintf(`{"success": false, "message": "查询字典列表失败: %v", "total": 0, "dictionaries": []}`, err.Error())), - }, - }, nil - } - - // 转换为响应格式 - for _, dict := range sysDictionaries { - dictInfo := DictionaryInfo{ - ID: dict.ID, - Name: dict.Name, - Type: dict.Type, - Status: dict.Status, - Desc: dict.Desc, - } - - // 获取字典详情 - for _, detail := range dict.SysDictionaryDetails { - if includeDisabled || (detail.Status != nil && *detail.Status) { - dictInfo.Details = append(dictInfo.Details, DictionaryDetailInfo{ - ID: detail.ID, - Label: detail.Label, - Value: detail.Value, - Extend: detail.Extend, - Status: detail.Status, - Sort: detail.Sort, - }) - } - } - - dictionaries = append(dictionaries, dictInfo) + return nil, err } + result = append(result, dictInfo) } - // 如果只需要详情信息,则提取所有详情 if detailsOnly { - var allDetails []DictionaryDetailInfo - for _, dict := range dictionaries { - allDetails = append(allDetails, dict.Details...) + details := make([]DictionaryDetailInfo, 0) + for _, dictionary := range result { + details = append(details, dictionary.Details...) } - - response := map[string]interface{}{ + return textResultWithJSON("", map[string]any{ "success": true, "message": "查询字典详情成功", - "total": len(allDetails), - "details": allDetails, - } - - responseJSON, _ := json.Marshal(response) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(responseJSON)), - }, - }, nil + "total": len(details), + "details": details, + }) } - // 构建响应 - response := DictionaryQueryResponse{ + return textResultWithJSON("", DictionaryQueryResponse{ Success: true, Message: "查询字典成功", - Total: len(dictionaries), - Dictionaries: dictionaries, - } + Total: len(result), + Dictionaries: result, + }) +} - responseJSON, err := json.Marshal(response) +func buildDictionaryInfo(ctx context.Context, dictionary system.SysDictionary, includeDisabled bool) (DictionaryInfo, error) { + exported, err := exportDictionary(ctx, dictionary.ID) if err != nil { - global.GVA_LOG.Error("序列化响应失败", zap.Error(err)) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(fmt.Sprintf(`{"success": false, "message": "序列化响应失败: %v", "total": 0, "dictionaries": []}`, err.Error())), - }, - }, nil + return DictionaryInfo{}, err + } + + info := DictionaryInfo{ + ID: dictionary.ID, + Name: exported.Name, + Type: exported.Type, + Status: exported.Status, + Desc: exported.Desc, + } + + for _, detail := range exported.SysDictionaryDetails { + if !includeDisabled && detail.Status != nil && !*detail.Status { + continue + } + info.Details = append(info.Details, DictionaryDetailInfo{ + ID: detail.ID, + Label: detail.Label, + Value: detail.Value, + Extend: detail.Extend, + Status: detail.Status, + Sort: detail.Sort, + }) } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.NewTextContent(string(responseJSON)), - }, - }, nil + return info, nil } diff --git a/server/mcp/gva_analyze.go b/server/mcp/gva_analyze.go index 1e974714de..6b5f74f458 100644 --- a/server/mcp/gva_analyze.go +++ b/server/mcp/gva_analyze.go @@ -1,11 +1,10 @@ -package mcpTool +package mcpTool import ( "context" "encoding/json" "errors" "fmt" - model "github.com/flipped-aurora/gin-vue-admin/server/model/system" "os" "path/filepath" "strings" @@ -30,7 +29,7 @@ type AnalyzeRequest struct { // AnalyzeResponse 分析响应结构体 type AnalyzeResponse struct { ExistingPackages []PackageInfo `json:"existingPackages"` // 现有包信息 - PredesignedModules []PredesignedModuleInfo `json:"predesignedModules"` // 预设计模块信息 +PredesignedModules []PredesignedModuleInfo `json:"predesignedModules"` // 预设计模块信息 Dictionaries []DictionaryPre `json:"dictionaries"` // 字典信息 CleanupInfo *CleanupInfo `json:"cleanupInfo"` // 清理信息(如果有) } @@ -43,13 +42,13 @@ type ModuleInfo struct { StructName string `json:"structName"` // 结构体名称 TableName string `json:"tableName"` // 表名 Description string `json:"description"` // 描述 - FilePaths []string `json:"filePaths"` // 相关文件路径 +FilePaths []string `json:"filePaths"` // 相关文件路径 } // PackageInfo 包信息 type PackageInfo struct { PackageName string `json:"packageName"` // 包名 - Template string `json:"template"` // 模板类型 +Template string `json:"template"` // 模板类型 Label string `json:"label"` // 标签 Desc string `json:"desc"` // 描述 Module string `json:"module"` // 模块 @@ -60,7 +59,7 @@ type PackageInfo struct { type PredesignedModuleInfo struct { ModuleName string `json:"moduleName"` // 模块名称 PackageName string `json:"packageName"` // 包名 - Template string `json:"template"` // 模板类型 +Template string `json:"template"` // 模板类型 FilePaths []string `json:"filePaths"` // 文件路径列表 Description string `json:"description"` // 描述 } @@ -117,63 +116,65 @@ func (g *GVAAnalyzer) Handle(ctx context.Context, request mcp.CallToolRequest) ( // performAnalysis 执行分析逻辑 func (g *GVAAnalyzer) performAnalysis(ctx context.Context, req AnalyzeRequest) (*AnalyzeResponse, error) { - // 1. 获取数据库中的包信息 - var packages []model.SysAutoCodePackage - if err := global.GVA_DB.Find(&packages).Error; err != nil { + _ = req + + packages, err := fetchAutoCodePackages(ctx) + if err != nil { return nil, fmt.Errorf("获取包信息失败: %v", err) } - // 2. 获取历史记录 - var histories []model.SysAutoCodeHistory - if err := global.GVA_DB.Find(&histories).Error; err != nil { + histories, err := fetchAutoCodeHistories(ctx) + if err != nil { return nil, fmt.Errorf("获取历史记录失败: %v", err) } - // 3. 检查空包并进行清理 cleanupInfo := &CleanupInfo{ DeletedPackages: []string{}, DeletedModules: []string{}, } - var validPackages []model.SysAutoCodePackage + validPackages := make([]PackageInfo, 0, len(packages)) var emptyPackageHistoryIDs []uint for _, pkg := range packages { isEmpty, err := g.isPackageFolderEmpty(pkg.PackageName, pkg.Template) if err != nil { - global.GVA_LOG.Warn(fmt.Sprintf("检查包 %s 是否为空时出错: %v", pkg.PackageName, err)) +global.GVA_LOG.Warn(fmt.Sprintf("检查包 %s 是否为空时出错: %v", pkg.PackageName, err)) continue } if isEmpty { - // 删除空包文件夹 if err := g.removeEmptyPackageFolder(pkg.PackageName, pkg.Template); err != nil { global.GVA_LOG.Warn(fmt.Sprintf("删除空包文件夹 %s 失败: %v", pkg.PackageName, err)) } else { cleanupInfo.DeletedPackages = append(cleanupInfo.DeletedPackages, pkg.PackageName) } - // 删除数据库记录 - if err := global.GVA_DB.Delete(&pkg).Error; err != nil { + if err := deleteAutoCodePackage(ctx, pkg.ID); err != nil { global.GVA_LOG.Warn(fmt.Sprintf("删除包数据库记录 %s 失败: %v", pkg.PackageName, err)) } - // 收集相关的历史记录ID for _, history := range histories { if history.Package == pkg.PackageName { emptyPackageHistoryIDs = append(emptyPackageHistoryIDs, history.ID) cleanupInfo.DeletedModules = append(cleanupInfo.DeletedModules, history.StructName) } } - } else { - validPackages = append(validPackages, pkg) + continue } + + validPackages = append(validPackages, PackageInfo{ + PackageName: pkg.PackageName, + Template: pkg.Template, + Label: pkg.Label, + Desc: pkg.Desc, + Module: pkg.Module, + IsEmpty: false, + }) } - // 5. 清理空包相关的历史记录和脏历史记录 var dirtyHistoryIDs []uint for _, history := range histories { - // 检查是否为空包相关的历史记录 for _, emptyID := range emptyPackageHistoryIDs { if history.ID == emptyID { dirtyHistoryIDs = append(dirtyHistoryIDs, history.ID) @@ -182,28 +183,30 @@ func (g *GVAAnalyzer) performAnalysis(ctx context.Context, req AnalyzeRequest) ( } } - // 删除脏历史记录 if len(dirtyHistoryIDs) > 0 { - if err := global.GVA_DB.Delete(&model.SysAutoCodeHistory{}, "id IN ?", dirtyHistoryIDs).Error; err != nil { - global.GVA_LOG.Warn(fmt.Sprintf("删除脏历史记录失败: %v", err)) - } else { - global.GVA_LOG.Info(fmt.Sprintf("成功删除 %d 条脏历史记录", len(dirtyHistoryIDs))) + deletedCount := 0 + for _, historyID := range dirtyHistoryIDs { + if err := deleteAutoCodeHistory(ctx, historyID); err != nil { + global.GVA_LOG.Warn(fmt.Sprintf("删除脏历史记录失败: %v", err)) + continue + } + deletedCount++ + } + if deletedCount > 0 { + global.GVA_LOG.Info(fmt.Sprintf("成功删除 %d 条脏历史记录", deletedCount)) } - // 清理相关的API和菜单记录 if err := g.cleanupRelatedApiAndMenus(dirtyHistoryIDs); err != nil { global.GVA_LOG.Warn(fmt.Sprintf("清理相关API和菜单记录失败: %v", err)) } } - // 6. 扫描预设计模块 predesignedModules, err := g.scanPredesignedModules() if err != nil { global.GVA_LOG.Warn(fmt.Sprintf("扫描预设计模块失败: %v", err)) - predesignedModules = []PredesignedModuleInfo{} // 设置为空列表,不影响主流程 + predesignedModules = []PredesignedModuleInfo{} } - // 7. 过滤掉与已删除包相关的模块 filteredModules := []PredesignedModuleInfo{} for _, module := range predesignedModules { isDeleted := false @@ -218,49 +221,38 @@ func (g *GVAAnalyzer) performAnalysis(ctx context.Context, req AnalyzeRequest) ( } } - // 8. 构建分析结果消息 - var analysisMessage strings.Builder + dictionaries := []DictionaryPre{} + dictEntities, err := fetchDictionaryList(ctx, "") + if err != nil { + global.GVA_LOG.Warn(fmt.Sprintf("获取字典信息失败: %v", err)) + } else { + for _, dictionary := range dictEntities { + dictionaries = append(dictionaries, DictionaryPre{ + Type: dictionary.Type, + Desc: dictionary.Desc, + }) + } + } + + var cleanupResult *CleanupInfo if len(cleanupInfo.DeletedPackages) > 0 || len(cleanupInfo.DeletedModules) > 0 { - analysisMessage.WriteString("**系统清理完成**\n\n") + var message strings.Builder + message.WriteString("**系统清理完成**\n\n") if len(cleanupInfo.DeletedPackages) > 0 { - analysisMessage.WriteString(fmt.Sprintf("- 删除了 %d 个空包: %s\n", len(cleanupInfo.DeletedPackages), strings.Join(cleanupInfo.DeletedPackages, ", "))) +message.WriteString(fmt.Sprintf("- 删除了 %d 个空包: %s\n", len(cleanupInfo.DeletedPackages), strings.Join(cleanupInfo.DeletedPackages, ", "))) } if len(cleanupInfo.DeletedModules) > 0 { - analysisMessage.WriteString(fmt.Sprintf("- 删除了 %d 个相关模块: %s\n", len(cleanupInfo.DeletedModules), strings.Join(cleanupInfo.DeletedModules, ", "))) - } - analysisMessage.WriteString("\n") - cleanupInfo.CleanupMessage = analysisMessage.String() - } - - analysisMessage.WriteString(" **分析结果**\n\n") - analysisMessage.WriteString(fmt.Sprintf("- **现有包数量**: %d\n", len(validPackages))) - analysisMessage.WriteString(fmt.Sprintf("- **预设计模块数量**: %d\n\n", len(filteredModules))) - - // 9. 转换包信息 - existingPackages := make([]PackageInfo, len(validPackages)) - for i, pkg := range validPackages { - existingPackages[i] = PackageInfo{ - PackageName: pkg.PackageName, - Template: pkg.Template, - Label: pkg.Label, - Desc: pkg.Desc, - Module: pkg.Module, - IsEmpty: false, // 已经过滤掉空包 +message.WriteString(fmt.Sprintf("- 删除了 %d 个相关模块: %s\n", len(cleanupInfo.DeletedModules), strings.Join(cleanupInfo.DeletedModules, ", "))) } + cleanupInfo.CleanupMessage = message.String() + cleanupResult = cleanupInfo } - dictionaries := []DictionaryPre{} // 这里可以根据需要填充字典信息 - err = global.GVA_DB.Table("sys_dictionaries").Find(&dictionaries, "deleted_at is null").Error - if err != nil { - global.GVA_LOG.Warn(fmt.Sprintf("获取字典信息失败: %v", err)) - dictionaries = []DictionaryPre{} // 设置为空列表,不影响主流程 - } - - // 10. 构建响应 response := &AnalyzeResponse{ - ExistingPackages: existingPackages, + ExistingPackages: validPackages, PredesignedModules: filteredModules, Dictionaries: dictionaries, + CleanupInfo: cleanupResult, } return response, nil @@ -278,7 +270,7 @@ func (g *GVAAnalyzer) isPackageFolderEmpty(packageName, template string) (bool, // 检查文件夹是否存在 if _, err := os.Stat(basePath); os.IsNotExist(err) { - return true, nil // 文件夹不存在,视为空 + return true, nil // 文件夹不存在,认为空 } else if err != nil { return false, err // 其他错误 } diff --git a/server/mcp/gva_execute.go b/server/mcp/gva_execute.go index 3b2edbc7de..c44de475bf 100644 --- a/server/mcp/gva_execute.go +++ b/server/mcp/gva_execute.go @@ -5,16 +5,12 @@ import ( "encoding/json" "errors" "fmt" - model "github.com/flipped-aurora/gin-vue-admin/server/model/system" - "github.com/flipped-aurora/gin-vue-admin/server/utils" "strings" "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/system" "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" - - "github.com/flipped-aurora/gin-vue-admin/server/service" "github.com/mark3labs/mcp-go/mcp" - "go.uber.org/zap" ) // 注册工具 @@ -25,13 +21,13 @@ func init() { // GVAExecutor GVA代码生成器 type GVAExecutor struct{} -// ExecuteRequest 执行请求结构 +// ExecuteRequest 执行请求结构体 type ExecuteRequest struct { ExecutionPlan ExecutionPlan `json:"executionPlan"` // 执行计划 Requirement string `json:"requirement"` // 原始需求(可选,用于日志记录) } -// ExecuteResponse 执行响应结构 +// ExecuteResponse 执行响应结构体 type ExecuteResponse struct { Success bool `json:"success"` Message string `json:"message"` @@ -42,7 +38,7 @@ type ExecuteResponse struct { NextActions []string `json:"nextActions,omitempty"` } -// ExecutionPlan 执行计划结构 +// ExecutionPlan 执行计划结构体 type ExecutionPlan struct { PackageName string `json:"packageName"` PackageType string `json:"packageType"` // "plugin" 或 "package" @@ -57,7 +53,7 @@ type ExecutionPlan struct { // New 创建GVA代码生成执行器工具 func (g *GVAExecutor) New() mcp.Tool { - return mcp.NewTool("gva_execute", + return mcp.NewTool("gva_execute", mcp.WithDescription(`**GVA代码生成执行器:直接执行代码生成,无需确认步骤** **核心功能:** @@ -71,147 +67,147 @@ func (g *GVAExecutor) New() mcp.Tool { - 字段使用字典类型时,系统会自动检查并创建字典 - 字典创建会在模块创建之前执行 - 当字段配置了dataSource且association=2(一对多关联)时,系统会自动将fieldType修改为'array'`), - mcp.WithObject("executionPlan", - mcp.Description("执行计划,包含包信息、模块与字典信息"), - mcp.Required(), - mcp.Properties(map[string]interface{}{ - "packageName": map[string]interface{}{ - "type": "string", - "description": "包名(小写开头)", - }, - "packageType": map[string]interface{}{ - "type": "string", - "description": "package 或 plugin,如果用户提到了使用插件则创建plugin,如果用户没有特定说明则一律选用package", - "enum": []string{"package", "plugin"}, - }, - "needCreatedPackage": map[string]interface{}{ - "type": "boolean", - "description": "是否需要创建包,为true时packageInfo必需", - }, - "needCreatedModules": map[string]interface{}{ - "type": "boolean", - "description": "是否需要创建模块,为true时modulesInfo必需", - }, - "needCreatedDictionaries": map[string]interface{}{ - "type": "boolean", - "description": "是否需要创建字典,为true时dictionariesInfo必需", - }, - "packageInfo": map[string]interface{}{ - "type": "object", - "description": "包创建信息,当needCreatedPackage=true时必需", - "properties": map[string]interface{}{ - "desc": map[string]interface{}{"type": "string", "description": "包描述"}, - "label": map[string]interface{}{"type": "string", "description": "展示名"}, - "template": map[string]interface{}{"type": "string", "description": "package 或 plugin,如果用户提到了使用插件则创建plugin,如果用户没有特定说明则一律选用package", "enum": []string{"package", "plugin"}}, - "packageName": map[string]interface{}{"type": "string", "description": "包名"}, - }, - }, - "modulesInfo": map[string]interface{}{ - "type": "array", - "description": "模块配置列表,支持批量创建多个模块", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "package": map[string]interface{}{"type": "string", "description": "包名(小写开头,示例: userInfo)"}, - "tableName": map[string]interface{}{"type": "string", "description": "数据库表名(蛇形命名法,示例:user_info)"}, - "businessDB": map[string]interface{}{"type": "string", "description": "业务数据库(可留空表示默认)"}, - "structName": map[string]interface{}{"type": "string", "description": "结构体名(大驼峰示例:UserInfo)"}, - "packageName": map[string]interface{}{"type": "string", "description": "文件名称"}, - "description": map[string]interface{}{"type": "string", "description": "中文描述"}, - "abbreviation": map[string]interface{}{"type": "string", "description": "简称"}, - "humpPackageName": map[string]interface{}{"type": "string", "description": "文件名称(小驼峰),一般是结构体名的小驼峰示例:userInfo"}, - "gvaModel": map[string]interface{}{"type": "boolean", "description": "是否使用GVA模型(固定为true),自动包含ID、CreatedAt、UpdatedAt、DeletedAt字段"}, - "autoMigrate": map[string]interface{}{"type": "boolean", "description": "是否自动迁移数据库"}, - "autoCreateResource": map[string]interface{}{"type": "boolean", "description": "是否创建资源(默认为false)"}, - "autoCreateApiToSql": map[string]interface{}{"type": "boolean", "description": "是否创建API(默认为true)"}, - "autoCreateMenuToSql": map[string]interface{}{"type": "boolean", "description": "是否创建菜单(默认为true)"}, - "autoCreateBtnAuth": map[string]interface{}{"type": "boolean", "description": "是否创建按钮权限(默认为false)"}, - "onlyTemplate": map[string]interface{}{"type": "boolean", "description": "是否仅模板(默认为false)"}, - "isTree": map[string]interface{}{"type": "boolean", "description": "是否树形结构(默认为false)"}, - "treeJson": map[string]interface{}{"type": "string", "description": "树形JSON字段"}, - "isAdd": map[string]interface{}{"type": "boolean", "description": "是否新增(固定为false)"}, - "generateWeb": map[string]interface{}{"type": "boolean", "description": "是否生成前端代码"}, - "generateServer": map[string]interface{}{"type": "boolean", "description": "是否生成后端代码"}, - "fields": map[string]interface{}{ - "type": "array", - "description": "字段列表", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "fieldName": map[string]interface{}{"type": "string", "description": "字段名(必须大写开头示例:UserName)"}, - "fieldDesc": map[string]interface{}{"type": "string", "description": "字段描述"}, - "fieldType": map[string]interface{}{"type": "string", "description": "字段类型:string(字符串)、richtext(富文本)、int(整型)、bool(布尔值)、float64(浮点型)、time.Time(时间)、enum(枚举)、picture(单图片)、pictures(多图片)、video(视频)、file(文件)、json(JSON)、array(数组)"}, - "fieldJson": map[string]interface{}{"type": "string", "description": "JSON标签,示例: userName"}, - "dataTypeLong": map[string]interface{}{"type": "string", "description": "数据长度"}, - "comment": map[string]interface{}{"type": "string", "description": "注释"}, - "columnName": map[string]interface{}{"type": "string", "description": "数据库列名,示例: user_name"}, - "fieldSearchType": map[string]interface{}{"type": "string", "description": "搜索类型:=、!=、>、>=、<、<=、LIKE、BETWEEN、IN、NOT IN、NOT BETWEEN"}, - "fieldSearchHide": map[string]interface{}{"type": "boolean", "description": "是否隐藏搜索"}, - "dictType": map[string]interface{}{"type": "string", "description": "字典类型,使用字典类型时系统会自动检查并创建字典"}, - "form": map[string]interface{}{"type": "boolean", "description": "表单显示"}, - "table": map[string]interface{}{"type": "boolean", "description": "表格显示"}, - "desc": map[string]interface{}{"type": "boolean", "description": "详情显示"}, - "excel": map[string]interface{}{"type": "boolean", "description": "导入导出"}, - "require": map[string]interface{}{"type": "boolean", "description": "是否必填"}, - "defaultValue": map[string]interface{}{"type": "string", "description": "默认值"}, - "errorText": map[string]interface{}{"type": "string", "description": "错误提示"}, - "clearable": map[string]interface{}{"type": "boolean", "description": "是否可清空"}, - "sort": map[string]interface{}{"type": "boolean", "description": "是否排序"}, - "primaryKey": map[string]interface{}{"type": "boolean", "description": "是否主键(gvaModel=false时必须有一个字段为true)"}, - "dataSource": map[string]interface{}{ - "type": "object", - "description": "数据源配置,用于配置字段的关联表信息。获取表名提示:可在 server/model 和 plugin/xxx/model 目录下查看对应模块的 TableName() 接口实现获取实际表名(如 SysUser 的表名为 sys_users)。获取数据库名提示:主数据库通常使用 gva(默认数据库标识),多数据库可在 config.yaml 的 db-list 配置中查看可用数据库的 alias-name 字段,如果用户未提及关联多数据库信息则使用默认数据库,默认数据库的情况下 dbName填写为空", - "properties": map[string]interface{}{ - "dbName": map[string]interface{}{"type": "string", "description": "关联的数据库名称(默认数据库留空)"}, - "table": map[string]interface{}{"type": "string", "description": "关联的表名"}, - "label": map[string]interface{}{"type": "string", "description": "用于显示的字段名(如name、title等)"}, - "value": map[string]interface{}{"type": "string", "description": "用于存储的值字段名(通常是id)"}, - "association": map[string]interface{}{"type": "integer", "description": "关联关系类型:1=一对一关联,2=一对多关联。一对一和一对多的前面的一是当前的实体,如果他只能关联另一个实体的一个则选用一对一,如果他需要关联多个他的关联实体则选用一对多"}, - "hasDeletedAt": map[string]interface{}{"type": "boolean", "description": "关联表是否有软删除字段"}, - }, - }, - "checkDataSource": map[string]interface{}{"type": "boolean", "description": "是否检查数据源,启用后会验证关联表的存在性"}, - "fieldIndexType": map[string]interface{}{"type": "string", "description": "索引类型"}, - }, - }, - }, - }, - }, - }, - "paths": map[string]interface{}{ - "type": "object", - "description": "生成的文件路径映射", - "additionalProperties": map[string]interface{}{"type": "string"}, - }, - "dictionariesInfo": map[string]interface{}{ - "type": "array", - "description": "字典创建信息,字典创建会在模块创建之前执行", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "dictType": map[string]interface{}{"type": "string", "description": "字典类型,用于标识字典的唯一性"}, - "dictName": map[string]interface{}{"type": "string", "description": "字典名称,必须生成,字典的中文名称"}, - "description": map[string]interface{}{"type": "string", "description": "字典描述,字典的用途说明"}, - "status": map[string]interface{}{"type": "boolean", "description": "字典状态:true启用,false禁用"}, - "fieldDesc": map[string]interface{}{"type": "string", "description": "字段描述,用于AI理解字段含义并生成合适的选项"}, - "options": map[string]interface{}{ - "type": "array", - "description": "字典选项列表(可选,如果不提供将根据fieldDesc自动生成默认选项)", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "label": map[string]interface{}{"type": "string", "description": "显示名称,用户看到的选项名"}, - "value": map[string]interface{}{"type": "string", "description": "选项值,实际存储的值"}, - "sort": map[string]interface{}{"type": "integer", "description": "排序号,数字越小越靠前"}, - }, - }, - }, - }, - }, - }, - }), - mcp.AdditionalProperties(false), - ), + mcp.WithObject("executionPlan", + mcp.Description("执行计划,包含包信息、模块与字典信息"), + mcp.Required(), + mcp.Properties(map[string]interface{}{ + "packageName": map[string]interface{}{ + "type": "string", + "description": "包名(小写开头)", + }, + "packageType": map[string]interface{}{ + "type": "string", + "description": "package 或 plugin,如果用户提到了使用插件则创建plugin,如果用户没有特定说明则一律选用package", + "enum": []string{"package", "plugin"}, + }, + "needCreatedPackage": map[string]interface{}{ + "type": "boolean", + "description": "是否需要创建包,为true时packageInfo必需", + }, + "needCreatedModules": map[string]interface{}{ + "type": "boolean", + "description": "是否需要创建模块,为true时modulesInfo必需", + }, + "needCreatedDictionaries": map[string]interface{}{ + "type": "boolean", + "description": "是否需要创建字典,为true时dictionariesInfo必需", + }, + "packageInfo": map[string]interface{}{ + "type": "object", + "description": "包创建信息,当needCreatedPackage=true时必需", + "properties": map[string]interface{}{ + "desc": map[string]interface{}{"type": "string", "description": "包描述"}, + "label": map[string]interface{}{"type": "string", "description": "展示名"}, + "template": map[string]interface{}{"type": "string", "description": "package 或 plugin,如果用户提到了使用插件则创建plugin,如果用户没有特定说明则一律选用package", "enum": []string{"package", "plugin"}}, + "packageName": map[string]interface{}{"type": "string", "description": "包名"}, + }, + }, + "modulesInfo": map[string]interface{}{ + "type": "array", + "description": "模块配置列表,支持批量创建多个模块", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "package": map[string]interface{}{"type": "string", "description": "包名(小写开头,示例: userInfo)"}, + "tableName": map[string]interface{}{"type": "string", "description": "数据库表名(蛇形命名法,示例:user_info)"}, + "businessDB": map[string]interface{}{"type": "string", "description": "业务数据库(可留空表示默认)"}, + "structName": map[string]interface{}{"type": "string", "description": "结构体名(大驼峰示例:UserInfo)"}, + "packageName": map[string]interface{}{"type": "string", "description": "文件名称"}, + "description": map[string]interface{}{"type": "string", "description": "中文描述"}, + "abbreviation": map[string]interface{}{"type": "string", "description": "简称"}, + "humpPackageName": map[string]interface{}{"type": "string", "description": "文件名称(小驼峰),一般是结构体名的小驼峰示例:userInfo"}, + "gvaModel": map[string]interface{}{"type": "boolean", "description": "是否使用GVA模型(固定为true),自动包含ID、CreatedAt、UpdatedAt、DeletedAt字段"}, + "autoMigrate": map[string]interface{}{"type": "boolean", "description": "是否自动迁移数据库"}, + "autoCreateResource": map[string]interface{}{"type": "boolean", "description": "是否创建资源(默认为false)"}, + "autoCreateApiToSql": map[string]interface{}{"type": "boolean", "description": "是否创建API(默认为true)"}, + "autoCreateMenuToSql": map[string]interface{}{"type": "boolean", "description": "是否创建菜单(默认为true)"}, + "autoCreateBtnAuth": map[string]interface{}{"type": "boolean", "description": "是否创建按钮权限(默认为false)"}, + "onlyTemplate": map[string]interface{}{"type": "boolean", "description": "是否仅模板(默认为false)"}, + "isTree": map[string]interface{}{"type": "boolean", "description": "是否树形结构(默认为false)"}, + "treeJson": map[string]interface{}{"type": "string", "description": "树形JSON字段"}, + "isAdd": map[string]interface{}{"type": "boolean", "description": "是否新增(固定为false)"}, + "generateWeb": map[string]interface{}{"type": "boolean", "description": "是否生成前端代码"}, + "generateServer": map[string]interface{}{"type": "boolean", "description": "是否生成后端代码"}, + "fields": map[string]interface{}{ + "type": "array", + "description": "字段列表", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "fieldName": map[string]interface{}{"type": "string", "description": "字段名(必须大写开头示例:UserName)"}, + "fieldDesc": map[string]interface{}{"type": "string", "description": "字段描述"}, + "fieldType": map[string]interface{}{"type": "string", "description": "字段类型:string(字符串)、richtext(富文本)、int(整型)、bool(布尔值)、float64(浮点型)、time.Time(时间)、enum(枚举)、picture(单图片)、pictures(多图片)、video(视频)、file(文件)、json(JSON)、array(数组)"}, + "fieldJson": map[string]interface{}{"type": "string", "description": "JSON标签,示例: userName"}, + "dataTypeLong": map[string]interface{}{"type": "string", "description": "数据长度"}, + "comment": map[string]interface{}{"type": "string", "description": "注释"}, + "columnName": map[string]interface{}{"type": "string", "description": "数据库列名,示例: user_name"}, + "fieldSearchType": map[string]interface{}{"type": "string", "description": "搜索类型:=、!=、>、>=、<、<=、LIKE、BETWEEN、IN、NOT IN、NOT BETWEEN"}, + "fieldSearchHide": map[string]interface{}{"type": "boolean", "description": "是否隐藏搜索"}, + "dictType": map[string]interface{}{"type": "string", "description": "字典类型,使用字典类型时系统会自动检查并创建字典"}, + "form": map[string]interface{}{"type": "boolean", "description": "表单显示"}, + "table": map[string]interface{}{"type": "boolean", "description": "表格显示"}, + "desc": map[string]interface{}{"type": "boolean", "description": "详情显示"}, + "excel": map[string]interface{}{"type": "boolean", "description": "导入导出"}, + "require": map[string]interface{}{"type": "boolean", "description": "是否必填"}, + "defaultValue": map[string]interface{}{"type": "string", "description": "默认值"}, + "errorText": map[string]interface{}{"type": "string", "description": "错误提示"}, + "clearable": map[string]interface{}{"type": "boolean", "description": "是否可清空"}, + "sort": map[string]interface{}{"type": "boolean", "description": "是否排序"}, + "primaryKey": map[string]interface{}{"type": "boolean", "description": "是否主键(gvaModel=false时必须有一个字段为true)"}, + "dataSource": map[string]interface{}{ + "type": "object", + "description": "数据源配置,用于配置字段的关联表信息。获取表名提示:可在 server/model 和 plugin/xxx/model 目录下查看对应模块的 TableName() 接口实现获取实际表名(如 SysUser 的表名为 sys_users)。获取数据库名提示:主数据库通常使用 gva(默认数据库标识),多数据库可在 config.yaml 的 db-list 配置中查看可用数据库的 alias-name 字段,如果用户未提及关联多数据库信息则使用默认数据库,默认数据库的情况下 dbName填写为空", + "properties": map[string]interface{}{ + "dbName": map[string]interface{}{"type": "string", "description": "关联的数据库名称(默认数据库留空)"}, + "table": map[string]interface{}{"type": "string", "description": "关联的表名"}, + "label": map[string]interface{}{"type": "string", "description": "用于显示的字段名(如name、title等)"}, + "value": map[string]interface{}{"type": "string", "description": "用于存储的值字段名(通常是id)"}, + "association": map[string]interface{}{"type": "integer", "description": "关联关系类型:1=一对一关联,2=一对多关联。一对一和一对多的前面的一是当前的实体,如果他只能关联另一个实体的一个则选用一对一,如果他需要关联多个他的关联实体则选用一对多"}, + "hasDeletedAt": map[string]interface{}{"type": "boolean", "description": "关联表是否有软删除字段"}, + }, + }, + "checkDataSource": map[string]interface{}{"type": "boolean", "description": "是否检查数据源,启用后会验证关联表的存在性"}, + "fieldIndexType": map[string]interface{}{"type": "string", "description": "索引类型"}, + }, + }, + }, + }, + }, + }, + "paths": map[string]interface{}{ + "type": "object", + "description": "生成的文件路径映射", + "additionalProperties": map[string]interface{}{"type": "string"}, + }, + "dictionariesInfo": map[string]interface{}{ + "type": "array", + "description": "字典创建信息,字典创建会在模块创建之前执行", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "dictType": map[string]interface{}{"type": "string", "description": "字典类型,用于标识字典的唯一性"}, + "dictName": map[string]interface{}{"type": "string", "description": "字典名称,必须生成,字典的中文名称"}, + "description": map[string]interface{}{"type": "string", "description": "字典描述,字典的用途说明"}, + "status": map[string]interface{}{"type": "boolean", "description": "字典状态:true启用,false禁用"}, + "fieldDesc": map[string]interface{}{"type": "string", "description": "字段描述,用于AI理解字段含义并生成合适的选项"}, + "options": map[string]interface{}{ + "type": "array", + "description": "字典选项列表(可选,如果不提供将根据fieldDesc自动生成默认选项)", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "label": map[string]interface{}{"type": "string", "description": "显示名称,用户看到的选项名"}, + "value": map[string]interface{}{"type": "string", "description": "选项值,实际存储的值"}, + "sort": map[string]interface{}{"type": "integer", "description": "排序号,数字越小越靠前"}, + }, + }, + }, + }, + }, + }, + }), + mcp.AdditionalProperties(false), + ), mcp.WithString("requirement", mcp.Description("原始需求描述(可选,用于日志记录)"), ), @@ -294,7 +290,6 @@ func (g *GVAExecutor) Handle(ctx context.Context, request mcp.CallToolRequest) ( // validateExecutionPlan 验证执行计划的完整性 func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { - // 验证基本字段 if plan.PackageName == "" { return errors.New("packageName 不能为空") } @@ -302,14 +297,10 @@ func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { return errors.New("packageType 必须是 'package' 或 'plugin'") } - // 验证packageType和template字段的一致性 - if plan.NeedCreatedPackage && plan.PackageInfo != nil { - if plan.PackageType != plan.PackageInfo.Template { - return errors.New("packageType 和 packageInfo.template 必须保持一致") - } + if plan.NeedCreatedPackage && plan.PackageInfo != nil && plan.PackageType != plan.PackageInfo.Template { + return errors.New("packageType 和 packageInfo.template 必须保持一致") } - // 验证包信息 if plan.NeedCreatedPackage { if plan.PackageInfo == nil { return errors.New("当 needCreatedPackage=true 时,packageInfo 不能为空") @@ -328,13 +319,11 @@ func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { } } - // 验证模块信息(批量验证) if plan.NeedCreatedModules { if len(plan.ModulesInfo) == 0 { return errors.New("当 needCreatedModules=true 时,modulesInfo 不能为空") } - // 遍历验证每个模块 for moduleIndex, moduleInfo := range plan.ModulesInfo { if moduleInfo.Package == "" { return fmt.Errorf("模块 %d 的 package 不能为空", moduleIndex+1) @@ -357,8 +346,6 @@ func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { if moduleInfo.HumpPackageName == "" { return fmt.Errorf("模块 %d 的 humpPackageName 不能为空", moduleIndex+1) } - - // 验证字段信息 if len(moduleInfo.Fields) == 0 { return fmt.Errorf("模块 %d 的 fields 不能为空,至少需要一个字段", moduleIndex+1) } @@ -367,8 +354,6 @@ func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { if field.FieldName == "" { return fmt.Errorf("模块 %d 字段 %d 的 fieldName 不能为空", moduleIndex+1, i+1) } - - // 确保字段名首字母大写 if len(field.FieldName) > 0 { firstChar := string(field.FieldName[0]) if firstChar >= "a" && firstChar <= "z" { @@ -388,7 +373,6 @@ func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { return fmt.Errorf("模块 %d 字段 %d 的 columnName 不能为空", moduleIndex+1, i+1) } - // 验证字段类型 validFieldTypes := []string{"string", "int", "int64", "float64", "bool", "time.Time", "enum", "picture", "video", "file", "pictures", "array", "richtext", "json"} validType := false for _, validFieldType := range validFieldTypes { @@ -398,45 +382,36 @@ func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { } } if !validType { - return fmt.Errorf("模块 %d 字段 %d 的 fieldType '%s' 不支持,支持的类型:%v", moduleIndex+1, i+1, field.FieldType, validFieldTypes) + return fmt.Errorf("模块 %d 字段 %d 的 fieldType '%s' 不支持", moduleIndex+1, i+1, field.FieldType) } - // 验证搜索类型(如果设置了) if field.FieldSearchType != "" { validSearchTypes := []string{"=", "!=", ">", ">=", "<", "<=", "LIKE", "BETWEEN", "IN", "NOT IN"} validSearchType := false - for _, validType := range validSearchTypes { - if field.FieldSearchType == validType { + for _, validSearchTypeValue := range validSearchTypes { + if field.FieldSearchType == validSearchTypeValue { validSearchType = true break } } if !validSearchType { - return fmt.Errorf("模块 %d 字段 %d 的 fieldSearchType '%s' 不支持,支持的类型:%v", moduleIndex+1, i+1, field.FieldSearchType, validSearchTypes) + return fmt.Errorf("模块 %d 字段 %d 的 fieldSearchType '%s' 不支持", moduleIndex+1, i+1, field.FieldSearchType) } } - // 验证 dataSource 字段配置 if field.DataSource != nil { associationValue := field.DataSource.Association - // 当 association 为 2(一对多关联)时,强制修改 fieldType 为 array - if associationValue == 2 { - if field.FieldType != "array" { - global.GVA_LOG.Info(fmt.Sprintf("模块 %d 字段 %d:检测到一对多关联(association=2),自动将 fieldType 从 '%s' 修改为 'array'", moduleIndex+1, i+1, field.FieldType)) - moduleInfo.Fields[i].FieldType = "array" - } + if associationValue == 2 && field.FieldType != "array" { + global.GVA_LOG.Info(fmt.Sprintf("module %d field %d association=2, force fieldType to array", moduleIndex+1, i+1)) + moduleInfo.Fields[i].FieldType = "array" } - - // 验证 association 值的有效性 if associationValue != 1 && associationValue != 2 { - return fmt.Errorf("模块 %d 字段 %d 的 dataSource.association 必须是 1(一对一)或 2(一对多)", moduleIndex+1, i+1) + return fmt.Errorf("模块 %d 字段 %d 的 dataSource.association 必须是 1 或 2", moduleIndex+1, i+1) } } } - // 验证主键设置 if !moduleInfo.GvaModel { - // 当不使用GVA模型时,必须有且仅有一个字段设置为主键 primaryKeyCount := 0 for _, field := range moduleInfo.Fields { if field.PrimaryKey { @@ -450,10 +425,9 @@ func (g *GVAExecutor) validateExecutionPlan(plan *ExecutionPlan) error { return fmt.Errorf("模块 %d:当 gvaModel=false 时,只能有一个字段的 primaryKey=true", moduleIndex+1) } } else { - // 当使用GVA模型时,所有字段的primaryKey都应该为false for i, field := range moduleInfo.Fields { if field.PrimaryKey { - return fmt.Errorf("模块 %d:当 gvaModel=true 时,字段 %d 的 primaryKey 应该为 false,系统会自动创建ID主键", moduleIndex+1, i+1) + return fmt.Errorf("模块 %d:当 gvaModel=true 时,字段 %d 的 primaryKey 应该为 false", moduleIndex+1, i+1) } } } @@ -485,8 +459,7 @@ func (g *GVAExecutor) executeCreation(ctx context.Context, plan *ExecutionPlan) // 创建包(如果需要) if plan.NeedCreatedPackage && plan.PackageInfo != nil { - packageService := service.ServiceGroupApp.SystemServiceGroup.AutoCodePackage - err := packageService.Create(ctx, plan.PackageInfo) + err := createAutoCodePackage(ctx, plan.PackageInfo) if err != nil { result.Message = fmt.Sprintf("创建包失败: %v", err) // 即使创建包失败,也要返回paths信息 @@ -503,8 +476,6 @@ func (g *GVAExecutor) executeCreation(ctx context.Context, plan *ExecutionPlan) // 批量创建字典和模块(如果需要) if plan.NeedCreatedModules && len(plan.ModulesInfo) > 0 { - templateService := service.ServiceGroupApp.SystemServiceGroup.AutoCodeTemplate - // 遍历所有模块进行创建 for _, moduleInfo := range plan.ModulesInfo { @@ -515,7 +486,7 @@ func (g *GVAExecutor) executeCreation(ctx context.Context, plan *ExecutionPlan) continue // 继续处理下一个模块 } - err = templateService.Create(ctx, *moduleInfo) + err = createAutoCodeModule(ctx, *moduleInfo) if err != nil { result.Message += fmt.Sprintf("创建模块 %s 失败: %v; ", moduleInfo.StructName, err) continue // 继续处理下一个模块 @@ -579,7 +550,7 @@ func (g *GVAExecutor) buildDirectoryStructure(plan *ExecutionPlan) map[string]st serverBasePath := fmt.Sprintf("%s/%s", rootPath, serverPath) if packageType == "plugin" { - // Plugin 模式:所有文件都在 /plugin/packageName/ 目录下 + // Plugin 模式:所有文件都在 /plugin/packageName/ 目录中 plugingBasePath := fmt.Sprintf("%s/plugin/%s", serverBasePath, packageName) // API 路径 @@ -631,7 +602,7 @@ func (g *GVAExecutor) buildDirectoryStructure(plan *ExecutionPlan) map[string]st webBasePath := fmt.Sprintf("%s/%s", rootPath, webPath) if packageType == "plugin" { - // Plugin 模式:前端文件也在 /plugin/packageName/ 目录下 + // Plugin 模式:前端文件也在 /plugin/packageName/ 目录中 pluginWebBasePath := fmt.Sprintf("%s/plugin/%s", webBasePath, packageName) // Vue 页面路径 @@ -708,29 +679,20 @@ func (g *GVAExecutor) collectExpectedFilePaths(plan *ExecutionPlan) []string { // checkDictionaryExists 检查字典是否存在 func (g *GVAExecutor) checkDictionaryExists(dictType string) (bool, error) { - dictionaryService := service.ServiceGroupApp.SystemServiceGroup.DictionaryService - _, err := dictionaryService.GetSysDictionary(dictType, 0, nil) + dictionary, err := findDictionaryByType(context.Background(), dictType) if err != nil { - // 如果是记录不存在的错误,返回false - if strings.Contains(err.Error(), "record not found") { - return false, nil - } - // 其他错误返回错误信息 return false, err } - return true, nil + return dictionary != nil, nil } // createDictionariesFromInfo 根据 DictionariesInfo 创建字典 func (g *GVAExecutor) createDictionariesFromInfo(ctx context.Context, dictionariesInfo []*DictionaryGenerateRequest) string { var messages []string - dictionaryService := service.ServiceGroupApp.SystemServiceGroup.DictionaryService - dictionaryDetailService := service.ServiceGroupApp.SystemServiceGroup.DictionaryDetailService messages = append(messages, fmt.Sprintf("开始创建 %d 个指定字典: ", len(dictionariesInfo))) for _, dictInfo := range dictionariesInfo { - // 检查字典是否存在 exists, err := g.checkDictionaryExists(dictInfo.DictType) if err != nil { messages = append(messages, fmt.Sprintf("检查字典 %s 时出错: %v; ", dictInfo.DictType, err)) @@ -738,15 +700,12 @@ func (g *GVAExecutor) createDictionariesFromInfo(ctx context.Context, dictionari } if !exists { - // 字典不存在,创建字典 - dictionary := model.SysDictionary{ + err = createDictionary(ctx, system.SysDictionary{ Name: dictInfo.DictName, Type: dictInfo.DictType, - Status: utils.Pointer(true), + Status: enabledBoolPointer(), Desc: dictInfo.Description, - } - - err = dictionaryService.CreateSysDictionary(dictionary) + }) if err != nil { messages = append(messages, fmt.Sprintf("创建字典 %s 失败: %v; ", dictInfo.DictType, err)) continue @@ -754,30 +713,29 @@ func (g *GVAExecutor) createDictionariesFromInfo(ctx context.Context, dictionari messages = append(messages, fmt.Sprintf("成功创建字典 %s (%s); ", dictInfo.DictType, dictInfo.DictName)) - // 获取刚创建的字典ID - var createdDict model.SysDictionary - err = global.GVA_DB.Where("type = ?", dictInfo.DictType).First(&createdDict).Error + createdDict, err := findDictionaryByType(ctx, dictInfo.DictType) if err != nil { messages = append(messages, fmt.Sprintf("获取创建的字典失败: %v; ", err)) continue } + if createdDict == nil { + messages = append(messages, fmt.Sprintf("获取创建的字典失败: %s; ", dictInfo.DictType)) + continue + } - // 创建字典选项 if len(dictInfo.Options) > 0 { successCount := 0 for _, option := range dictInfo.Options { - dictionaryDetail := model.SysDictionaryDetail{ + dictionaryDetail := system.SysDictionaryDetail{ Label: option.Label, Value: option.Value, - Status: &[]bool{true}[0], // 默认启用 + Status: enabledBoolPointer(), Sort: option.Sort, SysDictionaryID: int(createdDict.ID), } - err = dictionaryDetailService.CreateSysDictionaryDetail(dictionaryDetail) - if err != nil { - global.GVA_LOG.Warn("创建字典详情项失败", zap.Error(err)) - } else { + err = createDictionaryDetail(ctx, dictionaryDetail) + if err == nil { successCount++ } } diff --git a/server/mcp/http_client.go b/server/mcp/http_client.go new file mode 100644 index 0000000000..e2e9e745b2 --- /dev/null +++ b/server/mcp/http_client.go @@ -0,0 +1,153 @@ +package mcpTool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/flipped-aurora/gin-vue-admin/server/global" +) + +type upstreamEnvelope[T any] struct { + Code int `json:"code"` + Data T `json:"data"` + Msg string `json:"msg"` +} + +func ResolveMCPServiceURL() string { + baseURL := strings.TrimSpace(global.GVA_CONFIG.MCP.BaseURL) + if baseURL != "" { + return strings.TrimRight(baseURL, "/") + } + + addr := global.GVA_CONFIG.MCP.Addr + if addr <= 0 { + addr = 8889 + } + + path := strings.TrimSpace(global.GVA_CONFIG.MCP.Path) + if path == "" { + path = "/mcp" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + return fmt.Sprintf("http://127.0.0.1:%d%s", addr, path) +} + +func upstreamBaseURL() string { + baseURL := strings.TrimSpace(global.GVA_CONFIG.MCP.UpstreamBaseURL) + if baseURL != "" { + return strings.TrimRight(baseURL, "/") + } + + return "http://127.0.0.1:8888" +} + +func requestTimeout() time.Duration { + timeout := global.GVA_CONFIG.MCP.RequestTimeout + if timeout <= 0 { + timeout = 15 + } + return time.Duration(timeout) * time.Second +} + +func getUpstream[T any](ctx context.Context, endpoint string, query url.Values) (*upstreamEnvelope[T], error) { + return doUpstream[T](ctx, http.MethodGet, endpoint, query, nil) +} + +func postUpstream[T any](ctx context.Context, endpoint string, body any) (*upstreamEnvelope[T], error) { + return doUpstream[T](ctx, http.MethodPost, endpoint, nil, body) +} + +func deleteUpstream[T any](ctx context.Context, endpoint string, body any) (*upstreamEnvelope[T], error) { + return doUpstream[T](ctx, http.MethodDelete, endpoint, nil, body) +} + +func doUpstream[T any](ctx context.Context, method, endpoint string, query url.Values, body any) (*upstreamEnvelope[T], error) { + token := authTokenFromContext(ctx) + if token == "" { + return nil, fmt.Errorf("缺少MCP鉴权请求头: %s", configuredAuthHeader()) + } + + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return nil, fmt.Errorf("上游接口路径不能为空") + } + if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + + baseURL := upstreamBaseURL() + requestURL, err := url.Parse(baseURL + endpoint) + if err != nil { + return nil, fmt.Errorf("构建上游请求地址失败: %w", err) + } + if len(query) > 0 { + requestURL.RawQuery = query.Encode() + } + + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("序列化上游请求失败: %w", err) + } + reader = bytes.NewReader(payload) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, requestTimeout()) + defer cancel() + + req, err := http.NewRequestWithContext(timeoutCtx, method, requestURL.String(), reader) + if err != nil { + return nil, fmt.Errorf("创建上游请求失败: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set(configuredAuthHeader(), token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求上游服务失败: %w", err) + } + defer resp.Body.Close() + + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取上游响应失败: %w", err) + } + + var result upstreamEnvelope[T] + if len(rawBody) > 0 { + if err := json.Unmarshal(rawBody, &result); err != nil { + return nil, fmt.Errorf("解析上游响应失败: %w", err) + } + } + + if resp.StatusCode >= http.StatusBadRequest { + if result.Msg != "" { + return nil, errors.New(result.Msg) + } + return nil, fmt.Errorf("上游请求失败,状态码: %d", resp.StatusCode) + } + + if result.Code != 0 { + if result.Msg != "" { + return nil, errors.New(result.Msg) + } + return nil, fmt.Errorf("上游请求失败,业务码: %d", result.Code) + } + + return &result, nil +} diff --git a/server/mcp/menu_creator.go b/server/mcp/menu_creator.go index 2d7cd2c491..da97b0ea12 100644 --- a/server/mcp/menu_creator.go +++ b/server/mcp/menu_creator.go @@ -6,50 +6,42 @@ import ( "errors" "fmt" - "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/model/system" - "github.com/flipped-aurora/gin-vue-admin/server/service" "github.com/mark3labs/mcp-go/mcp" - "go.uber.org/zap" ) -// 注册工具 func init() { RegisterTool(&MenuCreator{}) } -// MenuCreateRequest 菜单创建请求结构 type MenuCreateRequest struct { - ParentId uint `json:"parentId"` // 父菜单ID,0表示根菜单 - Path string `json:"path"` // 路由path - Name string `json:"name"` // 路由name - Hidden bool `json:"hidden"` // 是否在列表隐藏 - Component string `json:"component"` // 对应前端文件路径 - Sort int `json:"sort"` // 排序标记 - Title string `json:"title"` // 菜单名 - Icon string `json:"icon"` // 菜单图标 - KeepAlive bool `json:"keepAlive"` // 是否缓存 - DefaultMenu bool `json:"defaultMenu"` // 是否是基础路由 - CloseTab bool `json:"closeTab"` // 自动关闭tab - ActiveName string `json:"activeName"` // 高亮菜单 - Parameters []MenuParameterRequest `json:"parameters"` // 路由参数 - MenuBtn []MenuButtonRequest `json:"menuBtn"` // 菜单按钮 + ParentId uint `json:"parentId"` + Path string `json:"path"` + Name string `json:"name"` + Hidden bool `json:"hidden"` + Component string `json:"component"` + Sort int `json:"sort"` + Title string `json:"title"` + Icon string `json:"icon"` + KeepAlive bool `json:"keepAlive"` + DefaultMenu bool `json:"defaultMenu"` + CloseTab bool `json:"closeTab"` + ActiveName string `json:"activeName"` + Parameters []MenuParameterRequest `json:"parameters"` + MenuBtn []MenuButtonRequest `json:"menuBtn"` } -// MenuParameterRequest 菜单参数请求结构 type MenuParameterRequest struct { - Type string `json:"type"` // 参数类型:params或query - Key string `json:"key"` // 参数key - Value string `json:"value"` // 参数值 + Type string `json:"type"` + Key string `json:"key"` + Value string `json:"value"` } -// MenuButtonRequest 菜单按钮请求结构 type MenuButtonRequest struct { - Name string `json:"name"` // 按钮名称 - Desc string `json:"desc"` // 按钮描述 + Name string `json:"name"` + Desc string `json:"desc"` } -// MenuCreateResponse 菜单创建响应结构 type MenuCreateResponse struct { Success bool `json:"success"` Message string `json:"message"` @@ -58,10 +50,8 @@ type MenuCreateResponse struct { Path string `json:"path"` } -// MenuCreator 菜单创建工具 type MenuCreator struct{} -// New 创建菜单创建工具 func (m *MenuCreator) New() mcp.Tool { return mcp.NewTool("create_menu", mcp.WithDescription(`创建前端菜单记录,用于AI编辑器自动添加前端页面时自动创建对应的菜单项。 @@ -121,75 +111,45 @@ func (m *MenuCreator) New() mcp.Tool { ) } -// Handle 处理菜单创建请求 func (m *MenuCreator) Handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 解析请求参数 args := request.GetArguments() - // 必需参数 path, ok := args["path"].(string) if !ok || path == "" { return nil, errors.New("path 参数是必需的") } - name, ok := args["name"].(string) if !ok || name == "" { return nil, errors.New("name 参数是必需的") } - component, ok := args["component"].(string) if !ok || component == "" { return nil, errors.New("component 参数是必需的") } - title, ok := args["title"].(string) if !ok || title == "" { return nil, errors.New("title 参数是必需的") } - // 可选参数 - parentId := uint(0) - if val, ok := args["parentId"].(float64); ok { - parentId = uint(val) + parentID := uint(0) + if value, ok := args["parentId"].(float64); ok { + parentID = uint(value) } - - hidden := false - if val, ok := args["hidden"].(bool); ok { - hidden = val - } - + hidden, _ := args["hidden"].(bool) sort := 1 - if val, ok := args["sort"].(float64); ok { - sort = int(val) + if value, ok := args["sort"].(float64); ok { + sort = int(value) } - icon := "menu" - if val, ok := args["icon"].(string); ok && val != "" { - icon = val - } - - keepAlive := false - if val, ok := args["keepAlive"].(bool); ok { - keepAlive = val - } - - defaultMenu := false - if val, ok := args["defaultMenu"].(bool); ok { - defaultMenu = val + if value, ok := args["icon"].(string); ok && value != "" { + icon = value } + keepAlive, _ := args["keepAlive"].(bool) + defaultMenu, _ := args["defaultMenu"].(bool) + closeTab, _ := args["closeTab"].(bool) + activeName, _ := args["activeName"].(string) - closeTab := false - if val, ok := args["closeTab"].(bool); ok { - closeTab = val - } - - activeName := "" - if val, ok := args["activeName"].(string); ok { - activeName = val - } - - // 解析参数和按钮 - var parameters []system.SysBaseMenuParameter + parameters := make([]system.SysBaseMenuParameter, 0) if parametersStr, ok := args["parameters"].(string); ok && parametersStr != "" { var paramReqs []MenuParameterRequest if err := json.Unmarshal([]byte(parametersStr), ¶mReqs); err != nil { @@ -204,23 +164,22 @@ func (m *MenuCreator) Handle(ctx context.Context, request mcp.CallToolRequest) ( } } - var menuBtn []system.SysBaseMenuBtn + menuBtns := make([]system.SysBaseMenuBtn, 0) if menuBtnStr, ok := args["menuBtn"].(string); ok && menuBtnStr != "" { - var btnReqs []MenuButtonRequest - if err := json.Unmarshal([]byte(menuBtnStr), &btnReqs); err != nil { + var buttonReqs []MenuButtonRequest + if err := json.Unmarshal([]byte(menuBtnStr), &buttonReqs); err != nil { return nil, fmt.Errorf("menuBtn 参数格式错误: %v", err) } - for _, btn := range btnReqs { - menuBtn = append(menuBtn, system.SysBaseMenuBtn{ - Name: btn.Name, - Desc: btn.Desc, + for _, button := range buttonReqs { + menuBtns = append(menuBtns, system.SysBaseMenuBtn{ + Name: button.Name, + Desc: button.Desc, }) } } - // 构建菜单对象 menu := system.SysBaseMenu{ - ParentId: parentId, + ParentId: parentID, Path: path, Name: name, Hidden: hidden, @@ -235,43 +194,35 @@ func (m *MenuCreator) Handle(ctx context.Context, request mcp.CallToolRequest) ( ActiveName: activeName, }, Parameters: parameters, - MenuBtn: menuBtn, + MenuBtn: menuBtns, } - // 创建菜单 - menuService := service.ServiceGroupApp.SystemServiceGroup.MenuService - err := menuService.AddBaseMenu(menu) - if err != nil { + if _, err := postUpstream[map[string]any](ctx, "/menu/addBaseMenu", menu); err != nil { return nil, fmt.Errorf("创建菜单失败: %v", err) } - // 获取创建的菜单ID - var createdMenu system.SysBaseMenu - err = global.GVA_DB.Where("name = ? AND path = ?", name, path).First(&createdMenu).Error - if err != nil { - global.GVA_LOG.Warn("获取创建的菜单ID失败", zap.Error(err)) + menuID := uint(0) + if menuListResp, err := postUpstream[[]system.SysBaseMenu](ctx, "/menu/getMenuList", map[string]any{}); err == nil { + menuID = findMenuID(menuListResp.Data, name, path) } - // 构建响应 - response := &MenuCreateResponse{ + return textResultWithJSON("菜单创建结果:", &MenuCreateResponse{ Success: true, Message: fmt.Sprintf("成功创建菜单 %s", title), - MenuID: createdMenu.ID, + MenuID: menuID, Name: name, Path: path, - } + }) +} - resultJSON, err := json.MarshalIndent(response, "", " ") - if err != nil { - return nil, fmt.Errorf("序列化结果失败: %v", err) +func findMenuID(menus []system.SysBaseMenu, name, path string) uint { + for _, menu := range menus { + if menu.Name == name && menu.Path == path { + return menu.ID + } + if id := findMenuID(menu.Children, name, path); id != 0 { + return id + } } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("菜单创建结果:\n\n%s", string(resultJSON)), - }, - }, - }, nil + return 0 } diff --git a/server/mcp/menu_lister.go b/server/mcp/menu_lister.go index 825ad237c7..841663b378 100644 --- a/server/mcp/menu_lister.go +++ b/server/mcp/menu_lister.go @@ -2,34 +2,25 @@ package mcpTool import ( "context" - "encoding/json" - "fmt" - "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/model/system" "github.com/mark3labs/mcp-go/mcp" - "go.uber.org/zap" ) -// 注册工具 func init() { - // 注册工具将在enter.go中统一处理 RegisterTool(&MenuLister{}) } -// MenuListResponse 菜单列表响应结构 type MenuListResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Menus []system.SysBaseMenu `json:"menus"` - TotalCount int `json:"totalCount"` - Description string `json:"description"` + Success bool `json:"success"` + Message string `json:"message"` + Menus []system.SysBaseMenu `json:"menus"` + TotalCount int `json:"totalCount"` + Description string `json:"description"` } -// MenuLister 菜单列表工具 type MenuLister struct{} -// New 创建菜单列表工具 func (m *MenuLister) New() mcp.Tool { return mcp.NewTool("list_all_menus", mcp.WithDescription(`获取系统中所有菜单信息,包括菜单树结构、路由信息、组件路径等,用于前端编写vue-router时正确跳转 @@ -46,70 +37,23 @@ func (m *MenuLister) New() mcp.Tool { - 菜单权限管理:了解系统中所有可用的菜单项 - 导航组件开发:构建动态导航菜单 - 系统架构分析:了解系统的菜单结构和页面组织`), -mcp.WithString("_placeholder", + mcp.WithString("_placeholder", mcp.Description("占位符,防止json schema校验失败"), - ), + ), ) } -// Handle 处理菜单列表请求 -func (m *MenuLister) Handle(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - // 获取所有基础菜单 - allMenus, err := m.getAllMenus() +func (m *MenuLister) Handle(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resp, err := postUpstream[[]system.SysBaseMenu](ctx, "/menu/getMenuList", map[string]any{}) if err != nil { - global.GVA_LOG.Error("获取菜单列表失败", zap.Error(err)) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("获取菜单列表失败: %v", err), - }, - }, - IsError: true, - }, nil + return nil, err } - // 构建返回结果 - response := MenuListResponse{ + return textResultWithJSON("", MenuListResponse{ Success: true, Message: "获取菜单列表成功", - Menus: allMenus, - TotalCount: len(allMenus), + Menus: resp.Data, + TotalCount: len(resp.Data), Description: "系统中所有菜单信息的标准列表,包含路由配置和组件信息", - } - - // 序列化响应 - responseJSON, err := json.MarshalIndent(response, "", " ") - if err != nil { - global.GVA_LOG.Error("序列化菜单响应失败", zap.Error(err)) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("序列化响应失败: %v", err), - }, - }, - IsError: true, - }, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: string(responseJSON), - }, - }, - }, nil + }) } - -// getAllMenus 获取所有基础菜单 -func (m *MenuLister) getAllMenus() ([]system.SysBaseMenu, error) { - var menus []system.SysBaseMenu - err := global.GVA_DB.Order("sort").Preload("Parameters").Preload("MenuBtn").Find(&menus).Error - if err != nil { - return nil, err - } - return menus, nil -} - diff --git a/server/mcp/page.go b/server/mcp/page.go new file mode 100644 index 0000000000..92c88321d3 --- /dev/null +++ b/server/mcp/page.go @@ -0,0 +1,8 @@ +package mcpTool + +type pageResultData[T any] struct { + List T `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} diff --git a/server/mcp/process_utils_unix.go b/server/mcp/process_utils_unix.go new file mode 100644 index 0000000000..79c34615e5 --- /dev/null +++ b/server/mcp/process_utils_unix.go @@ -0,0 +1,37 @@ +//go:build !windows + +package mcpTool + +import ( + "errors" + "os/exec" + "syscall" +) + +func prepareDetachedProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } +} + +func processExists(pid int) bool { + if pid <= 0 { + return false + } + + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +func terminateProcess(pid int) error { + if pid <= 0 { + return nil + } + + err := syscall.Kill(pid, syscall.SIGTERM) + if err == nil || errors.Is(err, syscall.ESRCH) { + return nil + } + + return err +} diff --git a/server/mcp/process_utils_windows.go b/server/mcp/process_utils_windows.go new file mode 100644 index 0000000000..7e7b9ca7ae --- /dev/null +++ b/server/mcp/process_utils_windows.go @@ -0,0 +1,58 @@ +//go:build windows + +package mcpTool + +import ( + "errors" + "os" + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +const windowsStillActive = 259 + +func prepareDetachedProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x00000008 | 0x00000200 | 0x08000000, + } +} + +func processExists(pid int) bool { + if pid <= 0 { + return false + } + + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(handle) + + var code uint32 + if err := windows.GetExitCodeProcess(handle, &code); err != nil { + return false + } + + return code == windowsStillActive +} + +func terminateProcess(pid int) error { + if pid <= 0 { + return nil + } + + process, err := os.FindProcess(pid) + if err != nil { + return err + } + + err = process.Kill() + if err == nil || errors.Is(err, os.ErrProcessDone) { + return nil + } + + return err +} diff --git a/server/mcp/result.go b/server/mcp/result.go new file mode 100644 index 0000000000..70b84eb759 --- /dev/null +++ b/server/mcp/result.go @@ -0,0 +1,29 @@ +package mcpTool + +import ( + "encoding/json" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" +) + +func textResultWithJSON(title string, payload any) (*mcp.CallToolResult, error) { + resultJSON, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return nil, fmt.Errorf("序列化结果失败: %w", err) + } + + text := string(resultJSON) + if title != "" { + text = fmt.Sprintf("%s\n\n%s", title, text) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: text, + }, + }, + }, nil +} diff --git a/server/mcp/server.go b/server/mcp/server.go new file mode 100644 index 0000000000..933f1cceaf --- /dev/null +++ b/server/mcp/server.go @@ -0,0 +1,52 @@ +package mcpTool + +import ( + "net/http" + "strings" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + mcpServer "github.com/mark3labs/mcp-go/server" +) + +func NewMCPServer() *mcpServer.MCPServer { + config := global.GVA_CONFIG.MCP + + s := mcpServer.NewMCPServer( + config.Name, + config.Version, + ) + + global.GVA_MCP_SERVER = s + RegisterAllTools(s) + + return s +} + +func NewStreamableHTTPServer() *mcpServer.StreamableHTTPServer { + config := global.GVA_CONFIG.MCP + path := config.Path + if path == "" { + path = "/mcp" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + mux := http.NewServeMux() + httpSrv := &http.Server{ + Handler: mux, + } + + handler := mcpServer.NewStreamableHTTPServer( + NewMCPServer(), + mcpServer.WithHTTPContextFunc(WithHTTPRequestContext), + mcpServer.WithStreamableHTTPServer(httpSrv), + ) + mux.Handle(path, handler) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + return handler +} diff --git a/server/mcp/standalone_manager.go b/server/mcp/standalone_manager.go new file mode 100644 index 0000000000..a7ab497d74 --- /dev/null +++ b/server/mcp/standalone_manager.go @@ -0,0 +1,479 @@ +package mcpTool + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/flipped-aurora/gin-vue-admin/server/global" +) + +const ( + mcpRuntimeDirName = ".tmp" + mcpRuntimeSubDir = "mcp" + mcpRuntimeMetaName = "managed-process.json" + mcpRuntimeLogName = "mcp.log" + mcpHealthCheckTimeout = 2 * time.Second + mcpStartWaitTimeout = 20 * time.Second + mcpStopWaitTimeout = 8 * time.Second + mcpBuildTimeout = 2 * time.Minute +) + +type ManagedStandaloneStatus struct { + State string `json:"state"` + Managed bool `json:"managed"` + Reachable bool `json:"reachable"` + Starting bool `json:"starting"` + BaseURL string `json:"baseURL"` + HealthURL string `json:"healthURL"` + ListenAddr string `json:"listenAddr"` + Path string `json:"path"` + AuthHeader string `json:"authHeader"` + PID int `json:"pid,omitempty"` + LogPath string `json:"logPath,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + LastError string `json:"lastError,omitempty"` + Message string `json:"message,omitempty"` +} + +type managedProcessMeta struct { + PID int `json:"pid"` + StartedAt string `json:"startedAt"` + LogPath string `json:"logPath"` + ConfigPath string `json:"configPath"` + WorkDir string `json:"workDir"` + Command string `json:"command"` + Args []string `json:"args"` +} + +func ResolveMCPListenAddr() string { + addr := global.GVA_CONFIG.MCP.Addr + if addr <= 0 { + addr = 8889 + } + return fmt.Sprintf(":%d", addr) +} + +func ResolveMCPPath() string { + path := strings.TrimSpace(global.GVA_CONFIG.MCP.Path) + if path == "" { + path = "/mcp" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func ResolveMCPHealthURL() string { + baseURL, err := url.Parse(ResolveMCPServiceURL()) + if err != nil || baseURL.Scheme == "" || baseURL.Host == "" { + return fmt.Sprintf("http://127.0.0.1%s/health", ResolveMCPListenAddr()) + } + baseURL.Path = "/health" + baseURL.RawQuery = "" + baseURL.Fragment = "" + return baseURL.String() +} + +func GetManagedStandaloneStatus(ctx context.Context) ManagedStandaloneStatus { + reachable, reachErr := checkMCPHealth(ctx) + meta, _ := readManagedProcessMeta() + + if meta != nil && meta.PID > 0 && !processExists(meta.PID) { + _ = removeManagedProcessMeta() + meta = nil + } + + status := ManagedStandaloneStatus{ + Managed: meta != nil, + Reachable: reachable, + BaseURL: ResolveMCPServiceURL(), + HealthURL: ResolveMCPHealthURL(), + ListenAddr: ResolveMCPListenAddr(), + Path: ResolveMCPPath(), + AuthHeader: ConfiguredAuthHeader(), + } + + if meta != nil { + status.PID = meta.PID + status.LogPath = meta.LogPath + status.StartedAt = meta.StartedAt + } + + switch { + case meta != nil && reachable: + status.State = "running" + status.Message = "MCP 独立服务运行中" + case meta != nil && processExists(meta.PID): + status.State = "starting" + status.Managed = true + status.Starting = true + status.Message = "MCP 独立服务启动中" + case reachable: + status.State = "external" + status.Managed = false + status.Message = "检测到 MCP 服务已运行,但不是由页面启动的托管进程" + case meta != nil: + status.State = "stopped" + status.Managed = false + status.Message = "上次托管的 MCP 进程已退出" + default: + status.State = "stopped" + status.Message = "MCP 独立服务未启动" + } + + if !reachable && reachErr != nil && status.State != "stopped" { + status.LastError = reachErr.Error() + } + + return status +} + +func StartManagedStandalone(ctx context.Context) (ManagedStandaloneStatus, error) { + current := GetManagedStandaloneStatus(ctx) + if current.Reachable { + return current, nil + } + + meta, _ := readManagedProcessMeta() + if meta != nil && meta.PID > 0 && processExists(meta.PID) { + return waitForManagedProcess(ctx, meta) + } + + commandPath, commandArgs, workDir, configPath, err := resolveManagedStartCommand() + if err != nil { + return GetManagedStandaloneStatus(context.Background()), err + } + + runtimeDir, err := ensureMCPRuntimeDir() + if err != nil { + return GetManagedStandaloneStatus(context.Background()), err + } + + logPath := filepath.Join(runtimeDir, mcpRuntimeLogName) + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return GetManagedStandaloneStatus(context.Background()), err + } + defer logFile.Close() + + cmd := exec.Command(commandPath, commandArgs...) + cmd.Dir = workDir + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.Env = append(os.Environ(), "GVA_MCP_CONFIG="+configPath) + prepareDetachedProcess(cmd) + + if err := cmd.Start(); err != nil { + return GetManagedStandaloneStatus(context.Background()), fmt.Errorf("启动 MCP 独立服务失败: %w", err) + } + + pid := cmd.Process.Pid + _ = cmd.Process.Release() + + meta = &managedProcessMeta{ + PID: pid, + StartedAt: time.Now().Format(time.RFC3339), + LogPath: logPath, + ConfigPath: configPath, + WorkDir: workDir, + Command: commandPath, + Args: append([]string{}, commandArgs...), + } + if err := writeManagedProcessMeta(meta); err != nil { + return GetManagedStandaloneStatus(context.Background()), err + } + + return waitForManagedProcess(ctx, meta) +} + +func StopManagedStandalone(ctx context.Context) (ManagedStandaloneStatus, error) { + meta, err := readManagedProcessMeta() + if err != nil { + status := GetManagedStandaloneStatus(ctx) + if status.Reachable { + return status, errors.New("当前 MCP 服务不是由页面启动的,无法自动停用") + } + return status, nil + } + + if meta.PID > 0 && processExists(meta.PID) { + if err := terminateProcess(meta.PID); err != nil { + return GetManagedStandaloneStatus(context.Background()), fmt.Errorf("停用 MCP 独立服务失败: %w", err) + } + } + + deadline := time.NewTimer(mcpStopWaitTimeout) + ticker := time.NewTicker(200 * time.Millisecond) + defer deadline.Stop() + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return GetManagedStandaloneStatus(context.Background()), ctx.Err() + case <-deadline.C: + _ = removeManagedProcessMeta() + return GetManagedStandaloneStatus(context.Background()), nil + case <-ticker.C: + if meta.PID <= 0 || !processExists(meta.PID) { + _ = removeManagedProcessMeta() + status := GetManagedStandaloneStatus(context.Background()) + if status.Reachable { + status.State = "external" + status.Message = "托管进程已停止,但检测到还有其他 MCP 服务在运行" + } + return status, nil + } + } + } +} + +func checkMCPHealth(ctx context.Context) (bool, error) { + timeoutCtx, cancel := context.WithTimeout(context.Background(), mcpHealthCheckTimeout) + defer cancel() + + if ctx != nil { + if deadline, ok := ctx.Deadline(); ok { + timeoutCtx, cancel = context.WithDeadline(context.Background(), deadline) + defer cancel() + } + } + + req, err := http.NewRequestWithContext(timeoutCtx, http.MethodGet, ResolveMCPHealthURL(), nil) + if err != nil { + return false, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return true, nil + } + + return false, fmt.Errorf("MCP 健康检查失败: %s", resp.Status) +} + +func waitForManagedProcess(ctx context.Context, meta *managedProcessMeta) (ManagedStandaloneStatus, error) { + deadline := time.NewTimer(mcpStartWaitTimeout) + ticker := time.NewTicker(300 * time.Millisecond) + defer deadline.Stop() + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return GetManagedStandaloneStatus(context.Background()), ctx.Err() + case <-deadline.C: + return GetManagedStandaloneStatus(context.Background()), fmt.Errorf("等待 MCP 独立服务启动超时,请查看日志: %s", meta.LogPath) + case <-ticker.C: + current := GetManagedStandaloneStatus(context.Background()) + if current.Reachable { + return current, nil + } + if meta.PID > 0 && !processExists(meta.PID) { + return current, fmt.Errorf("MCP 独立进程已退出,请查看日志: %s", meta.LogPath) + } + } + } +} + +func resolveManagedStartCommand() (string, []string, string, string, error) { + serverRoot := resolveMCPServerRoot() + if serverRoot == "" { + return "", nil, "", "", errors.New("未找到 server 根目录,无法启动 MCP 独立服务") + } + + configPath, err := resolveMCPConfigPath(serverRoot) + if err != nil { + return "", nil, "", "", err + } + + if explicit := strings.TrimSpace(os.Getenv("GVA_MCP_BIN")); explicit != "" { + if !fileExists(explicit) { + return "", nil, "", "", fmt.Errorf("GVA_MCP_BIN 指向的文件不存在: %s", explicit) + } + return explicit, []string{"-config", configPath}, filepath.Dir(explicit), configPath, nil + } + + binaryPath, err := ensureManagedBinary(serverRoot) + if err != nil { + return "", nil, "", "", err + } + + return binaryPath, []string{"-config", configPath}, serverRoot, configPath, nil +} + +func ensureManagedBinary(serverRoot string) (string, error) { + runtimeDir, err := ensureMCPRuntimeDir() + if err != nil { + return "", err + } + + binaryPath := filepath.Join(runtimeDir, managedBinaryName()) + sourceDir := filepath.Join(serverRoot, "cmd", "mcp") + + goBin, lookErr := exec.LookPath("go") + if lookErr == nil && isDir(sourceDir) { + buildCtx, cancel := context.WithTimeout(context.Background(), mcpBuildTimeout) + defer cancel() + + cmd := exec.CommandContext(buildCtx, goBin, "build", "-o", binaryPath, "./cmd/mcp") + cmd.Dir = serverRoot + output, err := cmd.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message != "" { + return "", fmt.Errorf("构建 MCP 独立服务失败: %w, 输出: %s", err, message) + } + return "", fmt.Errorf("构建 MCP 独立服务失败: %w", err) + } + return binaryPath, nil + } + + if fileExists(binaryPath) { + return binaryPath, nil + } + + return "", errors.New("未检测到可用的 Go 环境,且本地没有可复用的 MCP 独立二进制") +} + +func resolveMCPServerRoot() string { + root := strings.TrimSpace(global.GVA_CONFIG.AutoCode.Root) + serverDir := strings.TrimSpace(global.GVA_CONFIG.AutoCode.Server) + if serverDir == "" { + serverDir = "server" + } + + candidates := []string{} + if root != "" { + candidates = append(candidates, filepath.Join(root, filepath.FromSlash(serverDir))) + candidates = append(candidates, root) + } + + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, cwd) + candidates = append(candidates, filepath.Join(cwd, "server")) + } + + for _, candidate := range candidates { + if isDir(filepath.Join(candidate, "cmd", "mcp")) { + return candidate + } + } + + if len(candidates) > 0 { + return candidates[0] + } + + return "" +} + +func resolveMCPConfigPath(serverRoot string) (string, error) { + candidates := []string{ + filepath.Join(serverRoot, "cmd", "mcp", "config.yaml"), + filepath.Join(serverRoot, "config.yaml"), + } + + for _, candidate := range candidates { + if fileExists(candidate) { + return candidate, nil + } + } + + return "", errors.New("未找到 MCP 配置文件,请确认 cmd/mcp/config.yaml 或 server/config.yaml 存在") +} + +func ensureMCPRuntimeDir() (string, error) { + runtimeDir := filepath.Join(resolveMCPProjectRoot(), mcpRuntimeDirName, mcpRuntimeSubDir) + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + return "", err + } + return runtimeDir, nil +} + +func resolveMCPProjectRoot() string { + root := strings.TrimSpace(global.GVA_CONFIG.AutoCode.Root) + if root != "" { + return root + } + + serverRoot := resolveMCPServerRoot() + if serverRoot == "" { + return "." + } + + if fileExists(filepath.Join(serverRoot, "go.mod")) { + return filepath.Dir(serverRoot) + } + + return serverRoot +} + +func readManagedProcessMeta() (*managedProcessMeta, error) { + data, err := os.ReadFile(managedProcessMetaPath()) + if err != nil { + return nil, err + } + + var meta managedProcessMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +func writeManagedProcessMeta(meta *managedProcessMeta) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(managedProcessMetaPath(), data, 0o644) +} + +func removeManagedProcessMeta() error { + err := os.Remove(managedProcessMetaPath()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func managedProcessMetaPath() string { + runtimeDir, err := ensureMCPRuntimeDir() + if err != nil { + return filepath.Join(resolveMCPProjectRoot(), mcpRuntimeDirName, mcpRuntimeSubDir, mcpRuntimeMetaName) + } + return filepath.Join(runtimeDir, mcpRuntimeMetaName) +} + +func managedBinaryName() string { + if runtime.GOOS == "windows" { + return "gva-mcp-standalone.exe" + } + return "gva-mcp-standalone" +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/server/model/system/request/sys_ai_workflow_session.go b/server/model/system/request/sys_ai_workflow_session.go new file mode 100644 index 0000000000..6f0c5ed809 --- /dev/null +++ b/server/model/system/request/sys_ai_workflow_session.go @@ -0,0 +1,40 @@ +package request + +import ( + common "github.com/flipped-aurora/gin-vue-admin/server/model/common" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" + system "github.com/flipped-aurora/gin-vue-admin/server/model/system" +) + +type SysAIWorkflowSessionUpsert struct { + ID uint `json:"id"` + Tab string `json:"tab"` + Title string `json:"title"` + Summary string `json:"summary"` + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + CurrentNodeID string `json:"currentNodeId"` + Settings common.JSONMap `json:"settings"` + FormData common.JSONMap `json:"formData"` + ResultData common.JSONMap `json:"resultData"` + Messages []system.AIWorkflowMessage `json:"messages"` +} + +type SysAIWorkflowSessionSearch struct { + commonReq.PageInfo + Tab string `json:"tab" form:"tab"` +} + +type SysAIWorkflowMarkdownDump struct { + ID uint `json:"id"` + Tab string `json:"tab"` + Title string `json:"title"` + Summary string `json:"summary"` + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + CurrentNodeID string `json:"currentNodeId"` + Settings common.JSONMap `json:"settings"` + FormData common.JSONMap `json:"formData"` + ResultData common.JSONMap `json:"resultData"` + Messages []system.AIWorkflowMessage `json:"messages"` +} diff --git a/server/model/system/response/sys_ai_workflow_session.go b/server/model/system/response/sys_ai_workflow_session.go new file mode 100644 index 0000000000..09eba73d38 --- /dev/null +++ b/server/model/system/response/sys_ai_workflow_session.go @@ -0,0 +1,21 @@ +package response + +import "time" + +type SysAIWorkflowSessionListItem struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + Tab string `json:"tab"` + Title string `json:"title"` + Summary string `json:"summary"` + ConversationID string `json:"conversationId"` + CurrentNodeID string `json:"currentNodeId"` +} + +type AIWorkflowMarkdownDumpResult struct { + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + RelativePath string `json:"relativePath"` + Directory string `json:"directory"` +} diff --git a/server/model/system/sys_ai_workflow_session.go b/server/model/system/sys_ai_workflow_session.go new file mode 100644 index 0000000000..6b7236abdf --- /dev/null +++ b/server/model/system/sys_ai_workflow_session.go @@ -0,0 +1,35 @@ +package system + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + common "github.com/flipped-aurora/gin-vue-admin/server/model/common" +) + +type AIWorkflowMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + Snapshot common.JSONMap `json:"snapshot"` + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + CreatedAt string `json:"createdAt"` +} + +type SysAIWorkflowSession struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"column:user_id;index;comment:用户ID"` + Tab string `json:"tab" gorm:"column:tab;size:32;index;comment:会话类型"` + Title string `json:"title" gorm:"column:title;size:255;comment:会话标题"` + Summary string `json:"summary" gorm:"column:summary;type:text;comment:摘要"` + ConversationID string `json:"conversationId" gorm:"column:conversation_id;size:255;comment:Dify会话ID"` + MessageID string `json:"messageId" gorm:"column:message_id;size:255;comment:Dify消息ID"` + CurrentNodeID string `json:"currentNodeId" gorm:"column:current_node_id;size:64;comment:当前选中节点ID"` + Settings common.JSONMap `json:"settings" gorm:"column:settings;type:longtext;comment:页面设置"` + FormData common.JSONMap `json:"formData" gorm:"column:form_data;type:longtext;comment:表单数据"` + ResultData common.JSONMap `json:"resultData" gorm:"column:result_data;type:longtext;comment:当前展示结果"` + Messages []AIWorkflowMessage `json:"messages" gorm:"column:messages;serializer:json;type:longtext;comment:会话消息"` +} + +func (s *SysAIWorkflowSession) TableName() string { + return "sys_ai_workflow_sessions" +} diff --git a/server/router/example/enter.go b/server/router/example/enter.go index bf17697023..17cd3d5c77 100644 --- a/server/router/example/enter.go +++ b/server/router/example/enter.go @@ -6,12 +6,14 @@ import ( type RouterGroup struct { CustomerRouter - FileUploadAndDownloadRouter + AttachmentCategoryRouter + FileUploadAndDownloadRouter } var ( - exaCustomerApi = api.ApiGroupApp.ExampleApiGroup.CustomerApi - exaFileUploadAndDownloadApi = api.ApiGroupApp.ExampleApiGroup.FileUploadAndDownloadApi + exaCustomerApi = api.ApiGroupApp.ExampleApiGroup.CustomerApi + attachmentCategoryApi = api.ApiGroupApp.ExampleApiGroup.AttachmentCategoryApi + exaFileUploadAndDownloadApi = api.ApiGroupApp.ExampleApiGroup.FileUploadAndDownloadApi ) diff --git a/server/router/system/enter.go b/server/router/system/enter.go index d379c2abd7..7e5d23c337 100644 --- a/server/router/system/enter.go +++ b/server/router/system/enter.go @@ -27,26 +27,27 @@ type RouterGroup struct { } var ( - dbApi = api.ApiGroupApp.SystemApiGroup.DBApi - jwtApi = api.ApiGroupApp.SystemApiGroup.JwtApi - baseApi = api.ApiGroupApp.SystemApiGroup.BaseApi - casbinApi = api.ApiGroupApp.SystemApiGroup.CasbinApi - systemApi = api.ApiGroupApp.SystemApiGroup.SystemApi - sysParamsApi = api.ApiGroupApp.SystemApiGroup.SysParamsApi - autoCodeApi = api.ApiGroupApp.SystemApiGroup.AutoCodeApi - authorityApi = api.ApiGroupApp.SystemApiGroup.AuthorityApi - apiRouterApi = api.ApiGroupApp.SystemApiGroup.SystemApiApi - dictionaryApi = api.ApiGroupApp.SystemApiGroup.DictionaryApi - authorityBtnApi = api.ApiGroupApp.SystemApiGroup.AuthorityBtnApi - authorityMenuApi = api.ApiGroupApp.SystemApiGroup.AuthorityMenuApi - autoCodePluginApi = api.ApiGroupApp.SystemApiGroup.AutoCodePluginApi - autocodeHistoryApi = api.ApiGroupApp.SystemApiGroup.AutoCodeHistoryApi - operationRecordApi = api.ApiGroupApp.SystemApiGroup.OperationRecordApi - autoCodePackageApi = api.ApiGroupApp.SystemApiGroup.AutoCodePackageApi - dictionaryDetailApi = api.ApiGroupApp.SystemApiGroup.DictionaryDetailApi - autoCodeTemplateApi = api.ApiGroupApp.SystemApiGroup.AutoCodeTemplateApi - exportTemplateApi = api.ApiGroupApp.SystemApiGroup.SysExportTemplateApi - sysVersionApi = api.ApiGroupApp.SystemApiGroup.SysVersionApi - sysErrorApi = api.ApiGroupApp.SystemApiGroup.SysErrorApi - skillsApi = api.ApiGroupApp.SystemApiGroup.SkillsApi + dbApi = api.ApiGroupApp.SystemApiGroup.DBApi + jwtApi = api.ApiGroupApp.SystemApiGroup.JwtApi + baseApi = api.ApiGroupApp.SystemApiGroup.BaseApi + casbinApi = api.ApiGroupApp.SystemApiGroup.CasbinApi + systemApi = api.ApiGroupApp.SystemApiGroup.SystemApi + sysParamsApi = api.ApiGroupApp.SystemApiGroup.SysParamsApi + autoCodeApi = api.ApiGroupApp.SystemApiGroup.AutoCodeApi + authorityApi = api.ApiGroupApp.SystemApiGroup.AuthorityApi + apiRouterApi = api.ApiGroupApp.SystemApiGroup.SystemApiApi + dictionaryApi = api.ApiGroupApp.SystemApiGroup.DictionaryApi + authorityBtnApi = api.ApiGroupApp.SystemApiGroup.AuthorityBtnApi + authorityMenuApi = api.ApiGroupApp.SystemApiGroup.AuthorityMenuApi + autoCodePluginApi = api.ApiGroupApp.SystemApiGroup.AutoCodePluginApi + autocodeHistoryApi = api.ApiGroupApp.SystemApiGroup.AutoCodeHistoryApi + operationRecordApi = api.ApiGroupApp.SystemApiGroup.OperationRecordApi + autoCodePackageApi = api.ApiGroupApp.SystemApiGroup.AutoCodePackageApi + dictionaryDetailApi = api.ApiGroupApp.SystemApiGroup.DictionaryDetailApi + autoCodeTemplateApi = api.ApiGroupApp.SystemApiGroup.AutoCodeTemplateApi + exportTemplateApi = api.ApiGroupApp.SystemApiGroup.SysExportTemplateApi + sysVersionApi = api.ApiGroupApp.SystemApiGroup.SysVersionApi + sysErrorApi = api.ApiGroupApp.SystemApiGroup.SysErrorApi + skillsApi = api.ApiGroupApp.SystemApiGroup.SkillsApi + aiWorkflowSessionApi = api.ApiGroupApp.SystemApiGroup.AIWorkflowSessionApi ) diff --git a/server/router/system/sys_auto_code.go b/server/router/system/sys_auto_code.go index 261196d472..30030c1308 100644 --- a/server/router/system/sys_auto_code.go +++ b/server/router/system/sys_auto_code.go @@ -1,8 +1,6 @@ package system -import ( - "github.com/gin-gonic/gin" -) +import "github.com/gin-gonic/gin" type AutoCodeRouter struct{} @@ -10,38 +8,48 @@ func (s *AutoCodeRouter) InitAutoCodeRouter(Router *gin.RouterGroup, RouterPubli autoCodeRouter := Router.Group("autoCode") publicAutoCodeRouter := RouterPublic.Group("autoCode") { - autoCodeRouter.GET("getDB", autoCodeApi.GetDB) // 获取数据库 - autoCodeRouter.GET("getTables", autoCodeApi.GetTables) // 获取对应数据库的表 - autoCodeRouter.GET("getColumn", autoCodeApi.GetColumn) // 获取指定表所有字段信息 + autoCodeRouter.GET("getDB", autoCodeApi.GetDB) + autoCodeRouter.GET("getTables", autoCodeApi.GetTables) + autoCodeRouter.GET("getColumn", autoCodeApi.GetColumn) } { - autoCodeRouter.POST("preview", autoCodeTemplateApi.Preview) // 获取自动创建代码预览 - autoCodeRouter.POST("createTemp", autoCodeTemplateApi.Create) // 创建自动化代码 - autoCodeRouter.POST("addFunc", autoCodeTemplateApi.AddFunc) // 为代码插入方法 + autoCodeRouter.POST("preview", autoCodeTemplateApi.Preview) + autoCodeRouter.POST("createTemp", autoCodeTemplateApi.Create) + autoCodeRouter.POST("addFunc", autoCodeTemplateApi.AddFunc) } { - autoCodeRouter.POST("mcp", autoCodeTemplateApi.MCP) // 自动创建Mcp Tool模板 - autoCodeRouter.POST("mcpList", autoCodeTemplateApi.MCPList) // 获取MCP ToolList - autoCodeRouter.POST("mcpTest", autoCodeTemplateApi.MCPTest) // MCP 工具测试 + autoCodeRouter.POST("mcp", autoCodeTemplateApi.MCP) + autoCodeRouter.POST("mcpStatus", autoCodeTemplateApi.MCPStatus) + autoCodeRouter.POST("mcpStart", autoCodeTemplateApi.MCPStart) + autoCodeRouter.POST("mcpStop", autoCodeTemplateApi.MCPStop) + autoCodeRouter.POST("mcpList", autoCodeTemplateApi.MCPList) + autoCodeRouter.POST("mcpRoutes", autoCodeTemplateApi.MCPRoutes) + autoCodeRouter.POST("mcpTest", autoCodeTemplateApi.MCPTest) } { - autoCodeRouter.POST("getPackage", autoCodePackageApi.All) // 获取package包 - autoCodeRouter.POST("delPackage", autoCodePackageApi.Delete) // 删除package包 - autoCodeRouter.POST("createPackage", autoCodePackageApi.Create) // 创建package包 + autoCodeRouter.POST("getPackage", autoCodePackageApi.All) + autoCodeRouter.POST("delPackage", autoCodePackageApi.Delete) + autoCodeRouter.POST("createPackage", autoCodePackageApi.Create) + autoCodeRouter.POST("saveAIWorkflowSession", aiWorkflowSessionApi.Save) + autoCodeRouter.POST("getAIWorkflowSessionList", aiWorkflowSessionApi.GetList) + autoCodeRouter.POST("getAIWorkflowSessionDetail", aiWorkflowSessionApi.GetDetail) + autoCodeRouter.POST("deleteAIWorkflowSession", aiWorkflowSessionApi.Delete) + autoCodeRouter.POST("dumpAIWorkflowMarkdown", aiWorkflowSessionApi.DumpMarkdown) } { - autoCodeRouter.GET("getTemplates", autoCodePackageApi.Templates) // 创建package包 + autoCodeRouter.GET("getTemplates", autoCodePackageApi.Templates) } { - autoCodeRouter.POST("pubPlug", autoCodePluginApi.Packaged) // 打包插件 - autoCodeRouter.POST("installPlugin", autoCodePluginApi.Install) // 自动安装插件 - autoCodeRouter.POST("removePlugin", autoCodePluginApi.Remove) // 自动删除插件 - autoCodeRouter.GET("getPluginList", autoCodePluginApi.GetPluginList) // 获取插件列表 + autoCodeRouter.POST("pubPlug", autoCodePluginApi.Packaged) + autoCodeRouter.POST("installPlugin", autoCodePluginApi.Install) + autoCodeRouter.POST("removePlugin", autoCodePluginApi.Remove) + autoCodeRouter.GET("getPluginList", autoCodePluginApi.GetPluginList) } { publicAutoCodeRouter.POST("llmAuto", autoCodeApi.LLMAuto) - publicAutoCodeRouter.POST("initMenu", autoCodePluginApi.InitMenu) // 同步插件菜单 - publicAutoCodeRouter.POST("initAPI", autoCodePluginApi.InitAPI) // 同步插件API - publicAutoCodeRouter.POST("initDictionary", autoCodePluginApi.InitDictionary) // 同步插件字典 + publicAutoCodeRouter.POST("llmAutoSSE", autoCodeApi.LLMAutoSSE) + publicAutoCodeRouter.POST("initMenu", autoCodePluginApi.InitMenu) + publicAutoCodeRouter.POST("initAPI", autoCodePluginApi.InitAPI) + publicAutoCodeRouter.POST("initDictionary", autoCodePluginApi.InitDictionary) } } diff --git a/server/service/example/enter.go b/server/service/example/enter.go index f7198da452..13f25572ce 100644 --- a/server/service/example/enter.go +++ b/server/service/example/enter.go @@ -2,6 +2,7 @@ package example type ServiceGroup struct { CustomerService - FileUploadAndDownloadService + AttachmentCategoryService + FileUploadAndDownloadService } diff --git a/server/service/system/ai_workflow_markdown.go b/server/service/system/ai_workflow_markdown.go new file mode 100644 index 0000000000..96214ba37f --- /dev/null +++ b/server/service/system/ai_workflow_markdown.go @@ -0,0 +1,468 @@ +package system + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + common "github.com/flipped-aurora/gin-vue-admin/server/model/common" + system "github.com/flipped-aurora/gin-vue-admin/server/model/system" + systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" + systemResp "github.com/flipped-aurora/gin-vue-admin/server/model/system/response" +) + +const ( + aiWorkflowMarkdownRootDir = "ai-workflow-docs" + aiWorkflowAnalysisDir = "analysis" + aiWorkflowPromptDir = "prompt-workflow" +) + +func (s *aiWorkflowSession) DumpMarkdown(ctx context.Context, userID uint, info systemReq.SysAIWorkflowMarkdownDump) (result systemResp.AIWorkflowMarkdownDumpResult, err error) { + if userID == 0 { + return result, fmt.Errorf("用户未登录") + } + if info.Tab != "analysis" && info.Tab != "workflow" { + return result, fmt.Errorf("不支持的会话类型") + } + + root := strings.TrimSpace(global.GVA_CONFIG.AutoCode.Root) + if root == "" { + return result, fmt.Errorf("autocode.root 未配置") + } + + session := system.SysAIWorkflowSession{ + GVA_MODEL: global.GVA_MODEL{ID: info.ID}, + UserID: userID, + Tab: info.Tab, + Title: strings.TrimSpace(info.Title), + Summary: strings.TrimSpace(info.Summary), + ConversationID: strings.TrimSpace(info.ConversationID), + MessageID: strings.TrimSpace(info.MessageID), + CurrentNodeID: strings.TrimSpace(info.CurrentNodeID), + Settings: cloneJSONMap(info.Settings), + FormData: cloneJSONMap(info.FormData), + ResultData: cloneJSONMap(info.ResultData), + Messages: sanitizeMessages(info.Messages), + } + if strings.TrimSpace(session.Title) == "" { + session.Title = s.titleFromMessages(session.Messages) + } + if strings.TrimSpace(session.Title) == "" { + session.Title = s.titleFromForm(systemReq.SysAIWorkflowSessionUpsert{ + Tab: info.Tab, + FormData: info.FormData, + }) + } + if strings.TrimSpace(session.Summary) == "" { + session.Summary = s.summaryFromResult(info.ResultData) + } + + markdown := buildAIWorkflowMarkdown(session) + if strings.TrimSpace(markdown) == "" { + return result, fmt.Errorf("没有可落盘的内容") + } + + targetDir := filepath.Join(root, aiWorkflowMarkdownRootDir, workflowMarkdownSubDir(info.Tab)) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return result, err + } + + fileName := buildWorkflowMarkdownFileName(info.Tab, session.Title, session.ID) + filePath := filepath.Join(targetDir, fileName) + if err := os.WriteFile(filePath, []byte(markdown), 0o644); err != nil { + return result, err + } + + relativePath, relErr := filepath.Rel(root, filePath) + if relErr != nil { + relativePath = filepath.Join(aiWorkflowMarkdownRootDir, workflowMarkdownSubDir(info.Tab), fileName) + } + + return systemResp.AIWorkflowMarkdownDumpResult{ + FileName: fileName, + FilePath: filePath, + RelativePath: relativePath, + Directory: targetDir, + }, nil +} + +func workflowMarkdownSubDir(tab string) string { + if tab == "analysis" { + return aiWorkflowAnalysisDir + } + return aiWorkflowPromptDir +} + +func buildWorkflowMarkdownFileName(tab, title string, sessionID uint) string { + prefix := "prompt-workflow" + if tab == "analysis" { + prefix = "analysis" + } + + stem := sanitizeWorkflowFileStem(title) + if stem == "" { + if sessionID > 0 { + stem = fmt.Sprintf("session-%d", sessionID) + } else { + stem = "session" + } + } + + return fmt.Sprintf( + "%s-%s-%s.md", + time.Now().Format("20060102-150405"), + prefix, + stem, + ) +} + +func sanitizeWorkflowFileStem(title string) string { + var builder strings.Builder + lastDash := false + + for _, r := range strings.TrimSpace(title) { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + builder.WriteRune(unicode.ToLower(r)) + lastDash = false + case r == '-' || r == '_' || unicode.IsSpace(r): + if !lastDash && builder.Len() > 0 { + builder.WriteByte('-') + lastDash = true + } + } + } + + return strings.Trim(builder.String(), "-") +} + +func buildAIWorkflowMarkdown(session system.SysAIWorkflowSession) string { + if session.Tab == "analysis" { + return buildAnalysisMarkdown(session) + } + return buildPromptWorkflowMarkdown(session) +} + +func buildAnalysisMarkdown(session system.SysAIWorkflowSession) string { + var builder strings.Builder + + writeMarkdownTitle(&builder, "# AI 需求分析") + writeMarkdownMeta(&builder, + "标题", firstNonEmpty(session.Title, "未命名需求"), + "摘要", firstNonEmpty(session.Summary, getString(session.ResultData, "summary")), + "会话类型", "analysis", + "会话ID", session.ConversationID, + "消息ID", session.MessageID, + "节点ID", session.CurrentNodeID, + ) + + writeMarkdownSection(&builder, "## 原始输入") + writeMarkdownKeyValue(&builder, + "原始需求", getString(session.FormData, "requirement"), + "目标形态", getString(session.FormData, "packageType"), + "业务场景", getString(session.FormData, "businessScene"), + "额外约束", getString(session.FormData, "extraConstraints"), + "是否有客户端页面", formatBool(getBool(session.FormData, "hasClientPage")), + "客户端页面说明", getString(session.FormData, "clientPageDescription"), + "客户端额外约束", getString(session.FormData, "clientPageConstraints"), + ) + + writeMarkdownSection(&builder, "## 整理后的需求") + writeMarkdownKeyValue(&builder, + "总结", getString(session.ResultData, "summary"), + "推荐形态", getString(session.ResultData, "recommendedPackageType"), + ) + + writeStringListSection(&builder, "### 待确认信息", getStringSlice(session.ResultData, "missingInfo")) + writeStringListSection(&builder, "### 建议事项", getStringSlice(session.ResultData, "suggestions")) + + modules := getMapSlice(session.ResultData, "modules") + if len(modules) > 0 { + writeMarkdownSection(&builder, "### 模块拆解") + for index, module := range modules { + builder.WriteString(fmt.Sprintf("#### %d. %s\n\n", index+1, firstNonEmpty(getString(module, "label"), getString(module, "name"), fmt.Sprintf("模块 %d", index+1)))) + writeMarkdownKeyValue(&builder, + "模块标识", getString(module, "name"), + "模块说明", getString(module, "description"), + ) + + fields := getMapSlice(module, "fields") + if len(fields) > 0 { + builder.WriteString("| 字段 | 标识 | 类型 | 必填 | 说明 |\n") + builder.WriteString("| --- | --- | --- | --- | --- |\n") + for _, field := range fields { + builder.WriteString(fmt.Sprintf( + "| %s | %s | %s | %s | %s |\n", + markdownCell(firstNonEmpty(getString(field, "label"), getString(field, "name"), "-")), + markdownCell(firstNonEmpty(getString(field, "name"), "-")), + markdownCell(firstNonEmpty(getString(field, "type"), "string")), + markdownCell(formatBool(getBool(field, "required"))), + markdownCell(firstNonEmpty(getString(field, "description"), "-")), + )) + } + builder.WriteString("\n") + } + } + } + + clientPages := getMapSlice(session.ResultData, "clientPages") + if len(clientPages) > 0 { + writeMarkdownSection(&builder, "### 客户端页面") + for index, page := range clientPages { + builder.WriteString(fmt.Sprintf("#### %d. %s\n\n", index+1, firstNonEmpty(getString(page, "label"), getString(page, "name"), fmt.Sprintf("页面 %d", index+1)))) + writeMarkdownKeyValue(&builder, + "页面标识", getString(page, "name"), + "页面类型", getString(page, "pageType"), + "页面说明", getString(page, "description"), + ) + writeStringListSection(&builder, "目标模块", getStringSlice(page, "targetModules")) + writeStringListSection(&builder, "交互行为", getStringSlice(page, "interactions")) + writeStringListSection(&builder, "字段关系", getStringSlice(page, "relations")) + } + } + + writeMarkdownAppendix(&builder, session.ResultData) + return strings.TrimSpace(builder.String()) + "\n" +} + +func buildPromptWorkflowMarkdown(session system.SysAIWorkflowSession) string { + var builder strings.Builder + + writeMarkdownTitle(&builder, "# Prompt Workflow") + writeMarkdownMeta(&builder, + "标题", firstNonEmpty(session.Title, "未命名工作流"), + "摘要", firstNonEmpty(session.Summary, getString(session.ResultData, "summary")), + "会话类型", "workflow", + "会话ID", session.ConversationID, + "消息ID", session.MessageID, + "节点ID", session.CurrentNodeID, + ) + + writeMarkdownSection(&builder, "## 输入上下文") + writeMarkdownKeyValue(&builder, + "来源需求", getString(session.FormData, "source"), + "工作流类型", getString(session.FormData, "flowType"), + "额外约束", getString(session.FormData, "extraConstraints"), + ) + + writeMarkdownSection(&builder, "## Prompt 工作流") + writeMarkdownKeyValue(&builder, "总结", getString(session.ResultData, "summary")) + + steps := getMapSlice(session.ResultData, "steps") + if len(steps) == 0 { + rawText := getString(session.ResultData, "rawText") + if strings.TrimSpace(rawText) != "" { + builder.WriteString(rawText) + builder.WriteString("\n\n") + } + } else { + for index, step := range steps { + builder.WriteString(fmt.Sprintf("### %d. %s\n\n", index+1, firstNonEmpty(getString(step, "title"), fmt.Sprintf("步骤 %d", index+1)))) + writeMarkdownKeyValue(&builder, + "目标", getString(step, "goal"), + "建议工具", getString(step, "suggestedTool"), + "可自动执行", formatBool(getBool(step, "autoExecutable")), + "预期输出", getString(step, "expectedOutput"), + ) + prompt := getString(step, "prompt") + if strings.TrimSpace(prompt) != "" { + builder.WriteString("#### Prompt\n\n") + builder.WriteString("```text\n") + builder.WriteString(prompt) + builder.WriteString("\n```\n\n") + } + } + } + + writeMarkdownAppendix(&builder, session.ResultData) + return strings.TrimSpace(builder.String()) + "\n" +} + +func writeMarkdownTitle(builder *strings.Builder, title string) { + builder.WriteString(title) + builder.WriteString("\n\n") +} + +func writeMarkdownMeta(builder *strings.Builder, pairs ...string) { + for i := 0; i+1 < len(pairs); i += 2 { + if strings.TrimSpace(pairs[i+1]) == "" { + continue + } + builder.WriteString(fmt.Sprintf("- **%s**: %s\n", pairs[i], pairs[i+1])) + } + builder.WriteString("\n") +} + +func writeMarkdownSection(builder *strings.Builder, title string) { + builder.WriteString(title) + builder.WriteString("\n\n") +} + +func writeMarkdownKeyValue(builder *strings.Builder, pairs ...string) { + hasValue := false + for i := 0; i+1 < len(pairs); i += 2 { + if strings.TrimSpace(pairs[i+1]) == "" { + continue + } + hasValue = true + builder.WriteString(fmt.Sprintf("- **%s**: %s\n", pairs[i], pairs[i+1])) + } + if hasValue { + builder.WriteString("\n") + } +} + +func writeStringListSection(builder *strings.Builder, title string, list []string) { + if len(list) == 0 { + return + } + builder.WriteString(title) + builder.WriteString("\n\n") + for _, item := range list { + if strings.TrimSpace(item) == "" { + continue + } + builder.WriteString("- ") + builder.WriteString(item) + builder.WriteString("\n") + } + builder.WriteString("\n") +} + +func writeMarkdownAppendix(builder *strings.Builder, resultData common.JSONMap) { + rawText := getString(resultData, "rawText") + rawJSON := getString(resultData, "rawJson") + + if strings.TrimSpace(rawText) != "" { + builder.WriteString("## 原始文本\n\n") + builder.WriteString("```text\n") + builder.WriteString(rawText) + builder.WriteString("\n```\n\n") + } + + if strings.TrimSpace(rawJSON) != "" { + builder.WriteString("## 原始结构化数据\n\n") + builder.WriteString("```json\n") + builder.WriteString(rawJSON) + builder.WriteString("\n```\n") + } +} + +func getString(data map[string]interface{}, key string) string { + if len(data) == 0 { + return "" + } + value, ok := data[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case fmt.Stringer: + return strings.TrimSpace(typed.String()) + default: + return strings.TrimSpace(fmt.Sprintf("%v", value)) + } +} + +func getBool(data map[string]interface{}, key string) bool { + if len(data) == 0 { + return false + } + value, ok := data[key] + if !ok || value == nil { + return false + } + switch typed := value.(type) { + case bool: + return typed + case string: + return strings.EqualFold(strings.TrimSpace(typed), "true") + default: + return false + } +} + +func getStringSlice(data map[string]interface{}, key string) []string { + value, ok := data[key] + if !ok || value == nil { + return nil + } + return toStringSlice(value) +} + +func getMapSlice(data map[string]interface{}, key string) []map[string]interface{} { + value, ok := data[key] + if !ok || value == nil { + return nil + } + return toMapSlice(value) +} + +func toStringSlice(value interface{}) []string { + switch typed := value.(type) { + case []string: + result := make([]string, 0, len(typed)) + for _, item := range typed { + if strings.TrimSpace(item) != "" { + result = append(result, strings.TrimSpace(item)) + } + } + return result + case []interface{}: + result := make([]string, 0, len(typed)) + for _, item := range typed { + text := strings.TrimSpace(fmt.Sprintf("%v", item)) + if text != "" { + result = append(result, text) + } + } + return result + default: + text := strings.TrimSpace(fmt.Sprintf("%v", value)) + if text == "" || text == "" { + return nil + } + return []string{text} + } +} + +func toMapSlice(value interface{}) []map[string]interface{} { + switch typed := value.(type) { + case []map[string]interface{}: + return typed + case []interface{}: + result := make([]map[string]interface{}, 0, len(typed)) + for _, item := range typed { + if row, ok := item.(map[string]interface{}); ok { + result = append(result, row) + } + } + return result + default: + return nil + } +} + +func formatBool(value bool) string { + if value { + return "是" + } + return "否" +} + +func markdownCell(value string) string { + text := strings.TrimSpace(value) + if text == "" { + return "-" + } + text = strings.ReplaceAll(text, "\n", "
") + text = strings.ReplaceAll(text, "|", "\\|") + return text +} diff --git a/server/service/system/ai_workflow_session.go b/server/service/system/ai_workflow_session.go new file mode 100644 index 0000000000..4c6896e31b --- /dev/null +++ b/server/service/system/ai_workflow_session.go @@ -0,0 +1,184 @@ +package system + +import ( + "context" + "errors" + "strings" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + system "github.com/flipped-aurora/gin-vue-admin/server/model/system" + systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" + systemResp "github.com/flipped-aurora/gin-vue-admin/server/model/system/response" + "gorm.io/gorm" +) + +type aiWorkflowSession struct{} + +var AIWorkflowSessionServiceApp = new(aiWorkflowSession) + +func (s *aiWorkflowSession) Save(ctx context.Context, userID uint, info systemReq.SysAIWorkflowSessionUpsert) (session system.SysAIWorkflowSession, err error) { + if userID == 0 { + return session, errors.New("用户未登录") + } + if info.Tab != "analysis" && info.Tab != "workflow" { + return session, errors.New("不支持的会话类型") + } + + db := global.GVA_DB.WithContext(ctx) + if info.ID != 0 { + err = db.Where("id = ? AND user_id = ?", info.ID, userID).First(&session).Error + if err != nil { + return session, err + } + } + + session.UserID = userID + session.Tab = info.Tab + session.Title = truncateText(firstNonEmpty(strings.TrimSpace(info.Title), s.titleFromMessages(info.Messages), s.titleFromForm(info)), 255) + session.Summary = strings.TrimSpace(firstNonEmpty(info.Summary, s.summaryFromResult(info.ResultData))) + session.ConversationID = strings.TrimSpace(info.ConversationID) + session.MessageID = strings.TrimSpace(info.MessageID) + session.Settings = cloneJSONMap(info.Settings) + session.FormData = cloneJSONMap(info.FormData) + session.ResultData = cloneJSONMap(info.ResultData) + session.Messages = sanitizeMessages(info.Messages) + session.CurrentNodeID = strings.TrimSpace(info.CurrentNodeID) + if session.CurrentNodeID == "" { + session.CurrentNodeID = lastAssistantMessageID(session.Messages) + } + + if session.ID == 0 { + err = db.Create(&session).Error + return session, err + } + err = db.Save(&session).Error + return session, err +} + +func (s *aiWorkflowSession) GetList(ctx context.Context, userID uint, info systemReq.SysAIWorkflowSessionSearch) (list []systemResp.SysAIWorkflowSessionListItem, total int64, err error) { + db := global.GVA_DB.WithContext(ctx).Model(&system.SysAIWorkflowSession{}).Where("user_id = ?", userID) + if tab := strings.TrimSpace(info.Tab); tab != "" { + db = db.Where("tab = ?", tab) + } + if keyword := strings.TrimSpace(info.Keyword); keyword != "" { + like := "%" + keyword + "%" + db = db.Where("title LIKE ? OR summary LIKE ?", like, like) + } + + err = db.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = db.Select("id", "created_at", "updated_at", "tab", "title", "summary", "conversation_id", "current_node_id"). + Scopes(info.Paginate()). + Order("updated_at desc"). + Find(&list).Error + return list, total, err +} + +func (s *aiWorkflowSession) GetDetail(ctx context.Context, userID uint, id uint) (session system.SysAIWorkflowSession, err error) { + err = global.GVA_DB.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&session).Error + return session, err +} + +func (s *aiWorkflowSession) Delete(ctx context.Context, userID uint, id uint) error { + result := global.GVA_DB.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&system.SysAIWorkflowSession{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *aiWorkflowSession) titleFromMessages(messages []system.AIWorkflowMessage) string { + for _, item := range messages { + if item.Role == "user" && strings.TrimSpace(item.Content) != "" { + return strings.TrimSpace(item.Content) + } + } + return "" +} + +func (s *aiWorkflowSession) titleFromForm(info systemReq.SysAIWorkflowSessionUpsert) string { + if info.Tab == "analysis" { + if requirement, ok := info.FormData["requirement"].(string); ok { + return strings.TrimSpace(requirement) + } + } + if source, ok := info.FormData["source"].(string); ok { + return strings.TrimSpace(source) + } + return "" +} + +func (s *aiWorkflowSession) summaryFromResult(resultData map[string]interface{}) string { + if resultData == nil { + return "" + } + if summary, ok := resultData["summary"].(string); ok { + return strings.TrimSpace(summary) + } + return "" +} + +func sanitizeMessages(messages []system.AIWorkflowMessage) []system.AIWorkflowMessage { + if len(messages) == 0 { + return []system.AIWorkflowMessage{} + } + result := make([]system.AIWorkflowMessage, 0, len(messages)) + for _, item := range messages { + result = append(result, system.AIWorkflowMessage{ + ID: strings.TrimSpace(item.ID), + Role: strings.TrimSpace(item.Role), + Content: item.Content, + Snapshot: cloneJSONMap(item.Snapshot), + ConversationID: strings.TrimSpace(item.ConversationID), + MessageID: strings.TrimSpace(item.MessageID), + CreatedAt: strings.TrimSpace(item.CreatedAt), + }) + } + return result +} + +func cloneJSONMap(source map[string]interface{}) map[string]interface{} { + if len(source) == 0 { + return map[string]interface{}{} + } + target := make(map[string]interface{}, len(source)) + for key, value := range source { + target[key] = value + } + return target +} + +func lastAssistantMessageID(messages []system.AIWorkflowMessage) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "assistant" && strings.TrimSpace(messages[i].ID) != "" { + return messages[i].ID + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func truncateText(value string, size int) string { + if size <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(value)) + if len(runes) <= size { + return string(runes) + } + return string(runes[:size]) +} diff --git a/server/service/system/auto_code_llm.go b/server/service/system/auto_code_llm.go index 83c370f824..fdb1d54398 100644 --- a/server/service/system/auto_code_llm.go +++ b/server/service/system/auto_code_llm.go @@ -4,48 +4,122 @@ import ( "context" "errors" "fmt" + "io" + "net/http" + "strings" + "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/model/common" commonResp "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" "github.com/flipped-aurora/gin-vue-admin/server/utils/request" "github.com/goccy/go-json" - "io" - "strings" ) -// LLMAuto 调用大模型服务,返回生成结果数据 -// 入参为通用 JSONMap,需包含 mode(例如 ai/butler/eye/painter 等)以及业务 prompt/payload func (s *AutoCodeService) LLMAuto(ctx context.Context, llm common.JSONMap) (interface{}, error) { - if global.GVA_CONFIG.AutoCode.AiPath == "" { - return nil, errors.New("请先前往插件市场个人中心获取AiPath并填入config.yaml中") + path, err := buildLLMAutoPath(llm) + if err != nil { + return nil, err } - // 构建调用路径:{AiPath} 中的 {FUNC} 由 mode 替换 - mode := fmt.Sprintf("%v", llm["mode"]) // 统一转字符串,避免 nil 造成路径异常 - path := strings.ReplaceAll(global.GVA_CONFIG.AutoCode.AiPath, "{FUNC}", mode) - - res, err := request.HttpRequest( + res, err := request.HttpRequestWithContextAndTimeout( + ctx, path, - "POST", + http.MethodPost, nil, nil, llm, ) if err != nil { - return nil, fmt.Errorf("大模型生成失败: %w", err) + return nil, fmt.Errorf("调用上游大模型服务失败: %w", err) } defer res.Body.Close() - var resStruct commonResp.Response - b, err := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("读取大模型响应失败: %w", err) } - if err = json.Unmarshal(b, &resStruct); err != nil { - return nil, fmt.Errorf("解析大模型响应失败: %w", err) + + bodyPreview := previewResponseBody(body) + contentType := res.Header.Get("Content-Type") + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("上游大模型服务返回非 2xx: status=%d content-type=%s body=%s", res.StatusCode, contentType, bodyPreview) } - if resStruct.Code == 7 { // 业务约定:7 表示模型生成失败 - return nil, fmt.Errorf("大模型生成失败: %s", resStruct.Msg) + + var resStruct commonResp.Response + if err = json.Unmarshal(body, &resStruct); err != nil { + return nil, fmt.Errorf("解析大模型响应失败: status=%d content-type=%s body=%s err=%w", res.StatusCode, contentType, bodyPreview, err) + } + + if resStruct.Code != commonResp.SUCCESS { + return nil, fmt.Errorf("大模型服务返回业务错误: code=%d msg=%s body=%s", resStruct.Code, resStruct.Msg, bodyPreview) } + return resStruct.Data, nil } + +func (s *AutoCodeService) LLMAutoStream(ctx context.Context, llm common.JSONMap) (*http.Response, error) { + path, err := buildLLMAutoPath(llm) + if err != nil { + return nil, err + } + + payload := cloneLLMAutoJSONMap(llm) + responseMode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", payload["response_mode"]))) + if responseMode == "" { + payload["response_mode"] = "streaming" + } + + res, err := request.HttpRequestWithContextAndTimeout( + ctx, + path, + http.MethodPost, + map[string]string{ + "Accept": "text/event-stream", + "Accept-Encoding": "identity", // 禁止 gzip,避免 SSE 流被压缩导致缓冲卡住 + "Cache-Control": "no-cache", + }, + nil, + payload, + -1, // 不设置 client.Timeout,SSE 流的生命周期由 ctx 控制 + ) + if err != nil { + return nil, fmt.Errorf("调用上游大模型流式服务失败: %w", err) + } + return res, nil +} + +func buildLLMAutoPath(llm common.JSONMap) (string, error) { + if global.GVA_CONFIG.AutoCode.AiPath == "" { + return "", errors.New("请先前往插件市场个人中心获取 AiPath 并填写到 config.yaml 中") + } + + mode := strings.TrimSpace(fmt.Sprintf("%v", llm["mode"])) + if mode == "" { + return "", errors.New("llmAuto 缺少 mode 参数") + } + + return strings.ReplaceAll(global.GVA_CONFIG.AutoCode.AiPath, "{FUNC}", mode), nil +} + +func cloneLLMAutoJSONMap(src common.JSONMap) common.JSONMap { + dst := make(common.JSONMap, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +func previewResponseBody(body []byte) string { + text := strings.TrimSpace(string(body)) + text = strings.ReplaceAll(text, "\r", " ") + text = strings.ReplaceAll(text, "\n", " ") + text = strings.Join(strings.Fields(text), " ") + if text == "" { + return "" + } + runes := []rune(text) + if len(runes) > 300 { + return string(runes[:300]) + "..." + } + return text +} diff --git a/server/service/system/enter.go b/server/service/system/enter.go index 6d68bb7b93..536fb1abdc 100644 --- a/server/service/system/enter.go +++ b/server/service/system/enter.go @@ -19,10 +19,11 @@ type ServiceGroup struct { SysParamsService SysVersionService SkillsService - AutoCodePlugin autoCodePlugin - AutoCodePackage autoCodePackage - AutoCodeHistory autoCodeHistory - AutoCodeTemplate autoCodeTemplate + AIWorkflowSession aiWorkflowSession + AutoCodePlugin autoCodePlugin + AutoCodePackage autoCodePackage + AutoCodeHistory autoCodeHistory + AutoCodeTemplate autoCodeTemplate SysErrorService LoginLogService ApiTokenService diff --git a/server/source/system/api.go b/server/source/system/api.go index 0269473054..e1b466a02a 100644 --- a/server/source/system/api.go +++ b/server/source/system/api.go @@ -153,8 +153,16 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/removePlugin", Description: "卸载插件"}, {ApiGroup: "代码生成器", Method: "GET", Path: "/autoCode/getPluginList", Description: "获取已安装插件"}, {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/mcp", Description: "自动生成 MCP Tool 模板"}, - {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/mcpTest", Description: "MCP Tool 测试"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/mcpStatus", Description: "获取 MCP 独立服务状态"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/mcpStart", Description: "启动 MCP 独立服务"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/mcpStop", Description: "停用 MCP 独立服务"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/mcpTest", Description: "MCP Tool 管理"}, {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/mcpList", Description: "获取 MCP ToolList"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/saveAIWorkflowSession", Description: "保存AI需求工作流会话"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/getAIWorkflowSessionList", Description: "获取AI需求工作流会话列表"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/getAIWorkflowSessionDetail", Description: "获取AI需求工作流会话详情"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/deleteAIWorkflowSession", Description: "删除AI需求工作流会话"}, + {ApiGroup: "代码生成器", Method: "POST", Path: "/autoCode/dumpAIWorkflowMarkdown", Description: "AI需求工作流Markdown落盘"}, {ApiGroup: "模板配置", Method: "POST", Path: "/autoCode/createPackage", Description: "配置模板"}, {ApiGroup: "模板配置", Method: "GET", Path: "/autoCode/getTemplates", Description: "获取模板文件"}, diff --git a/server/source/system/api_ignore.go b/server/source/system/api_ignore.go index c0fab56500..e18aa7cd11 100644 --- a/server/source/system/api_ignore.go +++ b/server/source/system/api_ignore.go @@ -49,6 +49,7 @@ func (i *initApiIgnore) InitializeData(ctx context.Context) (context.Context, er {Method: "GET", Path: "/health"}, {Method: "HEAD", Path: "/uploads/file/*filepath"}, {Method: "POST", Path: "/autoCode/llmAuto"}, + {Method: "POST", Path: "/autoCode/llmAutoSSE"}, {Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"}, {Method: "POST", Path: "/base/captcha"}, diff --git a/server/source/system/casbin.go b/server/source/system/casbin.go index 3132f0e454..805d3c4df0 100644 --- a/server/source/system/casbin.go +++ b/server/source/system/casbin.go @@ -165,8 +165,16 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "888", V1: "/autoCode/getPluginList", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/autoCode/addFunc", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/autoCode/mcp", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/mcpStatus", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/mcpStart", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/mcpStop", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/autoCode/mcpTest", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/autoCode/mcpList", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/saveAIWorkflowSession", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/getAIWorkflowSessionList", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/getAIWorkflowSessionDetail", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/deleteAIWorkflowSession", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/autoCode/dumpAIWorkflowMarkdown", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/sysDictionaryDetail/findSysDictionaryDetail", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/sysDictionaryDetail/updateSysDictionaryDetail", V2: "PUT"}, @@ -334,6 +342,14 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "9528", V1: "/customer/customer", V2: "DELETE"}, {Ptype: "p", V0: "9528", V1: "/customer/customerList", V2: "GET"}, {Ptype: "p", V0: "9528", V1: "/autoCode/createTemp", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/mcpStatus", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/mcpStart", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/mcpStop", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/saveAIWorkflowSession", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/getAIWorkflowSessionList", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/getAIWorkflowSessionDetail", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/deleteAIWorkflowSession", V2: "POST"}, + {Ptype: "p", V0: "9528", V1: "/autoCode/dumpAIWorkflowMarkdown", V2: "POST"}, {Ptype: "p", V0: "9528", V1: "/user/getUserInfo", V2: "GET"}, } if err := db.Create(&entities).Error; err != nil { diff --git a/server/source/system/menu.go b/server/source/system/menu.go index 66b133a30c..7c9108b8b7 100644 --- a/server/source/system/menu.go +++ b/server/source/system/menu.go @@ -58,7 +58,7 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er {MenuLevel: 0, Hidden: false, ParentId: 0, Path: "admin", Name: "superAdmin", Component: "view/superAdmin/index.vue", Sort: 3, Meta: Meta{Title: "超级管理员", Icon: "user"}}, {MenuLevel: 0, Hidden: true, ParentId: 0, Path: "person", Name: "person", Component: "view/person/person.vue", Sort: 4, Meta: Meta{Title: "个人信息", Icon: "message"}}, {MenuLevel: 0, Hidden: false, ParentId: 0, Path: "example", Name: "example", Component: "view/example/index.vue", Sort: 7, Meta: Meta{Title: "示例文件", Icon: "management"}}, - {MenuLevel: 0, Hidden: false, ParentId: 0, Path: "systemTools", Name: "systemTools", Component: "view/systemTools/index.vue", Sort: 5, Meta: Meta{Title: "系统工具", Icon: "tools"}}, + {MenuLevel: 0, Hidden: false, ParentId: 0, Path: "systemTools", Name: "systemTools", Component: "view/systemTools/index.vue", Sort: 5, Meta: Meta{Title: "编程辅助", Icon: "tools"}}, {MenuLevel: 0, Hidden: false, ParentId: 0, Path: "https://www.gin-vue-admin.com", Name: "https://www.gin-vue-admin.com", Component: "/", Sort: 0, Meta: Meta{Title: "官方网站", Icon: "customer-gva"}}, {MenuLevel: 0, Hidden: false, ParentId: 0, Path: "state", Name: "state", Component: "view/system/state.vue", Sort: 8, Meta: Meta{Title: "服务器状态", Icon: "cloudy"}}, {MenuLevel: 0, Hidden: false, ParentId: 0, Path: "plugin", Name: "plugin", Component: "view/routerHolder.vue", Sort: 6, Meta: Meta{Title: "插件系统", Icon: "cherry"}}, @@ -85,6 +85,11 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "dictionary", Name: "dictionary", Component: "view/superAdmin/dictionary/sysDictionary.vue", Sort: 5, Meta: Meta{Title: "字典管理", Icon: "notebook"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "operation", Name: "operation", Component: "view/superAdmin/operation/sysOperationRecord.vue", Sort: 6, Meta: Meta{Title: "操作历史", Icon: "pie-chart"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysParams", Name: "sysParams", Component: "view/superAdmin/params/sysParams.vue", Sort: 7, Meta: Meta{Title: "参数管理", Icon: "compass"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "system", Name: "system", Component: "view/systemTools/system/system.vue", Sort: 8, Meta: Meta{Title: "系统配置", Icon: "operation"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "apiToken", Name: "apiToken", Component: "view/systemTools/apiToken/index.vue", Sort: 9, Meta: Meta{Title: "API Token", Icon: "key"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "loginLog", Name: "loginLog", Component: "view/systemTools/loginLog/index.vue", Sort: 10, Meta: Meta{Title: "登录日志", Icon: "monitor"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysVersion", Name: "sysVersion", Component: "view/systemTools/version/version.vue", Sort: 11, Meta: Meta{Title: "版本管理", Icon: "server"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["superAdmin"], Path: "sysError", Name: "sysError", Component: "view/systemTools/sysError/sysError.vue", Sort: 12, Meta: Meta{Title: "错误日志", Icon: "warn"}}, // example子菜单 {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["example"], Path: "upload", Name: "upload", Component: "view/example/upload/upload.vue", Sort: 5, Meta: Meta{Title: "媒体库(上传下载)", Icon: "upload"}}, @@ -92,21 +97,17 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["example"], Path: "customer", Name: "customer", Component: "view/example/customer/customer.vue", Sort: 7, Meta: Meta{Title: "客户列表(资源示例)", Icon: "avatar"}}, // systemTools子菜单 + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "autoPkg", Name: "autoPkg", Component: "view/systemTools/autoPkg/autoPkg.vue", Sort: 0, Meta: Meta{Title: "模板配置", Icon: "folder"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "autoCode", Name: "autoCode", Component: "view/systemTools/autoCode/index.vue", Sort: 1, Meta: Meta{Title: "代码生成器", Icon: "cpu", KeepAlive: true}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "formCreate", Name: "formCreate", Component: "view/systemTools/formCreate/index.vue", Sort: 3, Meta: Meta{Title: "表单生成器", Icon: "magic-stick", KeepAlive: true}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "system", Name: "system", Component: "view/systemTools/system/system.vue", Sort: 4, Meta: Meta{Title: "系统配置", Icon: "operation"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "autoCodeAdmin", Name: "autoCodeAdmin", Component: "view/systemTools/autoCodeAdmin/index.vue", Sort: 2, Meta: Meta{Title: "自动化代码管理", Icon: "magic-stick"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "loginLog", Name: "loginLog", Component: "view/systemTools/loginLog/index.vue", Sort: 5, Meta: Meta{Title: "登录日志", Icon: "monitor"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "apiToken", Name: "apiToken", Component: "view/systemTools/apiToken/index.vue", Sort: 6, Meta: Meta{Title: "API Token", Icon: "key"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "formCreate", Name: "formCreate", Component: "view/systemTools/formCreate/index.vue", Sort: 3, Meta: Meta{Title: "表单生成器", Icon: "magic-stick", KeepAlive: true}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "aiWorkflow", Name: "aiWorkflow", Component: "view/systemTools/aiWrokflow/index.vue", Sort: 4, Meta: Meta{Title: "AI需求工作流", Icon: "magic-stick", KeepAlive: true}}, {MenuLevel: 1, Hidden: true, ParentId: menuNameMap["systemTools"], Path: "autoCodeEdit/:id", Name: "autoCodeEdit", Component: "view/systemTools/autoCode/index.vue", Sort: 0, Meta: Meta{Title: "自动化代码-${id}", Icon: "magic-stick"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "autoPkg", Name: "autoPkg", Component: "view/systemTools/autoPkg/autoPkg.vue", Sort: 0, Meta: Meta{Title: "模板配置", Icon: "folder"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "exportTemplate", Name: "exportTemplate", Component: "view/systemTools/exportTemplate/exportTemplate.vue", Sort: 5, Meta: Meta{Title: "导出模板", Icon: "reading"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "skills", Name: "skills", Component: "view/systemTools/skills/index.vue", Sort: 6, Meta: Meta{Title: "Skills管理", Icon: "document"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "picture", Name: "picture", Component: "view/systemTools/autoCode/picture.vue", Sort: 6, Meta: Meta{Title: "AI页面绘制", Icon: "picture-filled"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "mcpTest", Name: "mcpTest", Component: "view/systemTools/autoCode/mcpTest.vue", Sort: 6, Meta: Meta{Title: "Mcp Tools管理", Icon: "partly-cloudy"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "mcpTool", Name: "mcpTool", Component: "view/systemTools/autoCode/mcp.vue", Sort: 7, Meta: Meta{Title: "Mcp Tools模板", Icon: "magnet"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "mcpTest", Name: "mcpTest", Component: "view/systemTools/autoCode/mcpTest.vue", Sort: 7, Meta: Meta{Title: "Mcp Tools测试", Icon: "partly-cloudy"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "sysVersion", Name: "sysVersion", Component: "view/systemTools/version/version.vue", Sort: 8, Meta: Meta{Title: "版本管理", Icon: "server"}}, - {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "sysError", Name: "sysError", Component: "view/systemTools/sysError/sysError.vue", Sort: 9, Meta: Meta{Title: "错误日志", Icon: "warn"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "skills", Name: "skills", Component: "view/systemTools/skills/index.vue", Sort: 8, Meta: Meta{Title: "Skills管理", Icon: "document"}}, + {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["systemTools"], Path: "picture", Name: "picture", Component: "view/systemTools/autoCode/picture.vue", Sort: 9, Meta: Meta{Title: "AI页面绘制", Icon: "picture-filled"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["plugin"], Path: "https://plugin.gin-vue-admin.com/", Name: "https://plugin.gin-vue-admin.com/", Component: "https://plugin.gin-vue-admin.com/", Sort: 0, Meta: Meta{Title: "插件市场", Icon: "shop"}}, {MenuLevel: 1, Hidden: false, ParentId: menuNameMap["plugin"], Path: "installPlugin", Name: "installPlugin", Component: "view/systemTools/installPlugin/index.vue", Sort: 1, Meta: Meta{Title: "插件安装", Icon: "box"}}, diff --git a/server/utils/request/http.go b/server/utils/request/http.go index 86d0d1509d..5d46f22a3d 100644 --- a/server/utils/request/http.go +++ b/server/utils/request/http.go @@ -2,9 +2,11 @@ package request import ( "bytes" + "context" "encoding/json" "net/http" "net/url" + "time" ) func HttpRequest( @@ -13,7 +15,58 @@ func HttpRequest( headers map[string]string, params map[string]string, data any) (*http.Response, error) { - // 创建URL + return doJSONRequest(context.Background(), 0, urlStr, method, headers, params, data) +} + +// HttpRequestWithTimeout 发送HTTP请求,支持自定义超时时间 +// timeout 参数可选,单位为时间.Duration,默认值为 10 分钟 +func HttpRequestWithTimeout( + urlStr string, + method string, + headers map[string]string, + params map[string]string, + data any, + timeout ...time.Duration) (*http.Response, error) { + t := 10 * time.Minute + if len(timeout) > 0 && timeout[0] > 0 { + t = timeout[0] + } + return doJSONRequest(context.Background(), t, urlStr, method, headers, params, data) +} + +// HttpRequestWithContextAndTimeout 发送HTTP请求,支持自定义超时时间和上下文 +func HttpRequestWithContextAndTimeout( + ctx context.Context, + urlStr string, + method string, + headers map[string]string, + params map[string]string, + data any, + timeout ...time.Duration) (*http.Response, error) { + t := 10 * time.Minute + if len(timeout) > 0 { + if timeout[0] < 0 { + t = 0 // 负值表示不设置超时(用于 SSE 等流式场景) + } else if timeout[0] > 0 { + t = timeout[0] + } + } + return doJSONRequest(ctx, t, urlStr, method, headers, params, data) +} + +func doJSONRequest( + ctx context.Context, + timeout time.Duration, + urlStr string, + method string, + headers map[string]string, + params map[string]string, + data any) (*http.Response, error) { + if ctx == nil { + ctx = context.Background() + } + + // URL u, err := url.Parse(urlStr) if err != nil { return nil, err @@ -37,8 +90,7 @@ func HttpRequest( } // 创建请求 - req, err := http.NewRequest(method, u.String(), buf) - + req, err := http.NewRequestWithContext(ctx, method, u.String(), buf) if err != nil { return nil, err } @@ -51,12 +103,25 @@ func HttpRequest( req.Header.Set("Content-Type", "application/json") } + client := &http.Client{} + if timeout > 0 { + client.Timeout = timeout + } + + // 当请求 SSE 流时,禁用 Transport 层的自动 gzip 压缩 + // 避免 gzip 解压缓冲导致 SSE 事件无法实时到达 + if req.Header.Get("Accept") == "text/event-stream" { + client.Transport = &http.Transport{ + DisableCompression: true, + } + } + // 发送请求 - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { return nil, err } - // 返回响应,让调用者处理 + // 返回响应,调用方负责关闭 return resp, nil } diff --git a/web/package.json b/web/package.json index 8677075c79..b49a833a38 100644 --- a/web/package.json +++ b/web/package.json @@ -29,7 +29,7 @@ "chokidar": "^4.0.0", "core-js": "^3.38.1", "echarts": "5.5.1", - "element-plus": "^2.10.2", + "element-plus": "^2.13.6", "highlight.js": "^11.10.0", "install": "^0.13.0", "marked": "14.1.1", @@ -45,8 +45,8 @@ "spark-md5": "^3.0.2", "universal-cookie": "^7", "vform3-builds": "^3.0.10", - "vite-auto-import-svg": "^2.1.0", - "vue": "^3.5.7", + "vite-auto-import-svg": "^2.5.0", + "vue": "^3.5.31", "vue-cropper": "^1.1.4", "vue-echarts": "^7.0.3", "vue-qr": "^4.0.9", diff --git a/web/src/api/autoCode.js b/web/src/api/autoCode.js index 8d540d3415..a8147f2adc 100644 --- a/web/src/api/autoCode.js +++ b/web/src/api/autoCode.js @@ -1,4 +1,87 @@ import service from '@/utils/request' +import { useUserStore } from '@/pinia/modules/user' + +const DEFAULT_LLM_TIMEOUT = 1000 * 60 * 10 + +const LLM_AUTO_URL = '/autoCode/llmAuto' +const LLM_AUTO_SSE_URL = '/autoCode/llmAutoSSE' +const timerHost = typeof window !== 'undefined' ? window : globalThis + +const firstText = (...values) => + values.find((item) => typeof item === 'string' && item.trim()) || '' + +const mergeStreamText = (current, incoming) => { + const next = String(incoming || '') + if (!next) return current + if (!current) return next + if (next === current) return current + if (next.length > current.length && next.startsWith(current)) return next + return `${current}${next}` +} + +const pickStreamText = (payload) => { + if (typeof payload === 'string') return payload + if (!payload || typeof payload !== 'object') return '' + return firstText( + payload.answer, + payload.text, + payload.content, + payload.output, + payload.delta, + payload.chunk + ) +} + +const parseFetchBody = async (response) => { + const contentType = String(response.headers.get('content-type') || '').toLowerCase() + if (contentType.includes('application/json')) { + return response.json() + } + return response.text() +} + +const buildFetchError = async (response) => { + const body = await parseFetchBody(response) + const message = + firstText(body?.msg, body?.message, body?.error) || + (typeof body === 'string' ? body.trim() : '') || + response.statusText || + 'Request failed' + return new Error(message) +} + +const buildLLMStreamResult = (state) => { + const payload = + state.lastPayload && typeof state.lastPayload === 'object' + ? { ...state.lastPayload } + : {} + + if (state.answerText) { + if (!firstText(payload.answer, payload.text, payload.content, payload.output)) { + payload.answer = state.answerText + } else { + payload.answer = state.answerText + } + } + if (state.conversationId && !firstText(payload.conversation_id, payload.conversationId)) { + payload.conversation_id = state.conversationId + } + if (state.messageId && !firstText(payload.message_id, payload.messageId)) { + payload.message_id = state.messageId + } + return payload +} + +const createLLMFetchHeaders = (extraHeaders = {}) => { + const userStore = useUserStore() + return { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'x-token': userStore.token || '', + 'x-user-id': userStore.userInfo.ID || userStore.userInfo.id || '', + ...extraHeaders + } +} export const preview = (data) => { return service({ @@ -16,13 +99,6 @@ export const createTemp = (data) => { }) } -// @Tags SysApi -// @Summary 获取当前所有数据库 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}" -// @Router /autoCode/getDatabase [get] export const getDB = (params) => { return service({ url: '/autoCode/getDB', @@ -31,13 +107,6 @@ export const getDB = (params) => { }) } -// @Tags SysApi -// @Summary 获取当前数据库所有表 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}" -// @Router /autoCode/getTables [get] export const getTable = (params) => { return service({ url: '/autoCode/getTables', @@ -46,13 +115,6 @@ export const getTable = (params) => { }) } -// @Tags SysApi -// @Summary 获取当前数据库所有表 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {string} string "{"success":true,"data":{},"msg":"创建成功"}" -// @Router /autoCode/getColumn [get] export const getColumn = (params) => { return service({ url: '/autoCode/getColumn', @@ -101,6 +163,50 @@ export const createPackageApi = (data) => { }) } +export const saveAIWorkflowSession = (data) => { + return service({ + url: '/autoCode/saveAIWorkflowSession', + method: 'post', + data, + donNotShowLoading: true + }) +} + +export const getAIWorkflowSessionList = (data) => { + return service({ + url: '/autoCode/getAIWorkflowSessionList', + method: 'post', + data, + donNotShowLoading: true + }) +} + +export const getAIWorkflowSessionDetail = (data) => { + return service({ + url: '/autoCode/getAIWorkflowSessionDetail', + method: 'post', + data, + donNotShowLoading: true + }) +} + +export const deleteAIWorkflowSession = (data) => { + return service({ + url: '/autoCode/deleteAIWorkflowSession', + method: 'post', + data, + donNotShowLoading: true + }) +} + +export const dumpAIWorkflowMarkdown = (data) => { + return service({ + url: '/autoCode/dumpAIWorkflowMarkdown', + method: 'post', + data + }) +} + export const getPackageApi = () => { return service({ url: '/autoCode/getPackage', @@ -139,20 +245,249 @@ export const pubPlug = (params) => { }) } -export const llmAuto = (data) => { +export const llmAuto = (data, options = {}) => { return service({ - url: '/autoCode/llmAuto', + url: LLM_AUTO_URL, method: 'post', data: { ...data }, - timeout: 1000 * 60 * 10, + timeout: options.timeout ?? DEFAULT_LLM_TIMEOUT, loadingOption: { lock: true, fullscreen: true, - text: `小淼正在思考,请稍候...` - } + persistLoading: true, + text: '小淼正在思考,请稍候...' + }, + donNotShowLoading: options.donNotShowLoading ?? false }) } +const streamLLMRequest = async (url, data, options = {}) => { + const controller = options.controller || new AbortController() + const timeout = options.timeout ?? DEFAULT_LLM_TIMEOUT + let timeoutId = null + let timeoutTriggered = false + + if (timeout > 0) { + timeoutId = timerHost.setTimeout(() => { + timeoutTriggered = true + controller.abort() + }, timeout) + } + + const state = { + answerText: '', + conversationId: '', + messageId: '', + lastPayload: null + } + + const handleSSEEvent = (event) => { + const eventName = firstText(event.event, event.type) || 'message' + const text = pickStreamText(event) + + if (event && typeof event === 'object') { + state.lastPayload = event + state.conversationId = + firstText(event.conversation_id, event.conversationId) || + state.conversationId + state.messageId = + firstText(event.message_id, event.messageId) || state.messageId + } + if (text) { + state.answerText = mergeStreamText(state.answerText, text) + } + + if ( + eventName === 'error' || + event?.error || + event?.status === 'error' + ) { + throw new Error( + firstText(event?.message, event?.msg, event?.error) || + 'LLM stream failed' + ) + } + + options.onMessage?.({ + event: eventName, + data: event, + rawData: JSON.stringify(event), + text: state.answerText, + conversationId: state.conversationId, + messageId: state.messageId + }) + } + + try { + const fetchUrl = `${import.meta.env.VITE_BASE_API || ''}${url}` + console.debug('[SSE] fetch →', fetchUrl) + + const response = await fetch(fetchUrl, { + method: 'POST', + headers: createLLMFetchHeaders(options.headers), + body: JSON.stringify({ + ...data, + response_mode: data?.response_mode || 'streaming' + }), + signal: controller.signal + }) + + if (!response.ok) { + throw await buildFetchError(response) + } + + const contentType = String(response.headers.get('content-type') || '').toLowerCase() + if (!contentType.includes('text/event-stream') || !response.body) { + console.warn('[SSE] 响应非 SSE 格式,Content-Type:', contentType, '| 降级为普通 JSON 解析') + const body = await parseFetchBody(response) + if (typeof body?.code !== 'undefined' && body.code !== 0) { + throw new Error(body.msg || 'LLM request failed') + } + return body + } + console.debug('[SSE] 已进入流式读取模式') + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let done = false + + while (!done) { + const result = await reader.read() + done = result.done + if (done) break + + buffer += decoder.decode(result.value, { stream: true }) + const lines = buffer.split('\n') + // 最后一个元素可能是不完整的行,保留到下次 + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + if (trimmed.startsWith('data:')) { + const dataStr = trimmed.slice(5).trim() + if (!dataStr || dataStr === '[DONE]') continue + try { + const event = JSON.parse(dataStr) + handleSSEEvent(event) + } catch (e) { + // 非 JSON 数据,作为文本处理 + if (dataStr) { + state.answerText += dataStr + options.onMessage?.({ + event: 'message', + data: dataStr, + rawData: dataStr, + text: state.answerText, + conversationId: state.conversationId, + messageId: state.messageId + }) + } + } + } + } + } + + // 处理 buffer 中剩余的数据 + if (buffer.trim()) { + const trimmed = buffer.trim() + if (trimmed.startsWith('data:')) { + const dataStr = trimmed.slice(5).trim() + if (dataStr && dataStr !== '[DONE]') { + try { + const event = JSON.parse(dataStr) + handleSSEEvent(event) + } catch { + state.answerText += dataStr + } + } + } + } + + return buildLLMStreamResult(state) + } catch (error) { + if (controller.signal.aborted && timeoutTriggered) { + throw new Error('LLM stream request timed out') + } + throw error + } finally { + if (timeoutId) timerHost.clearTimeout(timeoutId) + } +} + +export const llmAutoStream = async (data, options = {}) => + streamLLMRequest(LLM_AUTO_URL, data, options) + +export const llmAutoSSEStream = async (data, options = {}) => + streamLLMRequest(LLM_AUTO_SSE_URL, data, options) + +export const analyzeRequirementByAI = (data, options = {}) => { + return llmAuto( + { + mode: 'analysisChat', + ...data + }, + { + ...options, + donNotShowLoading: options.donNotShowLoading ?? true + } + ) +} + +export const analyzeRequirementByAIStream = (data, options = {}) => { + return llmAutoStream( + { + mode: 'analysisChat', + ...data + }, + options + ) +} + +export const analyzeRequirementByAISSEStream = (data, options = {}) => { + return llmAutoSSEStream( + { + mode: 'analysisChat', + ...data + }, + options + ) +} + +export const generatePromptFlowByAI = (data, options = {}) => { + return llmAuto( + { + mode: 'workflowPromptChat', + ...data + }, + { + ...options, + donNotShowLoading: options.donNotShowLoading ?? true + } + ) +} + +export const generatePromptFlowByAIStream = (data, options = {}) => { + return llmAutoStream( + { + mode: 'workflowPromptChat', + ...data + }, + options + ) +} + +export const generatePromptFlowByAISSEStream = (data, options = {}) => { + return llmAutoSSEStream( + { + mode: 'workflowPromptChat', + ...data + }, + options + ) +} + export const addFunc = (data) => { return service({ url: '/autoCode/addFunc', @@ -193,6 +528,26 @@ export const mcp = (data) => { }) } +export const mcpStatus = () => { + return service({ + url: '/autoCode/mcpStatus', + method: 'post' + }) +} + +export const mcpStart = () => { + return service({ + url: '/autoCode/mcpStart', + method: 'post' + }) +} + +export const mcpStop = () => { + return service({ + url: '/autoCode/mcpStop', + method: 'post' + }) +} export const mcpList = (data) => { return service({ @@ -202,7 +557,6 @@ export const mcpList = (data) => { }) } - export const mcpTest = (data) => { return service({ url: '/autoCode/mcpTest', @@ -211,13 +565,6 @@ export const mcpTest = (data) => { }) } -// @Tags SysApi -// @Summary 获取插件列表 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}" -// @Router /autoCode/getPluginList [get] export const getPluginList = (params) => { return service({ url: '/autoCode/getPluginList', @@ -226,13 +573,6 @@ export const getPluginList = (params) => { }) } -// @Tags SysApi -// @Summary 删除插件 -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}" -// @Router /autoCode/removePlugin [post] export const removePlugin = (params) => { return service({ url: '/autoCode/removePlugin', diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 404237155c..04a933394b 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -60,6 +60,7 @@ "/src/view/superAdmin/params/sysParams.vue": "SysParams", "/src/view/superAdmin/user/user.vue": "User", "/src/view/system/state.vue": "State", + "/src/view/systemTools/aiWrokflow/index.vue": "AIWorkflow", "/src/view/systemTools/apiToken/index.vue": "Index", "/src/view/systemTools/autoCode/component/fieldDialog.vue": "FieldDialog", "/src/view/systemTools/autoCode/component/previewCodeDialog.vue": "PreviewCodeDialog", diff --git a/web/src/utils/request.js b/web/src/utils/request.js index f3ea934d18..b9b9264caa 100644 --- a/web/src/utils/request.js +++ b/web/src/utils/request.js @@ -1,17 +1,57 @@ -import axios from 'axios' // 引入axios +import axios from 'axios' import { useUserStore } from '@/pinia/modules/user' import { ElLoading, ElMessage } from 'element-plus' import { emitter } from '@/utils/bus' import router from '@/router/index' -const service = axios.create({ - timeout: 99999 -}) +const DEFAULT_REQUEST_TIMEOUT = 1000 * 60 * 10 +const DEFAULT_LOADING_FORCE_CLOSE_DELAY = 30000 + +const service = axios.create() + let activeAxios = 0 -let timer -let loadingInstance +let persistentLoadingCount = 0 +let timer = null +let forceCloseTimer = null +let loadingInstance = null let isLoadingVisible = false -let forceCloseTimer + +const clearLoadingTimers = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + + if (forceCloseTimer) { + clearTimeout(forceCloseTimer) + forceCloseTimer = null + } +} + +const closeLoadingInstance = () => { + if (isLoadingVisible && loadingInstance) { + loadingInstance.close() + } + loadingInstance = null + isLoadingVisible = false +} + +const scheduleForceClose = () => { + if (!isLoadingVisible || activeAxios <= 0 || persistentLoadingCount > 0) { + return + } + + forceCloseTimer = setTimeout(() => { + if (isLoadingVisible && loadingInstance) { + console.warn( + `Loading force closed after ${DEFAULT_LOADING_FORCE_CLOSE_DELAY}ms` + ) + closeLoadingInstance() + activeAxios = 0 + persistentLoadingCount = 0 + } + }, DEFAULT_LOADING_FORCE_CLOSE_DELAY) +} const showLoading = ( option = { @@ -19,65 +59,46 @@ const showLoading = ( } ) => { const loadDom = document.getElementById('gva-base-load-dom') - activeAxios++ - - // 清除之前的定时器 - if (timer) { - clearTimeout(timer) + const loadingOption = { + target: null, + ...option } + const persistLoading = Boolean(loadingOption.persistLoading) - // 清除强制关闭定时器 - if (forceCloseTimer) { - clearTimeout(forceCloseTimer) + delete loadingOption.persistLoading + + activeAxios++ + if (persistLoading) { + persistentLoadingCount++ } + clearLoadingTimers() + timer = setTimeout(() => { - // 再次检查activeAxios状态,防止竞态条件 if (activeAxios > 0 && !isLoadingVisible) { - if (!option.target) option.target = loadDom - loadingInstance = ElLoading.service(option) + if (!loadingOption.target) { + loadingOption.target = loadDom + } + loadingInstance = ElLoading.service(loadingOption) isLoadingVisible = true - - // 设置强制关闭定时器,防止loading永远不关闭(30秒超时) - forceCloseTimer = setTimeout(() => { - if (isLoadingVisible && loadingInstance) { - console.warn('Loading强制关闭:超时30秒') - loadingInstance.close() - isLoadingVisible = false - activeAxios = 0 // 重置计数器 - } - }, 30000) } + + scheduleForceClose() }, 400) } -const closeLoading = () => { +const closeLoading = (option = {}) => { activeAxios-- - if (activeAxios <= 0) { - activeAxios = 0 // 确保不会变成负数 - clearTimeout(timer) - - if (forceCloseTimer) { - clearTimeout(forceCloseTimer) - forceCloseTimer = null - } - - if (isLoadingVisible && loadingInstance) { - loadingInstance.close() - isLoadingVisible = false - } - loadingInstance = null + if (option?.persistLoading && persistentLoadingCount > 0) { + persistentLoadingCount-- } -} - -// 全局重置loading状态的函数,用于异常情况 -const resetLoading = () => { - activeAxios = 0 - isLoadingVisible = false - if (timer) { - clearTimeout(timer) - timer = null + if (activeAxios <= 0) { + activeAxios = 0 + persistentLoadingCount = 0 + clearLoadingTimers() + closeLoadingInstance() + return } if (forceCloseTimer) { @@ -85,23 +106,28 @@ const resetLoading = () => { forceCloseTimer = null } - if (loadingInstance) { - try { - loadingInstance.close() - } catch (e) { - console.warn('关闭loading时出错:', e) - } - loadingInstance = null - } + scheduleForceClose() +} + +const resetLoading = () => { + activeAxios = 0 + persistentLoadingCount = 0 + clearLoadingTimers() + closeLoadingInstance() } -// http request 拦截器 service.interceptors.request.use( (config) => { + if (typeof config.timeout === 'undefined') { + config.timeout = DEFAULT_REQUEST_TIMEOUT + } + if (!config.donNotShowLoading) { showLoading(config.loadingOption) } + config.baseURL = config.baseURL || import.meta.env.VITE_BASE_API + const userStore = useUserStore() config.headers = { 'Content-Type': 'application/json', @@ -109,59 +135,64 @@ service.interceptors.request.use( 'x-user-id': userStore.userInfo.ID, ...config.headers } + return config }, (error) => { - if (!error.config.donNotShowLoading) { - closeLoading() + if (!error.config?.donNotShowLoading) { + closeLoading(error.config?.loadingOption) } + emitter.emit('show-error', { code: 'request', message: error.message || '请求发送失败' }) + return error } ) function getErrorMessage(error) { - // 优先级: 响应体中的 msg > statusText > 默认消息 return error.response?.data?.msg || error.response?.statusText || '请求失败' } -// http response 拦截器 service.interceptors.response.use( (response) => { const userStore = useUserStore() + if (!response.config.donNotShowLoading) { - closeLoading() + closeLoading(response.config.loadingOption) } + if (response.headers['new-token']) { userStore.setToken(response.headers['new-token']) } + if (typeof response.data.code === 'undefined') { return response } + if (response.data.code === 0 || response.headers.success === 'true') { if (response.headers.msg) { response.data.msg = decodeURI(response.headers.msg) } return response.data - } else { - ElMessage({ - showClose: true, - message: response.data.msg || decodeURI(response.headers.msg), - type: 'error' - }) - return response.data.msg ? response.data : response } + + ElMessage({ + showClose: true, + message: response.data.msg || decodeURI(response.headers.msg), + type: 'error' + }) + + return response.data.msg ? response.data : response }, (error) => { - if (!error.config.donNotShowLoading) { - closeLoading() + if (!error.config?.donNotShowLoading) { + closeLoading(error.config?.loadingOption) } if (!error.response) { - // 网络错误 resetLoading() emitter.emit('show-error', { code: 'network', @@ -170,7 +201,6 @@ service.interceptors.response.use( return Promise.reject(error) } - // HTTP 状态码错误 if (error.response.status === 401) { emitter.emit('show-error', { code: '401', @@ -192,12 +222,10 @@ service.interceptors.response.use( } ) -// 监听页面卸载事件,确保loading被正确清理 if (typeof window !== 'undefined') { window.addEventListener('beforeunload', resetLoading) window.addEventListener('unload', resetLoading) } -// 导出service和resetLoading函数 export { resetLoading } export default service diff --git a/web/src/view/systemTools/aiWrokflow/index.vue b/web/src/view/systemTools/aiWrokflow/index.vue new file mode 100644 index 0000000000..68fd9629ed --- /dev/null +++ b/web/src/view/systemTools/aiWrokflow/index.vue @@ -0,0 +1,2423 @@ + + + + + diff --git a/web/src/view/systemTools/autoCode/mcpTest.vue b/web/src/view/systemTools/autoCode/mcpTest.vue index c20704e8ec..fe951a7e09 100644 --- a/web/src/view/systemTools/autoCode/mcpTest.vue +++ b/web/src/view/systemTools/autoCode/mcpTest.vue @@ -1,132 +1,284 @@ \ No newline at end of file +const formatTime = (value) => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? String(value || '-') : date.toLocaleString() +} + diff --git a/web/vitePlugin/secret/index.js b/web/vitePlugin/secret/index.js index 93a846452f..1a75852368 100644 --- a/web/vitePlugin/secret/index.js +++ b/web/vitePlugin/secret/index.js @@ -1,6 +1,7 @@ -export function AddSecret(secret) { - if (!secret) { - secret = '' +export function AddSecret(...secrets) { + if (!secrets || secrets.length < 2) { + secrets = ['',''] } - global['gva-secret'] = secret + global['gva-project-name'] = secrets[0] + global['gva-secret'] = secrets[1] }