feat: add AI configuration support with related controllers and services

This commit is contained in:
LinkinStars
2026-01-23 17:09:05 +08:00
parent dc7f752128
commit ce5aadf30d
39 changed files with 3843 additions and 75 deletions
+16 -1
View File
@@ -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)
+20 -2
View File
@@ -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=
+49
View File
@@ -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.`
)
+3
View File
@@ -44,4 +44,7 @@ const (
SiteTypePolicies = "policies"
SiteTypeSecurity = "security"
SiteTypeAI = "ai"
SiteTypeFeatureToggle = "feature-toggle"
SiteTypeMCP = "mcp"
)
+47
View File
@@ -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)
}
}
+1
View File
@@ -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
+2 -4
View File
@@ -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
}
}
+756
View File
@@ -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
}
@@ -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)
}
+3
View File
@@ -54,4 +54,7 @@ var ProviderSetController = wire.NewSet(
NewBadgeController,
NewRenderController,
NewSidebarController,
NewMCPController,
NewAIController,
NewAIConversationController,
)
+351
View File
@@ -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
}
}
@@ -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)
}
@@ -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)
}
+2
View File
@@ -29,4 +29,6 @@ var ProviderSetController = wire.NewSet(
NewRoleController,
NewPluginController,
NewBadgeController,
NewAdminAPIKeyController,
NewAIConversationAdminController,
)
@@ -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)
}
@@ -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)
}
+37
View File
@@ -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"
}
+40
View File
@@ -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"
}
+42
View File
@@ -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"
}
+1
View File
@@ -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 {
+116
View File
@@ -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
}
@@ -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
}
+83
View File
@@ -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
}
+4
View File
@@ -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,
)
+6 -4
View File
@@ -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)
+101 -60
View File
@@ -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)
}
+51
View File
@@ -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"`
}
+123
View File
@@ -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
}
+60
View File
@@ -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:"-"`
}
+194
View File
@@ -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())
}
+105
View File
@@ -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
}
+52
View File
@@ -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 {
@@ -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
}
+116
View File
@@ -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
}
@@ -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
}
+27 -3
View File
@@ -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.
+6
View File
@@ -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,
)
@@ -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
}
@@ -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
}