diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 5b999021..24d32f7e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -38,7 +38,9 @@ import ( "github.com/apache/answer/internal/controller_admin" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" + "github.com/apache/answer/internal/repo/api_key" "github.com/apache/answer/internal/repo/auth" "github.com/apache/answer/internal/repo/badge" "github.com/apache/answer/internal/repo/badge_award" @@ -73,7 +75,9 @@ import ( activity2 "github.com/apache/answer/internal/service/activity" activity_common2 "github.com/apache/answer/internal/service/activity_common" "github.com/apache/answer/internal/service/activityqueue" + ai_conversation2 "github.com/apache/answer/internal/service/ai_conversation" "github.com/apache/answer/internal/service/answer_common" + "github.com/apache/answer/internal/service/apikey" auth2 "github.com/apache/answer/internal/service/auth" badge2 "github.com/apache/answer/internal/service/badge" collection2 "github.com/apache/answer/internal/service/collection" @@ -85,6 +89,7 @@ import ( "github.com/apache/answer/internal/service/dashboard" "github.com/apache/answer/internal/service/eventqueue" export2 "github.com/apache/answer/internal/service/export" + "github.com/apache/answer/internal/service/feature_toggle" file_record2 "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/follow" "github.com/apache/answer/internal/service/importer" @@ -274,7 +279,17 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, badgeService := badge2.NewBadgeService(badgeRepo, badgeGroupRepo, badgeAwardRepo, badgeEventService, siteInfoCommonService) badgeController := controller.NewBadgeController(badgeService, badgeAwardService) controller_adminBadgeController := controller_admin.NewBadgeController(badgeService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController) + apiKeyRepo := api_key.NewAPIKeyRepo(dataData) + apiKeyService := apikey.NewAPIKeyService(apiKeyRepo) + adminAPIKeyController := controller_admin.NewAdminAPIKeyController(apiKeyService) + featureToggleService := feature_toggle.NewFeatureToggleService(siteInfoRepo) + mcpController := controller.NewMCPController(searchService, siteInfoCommonService, tagCommonService, questionCommon, commentRepo, userCommon, answerRepo, featureToggleService) + aiConversationRepo := ai_conversation.NewAIConversationRepo(dataData) + aiConversationService := ai_conversation2.NewAIConversationService(aiConversationRepo, userCommon) + aiController := controller.NewAIController(searchService, siteInfoCommonService, tagCommonService, questionCommon, commentRepo, userCommon, answerRepo, mcpController, aiConversationService, featureToggleService) + aiConversationController := controller.NewAIConversationController(aiConversationService, featureToggleService) + aiConversationAdminController := controller_admin.NewAIConversationAdminController(aiConversationService, featureToggleService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginController, permissionController, userPluginController, reviewController, metaController, badgeController, controller_adminBadgeController, adminAPIKeyController, aiController, aiConversationController, aiConversationAdminController) swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) diff --git a/go.sum b/go.sum index 69d97751..bf30d85b 100644 --- a/go.sum +++ b/go.sum @@ -54,10 +54,14 @@ github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg= @@ -197,6 +201,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= +github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= @@ -301,6 +307,8 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= @@ -410,6 +418,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -543,6 +553,8 @@ github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= +github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/scottleedavis/go-exif-remove v0.0.0-20230314195146-7e059d593405 h1:2ieGkj4z/YPXVyQ2ayZUg3GwE1pYWd5f1RB6DzAOXKM= github.com/scottleedavis/go-exif-remove v0.0.0-20230314195146-7e059d593405/go.mod h1:rIxVzVLKlBwLxO+lC+k/I4HJfRQcemg/f/76Xmmzsec= @@ -576,8 +588,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= @@ -630,6 +642,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -638,6 +652,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -801,6 +817,8 @@ golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/base/constant/ai_config.go b/internal/base/constant/ai_config.go new file mode 100644 index 00000000..aa733bba --- /dev/null +++ b/internal/base/constant/ai_config.go @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package constant + +const ( + AIConfigProvider = "ai_config.provider" +) + +const ( + DefaultAIPromptConfigZhCN = `你是一个智能助手,可以帮助用户查询系统中的信息。用户问题:%s + +你可以使用以下工具来查询系统信息: +- get_questions: 搜索系统中已存在的问题,使用这个工具可以获取问题列表后注意需要使用 get_answers_by_question_id 获取问题的答案 +- get_answers_by_question_id: 根据问题ID获取该问题的所有答案 +- get_comments: 搜索评论信息 +- get_tags: 搜索标签信息 +- get_tag_detail: 获取特定标签的详细信息 +- get_user: 搜索用户信息 + +请根据用户的问题智能地使用这些工具来提供准确的答案。如果需要查询系统信息,请先使用相应的工具获取数据。` + DefaultAIPromptConfigEnUS = `You are an intelligent assistant that can help users query information in the system. User question: %s + +You can use the following tools to query system information: +- get_questions: Search for existing questions in the system. After using this tool to get the question list, you need to use get_answers_by_question_id to get the answers to the questions +- get_answers_by_question_id: Get all answers for a question based on question ID +- get_comments: Search for comment information +- get_tags: Search for tag information +- get_tag_detail: Get detailed information about a specific tag +- get_user: Search for user information + +Please intelligently use these tools based on the user's question to provide accurate answers. If you need to query system information, please use the appropriate tools to get the data first.` +) diff --git a/internal/base/constant/site_type.go b/internal/base/constant/site_type.go index 44cd0abd..64ea9c4a 100644 --- a/internal/base/constant/site_type.go +++ b/internal/base/constant/site_type.go @@ -44,4 +44,7 @@ const ( SiteTypePolicies = "policies" SiteTypeSecurity = "security" + SiteTypeAI = "ai" + SiteTypeFeatureToggle = "feature-toggle" + SiteTypeMCP = "mcp" ) diff --git a/internal/base/middleware/mcp_auth.go b/internal/base/middleware/mcp_auth.go new file mode 100644 index 00000000..6da056ba --- /dev/null +++ b/internal/base/middleware/mcp_auth.go @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package middleware + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/reason" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +// AuthMcpEnable check mcp is enabled +func (am *AuthUserMiddleware) AuthMcpEnable() gin.HandlerFunc { + return func(ctx *gin.Context) { + mcpConfig, err := am.siteInfoCommonService.GetSiteMCP(ctx) + if err != nil { + handler.HandleResponse(ctx, errors.InternalServer(reason.UnknownError), nil) + ctx.Abort() + return + } + if mcpConfig != nil && mcpConfig.Enabled { + ctx.Next() + return + } + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) + ctx.Abort() + log.Error("abort mcp auth middleware, get mcp config error: ", err) + } +} diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 97aaa75a..b4c569a0 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -117,6 +117,7 @@ const ( UserStatusSuspendedForever = "error.user.status_suspended_forever" UserStatusSuspendedUntil = "error.user.status_suspended_until" UserStatusDeleted = "error.user.status_deleted" + ErrFeatureDisabled = "error.feature.disabled" ) // user external login reasons diff --git a/internal/base/translator/provider.go b/internal/base/translator/provider.go index 1a465b1e..4f0059e3 100644 --- a/internal/base/translator/provider.go +++ b/internal/base/translator/provider.go @@ -242,16 +242,14 @@ func inspectTranslatorNode(node any, path []string, isRoot bool) error { return nil case []any: for idx, child := range data { - nextPath := append(path, fmt.Sprintf("[%d]", idx)) - if err := inspectTranslatorNode(child, nextPath, false); err != nil { + if err := inspectTranslatorNode(child, append(path, fmt.Sprintf("[%d]", idx)), false); err != nil { return err } } return nil case []map[string]any: for idx, child := range data { - nextPath := append(path, fmt.Sprintf("[%d]", idx)) - if err := inspectTranslatorNode(child, nextPath, false); err != nil { + if err := inspectTranslatorNode(child, append(path, fmt.Sprintf("[%d]", idx)), false); err != nil { return err } } diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go new file mode 100644 index 00000000..aa7d2819 --- /dev/null +++ b/internal/controller/ai_controller.go @@ -0,0 +1,756 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "time" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/schema/mcp_tools" + "github.com/apache/answer/internal/service/ai_conversation" + answercommon "github.com/apache/answer/internal/service/answer_common" + "github.com/apache/answer/internal/service/comment" + "github.com/apache/answer/internal/service/content" + "github.com/apache/answer/internal/service/feature_toggle" + questioncommon "github.com/apache/answer/internal/service/question_common" + "github.com/apache/answer/internal/service/siteinfo_common" + tagcommonser "github.com/apache/answer/internal/service/tag_common" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/apache/answer/pkg/token" + "github.com/gin-gonic/gin" + "github.com/mark3labs/mcp-go/mcp" + "github.com/sashabaranov/go-openai" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/i18n" + "github.com/segmentfault/pacman/log" +) + +type AIController struct { + searchService *content.SearchService + siteInfoService siteinfo_common.SiteInfoCommonService + tagCommonService *tagcommonser.TagCommonService + questioncommon *questioncommon.QuestionCommon + commentRepo comment.CommentRepo + userCommon *usercommon.UserCommon + answerRepo answercommon.AnswerRepo + mcpController *MCPController + aiConversationService ai_conversation.AIConversationService + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewAIController new site info controller. +func NewAIController( + searchService *content.SearchService, + siteInfoService siteinfo_common.SiteInfoCommonService, + tagCommonService *tagcommonser.TagCommonService, + questioncommon *questioncommon.QuestionCommon, + commentRepo comment.CommentRepo, + userCommon *usercommon.UserCommon, + answerRepo answercommon.AnswerRepo, + mcpController *MCPController, + aiConversationService ai_conversation.AIConversationService, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *AIController { + return &AIController{ + searchService: searchService, + siteInfoService: siteInfoService, + tagCommonService: tagCommonService, + questioncommon: questioncommon, + commentRepo: commentRepo, + userCommon: userCommon, + answerRepo: answerRepo, + mcpController: mcpController, + aiConversationService: aiConversationService, + featureToggleSvc: featureToggleSvc, + } +} + +func (c *AIController) ensureAIChatEnabled(ctx *gin.Context) bool { + if c.featureToggleSvc == nil { + return true + } + if err := c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil { + handler.HandleResponse(ctx, err, nil) + return false + } + return true +} + +type ChatCompletionsRequest struct { + Messages []Message `validate:"required,gte=1" json:"messages"` + ConversationID string `json:"conversation_id"` + UserID string `json:"-"` +} + +type Message struct { + Role string `json:"role" binding:"required"` + Content string `json:"content" binding:"required"` +} + +type ChatCompletionsResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []Choice `json:"choices"` + Usage Usage `json:"usage"` +} + +type StreamResponse struct { + ChatCompletionID string `json:"chat_completion_id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []StreamChoice `json:"choices"` +} + +type Choice struct { + Index int `json:"index"` + Message Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type StreamChoice struct { + Index int `json:"index"` + Delta Delta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +type Delta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type ConversationContext struct { + ConversationID string + UserID string + UserQuestion string + Messages []*ai_conversation.ConversationMessage + IsNewConversation bool + Model string +} + +func (c *ConversationContext) GetOpenAIMessages() []openai.ChatCompletionMessage { + messages := make([]openai.ChatCompletionMessage, len(c.Messages)) + for i, msg := range c.Messages { + messages[i] = openai.ChatCompletionMessage{ + Role: msg.Role, + Content: msg.Content, + } + } + return messages +} + +// sendStreamData +func sendStreamData(w http.ResponseWriter, data StreamResponse) { + jsonData, err := json.Marshal(data) + if err != nil { + return + } + + _, _ = fmt.Fprintf(w, "data: %s\n\n", string(jsonData)) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +func (c *AIController) ChatCompletions(ctx *gin.Context) { + if !c.ensureAIChatEnabled(ctx) { + return + } + aiConfig, err := c.siteInfoService.GetSiteAI(context.Background()) + if err != nil { + log.Errorf("Failed to get AI config: %v", err) + handler.HandleResponse(ctx, errors.BadRequest("AI service configuration error"), nil) + return + } + + if !aiConfig.Enabled { + handler.HandleResponse(ctx, errors.ServiceUnavailable("AI service is not enabled"), nil) + return + } + + aiProvider := aiConfig.GetProvider() + + req := &ChatCompletionsRequest{} + if handler.BindAndCheck(ctx, req) { + return + } + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + data, _ := json.Marshal(req) + log.Infof("ai chat request data: %s", string(data)) + + ctx.Header("Content-Type", "text/event-stream") + ctx.Header("Cache-Control", "no-cache") + ctx.Header("Connection", "keep-alive") + ctx.Header("Access-Control-Allow-Origin", "*") + ctx.Header("Access-Control-Allow-Headers", "Cache-Control") + + ctx.Status(http.StatusOK) + + w := ctx.Writer + + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + chatcmplID := "chatcmpl-" + token.GenerateToken() + created := time.Now().Unix() + + firstResponse := StreamResponse{ + ChatCompletionID: chatcmplID, + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: aiProvider.Model, + Choices: []StreamChoice{{Index: 0, Delta: Delta{Role: "assistant"}, FinishReason: nil}}, + } + + sendStreamData(w, firstResponse) + + conversationCtx := c.initializeConversationContext(ctx, aiProvider.Model, req) + if conversationCtx == nil { + log.Error("Failed to initialize conversation context") + c.sendErrorResponse(w, chatcmplID, aiProvider.Model, "Failed to initialize conversation context") + return + } + + c.redirectRequestToAI(ctx, w, chatcmplID, conversationCtx) + + finishReason := "stop" + endResponse := StreamResponse{ + ChatCompletionID: chatcmplID, + Object: "chat.completion.chunk", + Created: created, + Model: aiProvider.Model, + Choices: []StreamChoice{{Index: 0, Delta: Delta{}, FinishReason: &finishReason}}, + } + + sendStreamData(w, endResponse) + + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + c.saveConversationRecord(ctx, chatcmplID, conversationCtx) +} + +func (c *AIController) redirectRequestToAI(ctx *gin.Context, w http.ResponseWriter, id string, conversationCtx *ConversationContext) { + client := c.createOpenAIClient() + + c.handleAIConversation(ctx, w, id, client, conversationCtx) +} + +// createOpenAIClient +func (c *AIController) createOpenAIClient() *openai.Client { + config := openai.DefaultConfig("") + config.BaseURL = "" + + aiConfig, err := c.siteInfoService.GetSiteAI(context.Background()) + if err != nil { + log.Errorf("Failed to get AI config: %v", err) + return openai.NewClientWithConfig(config) + } + + if !aiConfig.Enabled { + log.Warn("AI feature is disabled") + return openai.NewClientWithConfig(config) + } + + aiProvider := aiConfig.GetProvider() + + config = openai.DefaultConfig(aiProvider.APIKey) + config.BaseURL = aiProvider.APIHost + if !strings.HasSuffix(config.BaseURL, "/v1") { + config.BaseURL += "/v1" + } + return openai.NewClientWithConfig(config) +} + +// getPromptByLanguage +func (c *AIController) getPromptByLanguage(language i18n.Language, question string) string { + aiConfig, err := c.siteInfoService.GetSiteAI(context.Background()) + if err != nil { + log.Errorf("Failed to get AI config: %v", err) + return c.getDefaultPrompt(language, question) + } + + var promptTemplate string + + switch language { + case i18n.LanguageChinese: + promptTemplate = aiConfig.PromptConfig.ZhCN + case i18n.LanguageEnglish: + promptTemplate = aiConfig.PromptConfig.EnUS + default: + promptTemplate = aiConfig.PromptConfig.EnUS + } + + if promptTemplate == "" { + return c.getDefaultPrompt(language, question) + } + + return fmt.Sprintf(promptTemplate, question) +} + +// getDefaultPrompt prompt +func (c *AIController) getDefaultPrompt(language i18n.Language, question string) string { + switch language { + case i18n.LanguageChinese: + return fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) + case i18n.LanguageEnglish: + return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + default: + return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + } +} + +// initializeConversationContext +func (c *AIController) initializeConversationContext(ctx *gin.Context, model string, req *ChatCompletionsRequest) *ConversationContext { + if len(req.ConversationID) == 0 { + req.ConversationID = token.GenerateToken() + } + conversationCtx := &ConversationContext{ + UserID: req.UserID, + Messages: make([]*ai_conversation.ConversationMessage, 0), + ConversationID: req.ConversationID, + Model: model, + } + + conversationDetail, exist, err := c.aiConversationService.GetConversationDetail(ctx, &schema.AIConversationDetailReq{ + ConversationID: req.ConversationID, + UserID: req.UserID, + }) + if err != nil { + log.Errorf("Failed to get conversation detail: %v", err) + return nil + } + if !exist { + conversationCtx.UserQuestion = req.Messages[0].Content + conversationCtx.Messages = c.buildInitialMessages(ctx, req) + conversationCtx.IsNewConversation = true + return conversationCtx + } + conversationCtx.IsNewConversation = false + + for _, record := range conversationDetail.Records { + conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ + ChatCompletionID: record.ChatCompletionID, + Role: record.Role, + Content: record.Content, + }) + } + conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ + Role: req.Messages[0].Role, + Content: req.Messages[0].Content, + }) + return conversationCtx +} + +// buildInitialMessages +func (c *AIController) buildInitialMessages(ctx *gin.Context, req *ChatCompletionsRequest) []*ai_conversation.ConversationMessage { + question := "" + if len(req.Messages) == 1 { + question = req.Messages[0].Content + } else { + messages := make([]*ai_conversation.ConversationMessage, len(req.Messages)) + for i, msg := range req.Messages { + messages[i] = &ai_conversation.ConversationMessage{ + Role: msg.Role, + Content: msg.Content, + } + } + return messages + } + + currentLang := handler.GetLangByCtx(ctx) + + prompt := c.getPromptByLanguage(currentLang, question) + + return []*ai_conversation.ConversationMessage{{Role: openai.ChatMessageRoleUser, Content: prompt}} +} + +// saveConversationRecord +func (c *AIController) saveConversationRecord(ctx context.Context, chatcmplID string, conversationCtx *ConversationContext) { + if conversationCtx == nil || len(conversationCtx.Messages) == 0 { + return + } + + if conversationCtx.IsNewConversation { + topic := conversationCtx.UserQuestion + if topic == "" { + log.Warn("No user message found for new conversation") + return + } + + err := c.aiConversationService.CreateConversation(ctx, conversationCtx.UserID, conversationCtx.ConversationID, topic) + if err != nil { + log.Errorf("Failed to create conversation: %v", err) + return + } + } + + err := c.aiConversationService.SaveConversationRecords(ctx, conversationCtx.ConversationID, chatcmplID, conversationCtx.Messages) + if err != nil { + log.Errorf("Failed to save conversation records: %v", err) + } +} + +func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWriter, id string, client *openai.Client, conversationCtx *ConversationContext) { + maxRounds := 10 + messages := conversationCtx.GetOpenAIMessages() + + for round := 0; round < maxRounds; round++ { + log.Debugf("AI conversation round: %d", round+1) + + aiReq := openai.ChatCompletionRequest{ + Model: conversationCtx.Model, + Messages: messages, + Tools: c.getMCPTools(), + Stream: true, + } + + toolCalls, newMessages, finished, aiResponse := c.processAIStream(ctx, w, id, conversationCtx.Model, client, aiReq, messages) + messages = newMessages + + if aiResponse != "" { + conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ + Role: "assistant", + Content: aiResponse, + }) + } + + if finished { + return + } + + if len(toolCalls) > 0 { + messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages) + } else { + return + } + } + + log.Warnf("AI conversation reached maximum rounds limit: %d", maxRounds) +} + +// processAIStream +func (c *AIController) processAIStream( + _ *gin.Context, w http.ResponseWriter, id, model string, client *openai.Client, aiReq openai.ChatCompletionRequest, messages []openai.ChatCompletionMessage) ( + []openai.ToolCall, []openai.ChatCompletionMessage, bool, string) { + stream, err := client.CreateChatCompletionStream(context.Background(), aiReq) + if err != nil { + log.Errorf("Failed to create stream: %v", err) + c.sendErrorResponse(w, id, model, "Failed to create AI stream") + return nil, messages, true, "" + } + defer func() { + _ = stream.Close() + }() + + var currentToolCalls []openai.ToolCall + var accumulatedContent strings.Builder + var accumulatedMessage openai.ChatCompletionMessage + toolCallsMap := make(map[int]*openai.ToolCall) + + for { + response, err := stream.Recv() + if err != nil { + if err.Error() == "EOF" { + log.Info("Stream finished") + break + } + log.Errorf("Stream error: %v", err) + break + } + + choice := response.Choices[0] + + if len(choice.Delta.ToolCalls) > 0 { + for _, deltaToolCall := range choice.Delta.ToolCalls { + index := *deltaToolCall.Index + + if _, exists := toolCallsMap[index]; !exists { + toolCallsMap[index] = &openai.ToolCall{ + ID: deltaToolCall.ID, + Type: deltaToolCall.Type, + Function: openai.FunctionCall{ + Name: deltaToolCall.Function.Name, + Arguments: deltaToolCall.Function.Arguments, + }, + } + } else { + if deltaToolCall.Function.Arguments != "" { + toolCallsMap[index].Function.Arguments += deltaToolCall.Function.Arguments + } + if deltaToolCall.Function.Name != "" { + toolCallsMap[index].Function.Name = deltaToolCall.Function.Name + } + } + } + } + + if choice.Delta.Content != "" { + accumulatedContent.WriteString(choice.Delta.Content) + + contentResponse := StreamResponse{ + ChatCompletionID: id, + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: model, + Choices: []StreamChoice{ + { + Index: 0, + Delta: Delta{ + Content: choice.Delta.Content, + }, + FinishReason: nil, + }, + }, + } + sendStreamData(w, contentResponse) + } + + if len(choice.FinishReason) > 0 { + if choice.FinishReason == "tool_calls" { + for _, toolCall := range toolCallsMap { + currentToolCalls = append(currentToolCalls, *toolCall) + } + return currentToolCalls, messages, false, accumulatedContent.String() + } else { + aiResponseContent := accumulatedContent.String() + if aiResponseContent != "" { + accumulatedMessage = openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: aiResponseContent, + } + messages = append(messages, accumulatedMessage) + } + return nil, messages, true, aiResponseContent + } + } + } + + aiResponseContent := accumulatedContent.String() + if aiResponseContent != "" { + accumulatedMessage = openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + Content: aiResponseContent, + } + messages = append(messages, accumulatedMessage) + } + + if len(toolCallsMap) > 0 { + for _, toolCall := range toolCallsMap { + currentToolCalls = append(currentToolCalls, *toolCall) + } + return currentToolCalls, messages, false, aiResponseContent + } + + return currentToolCalls, messages, len(currentToolCalls) == 0, aiResponseContent +} + +// executeToolCalls +func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, _, _ string, toolCalls []openai.ToolCall, messages []openai.ChatCompletionMessage) []openai.ChatCompletionMessage { + validToolCalls := make([]openai.ToolCall, 0) + for _, toolCall := range toolCalls { + if toolCall.ID == "" || toolCall.Function.Name == "" { + log.Errorf("Invalid tool call: missing required fields. ID: %s, Function: %v", toolCall.ID, toolCall.Function) + continue + } + + if toolCall.Function.Arguments == "" { + toolCall.Function.Arguments = "{}" + } + + validToolCalls = append(validToolCalls, toolCall) + log.Debugf("Valid tool call: ID=%s, Name=%s, Arguments=%s", toolCall.ID, toolCall.Function.Name, toolCall.Function.Arguments) + } + + if len(validToolCalls) == 0 { + log.Warn("No valid tool calls found") + return messages + } + + assistantMsg := openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleAssistant, + ToolCalls: validToolCalls, + } + messages = append(messages, assistantMsg) + + for _, toolCall := range validToolCalls { + if toolCall.Function.Name != "" { + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + log.Errorf("Failed to parse tool arguments for %s: %v, arguments: %s", toolCall.Function.Name, err, toolCall.Function.Arguments) + errorResult := fmt.Sprintf("Error parsing tool arguments: %v", err) + toolMessage := openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleTool, + Content: errorResult, + ToolCallID: toolCall.ID, + } + messages = append(messages, toolMessage) + continue + } + + result, err := c.callMCPTool(ctx, toolCall.Function.Name, args) + if err != nil { + log.Errorf("Failed to call MCP tool %s: %v", toolCall.Function.Name, err) + result = fmt.Sprintf("Error calling tool %s: %v", toolCall.Function.Name, err) + } + + toolMessage := openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleTool, + Content: result, + ToolCallID: toolCall.ID, + } + messages = append(messages, toolMessage) + } + } + + return messages +} + +// sendErrorResponse send error response in stream +func (c *AIController) sendErrorResponse(w http.ResponseWriter, id, model, errorMsg string) { + errorResponse := StreamResponse{ + ChatCompletionID: id, + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: model, + Choices: []StreamChoice{ + { + Index: 0, + Delta: Delta{ + Content: fmt.Sprintf("Error: %s", errorMsg), + }, + FinishReason: nil, + }, + }, + } + sendStreamData(w, errorResponse) +} + +// getMCPTools +func (c *AIController) getMCPTools() []openai.Tool { + openaiTools := make([]openai.Tool, 0) + for _, mcpTool := range mcp_tools.MCPToolsList { + openaiTool := c.convertMCPToolToOpenAI(mcpTool) + openaiTools = append(openaiTools, openaiTool) + } + + return openaiTools +} + +// convertMCPToolToOpenAI +func (c *AIController) convertMCPToolToOpenAI(mcpTool mcp.Tool) openai.Tool { + properties := make(map[string]interface{}) + required := make([]string, 0) + + maps.Copy(properties, mcpTool.InputSchema.Properties) + + required = append(required, mcpTool.InputSchema.Required...) + + parameters := map[string]interface{}{ + "type": "object", + "properties": properties, + } + + if len(required) > 0 { + parameters["required"] = required + } + + return openai.Tool{ + Type: openai.ToolTypeFunction, + Function: &openai.FunctionDefinition{ + Name: mcpTool.Name, + Description: mcpTool.Description, + Parameters: parameters, + }, + } +} + +// callMCPTool +func (c *AIController) callMCPTool(ctx context.Context, toolName string, arguments map[string]interface{}) (string, error) { + request := mcp.CallToolRequest{ + Request: mcp.Request{}, + Params: struct { + Name string `json:"name"` + Arguments any `json:"arguments,omitempty"` + Meta *mcp.Meta `json:"_meta,omitempty"` + }{ + Name: toolName, + Arguments: arguments, + }, + } + + var result *mcp.CallToolResult + var err error + + log.Debugf("Calling MCP tool: %s with arguments: %v", toolName, arguments) + + switch toolName { + case "get_questions": + result, err = c.mcpController.MCPQuestionsHandler()(ctx, request) + case "get_answers_by_question_id": + result, err = c.mcpController.MCPAnswersHandler()(ctx, request) + case "get_comments": + result, err = c.mcpController.MCPCommentsHandler()(ctx, request) + case "get_tags": + result, err = c.mcpController.MCPTagsHandler()(ctx, request) + case "get_tag_detail": + result, err = c.mcpController.MCPTagDetailsHandler()(ctx, request) + case "get_user": + result, err = c.mcpController.MCPUserDetailsHandler()(ctx, request) + default: + return "", fmt.Errorf("unknown tool: %s", toolName) + } + + if err != nil { + return "", err + } + + data, _ := json.Marshal(result) + log.Debugf("MCP tool %s called successfully, result: %v", toolName, string(data)) + + if result != nil && len(result.Content) > 0 { + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + return textContent.Text, nil + } + } + + return "No result found", nil +} diff --git a/internal/controller/ai_conversation_controller.go b/internal/controller/ai_conversation_controller.go new file mode 100644 index 00000000..1b8debd7 --- /dev/null +++ b/internal/controller/ai_conversation_controller.go @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/ai_conversation" + "github.com/apache/answer/internal/service/feature_toggle" + "github.com/gin-gonic/gin" +) + +// AIConversationController ai conversation controller +type AIConversationController struct { + aiConversationService ai_conversation.AIConversationService + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewAIConversationController creates a new AI conversation controller +func NewAIConversationController( + aiConversationService ai_conversation.AIConversationService, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *AIConversationController { + return &AIConversationController{ + aiConversationService: aiConversationService, + featureToggleSvc: featureToggleSvc, + } +} + +func (ctrl *AIConversationController) ensureEnabled(ctx *gin.Context) bool { + if ctrl.featureToggleSvc == nil { + return true + } + if err := ctrl.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil { + handler.HandleResponse(ctx, err, nil) + return false + } + return true +} + +// GetConversationList gets conversation list +// @Summary get conversation list +// @Description get conversation list +// @Tags ai-conversation +// @Accept json +// @Produce json +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.AIConversationListItem}} +// @Router /answer/api/v1/ai/conversation/page [get] +func (ctrl *AIConversationController) GetConversationList(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, err := ctrl.aiConversationService.GetConversationList(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// GetConversationDetail gets conversation detail +// @Summary get conversation detail +// @Description get conversation detail +// @Tags ai-conversation +// @Accept json +// @Produce json +// @Param conversation_id query string true "conversation id" +// @Success 200 {object} handler.RespBody{data=schema.AIConversationDetailResp} +// @Router /answer/api/v1/ai/conversation [get] +func (ctrl *AIConversationController) GetConversationDetail(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationDetailReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, _, err := ctrl.aiConversationService.GetConversationDetail(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// VoteRecord vote record +// @Summary vote record +// @Description vote record +// @Tags ai-conversation +// @Accept json +// @Produce json +// @Param data body schema.AIConversationVoteReq true "vote request" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/ai/conversation/vote [post] +func (ctrl *AIConversationController) VoteRecord(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationVoteReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + err := ctrl.aiConversationService.VoteRecord(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index b5c3f91c..c31763be 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -54,4 +54,7 @@ var ProviderSetController = wire.NewSet( NewBadgeController, NewRenderController, NewSidebarController, + NewMCPController, + NewAIController, + NewAIConversationController, ) diff --git a/internal/controller/mcp_controller.go b/internal/controller/mcp_controller.go new file mode 100644 index 00000000..d52f5797 --- /dev/null +++ b/internal/controller/mcp_controller.go @@ -0,0 +1,351 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + answercommon "github.com/apache/answer/internal/service/answer_common" + "github.com/apache/answer/internal/service/comment" + "github.com/apache/answer/internal/service/content" + "github.com/apache/answer/internal/service/feature_toggle" + questioncommon "github.com/apache/answer/internal/service/question_common" + "github.com/apache/answer/internal/service/siteinfo_common" + tagcommonser "github.com/apache/answer/internal/service/tag_common" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/mark3labs/mcp-go/mcp" + "github.com/segmentfault/pacman/log" +) + +type MCPController struct { + searchService *content.SearchService + siteInfoService siteinfo_common.SiteInfoCommonService + tagCommonService *tagcommonser.TagCommonService + questioncommon *questioncommon.QuestionCommon + commentRepo comment.CommentRepo + userCommon *usercommon.UserCommon + answerRepo answercommon.AnswerRepo + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewMCPController new site info controller. +func NewMCPController( + searchService *content.SearchService, + siteInfoService siteinfo_common.SiteInfoCommonService, + tagCommonService *tagcommonser.TagCommonService, + questioncommon *questioncommon.QuestionCommon, + commentRepo comment.CommentRepo, + userCommon *usercommon.UserCommon, + answerRepo answercommon.AnswerRepo, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *MCPController { + return &MCPController{ + searchService: searchService, + siteInfoService: siteInfoService, + tagCommonService: tagCommonService, + questioncommon: questioncommon, + commentRepo: commentRepo, + userCommon: userCommon, + answerRepo: answerRepo, + featureToggleSvc: featureToggleSvc, + } +} + +func (c *MCPController) ensureMCPEnabled(ctx context.Context) error { + if c.featureToggleSvc == nil { + return nil + } + return c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureMCP) +} + +func (c *MCPController) MCPQuestionsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + searchResp, err := c.searchService.Search(ctx, &schema.SearchDTO{ + Query: cond.ToQueryString() + " is:question", + Page: 1, + Size: 5, + Order: "newest", + }) + if err != nil { + return nil, err + } + + resp := make([]*schema.MCPSearchQuestionInfoResp, 0) + for _, question := range searchResp.SearchResults { + t := &schema.MCPSearchQuestionInfoResp{ + QuestionID: question.Object.QuestionID, + Title: question.Object.Title, + Content: question.Object.Excerpt, + Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.Object.QuestionID), + } + resp = append(resp, t) + } + + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchQuestionDetail(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + question, err := c.questioncommon.Info(ctx, cond.QuestionID, "") + if err != nil { + log.Errorf("get question failed: %v", err) + return mcp.NewToolResultText("No question found."), nil + } + + resp := &schema.MCPSearchQuestionInfoResp{ + QuestionID: question.ID, + Title: question.Title, + Content: question.Content, + Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.ID), + } + res, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(res)), nil + } +} + +func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchAnswerCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + if len(cond.QuestionID) > 0 { + answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID}) + if err != nil { + log.Errorf("get answers failed: %v", err) + return nil, err + } + resp := make([]*schema.MCPSearchAnswerInfoResp, 0) + for _, answer := range answerList { + t := &schema.MCPSearchAnswerInfoResp{ + QuestionID: answer.QuestionID, + AnswerID: answer.ID, + AnswerContent: answer.OriginalText, + Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } + + answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID}) + if err != nil { + log.Errorf("get answers failed: %v", err) + return nil, err + } + resp := make([]*schema.MCPSearchAnswerInfoResp, 0) + for _, answer := range answerList { + t := &schema.MCPSearchAnswerInfoResp{ + QuestionID: answer.QuestionID, + AnswerID: answer.ID, + AnswerContent: answer.OriginalText, + Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchCommentCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + dto := &comment.CommentQuery{ + PageCond: pager.PageCond{Page: 1, PageSize: 5}, + QueryCond: "newest", + ObjectID: cond.ObjectID, + } + commentList, total, err := c.commentRepo.GetCommentPage(ctx, dto) + if err != nil { + return nil, err + } + if total == 0 { + return mcp.NewToolResultText("No comments found."), nil + } + + resp := make([]*schema.MCPSearchCommentInfoResp, 0) + for _, comment := range commentList { + t := &schema.MCPSearchCommentInfoResp{ + CommentID: comment.ID, + Content: comment.OriginalText, + ObjectID: comment.ObjectID, + Link: fmt.Sprintf("%s/comments/%s", siteGeneral.SiteUrl, comment.ID), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPTagsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchTagCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + tags, total, err := c.tagCommonService.GetTagPage(ctx, 1, 10, &entity.Tag{DisplayName: cond.TagName}, "newest") + if err != nil { + log.Errorf("get tags failed: %v", err) + return nil, err + } + + if total == 0 { + res := strings.Builder{} + res.WriteString("No tags found.\n") + return mcp.NewToolResultText(res.String()), nil + } + + resp := make([]*schema.MCPSearchTagResp, 0) + for _, tag := range tags { + t := &schema.MCPSearchTagResp{ + TagName: tag.SlugName, + DisplayName: tag.DisplayName, + Description: tag.OriginalText, + Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPTagDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchTagCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + tag, exist, err := c.tagCommonService.GetTagBySlugName(ctx, cond.TagName) + if err != nil { + log.Errorf("get tag failed: %v", err) + return nil, err + } + if !exist { + return mcp.NewToolResultText("Tag not found."), nil + } + + resp := &schema.MCPSearchTagResp{ + TagName: tag.SlugName, + DisplayName: tag.DisplayName, + Description: tag.OriginalText, + Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName), + } + res, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(res)), nil + } +} + +func (c *MCPController) MCPUserDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchUserCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + user, exist, err := c.userCommon.GetUserBasicInfoByUserName(ctx, cond.Username) + if err != nil { + log.Errorf("get user failed: %v", err) + return nil, err + } + if !exist { + return mcp.NewToolResultText("User not found."), nil + } + + resp := &schema.MCPSearchUserInfoResp{ + Username: user.Username, + DisplayName: user.DisplayName, + Avatar: user.Avatar, + Link: fmt.Sprintf("%s/users/%s", siteGeneral.SiteUrl, user.Username), + } + res, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(res)), nil + } +} diff --git a/internal/controller/siteinfo_controller.go b/internal/controller/siteinfo_controller.go index 320c1b47..a5dde023 100644 --- a/internal/controller/siteinfo_controller.go +++ b/internal/controller/siteinfo_controller.go @@ -110,6 +110,13 @@ func (sc *SiteInfoController) GetSiteInfo(ctx *gin.Context) { if security, err := sc.siteInfoService.GetSiteSecurity(ctx); err == nil { resp.Security = security } + if aiConf, err := sc.siteInfoService.GetSiteAI(ctx); err == nil { + resp.AIEnabled = aiConf.Enabled + } + + if mcpConf, err := sc.siteInfoService.GetSiteMCP(ctx); err == nil { + resp.MCPEnabled = mcpConf.Enabled + } handler.HandleResponse(ctx, nil, resp) } diff --git a/internal/controller_admin/ai_conversation_admin_controller.go b/internal/controller_admin/ai_conversation_admin_controller.go new file mode 100644 index 00000000..5802a7a8 --- /dev/null +++ b/internal/controller_admin/ai_conversation_admin_controller.go @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller_admin + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/ai_conversation" + "github.com/apache/answer/internal/service/feature_toggle" + "github.com/gin-gonic/gin" +) + +// AIConversationAdminController ai conversation admin controller +type AIConversationAdminController struct { + aiConversationService ai_conversation.AIConversationService + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewAIConversationAdminController new AI conversation admin controller +func NewAIConversationAdminController( + aiConversationService ai_conversation.AIConversationService, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *AIConversationAdminController { + return &AIConversationAdminController{ + aiConversationService: aiConversationService, + featureToggleSvc: featureToggleSvc, + } +} + +func (ctrl *AIConversationAdminController) ensureEnabled(ctx *gin.Context) bool { + if ctrl.featureToggleSvc == nil { + return true + } + if err := ctrl.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil { + handler.HandleResponse(ctx, err, nil) + return false + } + return true +} + +// GetConversationList gets conversation list +// @Summary get conversation list for admin +// @Description get conversation list for admin +// @Tags ai-conversation-admin +// @Accept json +// @Produce json +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.AIConversationAdminListItem}} +// @Router /answer/admin/api/ai/conversation/page [get] +func (ctrl *AIConversationAdminController) GetConversationList(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationAdminListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, err := ctrl.aiConversationService.GetConversationListForAdmin(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// GetConversationDetail get conversation detail +// @Summary get conversation detail for admin +// @Description get conversation detail for admin +// @Tags ai-conversation-admin +// @Accept json +// @Produce json +// @Param conversation_id query string true "conversation id" +// @Success 200 {object} handler.RespBody{data=schema.AIConversationAdminDetailResp} +// @Router /answer/admin/api/ai/conversation [get] +func (ctrl *AIConversationAdminController) GetConversationDetail(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationAdminDetailReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, err := ctrl.aiConversationService.GetConversationDetailForAdmin(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// DeleteConversation delete conversation +// @Summary delete conversation for admin +// @Description delete conversation and its related records for admin +// @Tags ai-conversation-admin +// @Accept json +// @Produce json +// @Param data body schema.AIConversationAdminDeleteReq true "apikey" +// @Success 200 {object} handler.RespBody +// @Router /answer/admin/api/ai/conversation [delete] +func (ctrl *AIConversationAdminController) DeleteConversation(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationAdminDeleteReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := ctrl.aiConversationService.DeleteConversationForAdmin(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/controller.go b/internal/controller_admin/controller.go index ebf32cbf..de0c7ec8 100644 --- a/internal/controller_admin/controller.go +++ b/internal/controller_admin/controller.go @@ -29,4 +29,6 @@ var ProviderSetController = wire.NewSet( NewRoleController, NewPluginController, NewBadgeController, + NewAdminAPIKeyController, + NewAIConversationAdminController, ) diff --git a/internal/controller_admin/e_api_key_controller.go b/internal/controller_admin/e_api_key_controller.go new file mode 100644 index 00000000..1b32b535 --- /dev/null +++ b/internal/controller_admin/e_api_key_controller.go @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller_admin + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/apikey" + "github.com/gin-gonic/gin" +) + +// AdminAPIKeyController site info controller +type AdminAPIKeyController struct { + apiKeyService *apikey.APIKeyService +} + +// NewAdminAPIKeyController new site info controller +func NewAdminAPIKeyController(apiKeyService *apikey.APIKeyService) *AdminAPIKeyController { + return &AdminAPIKeyController{ + apiKeyService: apiKeyService, + } +} + +// GetAllAPIKeys get all api keys +// @Summary get all api keys +// @Description get all api keys +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.GetAPIKeyResp} +// @Router /answer/admin/api/api-key/all [get] +func (sc *AdminAPIKeyController) GetAllAPIKeys(ctx *gin.Context) { + resp, err := sc.apiKeyService.GetAPIKeyList(ctx, &schema.GetAPIKeyReq{}) + handler.HandleResponse(ctx, err, resp) +} + +// AddAPIKey add apikey +// @Summary add apikey +// @Description add apikey +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.AddAPIKeyReq true "apikey" +// @Success 200 {object} handler.RespBody{data=schema.AddAPIKeyResp} +// @Router /answer/admin/api/api-key [post] +func (sc *AdminAPIKeyController) AddAPIKey(ctx *gin.Context) { + req := &schema.AddAPIKeyReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, err := sc.apiKeyService.AddAPIKey(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateAPIKey update apikey +// @Summary update apikey +// @Description update apikey +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.UpdateAPIKeyReq true "apikey" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/api-key [put] +func (sc *AdminAPIKeyController) UpdateAPIKey(ctx *gin.Context) { + req := &schema.UpdateAPIKeyReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + err := sc.apiKeyService.UpdateAPIKey(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + +// DeleteAPIKey delete apikey +// @Summary delete apikey +// @Description delete apikey +// @Security ApiKeyAuth +// @Tags admin +// @Param data body schema.DeleteAPIKeyReq true "apikey" +// @Produce json +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/api-key [delete] +func (sc *AdminAPIKeyController) DeleteAPIKey(ctx *gin.Context) { + req := &schema.DeleteAPIKeyReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + err := sc.apiKeyService.DeleteAPIKey(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/siteinfo_controller.go b/internal/controller_admin/siteinfo_controller.go index 339d2caa..4575a5d1 100644 --- a/internal/controller_admin/siteinfo_controller.go +++ b/internal/controller_admin/siteinfo_controller.go @@ -585,3 +585,105 @@ func (sc *SiteInfoController) UpdatePrivilegesConfig(ctx *gin.Context) { err := sc.siteInfoService.UpdatePrivilegesConfig(ctx, req) handler.HandleResponse(ctx, err, nil) } + +// GetAIConfig get AI configuration +// @Summary get AI configuration +// @Description get AI configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteAIResp} +// @Router /answer/admin/api/ai-config [get] +func (sc *SiteInfoController) GetAIConfig(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteAI(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateAIConfig update AI configuration +// @Summary update AI configuration +// @Description update AI configuration +// @Security ApiKeyAuth +// @Tags admin +// @Param data body schema.SiteAIReq true "AI config" +// @Produce json +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/ai-config [put] +func (sc *SiteInfoController) UpdateAIConfig(ctx *gin.Context) { + req := &schema.SiteAIReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := sc.siteInfoService.SaveSiteAI(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + +// GetAIProvider get AI provider configuration +// @Summary get AI provider configuration +// @Description get AI provider configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.GetAIProviderResp} +// @Router /answer/admin/api/ai-provider [get] +func (sc *SiteInfoController) GetAIProvider(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetAIProvider(ctx) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, resp) +} + +// RequestAIModels get AI models +// @Summary get AI models +// @Description get AI models +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.GetAIModelResp} +// @Router /answer/admin/api/ai-models [post] +func (sc *SiteInfoController) RequestAIModels(ctx *gin.Context) { + req := &schema.GetAIModelsReq{} + if handler.BindAndCheck(ctx, req) { + return + } + resp, err := sc.siteInfoService.GetAIModels(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, resp) +} + +// GetMCPConfig get MCP configuration +// @Summary get MCP configuration +// @Description get MCP configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteMCPResp} +// @Router /answer/admin/api/mcp-config [get] +func (sc *SiteInfoController) GetMCPConfig(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteMCP(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateMCPConfig update MCP configuration +// @Summary update MCP configuration +// @Description update MCP configuration +// @Security ApiKeyAuth +// @Tags admin +// @Param data body schema.SiteMCPReq true "MCP config" +// @Produce json +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/mcp-config [put] +func (sc *SiteInfoController) UpdateMCPConfig(ctx *gin.Context) { + req := &schema.SiteMCPReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := sc.siteInfoService.SaveSiteMCP(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/entity/ai_conversation.go b/internal/entity/ai_conversation.go new file mode 100644 index 00000000..7a610f7b --- /dev/null +++ b/internal/entity/ai_conversation.go @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import "time" + +// AIConversation AI +type AIConversation struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + ConversationID string `xorm:"not null unique VARCHAR(255) conversation_id"` + Topic string `xorm:"not null MEDIUMTEXT topic"` + UserID string `xorm:"not null default 0 BIGINT(20) user_id"` +} + +// TableName returns the table name +func (AIConversation) TableName() string { + return "ai_conversation" +} diff --git a/internal/entity/ai_conversation_record.go b/internal/entity/ai_conversation_record.go new file mode 100644 index 00000000..14dea347 --- /dev/null +++ b/internal/entity/ai_conversation_record.go @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import "time" + +// AIConversationRecord AI Conversation Record +type AIConversationRecord struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + ConversationID string `xorm:"not null VARCHAR(255) conversation_id"` + ChatCompletionID string `xorm:"not null VARCHAR(255) chat_completion_id"` + Role string `xorm:"not null default '' VARCHAR(128) role"` + Content string `xorm:"not null MEDIUMTEXT content"` + Helpful int `xorm:"not null default 0 INT(11) helpful"` + Unhelpful int `xorm:"not null default 0 INT(11) unhelpful"` +} + +// TableName returns the table name +func (AIConversationRecord) TableName() string { + return "ai_conversation_record" +} diff --git a/internal/entity/api_key_entity.go b/internal/entity/api_key_entity.go new file mode 100644 index 00000000..6d7713fe --- /dev/null +++ b/internal/entity/api_key_entity.go @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import ( + "time" +) + +// APIKey entity +type APIKey struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + LastUsedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP last_used_at"` + Description string `xorm:"not null MEDIUMTEXT description"` + AccessKey string `xorm:"not null unique VARCHAR(255) access_key"` + Scope string `xorm:"not null VARCHAR(255) scope"` + UserID string `xorm:"not null default 0 BIGINT(20) user_id"` + Hidden int `xorm:"not null default 0 INT(11) hidden"` +} + +// TableName category table name +func (c *APIKey) TableName() string { + return "api_key" +} diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 783332df..33453ef1 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -106,6 +106,7 @@ var migrations = []Migration{ NewMigration("v1.7.0", "add optional tags", addOptionalTags, true), NewMigration("v1.7.2", "expand avatar column length", expandAvatarColumnLength, false), NewMigration("v1.8.0", "change admin menu", updateAdminMenuSettings, true), + NewMigration("v1.8.1", "ai feat", aiFeat, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v32.go b/internal/migrations/v32.go new file mode 100644 index 00000000..89b97047 --- /dev/null +++ b/internal/migrations/v32.go @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package migrations + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" +) + +func aiFeat(ctx context.Context, x *xorm.Engine) error { + if err := addAIConversationTables(ctx, x); err != nil { + return fmt.Errorf("add ai conversation tables failed: %w", err) + } + if err := addAPIKey(ctx, x); err != nil { + return fmt.Errorf("add api key failed: %w", err) + } + log.Info("AI feature migration completed successfully") + return nil +} + +func addAIConversationTables(ctx context.Context, x *xorm.Engine) error { + if err := x.Context(ctx).Sync(new(entity.AIConversation)); err != nil { + return fmt.Errorf("sync ai_conversation table failed: %w", err) + } + + if err := x.Context(ctx).Sync(new(entity.AIConversationRecord)); err != nil { + return fmt.Errorf("sync ai_conversation_record table failed: %w", err) + } + + return nil +} + +func addAPIKey(ctx context.Context, x *xorm.Engine) error { + err := x.Context(ctx).Sync(new(entity.APIKey)) + if err != nil { + return err + } + + defaultConfigTable := []*entity.Config{ + {ID: 10000, Key: "ai_config.provider", Value: `[{"default_api_host":"https://api.openai.com","display_name":"OpenAI","name":"openai"},{"default_api_host":"https://generativelanguage.googleapis.com","display_name":"Gemini","name":"gemini"},{"default_api_host":"https://api.anthropic.com","display_name":"Anthropic","name":"anthropic"}]`}, + } + for _, c := range defaultConfigTable { + exist, err := x.Context(ctx).Get(&entity.Config{Key: c.Key}) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + continue + } + if _, err = x.Context(ctx).Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil { + log.Errorf("insert %+v config failed: %s", c, err) + return fmt.Errorf("add config failed: %w", err) + } + } + + aiSiteInfo := &entity.SiteInfo{ + Type: constant.SiteTypeAI, + } + exist, err := x.Context(ctx).Get(aiSiteInfo) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + content := &schema.SiteAIReq{} + _ = json.Unmarshal([]byte(aiSiteInfo.Content), content) + content.PromptConfig = &schema.AIPromptConfig{ + ZhCN: constant.DefaultAIPromptConfigZhCN, + EnUS: constant.DefaultAIPromptConfigEnUS, + } + data, _ := json.Marshal(content) + aiSiteInfo.Content = string(data) + _, err = x.Context(ctx).ID(aiSiteInfo.ID).Cols("content").Update(aiSiteInfo) + if err != nil { + return fmt.Errorf("update site info failed: %w", err) + } + } else { + content := &schema.SiteAIReq{ + PromptConfig: &schema.AIPromptConfig{ + ZhCN: constant.DefaultAIPromptConfigZhCN, + EnUS: constant.DefaultAIPromptConfigEnUS, + }, + } + data, _ := json.Marshal(content) + aiSiteInfo.Content = string(data) + aiSiteInfo.Type = constant.SiteTypeAI + if _, err = x.Context(ctx).Insert(aiSiteInfo); err != nil { + return fmt.Errorf("insert site info failed: %w", err) + } + log.Infof("insert site info %+v", aiSiteInfo) + } + return nil +} diff --git a/internal/repo/ai_conversation/ai_conversation_repo.go b/internal/repo/ai_conversation/ai_conversation_repo.go new file mode 100644 index 00000000..9947eb6c --- /dev/null +++ b/internal/repo/ai_conversation/ai_conversation_repo.go @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ai_conversation + +import ( + "context" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "xorm.io/builder" + "xorm.io/xorm" +) + +// AIConversationRepo +type AIConversationRepo interface { + CreateConversation(ctx context.Context, conversation *entity.AIConversation) error + GetConversation(ctx context.Context, conversationID string) (*entity.AIConversation, bool, error) + UpdateConversation(ctx context.Context, conversation *entity.AIConversation) error + GetConversationsPage(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) + CreateRecord(ctx context.Context, record *entity.AIConversationRecord) error + GetRecordsByConversationID(ctx context.Context, conversationID string) ([]*entity.AIConversationRecord, error) + UpdateRecordVote(ctx context.Context, cond *entity.AIConversationRecord) error + GetRecord(ctx context.Context, recordID int) (*entity.AIConversationRecord, bool, error) + GetRecordByChatCompletionID(ctx context.Context, role, chatCompletionID string) (*entity.AIConversationRecord, bool, error) + GetConversationsForAdmin(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) + GetConversationWithVoteStats(ctx context.Context, conversationID string) (helpful, unhelpful int64, err error) + DeleteConversation(ctx context.Context, conversationID string) error +} + +type aiConversationRepo struct { + data *data.Data +} + +// NewAIConversationRepo new AIConversationRepo +func NewAIConversationRepo(data *data.Data) AIConversationRepo { + return &aiConversationRepo{ + data: data, + } +} + +// CreateConversation creates a conversation +func (r *aiConversationRepo) CreateConversation(ctx context.Context, conversation *entity.AIConversation) error { + _, err := r.data.DB.Context(ctx).Insert(conversation) + if err != nil { + log.Errorf("create ai conversation failed: %v", err) + return err + } + return nil +} + +// GetConversation gets a conversation +func (r *aiConversationRepo) GetConversation(ctx context.Context, conversationID string) (*entity.AIConversation, bool, error) { + conversation := &entity.AIConversation{} + exist, err := r.data.DB.Context(ctx).Where(builder.Eq{"conversation_id": conversationID}).Get(conversation) + if err != nil { + log.Errorf("get ai conversation failed: %v", err) + return nil, false, err + } + return conversation, exist, nil +} + +// UpdateConversation updates a conversation +func (r *aiConversationRepo) UpdateConversation(ctx context.Context, conversation *entity.AIConversation) error { + _, err := r.data.DB.Context(ctx).ID(conversation.ID).Update(conversation) + if err != nil { + log.Errorf("update ai conversation failed: %v", err) + return err + } + return nil +} + +// GetConversationsPage get conversations by user ID +func (r *aiConversationRepo) GetConversationsPage(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) { + list = make([]*entity.AIConversation, 0) + total, err = pager.Help(page, pageSize, &list, cond, r.data.DB.Context(ctx).Desc("id")) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return list, total, err +} + +// CreateRecord creates a conversation record +func (r *aiConversationRepo) CreateRecord(ctx context.Context, record *entity.AIConversationRecord) error { + _, err := r.data.DB.Context(ctx).Insert(record) + if err != nil { + log.Errorf("create ai conversation record failed: %v", err) + return err + } + return nil +} + +// GetRecordsByConversationID get records by conversation ID +func (r *aiConversationRepo) GetRecordsByConversationID(ctx context.Context, conversationID string) ([]*entity.AIConversationRecord, error) { + records := make([]*entity.AIConversationRecord, 0) + err := r.data.DB.Context(ctx). + Where(builder.Eq{"conversation_id": conversationID}). + OrderBy("created_at ASC"). + Find(&records) + if err != nil { + log.Errorf("get ai conversation records failed: %v", err) + return nil, err + } + return records, nil +} + +// UpdateRecordVote update record vote +func (r *aiConversationRepo) UpdateRecordVote(ctx context.Context, cond *entity.AIConversationRecord) (err error) { + _, err = r.data.DB.Context(ctx).ID(cond.ID).MustCols("helpful", "unhelpful").Update(cond) + if err != nil { + log.Errorf("update ai conversation record vote failed: %v", err) + return err + } + return nil +} + +// GetRecord get record +func (r *aiConversationRepo) GetRecord(ctx context.Context, recordID int) (*entity.AIConversationRecord, bool, error) { + record := &entity.AIConversationRecord{} + exist, err := r.data.DB.Context(ctx).ID(recordID).Get(record) + if err != nil { + log.Errorf("get ai conversation record failed: %v", err) + return nil, false, err + } + return record, exist, nil +} + +// GetRecordByChatCompletionID gets record by chat completion ID +func (r *aiConversationRepo) GetRecordByChatCompletionID(ctx context.Context, role, chatCompletionID string) (*entity.AIConversationRecord, bool, error) { + record := &entity.AIConversationRecord{} + exist, err := r.data.DB.Context(ctx).Where(builder.Eq{"role": role}). + Where(builder.Eq{"chat_completion_id": chatCompletionID}).Get(record) + if err != nil { + log.Errorf("get ai conversation record by chat completion id failed: %v", err) + return nil, false, err + } + return record, exist, nil +} + +// GetConversationsForAdmin gets conversation list for admin +func (r *aiConversationRepo) GetConversationsForAdmin(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) { + list = make([]*entity.AIConversation, 0) + total, err = pager.Help(page, pageSize, &list, cond, r.data.DB.Context(ctx).Desc("id")) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return list, total, err +} + +// GetConversationWithVoteStats gets conversation vote statistics +func (r *aiConversationRepo) GetConversationWithVoteStats(ctx context.Context, conversationID string) (helpful, unhelpful int64, err error) { + res, err := r.data.DB.Context(ctx).SumsInt(&entity.AIConversationRecord{ConversationID: conversationID}, "helpful", "unhelpful") + if err != nil { + log.Errorf("get ai conversation vote stats failed: %v", err) + return 0, 0, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if len(res) < 2 { + log.Errorf("get ai conversation vote stats failed: invalid result length %d", len(res)) + return 0, 0, nil + } + return res[0], res[1], nil +} + +// DeleteConversation deletes a conversation and its related records +func (r *aiConversationRepo) DeleteConversation(ctx context.Context, conversationID string) error { + _, err := r.data.DB.Transaction(func(session *xorm.Session) (result any, err error) { + if _, err := session.Context(ctx).Where("conversation_id = ?", conversationID).Delete(&entity.AIConversationRecord{}); err != nil { + log.Errorf("delete ai conversation records failed: %v", err) + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + if _, err := session.Context(ctx).Where("conversation_id = ?", conversationID).Delete(&entity.AIConversation{}); err != nil { + log.Errorf("delete ai conversation failed: %v", err) + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + return nil, nil + }) + + if err != nil { + return err + } + + return nil +} diff --git a/internal/repo/api_key/api_key_repo.go b/internal/repo/api_key/api_key_repo.go new file mode 100644 index 00000000..2309384a --- /dev/null +++ b/internal/repo/api_key/api_key_repo.go @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package api_key + +import ( + "context" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/apikey" + "github.com/segmentfault/pacman/errors" +) + +type apiKeyRepo struct { + data *data.Data +} + +// NewAPIKeyRepo creates a new apiKey repository +func NewAPIKeyRepo(data *data.Data) apikey.APIKeyRepo { + return &apiKeyRepo{ + data: data, + } +} + +func (ar *apiKeyRepo) GetAPIKeyList(ctx context.Context) (keys []*entity.APIKey, err error) { + keys = make([]*entity.APIKey, 0) + err = ar.data.DB.Context(ctx).Where("hidden = ?", 0).Find(&keys) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) GetAPIKey(ctx context.Context, apiKey string) (key *entity.APIKey, exist bool, err error) { + key = &entity.APIKey{} + exist, err = ar.data.DB.Context(ctx).Where("access_key = ?", apiKey).Get(key) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) UpdateAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) { + _, err = ar.data.DB.Context(ctx).ID(apiKey.ID).Update(&apiKey) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) AddAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) { + _, err = ar.data.DB.Context(ctx).Insert(&apiKey) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) DeleteAPIKey(ctx context.Context, id int) (err error) { + _, err = ar.data.DB.Context(ctx).ID(id).Delete(&entity.APIKey{}) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 02f27f62..510a94aa 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -23,7 +23,9 @@ import ( "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" + "github.com/apache/answer/internal/repo/api_key" "github.com/apache/answer/internal/repo/auth" "github.com/apache/answer/internal/repo/badge" "github.com/apache/answer/internal/repo/badge_award" @@ -109,4 +111,6 @@ var ProviderSetRepo = wire.NewSet( badge_group.NewBadgeGroupRepo, badge_award.NewBadgeAwardRepo, file_record.NewFileRecordRepo, + api_key.NewAPIKeyRepo, + ai_conversation.NewAIConversationRepo, ) diff --git a/internal/repo/site_info/siteinfo_repo.go b/internal/repo/site_info/siteinfo_repo.go index 5f95b748..379a59c9 100644 --- a/internal/repo/site_info/siteinfo_repo.go +++ b/internal/repo/site_info/siteinfo_repo.go @@ -63,10 +63,12 @@ func (sr *siteInfoRepo) SaveByType(ctx context.Context, siteType string, data *e } // GetByType get site info by type -func (sr *siteInfoRepo) GetByType(ctx context.Context, siteType string) (siteInfo *entity.SiteInfo, exist bool, err error) { - siteInfo = sr.getCache(ctx, siteType) - if siteInfo != nil { - return siteInfo, true, nil +func (sr *siteInfoRepo) GetByType(ctx context.Context, siteType string, withoutCache ...bool) (siteInfo *entity.SiteInfo, exist bool, err error) { + if len(withoutCache) == 0 { + siteInfo = sr.getCache(ctx, siteType) + if siteInfo != nil { + return siteInfo, true, nil + } } siteInfo = &entity.SiteInfo{} exist, err = sr.data.DB.Context(ctx).Where(builder.Eq{"type": siteType}).Get(siteInfo) diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 1fe8bdb1..fc53ec45 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -27,36 +27,40 @@ import ( ) type AnswerAPIRouter struct { - langController *controller.LangController - userController *controller.UserController - commentController *controller.CommentController - reportController *controller.ReportController - voteController *controller.VoteController - tagController *controller.TagController - followController *controller.FollowController - collectionController *controller.CollectionController - questionController *controller.QuestionController - answerController *controller.AnswerController - searchController *controller.SearchController - revisionController *controller.RevisionController - rankController *controller.RankController - adminUserController *controller_admin.UserAdminController - reasonController *controller.ReasonController - themeController *controller_admin.ThemeController - adminSiteInfoController *controller_admin.SiteInfoController - siteInfoController *controller.SiteInfoController - notificationController *controller.NotificationController - dashboardController *controller.DashboardController - uploadController *controller.UploadController - activityController *controller.ActivityController - roleController *controller_admin.RoleController - pluginController *controller_admin.PluginController - permissionController *controller.PermissionController - userPluginController *controller.UserPluginController - reviewController *controller.ReviewController - metaController *controller.MetaController - badgeController *controller.BadgeController - adminBadgeController *controller_admin.BadgeController + langController *controller.LangController + userController *controller.UserController + commentController *controller.CommentController + reportController *controller.ReportController + voteController *controller.VoteController + tagController *controller.TagController + followController *controller.FollowController + collectionController *controller.CollectionController + questionController *controller.QuestionController + answerController *controller.AnswerController + searchController *controller.SearchController + revisionController *controller.RevisionController + rankController *controller.RankController + adminUserController *controller_admin.UserAdminController + reasonController *controller.ReasonController + themeController *controller_admin.ThemeController + adminSiteInfoController *controller_admin.SiteInfoController + siteInfoController *controller.SiteInfoController + notificationController *controller.NotificationController + dashboardController *controller.DashboardController + uploadController *controller.UploadController + activityController *controller.ActivityController + roleController *controller_admin.RoleController + pluginController *controller_admin.PluginController + permissionController *controller.PermissionController + userPluginController *controller.UserPluginController + reviewController *controller.ReviewController + metaController *controller.MetaController + badgeController *controller.BadgeController + adminBadgeController *controller_admin.BadgeController + apiKeyController *controller_admin.AdminAPIKeyController + aiController *controller.AIController + aiConversationController *controller.AIConversationController + aiConversationAdminController *controller_admin.AIConversationAdminController } func NewAnswerAPIRouter( @@ -90,38 +94,46 @@ func NewAnswerAPIRouter( metaController *controller.MetaController, badgeController *controller.BadgeController, adminBadgeController *controller_admin.BadgeController, + apiKeyController *controller_admin.AdminAPIKeyController, + aiController *controller.AIController, + aiConversationController *controller.AIConversationController, + aiConversationAdminController *controller_admin.AIConversationAdminController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ - langController: langController, - userController: userController, - commentController: commentController, - reportController: reportController, - voteController: voteController, - tagController: tagController, - followController: followController, - collectionController: collectionController, - questionController: questionController, - answerController: answerController, - searchController: searchController, - revisionController: revisionController, - rankController: rankController, - adminUserController: adminUserController, - reasonController: reasonController, - themeController: themeController, - adminSiteInfoController: adminSiteInfoController, - notificationController: notificationController, - siteInfoController: siteInfoController, - dashboardController: dashboardController, - uploadController: uploadController, - activityController: activityController, - roleController: roleController, - pluginController: pluginController, - permissionController: permissionController, - userPluginController: userPluginController, - reviewController: reviewController, - metaController: metaController, - badgeController: badgeController, - adminBadgeController: adminBadgeController, + langController: langController, + userController: userController, + commentController: commentController, + reportController: reportController, + voteController: voteController, + tagController: tagController, + followController: followController, + collectionController: collectionController, + questionController: questionController, + answerController: answerController, + searchController: searchController, + revisionController: revisionController, + rankController: rankController, + adminUserController: adminUserController, + reasonController: reasonController, + themeController: themeController, + adminSiteInfoController: adminSiteInfoController, + notificationController: notificationController, + siteInfoController: siteInfoController, + dashboardController: dashboardController, + uploadController: uploadController, + activityController: activityController, + roleController: roleController, + pluginController: pluginController, + permissionController: permissionController, + userPluginController: userPluginController, + reviewController: reviewController, + metaController: metaController, + badgeController: badgeController, + adminBadgeController: adminBadgeController, + apiKeyController: apiKeyController, + aiController: aiController, + aiConversationController: aiConversationController, + aiConversationAdminController: aiConversationAdminController, } } @@ -310,6 +322,14 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { // meta r.PUT("/meta/reaction", a.metaController.AddOrUpdateReaction) + + // AI chat + r.POST("/chat/completions", a.aiController.ChatCompletions) + + // AI conversation + r.GET("/ai/conversation/page", a.aiConversationController.GetConversationList) + r.GET("/ai/conversation", a.aiConversationController.GetConversationDetail) + r.POST("/ai/conversation/vote", a.aiConversationController.VoteRecord) } func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { @@ -394,4 +414,25 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { // badge r.GET("/badges", a.adminBadgeController.GetBadgeList) r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus) + + // api key + r.GET("/api-key/all", a.apiKeyController.GetAllAPIKeys) + r.POST("/api-key", a.apiKeyController.AddAPIKey) + r.PUT("/api-key", a.apiKeyController.UpdateAPIKey) + r.DELETE("/api-key", a.apiKeyController.DeleteAPIKey) + + // ai config + r.GET("/ai-config", a.adminSiteInfoController.GetAIConfig) + r.PUT("/ai-config", a.adminSiteInfoController.UpdateAIConfig) + r.GET("/ai-provider", a.adminSiteInfoController.GetAIProvider) + r.POST("/ai-models", a.adminSiteInfoController.RequestAIModels) + + // mcp config + r.GET("/mcp-config", a.adminSiteInfoController.GetMCPConfig) + r.PUT("/mcp-config", a.adminSiteInfoController.UpdateMCPConfig) + + // AI conversation management + r.GET("/ai/conversation/page", a.aiConversationAdminController.GetConversationList) + r.GET("/ai/conversation", a.aiConversationAdminController.GetConversationDetail) + r.DELETE("/ai/conversation", a.aiConversationAdminController.DeleteConversation) } diff --git a/internal/schema/ai_config_schema.go b/internal/schema/ai_config_schema.go new file mode 100644 index 00000000..6ac68634 --- /dev/null +++ b/internal/schema/ai_config_schema.go @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +// GetAIProviderResp get AI providers response +type GetAIProviderResp struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + DefaultAPIHost string `json:"default_api_host"` +} + +// GetAIModelsResp get AI model response +type GetAIModelsResp struct { + Object string `json:"object"` + Data []struct { + Id string `json:"id"` + Object string `json:"object"` + Created int `json:"created"` + OwnedBy string `json:"owned_by"` + } `json:"data"` +} + +type GetAIModelsReq struct { + APIHost string `json:"api_host"` + APIKey string `json:"api_key"` +} + +// GetAIModelResp get AI model response +type GetAIModelResp struct { + Id string `json:"id"` + Object string `json:"object"` + Created int `json:"created"` + OwnedBy string `json:"owned_by"` +} diff --git a/internal/schema/ai_conversation_schema.go b/internal/schema/ai_conversation_schema.go new file mode 100644 index 00000000..fd34278a --- /dev/null +++ b/internal/schema/ai_conversation_schema.go @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +import ( + "github.com/apache/answer/internal/base/validator" +) + +// AIConversationListReq ai conversation list req +type AIConversationListReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1" form:"page_size"` + UserID string `validate:"omitempty" json:"-"` +} + +// AIConversationListItem ai conversation list item +type AIConversationListItem struct { + ConversationID string `json:"conversation_id"` + Topic string `json:"topic"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationDetailReq ai conversation detail req +type AIConversationDetailReq struct { + ConversationID string `validate:"required" form:"conversation_id" json:"conversation_id"` + UserID string `validate:"omitempty" json:"-"` +} + +// AIConversationRecord ai conversation record +type AIConversationRecord struct { + ChatCompletionID string `json:"chat_completion_id"` + Role string `json:"role"` + Content string `json:"content"` + Helpful int `json:"helpful"` + Unhelpful int `json:"unhelpful"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationDetailResp ai conversation detail resp +type AIConversationDetailResp struct { + ConversationID string `json:"conversation_id"` + Topic string `json:"topic"` + Records []*AIConversationRecord `json:"records"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// AIConversationVoteReq ai conversation vote req +type AIConversationVoteReq struct { + ChatCompletionID string `validate:"required" json:"chat_completion_id"` + VoteType string `validate:"required,oneof=helpful unhelpful" json:"vote_type"` + Cancel bool `validate:"omitempty" json:"cancel"` + UserID string `validate:"omitempty" json:"-"` +} + +// AIConversationAdminListReq ai conversation admin list req +type AIConversationAdminListReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1" form:"page_size"` +} + +// AIConversationAdminListItem ai conversation admin list item +type AIConversationAdminListItem struct { + ID string `json:"id"` + Topic string `json:"topic"` + UserInfo AIConversationUserInfo `json:"user_info"` + HelpfulCount int64 `json:"helpful_count"` + UnhelpfulCount int64 `json:"unhelpful_count"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationUserInfo ai conversation user info +type AIConversationUserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Rank int `json:"rank"` +} + +// AIConversationAdminDetailReq ai conversation admin detail req +type AIConversationAdminDetailReq struct { + ConversationID string `validate:"required" form:"conversation_id" json:"conversation_id"` +} + +// AIConversationAdminDetailResp ai conversation admin detail resp +type AIConversationAdminDetailResp struct { + ConversationID string `json:"conversation_id"` + Topic string `json:"topic"` + UserInfo AIConversationUserInfo `json:"user_info"` + Records []AIConversationRecord `json:"records"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationAdminDeleteReq admin delete ai +type AIConversationAdminDeleteReq struct { + ConversationID string `validate:"required" json:"conversation_id"` +} + +func (req *AIConversationDetailReq) Check() (errFields []*validator.FormErrorField, err error) { + return nil, nil +} + +func (req *AIConversationVoteReq) Check() (errFields []*validator.FormErrorField, err error) { + return nil, nil +} diff --git a/internal/schema/api_key_schema.go b/internal/schema/api_key_schema.go new file mode 100644 index 00000000..e9dde784 --- /dev/null +++ b/internal/schema/api_key_schema.go @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +// GetAPIKeyReq get api key request +type GetAPIKeyReq struct { + UserID string `json:"-"` +} + +// GetAPIKeyResp get api keys response +type GetAPIKeyResp struct { + ID int `json:"id"` + AccessKey string `json:"access_key"` + Description string `json:"description"` + Scope string `json:"scope"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` +} + +// AddAPIKeyReq add api key request +type AddAPIKeyReq struct { + Description string `validate:"required,notblank,lte=150" json:"description"` + Scope string `validate:"required,oneof=read-only global" json:"scope"` + UserID string `json:"-"` +} + +// AddAPIKeyResp add api key response +type AddAPIKeyResp struct { + AccessKey string `json:"access_key"` +} + +// UpdateAPIKeyReq update api key request +type UpdateAPIKeyReq struct { + ID int `validate:"required" json:"id"` + Description string `validate:"required,notblank,lte=150" json:"description"` + UserID string `json:"-"` +} + +// DeleteAPIKeyReq delete api key request +type DeleteAPIKeyReq struct { + ID int `json:"id"` + UserID string `json:"-"` +} diff --git a/internal/schema/mcp_schema.go b/internal/schema/mcp_schema.go new file mode 100644 index 00000000..bead21c9 --- /dev/null +++ b/internal/schema/mcp_schema.go @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +import ( + "strings" + + "github.com/apache/answer/pkg/converter" + "github.com/mark3labs/mcp-go/mcp" +) + +const ( + MCPSearchCondKeyword = "keyword" + MCPSearchCondUsername = "username" + MCPSearchCondScore = "score" + MCPSearchCondTag = "tag" + MCPSearchCondPage = "page" + MCPSearchCondPageSize = "page_size" + MCPSearchCondTagName = "tag_name" + MCPSearchCondQuestionID = "question_id" + MCPSearchCondObjectID = "object_id" +) + +type MCPSearchCond struct { + Keyword string `json:"keyword"` + Username string `json:"username"` + Score int `json:"score"` + Tags []string `json:"tags"` + QuestionID string `json:"question_id"` +} + +type MCPSearchQuestionDetail struct { + QuestionID string `json:"question_id"` +} + +type MCPSearchCommentCond struct { + ObjectID string `json:"object_id"` +} + +type MCPSearchTagCond struct { + TagName string `json:"tag_name"` +} + +type MCPSearchUserCond struct { + Username string `json:"username"` +} + +type MCPSearchQuestionInfoResp struct { + QuestionID string `json:"question_id"` + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` +} + +type MCPSearchAnswerInfoResp struct { + QuestionID string `json:"question_id"` + QuestionTitle string `json:"question_title,omitempty"` + AnswerID string `json:"answer_id"` + AnswerContent string `json:"answer_content"` + Link string `json:"link"` +} + +type MCPSearchTagResp struct { + TagName string `json:"tag_name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Link string `json:"link"` +} + +type MCPSearchUserInfoResp struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Link string `json:"link"` +} + +type MCPSearchCommentInfoResp struct { + CommentID string `json:"comment_id"` + Content string `json:"content"` + ObjectID string `json:"object_id"` + Link string `json:"link"` +} + +func NewMCPSearchCond(request mcp.CallToolRequest) *MCPSearchCond { + cond := &MCPSearchCond{} + if keyword, ok := getRequestValue(request, MCPSearchCondKeyword); ok { + cond.Keyword = keyword + } + if username, ok := getRequestValue(request, MCPSearchCondUsername); ok { + cond.Username = username + } + if score, ok := getRequestNumber(request, MCPSearchCondScore); ok { + cond.Score = score + } + if tag, ok := getRequestValue(request, MCPSearchCondTag); ok { + cond.Tags = strings.Split(tag, ",") + } + if questionID, ok := getRequestValue(request, MCPSearchCondQuestionID); ok { + cond.QuestionID = questionID + } + return cond +} + +func NewMCPSearchAnswerCond(request mcp.CallToolRequest) *MCPSearchCond { + cond := &MCPSearchCond{} + if questionID, ok := getRequestValue(request, MCPSearchCondQuestionID); ok { + cond.QuestionID = questionID + } + return cond +} + +func NewMCPSearchQuestionDetail(request mcp.CallToolRequest) *MCPSearchQuestionDetail { + cond := &MCPSearchQuestionDetail{} + if questionID, ok := getRequestValue(request, MCPSearchCondQuestionID); ok { + cond.QuestionID = questionID + } + return cond +} + +func NewMCPSearchCommentCond(request mcp.CallToolRequest) *MCPSearchCommentCond { + cond := &MCPSearchCommentCond{} + if keyword, ok := getRequestValue(request, MCPSearchCondObjectID); ok { + cond.ObjectID = keyword + } + return cond +} + +func NewMCPSearchTagCond(request mcp.CallToolRequest) *MCPSearchTagCond { + cond := &MCPSearchTagCond{} + if tagName, ok := getRequestValue(request, MCPSearchCondTagName); ok { + cond.TagName = tagName + } + return cond +} + +func NewMCPSearchUserCond(request mcp.CallToolRequest) *MCPSearchUserCond { + cond := &MCPSearchUserCond{} + if username, ok := getRequestValue(request, MCPSearchCondUsername); ok { + cond.Username = username + } + return cond +} + +func getRequestValue(request mcp.CallToolRequest, key string) (string, bool) { + value, ok := request.GetArguments()[key].(string) + if !ok { + return "", false + } + return value, true +} + +func getRequestNumber(request mcp.CallToolRequest, key string) (int, bool) { + value, ok := request.GetArguments()[key].(float64) + if !ok { + return 0, false + } + return int(value), true +} + +func (cond *MCPSearchCond) ToQueryString() string { + var queryBuilder strings.Builder + if len(cond.Keyword) > 0 { + queryBuilder.WriteString(cond.Keyword) + } + if len(cond.Username) > 0 { + queryBuilder.WriteString(" user:" + cond.Username) + } + if cond.Score > 0 { + queryBuilder.WriteString(" score:" + converter.IntToString(int64(cond.Score))) + } + if len(cond.Tags) > 0 { + for _, tag := range cond.Tags { + queryBuilder.WriteString(" [" + tag + "]") + } + } + return strings.TrimSpace(queryBuilder.String()) +} diff --git a/internal/schema/mcp_tools/mcp_tools.go b/internal/schema/mcp_tools/mcp_tools.go new file mode 100644 index 00000000..949a738c --- /dev/null +++ b/internal/schema/mcp_tools/mcp_tools.go @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcp_tools + +import ( + "github.com/apache/answer/internal/schema" + "github.com/mark3labs/mcp-go/mcp" +) + +var ( + MCPToolsList = []mcp.Tool{ + NewQuestionsTool(), + NewAnswersTool(), + NewCommentsTool(), + NewTagsTool(), + NewTagDetailTool(), + NewUserTool(), + } +) + +func NewQuestionsTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_questions", + mcp.WithDescription("Searching for questions that already existed in the system. After the search, you can use the get_answers_by_question_id tool to get answers for the questions."), + mcp.WithString(schema.MCPSearchCondKeyword, + mcp.Description("Keyword to search for questions. Multiple keywords separated by spaces"), + ), + mcp.WithString(schema.MCPSearchCondUsername, + mcp.Description("Search for questions that contain only those created by the specified user"), + ), + mcp.WithString(schema.MCPSearchCondTag, + mcp.Description("Filter by tag (semicolon separated for multiple tags)"), + ), + mcp.WithString(schema.MCPSearchCondScore, + mcp.Description("Minimum score that the question must have"), + ), + ) + return listFilesTool +} + +func NewAnswersTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_answers_by_question_id", + mcp.WithDescription("Search for all answers corresponding to the question ID. The question ID is provided by get_questions tool."), + mcp.WithString(schema.MCPSearchCondQuestionID, + mcp.Description("The ID of the question to which the answer belongs. The question ID is provided by get_questions tool."), + ), + ) + return listFilesTool +} + +func NewCommentsTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_comments", + mcp.WithDescription("Searching for comments that already existed in the system"), + mcp.WithString(schema.MCPSearchCondObjectID, + mcp.Description("Queries comments on an object, either a question or an answer. object_id is the id of the object."), + ), + ) + return listFilesTool +} + +func NewTagsTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_tags", + mcp.WithDescription("Searching for tags that already existed in the system"), + mcp.WithString(schema.MCPSearchCondTagName, + mcp.Description("Tag name"), + ), + ) + return listFilesTool +} + +func NewTagDetailTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_tag_detail", + mcp.WithDescription("Get detailed information about a specific tag"), + mcp.WithString(schema.MCPSearchCondTagName, + mcp.Description("Tag name"), + ), + ) + return listFilesTool +} + +func NewUserTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_user", + mcp.WithDescription("Searching for users that already existed in the system"), + mcp.WithString(schema.MCPSearchCondUsername, + mcp.Description("Username"), + ), + ) + return listFilesTool +} diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index bf1c7713..3338e80a 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -253,6 +253,56 @@ func (s *SiteSeoResp) IsShortLink() bool { s.Permalink == constant.PermalinkQuestionIDByShortID } +// AIPromptConfig AI prompt configuration for different languages +type AIPromptConfig struct { + ZhCN string `json:"zh_cn"` + EnUS string `json:"en_us"` +} + +// SiteAIReq AI configuration request +type SiteAIReq struct { + Enabled bool `validate:"omitempty" form:"enabled" json:"enabled"` + ChosenProvider string `validate:"omitempty,lte=50" form:"chosen_provider" json:"chosen_provider"` + SiteAIProviders []*SiteAIProvider `validate:"omitempty,dive" form:"ai_providers" json:"ai_providers"` + PromptConfig *AIPromptConfig `validate:"omitempty" form:"prompt_config" json:"prompt_config,omitempty"` +} + +func (s *SiteAIResp) GetProvider() *SiteAIProvider { + if !s.Enabled || s.ChosenProvider == "" { + return &SiteAIProvider{} + } + if len(s.SiteAIProviders) == 0 { + return &SiteAIProvider{} + } + for _, provider := range s.SiteAIProviders { + if provider.Provider == s.ChosenProvider { + return provider + } + } + return &SiteAIProvider{} +} + +type SiteAIProvider struct { + Provider string `validate:"omitempty,lte=50" form:"provider" json:"provider"` + APIHost string `validate:"omitempty,lte=512" form:"api_host" json:"api_host"` + APIKey string `validate:"omitempty,lte=256" form:"api_key" json:"api_key"` + Model string `validate:"omitempty,lte=100" form:"model" json:"model"` +} + +// SiteAIResp AI configuration response +type SiteAIResp SiteAIReq + +type SiteMCPReq struct { + Enabled bool `validate:"omitempty" form:"enabled" json:"enabled"` +} + +type SiteMCPResp struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + URL string `json:"url"` + HTTPHeader string `json:"http_header"` +} + // SiteGeneralResp site general response type SiteGeneralResp SiteGeneralReq @@ -331,6 +381,8 @@ type SiteInfoResp struct { Security *SiteSecurityResp `json:"site_security"` Version string `json:"version"` Revision string `json:"revision"` + AIEnabled bool `json:"ai_enabled"` + MCPEnabled bool `json:"mcp_enabled"` } type TemplateSiteInfoResp struct { diff --git a/internal/service/ai_conversation/ai_conversation_service.go b/internal/service/ai_conversation/ai_conversation_service.go new file mode 100644 index 00000000..d095ac0e --- /dev/null +++ b/internal/service/ai_conversation/ai_conversation_service.go @@ -0,0 +1,372 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ai_conversation + +import ( + "context" + "strings" + "time" + + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/repo/ai_conversation" + "github.com/apache/answer/internal/schema" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +// AIConversationService +type AIConversationService interface { + CreateConversation(ctx context.Context, userID, conversationID, topic string) error + SaveConversationRecords(ctx context.Context, conversationID, chatcmplID string, records []*ConversationMessage) error + GetConversationList(ctx context.Context, req *schema.AIConversationListReq) (*pager.PageModel, error) + GetConversationDetail(ctx context.Context, req *schema.AIConversationDetailReq) (resp *schema.AIConversationDetailResp, exist bool, err error) + VoteRecord(ctx context.Context, req *schema.AIConversationVoteReq) error + GetConversationListForAdmin(ctx context.Context, req *schema.AIConversationAdminListReq) (*pager.PageModel, error) + GetConversationDetailForAdmin(ctx context.Context, req *schema.AIConversationAdminDetailReq) (*schema.AIConversationAdminDetailResp, error) + DeleteConversationForAdmin(ctx context.Context, req *schema.AIConversationAdminDeleteReq) error +} + +// ConversationMessage +type ConversationMessage struct { + ChatCompletionID string `json:"chat_completion_id"` + Role string `json:"role"` + Content string `json:"content"` +} + +// aiConversationService +type aiConversationService struct { + aiConversationRepo ai_conversation.AIConversationRepo + userCommon *usercommon.UserCommon +} + +// NewAIConversationService +func NewAIConversationService( + aiConversationRepo ai_conversation.AIConversationRepo, + userCommon *usercommon.UserCommon, +) AIConversationService { + return &aiConversationService{ + aiConversationRepo: aiConversationRepo, + userCommon: userCommon, + } +} + +// CreateConversation +func (s *aiConversationService) CreateConversation(ctx context.Context, userID, conversationID, topic string) error { + conversation := &entity.AIConversation{ + ConversationID: conversationID, + Topic: topic, + UserID: userID, + } + err := s.aiConversationRepo.CreateConversation(ctx, conversation) + if err != nil { + log.Errorf("create conversation failed: %v", err) + return err + } + + return nil +} + +// SaveConversationRecords +func (s *aiConversationService) SaveConversationRecords(ctx context.Context, conversationID, chatcmplID string, records []*ConversationMessage) error { + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, conversationID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + content := strings.Builder{} + + for _, record := range records { + if len(record.ChatCompletionID) > 0 { + continue + } + if record.Role == "user" { + aiRecord := &entity.AIConversationRecord{ + ConversationID: conversationID, + ChatCompletionID: chatcmplID, + Role: "user", + Content: record.Content, + } + + err = s.aiConversationRepo.CreateRecord(ctx, aiRecord) + if err != nil { + log.Errorf("create conversation record failed: %v", err) + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + continue + } + + content.WriteString(record.Content) + content.WriteString("\n") + } + aiRecord := &entity.AIConversationRecord{ + ConversationID: conversationID, + ChatCompletionID: chatcmplID, + Role: "assistant", + Content: content.String(), + Helpful: 0, + Unhelpful: 0, + } + + err = s.aiConversationRepo.CreateRecord(ctx, aiRecord) + if err != nil { + log.Errorf("create conversation record failed: %v", err) + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + + conversation.UpdatedAt = time.Now() + err = s.aiConversationRepo.UpdateConversation(ctx, conversation) + if err != nil { + log.Errorf("update conversation failed: %v", err) + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + + return nil +} + +// GetConversationList +func (s *aiConversationService) GetConversationList(ctx context.Context, req *schema.AIConversationListReq) (*pager.PageModel, error) { + conversations, total, err := s.aiConversationRepo.GetConversationsPage(ctx, req.Page, req.PageSize, &entity.AIConversation{UserID: req.UserID}) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + list := make([]schema.AIConversationListItem, 0, len(conversations)) + for _, conversation := range conversations { + list = append(list, schema.AIConversationListItem{ + ConversationID: conversation.ConversationID, + CreatedAt: conversation.CreatedAt.Unix(), + Topic: conversation.Topic, + }) + } + + return pager.NewPageModel(total, list), nil +} + +// GetConversationDetail +func (s *aiConversationService) GetConversationDetail(ctx context.Context, req *schema.AIConversationDetailReq) ( + resp *schema.AIConversationDetailResp, exist bool, err error) { + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, req.ConversationID) + if err != nil { + return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist || conversation.UserID != req.UserID { + return nil, false, nil + } + + records, err := s.aiConversationRepo.GetRecordsByConversationID(ctx, req.ConversationID) + if err != nil { + return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + recordList := make([]*schema.AIConversationRecord, 0, len(records)) + for i, record := range records { + if i == 0 { + record.Content = conversation.Topic + } + recordList = append(recordList, &schema.AIConversationRecord{ + ChatCompletionID: record.ChatCompletionID, + Role: record.Role, + Content: record.Content, + Helpful: record.Helpful, + Unhelpful: record.Unhelpful, + CreatedAt: record.CreatedAt.Unix(), + }) + } + + return &schema.AIConversationDetailResp{ + ConversationID: conversation.ConversationID, + Topic: conversation.Topic, + Records: recordList, + CreatedAt: conversation.CreatedAt.Unix(), + UpdatedAt: conversation.UpdatedAt.Unix(), + }, true, nil +} + +// VoteRecord +func (s *aiConversationService) VoteRecord(ctx context.Context, req *schema.AIConversationVoteReq) error { + record, exist, err := s.aiConversationRepo.GetRecordByChatCompletionID(ctx, "assistant", req.ChatCompletionID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, record.ConversationID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + if conversation.UserID != req.UserID { + return errors.Forbidden(reason.UnauthorizedError) + } + + if record.Role != "assistant" { + return errors.BadRequest("Only AI responses can be voted") + } + + if req.VoteType == "helpful" { + if req.Cancel { + record.Helpful = 0 + } else { + record.Helpful = 1 + record.Unhelpful = 0 + } + } else { + if req.Cancel { + record.Unhelpful = 0 + } else { + record.Unhelpful = 1 + record.Helpful = 0 + } + } + + err = s.aiConversationRepo.UpdateRecordVote(ctx, record) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + + return nil +} + +// GetConversationListForAdmin +func (s *aiConversationService) GetConversationListForAdmin( + ctx context.Context, req *schema.AIConversationAdminListReq) (*pager.PageModel, error) { + conversations, total, err := s.aiConversationRepo.GetConversationsForAdmin(ctx, req.Page, req.PageSize, &entity.AIConversation{}) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + list := make([]*schema.AIConversationAdminListItem, 0, len(conversations)) + for _, conversation := range conversations { + userInfo, err := s.getUserInfo(ctx, conversation.UserID) + if err != nil { + log.Errorf("get user info failed for user %s: %v", conversation.UserID, err) + continue + } + + helpful, unhelpful, err := s.aiConversationRepo.GetConversationWithVoteStats(ctx, conversation.ConversationID) + if err != nil { + log.Errorf("get conversation vote stats failed for conversation %s: %v", conversation.ConversationID, err) + continue + } + + list = append(list, &schema.AIConversationAdminListItem{ + ID: conversation.ConversationID, + Topic: conversation.Topic, + UserInfo: userInfo, + HelpfulCount: helpful, + UnhelpfulCount: unhelpful, + CreatedAt: conversation.CreatedAt.Unix(), + }) + } + + return pager.NewPageModel(total, list), nil +} + +// GetConversationDetailForAdmin +func (s *aiConversationService) GetConversationDetailForAdmin(ctx context.Context, req *schema.AIConversationAdminDetailReq) (*schema.AIConversationAdminDetailResp, error) { + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, req.ConversationID) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return nil, errors.BadRequest(reason.ObjectNotFound) + } + + userInfo, err := s.getUserInfo(ctx, conversation.UserID) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + records, err := s.aiConversationRepo.GetRecordsByConversationID(ctx, req.ConversationID) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + recordList := make([]schema.AIConversationRecord, 0, len(records)) + for i, record := range records { + if i == 0 { + record.Content = conversation.Topic + } + recordList = append(recordList, schema.AIConversationRecord{ + ChatCompletionID: record.ChatCompletionID, + Role: record.Role, + Content: record.Content, + Helpful: record.Helpful, + Unhelpful: record.Unhelpful, + CreatedAt: record.CreatedAt.Unix(), + }) + } + + return &schema.AIConversationAdminDetailResp{ + ConversationID: conversation.ConversationID, + Topic: conversation.Topic, + UserInfo: userInfo, + Records: recordList, + CreatedAt: conversation.CreatedAt.Unix(), + }, nil +} + +// getUserInfo +func (s *aiConversationService) getUserInfo(ctx context.Context, userID string) (schema.AIConversationUserInfo, error) { + userInfo := schema.AIConversationUserInfo{} + + user, exist, err := s.userCommon.GetUserBasicInfoByID(ctx, userID) + if err != nil { + return userInfo, err + } + if !exist { + return userInfo, errors.BadRequest(reason.ObjectNotFound) + } + + userInfo.ID = user.ID + userInfo.Username = user.Username + userInfo.DisplayName = user.DisplayName + userInfo.Avatar = user.Avatar + userInfo.Rank = user.Rank + return userInfo, nil +} + +// DeleteConversationForAdmin +func (s *aiConversationService) DeleteConversationForAdmin(ctx context.Context, req *schema.AIConversationAdminDeleteReq) error { + _, exist, err := s.aiConversationRepo.GetConversation(ctx, req.ConversationID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + if err := s.aiConversationRepo.DeleteConversation(ctx, req.ConversationID); err != nil { + return err + } + + return nil +} diff --git a/internal/service/apikey/apikey_service.go b/internal/service/apikey/apikey_service.go new file mode 100644 index 00000000..43c1294c --- /dev/null +++ b/internal/service/apikey/apikey_service.go @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "context" + "strings" + "time" + + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/pkg/token" +) + +type APIKeyRepo interface { + GetAPIKeyList(ctx context.Context) (keys []*entity.APIKey, err error) + GetAPIKey(ctx context.Context, apiKey string) (key *entity.APIKey, exist bool, err error) + UpdateAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) + AddAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) + DeleteAPIKey(ctx context.Context, id int) (err error) +} + +type APIKeyService struct { + apiKeyRepo APIKeyRepo +} + +func NewAPIKeyService( + apiKeyRepo APIKeyRepo, +) *APIKeyService { + return &APIKeyService{ + apiKeyRepo: apiKeyRepo, + } +} + +func (s *APIKeyService) GetAPIKeyList(ctx context.Context, req *schema.GetAPIKeyReq) (resp []*schema.GetAPIKeyResp, err error) { + keys, err := s.apiKeyRepo.GetAPIKeyList(ctx) + if err != nil { + return nil, err + } + resp = make([]*schema.GetAPIKeyResp, 0) + for _, key := range keys { + // hide access key middle part, replace with * + if len(key.AccessKey) < 10 { + // If the access key is too short, do not mask it + key.AccessKey = strings.Repeat("*", len(key.AccessKey)) + } else { + key.AccessKey = key.AccessKey[:7] + strings.Repeat("*", 8) + key.AccessKey[len(key.AccessKey)-4:] + } + + resp = append(resp, &schema.GetAPIKeyResp{ + ID: key.ID, + AccessKey: key.AccessKey, + Description: key.Description, + Scope: key.Scope, + CreatedAt: key.CreatedAt.Unix(), + LastUsedAt: key.LastUsedAt.Unix(), + }) + } + return resp, nil +} + +func (s *APIKeyService) UpdateAPIKey(ctx context.Context, req *schema.UpdateAPIKeyReq) (err error) { + apiKey := entity.APIKey{ + ID: req.ID, + Description: req.Description, + } + err = s.apiKeyRepo.UpdateAPIKey(ctx, apiKey) + if err != nil { + return err + } + return nil +} + +func (s *APIKeyService) AddAPIKey(ctx context.Context, req *schema.AddAPIKeyReq) (resp *schema.AddAPIKeyResp, err error) { + ak := "sk_" + strings.ReplaceAll(token.GenerateToken(), "-", "") + apiKey := entity.APIKey{ + Description: req.Description, + AccessKey: ak, + Scope: req.Scope, + LastUsedAt: time.Now(), + UserID: req.UserID, + } + err = s.apiKeyRepo.AddAPIKey(ctx, apiKey) + if err != nil { + return nil, err + } + resp = &schema.AddAPIKeyResp{ + AccessKey: apiKey.AccessKey, + } + return resp, nil +} + +func (s *APIKeyService) DeleteAPIKey(ctx context.Context, req *schema.DeleteAPIKeyReq) (err error) { + err = s.apiKeyRepo.DeleteAPIKey(ctx, req.ID) + if err != nil { + return err + } + return nil +} diff --git a/internal/service/feature_toggle/feature_toggle_service.go b/internal/service/feature_toggle/feature_toggle_service.go new file mode 100644 index 00000000..b5bdad54 --- /dev/null +++ b/internal/service/feature_toggle/feature_toggle_service.go @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package feature_toggle + +import ( + "context" + "encoding/json" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/siteinfo_common" + "github.com/segmentfault/pacman/errors" +) + +// Feature keys +const ( + FeatureBadge = "badge" + FeatureCustomDomain = "custom_domain" + FeatureMCP = "mcp" + FeaturePrivateAPI = "private_api" + FeatureAIChatbot = "ai_chatbot" + FeatureArticle = "article" + FeatureCategory = "category" +) + +type toggleConfig struct { + Toggles map[string]bool `json:"toggles"` +} + +// FeatureToggleService persist and query feature switches. +type FeatureToggleService struct { + siteInfoRepo siteinfo_common.SiteInfoRepo +} + +// NewFeatureToggleService creates a new feature toggle service instance. +func NewFeatureToggleService(siteInfoRepo siteinfo_common.SiteInfoRepo) *FeatureToggleService { + return &FeatureToggleService{ + siteInfoRepo: siteInfoRepo, + } +} + +// UpdateAll overwrites the feature toggle configuration. +func (s *FeatureToggleService) UpdateAll(ctx context.Context, toggles map[string]bool) error { + cfg := &toggleConfig{ + Toggles: sanitizeToggleMap(toggles), + } + + data, err := json.Marshal(cfg) + if err != nil { + return err + } + + info := &entity.SiteInfo{ + Type: constant.SiteTypeFeatureToggle, + Content: string(data), + Status: 1, + } + + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeFeatureToggle, info) +} + +// GetAll returns all feature toggles. +func (s *FeatureToggleService) GetAll(ctx context.Context) (map[string]bool, error) { + siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeFeatureToggle, true) + if err != nil { + return nil, err + } + if !exist || siteInfo == nil || siteInfo.Content == "" { + return map[string]bool{}, nil + } + + cfg := &toggleConfig{} + if err := json.Unmarshal([]byte(siteInfo.Content), cfg); err != nil { + return map[string]bool{}, err + } + return sanitizeToggleMap(cfg.Toggles), nil +} + +// IsEnabled returns whether a feature is enabled. Missing config defaults to true. +func (s *FeatureToggleService) IsEnabled(ctx context.Context, feature string) (bool, error) { + toggles, err := s.GetAll(ctx) + if err != nil { + return false, err + } + if len(toggles) == 0 { + return true, nil + } + value, ok := toggles[feature] + if !ok { + return true, nil + } + return value, nil +} + +// EnsureEnabled returns error if feature disabled. +func (s *FeatureToggleService) EnsureEnabled(ctx context.Context, feature string) error { + enabled, err := s.IsEnabled(ctx, feature) + if err != nil { + return err + } + if !enabled { + return errors.BadRequest(reason.ErrFeatureDisabled) + } + return nil +} + +func sanitizeToggleMap(in map[string]bool) map[string]bool { + if in == nil { + return map[string]bool{} + } + return in +} diff --git a/internal/service/mock/siteinfo_repo_mock.go b/internal/service/mock/siteinfo_repo_mock.go index 8feb6a27..16f7abd8 100644 --- a/internal/service/mock/siteinfo_repo_mock.go +++ b/internal/service/mock/siteinfo_repo_mock.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + // Code generated by MockGen. DO NOT EDIT. // Source: ./siteinfo_service.go // @@ -42,9 +61,13 @@ func (m *MockSiteInfoRepo) EXPECT() *MockSiteInfoRepoMockRecorder { } // GetByType mocks base method. -func (m *MockSiteInfoRepo) GetByType(ctx context.Context, siteType string) (*entity.SiteInfo, bool, error) { +func (m *MockSiteInfoRepo) GetByType(ctx context.Context, siteType string, withoutCache ...bool) (*entity.SiteInfo, bool, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetByType", ctx, siteType) + varargs := []any{ctx, siteType} + for _, a := range withoutCache { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetByType", varargs...) ret0, _ := ret[0].(*entity.SiteInfo) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) @@ -54,7 +77,8 @@ func (m *MockSiteInfoRepo) GetByType(ctx context.Context, siteType string) (*ent // GetByType indicates an expected call of GetByType. func (mr *MockSiteInfoRepoMockRecorder) GetByType(ctx, siteType interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByType", reflect.TypeOf((*MockSiteInfoRepo)(nil).GetByType), ctx, siteType) + varargs := append([]any{ctx, siteType}, withoutCache...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByType", reflect.TypeOf((*MockSiteInfoRepo)(nil).GetByType), varargs...) } // IsBrandingFileUsed mocks base method. diff --git a/internal/service/provider.go b/internal/service/provider.go index f6d95470..3e43b0ae 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -24,7 +24,9 @@ import ( "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" "github.com/apache/answer/internal/service/activityqueue" + "github.com/apache/answer/internal/service/ai_conversation" answercommon "github.com/apache/answer/internal/service/answer_common" + "github.com/apache/answer/internal/service/apikey" "github.com/apache/answer/internal/service/auth" "github.com/apache/answer/internal/service/badge" "github.com/apache/answer/internal/service/collection" @@ -36,6 +38,7 @@ import ( "github.com/apache/answer/internal/service/dashboard" "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/internal/service/export" + "github.com/apache/answer/internal/service/feature_toggle" "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/follow" "github.com/apache/answer/internal/service/importer" @@ -128,4 +131,7 @@ var ProviderSetService = wire.NewSet( badge.NewBadgeGroupService, importer.NewImporterService, file_record.NewFileRecordService, + apikey.NewAPIKeyService, + ai_conversation.NewAIConversationService, + feature_toggle.NewFeatureToggleService, ) diff --git a/internal/service/siteinfo/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go index cc0cb599..6cdb8ae5 100644 --- a/internal/service/siteinfo/siteinfo_service.go +++ b/internal/service/siteinfo/siteinfo_service.go @@ -39,7 +39,9 @@ import ( "github.com/apache/answer/internal/service/siteinfo_common" tagcommon "github.com/apache/answer/internal/service/tag_common" "github.com/apache/answer/plugin" + "github.com/go-resty/resty/v2" "github.com/jinzhu/copier" + "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" ) @@ -335,6 +337,154 @@ func (s *SiteInfoService) SaveSiteUsers(ctx context.Context, req *schema.SiteUse return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeUsers, data) } +// GetSiteAI get site AI configuration +func (s *SiteInfoService) GetSiteAI(ctx context.Context) (resp *schema.SiteAIResp, err error) { + resp, err = s.siteInfoCommonService.GetSiteAI(ctx) + if err != nil { + return nil, err + } + aiProvider, err := s.GetAIProvider(ctx) + if err != nil { + return nil, err + } + providerMapping := make(map[string]*schema.SiteAIProvider) + for _, provider := range resp.SiteAIProviders { + providerMapping[provider.Provider] = provider + } + providers := make([]*schema.SiteAIProvider, 0) + for _, p := range aiProvider { + if provider, ok := providerMapping[p.Name]; ok { + providers = append(providers, provider) + } else { + providers = append(providers, &schema.SiteAIProvider{ + Provider: p.Name, + }) + } + } + resp.SiteAIProviders = providers + s.maskAIKeys(resp) + return resp, nil +} + +// SaveSiteAI save site AI configuration +func (s *SiteInfoService) SaveSiteAI(ctx context.Context, req *schema.SiteAIReq) (err error) { + if err := s.restoreMaskedAIKeys(ctx, req); err != nil { + return err + } + if req.PromptConfig == nil { + req.PromptConfig = &schema.AIPromptConfig{ + ZhCN: constant.DefaultAIPromptConfigZhCN, + EnUS: constant.DefaultAIPromptConfigEnUS, + } + } + + aiProvider, err := s.GetAIProvider(ctx) + if err != nil { + return err + } + + providerMapping := make(map[string]*schema.SiteAIProvider) + for _, provider := range req.SiteAIProviders { + providerMapping[provider.Provider] = provider + } + + providers := make([]*schema.SiteAIProvider, 0) + for _, p := range aiProvider { + if provider, ok := providerMapping[p.Name]; ok { + if len(provider.APIHost) == 0 && provider.Provider == req.ChosenProvider { + provider.APIHost = p.DefaultAPIHost + } + providers = append(providers, provider) + } else { + providers = append(providers, &schema.SiteAIProvider{ + Provider: p.Name, + APIHost: p.DefaultAPIHost, + }) + } + } + req.SiteAIProviders = providers + + content, _ := json.Marshal(req) + siteInfo := &entity.SiteInfo{ + Type: constant.SiteTypeAI, + Content: string(content), + Status: 1, + } + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeAI, siteInfo) +} + +func (s *SiteInfoService) maskAIKeys(resp *schema.SiteAIResp) { + for _, provider := range resp.SiteAIProviders { + if provider.APIKey == "" { + continue + } + provider.APIKey = strings.Repeat("*", len(provider.APIKey)) + } +} + +func (s *SiteInfoService) restoreMaskedAIKeys(ctx context.Context, req *schema.SiteAIReq) error { + hasMasked := false + for _, provider := range req.SiteAIProviders { + if provider.APIKey != "" && isAllMask(provider.APIKey) { + hasMasked = true + break + } + } + if !hasMasked { + return nil + } + + current, err := s.siteInfoCommonService.GetSiteAI(ctx) + if err != nil { + return err + } + currentMapping := make(map[string]*schema.SiteAIProvider) + for _, provider := range current.SiteAIProviders { + currentMapping[provider.Provider] = provider + } + for _, provider := range req.SiteAIProviders { + if provider.APIKey == "" || !isAllMask(provider.APIKey) { + continue + } + if stored, ok := currentMapping[provider.Provider]; ok { + provider.APIKey = stored.APIKey + } + } + return nil +} + +func isAllMask(value string) bool { + return strings.Trim(value, "*") == "" +} + +// GetSiteMCP get site MCP configuration +func (s *SiteInfoService) GetSiteMCP(ctx context.Context) (resp *schema.SiteMCPResp, err error) { + resp, err = s.siteInfoCommonService.GetSiteMCP(ctx) + if err != nil { + return nil, err + } + siteInfo, err := s.GetSiteGeneral(ctx) + if err != nil { + return nil, err + } + + resp.Type = "Server-Sent Event (SSE)" + resp.URL = fmt.Sprintf("%s/answer/api/v1/mcp/sse", siteInfo.SiteUrl) + resp.HTTPHeader = "Authorization={key}" + return +} + +// SaveSiteMCP save site MCP configuration +func (s *SiteInfoService) SaveSiteMCP(ctx context.Context, req *schema.SiteMCPReq) (err error) { + content, _ := json.Marshal(req) + siteInfo := &entity.SiteInfo{ + Type: constant.SiteTypeMCP, + Content: string(content), + Status: 1, + } + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeMCP, siteInfo) +} + // GetSMTPConfig get smtp config func (s *SiteInfoService) GetSMTPConfig(ctx context.Context) (resp *schema.GetSMTPConfigResp, err error) { emailConfig, err := s.emailService.GetEmailConfig(ctx) @@ -548,3 +698,76 @@ func (s *SiteInfoService) CleanUpRemovedBrandingFiles( } return nil } + +func (s *SiteInfoService) GetAIProvider(ctx context.Context) (resp []*schema.GetAIProviderResp, err error) { + resp = make([]*schema.GetAIProviderResp, 0) + aiProviderConfig, err := s.configService.GetStringValue(context.TODO(), constant.AIConfigProvider) + if err != nil { + log.Error(err) + return resp, nil + } + + _ = json.Unmarshal([]byte(aiProviderConfig), &resp) + return resp, nil +} + +func (s *SiteInfoService) GetAIModels(ctx context.Context, req *schema.GetAIModelsReq) (resp []*schema.GetAIModelResp, err error) { + resp = make([]*schema.GetAIModelResp, 0) + if req.APIKey != "" && isAllMask(req.APIKey) { + storedKey, err := s.getStoredAIKey(ctx, req.APIHost) + if err != nil { + return resp, err + } + if storedKey == "" { + return resp, errors.BadRequest("api_key is required") + } + req.APIKey = storedKey + } + + r := resty.New() + r.SetHeader("Authorization", fmt.Sprintf("Bearer %s", req.APIKey)) + r.SetHeader("Content-Type", "application/json") + respBody, err := r.R().Get(req.APIHost + "/v1/models") + if err != nil { + log.Error(err) + return resp, errors.BadRequest(fmt.Sprintf("failed to get AI models %s", err.Error())) + } + if !respBody.IsSuccess() { + log.Error(fmt.Sprintf("failed to get AI models, status code: %d, body: %s", respBody.StatusCode(), respBody.String())) + return resp, errors.BadRequest(fmt.Sprintf("failed to get AI models, response: %s", respBody.String())) + } + + data := schema.GetAIModelsResp{} + _ = json.Unmarshal(respBody.Body(), &data) + + for _, model := range data.Data { + resp = append(resp, &schema.GetAIModelResp{ + Id: model.Id, + Object: model.Object, + Created: model.Created, + OwnedBy: model.OwnedBy, + }) + } + return resp, nil +} + +func (s *SiteInfoService) getStoredAIKey(ctx context.Context, apiHost string) (string, error) { + current, err := s.siteInfoCommonService.GetSiteAI(ctx) + if err != nil { + return "", err + } + apiHost = strings.TrimRight(apiHost, "/") + for _, provider := range current.SiteAIProviders { + if strings.TrimRight(provider.APIHost, "/") == apiHost && provider.APIKey != "" { + return provider.APIKey, nil + } + } + if current.ChosenProvider != "" { + for _, provider := range current.SiteAIProviders { + if provider.Provider == current.ChosenProvider { + return provider.APIKey, nil + } + } + } + return "", nil +} diff --git a/internal/service/siteinfo_common/siteinfo_service.go b/internal/service/siteinfo_common/siteinfo_service.go index 2f2efb79..90d48fc3 100644 --- a/internal/service/siteinfo_common/siteinfo_service.go +++ b/internal/service/siteinfo_common/siteinfo_service.go @@ -34,7 +34,7 @@ import ( //go:generate mockgen -source=./siteinfo_service.go -destination=../mock/siteinfo_repo_mock.go -package=mock type SiteInfoRepo interface { SaveByType(ctx context.Context, siteType string, data *entity.SiteInfo) (err error) - GetByType(ctx context.Context, siteType string) (siteInfo *entity.SiteInfo, exist bool, err error) + GetByType(ctx context.Context, siteType string, withoutCache ...bool) (siteInfo *entity.SiteInfo, exist bool, err error) IsBrandingFileUsed(ctx context.Context, filePath string) (bool, error) } @@ -63,6 +63,8 @@ type SiteInfoCommonService interface { GetSiteSeo(ctx context.Context) (resp *schema.SiteSeoResp, err error) GetSiteInfoByType(ctx context.Context, siteType string, resp any) (err error) IsBrandingFileUsed(ctx context.Context, filePath string) bool + GetSiteAI(ctx context.Context) (resp *schema.SiteAIResp, err error) + GetSiteMCP(ctx context.Context) (resp *schema.SiteMCPResp, err error) } // NewSiteInfoCommonService new site info common service @@ -299,3 +301,21 @@ func (s *siteInfoCommonService) IsBrandingFileUsed(ctx context.Context, filePath } return used } + +// GetSiteAI get site AI configuration +func (s *siteInfoCommonService) GetSiteAI(ctx context.Context) (resp *schema.SiteAIResp, err error) { + resp = &schema.SiteAIResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeAI, resp); err != nil { + return nil, err + } + return resp, nil +} + +// GetSiteMCP get site AI configuration +func (s *siteInfoCommonService) GetSiteMCP(ctx context.Context) (resp *schema.SiteMCPResp, err error) { + resp = &schema.SiteMCPResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeMCP, resp); err != nil { + return nil, err + } + return resp, nil +}