diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b08736cdf --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Installation +INSTALL_PORT=80 +AUTO_INSTALL=false + +# Database +DB_TYPE= +DB_USERNAME= +DB_PASSWORD= +DB_HOST= +DB_NAME= +DB_FILE= + +# Site +LANGUAGE=en-US +SITE_NAME=Apache Answer +SITE_URL= +CONTACT_EMAIL= + +# Admin +ADMIN_NAME= +ADMIN_PASSWORD= +ADMIN_EMAIL= + +# Content +EXTERNAL_CONTENT_DISPLAY=ask_before_display + +# Swagger +SWAGGER_HOST= +SWAGGER_ADDRESS_PORT= + +# Server +SITE_ADDR=0.0.0.0:3000 + +# Logging +LOG_LEVEL=INFO +LOG_PATH= diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..53463b39b --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,62 @@ +# 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. + +name: Lint + +on: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.number || github.run_id }} + cancel-in-progress: true + +jobs: + lint: + name: Lint (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache: true + + - name: Run go mod tidy + run: go mod tidy + + - name: Run golangci-lint + run: make lint + + - name: Check for uncommitted changes + shell: bash + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Uncommitted changes detected" + git status + git diff + exit 1 + fi diff --git a/.gitignore b/.gitignore index 257ef31d6..ba66f51a0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ dist/ # Lint setup generated file .husky/ + +# Environment variables +.env \ No newline at end of file diff --git a/Makefile b/Makefile index 14d52503f..4944edbee 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ .PHONY: build clean ui -VERSION=1.7.1 -BIN=answer +VERSION=2.0.0 DIR_SRC=./cmd/answer DOCKER_CMD=docker diff --git a/README.md b/README.md index 6e96c516a..b9ffc2d85 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache ### Running with docker ```bash -docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:1.7.1 +docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.0 ``` For more information, see [Installation](https://answer.apache.org/docs/installation). diff --git a/cmd/main.go b/cmd/main.go index f166d2309..1f8153001 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -31,12 +31,18 @@ import ( "github.com/apache/answer/internal/base/path" "github.com/apache/answer/internal/schema" "github.com/gin-gonic/gin" + "github.com/joho/godotenv" "github.com/segmentfault/pacman" "github.com/segmentfault/pacman/contrib/log/zap" "github.com/segmentfault/pacman/contrib/server/http" "github.com/segmentfault/pacman/log" ) +func init() { + // Load .env if present, ignore error to keep backward compatibility + _ = godotenv.Load() +} + // go build -ldflags "-X github.com/apache/answer/cmd.Version=x.y.z" var ( // Name is the name of the project diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index aae1c6af6..24d32f7eb 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -38,7 +38,9 @@ import ( "github.com/apache/answer/internal/controller_admin" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" + "github.com/apache/answer/internal/repo/api_key" "github.com/apache/answer/internal/repo/auth" "github.com/apache/answer/internal/repo/badge" "github.com/apache/answer/internal/repo/badge_award" @@ -72,8 +74,10 @@ import ( "github.com/apache/answer/internal/service/action" activity2 "github.com/apache/answer/internal/service/activity" activity_common2 "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/activity_queue" + "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" @@ -83,14 +87,15 @@ import ( config2 "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/content" "github.com/apache/answer/internal/service/dashboard" - "github.com/apache/answer/internal/service/event_queue" + "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" meta2 "github.com/apache/answer/internal/service/meta" "github.com/apache/answer/internal/service/meta_common" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/notification" "github.com/apache/answer/internal/service/notification_common" "github.com/apache/answer/internal/service/object_info" @@ -172,29 +177,29 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, tagRepo := tag.NewTagRepo(dataData, uniqueIDRepo) revisionRepo := revision.NewRevisionRepo(dataData, uniqueIDRepo) revisionService := revision_common.NewRevisionService(revisionRepo, userRepo) - activityQueueService := activity_queue.NewActivityQueueService() - tagCommonService := tag_common2.NewTagCommonService(tagCommonRepo, tagRelRepo, tagRepo, revisionService, siteInfoCommonService, activityQueueService) + service := activityqueue.NewService() + tagCommonService := tag_common2.NewTagCommonService(tagCommonRepo, tagRelRepo, tagRepo, revisionService, siteInfoCommonService, service) collectionRepo := collection.NewCollectionRepo(dataData, uniqueIDRepo) collectionCommon := collectioncommon.NewCollectionCommon(collectionRepo) answerCommon := answercommon.NewAnswerCommon(answerRepo) metaRepo := meta.NewMetaRepo(dataData) metaCommonService := metacommon.NewMetaCommonService(metaRepo) - questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, activityQueueService, revisionRepo, siteInfoCommonService, dataData) - eventQueueService := event_queue.NewEventQueueService() + questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, service, revisionRepo, siteInfoCommonService, dataData) + eventqueueService := eventqueue.NewService() fileRecordRepo := file_record.NewFileRecordRepo(dataData) fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService, userCommon) - userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventQueueService, fileRecordService) + userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventqueueService, fileRecordService) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService) commentRepo := comment.NewCommentRepo(dataData, uniqueIDRepo) commentCommonRepo := comment.NewCommentCommonRepo(dataData, uniqueIDRepo) objService := object_info.NewObjService(answerRepo, questionRepo, commentCommonRepo, tagCommonRepo, tagCommonService) - notificationQueueService := notice_queue.NewNotificationQueueService() - externalNotificationQueueService := notice_queue.NewNewQuestionNotificationQueueService() + noticequeueService := noticequeue.NewService() + externalService := noticequeue.NewExternalService() reviewRepo := review.NewReviewRepo(dataData) - reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalNotificationQueueService, tagCommonService, questionCommon, notificationQueueService, siteInfoCommonService, commentCommonRepo) - commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, notificationQueueService, externalNotificationQueueService, activityQueueService, eventQueueService, reviewService) + reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalService, tagCommonService, questionCommon, noticequeueService, siteInfoCommonService, commentCommonRepo) + commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, noticequeueService, externalService, service, eventqueueService, reviewService) rolePowerRelRepo := role.NewRolePowerRelRepo(dataData) rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService) rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService) @@ -202,17 +207,17 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, rateLimitMiddleware := middleware.NewRateLimitMiddleware(limitRepo) commentController := controller.NewCommentController(commentService, rankService, captchaService, rateLimitMiddleware) reportRepo := report.NewReportRepo(dataData, uniqueIDRepo) - tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, activityQueueService) - answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, notificationQueueService) + tagService := tag2.NewTagService(tagRepo, tagCommonService, revisionService, followRepo, siteInfoCommonService, service) + answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, noticequeueService) answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService) - externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalNotificationQueueService, userExternalLoginRepo, siteInfoCommonService) - questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, notificationQueueService, externalNotificationQueueService, activityQueueService, siteInfoCommonService, externalNotificationService, reviewService, configService, eventQueueService, reviewRepo) - answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, notificationQueueService, externalNotificationQueueService, activityQueueService, reviewService, eventQueueService) + externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalService, userExternalLoginRepo, siteInfoCommonService) + questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, service, siteInfoCommonService, externalNotificationService, reviewService, configService, eventqueueService, reviewRepo) + answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, service, reviewService, eventqueueService) reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService) - reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventQueueService) + reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventqueueService) reportController := controller.NewReportController(reportService, rankService, captchaService) - contentVoteRepo := activity.NewVoteRepo(dataData, activityRepo, userRankRepo, notificationQueueService) - voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService, eventQueueService) + contentVoteRepo := activity.NewVoteRepo(dataData, activityRepo, userRankRepo, noticequeueService) + voteService := content.NewVoteService(contentVoteRepo, configService, questionRepo, answerRepo, commentCommonRepo, objService, eventqueueService) voteController := controller.NewVoteController(voteService, rankService, captchaService) tagController := controller.NewTagController(tagService, tagCommonService, rankService) followFollowRepo := activity.NewFollowRepo(dataData, uniqueIDRepo, activityRepo) @@ -228,7 +233,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, searchService := content.NewSearchService(searchParser, searchRepo) searchController := controller.NewSearchController(searchService, captchaService) reviewActivityRepo := activity.NewReviewActivityRepo(dataData, activityRepo, userRankRepo, configService) - contentRevisionService := content.NewRevisionService(revisionRepo, userCommon, questionCommon, answerService, objService, questionRepo, answerRepo, tagRepo, tagCommonService, notificationQueueService, activityQueueService, reportRepo, reviewService, reviewActivityRepo) + contentRevisionService := content.NewRevisionService(revisionRepo, userCommon, questionCommon, answerService, objService, questionRepo, answerRepo, tagRepo, tagCommonService, noticequeueService, service, reportRepo, reviewService, reviewActivityRepo) revisionController := controller.NewRevisionController(contentRevisionService, rankService) rankController := controller.NewRankController(rankService) userAdminRepo := user.NewUserAdminRepo(dataData, authRepo) @@ -244,7 +249,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, siteInfoCommonService, emailService, tagCommonService, configService, questionCommon, fileRecordService) siteInfoController := controller_admin.NewSiteInfoController(siteInfoService) controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService) - notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, notificationQueueService, userExternalLoginRepo, siteInfoCommonService) + notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, noticequeueService, userExternalLoginRepo, siteInfoCommonService) badgeRepo := badge.NewBadgeRepo(dataData, uniqueIDRepo) notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService, badgeRepo) notificationController := controller.NewNotificationController(notificationService, rankService) @@ -253,7 +258,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, uploaderService := uploader.NewUploaderService(serviceConf, siteInfoCommonService, fileRecordService) uploadController := controller.NewUploadController(uploaderService) activityActivityRepo := activity.NewActivityRepo(dataData, configService) - activityCommon := activity_common2.NewActivityCommon(activityRepo, activityQueueService) + activityCommon := activity_common2.NewActivityCommon(activityRepo, service) commentCommonService := comment_common.NewCommentCommonService(commentCommonRepo) activityService := activity2.NewActivityService(activityActivityRepo, userCommon, activityCommon, tagCommonService, objService, commentCommonService, revisionService, metaCommonService, configService) activityController := controller.NewActivityController(activityService) @@ -265,23 +270,33 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, permissionController := controller.NewPermissionController(rankService) userPluginController := controller.NewUserPluginController(pluginCommonService) reviewController := controller.NewReviewController(reviewService, rankService, captchaService) - metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, eventQueueService) + metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, eventqueueService) metaController := controller.NewMetaController(metaService) badgeGroupRepo := badge_group.NewBadgeGroupRepo(dataData, uniqueIDRepo) eventRuleRepo := badge.NewEventRuleRepo(dataData) - badgeAwardService := badge2.NewBadgeAwardService(badgeAwardRepo, badgeRepo, userCommon, objService, notificationQueueService) - badgeEventService := badge2.NewBadgeEventService(dataData, eventQueueService, badgeRepo, eventRuleRepo, badgeAwardService) + badgeAwardService := badge2.NewBadgeAwardService(badgeAwardRepo, badgeRepo, userCommon, objService, noticequeueService) + badgeEventService := badge2.NewBadgeEventService(dataData, eventqueueService, badgeRepo, eventRuleRepo, badgeAwardService) 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) avatarMiddleware := middleware.NewAvatarMiddleware(serviceConf, uploaderService) shortIDMiddleware := middleware.NewShortIDMiddleware(siteInfoCommonService) templateRenderController := templaterender.NewTemplateRenderController(questionService, userService, tagService, answerService, commentService, siteInfoCommonService, questionRepo) - templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService, eventQueueService, userService, questionService) + templateController := controller.NewTemplateController(templateRenderController, siteInfoCommonService, eventqueueService, userService, questionService) templateRouter := router.NewTemplateRouter(templateController, templateRenderController, siteInfoController, authUserMiddleware) connectorController := controller.NewConnectorController(siteInfoCommonService, emailService, userExternalLoginService) userCenterLoginService := user_external_login2.NewUserCenterLoginService(userRepo, userCommon, userExternalLoginRepo, userActiveActivityRepo, siteInfoCommonService) diff --git a/docs/docs.go b/docs/docs.go index 5e9d5b39d..57a23d432 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -50,6 +50,297 @@ const docTemplate = `{ "responses": {} } }, + "/answer/admin/api/ai-config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get AI configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get AI configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteAIResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update AI configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update AI configuration", + "parameters": [ + { + "description": "AI config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteAIReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/ai-models": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get AI models", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get AI models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetAIModelResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/ai-provider": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get AI provider configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get AI provider configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetAIProviderResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/ai/conversation": { + "get": { + "description": "get conversation detail for admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation-admin" + ], + "summary": "get conversation detail for admin", + "parameters": [ + { + "type": "string", + "description": "conversation id", + "name": "conversation_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.AIConversationAdminDetailResp" + } + } + } + ] + } + } + } + }, + "delete": { + "description": "delete conversation and its related records for admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation-admin" + ], + "summary": "delete conversation for admin", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AIConversationAdminDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/ai/conversation/page": { + "get": { + "description": "get conversation list for admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation-admin" + ], + "summary": "get conversation list for admin", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationAdminListItem" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/answer/page": { "get": { "security": [ @@ -57,52 +348,523 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Status:[available,deleted,pending]", - "consumes": [ - "application/json" - ], + "description": "Status:[available,deleted,pending]", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "AdminAnswerPage admin answer page", + "parameters": [ + { + "type": "integer", + "description": "page size", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "available", + "deleted", + "pending" + ], + "type": "string", + "description": "user status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "answer id or question title", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "question id", + "name": "question_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/answer/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update answer status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update answer status", + "parameters": [ + { + "description": "AdminUpdateAnswerStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AdminUpdateAnswerStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/api-key": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update apikey", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update apikey", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateAPIKeyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "add apikey", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "add apikey", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AddAPIKeyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.AddAPIKeyResp" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "delete apikey", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "delete apikey", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.DeleteAPIKeyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/api-key/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get all api keys", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get all api keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetAPIKeyResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/badge/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update badge status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "update badge status", + "parameters": [ + { + "description": "UpdateBadgeStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "list all badges by page", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "", + "active", + "inactive" + ], + "type": "string", + "description": "badge status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "search param", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListPagedResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/dashboard": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "DashboardInfo", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "DashboardInfo", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/delete/permanently": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "delete permanently", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "delete permanently", + "parameters": [ + { + "description": "DeletePermanentlyReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.DeletePermanentlyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/language/options": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get language options", + "produces": [ + "application/json" + ], + "tags": [ + "Lang" + ], + "summary": "Get language options", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/mcp-config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get MCP configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get MCP configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteMCPResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update MCP configuration", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "AdminAnswerPage admin answer page", + "summary": "update MCP configuration", "parameters": [ { - "type": "integer", - "description": "page size", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "page size", - "name": "page_size", - "in": "query" - }, - { - "enum": [ - "available", - "deleted", - "pending" - ], - "type": "string", - "description": "user status", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "answer id or question title", - "name": "query", - "in": "query" - }, - { - "type": "string", - "description": "question id", - "name": "question_id", - "in": "query" + "description": "MCP config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteMCPReq" + } } ], "responses": { @@ -115,14 +877,58 @@ const docTemplate = `{ } } }, - "/answer/admin/api/answer/status": { + "/answer/admin/api/plugin/config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin config", + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "get plugin config", + "parameters": [ + { + "type": "string", + "description": "plugin_slug_name", + "name": "plugin_slug_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPluginConfigResp" + } + } + } + ] + } + } + } + }, "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update answer status", + "description": "update plugin config", "consumes": [ "application/json" ], @@ -130,17 +936,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "admin" + "AdminPlugin" ], - "summary": "update answer status", + "summary": "update plugin config", "parameters": [ { - "description": "AdminUpdateAnswerStatusReq", + "description": "UpdatePluginConfigReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.AdminUpdateAnswerStatusReq" + "$ref": "#/definitions/schema.UpdatePluginConfigReq" } } ], @@ -154,14 +960,14 @@ const docTemplate = `{ } } }, - "/answer/admin/api/badge/status": { + "/answer/admin/api/plugin/status": { "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update badge status", + "description": "update plugin status", "consumes": [ "application/json" ], @@ -169,17 +975,17 @@ const docTemplate = `{ "application/json" ], "tags": [ - "AdminBadge" + "AdminPlugin" ], - "summary": "update badge status", + "summary": "update plugin status", "parameters": [ { - "description": "UpdateBadgeStatusReq", + "description": "UpdatePluginStatusReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + "$ref": "#/definitions/schema.UpdatePluginStatusReq" } } ], @@ -193,14 +999,14 @@ const docTemplate = `{ } } }, - "/answer/admin/api/badges": { + "/answer/admin/api/plugins": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "list all badges by page", + "description": "get plugin list", "consumes": [ "application/json" ], @@ -208,37 +1014,20 @@ const docTemplate = `{ "application/json" ], "tags": [ - "AdminBadge" + "AdminPlugin" ], - "summary": "list all badges by page", + "summary": "get plugin list", "parameters": [ { - "type": "integer", - "description": "page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "page size", - "name": "page_size", - "in": "query" - }, - { - "enum": [ - "", - "active", - "inactive" - ], "type": "string", - "description": "badge status", + "description": "status: active/inactive", "name": "status", "in": "query" }, { - "type": "string", - "description": "search param", - "name": "q", + "type": "boolean", + "description": "have config", + "name": "have_config", "in": "query" } ], @@ -256,7 +1045,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetBadgeListPagedResp" + "$ref": "#/definitions/schema.GetPluginListResp" } } } @@ -267,14 +1056,14 @@ const docTemplate = `{ } } }, - "/answer/admin/api/dashboard": { + "/answer/admin/api/question/page": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "DashboardInfo", + "description": "Status:[available,closed,deleted,pending]", "consumes": [ "application/json" ], @@ -284,7 +1073,39 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "DashboardInfo", + "summary": "AdminQuestionPage admin question page", + "parameters": [ + { + "type": "integer", + "description": "page size", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "available", + "closed", + "deleted", + "pending" + ], + "type": "string", + "description": "user status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "question id or title", + "name": "query", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -295,14 +1116,14 @@ const docTemplate = `{ } } }, - "/answer/admin/api/delete/permanently": { - "delete": { + "/answer/admin/api/question/status": { + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "delete permanently", + "description": "update question status", "consumes": [ "application/json" ], @@ -312,15 +1133,15 @@ const docTemplate = `{ "tags": [ "admin" ], - "summary": "delete permanently", + "summary": "update question status", "parameters": [ { - "description": "DeletePermanentlyReq", + "description": "AdminUpdateQuestionStatusReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.DeletePermanentlyReq" + "$ref": "#/definitions/schema.AdminUpdateQuestionStatusReq" } } ], @@ -334,21 +1155,52 @@ const docTemplate = `{ } } }, - "/answer/admin/api/language/options": { + "/answer/admin/api/reasons": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get language options", + "description": "get reasons by object type and action", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Lang" + "reason" + ], + "summary": "get reasons by object type and action", + "parameters": [ + { + "enum": [ + "question", + "answer", + "comment", + "user" + ], + "type": "string", + "description": "object_type", + "name": "object_type", + "in": "query", + "required": true + }, + { + "enum": [ + "status", + "close", + "flag", + "review" + ], + "type": "string", + "description": "action", + "name": "action", + "in": "query", + "required": true + } ], - "summary": "Get language options", "responses": { "200": { "description": "OK", @@ -359,30 +1211,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/plugin/config": { + "/answer/admin/api/roles": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get plugin config", + "description": "get role list", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" - ], - "summary": "get plugin config", - "parameters": [ - { - "type": "string", - "description": "plugin_slug_name", - "name": "plugin_slug_name", - "in": "query", - "required": true - } + "admin" ], + "summary": "get role list", "responses": { "200": { "description": "OK", @@ -395,7 +1238,10 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetPluginConfigResp" + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetRoleResp" + } } } } @@ -403,71 +1249,66 @@ const docTemplate = `{ } } } - }, - "put": { + } + }, + "/answer/admin/api/setting/privileges": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update plugin config", - "consumes": [ - "application/json" - ], + "description": "GetPrivilegesConfig get privileges config", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" - ], - "summary": "update plugin config", - "parameters": [ - { - "description": "UpdatePluginConfigReq", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.UpdatePluginConfigReq" - } - } + "admin" ], + "summary": "GetPrivilegesConfig get privileges config", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPrivilegesConfigResp" + } + } + } + ] } } } - } - }, - "/answer/admin/api/plugin/status": { + }, "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update plugin status", - "consumes": [ - "application/json" - ], + "description": "update privileges config", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" + "admin" ], - "summary": "update plugin status", + "summary": "update privileges config", "parameters": [ { - "description": "UpdatePluginStatusReq", + "description": "config", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdatePluginStatusReq" + "$ref": "#/definitions/schema.UpdatePrivilegesConfigReq" } } ], @@ -481,38 +1322,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/plugins": { + "/answer/admin/api/setting/smtp": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get plugin list", - "consumes": [ - "application/json" - ], + "description": "GetSMTPConfig get smtp config", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" - ], - "summary": "get plugin list", - "parameters": [ - { - "type": "string", - "description": "status: active/inactive", - "name": "status", - "in": "query" - }, - { - "type": "boolean", - "description": "have config", - "name": "have_config", - "in": "query" - } + "admin" ], + "summary": "GetSMTPConfig get smtp config", "responses": { "200": { "description": "OK", @@ -525,10 +1349,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.GetPluginListResp" - } + "$ref": "#/definitions/schema.GetSMTPConfigResp" } } } @@ -536,56 +1357,30 @@ const docTemplate = `{ } } } - } - }, - "/answer/admin/api/question/page": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Status:[available,closed,deleted,pending]", - "consumes": [ - "application/json" - ], + "description": "update smtp config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "AdminQuestionPage admin question page", + "summary": "update smtp config", "parameters": [ { - "type": "integer", - "description": "page size", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "page size", - "name": "page_size", - "in": "query" - }, - { - "enum": [ - "available", - "closed", - "deleted", - "pending" - ], - "type": "string", - "description": "user status", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "question id or title", - "name": "query", - "in": "query" + "description": "smtp config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateSMTPConfigReq" + } } ], "responses": { @@ -598,89 +1393,65 @@ const docTemplate = `{ } } }, - "/answer/admin/api/question/status": { - "put": { + "/answer/admin/api/siteinfo/advanced": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update question status", - "consumes": [ - "application/json" - ], + "description": "get site advanced setting", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update question status", - "parameters": [ - { - "description": "AdminUpdateQuestionStatusReq", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.AdminUpdateQuestionStatusReq" - } - } - ], + "summary": "get site advanced setting", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteAdvancedResp" + } + } + } + ] } } } - } - }, - "/answer/admin/api/reasons": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get reasons by object type and action", - "consumes": [ - "application/json" - ], + "description": "update site advanced info", "produces": [ "application/json" ], "tags": [ - "reason" + "admin" ], - "summary": "get reasons by object type and action", + "summary": "update site advanced info", "parameters": [ { - "enum": [ - "question", - "answer", - "comment", - "user" - ], - "type": "string", - "description": "object_type", - "name": "object_type", - "in": "query", - "required": true - }, - { - "enum": [ - "status", - "close", - "flag", - "review" - ], - "type": "string", - "description": "action", - "name": "action", - "in": "query", - "required": true + "description": "advanced settings", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteAdvancedReq" + } } ], "responses": { @@ -693,21 +1464,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/roles": { + "/answer/admin/api/siteinfo/branding": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get role list", + "description": "get site interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get role list", + "summary": "get site interface", "responses": { "200": { "description": "OK", @@ -720,10 +1491,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.GetRoleResp" - } + "$ref": "#/definitions/schema.SiteBrandingResp" } } } @@ -731,23 +1499,57 @@ const docTemplate = `{ } } } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update site info branding", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update site info branding", + "parameters": [ + { + "description": "branding info", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteBrandingReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } } }, - "/answer/admin/api/setting/privileges": { + "/answer/admin/api/siteinfo/custom-css-html": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "GetPrivilegesConfig get privileges config", + "description": "get site info custom html css config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "GetPrivilegesConfig get privileges config", + "summary": "get site info custom html css config", "responses": { "200": { "description": "OK", @@ -760,7 +1562,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetPrivilegesConfigResp" + "$ref": "#/definitions/schema.SiteCustomCssHTMLResp" } } } @@ -775,22 +1577,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update privileges config", + "description": "update site custom css html config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update privileges config", + "summary": "update site custom css html config", "parameters": [ { - "description": "config", + "description": "login info", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdatePrivilegesConfigReq" + "$ref": "#/definitions/schema.SiteCustomCssHTMLReq" } } ], @@ -804,21 +1606,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/setting/smtp": { + "/answer/admin/api/siteinfo/general": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "GetSMTPConfig get smtp config", + "description": "get site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "GetSMTPConfig get smtp config", + "summary": "get site general information", "responses": { "200": { "description": "OK", @@ -831,7 +1633,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetSMTPConfigResp" + "$ref": "#/definitions/schema.SiteGeneralResp" } } } @@ -846,22 +1648,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update smtp config", + "description": "update site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update smtp config", + "summary": "update site general information", "parameters": [ { - "description": "smtp config", + "description": "general", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdateSMTPConfigReq" + "$ref": "#/definitions/schema.SiteGeneralReq" } } ], @@ -875,7 +1677,7 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/branding": { + "/answer/admin/api/siteinfo/interface": { "get": { "security": [ { @@ -902,7 +1704,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteBrandingResp" + "$ref": "#/definitions/schema.SiteInterfaceSettingsResp" } } } @@ -917,22 +1719,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site info branding", + "description": "update site info interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site info branding", + "summary": "update site info interface", "parameters": [ { - "description": "branding info", + "description": "general", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteBrandingReq" + "$ref": "#/definitions/schema.SiteInterfaceReq" } } ], @@ -946,21 +1748,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/custom-css-html": { + "/answer/admin/api/siteinfo/login": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site info custom html css config", + "description": "get site info login config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site info custom html css config", + "summary": "get site info login config", "responses": { "200": { "description": "OK", @@ -973,7 +1775,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteCustomCssHTMLResp" + "$ref": "#/definitions/schema.SiteLoginResp" } } } @@ -988,14 +1790,14 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site custom css html config", + "description": "update site login", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site custom css html config", + "summary": "update site login", "parameters": [ { "description": "login info", @@ -1003,7 +1805,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteCustomCssHTMLReq" + "$ref": "#/definitions/schema.SiteLoginReq" } } ], @@ -1017,21 +1819,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/general": { + "/answer/admin/api/siteinfo/polices": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site general information", + "description": "Get the policies information for the site", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site general information", + "summary": "Get the policies information for the site", "responses": { "200": { "description": "OK", @@ -1044,7 +1846,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteGeneralResp" + "$ref": "#/definitions/schema.SitePoliciesResp" } } } @@ -1059,22 +1861,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site general information", + "description": "update site policies configuration", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site general information", + "summary": "update site policies configuration", "parameters": [ { - "description": "general", + "description": "write info", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteGeneralReq" + "$ref": "#/definitions/schema.SitePoliciesReq" } } ], @@ -1088,21 +1890,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/interface": { + "/answer/admin/api/siteinfo/question": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site interface", + "description": "get site questions setting", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site interface", + "summary": "get site questions setting", "responses": { "200": { "description": "OK", @@ -1115,7 +1917,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteInterfaceResp" + "$ref": "#/definitions/schema.SiteQuestionsResp" } } } @@ -1130,22 +1932,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site info interface", + "description": "update site question settings", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site info interface", + "summary": "update site question settings", "parameters": [ { - "description": "general", + "description": "questions settings", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteInterfaceReq" + "$ref": "#/definitions/schema.SiteQuestionsReq" } } ], @@ -1159,21 +1961,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/legal": { + "/answer/admin/api/siteinfo/security": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Set the legal information for the site", + "description": "Get the security information for the site", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Set the legal information for the site", + "summary": "Get the security information for the site", "responses": { "200": { "description": "OK", @@ -1186,7 +1988,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteLegalResp" + "$ref": "#/definitions/schema.SiteSecurityResp" } } } @@ -1201,14 +2003,14 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site legal info", + "description": "update site security configuration", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site legal info", + "summary": "update site security configuration", "parameters": [ { "description": "write info", @@ -1216,7 +2018,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteLegalReq" + "$ref": "#/definitions/schema.SiteSecurityReq" } } ], @@ -1230,21 +2032,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/login": { + "/answer/admin/api/siteinfo/seo": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site info login config", + "description": "get site seo information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site info login config", + "summary": "get site seo information", "responses": { "200": { "description": "OK", @@ -1257,7 +2059,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteLoginResp" + "$ref": "#/definitions/schema.SiteSeoResp" } } } @@ -1272,22 +2074,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site login", + "description": "update site seo information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site login", + "summary": "update site seo information", "parameters": [ { - "description": "login info", + "description": "seo", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteLoginReq" + "$ref": "#/definitions/schema.SiteSeoReq" } } ], @@ -1301,21 +2103,21 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/seo": { + "/answer/admin/api/siteinfo/tag": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site seo information", + "description": "get site tags setting", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site seo information", + "summary": "get site tags setting", "responses": { "200": { "description": "OK", @@ -1328,7 +2130,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteSeoResp" + "$ref": "#/definitions/schema.SiteTagsResp" } } } @@ -1343,22 +2145,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site seo information", + "description": "update site tag settings", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site seo information", + "summary": "update site tag settings", "parameters": [ { - "description": "seo", + "description": "tags settings", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteSeoReq" + "$ref": "#/definitions/schema.SiteTagsReq" } } ], @@ -1514,7 +2316,7 @@ const docTemplate = `{ } } }, - "/answer/admin/api/siteinfo/write": { + "/answer/admin/api/siteinfo/users-settings": { "get": { "security": [ { @@ -1541,7 +2343,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteWriteResp" + "$ref": "#/definitions/schema.SiteUsersSettingsResp" } } } @@ -1556,22 +2358,22 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "update site write info", + "description": "update site info users settings", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site write info", + "summary": "update site info users settings", "parameters": [ { - "description": "write info", + "description": "general", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteWriteReq" + "$ref": "#/definitions/schema.SiteUsersSettingsReq" } } ], @@ -1990,22 +2792,170 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "allOf": [ - { - "$ref": "#/definitions/pager.PageModel" - }, - { - "type": "object", - "properties": { - "records": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.GetUserPageResp" - } - } - } - } - ] + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserPageResp" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/activity/timeline": { + "get": { + "description": "get object timeline", + "produces": [ + "application/json" + ], + "tags": [ + "Comment" + ], + "summary": "get object timeline", + "parameters": [ + { + "type": "string", + "description": "object id", + "name": "object_id", + "in": "query" + }, + { + "type": "string", + "description": "tag slug name", + "name": "tag_slug_name", + "in": "query" + }, + { + "enum": [ + "question", + "answer", + "tag" + ], + "type": "string", + "description": "object type", + "name": "object_type", + "in": "query" + }, + { + "type": "boolean", + "description": "is show vote", + "name": "show_vote", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetObjectTimelineResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/activity/timeline/detail": { + "get": { + "description": "get object timeline detail", + "produces": [ + "application/json" + ], + "tags": [ + "Comment" + ], + "summary": "get object timeline detail", + "parameters": [ + { + "type": "string", + "description": "revision id", + "name": "revision_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetObjectTimelineResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/ai/conversation": { + "get": { + "description": "get conversation detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation" + ], + "summary": "get conversation detail", + "parameters": [ + { + "type": "string", + "description": "conversation id", + "name": "conversation_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.AIConversationDetailResp" } } } @@ -2015,44 +2965,30 @@ const docTemplate = `{ } } }, - "/answer/api/v1/activity/timeline": { + "/answer/api/v1/ai/conversation/page": { "get": { - "description": "get object timeline", + "description": "get conversation list", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Comment" + "ai-conversation" ], - "summary": "get object timeline", + "summary": "get conversation list", "parameters": [ { - "type": "string", - "description": "object id", - "name": "object_id", - "in": "query" - }, - { - "type": "string", - "description": "tag slug name", - "name": "tag_slug_name", - "in": "query" - }, - { - "enum": [ - "question", - "answer", - "tag" - ], - "type": "string", - "description": "object type", - "name": "object_type", + "type": "integer", + "description": "page", + "name": "page", "in": "query" }, { - "type": "boolean", - "description": "is show vote", - "name": "show_vote", + "type": "integer", + "description": "page size", + "name": "page_size", "in": "query" } ], @@ -2068,7 +3004,22 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetObjectTimelineResp" + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationListItem" + } + } + } + } + ] } } } @@ -2078,42 +3029,35 @@ const docTemplate = `{ } } }, - "/answer/api/v1/activity/timeline/detail": { - "get": { - "description": "get object timeline detail", + "/answer/api/v1/ai/conversation/vote": { + "post": { + "description": "vote record", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Comment" + "ai-conversation" ], - "summary": "get object timeline detail", + "summary": "vote record", "parameters": [ { - "type": "string", - "description": "revision id", - "name": "revision_id", - "in": "query", - "required": true + "description": "vote request", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AIConversationVoteReq" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/handler.RespBody" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/schema.GetObjectTimelineResp" - } - } - } - ] + "$ref": "#/definitions/handler.RespBody" } } } @@ -7728,6 +8672,176 @@ const docTemplate = `{ } } }, + "schema.AIConversationAdminDeleteReq": { + "type": "object", + "required": [ + "conversation_id" + ], + "properties": { + "conversation_id": { + "type": "string" + } + } + }, + "schema.AIConversationAdminDetailResp": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationRecord" + } + }, + "topic": { + "type": "string" + }, + "user_info": { + "$ref": "#/definitions/schema.AIConversationUserInfo" + } + } + }, + "schema.AIConversationAdminListItem": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "helpful_count": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "topic": { + "type": "string" + }, + "unhelpful_count": { + "type": "integer" + }, + "user_info": { + "$ref": "#/definitions/schema.AIConversationUserInfo" + } + } + }, + "schema.AIConversationDetailResp": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationRecord" + } + }, + "topic": { + "type": "string" + }, + "updated_at": { + "type": "integer" + } + } + }, + "schema.AIConversationListItem": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "topic": { + "type": "string" + } + } + }, + "schema.AIConversationRecord": { + "type": "object", + "properties": { + "chat_completion_id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "helpful": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "unhelpful": { + "type": "integer" + } + } + }, + "schema.AIConversationUserInfo": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "schema.AIConversationVoteReq": { + "type": "object", + "required": [ + "chat_completion_id", + "vote_type" + ], + "properties": { + "cancel": { + "type": "boolean" + }, + "chat_completion_id": { + "type": "string" + }, + "vote_type": { + "type": "string", + "enum": [ + "helpful", + "unhelpful" + ] + } + } + }, + "schema.AIPromptConfig": { + "type": "object", + "properties": { + "en_us": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + } + }, "schema.AcceptAnswerReq": { "type": "object", "required": [ @@ -7818,6 +8932,34 @@ const docTemplate = `{ } } }, + "schema.AddAPIKeyReq": { + "type": "object", + "required": [ + "description", + "scope" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 150 + }, + "scope": { + "type": "string", + "enum": [ + "read-only", + "global" + ] + } + } + }, + "schema.AddAPIKeyResp": { + "type": "object", + "properties": { + "access_key": { + "type": "string" + } + } + }, "schema.AddCommentReq": { "type": "object", "required": [ @@ -8298,6 +9440,14 @@ const docTemplate = `{ } } }, + "schema.DeleteAPIKeyReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, "schema.DeletePermanentlyReq": { "type": "object", "required": [ @@ -8414,6 +9564,60 @@ const docTemplate = `{ } } }, + "schema.GetAIModelResp": { + "type": "object", + "properties": { + "created": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "object": { + "type": "string" + }, + "owned_by": { + "type": "string" + } + } + }, + "schema.GetAIProviderResp": { + "type": "object", + "properties": { + "default_api_host": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "schema.GetAPIKeyResp": { + "type": "object", + "properties": { + "access_key": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "integer" + }, + "scope": { + "type": "string" + } + } + }, "schema.GetAnswerInfoResp": { "type": "object", "properties": { @@ -10497,6 +11701,121 @@ const docTemplate = `{ } } }, + "schema.SiteAIProvider": { + "type": "object", + "properties": { + "api_host": { + "type": "string", + "maxLength": 512 + }, + "api_key": { + "type": "string", + "maxLength": 256 + }, + "model": { + "type": "string", + "maxLength": 100 + }, + "provider": { + "type": "string", + "maxLength": 50 + } + } + }, + "schema.SiteAIReq": { + "type": "object", + "properties": { + "ai_providers": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteAIProvider" + } + }, + "chosen_provider": { + "type": "string", + "maxLength": 50 + }, + "enabled": { + "type": "boolean" + }, + "prompt_config": { + "$ref": "#/definitions/schema.AIPromptConfig" + } + } + }, + "schema.SiteAIResp": { + "type": "object", + "properties": { + "ai_providers": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteAIProvider" + } + }, + "chosen_provider": { + "type": "string", + "maxLength": 50 + }, + "enabled": { + "type": "boolean" + }, + "prompt_config": { + "$ref": "#/definitions/schema.AIPromptConfig" + } + } + }, + "schema.SiteAdvancedReq": { + "type": "object", + "properties": { + "authorized_attachment_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "authorized_image_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_attachment_size": { + "type": "integer" + }, + "max_image_megapixel": { + "type": "integer" + }, + "max_image_size": { + "type": "integer" + } + } + }, + "schema.SiteAdvancedResp": { + "type": "object", + "properties": { + "authorized_attachment_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "authorized_image_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_attachment_size": { + "type": "integer" + }, + "max_image_megapixel": { + "type": "integer" + }, + "max_image_size": { + "type": "integer" + } + } + }, "schema.SiteBrandingReq": { "type": "object", "properties": { @@ -10597,9 +11916,6 @@ const docTemplate = `{ "site_url" ], "properties": { - "check_update": { - "type": "boolean" - }, "contact_email": { "type": "string", "maxLength": 512 @@ -10630,9 +11946,6 @@ const docTemplate = `{ "site_url" ], "properties": { - "check_update": { - "type": "boolean" - }, "contact_email": { "type": "string", "maxLength": 512 @@ -10658,6 +11971,9 @@ const docTemplate = `{ "schema.SiteInfoResp": { "type": "object", "properties": { + "ai_enabled": { + "type": "boolean" + }, "branding": { "$ref": "#/definitions/schema.SiteBrandingResp" }, @@ -10668,29 +11984,44 @@ const docTemplate = `{ "$ref": "#/definitions/schema.SiteGeneralResp" }, "interface": { - "$ref": "#/definitions/schema.SiteInterfaceResp" + "$ref": "#/definitions/schema.SiteInterfaceSettingsResp" }, "login": { "$ref": "#/definitions/schema.SiteLoginResp" }, + "mcp_enabled": { + "type": "boolean" + }, "revision": { "type": "string" }, + "site_advanced": { + "$ref": "#/definitions/schema.SiteAdvancedResp" + }, "site_legal": { "$ref": "#/definitions/schema.SiteLegalSimpleResp" }, + "site_questions": { + "$ref": "#/definitions/schema.SiteQuestionsResp" + }, + "site_security": { + "$ref": "#/definitions/schema.SiteSecurityResp" + }, "site_seo": { "$ref": "#/definitions/schema.SiteSeoResp" }, + "site_tags": { + "$ref": "#/definitions/schema.SiteTagsResp" + }, "site_users": { "$ref": "#/definitions/schema.SiteUsersResp" }, - "site_write": { - "$ref": "#/definitions/schema.SiteWriteResp" - }, "theme": { "$ref": "#/definitions/schema.SiteThemeResp" }, + "users_settings": { + "$ref": "#/definitions/schema.SiteUsersSettingsResp" + }, "version": { "type": "string" } @@ -10699,21 +12030,10 @@ const docTemplate = `{ "schema.SiteInterfaceReq": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, - "gravatar_base_url": { - "type": "string" - }, "language": { "type": "string", "maxLength": 128 @@ -10724,24 +12044,13 @@ const docTemplate = `{ } } }, - "schema.SiteInterfaceResp": { + "schema.SiteInterfaceSettingsResp": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, - "gravatar_base_url": { - "type": "string" - }, "language": { "type": "string", "maxLength": 128 @@ -10752,7 +12061,7 @@ const docTemplate = `{ } } }, - "schema.SiteLegalReq": { + "schema.SiteLegalSimpleResp": { "type": "object", "required": [ "external_content_display" @@ -10764,7 +12073,77 @@ const docTemplate = `{ "always_display", "ask_before_display" ] + } + } + }, + "schema.SiteLoginReq": { + "type": "object", + "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" }, + "allow_new_registrations": { + "type": "boolean" + }, + "allow_password_login": { + "type": "boolean" + } + } + }, + "schema.SiteLoginResp": { + "type": "object", + "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" + }, + "allow_new_registrations": { + "type": "boolean" + }, + "allow_password_login": { + "type": "boolean" + } + } + }, + "schema.SiteMCPReq": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "schema.SiteMCPResp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "http_header": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "schema.SitePoliciesReq": { + "type": "object", + "properties": { "privacy_policy_original_text": { "type": "string" }, @@ -10779,19 +12158,9 @@ const docTemplate = `{ } } }, - "schema.SiteLegalResp": { - "type": "object", - "required": [ - "external_content_display" - ], - "properties": { - "external_content_display": { - "type": "string", - "enum": [ - "always_display", - "ask_before_display" - ] - }, + "schema.SitePoliciesResp": { + "type": "object", + "properties": { "privacy_policy_original_text": { "type": "string" }, @@ -10806,61 +12175,78 @@ const docTemplate = `{ } } }, - "schema.SiteLegalSimpleResp": { + "schema.SiteQuestionsReq": { "type": "object", - "required": [ - "external_content_display" - ], "properties": { - "external_content_display": { - "type": "string", - "enum": [ - "always_display", - "ask_before_display" - ] + "min_content": { + "type": "integer", + "maximum": 65535, + "minimum": 0 + }, + "min_tags": { + "type": "integer", + "maximum": 5, + "minimum": 0 + }, + "restrict_answer": { + "type": "boolean" } } }, - "schema.SiteLoginReq": { + "schema.SiteQuestionsResp": { "type": "object", "properties": { - "allow_email_domains": { - "type": "array", - "items": { - "type": "string" - } + "min_content": { + "type": "integer", + "maximum": 65535, + "minimum": 0 }, - "allow_email_registrations": { - "type": "boolean" + "min_tags": { + "type": "integer", + "maximum": 5, + "minimum": 0 }, - "allow_new_registrations": { + "restrict_answer": { "type": "boolean" - }, - "allow_password_login": { + } + } + }, + "schema.SiteSecurityReq": { + "type": "object", + "required": [ + "external_content_display" + ], + "properties": { + "check_update": { "type": "boolean" }, + "external_content_display": { + "type": "string", + "enum": [ + "always_display", + "ask_before_display" + ] + }, "login_required": { "type": "boolean" } } }, - "schema.SiteLoginResp": { + "schema.SiteSecurityResp": { "type": "object", + "required": [ + "external_content_display" + ], "properties": { - "allow_email_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "allow_email_registrations": { - "type": "boolean" - }, - "allow_new_registrations": { + "check_update": { "type": "boolean" }, - "allow_password_login": { - "type": "boolean" + "external_content_display": { + "type": "string", + "enum": [ + "always_display", + "ask_before_display" + ] }, "login_required": { "type": "boolean" @@ -10901,6 +12287,46 @@ const docTemplate = `{ } } }, + "schema.SiteTagsReq": { + "type": "object", + "properties": { + "recommend_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + }, + "required_tag": { + "type": "boolean" + }, + "reserved_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + } + } + }, + "schema.SiteTagsResp": { + "type": "object", + "properties": { + "recommend_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + }, + "required_tag": { + "type": "boolean" + }, + "reserved_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + } + } + }, "schema.SiteThemeReq": { "type": "object", "required": [ @@ -10911,6 +12337,13 @@ const docTemplate = `{ "type": "string", "maxLength": 100 }, + "layout": { + "type": "string", + "enum": [ + "Full-width", + "Fixed-width" + ] + }, "theme": { "type": "string", "maxLength": 255 @@ -10927,6 +12360,9 @@ const docTemplate = `{ "color_scheme": { "type": "string" }, + "layout": { + "type": "string" + }, "theme": { "type": "string" }, @@ -11014,111 +12450,39 @@ const docTemplate = `{ } } }, - "schema.SiteWriteReq": { + "schema.SiteUsersSettingsReq": { "type": "object", + "required": [ + "default_avatar" + ], "properties": { - "authorized_attachment_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "authorized_image_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "max_attachment_size": { - "type": "integer" - }, - "max_image_megapixel": { - "type": "integer" - }, - "max_image_size": { - "type": "integer" - }, - "min_content": { - "type": "integer", - "maximum": 65535, - "minimum": 0 - }, - "min_tags": { - "type": "integer", - "maximum": 5, - "minimum": 0 - }, - "recommend_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } - }, - "required_tag": { - "type": "boolean" - }, - "reserved_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] }, - "restrict_answer": { - "type": "boolean" + "gravatar_base_url": { + "type": "string" } } }, - "schema.SiteWriteResp": { + "schema.SiteUsersSettingsResp": { "type": "object", + "required": [ + "default_avatar" + ], "properties": { - "authorized_attachment_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "authorized_image_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "max_attachment_size": { - "type": "integer" - }, - "max_image_megapixel": { - "type": "integer" - }, - "max_image_size": { - "type": "integer" - }, - "min_content": { - "type": "integer", - "maximum": 65535, - "minimum": 0 - }, - "min_tags": { - "type": "integer", - "maximum": 5, - "minimum": 0 - }, - "recommend_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } - }, - "required_tag": { - "type": "boolean" - }, - "reserved_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] }, - "restrict_answer": { - "type": "boolean" + "gravatar_base_url": { + "type": "string" } } }, @@ -11281,6 +12645,22 @@ const docTemplate = `{ } } }, + "schema.UpdateAPIKeyReq": { + "type": "object", + "required": [ + "description", + "id" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 150 + }, + "id": { + "type": "integer" + } + } + }, "schema.UpdateBadgeStatusReq": { "type": "object", "required": [ diff --git a/docs/release/licenses/LICENSE-joho-godotenv.txt b/docs/release/licenses/LICENSE-joho-godotenv.txt new file mode 100644 index 000000000..9390caf66 --- /dev/null +++ b/docs/release/licenses/LICENSE-joho-godotenv.txt @@ -0,0 +1,22 @@ +Copyright (c) 2013 John Barton + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/swagger.json b/docs/swagger.json index e0f6378e4..dac2b38fd 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -23,6 +23,297 @@ "responses": {} } }, + "/answer/admin/api/ai-config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get AI configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get AI configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteAIResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update AI configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update AI configuration", + "parameters": [ + { + "description": "AI config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteAIReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/ai-models": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get AI models", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get AI models", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetAIModelResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/ai-provider": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get AI provider configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get AI provider configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetAIProviderResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/ai/conversation": { + "get": { + "description": "get conversation detail for admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation-admin" + ], + "summary": "get conversation detail for admin", + "parameters": [ + { + "type": "string", + "description": "conversation id", + "name": "conversation_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.AIConversationAdminDetailResp" + } + } + } + ] + } + } + } + }, + "delete": { + "description": "delete conversation and its related records for admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation-admin" + ], + "summary": "delete conversation for admin", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AIConversationAdminDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/ai/conversation/page": { + "get": { + "description": "get conversation list for admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation-admin" + ], + "summary": "get conversation list for admin", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationAdminListItem" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, "/answer/admin/api/answer/page": { "get": { "security": [ @@ -30,52 +321,523 @@ "ApiKeyAuth": [] } ], - "description": "Status:[available,deleted,pending]", - "consumes": [ - "application/json" - ], + "description": "Status:[available,deleted,pending]", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "AdminAnswerPage admin answer page", + "parameters": [ + { + "type": "integer", + "description": "page size", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "available", + "deleted", + "pending" + ], + "type": "string", + "description": "user status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "answer id or question title", + "name": "query", + "in": "query" + }, + { + "type": "string", + "description": "question id", + "name": "question_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/answer/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update answer status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update answer status", + "parameters": [ + { + "description": "AdminUpdateAnswerStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AdminUpdateAnswerStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/api-key": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update apikey", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update apikey", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateAPIKeyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + }, + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "add apikey", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "add apikey", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AddAPIKeyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.AddAPIKeyResp" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "delete apikey", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "delete apikey", + "parameters": [ + { + "description": "apikey", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.DeleteAPIKeyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/api-key/all": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get all api keys", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get all api keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetAPIKeyResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/badge/status": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update badge status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "update badge status", + "parameters": [ + { + "description": "UpdateBadgeStatusReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/badges": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "list all badges by page", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "AdminBadge" + ], + "summary": "list all badges by page", + "parameters": [ + { + "type": "integer", + "description": "page", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "", + "active", + "inactive" + ], + "type": "string", + "description": "badge status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "search param", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetBadgeListPagedResp" + } + } + } + } + ] + } + } + } + } + }, + "/answer/admin/api/dashboard": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "DashboardInfo", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "DashboardInfo", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/delete/permanently": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "delete permanently", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "delete permanently", + "parameters": [ + { + "description": "DeletePermanentlyReq", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.DeletePermanentlyReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/language/options": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Get language options", + "produces": [ + "application/json" + ], + "tags": [ + "Lang" + ], + "summary": "Get language options", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, + "/answer/admin/api/mcp-config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get MCP configuration", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "get MCP configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteMCPResp" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update MCP configuration", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "AdminAnswerPage admin answer page", + "summary": "update MCP configuration", "parameters": [ { - "type": "integer", - "description": "page size", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "page size", - "name": "page_size", - "in": "query" - }, - { - "enum": [ - "available", - "deleted", - "pending" - ], - "type": "string", - "description": "user status", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "answer id or question title", - "name": "query", - "in": "query" - }, - { - "type": "string", - "description": "question id", - "name": "question_id", - "in": "query" + "description": "MCP config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteMCPReq" + } } ], "responses": { @@ -88,14 +850,58 @@ } } }, - "/answer/admin/api/answer/status": { + "/answer/admin/api/plugin/config": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "get plugin config", + "produces": [ + "application/json" + ], + "tags": [ + "AdminPlugin" + ], + "summary": "get plugin config", + "parameters": [ + { + "type": "string", + "description": "plugin_slug_name", + "name": "plugin_slug_name", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPluginConfigResp" + } + } + } + ] + } + } + } + }, "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update answer status", + "description": "update plugin config", "consumes": [ "application/json" ], @@ -103,17 +909,17 @@ "application/json" ], "tags": [ - "admin" + "AdminPlugin" ], - "summary": "update answer status", + "summary": "update plugin config", "parameters": [ { - "description": "AdminUpdateAnswerStatusReq", + "description": "UpdatePluginConfigReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.AdminUpdateAnswerStatusReq" + "$ref": "#/definitions/schema.UpdatePluginConfigReq" } } ], @@ -127,14 +933,14 @@ } } }, - "/answer/admin/api/badge/status": { + "/answer/admin/api/plugin/status": { "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update badge status", + "description": "update plugin status", "consumes": [ "application/json" ], @@ -142,17 +948,17 @@ "application/json" ], "tags": [ - "AdminBadge" + "AdminPlugin" ], - "summary": "update badge status", + "summary": "update plugin status", "parameters": [ { - "description": "UpdateBadgeStatusReq", + "description": "UpdatePluginStatusReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdateBadgeStatusReq" + "$ref": "#/definitions/schema.UpdatePluginStatusReq" } } ], @@ -166,14 +972,14 @@ } } }, - "/answer/admin/api/badges": { + "/answer/admin/api/plugins": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "list all badges by page", + "description": "get plugin list", "consumes": [ "application/json" ], @@ -181,37 +987,20 @@ "application/json" ], "tags": [ - "AdminBadge" + "AdminPlugin" ], - "summary": "list all badges by page", + "summary": "get plugin list", "parameters": [ { - "type": "integer", - "description": "page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "page size", - "name": "page_size", - "in": "query" - }, - { - "enum": [ - "", - "active", - "inactive" - ], "type": "string", - "description": "badge status", + "description": "status: active/inactive", "name": "status", "in": "query" }, { - "type": "string", - "description": "search param", - "name": "q", + "type": "boolean", + "description": "have config", + "name": "have_config", "in": "query" } ], @@ -229,7 +1018,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/schema.GetBadgeListPagedResp" + "$ref": "#/definitions/schema.GetPluginListResp" } } } @@ -240,14 +1029,14 @@ } } }, - "/answer/admin/api/dashboard": { + "/answer/admin/api/question/page": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "DashboardInfo", + "description": "Status:[available,closed,deleted,pending]", "consumes": [ "application/json" ], @@ -257,7 +1046,39 @@ "tags": [ "admin" ], - "summary": "DashboardInfo", + "summary": "AdminQuestionPage admin question page", + "parameters": [ + { + "type": "integer", + "description": "page size", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size", + "name": "page_size", + "in": "query" + }, + { + "enum": [ + "available", + "closed", + "deleted", + "pending" + ], + "type": "string", + "description": "user status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "question id or title", + "name": "query", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -268,14 +1089,14 @@ } } }, - "/answer/admin/api/delete/permanently": { - "delete": { + "/answer/admin/api/question/status": { + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "delete permanently", + "description": "update question status", "consumes": [ "application/json" ], @@ -285,15 +1106,15 @@ "tags": [ "admin" ], - "summary": "delete permanently", + "summary": "update question status", "parameters": [ { - "description": "DeletePermanentlyReq", + "description": "AdminUpdateQuestionStatusReq", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.DeletePermanentlyReq" + "$ref": "#/definitions/schema.AdminUpdateQuestionStatusReq" } } ], @@ -307,21 +1128,52 @@ } } }, - "/answer/admin/api/language/options": { + "/answer/admin/api/reasons": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Get language options", + "description": "get reasons by object type and action", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Lang" + "reason" + ], + "summary": "get reasons by object type and action", + "parameters": [ + { + "enum": [ + "question", + "answer", + "comment", + "user" + ], + "type": "string", + "description": "object_type", + "name": "object_type", + "in": "query", + "required": true + }, + { + "enum": [ + "status", + "close", + "flag", + "review" + ], + "type": "string", + "description": "action", + "name": "action", + "in": "query", + "required": true + } ], - "summary": "Get language options", "responses": { "200": { "description": "OK", @@ -332,30 +1184,21 @@ } } }, - "/answer/admin/api/plugin/config": { + "/answer/admin/api/roles": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get plugin config", + "description": "get role list", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" - ], - "summary": "get plugin config", - "parameters": [ - { - "type": "string", - "description": "plugin_slug_name", - "name": "plugin_slug_name", - "in": "query", - "required": true - } + "admin" ], + "summary": "get role list", "responses": { "200": { "description": "OK", @@ -368,7 +1211,10 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetPluginConfigResp" + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetRoleResp" + } } } } @@ -376,71 +1222,66 @@ } } } - }, - "put": { + } + }, + "/answer/admin/api/setting/privileges": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update plugin config", - "consumes": [ - "application/json" - ], + "description": "GetPrivilegesConfig get privileges config", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" - ], - "summary": "update plugin config", - "parameters": [ - { - "description": "UpdatePluginConfigReq", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.UpdatePluginConfigReq" - } - } + "admin" ], + "summary": "GetPrivilegesConfig get privileges config", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetPrivilegesConfigResp" + } + } + } + ] } } } - } - }, - "/answer/admin/api/plugin/status": { + }, "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update plugin status", - "consumes": [ - "application/json" - ], + "description": "update privileges config", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" + "admin" ], - "summary": "update plugin status", + "summary": "update privileges config", "parameters": [ { - "description": "UpdatePluginStatusReq", + "description": "config", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdatePluginStatusReq" + "$ref": "#/definitions/schema.UpdatePrivilegesConfigReq" } } ], @@ -454,38 +1295,21 @@ } } }, - "/answer/admin/api/plugins": { + "/answer/admin/api/setting/smtp": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get plugin list", - "consumes": [ - "application/json" - ], + "description": "GetSMTPConfig get smtp config", "produces": [ "application/json" ], "tags": [ - "AdminPlugin" - ], - "summary": "get plugin list", - "parameters": [ - { - "type": "string", - "description": "status: active/inactive", - "name": "status", - "in": "query" - }, - { - "type": "boolean", - "description": "have config", - "name": "have_config", - "in": "query" - } + "admin" ], + "summary": "GetSMTPConfig get smtp config", "responses": { "200": { "description": "OK", @@ -498,10 +1322,7 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.GetPluginListResp" - } + "$ref": "#/definitions/schema.GetSMTPConfigResp" } } } @@ -509,56 +1330,30 @@ } } } - } - }, - "/answer/admin/api/question/page": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Status:[available,closed,deleted,pending]", - "consumes": [ - "application/json" - ], + "description": "update smtp config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "AdminQuestionPage admin question page", + "summary": "update smtp config", "parameters": [ { - "type": "integer", - "description": "page size", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "page size", - "name": "page_size", - "in": "query" - }, - { - "enum": [ - "available", - "closed", - "deleted", - "pending" - ], - "type": "string", - "description": "user status", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "question id or title", - "name": "query", - "in": "query" + "description": "smtp config", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.UpdateSMTPConfigReq" + } } ], "responses": { @@ -571,89 +1366,65 @@ } } }, - "/answer/admin/api/question/status": { - "put": { + "/answer/admin/api/siteinfo/advanced": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "update question status", - "consumes": [ - "application/json" - ], + "description": "get site advanced setting", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update question status", - "parameters": [ - { - "description": "AdminUpdateQuestionStatusReq", - "name": "data", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/schema.AdminUpdateQuestionStatusReq" - } - } - ], + "summary": "get site advanced setting", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.RespBody" + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.SiteAdvancedResp" + } + } + } + ] } } } - } - }, - "/answer/admin/api/reasons": { - "get": { + }, + "put": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get reasons by object type and action", - "consumes": [ - "application/json" - ], + "description": "update site advanced info", "produces": [ "application/json" ], "tags": [ - "reason" + "admin" ], - "summary": "get reasons by object type and action", + "summary": "update site advanced info", "parameters": [ { - "enum": [ - "question", - "answer", - "comment", - "user" - ], - "type": "string", - "description": "object_type", - "name": "object_type", - "in": "query", - "required": true - }, - { - "enum": [ - "status", - "close", - "flag", - "review" - ], - "type": "string", - "description": "action", - "name": "action", - "in": "query", - "required": true + "description": "advanced settings", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteAdvancedReq" + } } ], "responses": { @@ -666,21 +1437,21 @@ } } }, - "/answer/admin/api/roles": { + "/answer/admin/api/siteinfo/branding": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get role list", + "description": "get site interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get role list", + "summary": "get site interface", "responses": { "200": { "description": "OK", @@ -693,10 +1464,7 @@ "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.GetRoleResp" - } + "$ref": "#/definitions/schema.SiteBrandingResp" } } } @@ -704,23 +1472,57 @@ } } } + }, + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "update site info branding", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "update site info branding", + "parameters": [ + { + "description": "branding info", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.SiteBrandingReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } } }, - "/answer/admin/api/setting/privileges": { + "/answer/admin/api/siteinfo/custom-css-html": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "GetPrivilegesConfig get privileges config", + "description": "get site info custom html css config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "GetPrivilegesConfig get privileges config", + "summary": "get site info custom html css config", "responses": { "200": { "description": "OK", @@ -733,7 +1535,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetPrivilegesConfigResp" + "$ref": "#/definitions/schema.SiteCustomCssHTMLResp" } } } @@ -748,22 +1550,22 @@ "ApiKeyAuth": [] } ], - "description": "update privileges config", + "description": "update site custom css html config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update privileges config", + "summary": "update site custom css html config", "parameters": [ { - "description": "config", + "description": "login info", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdatePrivilegesConfigReq" + "$ref": "#/definitions/schema.SiteCustomCssHTMLReq" } } ], @@ -777,21 +1579,21 @@ } } }, - "/answer/admin/api/setting/smtp": { + "/answer/admin/api/siteinfo/general": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "GetSMTPConfig get smtp config", + "description": "get site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "GetSMTPConfig get smtp config", + "summary": "get site general information", "responses": { "200": { "description": "OK", @@ -804,7 +1606,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetSMTPConfigResp" + "$ref": "#/definitions/schema.SiteGeneralResp" } } } @@ -819,22 +1621,22 @@ "ApiKeyAuth": [] } ], - "description": "update smtp config", + "description": "update site general information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update smtp config", + "summary": "update site general information", "parameters": [ { - "description": "smtp config", + "description": "general", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.UpdateSMTPConfigReq" + "$ref": "#/definitions/schema.SiteGeneralReq" } } ], @@ -848,7 +1650,7 @@ } } }, - "/answer/admin/api/siteinfo/branding": { + "/answer/admin/api/siteinfo/interface": { "get": { "security": [ { @@ -875,7 +1677,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteBrandingResp" + "$ref": "#/definitions/schema.SiteInterfaceSettingsResp" } } } @@ -890,22 +1692,22 @@ "ApiKeyAuth": [] } ], - "description": "update site info branding", + "description": "update site info interface", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site info branding", + "summary": "update site info interface", "parameters": [ { - "description": "branding info", + "description": "general", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteBrandingReq" + "$ref": "#/definitions/schema.SiteInterfaceReq" } } ], @@ -919,21 +1721,21 @@ } } }, - "/answer/admin/api/siteinfo/custom-css-html": { + "/answer/admin/api/siteinfo/login": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site info custom html css config", + "description": "get site info login config", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site info custom html css config", + "summary": "get site info login config", "responses": { "200": { "description": "OK", @@ -946,7 +1748,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteCustomCssHTMLResp" + "$ref": "#/definitions/schema.SiteLoginResp" } } } @@ -961,14 +1763,14 @@ "ApiKeyAuth": [] } ], - "description": "update site custom css html config", + "description": "update site login", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site custom css html config", + "summary": "update site login", "parameters": [ { "description": "login info", @@ -976,7 +1778,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteCustomCssHTMLReq" + "$ref": "#/definitions/schema.SiteLoginReq" } } ], @@ -990,21 +1792,21 @@ } } }, - "/answer/admin/api/siteinfo/general": { + "/answer/admin/api/siteinfo/polices": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site general information", + "description": "Get the policies information for the site", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site general information", + "summary": "Get the policies information for the site", "responses": { "200": { "description": "OK", @@ -1017,7 +1819,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteGeneralResp" + "$ref": "#/definitions/schema.SitePoliciesResp" } } } @@ -1032,22 +1834,22 @@ "ApiKeyAuth": [] } ], - "description": "update site general information", + "description": "update site policies configuration", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site general information", + "summary": "update site policies configuration", "parameters": [ { - "description": "general", + "description": "write info", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteGeneralReq" + "$ref": "#/definitions/schema.SitePoliciesReq" } } ], @@ -1061,21 +1863,21 @@ } } }, - "/answer/admin/api/siteinfo/interface": { + "/answer/admin/api/siteinfo/question": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site interface", + "description": "get site questions setting", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site interface", + "summary": "get site questions setting", "responses": { "200": { "description": "OK", @@ -1088,7 +1890,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteInterfaceResp" + "$ref": "#/definitions/schema.SiteQuestionsResp" } } } @@ -1103,22 +1905,22 @@ "ApiKeyAuth": [] } ], - "description": "update site info interface", + "description": "update site question settings", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site info interface", + "summary": "update site question settings", "parameters": [ { - "description": "general", + "description": "questions settings", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteInterfaceReq" + "$ref": "#/definitions/schema.SiteQuestionsReq" } } ], @@ -1132,21 +1934,21 @@ } } }, - "/answer/admin/api/siteinfo/legal": { + "/answer/admin/api/siteinfo/security": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Set the legal information for the site", + "description": "Get the security information for the site", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "Set the legal information for the site", + "summary": "Get the security information for the site", "responses": { "200": { "description": "OK", @@ -1159,7 +1961,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteLegalResp" + "$ref": "#/definitions/schema.SiteSecurityResp" } } } @@ -1174,14 +1976,14 @@ "ApiKeyAuth": [] } ], - "description": "update site legal info", + "description": "update site security configuration", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site legal info", + "summary": "update site security configuration", "parameters": [ { "description": "write info", @@ -1189,7 +1991,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteLegalReq" + "$ref": "#/definitions/schema.SiteSecurityReq" } } ], @@ -1203,21 +2005,21 @@ } } }, - "/answer/admin/api/siteinfo/login": { + "/answer/admin/api/siteinfo/seo": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site info login config", + "description": "get site seo information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site info login config", + "summary": "get site seo information", "responses": { "200": { "description": "OK", @@ -1230,7 +2032,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteLoginResp" + "$ref": "#/definitions/schema.SiteSeoResp" } } } @@ -1245,22 +2047,22 @@ "ApiKeyAuth": [] } ], - "description": "update site login", + "description": "update site seo information", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site login", + "summary": "update site seo information", "parameters": [ { - "description": "login info", + "description": "seo", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteLoginReq" + "$ref": "#/definitions/schema.SiteSeoReq" } } ], @@ -1274,21 +2076,21 @@ } } }, - "/answer/admin/api/siteinfo/seo": { + "/answer/admin/api/siteinfo/tag": { "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "get site seo information", + "description": "get site tags setting", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "get site seo information", + "summary": "get site tags setting", "responses": { "200": { "description": "OK", @@ -1301,7 +2103,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteSeoResp" + "$ref": "#/definitions/schema.SiteTagsResp" } } } @@ -1316,22 +2118,22 @@ "ApiKeyAuth": [] } ], - "description": "update site seo information", + "description": "update site tag settings", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site seo information", + "summary": "update site tag settings", "parameters": [ { - "description": "seo", + "description": "tags settings", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteSeoReq" + "$ref": "#/definitions/schema.SiteTagsReq" } } ], @@ -1487,7 +2289,7 @@ } } }, - "/answer/admin/api/siteinfo/write": { + "/answer/admin/api/siteinfo/users-settings": { "get": { "security": [ { @@ -1514,7 +2316,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.SiteWriteResp" + "$ref": "#/definitions/schema.SiteUsersSettingsResp" } } } @@ -1529,22 +2331,22 @@ "ApiKeyAuth": [] } ], - "description": "update site write info", + "description": "update site info users settings", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "update site write info", + "summary": "update site info users settings", "parameters": [ { - "description": "write info", + "description": "general", "name": "data", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/schema.SiteWriteReq" + "$ref": "#/definitions/schema.SiteUsersSettingsReq" } } ], @@ -1963,22 +2765,170 @@ "type": "object", "properties": { "data": { - "allOf": [ - { - "$ref": "#/definitions/pager.PageModel" - }, - { - "type": "object", - "properties": { - "records": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.GetUserPageResp" - } - } - } - } - ] + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.GetUserPageResp" + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/activity/timeline": { + "get": { + "description": "get object timeline", + "produces": [ + "application/json" + ], + "tags": [ + "Comment" + ], + "summary": "get object timeline", + "parameters": [ + { + "type": "string", + "description": "object id", + "name": "object_id", + "in": "query" + }, + { + "type": "string", + "description": "tag slug name", + "name": "tag_slug_name", + "in": "query" + }, + { + "enum": [ + "question", + "answer", + "tag" + ], + "type": "string", + "description": "object type", + "name": "object_type", + "in": "query" + }, + { + "type": "boolean", + "description": "is show vote", + "name": "show_vote", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetObjectTimelineResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/activity/timeline/detail": { + "get": { + "description": "get object timeline detail", + "produces": [ + "application/json" + ], + "tags": [ + "Comment" + ], + "summary": "get object timeline detail", + "parameters": [ + { + "type": "string", + "description": "revision id", + "name": "revision_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.GetObjectTimelineResp" + } + } + } + ] + } + } + } + } + }, + "/answer/api/v1/ai/conversation": { + "get": { + "description": "get conversation detail", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ai-conversation" + ], + "summary": "get conversation detail", + "parameters": [ + { + "type": "string", + "description": "conversation id", + "name": "conversation_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.RespBody" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/schema.AIConversationDetailResp" } } } @@ -1988,44 +2938,30 @@ } } }, - "/answer/api/v1/activity/timeline": { + "/answer/api/v1/ai/conversation/page": { "get": { - "description": "get object timeline", + "description": "get conversation list", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Comment" + "ai-conversation" ], - "summary": "get object timeline", + "summary": "get conversation list", "parameters": [ { - "type": "string", - "description": "object id", - "name": "object_id", - "in": "query" - }, - { - "type": "string", - "description": "tag slug name", - "name": "tag_slug_name", - "in": "query" - }, - { - "enum": [ - "question", - "answer", - "tag" - ], - "type": "string", - "description": "object type", - "name": "object_type", + "type": "integer", + "description": "page", + "name": "page", "in": "query" }, { - "type": "boolean", - "description": "is show vote", - "name": "show_vote", + "type": "integer", + "description": "page size", + "name": "page_size", "in": "query" } ], @@ -2041,7 +2977,22 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/schema.GetObjectTimelineResp" + "allOf": [ + { + "$ref": "#/definitions/pager.PageModel" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationListItem" + } + } + } + } + ] } } } @@ -2051,42 +3002,35 @@ } } }, - "/answer/api/v1/activity/timeline/detail": { - "get": { - "description": "get object timeline detail", + "/answer/api/v1/ai/conversation/vote": { + "post": { + "description": "vote record", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Comment" + "ai-conversation" ], - "summary": "get object timeline detail", + "summary": "vote record", "parameters": [ { - "type": "string", - "description": "revision id", - "name": "revision_id", - "in": "query", - "required": true + "description": "vote request", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.AIConversationVoteReq" + } } ], "responses": { "200": { "description": "OK", "schema": { - "allOf": [ - { - "$ref": "#/definitions/handler.RespBody" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/schema.GetObjectTimelineResp" - } - } - } - ] + "$ref": "#/definitions/handler.RespBody" } } } @@ -7701,6 +8645,176 @@ } } }, + "schema.AIConversationAdminDeleteReq": { + "type": "object", + "required": [ + "conversation_id" + ], + "properties": { + "conversation_id": { + "type": "string" + } + } + }, + "schema.AIConversationAdminDetailResp": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationRecord" + } + }, + "topic": { + "type": "string" + }, + "user_info": { + "$ref": "#/definitions/schema.AIConversationUserInfo" + } + } + }, + "schema.AIConversationAdminListItem": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "helpful_count": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "topic": { + "type": "string" + }, + "unhelpful_count": { + "type": "integer" + }, + "user_info": { + "$ref": "#/definitions/schema.AIConversationUserInfo" + } + } + }, + "schema.AIConversationDetailResp": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.AIConversationRecord" + } + }, + "topic": { + "type": "string" + }, + "updated_at": { + "type": "integer" + } + } + }, + "schema.AIConversationListItem": { + "type": "object", + "properties": { + "conversation_id": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "topic": { + "type": "string" + } + } + }, + "schema.AIConversationRecord": { + "type": "object", + "properties": { + "chat_completion_id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "helpful": { + "type": "integer" + }, + "role": { + "type": "string" + }, + "unhelpful": { + "type": "integer" + } + } + }, + "schema.AIConversationUserInfo": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "schema.AIConversationVoteReq": { + "type": "object", + "required": [ + "chat_completion_id", + "vote_type" + ], + "properties": { + "cancel": { + "type": "boolean" + }, + "chat_completion_id": { + "type": "string" + }, + "vote_type": { + "type": "string", + "enum": [ + "helpful", + "unhelpful" + ] + } + } + }, + "schema.AIPromptConfig": { + "type": "object", + "properties": { + "en_us": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + } + }, "schema.AcceptAnswerReq": { "type": "object", "required": [ @@ -7791,6 +8905,34 @@ } } }, + "schema.AddAPIKeyReq": { + "type": "object", + "required": [ + "description", + "scope" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 150 + }, + "scope": { + "type": "string", + "enum": [ + "read-only", + "global" + ] + } + } + }, + "schema.AddAPIKeyResp": { + "type": "object", + "properties": { + "access_key": { + "type": "string" + } + } + }, "schema.AddCommentReq": { "type": "object", "required": [ @@ -8271,6 +9413,14 @@ } } }, + "schema.DeleteAPIKeyReq": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, "schema.DeletePermanentlyReq": { "type": "object", "required": [ @@ -8387,6 +9537,60 @@ } } }, + "schema.GetAIModelResp": { + "type": "object", + "properties": { + "created": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "object": { + "type": "string" + }, + "owned_by": { + "type": "string" + } + } + }, + "schema.GetAIProviderResp": { + "type": "object", + "properties": { + "default_api_host": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "schema.GetAPIKeyResp": { + "type": "object", + "properties": { + "access_key": { + "type": "string" + }, + "created_at": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "integer" + }, + "scope": { + "type": "string" + } + } + }, "schema.GetAnswerInfoResp": { "type": "object", "properties": { @@ -10470,6 +11674,121 @@ } } }, + "schema.SiteAIProvider": { + "type": "object", + "properties": { + "api_host": { + "type": "string", + "maxLength": 512 + }, + "api_key": { + "type": "string", + "maxLength": 256 + }, + "model": { + "type": "string", + "maxLength": 100 + }, + "provider": { + "type": "string", + "maxLength": 50 + } + } + }, + "schema.SiteAIReq": { + "type": "object", + "properties": { + "ai_providers": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteAIProvider" + } + }, + "chosen_provider": { + "type": "string", + "maxLength": 50 + }, + "enabled": { + "type": "boolean" + }, + "prompt_config": { + "$ref": "#/definitions/schema.AIPromptConfig" + } + } + }, + "schema.SiteAIResp": { + "type": "object", + "properties": { + "ai_providers": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteAIProvider" + } + }, + "chosen_provider": { + "type": "string", + "maxLength": 50 + }, + "enabled": { + "type": "boolean" + }, + "prompt_config": { + "$ref": "#/definitions/schema.AIPromptConfig" + } + } + }, + "schema.SiteAdvancedReq": { + "type": "object", + "properties": { + "authorized_attachment_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "authorized_image_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_attachment_size": { + "type": "integer" + }, + "max_image_megapixel": { + "type": "integer" + }, + "max_image_size": { + "type": "integer" + } + } + }, + "schema.SiteAdvancedResp": { + "type": "object", + "properties": { + "authorized_attachment_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "authorized_image_extensions": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_attachment_size": { + "type": "integer" + }, + "max_image_megapixel": { + "type": "integer" + }, + "max_image_size": { + "type": "integer" + } + } + }, "schema.SiteBrandingReq": { "type": "object", "properties": { @@ -10570,9 +11889,6 @@ "site_url" ], "properties": { - "check_update": { - "type": "boolean" - }, "contact_email": { "type": "string", "maxLength": 512 @@ -10603,9 +11919,6 @@ "site_url" ], "properties": { - "check_update": { - "type": "boolean" - }, "contact_email": { "type": "string", "maxLength": 512 @@ -10631,6 +11944,9 @@ "schema.SiteInfoResp": { "type": "object", "properties": { + "ai_enabled": { + "type": "boolean" + }, "branding": { "$ref": "#/definitions/schema.SiteBrandingResp" }, @@ -10641,29 +11957,44 @@ "$ref": "#/definitions/schema.SiteGeneralResp" }, "interface": { - "$ref": "#/definitions/schema.SiteInterfaceResp" + "$ref": "#/definitions/schema.SiteInterfaceSettingsResp" }, "login": { "$ref": "#/definitions/schema.SiteLoginResp" }, + "mcp_enabled": { + "type": "boolean" + }, "revision": { "type": "string" }, + "site_advanced": { + "$ref": "#/definitions/schema.SiteAdvancedResp" + }, "site_legal": { "$ref": "#/definitions/schema.SiteLegalSimpleResp" }, + "site_questions": { + "$ref": "#/definitions/schema.SiteQuestionsResp" + }, + "site_security": { + "$ref": "#/definitions/schema.SiteSecurityResp" + }, "site_seo": { "$ref": "#/definitions/schema.SiteSeoResp" }, + "site_tags": { + "$ref": "#/definitions/schema.SiteTagsResp" + }, "site_users": { "$ref": "#/definitions/schema.SiteUsersResp" }, - "site_write": { - "$ref": "#/definitions/schema.SiteWriteResp" - }, "theme": { "$ref": "#/definitions/schema.SiteThemeResp" }, + "users_settings": { + "$ref": "#/definitions/schema.SiteUsersSettingsResp" + }, "version": { "type": "string" } @@ -10672,21 +12003,10 @@ "schema.SiteInterfaceReq": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, - "gravatar_base_url": { - "type": "string" - }, "language": { "type": "string", "maxLength": 128 @@ -10697,24 +12017,13 @@ } } }, - "schema.SiteInterfaceResp": { + "schema.SiteInterfaceSettingsResp": { "type": "object", "required": [ - "default_avatar", "language", "time_zone" ], "properties": { - "default_avatar": { - "type": "string", - "enum": [ - "system", - "gravatar" - ] - }, - "gravatar_base_url": { - "type": "string" - }, "language": { "type": "string", "maxLength": 128 @@ -10725,7 +12034,7 @@ } } }, - "schema.SiteLegalReq": { + "schema.SiteLegalSimpleResp": { "type": "object", "required": [ "external_content_display" @@ -10737,7 +12046,77 @@ "always_display", "ask_before_display" ] + } + } + }, + "schema.SiteLoginReq": { + "type": "object", + "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" }, + "allow_new_registrations": { + "type": "boolean" + }, + "allow_password_login": { + "type": "boolean" + } + } + }, + "schema.SiteLoginResp": { + "type": "object", + "properties": { + "allow_email_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "allow_email_registrations": { + "type": "boolean" + }, + "allow_new_registrations": { + "type": "boolean" + }, + "allow_password_login": { + "type": "boolean" + } + } + }, + "schema.SiteMCPReq": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "schema.SiteMCPResp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "http_header": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "schema.SitePoliciesReq": { + "type": "object", + "properties": { "privacy_policy_original_text": { "type": "string" }, @@ -10752,19 +12131,9 @@ } } }, - "schema.SiteLegalResp": { - "type": "object", - "required": [ - "external_content_display" - ], - "properties": { - "external_content_display": { - "type": "string", - "enum": [ - "always_display", - "ask_before_display" - ] - }, + "schema.SitePoliciesResp": { + "type": "object", + "properties": { "privacy_policy_original_text": { "type": "string" }, @@ -10779,61 +12148,78 @@ } } }, - "schema.SiteLegalSimpleResp": { + "schema.SiteQuestionsReq": { "type": "object", - "required": [ - "external_content_display" - ], "properties": { - "external_content_display": { - "type": "string", - "enum": [ - "always_display", - "ask_before_display" - ] + "min_content": { + "type": "integer", + "maximum": 65535, + "minimum": 0 + }, + "min_tags": { + "type": "integer", + "maximum": 5, + "minimum": 0 + }, + "restrict_answer": { + "type": "boolean" } } }, - "schema.SiteLoginReq": { + "schema.SiteQuestionsResp": { "type": "object", "properties": { - "allow_email_domains": { - "type": "array", - "items": { - "type": "string" - } + "min_content": { + "type": "integer", + "maximum": 65535, + "minimum": 0 }, - "allow_email_registrations": { - "type": "boolean" + "min_tags": { + "type": "integer", + "maximum": 5, + "minimum": 0 }, - "allow_new_registrations": { + "restrict_answer": { "type": "boolean" - }, - "allow_password_login": { + } + } + }, + "schema.SiteSecurityReq": { + "type": "object", + "required": [ + "external_content_display" + ], + "properties": { + "check_update": { "type": "boolean" }, + "external_content_display": { + "type": "string", + "enum": [ + "always_display", + "ask_before_display" + ] + }, "login_required": { "type": "boolean" } } }, - "schema.SiteLoginResp": { + "schema.SiteSecurityResp": { "type": "object", + "required": [ + "external_content_display" + ], "properties": { - "allow_email_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "allow_email_registrations": { - "type": "boolean" - }, - "allow_new_registrations": { + "check_update": { "type": "boolean" }, - "allow_password_login": { - "type": "boolean" + "external_content_display": { + "type": "string", + "enum": [ + "always_display", + "ask_before_display" + ] }, "login_required": { "type": "boolean" @@ -10874,6 +12260,46 @@ } } }, + "schema.SiteTagsReq": { + "type": "object", + "properties": { + "recommend_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + }, + "required_tag": { + "type": "boolean" + }, + "reserved_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + } + } + }, + "schema.SiteTagsResp": { + "type": "object", + "properties": { + "recommend_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + }, + "required_tag": { + "type": "boolean" + }, + "reserved_tags": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.SiteWriteTag" + } + } + } + }, "schema.SiteThemeReq": { "type": "object", "required": [ @@ -10884,6 +12310,13 @@ "type": "string", "maxLength": 100 }, + "layout": { + "type": "string", + "enum": [ + "Full-width", + "Fixed-width" + ] + }, "theme": { "type": "string", "maxLength": 255 @@ -10900,6 +12333,9 @@ "color_scheme": { "type": "string" }, + "layout": { + "type": "string" + }, "theme": { "type": "string" }, @@ -10987,111 +12423,39 @@ } } }, - "schema.SiteWriteReq": { + "schema.SiteUsersSettingsReq": { "type": "object", + "required": [ + "default_avatar" + ], "properties": { - "authorized_attachment_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "authorized_image_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "max_attachment_size": { - "type": "integer" - }, - "max_image_megapixel": { - "type": "integer" - }, - "max_image_size": { - "type": "integer" - }, - "min_content": { - "type": "integer", - "maximum": 65535, - "minimum": 0 - }, - "min_tags": { - "type": "integer", - "maximum": 5, - "minimum": 0 - }, - "recommend_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } - }, - "required_tag": { - "type": "boolean" - }, - "reserved_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] }, - "restrict_answer": { - "type": "boolean" + "gravatar_base_url": { + "type": "string" } } }, - "schema.SiteWriteResp": { + "schema.SiteUsersSettingsResp": { "type": "object", + "required": [ + "default_avatar" + ], "properties": { - "authorized_attachment_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "authorized_image_extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "max_attachment_size": { - "type": "integer" - }, - "max_image_megapixel": { - "type": "integer" - }, - "max_image_size": { - "type": "integer" - }, - "min_content": { - "type": "integer", - "maximum": 65535, - "minimum": 0 - }, - "min_tags": { - "type": "integer", - "maximum": 5, - "minimum": 0 - }, - "recommend_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } - }, - "required_tag": { - "type": "boolean" - }, - "reserved_tags": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.SiteWriteTag" - } + "default_avatar": { + "type": "string", + "enum": [ + "system", + "gravatar" + ] }, - "restrict_answer": { - "type": "boolean" + "gravatar_base_url": { + "type": "string" } } }, @@ -11254,6 +12618,22 @@ } } }, + "schema.UpdateAPIKeyReq": { + "type": "object", + "required": [ + "description", + "id" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 150 + }, + "id": { + "type": "integer" + } + } + }, "schema.UpdateBadgeStatusReq": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e0244083b..7a7adb681 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -157,6 +157,117 @@ definitions: select_theme: type: string type: object + schema.AIConversationAdminDeleteReq: + properties: + conversation_id: + type: string + required: + - conversation_id + type: object + schema.AIConversationAdminDetailResp: + properties: + conversation_id: + type: string + created_at: + type: integer + records: + items: + $ref: '#/definitions/schema.AIConversationRecord' + type: array + topic: + type: string + user_info: + $ref: '#/definitions/schema.AIConversationUserInfo' + type: object + schema.AIConversationAdminListItem: + properties: + created_at: + type: integer + helpful_count: + type: integer + id: + type: string + topic: + type: string + unhelpful_count: + type: integer + user_info: + $ref: '#/definitions/schema.AIConversationUserInfo' + type: object + schema.AIConversationDetailResp: + properties: + conversation_id: + type: string + created_at: + type: integer + records: + items: + $ref: '#/definitions/schema.AIConversationRecord' + type: array + topic: + type: string + updated_at: + type: integer + type: object + schema.AIConversationListItem: + properties: + conversation_id: + type: string + created_at: + type: integer + topic: + type: string + type: object + schema.AIConversationRecord: + properties: + chat_completion_id: + type: string + content: + type: string + created_at: + type: integer + helpful: + type: integer + role: + type: string + unhelpful: + type: integer + type: object + schema.AIConversationUserInfo: + properties: + avatar: + type: string + display_name: + type: string + id: + type: string + rank: + type: integer + username: + type: string + type: object + schema.AIConversationVoteReq: + properties: + cancel: + type: boolean + chat_completion_id: + type: string + vote_type: + enum: + - helpful + - unhelpful + type: string + required: + - chat_completion_id + - vote_type + type: object + schema.AIPromptConfig: + properties: + en_us: + type: string + zh_cn: + type: string + type: object schema.AcceptAnswerReq: properties: answer_id: @@ -216,6 +327,25 @@ definitions: verify: type: boolean type: object + schema.AddAPIKeyReq: + properties: + description: + maxLength: 150 + type: string + scope: + enum: + - read-only + - global + type: string + required: + - description + - scope + type: object + schema.AddAPIKeyResp: + properties: + access_key: + type: string + type: object schema.AddCommentReq: properties: captcha_code: @@ -546,6 +676,11 @@ definitions: name: type: string type: object + schema.DeleteAPIKeyReq: + properties: + id: + type: integer + type: object schema.DeletePermanentlyReq: properties: type: @@ -629,6 +764,41 @@ definitions: description: if user is followed object will be true,otherwise false type: boolean type: object + schema.GetAIModelResp: + properties: + created: + type: integer + id: + type: string + object: + type: string + owned_by: + type: string + type: object + schema.GetAIProviderResp: + properties: + default_api_host: + type: string + display_name: + type: string + name: + type: string + type: object + schema.GetAPIKeyResp: + properties: + access_key: + type: string + created_at: + type: integer + description: + type: string + id: + type: integer + last_used_at: + type: integer + scope: + type: string + type: object schema.GetAnswerInfoResp: properties: info: @@ -2072,6 +2242,83 @@ definitions: required: - user_id type: object + schema.SiteAIProvider: + properties: + api_host: + maxLength: 512 + type: string + api_key: + maxLength: 256 + type: string + model: + maxLength: 100 + type: string + provider: + maxLength: 50 + type: string + type: object + schema.SiteAIReq: + properties: + ai_providers: + items: + $ref: '#/definitions/schema.SiteAIProvider' + type: array + chosen_provider: + maxLength: 50 + type: string + enabled: + type: boolean + prompt_config: + $ref: '#/definitions/schema.AIPromptConfig' + type: object + schema.SiteAIResp: + properties: + ai_providers: + items: + $ref: '#/definitions/schema.SiteAIProvider' + type: array + chosen_provider: + maxLength: 50 + type: string + enabled: + type: boolean + prompt_config: + $ref: '#/definitions/schema.AIPromptConfig' + type: object + schema.SiteAdvancedReq: + properties: + authorized_attachment_extensions: + items: + type: string + type: array + authorized_image_extensions: + items: + type: string + type: array + max_attachment_size: + type: integer + max_image_megapixel: + type: integer + max_image_size: + type: integer + type: object + schema.SiteAdvancedResp: + properties: + authorized_attachment_extensions: + items: + type: string + type: array + authorized_image_extensions: + items: + type: string + type: array + max_attachment_size: + type: integer + max_image_megapixel: + type: integer + max_image_size: + type: integer + type: object schema.SiteBrandingReq: properties: favicon: @@ -2140,8 +2387,6 @@ definitions: type: object schema.SiteGeneralReq: properties: - check_update: - type: boolean contact_email: maxLength: 512 type: string @@ -2164,8 +2409,6 @@ definitions: type: object schema.SiteGeneralResp: properties: - check_update: - type: boolean contact_email: maxLength: 512 type: string @@ -2188,6 +2431,8 @@ definitions: type: object schema.SiteInfoResp: properties: + ai_enabled: + type: boolean branding: $ref: '#/definitions/schema.SiteBrandingResp' custom_css_html: @@ -2195,33 +2440,36 @@ definitions: general: $ref: '#/definitions/schema.SiteGeneralResp' interface: - $ref: '#/definitions/schema.SiteInterfaceResp' + $ref: '#/definitions/schema.SiteInterfaceSettingsResp' login: $ref: '#/definitions/schema.SiteLoginResp' + mcp_enabled: + type: boolean revision: type: string + site_advanced: + $ref: '#/definitions/schema.SiteAdvancedResp' site_legal: $ref: '#/definitions/schema.SiteLegalSimpleResp' + site_questions: + $ref: '#/definitions/schema.SiteQuestionsResp' + site_security: + $ref: '#/definitions/schema.SiteSecurityResp' site_seo: $ref: '#/definitions/schema.SiteSeoResp' + site_tags: + $ref: '#/definitions/schema.SiteTagsResp' site_users: $ref: '#/definitions/schema.SiteUsersResp' - site_write: - $ref: '#/definitions/schema.SiteWriteResp' theme: $ref: '#/definitions/schema.SiteThemeResp' + users_settings: + $ref: '#/definitions/schema.SiteUsersSettingsResp' version: type: string type: object schema.SiteInterfaceReq: properties: - default_avatar: - enum: - - system - - gravatar - type: string - gravatar_base_url: - type: string language: maxLength: 128 type: string @@ -2229,19 +2477,11 @@ definitions: maxLength: 128 type: string required: - - default_avatar - language - time_zone type: object - schema.SiteInterfaceResp: + schema.SiteInterfaceSettingsResp: properties: - default_avatar: - enum: - - system - - gravatar - type: string - gravatar_base_url: - type: string language: maxLength: 128 type: string @@ -2249,46 +2489,9 @@ definitions: maxLength: 128 type: string required: - - default_avatar - language - time_zone type: object - schema.SiteLegalReq: - properties: - external_content_display: - enum: - - always_display - - ask_before_display - type: string - privacy_policy_original_text: - type: string - privacy_policy_parsed_text: - type: string - terms_of_service_original_text: - type: string - terms_of_service_parsed_text: - type: string - required: - - external_content_display - type: object - schema.SiteLegalResp: - properties: - external_content_display: - enum: - - always_display - - ask_before_display - type: string - privacy_policy_original_text: - type: string - privacy_policy_parsed_text: - type: string - terms_of_service_original_text: - type: string - terms_of_service_parsed_text: - type: string - required: - - external_content_display - type: object schema.SiteLegalSimpleResp: properties: external_content_display: @@ -2311,8 +2514,6 @@ definitions: type: boolean allow_password_login: type: boolean - login_required: - type: boolean type: object schema.SiteLoginResp: properties: @@ -2326,38 +2527,159 @@ definitions: type: boolean allow_password_login: type: boolean - login_required: + type: object + schema.SiteMCPReq: + properties: + enabled: type: boolean type: object - schema.SiteSeoReq: + schema.SiteMCPResp: properties: - permalink: - maximum: 4 - minimum: 0 - type: integer - robots: + enabled: + type: boolean + http_header: + type: string + type: + type: string + url: type: string - required: - - permalink - - robots type: object - schema.SiteSeoResp: + schema.SitePoliciesReq: properties: - permalink: - maximum: 4 - minimum: 0 - type: integer - robots: + privacy_policy_original_text: type: string - required: - - permalink - - robots + privacy_policy_parsed_text: + type: string + terms_of_service_original_text: + type: string + terms_of_service_parsed_text: + type: string + type: object + schema.SitePoliciesResp: + properties: + privacy_policy_original_text: + type: string + privacy_policy_parsed_text: + type: string + terms_of_service_original_text: + type: string + terms_of_service_parsed_text: + type: string + type: object + schema.SiteQuestionsReq: + properties: + min_content: + maximum: 65535 + minimum: 0 + type: integer + min_tags: + maximum: 5 + minimum: 0 + type: integer + restrict_answer: + type: boolean + type: object + schema.SiteQuestionsResp: + properties: + min_content: + maximum: 65535 + minimum: 0 + type: integer + min_tags: + maximum: 5 + minimum: 0 + type: integer + restrict_answer: + type: boolean + type: object + schema.SiteSecurityReq: + properties: + check_update: + type: boolean + external_content_display: + enum: + - always_display + - ask_before_display + type: string + login_required: + type: boolean + required: + - external_content_display + type: object + schema.SiteSecurityResp: + properties: + check_update: + type: boolean + external_content_display: + enum: + - always_display + - ask_before_display + type: string + login_required: + type: boolean + required: + - external_content_display + type: object + schema.SiteSeoReq: + properties: + permalink: + maximum: 4 + minimum: 0 + type: integer + robots: + type: string + required: + - permalink + - robots + type: object + schema.SiteSeoResp: + properties: + permalink: + maximum: 4 + minimum: 0 + type: integer + robots: + type: string + required: + - permalink + - robots + type: object + schema.SiteTagsReq: + properties: + recommend_tags: + items: + $ref: '#/definitions/schema.SiteWriteTag' + type: array + required_tag: + type: boolean + reserved_tags: + items: + $ref: '#/definitions/schema.SiteWriteTag' + type: array + type: object + schema.SiteTagsResp: + properties: + recommend_tags: + items: + $ref: '#/definitions/schema.SiteWriteTag' + type: array + required_tag: + type: boolean + reserved_tags: + items: + $ref: '#/definitions/schema.SiteWriteTag' + type: array type: object schema.SiteThemeReq: properties: color_scheme: maxLength: 100 type: string + layout: + enum: + - Full-width + - Fixed-width + type: string theme: maxLength: 255 type: string @@ -2371,6 +2693,8 @@ definitions: properties: color_scheme: type: string + layout: + type: string theme: type: string theme_config: @@ -2429,79 +2753,29 @@ definitions: required: - default_avatar type: object - schema.SiteWriteReq: + schema.SiteUsersSettingsReq: properties: - authorized_attachment_extensions: - items: - type: string - type: array - authorized_image_extensions: - items: - type: string - type: array - max_attachment_size: - type: integer - max_image_megapixel: - type: integer - max_image_size: - type: integer - min_content: - maximum: 65535 - minimum: 0 - type: integer - min_tags: - maximum: 5 - minimum: 0 - type: integer - recommend_tags: - items: - $ref: '#/definitions/schema.SiteWriteTag' - type: array - required_tag: - type: boolean - reserved_tags: - items: - $ref: '#/definitions/schema.SiteWriteTag' - type: array - restrict_answer: - type: boolean + default_avatar: + enum: + - system + - gravatar + type: string + gravatar_base_url: + type: string + required: + - default_avatar type: object - schema.SiteWriteResp: + schema.SiteUsersSettingsResp: properties: - authorized_attachment_extensions: - items: - type: string - type: array - authorized_image_extensions: - items: - type: string - type: array - max_attachment_size: - type: integer - max_image_megapixel: - type: integer - max_image_size: - type: integer - min_content: - maximum: 65535 - minimum: 0 - type: integer - min_tags: - maximum: 5 - minimum: 0 - type: integer - recommend_tags: - items: - $ref: '#/definitions/schema.SiteWriteTag' - type: array - required_tag: - type: boolean - reserved_tags: - items: - $ref: '#/definitions/schema.SiteWriteTag' - type: array - restrict_answer: - type: boolean + default_avatar: + enum: + - system + - gravatar + type: string + gravatar_base_url: + type: string + required: + - default_avatar type: object schema.SiteWriteTag: properties: @@ -2612,6 +2886,17 @@ definitions: url_title: type: string type: object + schema.UpdateAPIKeyReq: + properties: + description: + maxLength: 150 + type: string + id: + type: integer + required: + - description + - id + type: object schema.UpdateBadgeStatusReq: properties: id: @@ -3200,6 +3485,174 @@ paths: summary: if config file not exist try to redirect to install page tags: - installation + /answer/admin/api/ai-config: + get: + description: get AI configuration + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.SiteAIResp' + type: object + security: + - ApiKeyAuth: [] + summary: get AI configuration + tags: + - admin + put: + description: update AI configuration + parameters: + - description: AI config + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.SiteAIReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update AI configuration + tags: + - admin + /answer/admin/api/ai-models: + post: + description: get AI models + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetAIModelResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get AI models + tags: + - admin + /answer/admin/api/ai-provider: + get: + description: get AI provider configuration + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetAIProviderResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get AI provider configuration + tags: + - admin + /answer/admin/api/ai/conversation: + delete: + consumes: + - application/json + description: delete conversation and its related records for admin + parameters: + - description: apikey + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.AIConversationAdminDeleteReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + summary: delete conversation for admin + tags: + - ai-conversation-admin + get: + consumes: + - application/json + description: get conversation detail for admin + parameters: + - description: conversation id + in: query + name: conversation_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.AIConversationAdminDetailResp' + type: object + summary: get conversation detail for admin + tags: + - ai-conversation-admin + /answer/admin/api/ai/conversation/page: + get: + consumes: + - application/json + description: get conversation list for admin + parameters: + - description: page + in: query + name: page + type: integer + - description: page size + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + allOf: + - $ref: '#/definitions/pager.PageModel' + - properties: + list: + items: + $ref: '#/definitions/schema.AIConversationAdminListItem' + type: array + type: object + type: object + summary: get conversation list for admin + tags: + - ai-conversation-admin /answer/admin/api/answer/page: get: consumes: @@ -3263,7 +3716,98 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: update answer status + summary: update answer status + tags: + - admin + /answer/admin/api/api-key: + delete: + description: delete apikey + parameters: + - description: apikey + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.DeleteAPIKeyReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: delete apikey + tags: + - admin + post: + description: add apikey + parameters: + - description: apikey + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.AddAPIKeyReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.AddAPIKeyResp' + type: object + security: + - ApiKeyAuth: [] + summary: add apikey + tags: + - admin + put: + description: update apikey + parameters: + - description: apikey + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.UpdateAPIKeyReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update apikey + tags: + - admin + /answer/admin/api/api-key/all: + get: + description: get all api keys + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + items: + $ref: '#/definitions/schema.GetAPIKeyResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: get all api keys tags: - admin /answer/admin/api/badge/status: @@ -3391,6 +3935,47 @@ paths: summary: Get language options tags: - Lang + /answer/admin/api/mcp-config: + get: + description: get MCP configuration + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.SiteMCPResp' + type: object + security: + - ApiKeyAuth: [] + summary: get MCP configuration + tags: + - admin + put: + description: update MCP configuration + parameters: + - description: MCP config + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.SiteMCPReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update MCP configuration + tags: + - admin /answer/admin/api/plugin/config: get: description: get plugin config @@ -3702,6 +4287,47 @@ paths: summary: update smtp config tags: - admin + /answer/admin/api/siteinfo/advanced: + get: + description: get site advanced setting + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.SiteAdvancedResp' + type: object + security: + - ApiKeyAuth: [] + summary: get site advanced setting + tags: + - admin + put: + description: update site advanced info + parameters: + - description: advanced settings + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.SiteAdvancedReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update site advanced info + tags: + - admin /answer/admin/api/siteinfo/branding: get: description: get site interface @@ -3838,7 +4464,7 @@ paths: - $ref: '#/definitions/handler.RespBody' - properties: data: - $ref: '#/definitions/schema.SiteInterfaceResp' + $ref: '#/definitions/schema.SiteInterfaceSettingsResp' type: object security: - ApiKeyAuth: [] @@ -3866,9 +4492,50 @@ paths: summary: update site info interface tags: - admin - /answer/admin/api/siteinfo/legal: + /answer/admin/api/siteinfo/login: + get: + description: get site info login config + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.SiteLoginResp' + type: object + security: + - ApiKeyAuth: [] + summary: get site info login config + tags: + - admin + put: + description: update site login + parameters: + - description: login info + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.SiteLoginReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update site login + tags: + - admin + /answer/admin/api/siteinfo/polices: get: - description: Set the legal information for the site + description: Get the policies information for the site produces: - application/json responses: @@ -3879,22 +4546,22 @@ paths: - $ref: '#/definitions/handler.RespBody' - properties: data: - $ref: '#/definitions/schema.SiteLegalResp' + $ref: '#/definitions/schema.SitePoliciesResp' type: object security: - ApiKeyAuth: [] - summary: Set the legal information for the site + summary: Get the policies information for the site tags: - admin put: - description: update site legal info + description: update site policies configuration parameters: - description: write info in: body name: data required: true schema: - $ref: '#/definitions/schema.SiteLegalReq' + $ref: '#/definitions/schema.SitePoliciesReq' produces: - application/json responses: @@ -3904,12 +4571,12 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: update site legal info + summary: update site policies configuration tags: - admin - /answer/admin/api/siteinfo/login: + /answer/admin/api/siteinfo/question: get: - description: get site info login config + description: get site questions setting produces: - application/json responses: @@ -3920,22 +4587,22 @@ paths: - $ref: '#/definitions/handler.RespBody' - properties: data: - $ref: '#/definitions/schema.SiteLoginResp' + $ref: '#/definitions/schema.SiteQuestionsResp' type: object security: - ApiKeyAuth: [] - summary: get site info login config + summary: get site questions setting tags: - admin put: - description: update site login + description: update site question settings parameters: - - description: login info + - description: questions settings in: body name: data required: true schema: - $ref: '#/definitions/schema.SiteLoginReq' + $ref: '#/definitions/schema.SiteQuestionsReq' produces: - application/json responses: @@ -3945,7 +4612,48 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: update site login + summary: update site question settings + tags: + - admin + /answer/admin/api/siteinfo/security: + get: + description: Get the security information for the site + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.SiteSecurityResp' + type: object + security: + - ApiKeyAuth: [] + summary: Get the security information for the site + tags: + - admin + put: + description: update site security configuration + parameters: + - description: write info + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.SiteSecurityReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update site security configuration tags: - admin /answer/admin/api/siteinfo/seo: @@ -3989,6 +4697,47 @@ paths: summary: update site seo information tags: - admin + /answer/admin/api/siteinfo/tag: + get: + description: get site tags setting + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.SiteTagsResp' + type: object + security: + - ApiKeyAuth: [] + summary: get site tags setting + tags: + - admin + put: + description: update site tag settings + parameters: + - description: tags settings + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.SiteTagsReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: update site tag settings + tags: + - admin /answer/admin/api/siteinfo/theme: get: description: get site info theme config @@ -4071,7 +4820,7 @@ paths: summary: update site info config about users tags: - admin - /answer/admin/api/siteinfo/write: + /answer/admin/api/siteinfo/users-settings: get: description: get site interface produces: @@ -4084,7 +4833,7 @@ paths: - $ref: '#/definitions/handler.RespBody' - properties: data: - $ref: '#/definitions/schema.SiteWriteResp' + $ref: '#/definitions/schema.SiteUsersSettingsResp' type: object security: - ApiKeyAuth: [] @@ -4092,14 +4841,14 @@ paths: tags: - admin put: - description: update site write info + description: update site info users settings parameters: - - description: write info + - description: general in: body name: data required: true schema: - $ref: '#/definitions/schema.SiteWriteReq' + $ref: '#/definitions/schema.SiteUsersSettingsReq' produces: - application/json responses: @@ -4109,7 +4858,7 @@ paths: $ref: '#/definitions/handler.RespBody' security: - ApiKeyAuth: [] - summary: update site write info + summary: update site info users settings tags: - admin /answer/admin/api/theme/options: @@ -4434,6 +5183,90 @@ paths: summary: get object timeline detail tags: - Comment + /answer/api/v1/ai/conversation: + get: + consumes: + - application/json + description: get conversation detail + parameters: + - description: conversation id + in: query + name: conversation_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + $ref: '#/definitions/schema.AIConversationDetailResp' + type: object + summary: get conversation detail + tags: + - ai-conversation + /answer/api/v1/ai/conversation/page: + get: + consumes: + - application/json + description: get conversation list + parameters: + - description: page + in: query + name: page + type: integer + - description: page size + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.RespBody' + - properties: + data: + allOf: + - $ref: '#/definitions/pager.PageModel' + - properties: + list: + items: + $ref: '#/definitions/schema.AIConversationListItem' + type: array + type: object + type: object + summary: get conversation list + tags: + - ai-conversation + /answer/api/v1/ai/conversation/vote: + post: + consumes: + - application/json + description: vote record + parameters: + - description: vote request + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.AIConversationVoteReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + summary: vote record + tags: + - ai-conversation /answer/api/v1/answer: delete: consumes: diff --git a/go.mod b/go.mod index 969d9ebbc..5c48abfdb 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.22.1 + github.com/go-resty/resty/v2 v2.17.1 github.com/go-sql-driver/mysql v1.8.1 github.com/goccy/go-json v0.10.3 github.com/google/uuid v1.6.0 @@ -37,11 +38,14 @@ require ( github.com/grokify/html-strip-tags-go v0.1.0 github.com/jinzhu/copier v0.4.0 github.com/jinzhu/now v1.1.5 + github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/mark3labs/mcp-go v0.43.2 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mozillazg/go-pinyin v0.20.0 github.com/ory/dockertest/v3 v3.11.0 github.com/robfig/cron/v3 v3.0.1 + github.com/sashabaranov/go-openai v1.41.2 github.com/scottleedavis/go-exif-remove v0.0.0-20230314195146-7e059d593405 github.com/segmentfault/pacman v1.0.5-0.20230822083413-c0075a2d401f github.com/segmentfault/pacman/contrib/cache/memory v0.0.0-20230822083413-c0075a2d401f @@ -79,6 +83,8 @@ require ( github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/sonic v1.12.2 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -117,6 +123,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect @@ -145,7 +152,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.19.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect @@ -154,9 +161,11 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.10.0 // indirect diff --git a/go.sum b/go.sum index 35db004db..bf30d85b3 100644 --- a/go.sum +++ b/go.sum @@ -54,10 +54,14 @@ github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg= @@ -197,6 +201,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= +github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= @@ -301,6 +307,8 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= @@ -354,6 +362,8 @@ github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= @@ -408,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= @@ -541,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= @@ -574,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= @@ -628,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= @@ -636,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= @@ -799,6 +817,8 @@ golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index eac322dac..9a0d198b3 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -857,6 +857,16 @@ ui: http_403: HTTP Error 403 logout: Log Out posts: Posts + ai_assistant: AI Assistant + ai_assistant: + description: Got a question? Ask it and get answers, perspectives, and recommendations. + recent_conversations: Recent Conversations + show_more: Show more + new: New chat + ai_generate: AI-generated from posts and may not be accurate. + copy: Copy + ask_a_follow_up: Ask a follow-up + ask_placeholder: Ask a question notifications: title: Notifications inbox: Inbox @@ -1816,6 +1826,18 @@ ui: plugins: Plugins installed_plugins: Installed Plugins apperance: Appearance + community: Community + advanced: Advanced + tags: Tags + rules: Rules + policies: Policies + security: Security + files: Files + apikeys: API Keys + intelligence: Intelligence + ai_assistant: AI Assistant + ai_settings: AI Settings + mcp: MCP website_welcome: Welcome to {{site_name}} user_center: login: Login @@ -2128,7 +2150,7 @@ ui: always_display: Always display external content ask_before_display: Ask before displaying external content write: - page_title: Write + page_title: Files min_content: label: Minimum question body length text: Minimum allowed question body length in characters. @@ -2186,6 +2208,10 @@ ui: primary_color: label: Primary color text: Modify the colors used by your themes + layout: + label: Layout + full_width: Full-width + fixed_width: Fixed-width css_and_html: page_title: CSS and HTML custom_css: @@ -2287,6 +2313,70 @@ ui: show_logs: Show logs status: Status title: Badges + apikeys: + title: API Keys + add_api_key: Add API Key + desc: Description + scope: Scope + key: Key + created: Created + last_used: Last used + add_or_edit_modal: + add_title: Add API Key + edit_title: Edit API Key + description: Description + description_required: Description is required. + scope: Scope + global: Global + read-only: Read-only + created_modal: + title: API key created + api_key: API key + description: This key will not be displayed again. Make sure you take a copy before continuing. + delete_modal: + title: Delete API Key + content: Any applications or scripts using this key will no longer be able to access the API. This is permanent! + ai_settings: + enabled: + label: AI enabled + check: Enable AI features + text: The AI model must be configured correctly before it can be used. + provider: + label: Provider + api_host: + label: API host + msg: API host is required + api_key: + label: API key + check: Check + check_success: "Connection successful." + msg: API key is required + model: + label: Model + msg: Model is required + add_success: AI settings updated successfully. + conversations: + topic: Topic + helpful: Helpful + unhelpful: Unhelpful + created: Created + action: Action + empty: No conversations found. + delete_modal: + title: Delete conversation + content: Are you sure you want to delete this conversation? This is permanent! + delete_success: Conversation deleted successfully. + mcp: + mcp_server: + label: MCP server + switch: Enabled + type: + label: Type + url: + label: URL + http_header: + label: HTTP header + text: Please replace {key} with the API Key. form: optional: (optional) empty: cannot be empty @@ -2383,6 +2473,7 @@ ui: user_normal: This user is already normal. user_suspended: This user has been suspended. user_deleted: This user has been deleted. + user_added: User has been added successfully. badge_activated: This badge has been activated. badge_inactivated: This badge has been inactivated. users_deleted: These users have been deleted. diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 53dca07e3..07589872e 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -846,6 +846,16 @@ ui: http_403: HTTP 错误 403 logout: 退出 posts: 帖子 + ai_assistant: AI 助手 + ai_assistant: + description: 有问题吗?尽管提问,即可获得解答、不同观点及实用建议。 + recent_conversations: 最近的对话 + show_more: 展示更多 + new: 新建对话 + ai_generate: 由帖子生成的 AI 内容,可能并不准确。 + copy: 复制 + ask_a_follow_up: 继续提问 + ask_placeholder: 提出问题 notifications: title: 通知 inbox: 收件箱 @@ -1760,7 +1770,7 @@ ui: users: 用户管理 badges: 徽章 flags: 举报管理 - settings: 站点设置 + settings: 设置 general: 一般 interface: 界面 smtp: SMTP @@ -1778,6 +1788,18 @@ ui: plugins: 插件 installed_plugins: 已安装插件 apperance: 外观 + community: 社区 + advanced: 高级 + tags: 标签 + rules: 规则 + policies: 政策 + security: 安全 + files: 文件 + apikeys: API Keys + intelligence: 人工智能 + ai_assistant: AI 助手 + ai_settings: AI 设置 + mcp: MCP website_welcome: 欢迎来到 {{site_name}} user_center: login: 登录 @@ -2089,7 +2111,7 @@ ui: always_display: 总是显示外部内容 ask_before_display: 在显示外部内容之前询问 write: - page_title: 编辑 + page_title: 文件 min_content: label: 最小问题长度 text: 最小允许的问题内容长度(字符)。 @@ -2147,23 +2169,24 @@ ui: primary_color: label: 主色调 text: 修改您主题使用的颜色 + layout: + label: 布局 + full_width: 全宽 + fixed_width: 定宽 css_and_html: page_title: CSS 与 HTML custom_css: label: 自定义 CSS - text: > - + text: 这将插入为 <link> head: label: 头部 - text: > - + text: 这将插入到 </head> 之前。 header: label: 页眉 - text: > - + text: 这将插入到 <body> 之后。 footer: label: 页脚 - text: 这将在 </body> 之前插入。 + text: 这将插入到 </body> 之前。 sidebar: label: 侧边栏 text: 这将插入侧边栏中。 @@ -2251,6 +2274,70 @@ ui: show_logs: 显示日志 status: 状态 title: 徽章 + apikeys: + title: API Keys + add_api_key: 添加 API Key + desc: 描述 + scope: 范围 + key: Key + created: 创建时间 + last_used: 最后使用时间 + add_or_edit_modal: + add_title: 添加 API Key + edit_title: 编辑 API Key + description: 描述 + description_required: 描述必填. + scope: 范围 + global: 全局 + read-only: 只读 + created_modal: + title: API key 创建成功 + api_key: API key + description: 此密钥将不会再显示。请确保在继续之前复制一份。 + delete_modal: + title: 删除 API Key + content: 任何使用此密钥的应用程序或脚本将无法再访问 API。这是永久性的! + ai_settings: + enabled: + label: AI 启用 + check: 启用 AI 功能 + text: AI 模型必须正确配置才能使用。 + provider: + label: 提供者 + api_host: + label: API 主机 + msg: API 主机必填 + api_key: + label: API Key + check: 校验 + check_success: "连接成功." + msg: API key 必填 + model: + label: 模型 + msg: 模型必填 + add_success: AI 设置已成功更新. + conversations: + topic: 话题 + helpful: 有用的 + unhelpful: 无用的 + created: 创建于 + action: 操作 + empty: 没有会话记录。 + delete_modal: + title: 删除对话 + content: 你确定要删除此对话吗?这是永久的! + delete_success: 对话删除成功. + mcp: + mcp_server: + label: MCP 服务 + switch: 启用 + type: + label: 类型 + url: + label: URL + http_header: + label: HTTP header + text: 请将 {key} 替换为 API Key。 form: optional: (选填) empty: 不能为空 diff --git a/internal/base/constant/ai_config.go b/internal/base/constant/ai_config.go new file mode 100644 index 000000000..aa733bbaf --- /dev/null +++ b/internal/base/constant/ai_config.go @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package constant + +const ( + AIConfigProvider = "ai_config.provider" +) + +const ( + DefaultAIPromptConfigZhCN = `你是一个智能助手,可以帮助用户查询系统中的信息。用户问题:%s + +你可以使用以下工具来查询系统信息: +- get_questions: 搜索系统中已存在的问题,使用这个工具可以获取问题列表后注意需要使用 get_answers_by_question_id 获取问题的答案 +- get_answers_by_question_id: 根据问题ID获取该问题的所有答案 +- get_comments: 搜索评论信息 +- get_tags: 搜索标签信息 +- get_tag_detail: 获取特定标签的详细信息 +- get_user: 搜索用户信息 + +请根据用户的问题智能地使用这些工具来提供准确的答案。如果需要查询系统信息,请先使用相应的工具获取数据。` + DefaultAIPromptConfigEnUS = `You are an intelligent assistant that can help users query information in the system. User question: %s + +You can use the following tools to query system information: +- get_questions: Search for existing questions in the system. After using this tool to get the question list, you need to use get_answers_by_question_id to get the answers to the questions +- get_answers_by_question_id: Get all answers for a question based on question ID +- get_comments: Search for comment information +- get_tags: Search for tag information +- get_tag_detail: Get detailed information about a specific tag +- get_user: Search for user information + +Please intelligently use these tools based on the user's question to provide accurate answers. If you need to query system information, please use the appropriate tools to get the data first.` +) diff --git a/internal/base/constant/site_info.go b/internal/base/constant/site_info.go index 2d6668347..81481baba 100644 --- a/internal/base/constant/site_info.go +++ b/internal/base/constant/site_info.go @@ -43,6 +43,9 @@ const ( ColorSchemeLight = "light" ColorSchemeDark = "dark" ColorSchemeSystem = "system" + + ThemeLayoutFullWidth = "Full-width" + ThemeLayoutFixedWidth = "Fixed-width" ) const ( diff --git a/internal/base/constant/site_type.go b/internal/base/constant/site_type.go index 65b487c5d..ff015ae69 100644 --- a/internal/base/constant/site_type.go +++ b/internal/base/constant/site_type.go @@ -20,15 +20,30 @@ package constant const ( + // SiteTypeLegal\SiteTypeLegal\SiteTypeWrite The following items will no longer be used. + SiteTypeLegal = "legal" + SiteTypeInterface = "interface" + SiteTypeWrite = "write" + SiteTypeGeneral = "general" - SiteTypeInterface = "interface" SiteTypeBranding = "branding" - SiteTypeWrite = "write" - SiteTypeLegal = "legal" SiteTypeSeo = "seo" SiteTypeLogin = "login" SiteTypeCustomCssHTML = "css-html" SiteTypeTheme = "theme" SiteTypePrivileges = "privileges" SiteTypeUsers = "users" + + SiteTypeAdvanced = "advanced" + SiteTypeQuestions = "questions" + SiteTypeTags = "tags" + + SiteTypeUsersSettings = "users_settings" + SiteTypeInterfaceSettings = "interface_settings" + + SiteTypePolicies = "policies" + SiteTypeSecurity = "security" + SiteTypeAI = "ai" + SiteTypeFeatureToggle = "feature-toggle" + SiteTypeMCP = "mcp" ) diff --git a/internal/base/middleware/auth.go b/internal/base/middleware/auth.go index f21837b60..57bbaae21 100644 --- a/internal/base/middleware/auth.go +++ b/internal/base/middleware/auth.go @@ -80,7 +80,7 @@ func (am *AuthUserMiddleware) Auth() gin.HandlerFunc { func (am *AuthUserMiddleware) EjectUserBySiteInfo() gin.HandlerFunc { return func(ctx *gin.Context) { mustLogin := false - siteInfo, _ := am.siteInfoCommonService.GetSiteLogin(ctx) + siteInfo, _ := am.siteInfoCommonService.GetSiteSecurity(ctx) if siteInfo != nil { mustLogin = siteInfo.LoginRequired } @@ -197,7 +197,7 @@ func (am *AuthUserMiddleware) AdminAuth() gin.HandlerFunc { func (am *AuthUserMiddleware) CheckPrivateMode() gin.HandlerFunc { return func(ctx *gin.Context) { - resp, err := am.siteInfoCommonService.GetSiteLogin(ctx) + resp, err := am.siteInfoCommonService.GetSiteSecurity(ctx) if err != nil { ShowIndexPage(ctx) ctx.Abort() diff --git a/internal/base/middleware/mcp_auth.go b/internal/base/middleware/mcp_auth.go new file mode 100644 index 000000000..6da056bae --- /dev/null +++ b/internal/base/middleware/mcp_auth.go @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package middleware + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/reason" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +// AuthMcpEnable check mcp is enabled +func (am *AuthUserMiddleware) AuthMcpEnable() gin.HandlerFunc { + return func(ctx *gin.Context) { + mcpConfig, err := am.siteInfoCommonService.GetSiteMCP(ctx) + if err != nil { + handler.HandleResponse(ctx, errors.InternalServer(reason.UnknownError), nil) + ctx.Abort() + return + } + if mcpConfig != nil && mcpConfig.Enabled { + ctx.Next() + return + } + handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil) + ctx.Abort() + log.Error("abort mcp auth middleware, get mcp config error: ", err) + } +} diff --git a/internal/base/middleware/visit_img_auth.go b/internal/base/middleware/visit_img_auth.go index 33b62172f..bfd157a92 100644 --- a/internal/base/middleware/visit_img_auth.go +++ b/internal/base/middleware/visit_img_auth.go @@ -41,11 +41,11 @@ func (am *AuthUserMiddleware) VisitAuth() gin.HandlerFunc { return } - siteLogin, err := am.siteInfoCommonService.GetSiteLogin(ctx) + siteSecurity, err := am.siteInfoCommonService.GetSiteSecurity(ctx) if err != nil { return } - if !siteLogin.LoginRequired { + if !siteSecurity.LoginRequired { ctx.Next() return } diff --git a/internal/base/queue/queue.go b/internal/base/queue/queue.go new file mode 100644 index 000000000..b3a8757a2 --- /dev/null +++ b/internal/base/queue/queue.go @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package queue + +import ( + "context" + "sync" + + "github.com/segmentfault/pacman/log" +) + +type Service[T any] interface { + // Send enqueues a message to be processed asynchronously. + Send(ctx context.Context, msg T) + + // RegisterHandler sets the handler function for processing messages. + RegisterHandler(handler func(ctx context.Context, msg T) error) + + // Close gracefully shuts down the queue, waiting for pending messages to be processed. + Close() +} + +// Queue is a generic message queue service that processes messages asynchronously. +// It is thread-safe and supports graceful shutdown. +type Queue[T any] struct { + name string + queue chan T + handler func(ctx context.Context, msg T) error + mu sync.RWMutex + closed bool + wg sync.WaitGroup +} + +// New creates a new queue with the given name and buffer size. +func New[T any](name string, bufferSize int) *Queue[T] { + q := &Queue[T]{ + name: name, + queue: make(chan T, bufferSize), + } + q.startWorker() + return q +} + +// Send enqueues a message to be processed asynchronously. +// It will block if the queue is full. +func (q *Queue[T]) Send(ctx context.Context, msg T) { + q.mu.RLock() + defer q.mu.RUnlock() + + if q.closed { + log.Warnf("[%s] queue is closed, dropping message", q.name) + return + } + + select { + case q.queue <- msg: + log.Debugf("[%s] enqueued message: %+v", q.name, msg) + case <-ctx.Done(): + log.Warnf("[%s] context cancelled while sending message", q.name) + } +} + +// RegisterHandler sets the handler function for processing messages. +// This is thread-safe and can be called at any time. +func (q *Queue[T]) RegisterHandler(handler func(ctx context.Context, msg T) error) { + q.mu.Lock() + defer q.mu.Unlock() + q.handler = handler +} + +// Close gracefully shuts down the queue, waiting for pending messages to be processed. +func (q *Queue[T]) Close() { + q.mu.Lock() + if q.closed { + q.mu.Unlock() + return + } + q.closed = true + q.mu.Unlock() + + close(q.queue) + q.wg.Wait() + log.Infof("[%s] queue closed", q.name) +} + +// startWorker starts the background goroutine that processes messages. +func (q *Queue[T]) startWorker() { + q.wg.Add(1) + go func() { + defer q.wg.Done() + for msg := range q.queue { + q.processMessage(msg) + } + }() +} + +// processMessage handles a single message with proper synchronization. +func (q *Queue[T]) processMessage(msg T) { + q.mu.RLock() + handler := q.handler + q.mu.RUnlock() + + if handler == nil { + log.Warnf("[%s] no handler registered, dropping message: %+v", q.name, msg) + return + } + + // Use background context for async processing + // TODO: Consider adding timeout or using a derived context + if err := handler(context.TODO(), msg); err != nil { + log.Errorf("[%s] handler error: %v", q.name, err) + } +} diff --git a/internal/base/queue/queue_test.go b/internal/base/queue/queue_test.go new file mode 100644 index 000000000..23f0fda75 --- /dev/null +++ b/internal/base/queue/queue_test.go @@ -0,0 +1,253 @@ +/* + * 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 queue + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +type testMessage struct { + ID int + Data string +} + +func TestQueue_SendAndReceive(t *testing.T) { + q := New[*testMessage]("test", 10) + defer q.Close() + + received := make(chan *testMessage, 1) + q.RegisterHandler(func(ctx context.Context, msg *testMessage) error { + received <- msg + return nil + }) + + msg := &testMessage{ID: 1, Data: "hello"} + q.Send(context.Background(), msg) + + select { + case r := <-received: + if r.ID != msg.ID || r.Data != msg.Data { + t.Errorf("received message mismatch: got %+v, want %+v", r, msg) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for message") + } +} + +func TestQueue_MultipleMessages(t *testing.T) { + q := New[*testMessage]("test", 10) + defer q.Close() + + var count atomic.Int32 + var wg sync.WaitGroup + numMessages := 100 + wg.Add(numMessages) + + q.RegisterHandler(func(ctx context.Context, msg *testMessage) error { + count.Add(1) + wg.Done() + return nil + }) + + for i := range numMessages { + q.Send(context.Background(), &testMessage{ID: i}) + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + if int(count.Load()) != numMessages { + t.Errorf("expected %d messages, got %d", numMessages, count.Load()) + } + case <-time.After(5 * time.Second): + t.Fatalf("timeout: only received %d of %d messages", count.Load(), numMessages) + } +} + +func TestQueue_NoHandlerDropsMessage(t *testing.T) { + q := New[*testMessage]("test", 10) + defer q.Close() + + // Send without handler - should not panic + q.Send(context.Background(), &testMessage{ID: 1}) + + // Give time for the message to be processed (dropped) + time.Sleep(100 * time.Millisecond) +} + +func TestQueue_RegisterHandlerAfterSend(t *testing.T) { + q := New[*testMessage]("test", 10) + defer q.Close() + + received := make(chan *testMessage, 1) + + // Send first + q.Send(context.Background(), &testMessage{ID: 1}) + + // Small delay then register handler + time.Sleep(50 * time.Millisecond) + q.RegisterHandler(func(ctx context.Context, msg *testMessage) error { + received <- msg + return nil + }) + + // Send another message that should be received + q.Send(context.Background(), &testMessage{ID: 2}) + + select { + case r := <-received: + if r.ID != 2 { + // First message was dropped (no handler), second should be received + t.Logf("received message ID: %d", r.ID) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for message") + } +} + +func TestQueue_Close(t *testing.T) { + q := New[*testMessage]("test", 10) + + var count atomic.Int32 + q.RegisterHandler(func(ctx context.Context, msg *testMessage) error { + count.Add(1) + return nil + }) + + // Send some messages + for i := range 5 { + q.Send(context.Background(), &testMessage{ID: i}) + } + + // Close and wait + q.Close() + + // All messages should have been processed + if count.Load() != 5 { + t.Errorf("expected 5 messages processed, got %d", count.Load()) + } + + // Sending after close should not panic + q.Send(context.Background(), &testMessage{ID: 99}) +} + +func TestQueue_ConcurrentSend(t *testing.T) { + q := New[*testMessage]("test", 100) + defer q.Close() + + var count atomic.Int32 + q.RegisterHandler(func(ctx context.Context, msg *testMessage) error { + count.Add(1) + return nil + }) + + var wg sync.WaitGroup + numGoroutines := 10 + messagesPerGoroutine := 100 + + for i := range numGoroutines { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := range messagesPerGoroutine { + q.Send(context.Background(), &testMessage{ID: id*1000 + j}) + } + }(i) + } + + wg.Wait() + + // Wait for processing + time.Sleep(500 * time.Millisecond) + + expected := int32(numGoroutines * messagesPerGoroutine) + if count.Load() != expected { + t.Errorf("expected %d messages, got %d", expected, count.Load()) + } +} + +func TestQueue_ConcurrentRegisterHandler(t *testing.T) { + q := New[*testMessage]("test", 10) + defer q.Close() + + // Concurrently register handlers - should not race + var wg sync.WaitGroup + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + q.RegisterHandler(func(ctx context.Context, msg *testMessage) error { + return nil + }) + }() + } + wg.Wait() +} + +// TestQueue_SendCloseRace is a regression test for the race condition between +// Send and Close. Without proper synchronization, concurrent Send and Close +// calls could cause a "send on closed channel" panic. +// Run with: go test -race -run TestQueue_SendCloseRace +func TestQueue_SendCloseRace(t *testing.T) { + for i := range 100 { + t.Run(fmt.Sprintf("iteration_%d", i), func(t *testing.T) { + // Use large buffer to avoid blocking on channel send while holding RLock + q := New[*testMessage]("test-race", 1000) + q.RegisterHandler(func(ctx context.Context, msg *testMessage) error { + return nil + }) + + var wg sync.WaitGroup + + // Use cancellable context so senders can exit when Close is called + ctx, cancel := context.WithCancel(context.Background()) + + // Start multiple senders + for j := range 10 { + wg.Add(1) + go func(id int) { + defer wg.Done() + for k := range 100 { + q.Send(ctx, &testMessage{ID: id*1000 + k}) + } + }(j) + } + + // Close while senders are still running + go func() { + time.Sleep(time.Microsecond * 10) + cancel() // Cancel context to unblock any waiting senders + q.Close() + }() + + wg.Wait() + }) + } +} diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 97aaa75a9..b4c569a03 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -117,6 +117,7 @@ const ( UserStatusSuspendedForever = "error.user.status_suspended_forever" UserStatusSuspendedUntil = "error.user.status_suspended_until" UserStatusDeleted = "error.user.status_deleted" + ErrFeatureDisabled = "error.feature.disabled" ) // user external login reasons diff --git a/internal/base/server/http.go b/internal/base/server/http.go index 512f3da21..050e31923 100644 --- a/internal/base/server/http.go +++ b/internal/base/server/http.go @@ -23,6 +23,7 @@ import ( "html/template" "io/fs" "os" + "strings" brotli "github.com/anargu/gin-brotli" "github.com/apache/answer/internal/base/middleware" @@ -51,7 +52,12 @@ func NewHTTPServer(debug bool, gin.SetMode(gin.ReleaseMode) } r := gin.New() - r.Use(brotli.Brotli(brotli.DefaultCompression), middleware.ExtractAndSetAcceptLanguage, shortIDMiddleware.SetShortIDFlag()) + r.Use(func(ctx *gin.Context) { + if strings.Contains(ctx.Request.URL.Path, "/chat/completions") { + return + } + brotli.Brotli(brotli.DefaultCompression)(ctx) + }, middleware.ExtractAndSetAcceptLanguage, shortIDMiddleware.SetShortIDFlag()) r.GET("/healthz", func(ctx *gin.Context) { ctx.String(200, "OK") }) templatePath := os.Getenv("ANSWER_TEMPLATE_PATH") diff --git a/internal/base/translator/provider.go b/internal/base/translator/provider.go index 1a465b1e8..4f0059e36 100644 --- a/internal/base/translator/provider.go +++ b/internal/base/translator/provider.go @@ -242,16 +242,14 @@ func inspectTranslatorNode(node any, path []string, isRoot bool) error { return nil case []any: for idx, child := range data { - nextPath := append(path, fmt.Sprintf("[%d]", idx)) - if err := inspectTranslatorNode(child, nextPath, false); err != nil { + if err := inspectTranslatorNode(child, append(path, fmt.Sprintf("[%d]", idx)), false); err != nil { return err } } return nil case []map[string]any: for idx, child := range data { - nextPath := append(path, fmt.Sprintf("[%d]", idx)) - if err := inspectTranslatorNode(child, nextPath, false); err != nil { + if err := inspectTranslatorNode(child, append(path, fmt.Sprintf("[%d]", idx)), false); err != nil { return err } } diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go new file mode 100644 index 000000000..e020ed30e --- /dev/null +++ b/internal/controller/ai_controller.go @@ -0,0 +1,756 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "time" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/schema/mcp_tools" + "github.com/apache/answer/internal/service/ai_conversation" + answercommon "github.com/apache/answer/internal/service/answer_common" + "github.com/apache/answer/internal/service/comment" + "github.com/apache/answer/internal/service/content" + "github.com/apache/answer/internal/service/feature_toggle" + questioncommon "github.com/apache/answer/internal/service/question_common" + "github.com/apache/answer/internal/service/siteinfo_common" + tagcommonser "github.com/apache/answer/internal/service/tag_common" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/apache/answer/pkg/token" + "github.com/gin-gonic/gin" + "github.com/mark3labs/mcp-go/mcp" + "github.com/sashabaranov/go-openai" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/i18n" + "github.com/segmentfault/pacman/log" +) + +type AIController struct { + searchService *content.SearchService + siteInfoService siteinfo_common.SiteInfoCommonService + tagCommonService *tagcommonser.TagCommonService + questioncommon *questioncommon.QuestionCommon + commentRepo comment.CommentRepo + userCommon *usercommon.UserCommon + answerRepo answercommon.AnswerRepo + mcpController *MCPController + aiConversationService ai_conversation.AIConversationService + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewAIController new site info controller. +func NewAIController( + searchService *content.SearchService, + siteInfoService siteinfo_common.SiteInfoCommonService, + tagCommonService *tagcommonser.TagCommonService, + questioncommon *questioncommon.QuestionCommon, + commentRepo comment.CommentRepo, + userCommon *usercommon.UserCommon, + answerRepo answercommon.AnswerRepo, + mcpController *MCPController, + aiConversationService ai_conversation.AIConversationService, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *AIController { + return &AIController{ + searchService: searchService, + siteInfoService: siteInfoService, + tagCommonService: tagCommonService, + questioncommon: questioncommon, + commentRepo: commentRepo, + userCommon: userCommon, + answerRepo: answerRepo, + mcpController: mcpController, + aiConversationService: aiConversationService, + featureToggleSvc: featureToggleSvc, + } +} + +func (c *AIController) ensureAIChatEnabled(ctx *gin.Context) bool { + if c.featureToggleSvc == nil { + return true + } + if err := c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil { + handler.HandleResponse(ctx, err, nil) + return false + } + return true +} + +type ChatCompletionsRequest struct { + Messages []Message `validate:"required,gte=1" json:"messages"` + ConversationID string `json:"conversation_id"` + UserID string `json:"-"` +} + +type Message struct { + Role string `json:"role" binding:"required"` + Content string `json:"content" binding:"required"` +} + +type ChatCompletionsResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []Choice `json:"choices"` + Usage Usage `json:"usage"` +} + +type StreamResponse struct { + ChatCompletionID string `json:"chat_completion_id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []StreamChoice `json:"choices"` +} + +type Choice struct { + Index int `json:"index"` + Message Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type StreamChoice struct { + Index int `json:"index"` + Delta Delta `json:"delta"` + FinishReason *string `json:"finish_reason"` +} + +type Delta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type ConversationContext struct { + ConversationID string + UserID string + UserQuestion string + Messages []*ai_conversation.ConversationMessage + IsNewConversation bool + Model string +} + +func (c *ConversationContext) GetOpenAIMessages() []openai.ChatCompletionMessage { + messages := make([]openai.ChatCompletionMessage, len(c.Messages)) + for i, msg := range c.Messages { + messages[i] = openai.ChatCompletionMessage{ + Role: msg.Role, + Content: msg.Content, + } + } + return messages +} + +// sendStreamData +func sendStreamData(w http.ResponseWriter, data StreamResponse) { + jsonData, err := json.Marshal(data) + if err != nil { + return + } + + _, _ = fmt.Fprintf(w, "data: %s\n\n", string(jsonData)) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +func (c *AIController) ChatCompletions(ctx *gin.Context) { + if !c.ensureAIChatEnabled(ctx) { + return + } + aiConfig, err := c.siteInfoService.GetSiteAI(context.Background()) + if err != nil { + log.Errorf("Failed to get AI config: %v", err) + handler.HandleResponse(ctx, errors.BadRequest("AI service configuration error"), nil) + return + } + + if !aiConfig.Enabled { + handler.HandleResponse(ctx, errors.ServiceUnavailable("AI service is not enabled"), nil) + return + } + + aiProvider := aiConfig.GetProvider() + + req := &ChatCompletionsRequest{} + if handler.BindAndCheck(ctx, req) { + return + } + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + data, _ := json.Marshal(req) + log.Infof("ai chat request data: %s", string(data)) + + ctx.Header("Content-Type", "text/event-stream") + ctx.Header("Cache-Control", "no-cache") + ctx.Header("Connection", "keep-alive") + ctx.Header("Access-Control-Allow-Origin", "*") + ctx.Header("Access-Control-Allow-Headers", "Cache-Control") + + ctx.Status(http.StatusOK) + + w := ctx.Writer + + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + chatcmplID := "chatcmpl-" + token.GenerateToken() + created := time.Now().Unix() + + firstResponse := StreamResponse{ + ChatCompletionID: chatcmplID, + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: aiProvider.Model, + Choices: []StreamChoice{{Index: 0, Delta: Delta{Role: "assistant"}, FinishReason: nil}}, + } + + sendStreamData(w, firstResponse) + + conversationCtx := c.initializeConversationContext(ctx, aiProvider.Model, req) + if conversationCtx == nil { + log.Error("Failed to initialize conversation context") + c.sendErrorResponse(w, chatcmplID, aiProvider.Model, "Failed to initialize conversation context") + return + } + + c.redirectRequestToAI(ctx, w, chatcmplID, conversationCtx) + + finishReason := "stop" + endResponse := StreamResponse{ + ChatCompletionID: chatcmplID, + Object: "chat.completion.chunk", + Created: created, + Model: aiProvider.Model, + Choices: []StreamChoice{{Index: 0, Delta: Delta{}, FinishReason: &finishReason}}, + } + + sendStreamData(w, endResponse) + + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + c.saveConversationRecord(ctx, chatcmplID, conversationCtx) +} + +func (c *AIController) redirectRequestToAI(ctx *gin.Context, w http.ResponseWriter, id string, conversationCtx *ConversationContext) { + client := c.createOpenAIClient() + + c.handleAIConversation(ctx, w, id, client, conversationCtx) +} + +// createOpenAIClient +func (c *AIController) createOpenAIClient() *openai.Client { + config := openai.DefaultConfig("") + config.BaseURL = "" + + aiConfig, err := c.siteInfoService.GetSiteAI(context.Background()) + if err != nil { + log.Errorf("Failed to get AI config: %v", err) + return openai.NewClientWithConfig(config) + } + + if !aiConfig.Enabled { + log.Warn("AI feature is disabled") + return openai.NewClientWithConfig(config) + } + + aiProvider := aiConfig.GetProvider() + + config = openai.DefaultConfig(aiProvider.APIKey) + config.BaseURL = aiProvider.APIHost + if !strings.HasSuffix(config.BaseURL, "/v1") { + config.BaseURL += "/v1" + } + return openai.NewClientWithConfig(config) +} + +// getPromptByLanguage +func (c *AIController) getPromptByLanguage(language i18n.Language, question string) string { + aiConfig, err := c.siteInfoService.GetSiteAI(context.Background()) + if err != nil { + log.Errorf("Failed to get AI config: %v", err) + return c.getDefaultPrompt(language, question) + } + + var promptTemplate string + + switch language { + case i18n.LanguageChinese: + promptTemplate = aiConfig.PromptConfig.ZhCN + case i18n.LanguageEnglish: + promptTemplate = aiConfig.PromptConfig.EnUS + default: + promptTemplate = aiConfig.PromptConfig.EnUS + } + + if promptTemplate == "" { + return c.getDefaultPrompt(language, question) + } + + return fmt.Sprintf(promptTemplate, question) +} + +// getDefaultPrompt prompt +func (c *AIController) getDefaultPrompt(language i18n.Language, question string) string { + switch language { + case i18n.LanguageChinese: + return fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) + case i18n.LanguageEnglish: + return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + default: + return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + } +} + +// initializeConversationContext +func (c *AIController) initializeConversationContext(ctx *gin.Context, model string, req *ChatCompletionsRequest) *ConversationContext { + if len(req.ConversationID) == 0 { + req.ConversationID = token.GenerateToken() + } + conversationCtx := &ConversationContext{ + UserID: req.UserID, + Messages: make([]*ai_conversation.ConversationMessage, 0), + ConversationID: req.ConversationID, + Model: model, + } + + conversationDetail, exist, err := c.aiConversationService.GetConversationDetail(ctx, &schema.AIConversationDetailReq{ + ConversationID: req.ConversationID, + UserID: req.UserID, + }) + if err != nil { + log.Errorf("Failed to get conversation detail: %v", err) + return nil + } + if !exist { + conversationCtx.UserQuestion = req.Messages[0].Content + conversationCtx.Messages = c.buildInitialMessages(ctx, req) + conversationCtx.IsNewConversation = true + return conversationCtx + } + conversationCtx.IsNewConversation = false + + for _, record := range conversationDetail.Records { + conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ + ChatCompletionID: record.ChatCompletionID, + Role: record.Role, + Content: record.Content, + }) + } + conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ + Role: req.Messages[0].Role, + Content: req.Messages[0].Content, + }) + return conversationCtx +} + +// buildInitialMessages +func (c *AIController) buildInitialMessages(ctx *gin.Context, req *ChatCompletionsRequest) []*ai_conversation.ConversationMessage { + question := "" + if len(req.Messages) == 1 { + question = req.Messages[0].Content + } else { + messages := make([]*ai_conversation.ConversationMessage, len(req.Messages)) + for i, msg := range req.Messages { + messages[i] = &ai_conversation.ConversationMessage{ + Role: msg.Role, + Content: msg.Content, + } + } + return messages + } + + currentLang := handler.GetLangByCtx(ctx) + + prompt := c.getPromptByLanguage(currentLang, question) + + return []*ai_conversation.ConversationMessage{{Role: openai.ChatMessageRoleUser, Content: prompt}} +} + +// saveConversationRecord +func (c *AIController) saveConversationRecord(ctx context.Context, chatcmplID string, conversationCtx *ConversationContext) { + if conversationCtx == nil || len(conversationCtx.Messages) == 0 { + return + } + + if conversationCtx.IsNewConversation { + topic := conversationCtx.UserQuestion + if topic == "" { + log.Warn("No user message found for new conversation") + return + } + + err := c.aiConversationService.CreateConversation(ctx, conversationCtx.UserID, conversationCtx.ConversationID, topic) + if err != nil { + log.Errorf("Failed to create conversation: %v", err) + return + } + } + + err := c.aiConversationService.SaveConversationRecords(ctx, conversationCtx.ConversationID, chatcmplID, conversationCtx.Messages) + if err != nil { + log.Errorf("Failed to save conversation records: %v", err) + } +} + +func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWriter, id string, client *openai.Client, conversationCtx *ConversationContext) { + maxRounds := 10 + messages := conversationCtx.GetOpenAIMessages() + + for round := range maxRounds { + 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]any + 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]any) + required := make([]string, 0) + + maps.Copy(properties, mcpTool.InputSchema.Properties) + + required = append(required, mcpTool.InputSchema.Required...) + + parameters := map[string]any{ + "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]any) (string, error) { + request := mcp.CallToolRequest{ + Request: mcp.Request{}, + Params: struct { + Name string `json:"name"` + Arguments any `json:"arguments,omitempty"` + Meta *mcp.Meta `json:"_meta,omitempty"` + }{ + Name: toolName, + Arguments: arguments, + }, + } + + var result *mcp.CallToolResult + var err error + + log.Debugf("Calling MCP tool: %s with arguments: %v", toolName, arguments) + + switch toolName { + case "get_questions": + result, err = c.mcpController.MCPQuestionsHandler()(ctx, request) + case "get_answers_by_question_id": + result, err = c.mcpController.MCPAnswersHandler()(ctx, request) + case "get_comments": + result, err = c.mcpController.MCPCommentsHandler()(ctx, request) + case "get_tags": + result, err = c.mcpController.MCPTagsHandler()(ctx, request) + case "get_tag_detail": + result, err = c.mcpController.MCPTagDetailsHandler()(ctx, request) + case "get_user": + result, err = c.mcpController.MCPUserDetailsHandler()(ctx, request) + default: + return "", fmt.Errorf("unknown tool: %s", toolName) + } + + if err != nil { + return "", err + } + + data, _ := json.Marshal(result) + log.Debugf("MCP tool %s called successfully, result: %v", toolName, string(data)) + + if result != nil && len(result.Content) > 0 { + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + return textContent.Text, nil + } + } + + return "No result found", nil +} diff --git a/internal/controller/ai_conversation_controller.go b/internal/controller/ai_conversation_controller.go new file mode 100644 index 000000000..1b8debd78 --- /dev/null +++ b/internal/controller/ai_conversation_controller.go @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/ai_conversation" + "github.com/apache/answer/internal/service/feature_toggle" + "github.com/gin-gonic/gin" +) + +// AIConversationController ai conversation controller +type AIConversationController struct { + aiConversationService ai_conversation.AIConversationService + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewAIConversationController creates a new AI conversation controller +func NewAIConversationController( + aiConversationService ai_conversation.AIConversationService, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *AIConversationController { + return &AIConversationController{ + aiConversationService: aiConversationService, + featureToggleSvc: featureToggleSvc, + } +} + +func (ctrl *AIConversationController) ensureEnabled(ctx *gin.Context) bool { + if ctrl.featureToggleSvc == nil { + return true + } + if err := ctrl.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil { + handler.HandleResponse(ctx, err, nil) + return false + } + return true +} + +// GetConversationList gets conversation list +// @Summary get conversation list +// @Description get conversation list +// @Tags ai-conversation +// @Accept json +// @Produce json +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.AIConversationListItem}} +// @Router /answer/api/v1/ai/conversation/page [get] +func (ctrl *AIConversationController) GetConversationList(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, err := ctrl.aiConversationService.GetConversationList(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// GetConversationDetail gets conversation detail +// @Summary get conversation detail +// @Description get conversation detail +// @Tags ai-conversation +// @Accept json +// @Produce json +// @Param conversation_id query string true "conversation id" +// @Success 200 {object} handler.RespBody{data=schema.AIConversationDetailResp} +// @Router /answer/api/v1/ai/conversation [get] +func (ctrl *AIConversationController) GetConversationDetail(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationDetailReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, _, err := ctrl.aiConversationService.GetConversationDetail(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// VoteRecord vote record +// @Summary vote record +// @Description vote record +// @Tags ai-conversation +// @Accept json +// @Produce json +// @Param data body schema.AIConversationVoteReq true "vote request" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/ai/conversation/vote [post] +func (ctrl *AIConversationController) VoteRecord(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationVoteReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + err := ctrl.aiConversationService.VoteRecord(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go index e76b02ccc..99c89b778 100644 --- a/internal/controller/answer_controller.go +++ b/internal/controller/answer_controller.go @@ -242,7 +242,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) { return } - write, err := ac.siteInfoCommonService.GetSiteWrite(ctx) + write, err := ac.siteInfoCommonService.GetSiteQuestion(ctx) if err != nil { handler.HandleResponse(ctx, err, nil) return diff --git a/internal/controller/controller.go b/internal/controller/controller.go index b5c3f91c4..c31763bea 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -54,4 +54,7 @@ var ProviderSetController = wire.NewSet( NewBadgeController, NewRenderController, NewSidebarController, + NewMCPController, + NewAIController, + NewAIConversationController, ) diff --git a/internal/controller/mcp_controller.go b/internal/controller/mcp_controller.go new file mode 100644 index 000000000..d52f57979 --- /dev/null +++ b/internal/controller/mcp_controller.go @@ -0,0 +1,351 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + answercommon "github.com/apache/answer/internal/service/answer_common" + "github.com/apache/answer/internal/service/comment" + "github.com/apache/answer/internal/service/content" + "github.com/apache/answer/internal/service/feature_toggle" + questioncommon "github.com/apache/answer/internal/service/question_common" + "github.com/apache/answer/internal/service/siteinfo_common" + tagcommonser "github.com/apache/answer/internal/service/tag_common" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/mark3labs/mcp-go/mcp" + "github.com/segmentfault/pacman/log" +) + +type MCPController struct { + searchService *content.SearchService + siteInfoService siteinfo_common.SiteInfoCommonService + tagCommonService *tagcommonser.TagCommonService + questioncommon *questioncommon.QuestionCommon + commentRepo comment.CommentRepo + userCommon *usercommon.UserCommon + answerRepo answercommon.AnswerRepo + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewMCPController new site info controller. +func NewMCPController( + searchService *content.SearchService, + siteInfoService siteinfo_common.SiteInfoCommonService, + tagCommonService *tagcommonser.TagCommonService, + questioncommon *questioncommon.QuestionCommon, + commentRepo comment.CommentRepo, + userCommon *usercommon.UserCommon, + answerRepo answercommon.AnswerRepo, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *MCPController { + return &MCPController{ + searchService: searchService, + siteInfoService: siteInfoService, + tagCommonService: tagCommonService, + questioncommon: questioncommon, + commentRepo: commentRepo, + userCommon: userCommon, + answerRepo: answerRepo, + featureToggleSvc: featureToggleSvc, + } +} + +func (c *MCPController) ensureMCPEnabled(ctx context.Context) error { + if c.featureToggleSvc == nil { + return nil + } + return c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureMCP) +} + +func (c *MCPController) MCPQuestionsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + searchResp, err := c.searchService.Search(ctx, &schema.SearchDTO{ + Query: cond.ToQueryString() + " is:question", + Page: 1, + Size: 5, + Order: "newest", + }) + if err != nil { + return nil, err + } + + resp := make([]*schema.MCPSearchQuestionInfoResp, 0) + for _, question := range searchResp.SearchResults { + t := &schema.MCPSearchQuestionInfoResp{ + QuestionID: question.Object.QuestionID, + Title: question.Object.Title, + Content: question.Object.Excerpt, + Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.Object.QuestionID), + } + resp = append(resp, t) + } + + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchQuestionDetail(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + question, err := c.questioncommon.Info(ctx, cond.QuestionID, "") + if err != nil { + log.Errorf("get question failed: %v", err) + return mcp.NewToolResultText("No question found."), nil + } + + resp := &schema.MCPSearchQuestionInfoResp{ + QuestionID: question.ID, + Title: question.Title, + Content: question.Content, + Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.ID), + } + res, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(res)), nil + } +} + +func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchAnswerCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + if len(cond.QuestionID) > 0 { + answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID}) + if err != nil { + log.Errorf("get answers failed: %v", err) + return nil, err + } + resp := make([]*schema.MCPSearchAnswerInfoResp, 0) + for _, answer := range answerList { + t := &schema.MCPSearchAnswerInfoResp{ + QuestionID: answer.QuestionID, + AnswerID: answer.ID, + AnswerContent: answer.OriginalText, + Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } + + answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID}) + if err != nil { + log.Errorf("get answers failed: %v", err) + return nil, err + } + resp := make([]*schema.MCPSearchAnswerInfoResp, 0) + for _, answer := range answerList { + t := &schema.MCPSearchAnswerInfoResp{ + QuestionID: answer.QuestionID, + AnswerID: answer.ID, + AnswerContent: answer.OriginalText, + Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchCommentCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + dto := &comment.CommentQuery{ + PageCond: pager.PageCond{Page: 1, PageSize: 5}, + QueryCond: "newest", + ObjectID: cond.ObjectID, + } + commentList, total, err := c.commentRepo.GetCommentPage(ctx, dto) + if err != nil { + return nil, err + } + if total == 0 { + return mcp.NewToolResultText("No comments found."), nil + } + + resp := make([]*schema.MCPSearchCommentInfoResp, 0) + for _, comment := range commentList { + t := &schema.MCPSearchCommentInfoResp{ + CommentID: comment.ID, + Content: comment.OriginalText, + ObjectID: comment.ObjectID, + Link: fmt.Sprintf("%s/comments/%s", siteGeneral.SiteUrl, comment.ID), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPTagsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchTagCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + tags, total, err := c.tagCommonService.GetTagPage(ctx, 1, 10, &entity.Tag{DisplayName: cond.TagName}, "newest") + if err != nil { + log.Errorf("get tags failed: %v", err) + return nil, err + } + + if total == 0 { + res := strings.Builder{} + res.WriteString("No tags found.\n") + return mcp.NewToolResultText(res.String()), nil + } + + resp := make([]*schema.MCPSearchTagResp, 0) + for _, tag := range tags { + t := &schema.MCPSearchTagResp{ + TagName: tag.SlugName, + DisplayName: tag.DisplayName, + Description: tag.OriginalText, + Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName), + } + resp = append(resp, t) + } + data, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(data)), nil + } +} + +func (c *MCPController) MCPTagDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchTagCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + tag, exist, err := c.tagCommonService.GetTagBySlugName(ctx, cond.TagName) + if err != nil { + log.Errorf("get tag failed: %v", err) + return nil, err + } + if !exist { + return mcp.NewToolResultText("Tag not found."), nil + } + + resp := &schema.MCPSearchTagResp{ + TagName: tag.SlugName, + DisplayName: tag.DisplayName, + Description: tag.OriginalText, + Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName), + } + res, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(res)), nil + } +} + +func (c *MCPController) MCPUserDetailsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if err := c.ensureMCPEnabled(ctx); err != nil { + return nil, err + } + cond := schema.NewMCPSearchUserCond(request) + + siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx) + if err != nil { + log.Errorf("get site general info failed: %v", err) + return nil, err + } + + user, exist, err := c.userCommon.GetUserBasicInfoByUserName(ctx, cond.Username) + if err != nil { + log.Errorf("get user failed: %v", err) + return nil, err + } + if !exist { + return mcp.NewToolResultText("User not found."), nil + } + + resp := &schema.MCPSearchUserInfoResp{ + Username: user.Username, + DisplayName: user.DisplayName, + Avatar: user.Avatar, + Link: fmt.Sprintf("%s/users/%s", siteGeneral.SiteUrl, user.Username), + } + res, _ := json.Marshal(resp) + return mcp.NewToolResultText(string(res)), nil + } +} diff --git a/internal/controller/revision_controller.go b/internal/controller/revision_controller.go index 13bee19c4..57574375a 100644 --- a/internal/controller/revision_controller.go +++ b/internal/controller/revision_controller.go @@ -69,6 +69,8 @@ func (rc *RevisionController) GetRevisionList(ctx *gin.Context) { objectID = uid.DeShortID(objectID) req := &schema.GetRevisionListReq{ ObjectID: objectID, + IsAdmin: middleware.GetUserIsAdminModerator(ctx), + UserID: middleware.GetLoginUserIDFromContext(ctx), } resp, err := rc.revisionListService.GetRevisionList(ctx, req) diff --git a/internal/controller/siteinfo_controller.go b/internal/controller/siteinfo_controller.go index a336fb242..a5dde0234 100644 --- a/internal/controller/siteinfo_controller.go +++ b/internal/controller/siteinfo_controller.go @@ -60,6 +60,11 @@ func (sc *SiteInfoController) GetSiteInfo(ctx *gin.Context) { log.Error(err) } + resp.UsersSettings, err = sc.siteInfoService.GetSiteUsersSettings(ctx) + if err != nil { + log.Error(err) + } + resp.Branding, err = sc.siteInfoService.GetSiteBranding(ctx) if err != nil { log.Error(err) @@ -87,13 +92,31 @@ func (sc *SiteInfoController) GetSiteInfo(ctx *gin.Context) { if err != nil { log.Error(err) } - resp.Write, err = sc.siteInfoService.GetSiteWrite(ctx) + resp.Questions, err = sc.siteInfoService.GetSiteQuestion(ctx) + if err != nil { + log.Error(err) + } + resp.Tags, err = sc.siteInfoService.GetSiteTag(ctx) if err != nil { log.Error(err) } - if legal, err := sc.siteInfoService.GetSiteLegal(ctx); err == nil { + resp.Advanced, err = sc.siteInfoService.GetSiteAdvanced(ctx) + if err != nil { + log.Error(err) + } + if legal, err := sc.siteInfoService.GetSiteSecurity(ctx); err == nil { resp.Legal = &schema.SiteLegalSimpleResp{ExternalContentDisplay: legal.ExternalContentDisplay} } + 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) } @@ -111,7 +134,7 @@ func (sc *SiteInfoController) GetSiteLegalInfo(ctx *gin.Context) { if handler.BindAndCheck(ctx, req) { return } - siteLegal, err := sc.siteInfoService.GetSiteLegal(ctx) + siteLegal, err := sc.siteInfoService.GetSitePolicies(ctx) if err != nil { handler.HandleResponse(ctx, err, nil) return diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 257b02fa4..31cc5152a 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -32,7 +32,7 @@ import ( "github.com/apache/answer/internal/base/middleware" "github.com/apache/answer/internal/base/pager" "github.com/apache/answer/internal/service/content" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/plugin" "github.com/apache/answer/internal/base/constant" @@ -59,7 +59,7 @@ type TemplateController struct { cssPath string templateRenderController *templaterender.TemplateRenderController siteInfoService siteinfo_common.SiteInfoCommonService - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service userService *content.UserService questionService *content.QuestionService } @@ -68,7 +68,7 @@ type TemplateController struct { func NewTemplateController( templateRenderController *templaterender.TemplateRenderController, siteInfoService siteinfo_common.SiteInfoCommonService, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, userService *content.UserService, questionService *content.QuestionService, ) *TemplateController { @@ -656,7 +656,7 @@ func (tc *TemplateController) SitemapPage(ctx *gin.Context) { } func (tc *TemplateController) checkPrivateMode(ctx *gin.Context) bool { - resp, err := tc.siteInfoService.GetSiteLogin(ctx) + resp, err := tc.siteInfoService.GetSiteSecurity(ctx) if err != nil { log.Error(err) return false diff --git a/internal/controller_admin/ai_conversation_admin_controller.go b/internal/controller_admin/ai_conversation_admin_controller.go new file mode 100644 index 000000000..5802a7a8d --- /dev/null +++ b/internal/controller_admin/ai_conversation_admin_controller.go @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller_admin + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/ai_conversation" + "github.com/apache/answer/internal/service/feature_toggle" + "github.com/gin-gonic/gin" +) + +// AIConversationAdminController ai conversation admin controller +type AIConversationAdminController struct { + aiConversationService ai_conversation.AIConversationService + featureToggleSvc *feature_toggle.FeatureToggleService +} + +// NewAIConversationAdminController new AI conversation admin controller +func NewAIConversationAdminController( + aiConversationService ai_conversation.AIConversationService, + featureToggleSvc *feature_toggle.FeatureToggleService, +) *AIConversationAdminController { + return &AIConversationAdminController{ + aiConversationService: aiConversationService, + featureToggleSvc: featureToggleSvc, + } +} + +func (ctrl *AIConversationAdminController) ensureEnabled(ctx *gin.Context) bool { + if ctrl.featureToggleSvc == nil { + return true + } + if err := ctrl.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil { + handler.HandleResponse(ctx, err, nil) + return false + } + return true +} + +// GetConversationList gets conversation list +// @Summary get conversation list for admin +// @Description get conversation list for admin +// @Tags ai-conversation-admin +// @Accept json +// @Produce json +// @Param page query int false "page" +// @Param page_size query int false "page size" +// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.AIConversationAdminListItem}} +// @Router /answer/admin/api/ai/conversation/page [get] +func (ctrl *AIConversationAdminController) GetConversationList(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationAdminListReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, err := ctrl.aiConversationService.GetConversationListForAdmin(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// GetConversationDetail get conversation detail +// @Summary get conversation detail for admin +// @Description get conversation detail for admin +// @Tags ai-conversation-admin +// @Accept json +// @Produce json +// @Param conversation_id query string true "conversation id" +// @Success 200 {object} handler.RespBody{data=schema.AIConversationAdminDetailResp} +// @Router /answer/admin/api/ai/conversation [get] +func (ctrl *AIConversationAdminController) GetConversationDetail(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationAdminDetailReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, err := ctrl.aiConversationService.GetConversationDetailForAdmin(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// DeleteConversation delete conversation +// @Summary delete conversation for admin +// @Description delete conversation and its related records for admin +// @Tags ai-conversation-admin +// @Accept json +// @Produce json +// @Param data body schema.AIConversationAdminDeleteReq true "apikey" +// @Success 200 {object} handler.RespBody +// @Router /answer/admin/api/ai/conversation [delete] +func (ctrl *AIConversationAdminController) DeleteConversation(ctx *gin.Context) { + if !ctrl.ensureEnabled(ctx) { + return + } + req := &schema.AIConversationAdminDeleteReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := ctrl.aiConversationService.DeleteConversationForAdmin(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/controller.go b/internal/controller_admin/controller.go index ebf32cbfc..de0c7ec88 100644 --- a/internal/controller_admin/controller.go +++ b/internal/controller_admin/controller.go @@ -29,4 +29,6 @@ var ProviderSetController = wire.NewSet( NewRoleController, NewPluginController, NewBadgeController, + NewAdminAPIKeyController, + NewAIConversationAdminController, ) diff --git a/internal/controller_admin/e_api_key_controller.go b/internal/controller_admin/e_api_key_controller.go new file mode 100644 index 000000000..1b32b5350 --- /dev/null +++ b/internal/controller_admin/e_api_key_controller.go @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller_admin + +import ( + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/middleware" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/apikey" + "github.com/gin-gonic/gin" +) + +// AdminAPIKeyController site info controller +type AdminAPIKeyController struct { + apiKeyService *apikey.APIKeyService +} + +// NewAdminAPIKeyController new site info controller +func NewAdminAPIKeyController(apiKeyService *apikey.APIKeyService) *AdminAPIKeyController { + return &AdminAPIKeyController{ + apiKeyService: apiKeyService, + } +} + +// GetAllAPIKeys get all api keys +// @Summary get all api keys +// @Description get all api keys +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.GetAPIKeyResp} +// @Router /answer/admin/api/api-key/all [get] +func (sc *AdminAPIKeyController) GetAllAPIKeys(ctx *gin.Context) { + resp, err := sc.apiKeyService.GetAPIKeyList(ctx, &schema.GetAPIKeyReq{}) + handler.HandleResponse(ctx, err, resp) +} + +// AddAPIKey add apikey +// @Summary add apikey +// @Description add apikey +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.AddAPIKeyReq true "apikey" +// @Success 200 {object} handler.RespBody{data=schema.AddAPIKeyResp} +// @Router /answer/admin/api/api-key [post] +func (sc *AdminAPIKeyController) AddAPIKey(ctx *gin.Context) { + req := &schema.AddAPIKeyReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, err := sc.apiKeyService.AddAPIKey(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateAPIKey update apikey +// @Summary update apikey +// @Description update apikey +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.UpdateAPIKeyReq true "apikey" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/api-key [put] +func (sc *AdminAPIKeyController) UpdateAPIKey(ctx *gin.Context) { + req := &schema.UpdateAPIKeyReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + err := sc.apiKeyService.UpdateAPIKey(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + +// DeleteAPIKey delete apikey +// @Summary delete apikey +// @Description delete apikey +// @Security ApiKeyAuth +// @Tags admin +// @Param data body schema.DeleteAPIKeyReq true "apikey" +// @Produce json +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/api-key [delete] +func (sc *AdminAPIKeyController) DeleteAPIKey(ctx *gin.Context) { + req := &schema.DeleteAPIKeyReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + err := sc.apiKeyService.DeleteAPIKey(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/controller_admin/siteinfo_controller.go b/internal/controller_admin/siteinfo_controller.go index 8a92daba3..4575a5d19 100644 --- a/internal/controller_admin/siteinfo_controller.go +++ b/internal/controller_admin/siteinfo_controller.go @@ -62,13 +62,26 @@ func (sc *SiteInfoController) GetGeneral(ctx *gin.Context) { // @Security ApiKeyAuth // @Tags admin // @Produce json -// @Success 200 {object} handler.RespBody{data=schema.SiteInterfaceResp} +// @Success 200 {object} handler.RespBody{data=schema.SiteInterfaceSettingsResp} // @Router /answer/admin/api/siteinfo/interface [get] func (sc *SiteInfoController) GetInterface(ctx *gin.Context) { resp, err := sc.siteInfoService.GetSiteInterface(ctx) handler.HandleResponse(ctx, err, resp) } +// GetUsersSettings get site interface +// @Summary get site interface +// @Description get site interface +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteUsersSettingsResp} +// @Router /answer/admin/api/siteinfo/users-settings [get] +func (sc *SiteInfoController) GetUsersSettings(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteUsersSettings(ctx) + handler.HandleResponse(ctx, err, resp) +} + // GetSiteBranding get site interface // @Summary get site interface // @Description get site interface @@ -82,29 +95,68 @@ func (sc *SiteInfoController) GetSiteBranding(ctx *gin.Context) { handler.HandleResponse(ctx, err, resp) } -// GetSiteWrite get site interface -// @Summary get site interface -// @Description get site interface +// GetSiteTag get site tags setting +// @Summary get site tags setting +// @Description get site tags setting +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteTagsResp} +// @Router /answer/admin/api/siteinfo/tag [get] +func (sc *SiteInfoController) GetSiteTag(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteTag(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// GetSiteQuestion get site questions setting +// @Summary get site questions setting +// @Description get site questions setting // @Security ApiKeyAuth // @Tags admin // @Produce json -// @Success 200 {object} handler.RespBody{data=schema.SiteWriteResp} -// @Router /answer/admin/api/siteinfo/write [get] -func (sc *SiteInfoController) GetSiteWrite(ctx *gin.Context) { - resp, err := sc.siteInfoService.GetSiteWrite(ctx) +// @Success 200 {object} handler.RespBody{data=schema.SiteQuestionsResp} +// @Router /answer/admin/api/siteinfo/question [get] +func (sc *SiteInfoController) GetSiteQuestion(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteQuestion(ctx) handler.HandleResponse(ctx, err, resp) } -// GetSiteLegal Set the legal information for the site -// @Summary Set the legal information for the site -// @Description Set the legal information for the site +// GetSiteAdvanced get site advanced setting +// @Summary get site advanced setting +// @Description get site advanced setting // @Security ApiKeyAuth // @Tags admin // @Produce json -// @Success 200 {object} handler.RespBody{data=schema.SiteLegalResp} -// @Router /answer/admin/api/siteinfo/legal [get] -func (sc *SiteInfoController) GetSiteLegal(ctx *gin.Context) { - resp, err := sc.siteInfoService.GetSiteLegal(ctx) +// @Success 200 {object} handler.RespBody{data=schema.SiteAdvancedResp} +// @Router /answer/admin/api/siteinfo/advanced [get] +func (sc *SiteInfoController) GetSiteAdvanced(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteAdvanced(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// GetSitePolicies Get the policies information for the site +// @Summary Get the policies information for the site +// @Description Get the policies information for the site +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SitePoliciesResp} +// @Router /answer/admin/api/siteinfo/polices [get] +func (sc *SiteInfoController) GetSitePolicies(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSitePolicies(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// GetSiteSecurity Get the security information for the site +// @Summary Get the security information for the site +// @Description Get the security information for the site +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteSecurityResp} +// @Router /answer/admin/api/siteinfo/security [get] +func (sc *SiteInfoController) GetSiteSecurity(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteSecurity(ctx) handler.HandleResponse(ctx, err, resp) } @@ -261,6 +313,24 @@ func (sc *SiteInfoController) UpdateInterface(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } +// UpdateUsersSettings update users settings +// @Summary update site info users settings +// @Description update site info users settings +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.SiteUsersSettingsReq true "general" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/siteinfo/users-settings [put] +func (sc *SiteInfoController) UpdateUsersSettings(ctx *gin.Context) { + req := schema.SiteUsersSettingsReq{} + if handler.BindAndCheck(ctx, &req) { + return + } + err := sc.siteInfoService.SaveSiteUsersSettings(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + // UpdateBranding update site branding // @Summary update site info branding // @Description update site info branding @@ -288,41 +358,97 @@ func (sc *SiteInfoController) UpdateBranding(ctx *gin.Context) { handler.HandleResponse(ctx, saveErr, nil) } -// UpdateSiteWrite update site write info -// @Summary update site write info -// @Description update site write info +// UpdateSiteQuestion update site question settings +// @Summary update site question settings +// @Description update site question settings // @Security ApiKeyAuth // @Tags admin // @Produce json -// @Param data body schema.SiteWriteReq true "write info" +// @Param data body schema.SiteQuestionsReq true "questions settings" // @Success 200 {object} handler.RespBody{} -// @Router /answer/admin/api/siteinfo/write [put] -func (sc *SiteInfoController) UpdateSiteWrite(ctx *gin.Context) { - req := &schema.SiteWriteReq{} +// @Router /answer/admin/api/siteinfo/question [put] +func (sc *SiteInfoController) UpdateSiteQuestion(ctx *gin.Context) { + req := &schema.SiteQuestionsReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + resp, err := sc.siteInfoService.SaveSiteQuestions(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateSiteTag update site tag settings +// @Summary update site tag settings +// @Description update site tag settings +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.SiteTagsReq true "tags settings" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/siteinfo/tag [put] +func (sc *SiteInfoController) UpdateSiteTag(ctx *gin.Context) { + req := &schema.SiteTagsReq{} if handler.BindAndCheck(ctx, req) { return } req.UserID = middleware.GetLoginUserIDFromContext(ctx) - resp, err := sc.siteInfoService.SaveSiteWrite(ctx, req) + resp, err := sc.siteInfoService.SaveSiteTags(ctx, req) handler.HandleResponse(ctx, err, resp) } -// UpdateSiteLegal update site legal info -// @Summary update site legal info -// @Description update site legal info +// UpdateSiteAdvanced update site advanced info +// @Summary update site advanced info +// @Description update site advanced info // @Security ApiKeyAuth // @Tags admin // @Produce json -// @Param data body schema.SiteLegalReq true "write info" +// @Param data body schema.SiteAdvancedReq true "advanced settings" // @Success 200 {object} handler.RespBody{} -// @Router /answer/admin/api/siteinfo/legal [put] -func (sc *SiteInfoController) UpdateSiteLegal(ctx *gin.Context) { - req := &schema.SiteLegalReq{} +// @Router /answer/admin/api/siteinfo/advanced [put] +func (sc *SiteInfoController) UpdateSiteAdvanced(ctx *gin.Context) { + req := &schema.SiteAdvancedReq{} if handler.BindAndCheck(ctx, req) { return } - err := sc.siteInfoService.SaveSiteLegal(ctx, req) + + resp, err := sc.siteInfoService.SaveSiteAdvanced(ctx, req) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateSitePolices update site policies configuration +// @Summary update site policies configuration +// @Description update site policies configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.SitePoliciesReq true "write info" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/siteinfo/polices [put] +func (sc *SiteInfoController) UpdateSitePolices(ctx *gin.Context) { + req := &schema.SitePoliciesReq{} + if handler.BindAndCheck(ctx, req) { + return + } + err := sc.siteInfoService.SaveSitePolicies(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + +// UpdateSiteSecurity update site security configuration +// @Summary update site security configuration +// @Description update site security configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Param data body schema.SiteSecurityReq true "write info" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/siteinfo/security [put] +func (sc *SiteInfoController) UpdateSiteSecurity(ctx *gin.Context) { + req := &schema.SiteSecurityReq{} + if handler.BindAndCheck(ctx, req) { + return + } + err := sc.siteInfoService.SaveSiteSecurity(ctx, req) handler.HandleResponse(ctx, err, nil) } @@ -459,3 +585,105 @@ func (sc *SiteInfoController) UpdatePrivilegesConfig(ctx *gin.Context) { err := sc.siteInfoService.UpdatePrivilegesConfig(ctx, req) handler.HandleResponse(ctx, err, nil) } + +// GetAIConfig get AI configuration +// @Summary get AI configuration +// @Description get AI configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteAIResp} +// @Router /answer/admin/api/ai-config [get] +func (sc *SiteInfoController) GetAIConfig(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteAI(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateAIConfig update AI configuration +// @Summary update AI configuration +// @Description update AI configuration +// @Security ApiKeyAuth +// @Tags admin +// @Param data body schema.SiteAIReq true "AI config" +// @Produce json +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/ai-config [put] +func (sc *SiteInfoController) UpdateAIConfig(ctx *gin.Context) { + req := &schema.SiteAIReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := sc.siteInfoService.SaveSiteAI(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + +// GetAIProvider get AI provider configuration +// @Summary get AI provider configuration +// @Description get AI provider configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.GetAIProviderResp} +// @Router /answer/admin/api/ai-provider [get] +func (sc *SiteInfoController) GetAIProvider(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetAIProvider(ctx) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, resp) +} + +// RequestAIModels get AI models +// @Summary get AI models +// @Description get AI models +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=[]schema.GetAIModelResp} +// @Router /answer/admin/api/ai-models [post] +func (sc *SiteInfoController) RequestAIModels(ctx *gin.Context) { + req := &schema.GetAIModelsReq{} + if handler.BindAndCheck(ctx, req) { + return + } + resp, err := sc.siteInfoService.GetAIModels(ctx, req) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + handler.HandleResponse(ctx, nil, resp) +} + +// GetMCPConfig get MCP configuration +// @Summary get MCP configuration +// @Description get MCP configuration +// @Security ApiKeyAuth +// @Tags admin +// @Produce json +// @Success 200 {object} handler.RespBody{data=schema.SiteMCPResp} +// @Router /answer/admin/api/mcp-config [get] +func (sc *SiteInfoController) GetMCPConfig(ctx *gin.Context) { + resp, err := sc.siteInfoService.GetSiteMCP(ctx) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateMCPConfig update MCP configuration +// @Summary update MCP configuration +// @Description update MCP configuration +// @Security ApiKeyAuth +// @Tags admin +// @Param data body schema.SiteMCPReq true "MCP config" +// @Produce json +// @Success 200 {object} handler.RespBody{} +// @Router /answer/admin/api/mcp-config [put] +func (sc *SiteInfoController) UpdateMCPConfig(ctx *gin.Context) { + req := &schema.SiteMCPReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + err := sc.siteInfoService.SaveSiteMCP(ctx, req) + handler.HandleResponse(ctx, err, nil) +} diff --git a/internal/entity/ai_conversation.go b/internal/entity/ai_conversation.go new file mode 100644 index 000000000..7a610f7ba --- /dev/null +++ b/internal/entity/ai_conversation.go @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import "time" + +// AIConversation AI +type AIConversation struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + ConversationID string `xorm:"not null unique VARCHAR(255) conversation_id"` + Topic string `xorm:"not null MEDIUMTEXT topic"` + UserID string `xorm:"not null default 0 BIGINT(20) user_id"` +} + +// TableName returns the table name +func (AIConversation) TableName() string { + return "ai_conversation" +} diff --git a/internal/entity/ai_conversation_record.go b/internal/entity/ai_conversation_record.go new file mode 100644 index 000000000..14dea3470 --- /dev/null +++ b/internal/entity/ai_conversation_record.go @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import "time" + +// AIConversationRecord AI Conversation Record +type AIConversationRecord struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + ConversationID string `xorm:"not null VARCHAR(255) conversation_id"` + ChatCompletionID string `xorm:"not null VARCHAR(255) chat_completion_id"` + Role string `xorm:"not null default '' VARCHAR(128) role"` + Content string `xorm:"not null MEDIUMTEXT content"` + Helpful int `xorm:"not null default 0 INT(11) helpful"` + Unhelpful int `xorm:"not null default 0 INT(11) unhelpful"` +} + +// TableName returns the table name +func (AIConversationRecord) TableName() string { + return "ai_conversation_record" +} diff --git a/internal/entity/api_key_entity.go b/internal/entity/api_key_entity.go new file mode 100644 index 000000000..6d7713fec --- /dev/null +++ b/internal/entity/api_key_entity.go @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package entity + +import ( + "time" +) + +// APIKey entity +type APIKey struct { + ID int `xorm:"not null pk autoincr INT(11) id"` + CreatedAt time.Time `xorm:"created not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated not null default CURRENT_TIMESTAMP TIMESTAMP updated_at"` + LastUsedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP last_used_at"` + Description string `xorm:"not null MEDIUMTEXT description"` + AccessKey string `xorm:"not null unique VARCHAR(255) access_key"` + Scope string `xorm:"not null VARCHAR(255) scope"` + UserID string `xorm:"not null default 0 BIGINT(20) user_id"` + Hidden int `xorm:"not null default 0 INT(11) hidden"` +} + +// TableName category table name +func (c *APIKey) TableName() string { + return "api_key" +} diff --git a/internal/entity/user_entity.go b/internal/entity/user_entity.go index 66d612926..8e63fc95a 100644 --- a/internal/entity/user_entity.go +++ b/internal/entity/user_entity.go @@ -60,7 +60,7 @@ type User struct { Status int `xorm:"not null default 1 INT(11) status"` AuthorityGroup int `xorm:"not null default 1 INT(11) authority_group"` DisplayName string `xorm:"not null default '' VARCHAR(30) display_name"` - Avatar string `xorm:"not null default '' VARCHAR(1024) avatar"` + Avatar string `xorm:"not null default '' VARCHAR(2048) avatar"` Mobile string `xorm:"not null VARCHAR(20) mobile"` Bio string `xorm:"not null TEXT bio"` BioHTML string `xorm:"not null TEXT bio_html"` diff --git a/internal/install/install_main.go b/internal/install/install_main.go index 41ccdcf40..30fc6ddcb 100644 --- a/internal/install/install_main.go +++ b/internal/install/install_main.go @@ -25,13 +25,19 @@ import ( "github.com/apache/answer/internal/base/path" "github.com/apache/answer/internal/base/translator" + "github.com/joho/godotenv" ) var ( - port = os.Getenv("INSTALL_PORT") + port string confPath = "" ) +func init() { + _ = godotenv.Load() + port = os.Getenv("INSTALL_PORT") +} + func Run(configPath string) { confPath = configPath // initialize translator for return internationalization error when installing. diff --git a/internal/migrations/init.go b/internal/migrations/init.go index 8a72794fe..ae2eeb9a3 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -74,14 +74,17 @@ func (m *Mentor) InitDB() error { m.do("init role power rel", m.initRolePowerRel) m.do("init admin user role rel", m.initAdminUserRoleRel) m.do("init site info interface", m.initSiteInfoInterface) + m.do("init site info users settings", m.initSiteInfoUsersSettings) m.do("init site info general config", m.initSiteInfoGeneralData) m.do("init site info login config", m.initSiteInfoLoginConfig) m.do("init site info theme config", m.initSiteInfoThemeConfig) m.do("init site info seo config", m.initSiteInfoSEOConfig) m.do("init site info user config", m.initSiteInfoUsersConfig) m.do("init site info privilege rank", m.initSiteInfoPrivilegeRank) - m.do("init site info write", m.initSiteInfoWrite) - m.do("init site info legal", m.initSiteInfoLegalConfig) + m.do("init site info write", m.initSiteInfoAdvanced) + m.do("init site info write", m.initSiteInfoQuestions) + m.do("init site info write", m.initSiteInfoTags) + m.do("init site info security", m.initSiteInfoSecurityConfig) m.do("init default content", m.initDefaultContent) m.do("init default badges", m.initDefaultBadges) return m.err @@ -181,19 +184,30 @@ func (m *Mentor) initSiteInfoInterface() { } interfaceData := map[string]string{ - "language": m.userData.Language, - "time_zone": localTimezone, - "default_avatar": "gravatar", - "gravatar_base_url": "https://www.gravatar.com/avatar/", + "language": m.userData.Language, + "time_zone": localTimezone, } interfaceDataBytes, _ := json.Marshal(interfaceData) _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ - Type: "interface", + Type: "interface_settings", Content: string(interfaceDataBytes), Status: 1, }) } +func (m *Mentor) initSiteInfoUsersSettings() { + usersSettings := map[string]any{ + "default_avatar": "gravatar", + "gravatar_base_url": "https://www.gravatar.com/avatar/", + } + usersSettingsDataBytes, _ := json.Marshal(usersSettings) + _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ + Type: "users_settings", + Content: string(usersSettingsDataBytes), + Status: 1, + }) +} + func (m *Mentor) initSiteInfoGeneralData() { generalData := map[string]string{ "name": m.userData.SiteName, @@ -213,7 +227,6 @@ func (m *Mentor) initSiteInfoLoginConfig() { "allow_new_registrations": true, "allow_email_registrations": true, "allow_password_login": true, - "login_required": m.userData.LoginRequired, } loginConfigDataBytes, _ := json.Marshal(loginConfig) _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ @@ -223,20 +236,22 @@ func (m *Mentor) initSiteInfoLoginConfig() { }) } -func (m *Mentor) initSiteInfoLegalConfig() { - legalConfig := map[string]any{ +func (m *Mentor) initSiteInfoSecurityConfig() { + securityConfig := map[string]any{ + "login_required": m.userData.LoginRequired, "external_content_display": m.userData.ExternalContentDisplay, + "check_update": true, } - legalConfigDataBytes, _ := json.Marshal(legalConfig) + securityConfigDataBytes, _ := json.Marshal(securityConfig) _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ - Type: "legal", - Content: string(legalConfigDataBytes), + Type: "security", + Content: string(securityConfigDataBytes), Status: 1, }) } func (m *Mentor) initSiteInfoThemeConfig() { - themeConfig := `{"theme":"default","theme_config":{"default":{"navbar_style":"#0033ff","primary_color":"#0033ff"}}}` + themeConfig := fmt.Sprintf(`{"theme":"default","theme_config":{"default":{"navbar_style":"#0033ff","primary_color":"#0033ff"}},"layout":"%s"}`, constant.ThemeLayoutFullWidth) _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ Type: "theme", Content: themeConfig, @@ -288,24 +303,46 @@ func (m *Mentor) initSiteInfoPrivilegeRank() { }) } -func (m *Mentor) initSiteInfoWrite() { - writeData := map[string]any{ - "min_content": 6, - "restrict_answer": true, - "min_tags": 1, - "required_tag": false, - "recommend_tags": []string{}, - "reserved_tags": []string{}, +func (m *Mentor) initSiteInfoAdvanced() { + advancedData := map[string]any{ "max_image_size": 4, "max_attachment_size": 8, "max_image_megapixel": 40, "authorized_image_extensions": []string{"jpg", "jpeg", "png", "gif", "webp"}, "authorized_attachment_extensions": []string{}, } - writeDataBytes, _ := json.Marshal(writeData) + advancedDataBytes, _ := json.Marshal(advancedData) + _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ + Type: "advanced", + Content: string(advancedDataBytes), + Status: 1, + }) +} + +func (m *Mentor) initSiteInfoQuestions() { + questionsData := map[string]any{ + "min_tags": 1, + "min_content": 6, + "restrict_answer": true, + } + questionsDataBytes, _ := json.Marshal(questionsData) + _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ + Type: "questions", + Content: string(questionsDataBytes), + Status: 1, + }) +} + +func (m *Mentor) initSiteInfoTags() { + tagsData := map[string]any{ + "required_tag": false, + "recommend_tags": []string{}, + "reserved_tags": []string{}, + } + tagsDataBytes, _ := json.Marshal(tagsData) _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ - Type: "write", - Content: string(writeDataBytes), + Type: "tags", + Content: string(tagsDataBytes), Status: 1, }) } diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 2fbfbb7fd..33453ef19 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -104,6 +104,9 @@ var migrations = []Migration{ NewMigration("v1.5.1", "add plugin kv storage", addPluginKVStorage, true), NewMigration("v1.6.0", "move user config to interface", moveUserConfigToInterface, true), NewMigration("v1.7.0", "add optional tags", addOptionalTags, true), + NewMigration("v1.7.2", "expand avatar column length", expandAvatarColumnLength, false), + NewMigration("v1.8.0", "change admin menu", updateAdminMenuSettings, true), + NewMigration("v1.8.1", "ai feat", aiFeat, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v29.go b/internal/migrations/v29.go new file mode 100644 index 000000000..82e120d1b --- /dev/null +++ b/internal/migrations/v29.go @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package migrations + +import ( + "context" + "fmt" + + "xorm.io/xorm" +) + +func expandAvatarColumnLength(ctx context.Context, x *xorm.Engine) error { + type User struct { + Avatar string `xorm:"not null default '' VARCHAR(2048) avatar"` + } + if err := x.Context(ctx).Sync(new(User)); err != nil { + return fmt.Errorf("expand avatar column length failed: %w", err) + } + return nil +} diff --git a/internal/migrations/v30.go b/internal/migrations/v30.go new file mode 100644 index 000000000..a2dca77e9 --- /dev/null +++ b/internal/migrations/v30.go @@ -0,0 +1,396 @@ +/* + * 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" + + "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/schema" + "github.com/segmentfault/pacman/errors" + "xorm.io/builder" + "xorm.io/xorm" +) + +func updateAdminMenuSettings(ctx context.Context, x *xorm.Engine) (err error) { + err = splitWriteMenu(ctx, x) + if err != nil { + return + } + + err = splitInterfaceMenu(ctx, x) + if err != nil { + return + } + + err = splitLegalMenu(ctx, x) + if err != nil { + return + } + return +} + +// splitWriteMenu splits the site write settings into advanced, questions, and tags settings +func splitWriteMenu(ctx context.Context, x *xorm.Engine) error { + var ( + siteInfo = &entity.SiteInfo{} + siteInfoAdvanced = &entity.SiteInfo{} + siteInfoQuestions = &entity.SiteInfo{} + siteInfoTags = &entity.SiteInfo{} + ) + exist, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeWrite}).Get(siteInfo) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return err + } + if !exist { + return nil + } + siteWrite := &schema.SiteWriteResp{} + if err := json.Unmarshal([]byte(siteInfo.Content), siteWrite); err != nil { + return err + } + // site advanced settings + siteAdvanced := &schema.SiteAdvancedResp{ + MaxImageSize: siteWrite.MaxImageSize, + MaxAttachmentSize: siteWrite.MaxAttachmentSize, + MaxImageMegapixel: siteWrite.MaxImageMegapixel, + AuthorizedImageExtensions: siteWrite.AuthorizedImageExtensions, + AuthorizedAttachmentExtensions: siteWrite.AuthorizedAttachmentExtensions, + } + // site questions settings + siteQuestions := &schema.SiteQuestionsResp{ + MinimumTags: siteWrite.MinimumTags, + MinimumContent: siteWrite.MinimumContent, + RestrictAnswer: siteWrite.RestrictAnswer, + } + // site tags settings + siteTags := &schema.SiteTagsResp{ + ReservedTags: siteWrite.ReservedTags, + RecommendTags: siteWrite.RecommendTags, + RequiredTag: siteWrite.RequiredTag, + } + + // save site settings + // save advanced settings + existsAdvanced, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeWrite}).Get(siteInfoAdvanced) + if err != nil { + return err + } + advancedContent, err := json.Marshal(siteAdvanced) + if err != nil { + return err + } + if !existsAdvanced { + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypeAdvanced, + Content: string(advancedContent), + Status: 1, + }) + if err != nil { + return err + } + } + + // save questions settings + existsQuestions, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeQuestions}).Get(siteInfoQuestions) + if err != nil { + return err + } + questionsContent, err := json.Marshal(siteQuestions) + if err != nil { + return err + } + if !existsQuestions { + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypeQuestions, + Content: string(questionsContent), + Status: 1, + }) + if err != nil { + return err + } + } + + // save tags settings + existsTags, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeTags}).Get(siteInfoTags) + if err != nil { + return err + } + tagsContent, err := json.Marshal(siteTags) + if err != nil { + return err + } + if !existsTags { + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypeTags, + Content: string(tagsContent), + Status: 1, + }) + if err != nil { + return err + } + } + + return nil +} + +// splitInterfaceMenu splits the site interface settings into interface and user settings +func splitInterfaceMenu(ctx context.Context, x *xorm.Engine) error { + var ( + siteInfo = &entity.SiteInfo{} + siteInfoInterface = &entity.SiteInfo{} + siteInfoUsers = &entity.SiteInfo{} + ) + type SiteInterface struct { + Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` + TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` + DefaultAvatar string `validate:"required,oneof=system gravatar" json:"default_avatar"` + GravatarBaseURL string `validate:"omitempty" json:"gravatar_base_url"` + } + + exist, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeInterface}).Get(siteInfo) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return err + } + if !exist { + return nil + } + oldSiteInterface := &SiteInterface{} + if err := json.Unmarshal([]byte(siteInfo.Content), oldSiteInterface); err != nil { + return err + } + siteUser := &schema.SiteUsersSettingsResp{ + DefaultAvatar: oldSiteInterface.DefaultAvatar, + GravatarBaseURL: oldSiteInterface.GravatarBaseURL, + } + siteInterface := &schema.SiteInterfaceResp{ + Language: oldSiteInterface.Language, + TimeZone: oldSiteInterface.TimeZone, + } + + // save settings + // save user settings + existsUsers, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeUsersSettings}).Get(siteInfoUsers) + if err != nil { + return err + } + userContent, err := json.Marshal(siteUser) + if err != nil { + return err + } + if !existsUsers { + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypeUsersSettings, + Content: string(userContent), + Status: 1, + }) + if err != nil { + return err + } + } + + // save interface settings + existsInterface, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeInterfaceSettings}).Get(siteInfoInterface) + if err != nil { + return err + } + interfaceContent, err := json.Marshal(siteInterface) + if err != nil { + return err + } + if !existsInterface { + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypeInterfaceSettings, + Content: string(interfaceContent), + Status: 1, + }) + if err != nil { + return err + } + } + + return nil +} + +// splitLegalMenu splits the site legal settings into policies and security settings +func splitLegalMenu(ctx context.Context, x *xorm.Engine) error { + var ( + siteInfo = &entity.SiteInfo{} + siteInfoPolices = &entity.SiteInfo{} + siteInfoSecurity = &entity.SiteInfo{} + siteInfoLogin = &entity.SiteInfo{} + siteInfoGeneral = &entity.SiteInfo{} + ) + + type SiteLogin struct { + AllowNewRegistrations bool `json:"allow_new_registrations"` + AllowEmailRegistrations bool `json:"allow_email_registrations"` + AllowPasswordLogin bool `json:"allow_password_login"` + LoginRequired bool `json:"login_required"` + AllowEmailDomains []string `json:"allow_email_domains"` + } + + type SiteGeneral struct { + Name string `validate:"required,sanitizer,gt=1,lte=128" form:"name" json:"name"` + ShortDescription string `validate:"omitempty,sanitizer,gt=3,lte=255" form:"short_description" json:"short_description"` + Description string `validate:"omitempty,sanitizer,gt=3,lte=2000" form:"description" json:"description"` + SiteUrl string `validate:"required,sanitizer,gt=1,lte=512,url" form:"site_url" json:"site_url"` + ContactEmail string `validate:"required,sanitizer,gt=1,lte=512,email" form:"contact_email" json:"contact_email"` + CheckUpdate bool `validate:"omitempty,sanitizer" form:"check_update" json:"check_update"` + } + + // find old site legal settings + exist, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeLegal}).Get(siteInfo) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return err + } + if !exist { + return nil + } + oldSiteLegal := &schema.SiteLegalResp{} + if err := json.Unmarshal([]byte(siteInfo.Content), oldSiteLegal); err != nil { + return err + } + + // find old site login settings + existsLogin, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeLogin}).Get(siteInfoLogin) + if err != nil { + return err + } + oldSiteLogin := &SiteLogin{} + if err := json.Unmarshal([]byte(siteInfoLogin.Content), oldSiteLogin); err != nil { + return err + } + + // find old site general settings + existGeneral, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeGeneral}).Get(siteInfoGeneral) + if err != nil { + return err + } + oldSiteGeneral := &SiteGeneral{} + if err := json.Unmarshal([]byte(siteInfoLogin.Content), oldSiteGeneral); err != nil { + return err + } + + sitePolicies := &schema.SitePoliciesResp{ + TermsOfServiceOriginalText: oldSiteLegal.TermsOfServiceOriginalText, + TermsOfServiceParsedText: oldSiteLegal.TermsOfServiceParsedText, + PrivacyPolicyOriginalText: oldSiteLegal.PrivacyPolicyOriginalText, + PrivacyPolicyParsedText: oldSiteLegal.PrivacyPolicyParsedText, + } + siteLogin := &schema.SiteLoginResp{ + AllowNewRegistrations: oldSiteLogin.AllowNewRegistrations, + AllowEmailRegistrations: oldSiteLogin.AllowEmailRegistrations, + AllowPasswordLogin: oldSiteLogin.AllowPasswordLogin, + AllowEmailDomains: oldSiteLogin.AllowEmailDomains, + } + siteGeneral := &schema.SiteGeneralReq{ + Name: oldSiteGeneral.Name, + ShortDescription: oldSiteGeneral.ShortDescription, + Description: oldSiteGeneral.Description, + SiteUrl: oldSiteGeneral.SiteUrl, + ContactEmail: oldSiteGeneral.ContactEmail, + } + siteSecurity := &schema.SiteSecurityResp{ + LoginRequired: oldSiteLogin.LoginRequired, + ExternalContentDisplay: oldSiteLegal.ExternalContentDisplay, + CheckUpdate: oldSiteGeneral.CheckUpdate, + } + if !existsLogin { + siteSecurity.LoginRequired = false + } + if !existGeneral { + siteSecurity.CheckUpdate = true + } + + // save settings + // save policies settings + existsPolicies, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypePolicies}).Get(siteInfoPolices) + if err != nil { + return err + } + policiesContent, err := json.Marshal(sitePolicies) + if err != nil { + return err + } + if !existsPolicies { + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypePolicies, + Content: string(policiesContent), + Status: 1, + }) + if err != nil { + return err + } + } + + // save security settings + existsSecurity, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeSecurity}).Get(siteInfoSecurity) + if err != nil { + return err + } + securityContent, err := json.Marshal(siteSecurity) + if err != nil { + return err + } + if !existsSecurity { + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypeSecurity, + Content: string(securityContent), + Status: 1, + }) + if err != nil { + return err + } + } + + // save login settings + if existsLogin { + loginContent, _ := json.Marshal(siteLogin) + _, err = x.Context(ctx).ID(siteInfoLogin.ID).Update(&entity.SiteInfo{ + Type: constant.SiteTypeLogin, + Content: string(loginContent), + Status: 1, + }) + if err != nil { + return err + } + } + + // save general settings + if existGeneral { + generalContent, _ := json.Marshal(siteGeneral) + _, err = x.Context(ctx).ID(siteInfoGeneral.ID).Update(&entity.SiteInfo{ + Type: constant.SiteTypeGeneral, + Content: string(generalContent), + Status: 1, + }) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/migrations/v31.go b/internal/migrations/v31.go new file mode 100644 index 000000000..89b970475 --- /dev/null +++ b/internal/migrations/v31.go @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package migrations + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" +) + +func aiFeat(ctx context.Context, x *xorm.Engine) error { + if err := addAIConversationTables(ctx, x); err != nil { + return fmt.Errorf("add ai conversation tables failed: %w", err) + } + if err := addAPIKey(ctx, x); err != nil { + return fmt.Errorf("add api key failed: %w", err) + } + log.Info("AI feature migration completed successfully") + return nil +} + +func addAIConversationTables(ctx context.Context, x *xorm.Engine) error { + if err := x.Context(ctx).Sync(new(entity.AIConversation)); err != nil { + return fmt.Errorf("sync ai_conversation table failed: %w", err) + } + + if err := x.Context(ctx).Sync(new(entity.AIConversationRecord)); err != nil { + return fmt.Errorf("sync ai_conversation_record table failed: %w", err) + } + + return nil +} + +func addAPIKey(ctx context.Context, x *xorm.Engine) error { + err := x.Context(ctx).Sync(new(entity.APIKey)) + if err != nil { + return err + } + + defaultConfigTable := []*entity.Config{ + {ID: 10000, Key: "ai_config.provider", Value: `[{"default_api_host":"https://api.openai.com","display_name":"OpenAI","name":"openai"},{"default_api_host":"https://generativelanguage.googleapis.com","display_name":"Gemini","name":"gemini"},{"default_api_host":"https://api.anthropic.com","display_name":"Anthropic","name":"anthropic"}]`}, + } + for _, c := range defaultConfigTable { + exist, err := x.Context(ctx).Get(&entity.Config{Key: c.Key}) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + continue + } + if _, err = x.Context(ctx).Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil { + log.Errorf("insert %+v config failed: %s", c, err) + return fmt.Errorf("add config failed: %w", err) + } + } + + aiSiteInfo := &entity.SiteInfo{ + Type: constant.SiteTypeAI, + } + exist, err := x.Context(ctx).Get(aiSiteInfo) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + content := &schema.SiteAIReq{} + _ = json.Unmarshal([]byte(aiSiteInfo.Content), content) + content.PromptConfig = &schema.AIPromptConfig{ + ZhCN: constant.DefaultAIPromptConfigZhCN, + EnUS: constant.DefaultAIPromptConfigEnUS, + } + data, _ := json.Marshal(content) + aiSiteInfo.Content = string(data) + _, err = x.Context(ctx).ID(aiSiteInfo.ID).Cols("content").Update(aiSiteInfo) + if err != nil { + return fmt.Errorf("update site info failed: %w", err) + } + } else { + content := &schema.SiteAIReq{ + PromptConfig: &schema.AIPromptConfig{ + ZhCN: constant.DefaultAIPromptConfigZhCN, + EnUS: constant.DefaultAIPromptConfigEnUS, + }, + } + data, _ := json.Marshal(content) + aiSiteInfo.Content = string(data) + aiSiteInfo.Type = constant.SiteTypeAI + if _, err = x.Context(ctx).Insert(aiSiteInfo); err != nil { + return fmt.Errorf("insert site info failed: %w", err) + } + log.Infof("insert site info %+v", aiSiteInfo) + } + return nil +} diff --git a/internal/migrations/v5.go b/internal/migrations/v5.go index 91d12f159..b988dc655 100644 --- a/internal/migrations/v5.go +++ b/internal/migrations/v5.go @@ -24,6 +24,7 @@ import ( "encoding/json" "fmt" + "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/entity" "xorm.io/xorm" ) @@ -50,7 +51,7 @@ func addThemeAndPrivateMode(ctx context.Context, x *xorm.Engine) error { } } - themeConfig := `{"theme":"default","theme_config":{"default":{"navbar_style":"#0033ff","primary_color":"#0033ff"}}}` + themeConfig := fmt.Sprintf(`{"theme":"default","theme_config":{"default":{"navbar_style":"#0033ff","primary_color":"#0033ff"}},"layout":"%s"}`, constant.ThemeLayoutFullWidth) themeSiteInfo := &entity.SiteInfo{ Type: "theme", Content: themeConfig, diff --git a/internal/repo/activity/answer_repo.go b/internal/repo/activity/answer_repo.go index 4aca874a7..96813f50c 100644 --- a/internal/repo/activity/answer_repo.go +++ b/internal/repo/activity/answer_repo.go @@ -34,7 +34,7 @@ import ( "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/rank" "github.com/apache/answer/pkg/converter" "github.com/segmentfault/pacman/errors" @@ -46,7 +46,7 @@ type AnswerActivityRepo struct { data *data.Data activityRepo activity_common.ActivityRepo userRankRepo rank.UserRankRepo - notificationQueueService notice_queue.NotificationQueueService + notificationQueueService noticequeue.Service } // NewAnswerActivityRepo new repository @@ -54,7 +54,7 @@ func NewAnswerActivityRepo( data *data.Data, activityRepo activity_common.ActivityRepo, userRankRepo rank.UserRankRepo, - notificationQueueService notice_queue.NotificationQueueService, + notificationQueueService noticequeue.Service, ) activity.AnswerActivityRepo { return &AnswerActivityRepo{ data: data, diff --git a/internal/repo/activity/vote_repo.go b/internal/repo/activity/vote_repo.go index f2d2be5f8..389ae18d8 100644 --- a/internal/repo/activity/vote_repo.go +++ b/internal/repo/activity/vote_repo.go @@ -28,7 +28,7 @@ import ( "github.com/segmentfault/pacman/log" "github.com/apache/answer/internal/base/constant" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/pkg/converter" "github.com/apache/answer/internal/base/pager" @@ -51,7 +51,7 @@ type VoteRepo struct { data *data.Data activityRepo activity_common.ActivityRepo userRankRepo rank.UserRankRepo - notificationQueueService notice_queue.NotificationQueueService + notificationQueueService noticequeue.Service } // NewVoteRepo new repository @@ -59,7 +59,7 @@ func NewVoteRepo( data *data.Data, activityRepo activity_common.ActivityRepo, userRankRepo rank.UserRankRepo, - notificationQueueService notice_queue.NotificationQueueService, + notificationQueueService noticequeue.Service, ) content.VoteRepo { return &VoteRepo{ data: data, diff --git a/internal/repo/activity_common/vote.go b/internal/repo/activity_common/vote.go index 506578424..8ec834231 100644 --- a/internal/repo/activity_common/vote.go +++ b/internal/repo/activity_common/vote.go @@ -47,7 +47,13 @@ func NewVoteRepo(data *data.Data, activityRepo activity_common.ActivityRepo) act } func (vr *VoteRepo) GetVoteStatus(ctx context.Context, objectID, userID string) (status string) { + if len(userID) == 0 { + return "" + } objectID = uid.DeShortID(objectID) + if len(objectID) == 0 || objectID == "0" { + return "" + } for _, action := range []string{"vote_up", "vote_down"} { activityType, _, _, err := vr.activityRepo.GetActivityTypeByObjID(ctx, objectID, action) if err != nil { diff --git a/internal/repo/ai_conversation/ai_conversation_repo.go b/internal/repo/ai_conversation/ai_conversation_repo.go new file mode 100644 index 000000000..9947eb6cc --- /dev/null +++ b/internal/repo/ai_conversation/ai_conversation_repo.go @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ai_conversation + +import ( + "context" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" + "xorm.io/builder" + "xorm.io/xorm" +) + +// AIConversationRepo +type AIConversationRepo interface { + CreateConversation(ctx context.Context, conversation *entity.AIConversation) error + GetConversation(ctx context.Context, conversationID string) (*entity.AIConversation, bool, error) + UpdateConversation(ctx context.Context, conversation *entity.AIConversation) error + GetConversationsPage(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) + CreateRecord(ctx context.Context, record *entity.AIConversationRecord) error + GetRecordsByConversationID(ctx context.Context, conversationID string) ([]*entity.AIConversationRecord, error) + UpdateRecordVote(ctx context.Context, cond *entity.AIConversationRecord) error + GetRecord(ctx context.Context, recordID int) (*entity.AIConversationRecord, bool, error) + GetRecordByChatCompletionID(ctx context.Context, role, chatCompletionID string) (*entity.AIConversationRecord, bool, error) + GetConversationsForAdmin(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) + GetConversationWithVoteStats(ctx context.Context, conversationID string) (helpful, unhelpful int64, err error) + DeleteConversation(ctx context.Context, conversationID string) error +} + +type aiConversationRepo struct { + data *data.Data +} + +// NewAIConversationRepo new AIConversationRepo +func NewAIConversationRepo(data *data.Data) AIConversationRepo { + return &aiConversationRepo{ + data: data, + } +} + +// CreateConversation creates a conversation +func (r *aiConversationRepo) CreateConversation(ctx context.Context, conversation *entity.AIConversation) error { + _, err := r.data.DB.Context(ctx).Insert(conversation) + if err != nil { + log.Errorf("create ai conversation failed: %v", err) + return err + } + return nil +} + +// GetConversation gets a conversation +func (r *aiConversationRepo) GetConversation(ctx context.Context, conversationID string) (*entity.AIConversation, bool, error) { + conversation := &entity.AIConversation{} + exist, err := r.data.DB.Context(ctx).Where(builder.Eq{"conversation_id": conversationID}).Get(conversation) + if err != nil { + log.Errorf("get ai conversation failed: %v", err) + return nil, false, err + } + return conversation, exist, nil +} + +// UpdateConversation updates a conversation +func (r *aiConversationRepo) UpdateConversation(ctx context.Context, conversation *entity.AIConversation) error { + _, err := r.data.DB.Context(ctx).ID(conversation.ID).Update(conversation) + if err != nil { + log.Errorf("update ai conversation failed: %v", err) + return err + } + return nil +} + +// GetConversationsPage get conversations by user ID +func (r *aiConversationRepo) GetConversationsPage(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) { + list = make([]*entity.AIConversation, 0) + total, err = pager.Help(page, pageSize, &list, cond, r.data.DB.Context(ctx).Desc("id")) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return list, total, err +} + +// CreateRecord creates a conversation record +func (r *aiConversationRepo) CreateRecord(ctx context.Context, record *entity.AIConversationRecord) error { + _, err := r.data.DB.Context(ctx).Insert(record) + if err != nil { + log.Errorf("create ai conversation record failed: %v", err) + return err + } + return nil +} + +// GetRecordsByConversationID get records by conversation ID +func (r *aiConversationRepo) GetRecordsByConversationID(ctx context.Context, conversationID string) ([]*entity.AIConversationRecord, error) { + records := make([]*entity.AIConversationRecord, 0) + err := r.data.DB.Context(ctx). + Where(builder.Eq{"conversation_id": conversationID}). + OrderBy("created_at ASC"). + Find(&records) + if err != nil { + log.Errorf("get ai conversation records failed: %v", err) + return nil, err + } + return records, nil +} + +// UpdateRecordVote update record vote +func (r *aiConversationRepo) UpdateRecordVote(ctx context.Context, cond *entity.AIConversationRecord) (err error) { + _, err = r.data.DB.Context(ctx).ID(cond.ID).MustCols("helpful", "unhelpful").Update(cond) + if err != nil { + log.Errorf("update ai conversation record vote failed: %v", err) + return err + } + return nil +} + +// GetRecord get record +func (r *aiConversationRepo) GetRecord(ctx context.Context, recordID int) (*entity.AIConversationRecord, bool, error) { + record := &entity.AIConversationRecord{} + exist, err := r.data.DB.Context(ctx).ID(recordID).Get(record) + if err != nil { + log.Errorf("get ai conversation record failed: %v", err) + return nil, false, err + } + return record, exist, nil +} + +// GetRecordByChatCompletionID gets record by chat completion ID +func (r *aiConversationRepo) GetRecordByChatCompletionID(ctx context.Context, role, chatCompletionID string) (*entity.AIConversationRecord, bool, error) { + record := &entity.AIConversationRecord{} + exist, err := r.data.DB.Context(ctx).Where(builder.Eq{"role": role}). + Where(builder.Eq{"chat_completion_id": chatCompletionID}).Get(record) + if err != nil { + log.Errorf("get ai conversation record by chat completion id failed: %v", err) + return nil, false, err + } + return record, exist, nil +} + +// GetConversationsForAdmin gets conversation list for admin +func (r *aiConversationRepo) GetConversationsForAdmin(ctx context.Context, page, pageSize int, cond *entity.AIConversation) (list []*entity.AIConversation, total int64, err error) { + list = make([]*entity.AIConversation, 0) + total, err = pager.Help(page, pageSize, &list, cond, r.data.DB.Context(ctx).Desc("id")) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return list, total, err +} + +// GetConversationWithVoteStats gets conversation vote statistics +func (r *aiConversationRepo) GetConversationWithVoteStats(ctx context.Context, conversationID string) (helpful, unhelpful int64, err error) { + res, err := r.data.DB.Context(ctx).SumsInt(&entity.AIConversationRecord{ConversationID: conversationID}, "helpful", "unhelpful") + if err != nil { + log.Errorf("get ai conversation vote stats failed: %v", err) + return 0, 0, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if len(res) < 2 { + log.Errorf("get ai conversation vote stats failed: invalid result length %d", len(res)) + return 0, 0, nil + } + return res[0], res[1], nil +} + +// DeleteConversation deletes a conversation and its related records +func (r *aiConversationRepo) DeleteConversation(ctx context.Context, conversationID string) error { + _, err := r.data.DB.Transaction(func(session *xorm.Session) (result any, err error) { + if _, err := session.Context(ctx).Where("conversation_id = ?", conversationID).Delete(&entity.AIConversationRecord{}); err != nil { + log.Errorf("delete ai conversation records failed: %v", err) + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + if _, err := session.Context(ctx).Where("conversation_id = ?", conversationID).Delete(&entity.AIConversation{}); err != nil { + log.Errorf("delete ai conversation failed: %v", err) + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + return nil, nil + }) + + if err != nil { + return err + } + + return nil +} diff --git a/internal/repo/api_key/api_key_repo.go b/internal/repo/api_key/api_key_repo.go new file mode 100644 index 000000000..2309384a9 --- /dev/null +++ b/internal/repo/api_key/api_key_repo.go @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package api_key + +import ( + "context" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/apikey" + "github.com/segmentfault/pacman/errors" +) + +type apiKeyRepo struct { + data *data.Data +} + +// NewAPIKeyRepo creates a new apiKey repository +func NewAPIKeyRepo(data *data.Data) apikey.APIKeyRepo { + return &apiKeyRepo{ + data: data, + } +} + +func (ar *apiKeyRepo) GetAPIKeyList(ctx context.Context) (keys []*entity.APIKey, err error) { + keys = make([]*entity.APIKey, 0) + err = ar.data.DB.Context(ctx).Where("hidden = ?", 0).Find(&keys) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) GetAPIKey(ctx context.Context, apiKey string) (key *entity.APIKey, exist bool, err error) { + key = &entity.APIKey{} + exist, err = ar.data.DB.Context(ctx).Where("access_key = ?", apiKey).Get(key) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) UpdateAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) { + _, err = ar.data.DB.Context(ctx).ID(apiKey.ID).Update(&apiKey) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) AddAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) { + _, err = ar.data.DB.Context(ctx).Insert(&apiKey) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} + +func (ar *apiKeyRepo) DeleteAPIKey(ctx context.Context, id int) (err error) { + _, err = ar.data.DB.Context(ctx).ID(id).Delete(&entity.APIKey{}) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 02f27f62f..510a94aaa 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -23,7 +23,9 @@ import ( "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/repo/activity" "github.com/apache/answer/internal/repo/activity_common" + "github.com/apache/answer/internal/repo/ai_conversation" "github.com/apache/answer/internal/repo/answer" + "github.com/apache/answer/internal/repo/api_key" "github.com/apache/answer/internal/repo/auth" "github.com/apache/answer/internal/repo/badge" "github.com/apache/answer/internal/repo/badge_award" @@ -109,4 +111,6 @@ var ProviderSetRepo = wire.NewSet( badge_group.NewBadgeGroupRepo, badge_award.NewBadgeAwardRepo, file_record.NewFileRecordRepo, + api_key.NewAPIKeyRepo, + ai_conversation.NewAIConversationRepo, ) diff --git a/internal/repo/site_info/siteinfo_repo.go b/internal/repo/site_info/siteinfo_repo.go index 5f95b7486..379a59c91 100644 --- a/internal/repo/site_info/siteinfo_repo.go +++ b/internal/repo/site_info/siteinfo_repo.go @@ -63,10 +63,12 @@ func (sr *siteInfoRepo) SaveByType(ctx context.Context, siteType string, data *e } // GetByType get site info by type -func (sr *siteInfoRepo) GetByType(ctx context.Context, siteType string) (siteInfo *entity.SiteInfo, exist bool, err error) { - siteInfo = sr.getCache(ctx, siteType) - if siteInfo != nil { - return siteInfo, true, nil +func (sr *siteInfoRepo) GetByType(ctx context.Context, siteType string, withoutCache ...bool) (siteInfo *entity.SiteInfo, exist bool, err error) { + if len(withoutCache) == 0 { + siteInfo = sr.getCache(ctx, siteType) + if siteInfo != nil { + return siteInfo, true, nil + } } siteInfo = &entity.SiteInfo{} exist, err = sr.data.DB.Context(ctx).Where(builder.Eq{"type": siteType}).Get(siteInfo) diff --git a/internal/repo/user_external_login/user_external_login_repo.go b/internal/repo/user_external_login/user_external_login_repo.go index c797e461d..b5cf85e86 100644 --- a/internal/repo/user_external_login/user_external_login_repo.go +++ b/internal/repo/user_external_login/user_external_login_repo.go @@ -87,7 +87,7 @@ func (ur *userExternalLoginRepo) GetByUserID(ctx context.Context, provider, user func (ur *userExternalLoginRepo) GetUserExternalLoginList(ctx context.Context, userID string) ( resp []*entity.UserExternalLogin, err error) { resp = make([]*entity.UserExternalLogin, 0) - err = ur.data.DB.Context(ctx).Where("user_id = ?", userID).Find(&resp) + err = ur.data.DB.Context(ctx).Where("user_id = ?", userID).OrderBy("updated_at DESC").Find(&resp) if err != nil { err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 1191492b9..c642b2a13 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -27,36 +27,40 @@ import ( ) type AnswerAPIRouter struct { - langController *controller.LangController - userController *controller.UserController - commentController *controller.CommentController - reportController *controller.ReportController - voteController *controller.VoteController - tagController *controller.TagController - followController *controller.FollowController - collectionController *controller.CollectionController - questionController *controller.QuestionController - answerController *controller.AnswerController - searchController *controller.SearchController - revisionController *controller.RevisionController - rankController *controller.RankController - adminUserController *controller_admin.UserAdminController - reasonController *controller.ReasonController - themeController *controller_admin.ThemeController - adminSiteInfoController *controller_admin.SiteInfoController - siteInfoController *controller.SiteInfoController - notificationController *controller.NotificationController - dashboardController *controller.DashboardController - uploadController *controller.UploadController - activityController *controller.ActivityController - roleController *controller_admin.RoleController - pluginController *controller_admin.PluginController - permissionController *controller.PermissionController - userPluginController *controller.UserPluginController - reviewController *controller.ReviewController - metaController *controller.MetaController - badgeController *controller.BadgeController - adminBadgeController *controller_admin.BadgeController + langController *controller.LangController + userController *controller.UserController + commentController *controller.CommentController + reportController *controller.ReportController + voteController *controller.VoteController + tagController *controller.TagController + followController *controller.FollowController + collectionController *controller.CollectionController + questionController *controller.QuestionController + answerController *controller.AnswerController + searchController *controller.SearchController + revisionController *controller.RevisionController + rankController *controller.RankController + adminUserController *controller_admin.UserAdminController + reasonController *controller.ReasonController + themeController *controller_admin.ThemeController + adminSiteInfoController *controller_admin.SiteInfoController + siteInfoController *controller.SiteInfoController + notificationController *controller.NotificationController + dashboardController *controller.DashboardController + uploadController *controller.UploadController + activityController *controller.ActivityController + roleController *controller_admin.RoleController + pluginController *controller_admin.PluginController + permissionController *controller.PermissionController + userPluginController *controller.UserPluginController + reviewController *controller.ReviewController + metaController *controller.MetaController + badgeController *controller.BadgeController + adminBadgeController *controller_admin.BadgeController + apiKeyController *controller_admin.AdminAPIKeyController + aiController *controller.AIController + aiConversationController *controller.AIConversationController + aiConversationAdminController *controller_admin.AIConversationAdminController } func NewAnswerAPIRouter( @@ -90,38 +94,46 @@ func NewAnswerAPIRouter( metaController *controller.MetaController, badgeController *controller.BadgeController, adminBadgeController *controller_admin.BadgeController, + apiKeyController *controller_admin.AdminAPIKeyController, + aiController *controller.AIController, + aiConversationController *controller.AIConversationController, + aiConversationAdminController *controller_admin.AIConversationAdminController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ - langController: langController, - userController: userController, - commentController: commentController, - reportController: reportController, - voteController: voteController, - tagController: tagController, - followController: followController, - collectionController: collectionController, - questionController: questionController, - answerController: answerController, - searchController: searchController, - revisionController: revisionController, - rankController: rankController, - adminUserController: adminUserController, - reasonController: reasonController, - themeController: themeController, - adminSiteInfoController: adminSiteInfoController, - notificationController: notificationController, - siteInfoController: siteInfoController, - dashboardController: dashboardController, - uploadController: uploadController, - activityController: activityController, - roleController: roleController, - pluginController: pluginController, - permissionController: permissionController, - userPluginController: userPluginController, - reviewController: reviewController, - metaController: metaController, - badgeController: badgeController, - adminBadgeController: adminBadgeController, + langController: langController, + userController: userController, + commentController: commentController, + reportController: reportController, + voteController: voteController, + tagController: tagController, + followController: followController, + collectionController: collectionController, + questionController: questionController, + answerController: answerController, + searchController: searchController, + revisionController: revisionController, + rankController: rankController, + adminUserController: adminUserController, + reasonController: reasonController, + themeController: themeController, + adminSiteInfoController: adminSiteInfoController, + notificationController: notificationController, + siteInfoController: siteInfoController, + dashboardController: dashboardController, + uploadController: uploadController, + activityController: activityController, + roleController: roleController, + pluginController: pluginController, + permissionController: permissionController, + userPluginController: userPluginController, + reviewController: reviewController, + metaController: metaController, + badgeController: badgeController, + adminBadgeController: adminBadgeController, + apiKeyController: apiKeyController, + aiController: aiController, + aiConversationController: aiConversationController, + aiConversationAdminController: aiConversationAdminController, } } @@ -176,9 +188,6 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/personal/comment/page", a.commentController.GetCommentPersonalWithPage) r.GET("/comment", a.commentController.GetComment) - // revision - r.GET("/revisions", a.revisionController.GetRevisionList) - // tag r.GET("/tags/page", a.tagController.GetTagWithPage) r.GET("/tags/following", a.tagController.GetFollowingTags) @@ -212,6 +221,7 @@ func (a *AnswerAPIRouter) RegisterAuthUserWithAnyStatusAnswerAPIRouter(r *gin.Ro func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { // revisions + r.GET("/revisions", a.revisionController.GetRevisionList) r.GET("/revisions/unreviewed", a.revisionController.GetUnreviewedRevisionList) r.PUT("/revisions/audit", a.revisionController.RevisionAudit) r.GET("/revisions/edit/check", a.revisionController.CheckCanUpdateRevision) @@ -310,6 +320,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) { @@ -343,14 +361,27 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { // siteinfo r.GET("/siteinfo/general", a.adminSiteInfoController.GetGeneral) r.PUT("/siteinfo/general", a.adminSiteInfoController.UpdateGeneral) + r.GET("/siteinfo/interface", a.adminSiteInfoController.GetInterface) r.PUT("/siteinfo/interface", a.adminSiteInfoController.UpdateInterface) + r.GET("/siteinfo/users-settings", a.adminSiteInfoController.GetUsersSettings) + r.PUT("/siteinfo/users-settings", a.adminSiteInfoController.UpdateUsersSettings) + r.GET("/siteinfo/branding", a.adminSiteInfoController.GetSiteBranding) r.PUT("/siteinfo/branding", a.adminSiteInfoController.UpdateBranding) - r.GET("/siteinfo/write", a.adminSiteInfoController.GetSiteWrite) - r.PUT("/siteinfo/write", a.adminSiteInfoController.UpdateSiteWrite) - r.GET("/siteinfo/legal", a.adminSiteInfoController.GetSiteLegal) - r.PUT("/siteinfo/legal", a.adminSiteInfoController.UpdateSiteLegal) + + r.GET("/siteinfo/question", a.adminSiteInfoController.GetSiteQuestion) + r.PUT("/siteinfo/question", a.adminSiteInfoController.UpdateSiteQuestion) + r.GET("/siteinfo/tag", a.adminSiteInfoController.GetSiteTag) + r.PUT("/siteinfo/tag", a.adminSiteInfoController.UpdateSiteTag) + r.GET("/siteinfo/advanced", a.adminSiteInfoController.GetSiteAdvanced) + r.PUT("/siteinfo/advanced", a.adminSiteInfoController.UpdateSiteAdvanced) + + r.GET("/siteinfo/polices", a.adminSiteInfoController.GetSitePolicies) + r.PUT("/siteinfo/polices", a.adminSiteInfoController.UpdateSitePolices) + r.GET("/siteinfo/security", a.adminSiteInfoController.GetSiteSecurity) + r.PUT("/siteinfo/security", a.adminSiteInfoController.UpdateSiteSecurity) + r.GET("/siteinfo/seo", a.adminSiteInfoController.GetSeo) r.PUT("/siteinfo/seo", a.adminSiteInfoController.UpdateSeo) r.GET("/siteinfo/login", a.adminSiteInfoController.GetSiteLogin) @@ -381,4 +412,25 @@ func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { // badge r.GET("/badges", a.adminBadgeController.GetBadgeList) r.PUT("/badge/status", a.adminBadgeController.UpdateBadgeStatus) + + // api key + r.GET("/api-key/all", a.apiKeyController.GetAllAPIKeys) + r.POST("/api-key", a.apiKeyController.AddAPIKey) + r.PUT("/api-key", a.apiKeyController.UpdateAPIKey) + r.DELETE("/api-key", a.apiKeyController.DeleteAPIKey) + + // ai config + r.GET("/ai-config", a.adminSiteInfoController.GetAIConfig) + r.PUT("/ai-config", a.adminSiteInfoController.UpdateAIConfig) + r.GET("/ai-provider", a.adminSiteInfoController.GetAIProvider) + r.POST("/ai-models", a.adminSiteInfoController.RequestAIModels) + + // mcp config + r.GET("/mcp-config", a.adminSiteInfoController.GetMCPConfig) + r.PUT("/mcp-config", a.adminSiteInfoController.UpdateMCPConfig) + + // AI conversation management + r.GET("/ai/conversation/page", a.aiConversationAdminController.GetConversationList) + r.GET("/ai/conversation", a.aiConversationAdminController.GetConversationDetail) + r.DELETE("/ai/conversation", a.aiConversationAdminController.DeleteConversation) } diff --git a/internal/schema/ai_config_schema.go b/internal/schema/ai_config_schema.go new file mode 100644 index 000000000..6ac686343 --- /dev/null +++ b/internal/schema/ai_config_schema.go @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +// GetAIProviderResp get AI providers response +type GetAIProviderResp struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + DefaultAPIHost string `json:"default_api_host"` +} + +// GetAIModelsResp get AI model response +type GetAIModelsResp struct { + Object string `json:"object"` + Data []struct { + Id string `json:"id"` + Object string `json:"object"` + Created int `json:"created"` + OwnedBy string `json:"owned_by"` + } `json:"data"` +} + +type GetAIModelsReq struct { + APIHost string `json:"api_host"` + APIKey string `json:"api_key"` +} + +// GetAIModelResp get AI model response +type GetAIModelResp struct { + Id string `json:"id"` + Object string `json:"object"` + Created int `json:"created"` + OwnedBy string `json:"owned_by"` +} diff --git a/internal/schema/ai_conversation_schema.go b/internal/schema/ai_conversation_schema.go new file mode 100644 index 000000000..fd34278a1 --- /dev/null +++ b/internal/schema/ai_conversation_schema.go @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +import ( + "github.com/apache/answer/internal/base/validator" +) + +// AIConversationListReq ai conversation list req +type AIConversationListReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1" form:"page_size"` + UserID string `validate:"omitempty" json:"-"` +} + +// AIConversationListItem ai conversation list item +type AIConversationListItem struct { + ConversationID string `json:"conversation_id"` + Topic string `json:"topic"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationDetailReq ai conversation detail req +type AIConversationDetailReq struct { + ConversationID string `validate:"required" form:"conversation_id" json:"conversation_id"` + UserID string `validate:"omitempty" json:"-"` +} + +// AIConversationRecord ai conversation record +type AIConversationRecord struct { + ChatCompletionID string `json:"chat_completion_id"` + Role string `json:"role"` + Content string `json:"content"` + Helpful int `json:"helpful"` + Unhelpful int `json:"unhelpful"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationDetailResp ai conversation detail resp +type AIConversationDetailResp struct { + ConversationID string `json:"conversation_id"` + Topic string `json:"topic"` + Records []*AIConversationRecord `json:"records"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// AIConversationVoteReq ai conversation vote req +type AIConversationVoteReq struct { + ChatCompletionID string `validate:"required" json:"chat_completion_id"` + VoteType string `validate:"required,oneof=helpful unhelpful" json:"vote_type"` + Cancel bool `validate:"omitempty" json:"cancel"` + UserID string `validate:"omitempty" json:"-"` +} + +// AIConversationAdminListReq ai conversation admin list req +type AIConversationAdminListReq struct { + Page int `validate:"omitempty,min=1" form:"page"` + PageSize int `validate:"omitempty,min=1" form:"page_size"` +} + +// AIConversationAdminListItem ai conversation admin list item +type AIConversationAdminListItem struct { + ID string `json:"id"` + Topic string `json:"topic"` + UserInfo AIConversationUserInfo `json:"user_info"` + HelpfulCount int64 `json:"helpful_count"` + UnhelpfulCount int64 `json:"unhelpful_count"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationUserInfo ai conversation user info +type AIConversationUserInfo struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Rank int `json:"rank"` +} + +// AIConversationAdminDetailReq ai conversation admin detail req +type AIConversationAdminDetailReq struct { + ConversationID string `validate:"required" form:"conversation_id" json:"conversation_id"` +} + +// AIConversationAdminDetailResp ai conversation admin detail resp +type AIConversationAdminDetailResp struct { + ConversationID string `json:"conversation_id"` + Topic string `json:"topic"` + UserInfo AIConversationUserInfo `json:"user_info"` + Records []AIConversationRecord `json:"records"` + CreatedAt int64 `json:"created_at"` +} + +// AIConversationAdminDeleteReq admin delete ai +type AIConversationAdminDeleteReq struct { + ConversationID string `validate:"required" json:"conversation_id"` +} + +func (req *AIConversationDetailReq) Check() (errFields []*validator.FormErrorField, err error) { + return nil, nil +} + +func (req *AIConversationVoteReq) Check() (errFields []*validator.FormErrorField, err error) { + return nil, nil +} diff --git a/internal/schema/api_key_schema.go b/internal/schema/api_key_schema.go new file mode 100644 index 000000000..e9dde7847 --- /dev/null +++ b/internal/schema/api_key_schema.go @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +// GetAPIKeyReq get api key request +type GetAPIKeyReq struct { + UserID string `json:"-"` +} + +// GetAPIKeyResp get api keys response +type GetAPIKeyResp struct { + ID int `json:"id"` + AccessKey string `json:"access_key"` + Description string `json:"description"` + Scope string `json:"scope"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` +} + +// AddAPIKeyReq add api key request +type AddAPIKeyReq struct { + Description string `validate:"required,notblank,lte=150" json:"description"` + Scope string `validate:"required,oneof=read-only global" json:"scope"` + UserID string `json:"-"` +} + +// AddAPIKeyResp add api key response +type AddAPIKeyResp struct { + AccessKey string `json:"access_key"` +} + +// UpdateAPIKeyReq update api key request +type UpdateAPIKeyReq struct { + ID int `validate:"required" json:"id"` + Description string `validate:"required,notblank,lte=150" json:"description"` + UserID string `json:"-"` +} + +// DeleteAPIKeyReq delete api key request +type DeleteAPIKeyReq struct { + ID int `json:"id"` + UserID string `json:"-"` +} diff --git a/internal/schema/mcp_schema.go b/internal/schema/mcp_schema.go new file mode 100644 index 000000000..bead21c9d --- /dev/null +++ b/internal/schema/mcp_schema.go @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package schema + +import ( + "strings" + + "github.com/apache/answer/pkg/converter" + "github.com/mark3labs/mcp-go/mcp" +) + +const ( + MCPSearchCondKeyword = "keyword" + MCPSearchCondUsername = "username" + MCPSearchCondScore = "score" + MCPSearchCondTag = "tag" + MCPSearchCondPage = "page" + MCPSearchCondPageSize = "page_size" + MCPSearchCondTagName = "tag_name" + MCPSearchCondQuestionID = "question_id" + MCPSearchCondObjectID = "object_id" +) + +type MCPSearchCond struct { + Keyword string `json:"keyword"` + Username string `json:"username"` + Score int `json:"score"` + Tags []string `json:"tags"` + QuestionID string `json:"question_id"` +} + +type MCPSearchQuestionDetail struct { + QuestionID string `json:"question_id"` +} + +type MCPSearchCommentCond struct { + ObjectID string `json:"object_id"` +} + +type MCPSearchTagCond struct { + TagName string `json:"tag_name"` +} + +type MCPSearchUserCond struct { + Username string `json:"username"` +} + +type MCPSearchQuestionInfoResp struct { + QuestionID string `json:"question_id"` + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` +} + +type MCPSearchAnswerInfoResp struct { + QuestionID string `json:"question_id"` + QuestionTitle string `json:"question_title,omitempty"` + AnswerID string `json:"answer_id"` + AnswerContent string `json:"answer_content"` + Link string `json:"link"` +} + +type MCPSearchTagResp struct { + TagName string `json:"tag_name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Link string `json:"link"` +} + +type MCPSearchUserInfoResp struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Link string `json:"link"` +} + +type MCPSearchCommentInfoResp struct { + CommentID string `json:"comment_id"` + Content string `json:"content"` + ObjectID string `json:"object_id"` + Link string `json:"link"` +} + +func NewMCPSearchCond(request mcp.CallToolRequest) *MCPSearchCond { + cond := &MCPSearchCond{} + if keyword, ok := getRequestValue(request, MCPSearchCondKeyword); ok { + cond.Keyword = keyword + } + if username, ok := getRequestValue(request, MCPSearchCondUsername); ok { + cond.Username = username + } + if score, ok := getRequestNumber(request, MCPSearchCondScore); ok { + cond.Score = score + } + if tag, ok := getRequestValue(request, MCPSearchCondTag); ok { + cond.Tags = strings.Split(tag, ",") + } + if questionID, ok := getRequestValue(request, MCPSearchCondQuestionID); ok { + cond.QuestionID = questionID + } + return cond +} + +func NewMCPSearchAnswerCond(request mcp.CallToolRequest) *MCPSearchCond { + cond := &MCPSearchCond{} + if questionID, ok := getRequestValue(request, MCPSearchCondQuestionID); ok { + cond.QuestionID = questionID + } + return cond +} + +func NewMCPSearchQuestionDetail(request mcp.CallToolRequest) *MCPSearchQuestionDetail { + cond := &MCPSearchQuestionDetail{} + if questionID, ok := getRequestValue(request, MCPSearchCondQuestionID); ok { + cond.QuestionID = questionID + } + return cond +} + +func NewMCPSearchCommentCond(request mcp.CallToolRequest) *MCPSearchCommentCond { + cond := &MCPSearchCommentCond{} + if keyword, ok := getRequestValue(request, MCPSearchCondObjectID); ok { + cond.ObjectID = keyword + } + return cond +} + +func NewMCPSearchTagCond(request mcp.CallToolRequest) *MCPSearchTagCond { + cond := &MCPSearchTagCond{} + if tagName, ok := getRequestValue(request, MCPSearchCondTagName); ok { + cond.TagName = tagName + } + return cond +} + +func NewMCPSearchUserCond(request mcp.CallToolRequest) *MCPSearchUserCond { + cond := &MCPSearchUserCond{} + if username, ok := getRequestValue(request, MCPSearchCondUsername); ok { + cond.Username = username + } + return cond +} + +func getRequestValue(request mcp.CallToolRequest, key string) (string, bool) { + value, ok := request.GetArguments()[key].(string) + if !ok { + return "", false + } + return value, true +} + +func getRequestNumber(request mcp.CallToolRequest, key string) (int, bool) { + value, ok := request.GetArguments()[key].(float64) + if !ok { + return 0, false + } + return int(value), true +} + +func (cond *MCPSearchCond) ToQueryString() string { + var queryBuilder strings.Builder + if len(cond.Keyword) > 0 { + queryBuilder.WriteString(cond.Keyword) + } + if len(cond.Username) > 0 { + queryBuilder.WriteString(" user:" + cond.Username) + } + if cond.Score > 0 { + queryBuilder.WriteString(" score:" + converter.IntToString(int64(cond.Score))) + } + if len(cond.Tags) > 0 { + for _, tag := range cond.Tags { + queryBuilder.WriteString(" [" + tag + "]") + } + } + return strings.TrimSpace(queryBuilder.String()) +} diff --git a/internal/schema/mcp_tools/mcp_tools.go b/internal/schema/mcp_tools/mcp_tools.go new file mode 100644 index 000000000..949a738c7 --- /dev/null +++ b/internal/schema/mcp_tools/mcp_tools.go @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package mcp_tools + +import ( + "github.com/apache/answer/internal/schema" + "github.com/mark3labs/mcp-go/mcp" +) + +var ( + MCPToolsList = []mcp.Tool{ + NewQuestionsTool(), + NewAnswersTool(), + NewCommentsTool(), + NewTagsTool(), + NewTagDetailTool(), + NewUserTool(), + } +) + +func NewQuestionsTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_questions", + mcp.WithDescription("Searching for questions that already existed in the system. After the search, you can use the get_answers_by_question_id tool to get answers for the questions."), + mcp.WithString(schema.MCPSearchCondKeyword, + mcp.Description("Keyword to search for questions. Multiple keywords separated by spaces"), + ), + mcp.WithString(schema.MCPSearchCondUsername, + mcp.Description("Search for questions that contain only those created by the specified user"), + ), + mcp.WithString(schema.MCPSearchCondTag, + mcp.Description("Filter by tag (semicolon separated for multiple tags)"), + ), + mcp.WithString(schema.MCPSearchCondScore, + mcp.Description("Minimum score that the question must have"), + ), + ) + return listFilesTool +} + +func NewAnswersTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_answers_by_question_id", + mcp.WithDescription("Search for all answers corresponding to the question ID. The question ID is provided by get_questions tool."), + mcp.WithString(schema.MCPSearchCondQuestionID, + mcp.Description("The ID of the question to which the answer belongs. The question ID is provided by get_questions tool."), + ), + ) + return listFilesTool +} + +func NewCommentsTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_comments", + mcp.WithDescription("Searching for comments that already existed in the system"), + mcp.WithString(schema.MCPSearchCondObjectID, + mcp.Description("Queries comments on an object, either a question or an answer. object_id is the id of the object."), + ), + ) + return listFilesTool +} + +func NewTagsTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_tags", + mcp.WithDescription("Searching for tags that already existed in the system"), + mcp.WithString(schema.MCPSearchCondTagName, + mcp.Description("Tag name"), + ), + ) + return listFilesTool +} + +func NewTagDetailTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_tag_detail", + mcp.WithDescription("Get detailed information about a specific tag"), + mcp.WithString(schema.MCPSearchCondTagName, + mcp.Description("Tag name"), + ), + ) + return listFilesTool +} + +func NewUserTool() mcp.Tool { + listFilesTool := mcp.NewTool("get_user", + mcp.WithDescription("Searching for users that already existed in the system"), + mcp.WithString(schema.MCPSearchCondUsername, + mcp.Description("Username"), + ), + ) + return listFilesTool +} diff --git a/internal/schema/revision_schema.go b/internal/schema/revision_schema.go index 6f3246e51..dad067f4e 100644 --- a/internal/schema/revision_schema.go +++ b/internal/schema/revision_schema.go @@ -45,6 +45,8 @@ type AddRevisionDTO struct { type GetRevisionListReq struct { // object id ObjectID string `validate:"required" comment:"object_id" form:"object_id"` + IsAdmin bool `json:"-"` + UserID string `json:"-"` } const RevisionAuditApprove = "approve" diff --git a/internal/schema/simple_obj_info_schema.go b/internal/schema/simple_obj_info_schema.go index a9bcf3b19..21a03ae22 100644 --- a/internal/schema/simple_obj_info_schema.go +++ b/internal/schema/simple_obj_info_schema.go @@ -35,6 +35,7 @@ type SimpleObjectInfo struct { CommentID string `json:"comment_id"` CommentStatus int `json:"comment_status"` TagID string `json:"tag_id"` + TagStatus int `json:"tag_status"` ObjectType string `json:"object_type"` Title string `json:"title"` Content string `json:"content"` @@ -49,6 +50,8 @@ func (s *SimpleObjectInfo) IsDeleted() bool { return s.AnswerStatus == entity.AnswerStatusDeleted case constant.CommentObjectType: return s.CommentStatus == entity.CommentStatusDeleted + case constant.TagObjectType: + return s.TagStatus == entity.TagStatusDeleted } return false } diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 7ab657512..bdf2308d3 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -42,7 +42,6 @@ type SiteGeneralReq struct { Description string `validate:"omitempty,sanitizer,gt=3,lte=2000" form:"description" json:"description"` SiteUrl string `validate:"required,sanitizer,gt=1,lte=512,url" form:"site_url" json:"site_url"` ContactEmail string `validate:"required,sanitizer,gt=1,lte=512,email" form:"contact_email" json:"contact_email"` - CheckUpdate bool `validate:"omitempty,sanitizer" form:"check_update" json:"check_update"` } func (r *SiteGeneralReq) FormatSiteUrl() { @@ -59,12 +58,29 @@ func (r *SiteGeneralReq) FormatSiteUrl() { // SiteInterfaceReq site interface request type SiteInterfaceReq struct { - Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` - TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` + Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"` + TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"` + // Deperecated: use SiteUsersSettingsReq instead + DefaultAvatar string `validate:"omitempty" json:"-"` + // Deperecated: use SiteUsersSettingsReq instead + GravatarBaseURL string `validate:"omitempty" json:"-"` +} + +// SiteInterfaceSettingsReq site interface settings request +type SiteInterfaceSettingsReq struct { + Language string `validate:"required,gt=1,lte=128" json:"language"` + TimeZone string `validate:"required,gt=1,lte=128" json:"time_zone"` +} + +type SiteInterfaceSettingsResp SiteInterfaceSettingsReq + +type SiteUsersSettingsReq struct { DefaultAvatar string `validate:"required,oneof=system gravatar" json:"default_avatar"` GravatarBaseURL string `validate:"omitempty" json:"gravatar_base_url"` } +type SiteUsersSettingsResp SiteUsersSettingsReq + // SiteBrandingReq site branding request type SiteBrandingReq struct { Logo string `validate:"omitempty,gt=0,lte=512" form:"logo" json:"logo"` @@ -73,7 +89,7 @@ type SiteBrandingReq struct { Favicon string `validate:"omitempty,gt=0,lte=512" form:"favicon" json:"favicon"` } -// SiteWriteReq site write request +// SiteWriteReq site write request use SiteQuestionsReq, SiteAdvancedReq and SiteTagsReq instead type SiteWriteReq struct { MinimumContent int `validate:"omitempty,gte=0,lte=65535" json:"min_content"` RestrictAnswer bool `validate:"omitempty" json:"restrict_answer"` @@ -89,21 +105,47 @@ type SiteWriteReq struct { UserID string `json:"-"` } -func (s *SiteWriteResp) GetMaxImageSize() int64 { +type SiteWriteResp SiteWriteReq + +// SiteQuestionsReq site questions settings request +type SiteQuestionsReq struct { + MinimumTags int `validate:"omitempty,gte=0,lte=5" json:"min_tags"` + MinimumContent int `validate:"omitempty,gte=0,lte=65535" json:"min_content"` + RestrictAnswer bool `validate:"omitempty" json:"restrict_answer"` +} + +// SiteAdvancedReq site advanced settings request +type SiteAdvancedReq struct { + MaxImageSize int `validate:"omitempty,gt=0" json:"max_image_size"` + MaxAttachmentSize int `validate:"omitempty,gt=0" json:"max_attachment_size"` + MaxImageMegapixel int `validate:"omitempty,gt=0" json:"max_image_megapixel"` + AuthorizedImageExtensions []string `validate:"omitempty" json:"authorized_image_extensions"` + AuthorizedAttachmentExtensions []string `validate:"omitempty" json:"authorized_attachment_extensions"` +} + +// SiteTagsReq site tags settings request +type SiteTagsReq struct { + ReservedTags []*SiteWriteTag `validate:"omitempty,dive" json:"reserved_tags"` + RecommendTags []*SiteWriteTag `validate:"omitempty,dive" json:"recommend_tags"` + RequiredTag bool `validate:"omitempty" json:"required_tag"` + UserID string `json:"-"` +} + +func (s *SiteAdvancedResp) GetMaxImageSize() int64 { if s.MaxImageSize <= 0 { return constant.DefaultMaxImageSize } return int64(s.MaxImageSize) * 1024 * 1024 } -func (s *SiteWriteResp) GetMaxAttachmentSize() int64 { +func (s *SiteAdvancedResp) GetMaxAttachmentSize() int64 { if s.MaxAttachmentSize <= 0 { return constant.DefaultMaxAttachmentSize } return int64(s.MaxAttachmentSize) * 1024 * 1024 } -func (s *SiteWriteResp) GetMaxImageMegapixel() int { +func (s *SiteAdvancedResp) GetMaxImageMegapixel() int { if s.MaxImageMegapixel <= 0 { return constant.DefaultMaxImageMegapixel } @@ -116,7 +158,7 @@ type SiteWriteTag struct { DisplayName string `json:"display_name"` } -// SiteLegalReq site branding request +// SiteLegalReq site branding request use SitePoliciesReq and SiteSecurityReq instead type SiteLegalReq struct { TermsOfServiceOriginalText string `json:"terms_of_service_original_text"` TermsOfServiceParsedText string `json:"terms_of_service_parsed_text"` @@ -125,6 +167,22 @@ type SiteLegalReq struct { ExternalContentDisplay string `validate:"required,oneof=always_display ask_before_display" json:"external_content_display"` } +type SitePoliciesReq struct { + TermsOfServiceOriginalText string `json:"terms_of_service_original_text"` + TermsOfServiceParsedText string `json:"terms_of_service_parsed_text"` + PrivacyPolicyOriginalText string `json:"privacy_policy_original_text"` + PrivacyPolicyParsedText string `json:"privacy_policy_parsed_text"` +} + +type SiteSecurityReq struct { + LoginRequired bool `json:"login_required"` + ExternalContentDisplay string `validate:"required,oneof=always_display ask_before_display" json:"external_content_display"` + CheckUpdate bool `validate:"omitempty,sanitizer" form:"check_update" json:"check_update"` +} + +type SitePoliciesResp SitePoliciesReq +type SiteSecurityResp SiteSecurityReq + // GetSiteLegalInfoReq site site legal request type GetSiteLegalInfoReq struct { InfoType string `validate:"required,oneof=tos privacy" form:"info_type"` @@ -163,7 +221,6 @@ type SiteLoginReq struct { AllowNewRegistrations bool `json:"allow_new_registrations"` AllowEmailRegistrations bool `json:"allow_email_registrations"` AllowPasswordLogin bool `json:"allow_password_login"` - LoginRequired bool `json:"login_required"` AllowEmailDomains []string `json:"allow_email_domains"` } @@ -181,6 +238,7 @@ type SiteThemeReq struct { Theme string `validate:"required,gt=0,lte=255" json:"theme"` ThemeConfig map[string]any `validate:"omitempty" json:"theme_config"` ColorScheme string `validate:"omitempty,gt=0,lte=100" json:"color_scheme"` + Layout string `validate:"omitempty,oneof=Full-width Fixed-width" json:"layout"` } type SiteSeoReq struct { @@ -193,6 +251,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 @@ -217,6 +325,7 @@ type SiteThemeResp struct { Theme string `json:"theme"` ThemeConfig map[string]any `json:"theme_config"` ColorScheme string `json:"color_scheme"` + Layout string `json:"layout"` } func (s *SiteThemeResp) TrTheme(ctx context.Context) { @@ -236,10 +345,11 @@ type ThemeOption struct { Value string `json:"value"` } -// SiteWriteResp site write response -type SiteWriteResp SiteWriteReq +type SiteQuestionsResp SiteQuestionsReq +type SiteAdvancedResp SiteAdvancedReq +type SiteTagsResp SiteTagsReq -// SiteLegalResp site write response +// SiteLegalResp site write response use SitePoliciesResp and SiteSecurityResp instead type SiteLegalResp SiteLegalReq // SiteLegalSimpleResp site write response @@ -252,25 +362,32 @@ type SiteSeoResp SiteSeoReq // SiteInfoResp get site info response type SiteInfoResp struct { - General *SiteGeneralResp `json:"general"` - Interface *SiteInterfaceResp `json:"interface"` - Branding *SiteBrandingResp `json:"branding"` - Login *SiteLoginResp `json:"login"` - Theme *SiteThemeResp `json:"theme"` - CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"` - SiteSeo *SiteSeoResp `json:"site_seo"` - SiteUsers *SiteUsersResp `json:"site_users"` - Write *SiteWriteResp `json:"site_write"` - Legal *SiteLegalSimpleResp `json:"site_legal"` - Version string `json:"version"` - Revision string `json:"revision"` + General *SiteGeneralResp `json:"general"` + Interface *SiteInterfaceSettingsResp `json:"interface"` + UsersSettings *SiteUsersSettingsResp `json:"users_settings"` + Branding *SiteBrandingResp `json:"branding"` + Login *SiteLoginResp `json:"login"` + Theme *SiteThemeResp `json:"theme"` + CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"` + SiteSeo *SiteSeoResp `json:"site_seo"` + SiteUsers *SiteUsersResp `json:"site_users"` + Advanced *SiteAdvancedResp `json:"site_advanced"` + Questions *SiteQuestionsResp `json:"site_questions"` + Tags *SiteTagsResp `json:"site_tags"` + Legal *SiteLegalSimpleResp `json:"site_legal"` + 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 { - General *SiteGeneralResp `json:"general"` - Interface *SiteInterfaceResp `json:"interface"` - Branding *SiteBrandingResp `json:"branding"` - SiteSeo *SiteSeoResp `json:"site_seo"` - CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"` + General *SiteGeneralResp `json:"general"` + Interface *SiteInterfaceSettingsResp `json:"interface"` + Branding *SiteBrandingResp `json:"branding"` + SiteSeo *SiteSeoResp `json:"site_seo"` + CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"` Title string Year string Canonical string diff --git a/internal/service/activity_common/activity.go b/internal/service/activity_common/activity.go index 74f73a755..3d2efd6a3 100644 --- a/internal/service/activity_common/activity.go +++ b/internal/service/activity_common/activity.go @@ -25,7 +25,7 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" "github.com/apache/answer/pkg/converter" "github.com/apache/answer/pkg/uid" "github.com/segmentfault/pacman/log" @@ -49,13 +49,13 @@ type ActivityRepo interface { type ActivityCommon struct { activityRepo ActivityRepo - activityQueueService activity_queue.ActivityQueueService + activityQueueService activityqueue.Service } // NewActivityCommon new activity common func NewActivityCommon( activityRepo ActivityRepo, - activityQueueService activity_queue.ActivityQueueService, + activityQueueService activityqueue.Service, ) *ActivityCommon { activity := &ActivityCommon{ activityRepo: activityRepo, diff --git a/internal/service/activity_queue/activity_queue.go b/internal/service/activity_queue/activity_queue.go deleted file mode 100644 index 7b8c1e3b8..000000000 --- a/internal/service/activity_queue/activity_queue.go +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 activity_queue - -import ( - "context" - - "github.com/apache/answer/internal/schema" - "github.com/segmentfault/pacman/log" -) - -type ActivityQueueService interface { - Send(ctx context.Context, msg *schema.ActivityMsg) - RegisterHandler(handler func(ctx context.Context, msg *schema.ActivityMsg) error) -} - -type activityQueueService struct { - Queue chan *schema.ActivityMsg - Handler func(ctx context.Context, msg *schema.ActivityMsg) error -} - -func (ns *activityQueueService) Send(ctx context.Context, msg *schema.ActivityMsg) { - ns.Queue <- msg -} - -func (ns *activityQueueService) RegisterHandler( - handler func(ctx context.Context, msg *schema.ActivityMsg) error) { - ns.Handler = handler -} - -func (ns *activityQueueService) working() { - go func() { - for msg := range ns.Queue { - log.Debugf("received activity %+v", msg) - if ns.Handler == nil { - log.Warnf("no handler for activity") - continue - } - if err := ns.Handler(context.Background(), msg); err != nil { - log.Error(err) - } - } - }() -} - -// NewActivityQueueService create a new activity queue service -func NewActivityQueueService() ActivityQueueService { - ns := &activityQueueService{} - ns.Queue = make(chan *schema.ActivityMsg, 128) - ns.working() - return ns -} diff --git a/internal/service/activityqueue/activity_queue.go b/internal/service/activityqueue/activity_queue.go new file mode 100644 index 000000000..2210977bd --- /dev/null +++ b/internal/service/activityqueue/activity_queue.go @@ -0,0 +1,31 @@ +/* + * 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 activityqueue + +import ( + "github.com/apache/answer/internal/base/queue" + "github.com/apache/answer/internal/schema" +) + +type Service queue.Service[*schema.ActivityMsg] + +func NewService() Service { + return queue.New[*schema.ActivityMsg]("activity", 128) +} diff --git a/internal/service/ai_conversation/ai_conversation_service.go b/internal/service/ai_conversation/ai_conversation_service.go new file mode 100644 index 000000000..d095ac0e9 --- /dev/null +++ b/internal/service/ai_conversation/ai_conversation_service.go @@ -0,0 +1,372 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ai_conversation + +import ( + "context" + "strings" + "time" + + "github.com/apache/answer/internal/base/pager" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/repo/ai_conversation" + "github.com/apache/answer/internal/schema" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/segmentfault/pacman/errors" + "github.com/segmentfault/pacman/log" +) + +// AIConversationService +type AIConversationService interface { + CreateConversation(ctx context.Context, userID, conversationID, topic string) error + SaveConversationRecords(ctx context.Context, conversationID, chatcmplID string, records []*ConversationMessage) error + GetConversationList(ctx context.Context, req *schema.AIConversationListReq) (*pager.PageModel, error) + GetConversationDetail(ctx context.Context, req *schema.AIConversationDetailReq) (resp *schema.AIConversationDetailResp, exist bool, err error) + VoteRecord(ctx context.Context, req *schema.AIConversationVoteReq) error + GetConversationListForAdmin(ctx context.Context, req *schema.AIConversationAdminListReq) (*pager.PageModel, error) + GetConversationDetailForAdmin(ctx context.Context, req *schema.AIConversationAdminDetailReq) (*schema.AIConversationAdminDetailResp, error) + DeleteConversationForAdmin(ctx context.Context, req *schema.AIConversationAdminDeleteReq) error +} + +// ConversationMessage +type ConversationMessage struct { + ChatCompletionID string `json:"chat_completion_id"` + Role string `json:"role"` + Content string `json:"content"` +} + +// aiConversationService +type aiConversationService struct { + aiConversationRepo ai_conversation.AIConversationRepo + userCommon *usercommon.UserCommon +} + +// NewAIConversationService +func NewAIConversationService( + aiConversationRepo ai_conversation.AIConversationRepo, + userCommon *usercommon.UserCommon, +) AIConversationService { + return &aiConversationService{ + aiConversationRepo: aiConversationRepo, + userCommon: userCommon, + } +} + +// CreateConversation +func (s *aiConversationService) CreateConversation(ctx context.Context, userID, conversationID, topic string) error { + conversation := &entity.AIConversation{ + ConversationID: conversationID, + Topic: topic, + UserID: userID, + } + err := s.aiConversationRepo.CreateConversation(ctx, conversation) + if err != nil { + log.Errorf("create conversation failed: %v", err) + return err + } + + return nil +} + +// SaveConversationRecords +func (s *aiConversationService) SaveConversationRecords(ctx context.Context, conversationID, chatcmplID string, records []*ConversationMessage) error { + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, conversationID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + content := strings.Builder{} + + for _, record := range records { + if len(record.ChatCompletionID) > 0 { + continue + } + if record.Role == "user" { + aiRecord := &entity.AIConversationRecord{ + ConversationID: conversationID, + ChatCompletionID: chatcmplID, + Role: "user", + Content: record.Content, + } + + err = s.aiConversationRepo.CreateRecord(ctx, aiRecord) + if err != nil { + log.Errorf("create conversation record failed: %v", err) + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + continue + } + + content.WriteString(record.Content) + content.WriteString("\n") + } + aiRecord := &entity.AIConversationRecord{ + ConversationID: conversationID, + ChatCompletionID: chatcmplID, + Role: "assistant", + Content: content.String(), + Helpful: 0, + Unhelpful: 0, + } + + err = s.aiConversationRepo.CreateRecord(ctx, aiRecord) + if err != nil { + log.Errorf("create conversation record failed: %v", err) + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + + conversation.UpdatedAt = time.Now() + err = s.aiConversationRepo.UpdateConversation(ctx, conversation) + if err != nil { + log.Errorf("update conversation failed: %v", err) + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + + return nil +} + +// GetConversationList +func (s *aiConversationService) GetConversationList(ctx context.Context, req *schema.AIConversationListReq) (*pager.PageModel, error) { + conversations, total, err := s.aiConversationRepo.GetConversationsPage(ctx, req.Page, req.PageSize, &entity.AIConversation{UserID: req.UserID}) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + list := make([]schema.AIConversationListItem, 0, len(conversations)) + for _, conversation := range conversations { + list = append(list, schema.AIConversationListItem{ + ConversationID: conversation.ConversationID, + CreatedAt: conversation.CreatedAt.Unix(), + Topic: conversation.Topic, + }) + } + + return pager.NewPageModel(total, list), nil +} + +// GetConversationDetail +func (s *aiConversationService) GetConversationDetail(ctx context.Context, req *schema.AIConversationDetailReq) ( + resp *schema.AIConversationDetailResp, exist bool, err error) { + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, req.ConversationID) + if err != nil { + return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist || conversation.UserID != req.UserID { + return nil, false, nil + } + + records, err := s.aiConversationRepo.GetRecordsByConversationID(ctx, req.ConversationID) + if err != nil { + return nil, false, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + recordList := make([]*schema.AIConversationRecord, 0, len(records)) + for i, record := range records { + if i == 0 { + record.Content = conversation.Topic + } + recordList = append(recordList, &schema.AIConversationRecord{ + ChatCompletionID: record.ChatCompletionID, + Role: record.Role, + Content: record.Content, + Helpful: record.Helpful, + Unhelpful: record.Unhelpful, + CreatedAt: record.CreatedAt.Unix(), + }) + } + + return &schema.AIConversationDetailResp{ + ConversationID: conversation.ConversationID, + Topic: conversation.Topic, + Records: recordList, + CreatedAt: conversation.CreatedAt.Unix(), + UpdatedAt: conversation.UpdatedAt.Unix(), + }, true, nil +} + +// VoteRecord +func (s *aiConversationService) VoteRecord(ctx context.Context, req *schema.AIConversationVoteReq) error { + record, exist, err := s.aiConversationRepo.GetRecordByChatCompletionID(ctx, "assistant", req.ChatCompletionID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, record.ConversationID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + if conversation.UserID != req.UserID { + return errors.Forbidden(reason.UnauthorizedError) + } + + if record.Role != "assistant" { + return errors.BadRequest("Only AI responses can be voted") + } + + if req.VoteType == "helpful" { + if req.Cancel { + record.Helpful = 0 + } else { + record.Helpful = 1 + record.Unhelpful = 0 + } + } else { + if req.Cancel { + record.Unhelpful = 0 + } else { + record.Unhelpful = 1 + record.Helpful = 0 + } + } + + err = s.aiConversationRepo.UpdateRecordVote(ctx, record) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + + return nil +} + +// GetConversationListForAdmin +func (s *aiConversationService) GetConversationListForAdmin( + ctx context.Context, req *schema.AIConversationAdminListReq) (*pager.PageModel, error) { + conversations, total, err := s.aiConversationRepo.GetConversationsForAdmin(ctx, req.Page, req.PageSize, &entity.AIConversation{}) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + list := make([]*schema.AIConversationAdminListItem, 0, len(conversations)) + for _, conversation := range conversations { + userInfo, err := s.getUserInfo(ctx, conversation.UserID) + if err != nil { + log.Errorf("get user info failed for user %s: %v", conversation.UserID, err) + continue + } + + helpful, unhelpful, err := s.aiConversationRepo.GetConversationWithVoteStats(ctx, conversation.ConversationID) + if err != nil { + log.Errorf("get conversation vote stats failed for conversation %s: %v", conversation.ConversationID, err) + continue + } + + list = append(list, &schema.AIConversationAdminListItem{ + ID: conversation.ConversationID, + Topic: conversation.Topic, + UserInfo: userInfo, + HelpfulCount: helpful, + UnhelpfulCount: unhelpful, + CreatedAt: conversation.CreatedAt.Unix(), + }) + } + + return pager.NewPageModel(total, list), nil +} + +// GetConversationDetailForAdmin +func (s *aiConversationService) GetConversationDetailForAdmin(ctx context.Context, req *schema.AIConversationAdminDetailReq) (*schema.AIConversationAdminDetailResp, error) { + conversation, exist, err := s.aiConversationRepo.GetConversation(ctx, req.ConversationID) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return nil, errors.BadRequest(reason.ObjectNotFound) + } + + userInfo, err := s.getUserInfo(ctx, conversation.UserID) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + records, err := s.aiConversationRepo.GetRecordsByConversationID(ctx, req.ConversationID) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err) + } + + recordList := make([]schema.AIConversationRecord, 0, len(records)) + for i, record := range records { + if i == 0 { + record.Content = conversation.Topic + } + recordList = append(recordList, schema.AIConversationRecord{ + ChatCompletionID: record.ChatCompletionID, + Role: record.Role, + Content: record.Content, + Helpful: record.Helpful, + Unhelpful: record.Unhelpful, + CreatedAt: record.CreatedAt.Unix(), + }) + } + + return &schema.AIConversationAdminDetailResp{ + ConversationID: conversation.ConversationID, + Topic: conversation.Topic, + UserInfo: userInfo, + Records: recordList, + CreatedAt: conversation.CreatedAt.Unix(), + }, nil +} + +// getUserInfo +func (s *aiConversationService) getUserInfo(ctx context.Context, userID string) (schema.AIConversationUserInfo, error) { + userInfo := schema.AIConversationUserInfo{} + + user, exist, err := s.userCommon.GetUserBasicInfoByID(ctx, userID) + if err != nil { + return userInfo, err + } + if !exist { + return userInfo, errors.BadRequest(reason.ObjectNotFound) + } + + userInfo.ID = user.ID + userInfo.Username = user.Username + userInfo.DisplayName = user.DisplayName + userInfo.Avatar = user.Avatar + userInfo.Rank = user.Rank + return userInfo, nil +} + +// DeleteConversationForAdmin +func (s *aiConversationService) DeleteConversationForAdmin(ctx context.Context, req *schema.AIConversationAdminDeleteReq) error { + _, exist, err := s.aiConversationRepo.GetConversation(ctx, req.ConversationID) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err) + } + if !exist { + return errors.BadRequest(reason.ObjectNotFound) + } + + if err := s.aiConversationRepo.DeleteConversation(ctx, req.ConversationID); err != nil { + return err + } + + return nil +} diff --git a/internal/service/apikey/apikey_service.go b/internal/service/apikey/apikey_service.go new file mode 100644 index 000000000..43c1294ce --- /dev/null +++ b/internal/service/apikey/apikey_service.go @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package apikey + +import ( + "context" + "strings" + "time" + + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/pkg/token" +) + +type APIKeyRepo interface { + GetAPIKeyList(ctx context.Context) (keys []*entity.APIKey, err error) + GetAPIKey(ctx context.Context, apiKey string) (key *entity.APIKey, exist bool, err error) + UpdateAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) + AddAPIKey(ctx context.Context, apiKey entity.APIKey) (err error) + DeleteAPIKey(ctx context.Context, id int) (err error) +} + +type APIKeyService struct { + apiKeyRepo APIKeyRepo +} + +func NewAPIKeyService( + apiKeyRepo APIKeyRepo, +) *APIKeyService { + return &APIKeyService{ + apiKeyRepo: apiKeyRepo, + } +} + +func (s *APIKeyService) GetAPIKeyList(ctx context.Context, req *schema.GetAPIKeyReq) (resp []*schema.GetAPIKeyResp, err error) { + keys, err := s.apiKeyRepo.GetAPIKeyList(ctx) + if err != nil { + return nil, err + } + resp = make([]*schema.GetAPIKeyResp, 0) + for _, key := range keys { + // hide access key middle part, replace with * + if len(key.AccessKey) < 10 { + // If the access key is too short, do not mask it + key.AccessKey = strings.Repeat("*", len(key.AccessKey)) + } else { + key.AccessKey = key.AccessKey[:7] + strings.Repeat("*", 8) + key.AccessKey[len(key.AccessKey)-4:] + } + + resp = append(resp, &schema.GetAPIKeyResp{ + ID: key.ID, + AccessKey: key.AccessKey, + Description: key.Description, + Scope: key.Scope, + CreatedAt: key.CreatedAt.Unix(), + LastUsedAt: key.LastUsedAt.Unix(), + }) + } + return resp, nil +} + +func (s *APIKeyService) UpdateAPIKey(ctx context.Context, req *schema.UpdateAPIKeyReq) (err error) { + apiKey := entity.APIKey{ + ID: req.ID, + Description: req.Description, + } + err = s.apiKeyRepo.UpdateAPIKey(ctx, apiKey) + if err != nil { + return err + } + return nil +} + +func (s *APIKeyService) AddAPIKey(ctx context.Context, req *schema.AddAPIKeyReq) (resp *schema.AddAPIKeyResp, err error) { + ak := "sk_" + strings.ReplaceAll(token.GenerateToken(), "-", "") + apiKey := entity.APIKey{ + Description: req.Description, + AccessKey: ak, + Scope: req.Scope, + LastUsedAt: time.Now(), + UserID: req.UserID, + } + err = s.apiKeyRepo.AddAPIKey(ctx, apiKey) + if err != nil { + return nil, err + } + resp = &schema.AddAPIKeyResp{ + AccessKey: apiKey.AccessKey, + } + return resp, nil +} + +func (s *APIKeyService) DeleteAPIKey(ctx context.Context, req *schema.DeleteAPIKeyReq) (err error) { + err = s.apiKeyRepo.DeleteAPIKey(ctx, req.ID) + if err != nil { + return err + } + return nil +} diff --git a/internal/service/badge/badge_award_service.go b/internal/service/badge/badge_award_service.go index 982c1d1a4..0799b87c0 100644 --- a/internal/service/badge/badge_award_service.go +++ b/internal/service/badge/badge_award_service.go @@ -28,7 +28,7 @@ import ( "github.com/apache/answer/internal/base/translator" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/object_info" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/pkg/uid" @@ -62,7 +62,7 @@ type BadgeAwardService struct { badgeRepo BadgeRepo userCommon *usercommon.UserCommon objectInfoService *object_info.ObjService - notificationQueueService notice_queue.NotificationQueueService + notificationQueueService noticequeue.Service } func NewBadgeAwardService( @@ -70,7 +70,7 @@ func NewBadgeAwardService( badgeRepo BadgeRepo, userCommon *usercommon.UserCommon, objectInfoService *object_info.ObjService, - notificationQueueService notice_queue.NotificationQueueService, + notificationQueueService noticequeue.Service, ) *BadgeAwardService { return &BadgeAwardService{ badgeAwardRepo: badgeAwardRepo, diff --git a/internal/service/badge/badge_event_handler.go b/internal/service/badge/badge_event_handler.go index 24cabf29b..0a9a84c0f 100644 --- a/internal/service/badge/badge_event_handler.go +++ b/internal/service/badge/badge_event_handler.go @@ -25,13 +25,13 @@ import ( "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/segmentfault/pacman/log" ) type BadgeEventService struct { data *data.Data - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service badgeRepo BadgeRepo eventRuleRepo EventRuleRepo badgeAwardService *BadgeAwardService @@ -45,7 +45,7 @@ type EventRuleRepo interface { func NewBadgeEventService( data *data.Data, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, badgeRepo BadgeRepo, eventRuleRepo EventRuleRepo, badgeAwardService *BadgeAwardService, diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index dc599e6df..30ff43c6b 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -22,7 +22,7 @@ package comment import ( "context" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/internal/service/review" "time" @@ -33,10 +33,10 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" "github.com/apache/answer/internal/service/comment_common" "github.com/apache/answer/internal/service/export" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/object_info" "github.com/apache/answer/internal/service/permission" usercommon "github.com/apache/answer/internal/service/user_common" @@ -88,10 +88,10 @@ type CommentService struct { objectInfoService *object_info.ObjService emailService *export.EmailService userRepo usercommon.UserRepo - notificationQueueService notice_queue.NotificationQueueService - externalNotificationQueueService notice_queue.ExternalNotificationQueueService - activityQueueService activity_queue.ActivityQueueService - eventQueueService event_queue.EventQueueService + notificationQueueService noticequeue.Service + externalNotificationQueueService noticequeue.ExternalService + activityQueueService activityqueue.Service + eventQueueService eventqueue.Service reviewService *review.ReviewService } @@ -104,10 +104,10 @@ func NewCommentService( voteCommon activity_common.VoteRepo, emailService *export.EmailService, userRepo usercommon.UserRepo, - notificationQueueService notice_queue.NotificationQueueService, - externalNotificationQueueService notice_queue.ExternalNotificationQueueService, - activityQueueService activity_queue.ActivityQueueService, - eventQueueService event_queue.EventQueueService, + notificationQueueService noticequeue.Service, + externalNotificationQueueService noticequeue.ExternalService, + activityQueueService activityqueue.Service, + eventQueueService eventqueue.Service, reviewService *review.ReviewService, ) *CommentService { return &CommentService{ diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index f904b82f0..2ad875177 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -24,7 +24,7 @@ import ( "encoding/json" "time" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/reason" @@ -32,11 +32,11 @@ import ( "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" answercommon "github.com/apache/answer/internal/service/answer_common" collectioncommon "github.com/apache/answer/internal/service/collection_common" "github.com/apache/answer/internal/service/export" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/permission" questioncommon "github.com/apache/answer/internal/service/question_common" "github.com/apache/answer/internal/service/review" @@ -65,11 +65,11 @@ type AnswerService struct { voteRepo activity_common.VoteRepo emailService *export.EmailService roleService *role.UserRoleRelService - notificationQueueService notice_queue.NotificationQueueService - externalNotificationQueueService notice_queue.ExternalNotificationQueueService - activityQueueService activity_queue.ActivityQueueService + notificationQueueService noticequeue.Service + externalNotificationQueueService noticequeue.ExternalService + activityQueueService activityqueue.Service reviewService *review.ReviewService - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service } func NewAnswerService( @@ -85,11 +85,11 @@ func NewAnswerService( voteRepo activity_common.VoteRepo, emailService *export.EmailService, roleService *role.UserRoleRelService, - notificationQueueService notice_queue.NotificationQueueService, - externalNotificationQueueService notice_queue.ExternalNotificationQueueService, - activityQueueService activity_queue.ActivityQueueService, + notificationQueueService noticequeue.Service, + externalNotificationQueueService noticequeue.ExternalService, + activityQueueService activityqueue.Service, reviewService *review.ReviewService, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, ) *AnswerService { return &AnswerService{ answerRepo: answerRepo, @@ -455,6 +455,11 @@ func (as *AnswerService) AcceptAnswer(ctx context.Context, req *schema.AcceptAns if !exist { return errors.BadRequest(reason.AnswerNotFound) } + + // check answer belong to question + if acceptedAnswerInfo.QuestionID != req.QuestionID { + return errors.BadRequest(reason.AnswerNotFound) + } acceptedAnswerInfo.ID = uid.DeShortID(acceptedAnswerInfo.ID) } diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index b8372a72e..bc3ac0bb6 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -25,7 +25,7 @@ import ( "strings" "time" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/plugin" "github.com/apache/answer/internal/base/constant" @@ -38,13 +38,13 @@ import ( "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" answercommon "github.com/apache/answer/internal/service/answer_common" collectioncommon "github.com/apache/answer/internal/service/collection_common" "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/export" metacommon "github.com/apache/answer/internal/service/meta_common" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/notification" "github.com/apache/answer/internal/service/permission" questioncommon "github.com/apache/answer/internal/service/question_common" @@ -84,14 +84,14 @@ type QuestionService struct { collectionCommon *collectioncommon.CollectionCommon answerActivityService *activity.AnswerActivityService emailService *export.EmailService - notificationQueueService notice_queue.NotificationQueueService - externalNotificationQueueService notice_queue.ExternalNotificationQueueService - activityQueueService activity_queue.ActivityQueueService + notificationQueueService noticequeue.Service + externalNotificationQueueService noticequeue.ExternalService + activityQueueService activityqueue.Service siteInfoService siteinfo_common.SiteInfoCommonService newQuestionNotificationService *notification.ExternalNotificationService reviewService *review.ReviewService configService *config.ConfigService - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service reviewRepo review.ReviewRepo } @@ -110,14 +110,14 @@ func NewQuestionService( collectionCommon *collectioncommon.CollectionCommon, answerActivityService *activity.AnswerActivityService, emailService *export.EmailService, - notificationQueueService notice_queue.NotificationQueueService, - externalNotificationQueueService notice_queue.ExternalNotificationQueueService, - activityQueueService activity_queue.ActivityQueueService, + notificationQueueService noticequeue.Service, + externalNotificationQueueService noticequeue.ExternalService, + activityQueueService activityqueue.Service, siteInfoService siteinfo_common.SiteInfoCommonService, newQuestionNotificationService *notification.ExternalNotificationService, reviewService *review.ReviewService, configService *config.ConfigService, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, reviewRepo review.ReviewRepo, ) *QuestionService { return &QuestionService{ diff --git a/internal/service/content/revision_service.go b/internal/service/content/revision_service.go index 4ac08e769..66e6181f9 100644 --- a/internal/service/content/revision_service.go +++ b/internal/service/content/revision_service.go @@ -32,9 +32,9 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" answercommon "github.com/apache/answer/internal/service/answer_common" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/object_info" questioncommon "github.com/apache/answer/internal/service/question_common" "github.com/apache/answer/internal/service/report_common" @@ -62,8 +62,8 @@ type RevisionService struct { answerRepo answercommon.AnswerRepo tagRepo tag_common.TagRepo tagCommon *tag_common.TagCommonService - notificationQueueService notice_queue.NotificationQueueService - activityQueueService activity_queue.ActivityQueueService + notificationQueueService noticequeue.Service + activityQueueService activityqueue.Service reportRepo report_common.ReportRepo reviewService *review.ReviewService reviewActivity activity.ReviewActivityRepo @@ -79,8 +79,8 @@ func NewRevisionService( answerRepo answercommon.AnswerRepo, tagRepo tag_common.TagRepo, tagCommon *tag_common.TagCommonService, - notificationQueueService notice_queue.NotificationQueueService, - activityQueueService activity_queue.ActivityQueueService, + notificationQueueService noticequeue.Service, + activityQueueService activityqueue.Service, reportRepo report_common.ReportRepo, reviewService *review.ReviewService, reviewActivity activity.ReviewActivityRepo, @@ -388,6 +388,23 @@ func (rs *RevisionService) GetRevisionList(ctx context.Context, req *schema.GetR ) resp = []schema.GetRevisionResp{} + objInfo, infoErr := rs.objectInfoService.GetInfo(ctx, req.ObjectID) + if infoErr != nil { + return nil, infoErr + } + if !req.IsAdmin && objInfo.IsDeleted() && objInfo.ObjectCreatorUserID != req.UserID { + switch objInfo.ObjectType { + case constant.QuestionObjectType: + return nil, errors.NotFound(reason.QuestionNotFound) + case constant.AnswerObjectType: + return nil, errors.NotFound(reason.AnswerNotFound) + case constant.TagObjectType: + return nil, errors.NotFound(reason.TagNotFound) + default: + return nil, errors.NotFound(reason.ObjectNotFound) + } + } + _ = copier.Copy(&rev, req) revs, err = rs.revisionRepo.GetRevisionList(ctx, &rev) diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index e9cc35788..711d6caa0 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -25,7 +25,7 @@ import ( "fmt" "time" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/pkg/token" "github.com/apache/answer/internal/base/constant" @@ -68,7 +68,7 @@ type UserService struct { userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo userNotificationConfigService *user_notification_config.UserNotificationConfigService questionService *questioncommon.QuestionCommon - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service fileRecordService *file_record.FileRecordService } @@ -84,7 +84,7 @@ func NewUserService(userRepo usercommon.UserRepo, userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo, userNotificationConfigService *user_notification_config.UserNotificationConfigService, questionService *questioncommon.QuestionCommon, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, fileRecordService *file_record.FileRecordService, ) *UserService { return &UserService{ diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go index aa6150497..1f74769f5 100644 --- a/internal/service/content/vote_service.go +++ b/internal/service/content/vote_service.go @@ -24,7 +24,7 @@ import ( "fmt" "strings" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/handler" @@ -62,7 +62,7 @@ type VoteService struct { answerRepo answercommon.AnswerRepo commentCommonRepo comment_common.CommentCommonRepo objectService *object_info.ObjService - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service } func NewVoteService( @@ -72,7 +72,7 @@ func NewVoteService( answerRepo answercommon.AnswerRepo, commentCommonRepo comment_common.CommentCommonRepo, objectService *object_info.ObjService, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, ) *VoteService { return &VoteService{ voteRepo: voteRepo, diff --git a/internal/service/dashboard/dashboard_service.go b/internal/service/dashboard/dashboard_service.go index 8b08ba02f..a6ac76e63 100644 --- a/internal/service/dashboard/dashboard_service.go +++ b/internal/service/dashboard/dashboard_service.go @@ -101,6 +101,12 @@ type DashboardService interface { func (ds *dashboardService) Statistical(ctx context.Context) (*schema.DashboardInfo, error) { dashboardInfo := ds.getFromCache(ctx) + security, err := ds.siteInfoService.GetSiteSecurity(ctx) + if err != nil { + log.Errorf("get general site info failed: %s", err) + return dashboardInfo, nil + } + if dashboardInfo == nil { dashboardInfo = &schema.DashboardInfo{} dashboardInfo.AnswerCount = ds.answerCount(ctx) @@ -108,12 +114,7 @@ func (ds *dashboardService) Statistical(ctx context.Context) (*schema.DashboardI dashboardInfo.UserCount = ds.userCount(ctx) dashboardInfo.VoteCount = ds.voteCount(ctx) dashboardInfo.OccupyingStorageSpace = ds.calculateStorage() - general, err := ds.siteInfoService.GetSiteGeneral(ctx) - if err != nil { - log.Errorf("get general site info failed: %s", err) - return dashboardInfo, nil - } - if general.CheckUpdate { + if security.CheckUpdate { dashboardInfo.VersionInfo.RemoteVersion = ds.remoteVersion(ctx) } dashboardInfo.DatabaseVersion = ds.getDatabaseInfo() @@ -141,9 +142,7 @@ func (ds *dashboardService) Statistical(ctx context.Context) (*schema.DashboardI dashboardInfo.VersionInfo.Version = constant.Version dashboardInfo.VersionInfo.Revision = constant.Revision dashboardInfo.GoVersion = constant.GoVersion - if siteLogin, err := ds.siteInfoService.GetSiteLogin(ctx); err == nil { - dashboardInfo.LoginRequired = siteLogin.LoginRequired - } + dashboardInfo.LoginRequired = security.LoginRequired ds.setCache(ctx, dashboardInfo) return dashboardInfo, nil diff --git a/internal/service/event_queue/event_queue.go b/internal/service/event_queue/event_queue.go deleted file mode 100644 index 77dc302b5..000000000 --- a/internal/service/event_queue/event_queue.go +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 event_queue - -import ( - "context" - - "github.com/apache/answer/internal/schema" - "github.com/segmentfault/pacman/log" -) - -type EventQueueService interface { - Send(ctx context.Context, msg *schema.EventMsg) - RegisterHandler(handler func(ctx context.Context, msg *schema.EventMsg) error) -} - -type eventQueueService struct { - Queue chan *schema.EventMsg - Handler func(ctx context.Context, msg *schema.EventMsg) error -} - -func (ns *eventQueueService) Send(ctx context.Context, msg *schema.EventMsg) { - ns.Queue <- msg -} - -func (ns *eventQueueService) RegisterHandler( - handler func(ctx context.Context, msg *schema.EventMsg) error) { - ns.Handler = handler -} - -func (ns *eventQueueService) working() { - go func() { - for msg := range ns.Queue { - log.Debugf("received badge %+v", msg) - if ns.Handler == nil { - log.Warnf("no handler for badge") - continue - } - if err := ns.Handler(context.Background(), msg); err != nil { - log.Error(err) - } - } - }() -} - -// NewEventQueueService create a new badge queue service -func NewEventQueueService() EventQueueService { - ns := &eventQueueService{} - ns.Queue = make(chan *schema.EventMsg, 128) - ns.working() - return ns -} diff --git a/internal/service/eventqueue/event_queue.go b/internal/service/eventqueue/event_queue.go new file mode 100644 index 000000000..e93a83636 --- /dev/null +++ b/internal/service/eventqueue/event_queue.go @@ -0,0 +1,31 @@ +/* + * 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 eventqueue + +import ( + "github.com/apache/answer/internal/base/queue" + "github.com/apache/answer/internal/schema" +) + +type Service queue.Service[*schema.EventMsg] + +func NewService() Service { + return queue.New[*schema.EventMsg]("event", 128) +} diff --git a/internal/service/feature_toggle/feature_toggle_service.go b/internal/service/feature_toggle/feature_toggle_service.go new file mode 100644 index 000000000..b5bdad541 --- /dev/null +++ b/internal/service/feature_toggle/feature_toggle_service.go @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package feature_toggle + +import ( + "context" + "encoding/json" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/siteinfo_common" + "github.com/segmentfault/pacman/errors" +) + +// Feature keys +const ( + FeatureBadge = "badge" + FeatureCustomDomain = "custom_domain" + FeatureMCP = "mcp" + FeaturePrivateAPI = "private_api" + FeatureAIChatbot = "ai_chatbot" + FeatureArticle = "article" + FeatureCategory = "category" +) + +type toggleConfig struct { + Toggles map[string]bool `json:"toggles"` +} + +// FeatureToggleService persist and query feature switches. +type FeatureToggleService struct { + siteInfoRepo siteinfo_common.SiteInfoRepo +} + +// NewFeatureToggleService creates a new feature toggle service instance. +func NewFeatureToggleService(siteInfoRepo siteinfo_common.SiteInfoRepo) *FeatureToggleService { + return &FeatureToggleService{ + siteInfoRepo: siteInfoRepo, + } +} + +// UpdateAll overwrites the feature toggle configuration. +func (s *FeatureToggleService) UpdateAll(ctx context.Context, toggles map[string]bool) error { + cfg := &toggleConfig{ + Toggles: sanitizeToggleMap(toggles), + } + + data, err := json.Marshal(cfg) + if err != nil { + return err + } + + info := &entity.SiteInfo{ + Type: constant.SiteTypeFeatureToggle, + Content: string(data), + Status: 1, + } + + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeFeatureToggle, info) +} + +// GetAll returns all feature toggles. +func (s *FeatureToggleService) GetAll(ctx context.Context) (map[string]bool, error) { + siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeFeatureToggle, true) + if err != nil { + return nil, err + } + if !exist || siteInfo == nil || siteInfo.Content == "" { + return map[string]bool{}, nil + } + + cfg := &toggleConfig{} + if err := json.Unmarshal([]byte(siteInfo.Content), cfg); err != nil { + return map[string]bool{}, err + } + return sanitizeToggleMap(cfg.Toggles), nil +} + +// IsEnabled returns whether a feature is enabled. Missing config defaults to true. +func (s *FeatureToggleService) IsEnabled(ctx context.Context, feature string) (bool, error) { + toggles, err := s.GetAll(ctx) + if err != nil { + return false, err + } + if len(toggles) == 0 { + return true, nil + } + value, ok := toggles[feature] + if !ok { + return true, nil + } + return value, nil +} + +// EnsureEnabled returns error if feature disabled. +func (s *FeatureToggleService) EnsureEnabled(ctx context.Context, feature string) error { + enabled, err := s.IsEnabled(ctx, feature) + if err != nil { + return err + } + if !enabled { + return errors.BadRequest(reason.ErrFeatureDisabled) + } + return nil +} + +func sanitizeToggleMap(in map[string]bool) map[string]bool { + if in == nil { + return map[string]bool{} + } + return in +} diff --git a/internal/service/meta/meta_service.go b/internal/service/meta/meta_service.go index c1ca7c619..e48e8f468 100644 --- a/internal/service/meta/meta_service.go +++ b/internal/service/meta/meta_service.go @@ -26,7 +26,7 @@ import ( "strconv" "strings" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/handler" @@ -48,7 +48,7 @@ type MetaService struct { userCommon *usercommon.UserCommon questionRepo questioncommon.QuestionRepo answerRepo answercommon.AnswerRepo - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service } func NewMetaService( @@ -56,7 +56,7 @@ func NewMetaService( userCommon *usercommon.UserCommon, answerRepo answercommon.AnswerRepo, questionRepo questioncommon.QuestionRepo, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, ) *MetaService { return &MetaService{ metaCommonService: metaCommonService, diff --git a/internal/service/mock/siteinfo_repo_mock.go b/internal/service/mock/siteinfo_repo_mock.go index a98ceb68c..ad3a170a7 100644 --- a/internal/service/mock/siteinfo_repo_mock.go +++ b/internal/service/mock/siteinfo_repo_mock.go @@ -62,9 +62,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) @@ -72,9 +76,10 @@ func (m *MockSiteInfoRepo) GetByType(ctx context.Context, siteType string) (*ent } // GetByType indicates an expected call of GetByType. -func (mr *MockSiteInfoRepoMockRecorder) GetByType(ctx, siteType any) *gomock.Call { +func (mr *MockSiteInfoRepoMockRecorder) GetByType(ctx, siteType any, withoutCache ...any) *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. @@ -158,6 +163,36 @@ func (mr *MockSiteInfoCommonServiceMockRecorder) FormatListAvatar(ctx, userList return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FormatListAvatar", reflect.TypeOf((*MockSiteInfoCommonService)(nil).FormatListAvatar), ctx, userList) } +// GetSiteAI mocks base method. +func (m *MockSiteInfoCommonService) GetSiteAI(ctx context.Context) (*schema.SiteAIResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSiteAI", ctx) + ret0, _ := ret[0].(*schema.SiteAIResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSiteAI indicates an expected call of GetSiteAI. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteAI(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteAI", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteAI), ctx) +} + +// GetSiteAdvanced mocks base method. +func (m *MockSiteInfoCommonService) GetSiteAdvanced(ctx context.Context) (*schema.SiteAdvancedResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSiteAdvanced", ctx) + ret0, _ := ret[0].(*schema.SiteAdvancedResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSiteAdvanced indicates an expected call of GetSiteAdvanced. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteAdvanced(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteAdvanced", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteAdvanced), ctx) +} + // GetSiteBranding mocks base method. func (m *MockSiteInfoCommonService) GetSiteBranding(ctx context.Context) (*schema.SiteBrandingResp, error) { m.ctrl.T.Helper() @@ -218,10 +253,10 @@ func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteInfoByType(ctx, siteType } // GetSiteInterface mocks base method. -func (m *MockSiteInfoCommonService) GetSiteInterface(ctx context.Context) (*schema.SiteInterfaceResp, error) { +func (m *MockSiteInfoCommonService) GetSiteInterface(ctx context.Context) (*schema.SiteInterfaceSettingsResp, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSiteInterface", ctx) - ret0, _ := ret[0].(*schema.SiteInterfaceResp) + ret0, _ := ret[0].(*schema.SiteInterfaceSettingsResp) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -232,34 +267,79 @@ func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteInterface(ctx any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteInterface", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteInterface), ctx) } -// GetSiteLegal mocks base method. -func (m *MockSiteInfoCommonService) GetSiteLegal(ctx context.Context) (*schema.SiteLegalResp, error) { +// GetSiteLogin mocks base method. +func (m *MockSiteInfoCommonService) GetSiteLogin(ctx context.Context) (*schema.SiteLoginResp, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSiteLegal", ctx) - ret0, _ := ret[0].(*schema.SiteLegalResp) + ret := m.ctrl.Call(m, "GetSiteLogin", ctx) + ret0, _ := ret[0].(*schema.SiteLoginResp) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetSiteLegal indicates an expected call of GetSiteLegal. -func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteLegal(ctx any) *gomock.Call { +// GetSiteLogin indicates an expected call of GetSiteLogin. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteLogin(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteLegal", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteLegal), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteLogin", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteLogin), ctx) } -// GetSiteLogin mocks base method. -func (m *MockSiteInfoCommonService) GetSiteLogin(ctx context.Context) (*schema.SiteLoginResp, error) { +// GetSiteMCP mocks base method. +func (m *MockSiteInfoCommonService) GetSiteMCP(ctx context.Context) (*schema.SiteMCPResp, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSiteLogin", ctx) - ret0, _ := ret[0].(*schema.SiteLoginResp) + ret := m.ctrl.Call(m, "GetSiteMCP", ctx) + ret0, _ := ret[0].(*schema.SiteMCPResp) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetSiteLogin indicates an expected call of GetSiteLogin. -func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteLogin(ctx any) *gomock.Call { +// GetSiteMCP indicates an expected call of GetSiteMCP. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteMCP(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteLogin", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteLogin), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteMCP", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteMCP), ctx) +} + +// GetSitePolicies mocks base method. +func (m *MockSiteInfoCommonService) GetSitePolicies(ctx context.Context) (*schema.SitePoliciesResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSitePolicies", ctx) + ret0, _ := ret[0].(*schema.SitePoliciesResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSitePolicies indicates an expected call of GetSitePolicies. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSitePolicies(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSitePolicies", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSitePolicies), ctx) +} + +// GetSiteQuestion mocks base method. +func (m *MockSiteInfoCommonService) GetSiteQuestion(ctx context.Context) (*schema.SiteQuestionsResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSiteQuestion", ctx) + ret0, _ := ret[0].(*schema.SiteQuestionsResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSiteQuestion indicates an expected call of GetSiteQuestion. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteQuestion(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteQuestion", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteQuestion), ctx) +} + +// GetSiteSecurity mocks base method. +func (m *MockSiteInfoCommonService) GetSiteSecurity(ctx context.Context) (*schema.SiteSecurityResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSiteSecurity", ctx) + ret0, _ := ret[0].(*schema.SiteSecurityResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSiteSecurity indicates an expected call of GetSiteSecurity. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteSecurity(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteSecurity", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteSecurity), ctx) } // GetSiteSeo mocks base method. @@ -277,6 +357,21 @@ func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteSeo(ctx any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteSeo", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteSeo), ctx) } +// GetSiteTag mocks base method. +func (m *MockSiteInfoCommonService) GetSiteTag(ctx context.Context) (*schema.SiteTagsResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSiteTag", ctx) + ret0, _ := ret[0].(*schema.SiteTagsResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSiteTag indicates an expected call of GetSiteTag. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteTag(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteTag", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteTag), ctx) +} + // GetSiteTheme mocks base method. func (m *MockSiteInfoCommonService) GetSiteTheme(ctx context.Context) (*schema.SiteThemeResp, error) { m.ctrl.T.Helper() @@ -307,6 +402,21 @@ func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteUsers(ctx any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteUsers", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteUsers), ctx) } +// GetSiteUsersSettings mocks base method. +func (m *MockSiteInfoCommonService) GetSiteUsersSettings(ctx context.Context) (*schema.SiteUsersSettingsResp, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSiteUsersSettings", ctx) + ret0, _ := ret[0].(*schema.SiteUsersSettingsResp) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSiteUsersSettings indicates an expected call of GetSiteUsersSettings. +func (mr *MockSiteInfoCommonServiceMockRecorder) GetSiteUsersSettings(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSiteUsersSettings", reflect.TypeOf((*MockSiteInfoCommonService)(nil).GetSiteUsersSettings), ctx) +} + // GetSiteWrite mocks base method. func (m *MockSiteInfoCommonService) GetSiteWrite(ctx context.Context) (*schema.SiteWriteResp, error) { m.ctrl.T.Helper() diff --git a/internal/service/notice_queue/external_notification_queue.go b/internal/service/notice_queue/external_notification_queue.go deleted file mode 100644 index 6322a77ec..000000000 --- a/internal/service/notice_queue/external_notification_queue.go +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 notice_queue - -import ( - "context" - - "github.com/apache/answer/internal/schema" - "github.com/segmentfault/pacman/log" -) - -type ExternalNotificationQueueService interface { - Send(ctx context.Context, msg *schema.ExternalNotificationMsg) - RegisterHandler(handler func(ctx context.Context, msg *schema.ExternalNotificationMsg) error) -} - -type externalNotificationQueueService struct { - Queue chan *schema.ExternalNotificationMsg - Handler func(ctx context.Context, msg *schema.ExternalNotificationMsg) error -} - -func (ns *externalNotificationQueueService) Send(ctx context.Context, msg *schema.ExternalNotificationMsg) { - ns.Queue <- msg -} - -func (ns *externalNotificationQueueService) RegisterHandler( - handler func(ctx context.Context, msg *schema.ExternalNotificationMsg) error) { - ns.Handler = handler -} - -func (ns *externalNotificationQueueService) working() { - go func() { - for msg := range ns.Queue { - log.Debugf("received notification %+v", msg) - if ns.Handler == nil { - log.Warnf("no handler for notification") - continue - } - if err := ns.Handler(context.Background(), msg); err != nil { - log.Error(err) - } - } - }() -} - -// NewNewQuestionNotificationQueueService create a new notification queue service -func NewNewQuestionNotificationQueueService() ExternalNotificationQueueService { - ns := &externalNotificationQueueService{} - ns.Queue = make(chan *schema.ExternalNotificationMsg, 128) - ns.working() - return ns -} diff --git a/internal/service/notice_queue/notice_queue.go b/internal/service/notice_queue/notice_queue.go deleted file mode 100644 index 22b733e32..000000000 --- a/internal/service/notice_queue/notice_queue.go +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 notice_queue - -import ( - "context" - - "github.com/apache/answer/internal/schema" - "github.com/segmentfault/pacman/log" -) - -type NotificationQueueService interface { - Send(ctx context.Context, msg *schema.NotificationMsg) - RegisterHandler(handler func(ctx context.Context, msg *schema.NotificationMsg) error) -} - -type notificationQueueService struct { - Queue chan *schema.NotificationMsg - Handler func(ctx context.Context, msg *schema.NotificationMsg) error -} - -func (ns *notificationQueueService) Send(ctx context.Context, msg *schema.NotificationMsg) { - ns.Queue <- msg -} - -func (ns *notificationQueueService) RegisterHandler( - handler func(ctx context.Context, msg *schema.NotificationMsg) error) { - ns.Handler = handler -} - -func (ns *notificationQueueService) working() { - go func() { - for msg := range ns.Queue { - log.Debugf("received notification %+v", msg) - if ns.Handler == nil { - log.Warnf("no handler for notification") - continue - } - if err := ns.Handler(context.Background(), msg); err != nil { - log.Error(err) - } - } - }() -} - -// NewNotificationQueueService create a new notification queue service -func NewNotificationQueueService() NotificationQueueService { - ns := ¬ificationQueueService{} - ns.Queue = make(chan *schema.NotificationMsg, 128) - ns.working() - return ns -} diff --git a/internal/service/noticequeue/notice_queue.go b/internal/service/noticequeue/notice_queue.go new file mode 100644 index 000000000..5e4d4b0f9 --- /dev/null +++ b/internal/service/noticequeue/notice_queue.go @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package noticequeue + +import ( + "github.com/apache/answer/internal/base/queue" + "github.com/apache/answer/internal/schema" +) + +type Service queue.Service[*schema.NotificationMsg] + +func NewService() Service { + return queue.New[*schema.NotificationMsg]("notification", 128) +} + +type ExternalService queue.Service[*schema.ExternalNotificationMsg] + +func NewExternalService() ExternalService { + return queue.New[*schema.ExternalNotificationMsg]("external_notification", 128) +} diff --git a/internal/service/notification/external_notification.go b/internal/service/notification/external_notification.go index d6bdd2fb7..425a8c2bb 100644 --- a/internal/service/notification/external_notification.go +++ b/internal/service/notification/external_notification.go @@ -28,7 +28,7 @@ import ( "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity_common" "github.com/apache/answer/internal/service/export" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/siteinfo_common" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/internal/service/user_external_login" @@ -42,7 +42,7 @@ type ExternalNotificationService struct { followRepo activity_common.FollowRepo emailService *export.EmailService userRepo usercommon.UserRepo - notificationQueueService notice_queue.ExternalNotificationQueueService + notificationQueueService noticequeue.ExternalService userExternalLoginRepo user_external_login.UserExternalLoginRepo siteInfoService siteinfo_common.SiteInfoCommonService } @@ -53,7 +53,7 @@ func NewExternalNotificationService( followRepo activity_common.FollowRepo, emailService *export.EmailService, userRepo usercommon.UserRepo, - notificationQueueService notice_queue.ExternalNotificationQueueService, + notificationQueueService noticequeue.ExternalService, userExternalLoginRepo user_external_login.UserExternalLoginRepo, siteInfoService siteinfo_common.SiteInfoCommonService, ) *ExternalNotificationService { diff --git a/internal/service/notification/new_question_notification.go b/internal/service/notification/new_question_notification.go index 2f83042b2..0a5471873 100644 --- a/internal/service/notification/new_question_notification.go +++ b/internal/service/notification/new_question_notification.go @@ -238,6 +238,19 @@ func (ns *ExternalNotificationService) syncNewQuestionNotificationToPlugin(ctx c } } + // Get all external logins as fallback + externalLogins, err := ns.userExternalLoginRepo.GetUserExternalLoginList(ctx, subscriberUserID) + if err != nil { + log.Errorf("get user external login list failed for user %s: %v", subscriberUserID, err) + } else if len(externalLogins) > 0 { + newMsg.ReceiverExternalID = externalLogins[0].ExternalID + if len(externalLogins) > 1 { + log.Debugf("user %s has %d SSO logins, using most recent: provider=%s", + subscriberUserID, len(externalLogins), externalLogins[0].Provider) + } + } + + // Try to get external login specific to this plugin (takes precedence over fallback) userInfo, exist, err := ns.userExternalLoginRepo.GetByUserID(ctx, fn.Info().SlugName, subscriberUserID) if err != nil { log.Errorf("get user external login info failed: %v", err) diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index 0bbd1865f..aa3f4106c 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -35,7 +35,7 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/object_info" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/pkg/uid" @@ -66,7 +66,7 @@ type NotificationCommon struct { followRepo activity_common.FollowRepo userCommon *usercommon.UserCommon objectInfoService *object_info.ObjService - notificationQueueService notice_queue.NotificationQueueService + notificationQueueService noticequeue.Service userExternalLoginRepo user_external_login.UserExternalLoginRepo siteInfoService siteinfo_common.SiteInfoCommonService } @@ -78,7 +78,7 @@ func NewNotificationCommon( activityRepo activity_common.ActivityRepo, followRepo activity_common.FollowRepo, objectInfoService *object_info.ObjService, - notificationQueueService notice_queue.NotificationQueueService, + notificationQueueService noticequeue.Service, userExternalLoginRepo user_external_login.UserExternalLoginRepo, siteInfoService siteinfo_common.SiteInfoCommonService, ) *NotificationCommon { @@ -423,6 +423,13 @@ func (ns *NotificationCommon) syncNotificationToPlugin(ctx context.Context, objI } } + externalLogins, err := ns.userExternalLoginRepo.GetUserExternalLoginList(ctx, msg.ReceiverUserID) + if err != nil { + log.Errorf("get user external login list failed for user %s: %v", msg.ReceiverUserID, err) + } else if len(externalLogins) > 0 { + pluginNotificationMsg.ReceiverExternalID = externalLogins[0].ExternalID + } + _ = plugin.CallNotification(func(fn plugin.Notification) error { userInfo, exist, err := ns.userExternalLoginRepo.GetByUserID(ctx, fn.Info().SlugName, msg.ReceiverUserID) if err != nil { diff --git a/internal/service/object_info/object_info.go b/internal/service/object_info/object_info.go index 5ef438ff3..6380b8809 100644 --- a/internal/service/object_info/object_info.go +++ b/internal/service/object_info/object_info.go @@ -277,11 +277,13 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc break } objInfo = &schema.SimpleObjectInfo{ - ObjectID: tagInfo.ID, - TagID: tagInfo.ID, - ObjectType: objectType, - Title: tagInfo.SlugName, - Content: tagInfo.ParsedText, // todo trim + ObjectID: tagInfo.ID, + ObjectCreatorUserID: tagInfo.UserID, + TagID: tagInfo.ID, + TagStatus: tagInfo.Status, + ObjectType: objectType, + Title: tagInfo.SlugName, + Content: tagInfo.ParsedText, // todo trim } } if objInfo == nil { diff --git a/internal/service/provider.go b/internal/service/provider.go index 65535f41b..3e43b0ae0 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -23,8 +23,10 @@ import ( "github.com/apache/answer/internal/service/action" "github.com/apache/answer/internal/service/activity" "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/activity_queue" + "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" @@ -34,14 +36,15 @@ import ( "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/content" "github.com/apache/answer/internal/service/dashboard" - "github.com/apache/answer/internal/service/event_queue" + "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" "github.com/apache/answer/internal/service/meta" metacommon "github.com/apache/answer/internal/service/meta_common" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/notification" notficationcommon "github.com/apache/answer/internal/service/notification_common" "github.com/apache/answer/internal/service/object_info" @@ -114,18 +117,21 @@ var ProviderSetService = wire.NewSet( user_external_login.NewUserCenterLoginService, plugin_common.NewPluginCommonService, config.NewConfigService, - notice_queue.NewNotificationQueueService, - activity_queue.NewActivityQueueService, + noticequeue.NewService, + activityqueue.NewService, user_notification_config.NewUserNotificationConfigService, notification.NewExternalNotificationService, - notice_queue.NewNewQuestionNotificationQueueService, + noticequeue.NewExternalService, review.NewReviewService, meta.NewMetaService, - event_queue.NewEventQueueService, + eventqueue.NewService, badge.NewBadgeService, badge.NewBadgeEventService, badge.NewBadgeAwardService, badge.NewBadgeGroupService, importer.NewImporterService, file_record.NewFileRecordService, + apikey.NewAPIKeyService, + ai_conversation.NewAIConversationService, + feature_toggle.NewFeatureToggleService, ) diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 846dea894..3a7306342 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -34,7 +34,7 @@ import ( "github.com/apache/answer/internal/base/handler" "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/service/activity_common" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" "github.com/apache/answer/internal/service/config" metacommon "github.com/apache/answer/internal/service/meta_common" "github.com/apache/answer/internal/service/revision" @@ -103,7 +103,7 @@ type QuestionCommon struct { AnswerCommon *answercommon.AnswerCommon metaCommonService *metacommon.MetaCommonService configService *config.ConfigService - activityQueueService activity_queue.ActivityQueueService + activityQueueService activityqueue.Service revisionRepo revision.RevisionRepo siteInfoService siteinfo_common.SiteInfoCommonService data *data.Data @@ -119,7 +119,7 @@ func NewQuestionCommon(questionRepo QuestionRepo, answerCommon *answercommon.AnswerCommon, metaCommonService *metacommon.MetaCommonService, configService *config.ConfigService, - activityQueueService activity_queue.ActivityQueueService, + activityQueueService activityqueue.Service, revisionRepo revision.RevisionRepo, siteInfoService siteinfo_common.SiteInfoCommonService, data *data.Data, @@ -900,7 +900,7 @@ func (qs *QuestionCommon) tryToGetQuestionIDFromMsg(ctx context.Context, closeMs } func (qs *QuestionCommon) GetMinimumContentLength(ctx context.Context) (int, error) { - siteInfo, err := qs.siteInfoService.GetSiteWrite(ctx) + siteInfo, err := qs.siteInfoService.GetSiteQuestion(ctx) if err != nil { return 6, err } diff --git a/internal/service/report/report_service.go b/internal/service/report/report_service.go index d32ccdabf..84c15d597 100644 --- a/internal/service/report/report_service.go +++ b/internal/service/report/report_service.go @@ -22,7 +22,7 @@ package report import ( "encoding/json" - "github.com/apache/answer/internal/service/event_queue" + "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/handler" @@ -57,7 +57,7 @@ type ReportService struct { commentCommonRepo comment_common.CommentCommonRepo reportHandle *report_handle.ReportHandle configService *config.ConfigService - eventQueueService event_queue.EventQueueService + eventQueueService eventqueue.Service } // NewReportService new report service @@ -70,7 +70,7 @@ func NewReportService( commentCommonRepo comment_common.CommentCommonRepo, reportHandle *report_handle.ReportHandle, configService *config.ConfigService, - eventQueueService event_queue.EventQueueService, + eventQueueService eventqueue.Service, ) *ReportService { return &ReportService{ reportRepo: reportRepo, diff --git a/internal/service/review/review_service.go b/internal/service/review/review_service.go index a23b9ee43..bbb142894 100644 --- a/internal/service/review/review_service.go +++ b/internal/service/review/review_service.go @@ -29,7 +29,7 @@ import ( "github.com/apache/answer/internal/schema" answercommon "github.com/apache/answer/internal/service/answer_common" commentcommon "github.com/apache/answer/internal/service/comment_common" - "github.com/apache/answer/internal/service/notice_queue" + "github.com/apache/answer/internal/service/noticequeue" "github.com/apache/answer/internal/service/object_info" questioncommon "github.com/apache/answer/internal/service/question_common" "github.com/apache/answer/internal/service/role" @@ -66,8 +66,8 @@ type ReviewService struct { userRoleService *role.UserRoleRelService tagCommon *tagcommon.TagCommonService questionCommon *questioncommon.QuestionCommon - externalNotificationQueueService notice_queue.ExternalNotificationQueueService - notificationQueueService notice_queue.NotificationQueueService + externalNotificationQueueService noticequeue.ExternalService + notificationQueueService noticequeue.Service siteInfoService siteinfo_common.SiteInfoCommonService commentCommonRepo commentcommon.CommentCommonRepo } @@ -81,10 +81,10 @@ func NewReviewService( questionRepo questioncommon.QuestionRepo, answerRepo answercommon.AnswerRepo, userRoleService *role.UserRoleRelService, - externalNotificationQueueService notice_queue.ExternalNotificationQueueService, + externalNotificationQueueService noticequeue.ExternalService, tagCommon *tagcommon.TagCommonService, questionCommon *questioncommon.QuestionCommon, - notificationQueueService notice_queue.NotificationQueueService, + notificationQueueService noticequeue.Service, siteInfoService siteinfo_common.SiteInfoCommonService, commentCommonRepo commentcommon.CommentCommonRepo, ) *ReviewService { diff --git a/internal/service/siteinfo/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go index f355d09f4..1e25cbaa4 100644 --- a/internal/service/siteinfo/siteinfo_service.go +++ b/internal/service/siteinfo/siteinfo_service.go @@ -39,7 +39,9 @@ import ( "github.com/apache/answer/internal/service/siteinfo_common" tagcommon "github.com/apache/answer/internal/service/tag_common" "github.com/apache/answer/plugin" + "github.com/go-resty/resty/v2" "github.com/jinzhu/copier" + "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" ) @@ -89,10 +91,15 @@ func (s *SiteInfoService) GetSiteGeneral(ctx context.Context) (resp *schema.Site } // GetSiteInterface get site info interface -func (s *SiteInfoService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceResp, err error) { +func (s *SiteInfoService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceSettingsResp, err error) { return s.siteInfoCommonService.GetSiteInterface(ctx) } +// GetSiteUsersSettings get site info users settings +func (s *SiteInfoService) GetSiteUsersSettings(ctx context.Context) (resp *schema.SiteUsersSettingsResp, err error) { + return s.siteInfoCommonService.GetSiteUsersSettings(ctx) +} + // GetSiteBranding get site info branding func (s *SiteInfoService) GetSiteBranding(ctx context.Context) (resp *schema.SiteBrandingResp, err error) { return s.siteInfoCommonService.GetSiteBranding(ctx) @@ -103,17 +110,13 @@ func (s *SiteInfoService) GetSiteUsers(ctx context.Context) (resp *schema.SiteUs return s.siteInfoCommonService.GetSiteUsers(ctx) } -// GetSiteWrite get site info write -func (s *SiteInfoService) GetSiteWrite(ctx context.Context) (resp *schema.SiteWriteResp, err error) { - resp = &schema.SiteWriteResp{} - siteInfo, exist, err := s.siteInfoRepo.GetByType(ctx, constant.SiteTypeWrite) +// GetSiteTag get site info write +func (s *SiteInfoService) GetSiteTag(ctx context.Context) (resp *schema.SiteTagsResp, err error) { + resp, err = s.siteInfoCommonService.GetSiteTag(ctx) if err != nil { log.Error(err) return resp, nil } - if exist { - _ = json.Unmarshal([]byte(siteInfo.Content), resp) - } resp.RecommendTags, err = s.tagCommonService.GetSiteWriteRecommendTag(ctx) if err != nil { @@ -126,9 +129,24 @@ func (s *SiteInfoService) GetSiteWrite(ctx context.Context) (resp *schema.SiteWr return resp, nil } -// GetSiteLegal get site legal info -func (s *SiteInfoService) GetSiteLegal(ctx context.Context) (resp *schema.SiteLegalResp, err error) { - return s.siteInfoCommonService.GetSiteLegal(ctx) +// GetSiteQuestion get site questions settings +func (s *SiteInfoService) GetSiteQuestion(ctx context.Context) (resp *schema.SiteQuestionsResp, err error) { + return s.siteInfoCommonService.GetSiteQuestion(ctx) +} + +// GetSiteAdvanced get site advanced settings +func (s *SiteInfoService) GetSiteAdvanced(ctx context.Context) (resp *schema.SiteAdvancedResp, err error) { + return s.siteInfoCommonService.GetSiteAdvanced(ctx) +} + +// GetSitePolicies get site legal info +func (s *SiteInfoService) GetSitePolicies(ctx context.Context) (resp *schema.SitePoliciesResp, err error) { + return s.siteInfoCommonService.GetSitePolicies(ctx) +} + +// GetSiteSecurity get site security info +func (s *SiteInfoService) GetSiteSecurity(ctx context.Context) (resp *schema.SiteSecurityResp, err error) { + return s.siteInfoCommonService.GetSiteSecurity(ctx) } // GetSiteLogin get site login info @@ -165,10 +183,20 @@ func (s *SiteInfoService) SaveSiteInterface(ctx context.Context, req schema.Site content, _ := json.Marshal(req) data := entity.SiteInfo{ - Type: constant.SiteTypeInterface, + Type: constant.SiteTypeInterfaceSettings, Content: string(content), } - return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeInterface, &data) + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeInterfaceSettings, &data) +} + +// SaveSiteUsersSettings save site users settings +func (s *SiteInfoService) SaveSiteUsersSettings(ctx context.Context, req schema.SiteUsersSettingsReq) (err error) { + content, _ := json.Marshal(req) + data := entity.SiteInfo{ + Type: constant.SiteTypeInterfaceSettings, + Content: string(content), + } + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeUsersSettings, &data) } // SaveSiteBranding save site branding information @@ -182,8 +210,30 @@ func (s *SiteInfoService) SaveSiteBranding(ctx context.Context, req *schema.Site return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeBranding, data) } -// SaveSiteWrite save site configuration about write -func (s *SiteInfoService) SaveSiteWrite(ctx context.Context, req *schema.SiteWriteReq) (resp any, err error) { +// SaveSiteAdvanced save site advanced configuration +func (s *SiteInfoService) SaveSiteAdvanced(ctx context.Context, req *schema.SiteAdvancedReq) (resp any, err error) { + content, _ := json.Marshal(req) + data := &entity.SiteInfo{ + Type: constant.SiteTypeAdvanced, + Content: string(content), + Status: 1, + } + return nil, s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeAdvanced, data) +} + +// SaveSiteQuestions save site questions configuration +func (s *SiteInfoService) SaveSiteQuestions(ctx context.Context, req *schema.SiteQuestionsReq) (resp any, err error) { + content, _ := json.Marshal(req) + data := &entity.SiteInfo{ + Type: constant.SiteTypeQuestions, + Content: string(content), + Status: 1, + } + return nil, s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeQuestions, data) +} + +// SaveSiteTags save site tags configuration +func (s *SiteInfoService) SaveSiteTags(ctx context.Context, req *schema.SiteTagsReq) (resp any, err error) { recommendTags, reservedTags := make([]string, 0), make([]string, 0) recommendTagMapping, reservedTagMapping := make(map[string]bool), make(map[string]bool) for _, tag := range req.ReservedTags { @@ -210,22 +260,33 @@ func (s *SiteInfoService) SaveSiteWrite(ctx context.Context, req *schema.SiteWri content, _ := json.Marshal(req) data := &entity.SiteInfo{ - Type: constant.SiteTypeWrite, + Type: constant.SiteTypeTags, + Content: string(content), + Status: 1, + } + return nil, s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeTags, data) +} + +// SaveSitePolicies save site policies configuration +func (s *SiteInfoService) SaveSitePolicies(ctx context.Context, req *schema.SitePoliciesReq) (err error) { + content, _ := json.Marshal(req) + data := &entity.SiteInfo{ + Type: constant.SiteTypePolicies, Content: string(content), Status: 1, } - return nil, s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeWrite, data) + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypePolicies, data) } -// SaveSiteLegal save site legal configuration -func (s *SiteInfoService) SaveSiteLegal(ctx context.Context, req *schema.SiteLegalReq) (err error) { +// SaveSiteSecurity save site security configuration +func (s *SiteInfoService) SaveSiteSecurity(ctx context.Context, req *schema.SiteSecurityReq) (err error) { content, _ := json.Marshal(req) data := &entity.SiteInfo{ - Type: constant.SiteTypeLegal, + Type: constant.SiteTypeSecurity, Content: string(content), Status: 1, } - return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeLegal, data) + return s.siteInfoRepo.SaveByType(ctx, constant.SiteTypeSecurity, data) } // SaveSiteLogin save site legal configuration @@ -252,6 +313,9 @@ func (s *SiteInfoService) SaveSiteCustomCssHTML(ctx context.Context, req *schema // SaveSiteTheme save site custom html configuration func (s *SiteInfoService) SaveSiteTheme(ctx context.Context, req *schema.SiteThemeReq) (err error) { + if len(req.Layout) == 0 { + req.Layout = constant.ThemeLayoutFullWidth + } content, _ := json.Marshal(req) data := &entity.SiteInfo{ Type: constant.SiteTypeTheme, @@ -272,6 +336,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) @@ -317,13 +529,13 @@ func (s *SiteInfoService) GetSeo(ctx context.Context) (resp *schema.SiteSeoReq, if err = s.siteInfoCommonService.GetSiteInfoByType(ctx, constant.SiteTypeSeo, resp); err != nil { return resp, err } - loginConfig, err := s.GetSiteLogin(ctx) + siteSecurity, err := s.GetSiteSecurity(ctx) if err != nil { log.Error(err) return resp, nil } // If the site is set to privacy mode, prohibit crawling any page. - if loginConfig.LoginRequired { + if siteSecurity.LoginRequired { resp.Robots = "User-agent: *\nDisallow: /" return resp, nil } @@ -485,3 +697,76 @@ func (s *SiteInfoService) CleanUpRemovedBrandingFiles( } return nil } + +func (s *SiteInfoService) GetAIProvider(ctx context.Context) (resp []*schema.GetAIProviderResp, err error) { + resp = make([]*schema.GetAIProviderResp, 0) + aiProviderConfig, err := s.configService.GetStringValue(context.TODO(), constant.AIConfigProvider) + if err != nil { + log.Error(err) + return resp, nil + } + + _ = json.Unmarshal([]byte(aiProviderConfig), &resp) + return resp, nil +} + +func (s *SiteInfoService) GetAIModels(ctx context.Context, req *schema.GetAIModelsReq) (resp []*schema.GetAIModelResp, err error) { + resp = make([]*schema.GetAIModelResp, 0) + if req.APIKey != "" && isAllMask(req.APIKey) { + storedKey, err := s.getStoredAIKey(ctx, req.APIHost) + if err != nil { + return resp, err + } + if storedKey == "" { + return resp, errors.BadRequest("api_key is required") + } + req.APIKey = storedKey + } + + r := resty.New() + r.SetHeader("Authorization", fmt.Sprintf("Bearer %s", req.APIKey)) + r.SetHeader("Content-Type", "application/json") + respBody, err := r.R().Get(req.APIHost + "/v1/models") + if err != nil { + log.Error(err) + return resp, errors.BadRequest(fmt.Sprintf("failed to get AI models %s", err.Error())) + } + if !respBody.IsSuccess() { + log.Error(fmt.Sprintf("failed to get AI models, status code: %d, body: %s", respBody.StatusCode(), respBody.String())) + return resp, errors.BadRequest(fmt.Sprintf("failed to get AI models, response: %s", respBody.String())) + } + + data := schema.GetAIModelsResp{} + _ = json.Unmarshal(respBody.Body(), &data) + + for _, model := range data.Data { + resp = append(resp, &schema.GetAIModelResp{ + Id: model.Id, + Object: model.Object, + Created: model.Created, + OwnedBy: model.OwnedBy, + }) + } + return resp, nil +} + +func (s *SiteInfoService) getStoredAIKey(ctx context.Context, apiHost string) (string, error) { + current, err := s.siteInfoCommonService.GetSiteAI(ctx) + if err != nil { + return "", err + } + apiHost = strings.TrimRight(apiHost, "/") + for _, provider := range current.SiteAIProviders { + if strings.TrimRight(provider.APIHost, "/") == apiHost && provider.APIKey != "" { + return provider.APIKey, nil + } + } + if current.ChosenProvider != "" { + for _, provider := range current.SiteAIProviders { + if provider.Provider == current.ChosenProvider { + return provider.APIKey, nil + } + } + } + return "", nil +} diff --git a/internal/service/siteinfo_common/siteinfo_service.go b/internal/service/siteinfo_common/siteinfo_service.go index fda117229..90d48fc3a 100644 --- a/internal/service/siteinfo_common/siteinfo_service.go +++ b/internal/service/siteinfo_common/siteinfo_service.go @@ -34,7 +34,7 @@ import ( //go:generate mockgen -source=./siteinfo_service.go -destination=../mock/siteinfo_repo_mock.go -package=mock type SiteInfoRepo interface { SaveByType(ctx context.Context, siteType string, data *entity.SiteInfo) (err error) - GetByType(ctx context.Context, siteType string) (siteInfo *entity.SiteInfo, exist bool, err error) + GetByType(ctx context.Context, siteType string, withoutCache ...bool) (siteInfo *entity.SiteInfo, exist bool, err error) IsBrandingFileUsed(ctx context.Context, filePath string) (bool, error) } @@ -45,19 +45,26 @@ type siteInfoCommonService struct { type SiteInfoCommonService interface { GetSiteGeneral(ctx context.Context) (resp *schema.SiteGeneralResp, err error) - GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceResp, err error) + GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceSettingsResp, err error) + GetSiteUsersSettings(ctx context.Context) (resp *schema.SiteUsersSettingsResp, err error) GetSiteBranding(ctx context.Context) (resp *schema.SiteBrandingResp, err error) GetSiteUsers(ctx context.Context) (resp *schema.SiteUsersResp, err error) FormatAvatar(ctx context.Context, originalAvatarData, email string, userStatus int) *schema.AvatarInfo FormatListAvatar(ctx context.Context, userList []*entity.User) (userID2AvatarMapping map[string]*schema.AvatarInfo) GetSiteWrite(ctx context.Context) (resp *schema.SiteWriteResp, err error) - GetSiteLegal(ctx context.Context) (resp *schema.SiteLegalResp, err error) + GetSiteAdvanced(ctx context.Context) (resp *schema.SiteAdvancedResp, err error) + GetSiteQuestion(ctx context.Context) (resp *schema.SiteQuestionsResp, err error) + GetSiteTag(ctx context.Context) (resp *schema.SiteTagsResp, err error) + GetSitePolicies(ctx context.Context) (resp *schema.SitePoliciesResp, err error) + GetSiteSecurity(ctx context.Context) (resp *schema.SiteSecurityResp, err error) GetSiteLogin(ctx context.Context) (resp *schema.SiteLoginResp, err error) GetSiteCustomCssHTML(ctx context.Context) (resp *schema.SiteCustomCssHTMLResp, err error) GetSiteTheme(ctx context.Context) (resp *schema.SiteThemeResp, err error) 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 @@ -69,7 +76,7 @@ func NewSiteInfoCommonService(siteInfoRepo SiteInfoRepo) SiteInfoCommonService { // GetSiteGeneral get site info general func (s *siteInfoCommonService) GetSiteGeneral(ctx context.Context) (resp *schema.SiteGeneralResp, err error) { - resp = &schema.SiteGeneralResp{CheckUpdate: true} + resp = &schema.SiteGeneralResp{} if err = s.GetSiteInfoByType(ctx, constant.SiteTypeGeneral, resp); err != nil { return nil, err } @@ -78,9 +85,18 @@ func (s *siteInfoCommonService) GetSiteGeneral(ctx context.Context) (resp *schem } // GetSiteInterface get site info interface -func (s *siteInfoCommonService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceResp, err error) { - resp = &schema.SiteInterfaceResp{} - if err = s.GetSiteInfoByType(ctx, constant.SiteTypeInterface, resp); err != nil { +func (s *siteInfoCommonService) GetSiteInterface(ctx context.Context) (resp *schema.SiteInterfaceSettingsResp, err error) { + resp = &schema.SiteInterfaceSettingsResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeInterfaceSettings, resp); err != nil { + return nil, err + } + return resp, nil +} + +// GetSiteUsersSettings get site info interface +func (s *siteInfoCommonService) GetSiteUsersSettings(ctx context.Context) (resp *schema.SiteUsersSettingsResp, err error) { + resp = &schema.SiteUsersSettingsResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeUsersSettings, resp); err != nil { return nil, err } return resp, nil @@ -123,7 +139,7 @@ func (s *siteInfoCommonService) FormatListAvatar(ctx context.Context, userList [ func (s *siteInfoCommonService) getAvatarDefaultConfig(ctx context.Context) (string, string) { gravatarBaseURL, defaultAvatar := constant.DefaultGravatarBaseURL, constant.DefaultAvatar - usersConfig, err := s.GetSiteInterface(ctx) + usersConfig, err := s.GetSiteUsersSettings(ctx) if err != nil { log.Error(err) } @@ -167,10 +183,46 @@ func (s *siteInfoCommonService) GetSiteWrite(ctx context.Context) (resp *schema. return resp, nil } -// GetSiteLegal get site info write -func (s *siteInfoCommonService) GetSiteLegal(ctx context.Context) (resp *schema.SiteLegalResp, err error) { - resp = &schema.SiteLegalResp{} - if err = s.GetSiteInfoByType(ctx, constant.SiteTypeLegal, resp); err != nil { +// GetSiteAdvanced get site info advanced +func (s *siteInfoCommonService) GetSiteAdvanced(ctx context.Context) (resp *schema.SiteAdvancedResp, err error) { + resp = &schema.SiteAdvancedResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeAdvanced, resp); err != nil { + return nil, err + } + return resp, nil +} + +// GetSiteQuestion get site info question +func (s *siteInfoCommonService) GetSiteQuestion(ctx context.Context) (resp *schema.SiteQuestionsResp, err error) { + resp = &schema.SiteQuestionsResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeQuestions, resp); err != nil { + return nil, err + } + return resp, nil +} + +// GetSiteTag get site info tag +func (s *siteInfoCommonService) GetSiteTag(ctx context.Context) (resp *schema.SiteTagsResp, err error) { + resp = &schema.SiteTagsResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeTags, resp); err != nil { + return nil, err + } + return resp, nil +} + +// GetSitePolicies get site info policies +func (s *siteInfoCommonService) GetSitePolicies(ctx context.Context) (resp *schema.SitePoliciesResp, err error) { + resp = &schema.SitePoliciesResp{} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypePolicies, resp); err != nil { + return nil, err + } + return resp, nil +} + +// GetSiteSecurity get site security config +func (s *siteInfoCommonService) GetSiteSecurity(ctx context.Context) (resp *schema.SiteSecurityResp, err error) { + resp = &schema.SiteSecurityResp{CheckUpdate: true} + if err = s.GetSiteInfoByType(ctx, constant.SiteTypeSecurity, resp); err != nil { return nil, err } return resp, nil @@ -198,10 +250,14 @@ func (s *siteInfoCommonService) GetSiteCustomCssHTML(ctx context.Context) (resp func (s *siteInfoCommonService) GetSiteTheme(ctx context.Context) (resp *schema.SiteThemeResp, err error) { resp = &schema.SiteThemeResp{ ThemeOptions: schema.GetThemeOptions, + Layout: constant.ThemeLayoutFullWidth, } if err = s.GetSiteInfoByType(ctx, constant.SiteTypeTheme, resp); err != nil { return nil, err } + if resp.Layout == "" { + resp.Layout = constant.ThemeLayoutFullWidth + } resp.TrTheme(ctx) return resp, nil } @@ -245,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 +} diff --git a/internal/service/tag/tag_service.go b/internal/service/tag/tag_service.go index e61bfa06e..640f06b69 100644 --- a/internal/service/tag/tag_service.go +++ b/internal/service/tag/tag_service.go @@ -25,7 +25,7 @@ import ( "strings" "github.com/apache/answer/internal/base/constant" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" "github.com/apache/answer/internal/service/revision_common" "github.com/apache/answer/internal/service/siteinfo_common" tagcommonser "github.com/apache/answer/internal/service/tag_common" @@ -50,7 +50,7 @@ type TagService struct { revisionService *revision_common.RevisionService followCommon activity_common.FollowRepo siteInfoService siteinfo_common.SiteInfoCommonService - activityQueueService activity_queue.ActivityQueueService + activityQueueService activityqueue.Service } // NewTagService new tag service @@ -60,7 +60,7 @@ func NewTagService( revisionService *revision_common.RevisionService, followCommon activity_common.FollowRepo, siteInfoService siteinfo_common.SiteInfoCommonService, - activityQueueService activity_queue.ActivityQueueService, + activityQueueService activityqueue.Service, ) *TagService { return &TagService{ tagRepo: tagRepo, diff --git a/internal/service/tag_common/tag_common.go b/internal/service/tag_common/tag_common.go index 87c10bcc9..87b53f396 100644 --- a/internal/service/tag_common/tag_common.go +++ b/internal/service/tag_common/tag_common.go @@ -33,7 +33,7 @@ import ( "github.com/apache/answer/internal/base/validator" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" - "github.com/apache/answer/internal/service/activity_queue" + "github.com/apache/answer/internal/service/activityqueue" "github.com/apache/answer/internal/service/revision_common" "github.com/apache/answer/internal/service/siteinfo_common" "github.com/apache/answer/pkg/converter" @@ -89,7 +89,7 @@ type TagCommonService struct { tagRelRepo TagRelRepo tagRepo TagRepo siteInfoService siteinfo_common.SiteInfoCommonService - activityQueueService activity_queue.ActivityQueueService + activityQueueService activityqueue.Service } // NewTagCommonService new tag service @@ -99,7 +99,7 @@ func NewTagCommonService( tagRepo TagRepo, revisionService *revision_common.RevisionService, siteInfoService siteinfo_common.SiteInfoCommonService, - activityQueueService activity_queue.ActivityQueueService, + activityQueueService activityqueue.Service, ) *TagCommonService { return &TagCommonService{ tagCommonRepo: tagCommonRepo, @@ -270,7 +270,7 @@ func (ts *TagCommonService) GetTagListByNames(ctx context.Context, tagNames []st } func (ts *TagCommonService) ExistRecommend(ctx context.Context, tags []*schema.TagItem) (bool, error) { - taginfo, err := ts.siteInfoService.GetSiteWrite(ctx) + taginfo, err := ts.siteInfoService.GetSiteTag(ctx) if err != nil { return false, err } @@ -295,7 +295,7 @@ func (ts *TagCommonService) ExistRecommend(ctx context.Context, tags []*schema.T } func (ts *TagCommonService) GetMinimumTags(ctx context.Context) (int, error) { - siteInfo, err := ts.siteInfoService.GetSiteWrite(ctx) + siteInfo, err := ts.siteInfoService.GetSiteQuestion(ctx) if err != nil { return 1, err } @@ -469,7 +469,7 @@ func (ts *TagCommonService) TagsFormatRecommendAndReserved(ctx context.Context, if len(tagList) == 0 { return } - tagConfig, err := ts.siteInfoService.GetSiteWrite(ctx) + tagConfig, err := ts.siteInfoService.GetSiteTag(ctx) if err != nil { log.Error(err) return @@ -485,7 +485,7 @@ func (ts *TagCommonService) tagFormatRecommendAndReserved(ctx context.Context, t if tag == nil { return } - tagConfig, err := ts.siteInfoService.GetSiteWrite(ctx) + tagConfig, err := ts.siteInfoService.GetSiteTag(ctx) if err != nil { log.Error(err) return diff --git a/internal/service/uploader/upload.go b/internal/service/uploader/upload.go index 8dea746ce..58f808468 100644 --- a/internal/service/uploader/upload.go +++ b/internal/service/uploader/upload.go @@ -109,12 +109,12 @@ func (us *uploaderService) UploadAvatarFile(ctx *gin.Context, userID string) (ur return url, nil } - siteWrite, err := us.siteInfoService.GetSiteWrite(ctx) + siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) if err != nil { return "", err } - ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteWrite.GetMaxImageSize()) + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteAdvanced.GetMaxImageSize()) file, fileHeader, err := ctx.Request.FormFile("file") if err != nil { return "", errors.BadRequest(reason.RequestFormatError).WithError(err) @@ -201,12 +201,12 @@ func (us *uploaderService) UploadPostFile(ctx *gin.Context, userID string) ( return url, nil } - siteWrite, err := us.siteInfoService.GetSiteWrite(ctx) + siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) if err != nil { return "", err } - ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteWrite.GetMaxImageSize()) + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteAdvanced.GetMaxImageSize()) file, fileHeader, err := ctx.Request.FormFile("file") if err != nil { return "", errors.BadRequest(reason.RequestFormatError).WithError(err) @@ -214,7 +214,7 @@ func (us *uploaderService) UploadPostFile(ctx *gin.Context, userID string) ( defer func() { _ = file.Close() }() - if checker.IsUnAuthorizedExtension(fileHeader.Filename, siteWrite.AuthorizedImageExtensions) { + if checker.IsUnAuthorizedExtension(fileHeader.Filename, siteAdvanced.AuthorizedImageExtensions) { return "", errors.BadRequest(reason.RequestFormatError).WithError(err) } @@ -239,7 +239,7 @@ func (us *uploaderService) UploadPostAttachment(ctx *gin.Context, userID string) return url, nil } - resp, err := us.siteInfoService.GetSiteWrite(ctx) + resp, err := us.siteInfoService.GetSiteAdvanced(ctx) if err != nil { return "", err } @@ -277,12 +277,12 @@ func (us *uploaderService) UploadBrandingFile(ctx *gin.Context, userID string) ( return url, nil } - siteWrite, err := us.siteInfoService.GetSiteWrite(ctx) + siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) if err != nil { return "", err } - ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteWrite.GetMaxImageSize()) + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteAdvanced.GetMaxImageSize()) file, fileHeader, err := ctx.Request.FormFile("file") if err != nil { return "", errors.BadRequest(reason.RequestFormatError).WithError(err) @@ -311,7 +311,7 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil if err != nil { return "", err } - siteWrite, err := us.siteInfoService.GetSiteWrite(ctx) + siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) if err != nil { return "", err } @@ -328,7 +328,7 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil _ = src.Close() }() - if !checker.DecodeAndCheckImageFile(filePath, siteWrite.GetMaxImageMegapixel()) { + if !checker.DecodeAndCheckImageFile(filePath, siteAdvanced.GetMaxImageMegapixel()) { return "", errors.BadRequest(reason.UploadFileUnsupportedFileFormat) } @@ -364,17 +364,17 @@ func (us *uploaderService) uploadAttachmentFile(ctx *gin.Context, file *multipar func (us *uploaderService) tryToUploadByPlugin(ctx *gin.Context, source plugin.UploadSource) ( url string, err error) { - siteWrite, err := us.siteInfoService.GetSiteWrite(ctx) + siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) if err != nil { return "", err } cond := plugin.UploadFileCondition{ Source: source, - MaxImageSize: siteWrite.MaxImageSize, - MaxAttachmentSize: siteWrite.MaxAttachmentSize, - MaxImageMegapixel: siteWrite.MaxImageMegapixel, - AuthorizedImageExtensions: siteWrite.AuthorizedImageExtensions, - AuthorizedAttachmentExtensions: siteWrite.AuthorizedAttachmentExtensions, + MaxImageSize: siteAdvanced.MaxImageSize, + MaxAttachmentSize: siteAdvanced.MaxAttachmentSize, + MaxImageMegapixel: siteAdvanced.MaxImageMegapixel, + AuthorizedImageExtensions: siteAdvanced.AuthorizedImageExtensions, + AuthorizedAttachmentExtensions: siteAdvanced.AuthorizedAttachmentExtensions, } _ = plugin.CallStorage(func(fn plugin.Storage) error { resp := fn.UploadFile(ctx, cond) diff --git a/script/check-asf-header.sh b/script/check-asf-header.sh index 808efa108..05e523140 100755 --- a/script/check-asf-header.sh +++ b/script/check-asf-header.sh @@ -26,6 +26,6 @@ else exit 1 fi -$CONTAINER_RUNTIME run -it --rm -v "$(pwd)":/github/workspace ghcr.io/korandoru/hawkeye-native format +$CONTAINER_RUNTIME run --rm -v "$(pwd)":/github/workspace ghcr.io/korandoru/hawkeye-native format gofmt -w -l . diff --git a/ui/.gitignore b/ui/.gitignore index a73983ee7..3b1e96bd4 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,7 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +node_modules /.pnp .pnp.js @@ -36,6 +36,4 @@ package-lock.json /src/plugins/* !/src/plugins/builtin !/src/plugins/Demo -!/src/plugins/answer-chart -!/src/plugins/answer-formula /src/plugins/*/*.go diff --git a/ui/package.json b/ui/package.json index b88fc12da..5abd241ac 100644 --- a/ui/package.json +++ b/ui/package.json @@ -46,6 +46,7 @@ "react-router-dom": "^7.0.2", "semver": "^7.3.8", "swr": "^1.3.0", + "uuid": "13.0.0", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 842009a70..2ae03d036 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -95,13 +95,16 @@ importers: swr: specifier: ^1.3.0 version: 1.3.0(react@18.3.1) + uuid: + specifier: 13.0.0 + version: 13.0.0 zustand: specifier: ^5.0.2 - version: 5.0.2(@types/react@18.3.16)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)) + version: 5.0.2(@types/react@18.3.16)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@commitlint/cli': specifier: ^17.0.3 - version: 17.8.1 + version: 17.8.1(@swc/core@1.15.3) '@commitlint/config-conventional': specifier: ^17.2.0 version: 17.8.1 @@ -212,13 +215,13 @@ importers: version: 3.4.2 purgecss-webpack-plugin: specifier: ^4.1.3 - version: 4.1.3(webpack@5.97.1) + version: 4.1.3(webpack@5.97.1(@swc/core@1.15.3)) react-app-rewired: specifier: ^2.2.1 - version: 2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5)) + version: 2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@swc/core@1.15.3)(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5)(vue-template-compiler@2.7.16)) react-scripts: specifier: 5.0.1 - version: 5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5) + version: 5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@swc/core@1.15.3)(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5)(vue-template-compiler@2.7.16) sass: specifier: 1.54.4 version: 1.54.4 @@ -1585,9 +1588,88 @@ packages: resolution: {integrity: sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==} engines: {node: '>=10'} + '@swc/core-darwin-arm64@1.15.3': + resolution: {integrity: sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.3': + resolution: {integrity: sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.3': + resolution: {integrity: sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.3': + resolution: {integrity: sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.3': + resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.15.3': + resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.3': + resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.3': + resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.3': + resolution: {integrity: sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.3': + resolution: {integrity: sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.3': + resolution: {integrity: sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@testing-library/dom@8.20.1': resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==} engines: {node: '>=12'} @@ -2836,6 +2918,9 @@ packages: dayjs@1.11.13: resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -6084,6 +6169,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -6593,10 +6679,10 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - use-sync-external-store@1.2.2: - resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6611,6 +6697,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -6633,6 +6723,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + w3c-hr-time@1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} deprecated: Use your platform's native performance.now() and performance.timeOrigin. @@ -8111,11 +8204,11 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 - '@commitlint/cli@17.8.1': + '@commitlint/cli@17.8.1(@swc/core@1.15.3)': dependencies: '@commitlint/format': 17.8.1 '@commitlint/lint': 17.8.1 - '@commitlint/load': 17.8.1 + '@commitlint/load': 17.8.1(@swc/core@1.15.3) '@commitlint/read': 17.8.1 '@commitlint/types': 17.8.1 execa: 5.1.1 @@ -8164,7 +8257,7 @@ snapshots: '@commitlint/rules': 17.8.1 '@commitlint/types': 17.8.1 - '@commitlint/load@17.8.1': + '@commitlint/load@17.8.1(@swc/core@1.15.3)': dependencies: '@commitlint/config-validator': 17.8.1 '@commitlint/execute-rule': 17.8.1 @@ -8173,12 +8266,12 @@ snapshots: '@types/node': 20.5.1 chalk: 4.1.2 cosmiconfig: 8.3.6(typescript@4.9.5) - cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@4.9.5))(ts-node@10.9.2(@types/node@20.5.1)(typescript@4.9.5))(typescript@4.9.5) + cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@4.9.5))(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))(typescript@4.9.5) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 resolve-from: 5.0.0 - ts-node: 10.9.2(@types/node@20.5.1)(typescript@4.9.5) + ts-node: 10.9.2(@swc/core@1.15.3)(@types/node@16.18.121)(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - '@swc/core' @@ -8388,7 +8481,7 @@ snapshots: jest-util: 28.1.3 slash: 3.0.0 - '@jest/core@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))': + '@jest/core@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))': dependencies: '@jest/console': 27.5.1 '@jest/reporters': 27.5.1 @@ -8402,7 +8495,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 27.5.1 - jest-config: 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + jest-config: 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) jest-haste-map: 27.5.1 jest-message-util: 27.5.1 jest-regex-util: 27.5.1 @@ -8698,7 +8791,7 @@ snapshots: '@pkgr/core@0.1.1': {} - '@pmmmwh/react-refresh-webpack-plugin@0.5.15(react-refresh@0.11.0)(type-fest@1.4.0)(webpack-dev-server@4.15.2(webpack@5.97.1))(webpack@5.97.1)': + '@pmmmwh/react-refresh-webpack-plugin@0.5.15(react-refresh@0.11.0)(type-fest@1.4.0)(webpack-dev-server@4.15.2(webpack@5.97.1(@swc/core@1.15.3)))(webpack@5.97.1(@swc/core@1.15.3))': dependencies: ansi-html: 0.0.9 core-js-pure: 3.39.0 @@ -8708,10 +8801,10 @@ snapshots: react-refresh: 0.11.0 schema-utils: 4.2.0 source-map: 0.7.4 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) optionalDependencies: type-fest: 1.4.0 - webpack-dev-server: 4.15.2(webpack@5.97.1) + webpack-dev-server: 4.15.2(webpack@5.97.1(@swc/core@1.15.3)) '@popperjs/core@2.11.8': {} @@ -8866,10 +8959,65 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/core-darwin-arm64@1.15.3': + optional: true + + '@swc/core-darwin-x64@1.15.3': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.3': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.3': + optional: true + + '@swc/core-linux-arm64-musl@1.15.3': + optional: true + + '@swc/core-linux-x64-gnu@1.15.3': + optional: true + + '@swc/core-linux-x64-musl@1.15.3': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.3': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.3': + optional: true + + '@swc/core-win32-x64-msvc@1.15.3': + optional: true + + '@swc/core@1.15.3': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.3 + '@swc/core-darwin-x64': 1.15.3 + '@swc/core-linux-arm-gnueabihf': 1.15.3 + '@swc/core-linux-arm64-gnu': 1.15.3 + '@swc/core-linux-arm64-musl': 1.15.3 + '@swc/core-linux-x64-gnu': 1.15.3 + '@swc/core-linux-x64-musl': 1.15.3 + '@swc/core-win32-arm64-msvc': 1.15.3 + '@swc/core-win32-ia32-msvc': 1.15.3 + '@swc/core-win32-x64-msvc': 1.15.3 + optional: true + + '@swc/counter@0.1.3': + optional: true + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + optional: true + '@testing-library/dom@8.20.1': dependencies: '@babel/code-frame': 7.26.2 @@ -9666,14 +9814,14 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@8.4.1(@babel/core@7.26.0)(webpack@5.97.1): + babel-loader@8.4.1(@babel/core@7.26.0)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: '@babel/core': 7.26.0 find-cache-dir: 3.3.2 loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) babel-plugin-istanbul@6.1.1: dependencies: @@ -10118,11 +10266,11 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@4.9.5))(ts-node@10.9.2(@types/node@20.5.1)(typescript@4.9.5))(typescript@4.9.5): + cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@4.9.5))(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))(typescript@4.9.5): dependencies: '@types/node': 20.5.1 cosmiconfig: 8.3.6(typescript@4.9.5) - ts-node: 10.9.2(@types/node@20.5.1)(typescript@4.9.5) + ts-node: 10.9.2(@swc/core@1.15.3)(@types/node@16.18.121)(typescript@4.9.5) typescript: 4.9.5 cosmiconfig@6.0.0: @@ -10176,7 +10324,7 @@ snapshots: postcss: 8.4.49 postcss-selector-parser: 6.1.2 - css-loader@6.11.0(webpack@5.97.1): + css-loader@6.11.0(webpack@5.97.1(@swc/core@1.15.3)): dependencies: icss-utils: 5.1.0(postcss@8.4.49) postcss: 8.4.49 @@ -10187,9 +10335,9 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.6.3 optionalDependencies: - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) - css-minimizer-webpack-plugin@3.4.1(webpack@5.97.1): + css-minimizer-webpack-plugin@3.4.1(webpack@5.97.1(@swc/core@1.15.3)): dependencies: cssnano: 5.1.15(postcss@8.4.49) jest-worker: 27.5.1 @@ -10197,7 +10345,7 @@ snapshots: schema-utils: 4.2.0 serialize-javascript: 6.0.2 source-map: 0.6.1 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) css-prefers-color-scheme@6.0.3(postcss@8.4.49): dependencies: @@ -10339,6 +10487,9 @@ snapshots: dayjs@1.11.13: {} + de-indent@1.0.2: + optional: true + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10738,7 +10889,7 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)))(typescript@4.9.5): + eslint-config-react-app@7.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)))(typescript@4.9.5): dependencies: '@babel/core': 7.26.0 '@babel/eslint-parser': 7.25.9(@babel/core@7.26.0)(eslint@8.57.1) @@ -10750,7 +10901,7 @@ snapshots: eslint: 8.57.1 eslint-plugin-flowtype: 8.0.3(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(eslint@8.57.1) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1) - eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)))(typescript@4.9.5) + eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)))(typescript@4.9.5) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.2(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) @@ -10886,13 +11037,13 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)))(typescript@4.9.5): + eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)))(typescript@4.9.5): dependencies: '@typescript-eslint/experimental-utils': 5.62.0(eslint@8.57.1)(typescript@4.9.5) eslint: 8.57.1 optionalDependencies: '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5) - jest: 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + jest: 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) transitivePeerDependencies: - supports-color - typescript @@ -10993,7 +11144,7 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint-webpack-plugin@3.2.0(eslint@8.57.1)(webpack@5.97.1): + eslint-webpack-plugin@3.2.0(eslint@8.57.1)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: '@types/eslint': 8.56.12 eslint: 8.57.1 @@ -11001,7 +11152,7 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 schema-utils: 4.2.0 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) eslint@8.57.1: dependencies: @@ -11183,11 +11334,11 @@ snapshots: dependencies: flat-cache: 3.2.0 - file-loader@6.2.0(webpack@5.97.1): + file-loader@6.2.0(webpack@5.97.1(@swc/core@1.15.3)): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) filelist@1.0.4: dependencies: @@ -11250,7 +11401,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@6.5.3(eslint@8.57.1)(typescript@4.9.5)(webpack@5.97.1): + fork-ts-checker-webpack-plugin@6.5.3(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: '@babel/code-frame': 7.26.2 '@types/json-schema': 7.0.15 @@ -11266,9 +11417,10 @@ snapshots: semver: 7.6.3 tapable: 1.1.3 typescript: 4.9.5 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) optionalDependencies: eslint: 8.57.1 + vue-template-compiler: 2.7.16 form-data@3.0.2: dependencies: @@ -11513,7 +11665,7 @@ snapshots: dependencies: void-elements: 3.1.0 - html-webpack-plugin@5.6.3(webpack@5.97.1): + html-webpack-plugin@5.6.3(webpack@5.97.1(@swc/core@1.15.3)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -11521,7 +11673,7 @@ snapshots: pretty-error: 4.0.0 tapable: 2.2.1 optionalDependencies: - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) htmlparser2@6.1.0: dependencies: @@ -11906,16 +12058,16 @@ snapshots: transitivePeerDependencies: - supports-color - jest-cli@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)): + jest-cli@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)): dependencies: - '@jest/core': 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + '@jest/core': 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 import-local: 3.2.0 - jest-config: 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + jest-config: 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) jest-util: 27.5.1 jest-validate: 27.5.1 prompts: 2.4.2 @@ -11927,7 +12079,7 @@ snapshots: - ts-node - utf-8-validate - jest-config@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)): + jest-config@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)): dependencies: '@babel/core': 7.26.0 '@jest/test-sequencer': 27.5.1 @@ -11954,7 +12106,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - ts-node: 10.9.2(@types/node@20.5.1)(typescript@4.9.5) + ts-node: 10.9.2(@swc/core@1.15.3)(@types/node@16.18.121)(typescript@4.9.5) transitivePeerDependencies: - bufferutil - canvas @@ -12246,11 +12398,11 @@ snapshots: leven: 3.1.0 pretty-format: 27.5.1 - jest-watch-typeahead@1.1.0(jest@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))): + jest-watch-typeahead@1.1.0(jest@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))): dependencies: ansi-escapes: 4.3.2 chalk: 4.1.2 - jest: 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + jest: 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) jest-regex-util: 28.0.2 jest-watcher: 28.1.3 slash: 4.0.0 @@ -12296,11 +12448,11 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)): + jest@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)): dependencies: - '@jest/core': 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + '@jest/core': 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) import-local: 3.2.0 - jest-cli: 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + jest-cli: 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) transitivePeerDependencies: - bufferutil - canvas @@ -12629,11 +12781,11 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.9.2(webpack@5.97.1): + mini-css-extract-plugin@2.9.2(webpack@5.97.1(@swc/core@1.15.3)): dependencies: schema-utils: 4.2.0 tapable: 2.2.1 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) minimalistic-assert@1.0.1: {} @@ -13105,21 +13257,21 @@ snapshots: postcss: 8.4.49 postcss-value-parser: 4.2.0 - postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)): + postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)): dependencies: lilconfig: 3.1.3 yaml: 2.7.0 optionalDependencies: postcss: 8.4.49 - ts-node: 10.9.2(@types/node@20.5.1)(typescript@4.9.5) + ts-node: 10.9.2(@swc/core@1.15.3)(@types/node@16.18.121)(typescript@4.9.5) - postcss-loader@6.2.1(postcss@8.4.49)(webpack@5.97.1): + postcss-loader@6.2.1(postcss@8.4.49)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: cosmiconfig: 7.1.0 klona: 2.0.6 postcss: 8.4.49 semver: 7.6.3 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) postcss-logical@5.0.4(postcss@8.4.49): dependencies: @@ -13462,10 +13614,10 @@ snapshots: punycode@2.3.1: {} - purgecss-webpack-plugin@4.1.3(webpack@5.97.1): + purgecss-webpack-plugin@4.1.3(webpack@5.97.1(@swc/core@1.15.3)): dependencies: purgecss: 4.1.3 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) webpack-sources: 3.2.3 purgecss@4.1.3: @@ -13523,9 +13675,9 @@ snapshots: regenerator-runtime: 0.13.11 whatwg-fetch: 3.6.20 - react-app-rewired@2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5)): + react-app-rewired@2.2.1(react-scripts@5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@swc/core@1.15.3)(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5)(vue-template-compiler@2.7.16)): dependencies: - react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5) + react-scripts: 5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@swc/core@1.15.3)(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5)(vue-template-compiler@2.7.16) semver: 5.7.2 react-bootstrap@2.10.6(@types/react@18.3.16)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -13547,7 +13699,7 @@ snapshots: optionalDependencies: '@types/react': 18.3.16 - react-dev-utils@12.0.1(eslint@8.57.1)(typescript@4.9.5)(webpack@5.97.1): + react-dev-utils@12.0.1(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: '@babel/code-frame': 7.26.2 address: 1.2.2 @@ -13558,7 +13710,7 @@ snapshots: escape-string-regexp: 4.0.0 filesize: 8.0.7 find-up: 5.0.0 - fork-ts-checker-webpack-plugin: 6.5.3(eslint@8.57.1)(typescript@4.9.5)(webpack@5.97.1) + fork-ts-checker-webpack-plugin: 6.5.3(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.97.1(@swc/core@1.15.3)) global-modules: 2.0.0 globby: 11.1.0 gzip-size: 6.0.0 @@ -13573,7 +13725,7 @@ snapshots: shell-quote: 1.8.2 strip-ansi: 6.0.1 text-table: 0.2.0 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) optionalDependencies: typescript: 4.9.5 transitivePeerDependencies: @@ -13636,56 +13788,56 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) - react-scripts@5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5): + react-scripts@5.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(@swc/core@1.15.3)(@types/babel__core@7.20.5)(eslint@8.57.1)(react@18.3.1)(sass@1.54.4)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))(type-fest@1.4.0)(typescript@4.9.5)(vue-template-compiler@2.7.16): dependencies: '@babel/core': 7.26.0 - '@pmmmwh/react-refresh-webpack-plugin': 0.5.15(react-refresh@0.11.0)(type-fest@1.4.0)(webpack-dev-server@4.15.2(webpack@5.97.1))(webpack@5.97.1) + '@pmmmwh/react-refresh-webpack-plugin': 0.5.15(react-refresh@0.11.0)(type-fest@1.4.0)(webpack-dev-server@4.15.2(webpack@5.97.1(@swc/core@1.15.3)))(webpack@5.97.1(@swc/core@1.15.3)) '@svgr/webpack': 5.5.0 babel-jest: 27.5.1(@babel/core@7.26.0) - babel-loader: 8.4.1(@babel/core@7.26.0)(webpack@5.97.1) + babel-loader: 8.4.1(@babel/core@7.26.0)(webpack@5.97.1(@swc/core@1.15.3)) babel-plugin-named-asset-import: 0.3.8(@babel/core@7.26.0) babel-preset-react-app: 10.0.1 bfj: 7.1.0 browserslist: 4.24.2 camelcase: 6.3.0 case-sensitive-paths-webpack-plugin: 2.4.0 - css-loader: 6.11.0(webpack@5.97.1) - css-minimizer-webpack-plugin: 3.4.1(webpack@5.97.1) + css-loader: 6.11.0(webpack@5.97.1(@swc/core@1.15.3)) + css-minimizer-webpack-plugin: 3.4.1(webpack@5.97.1(@swc/core@1.15.3)) dotenv: 10.0.0 dotenv-expand: 5.1.0 eslint: 8.57.1 - eslint-config-react-app: 7.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)))(typescript@4.9.5) - eslint-webpack-plugin: 3.2.0(eslint@8.57.1)(webpack@5.97.1) - file-loader: 6.2.0(webpack@5.97.1) + eslint-config-react-app: 7.0.1(@babel/plugin-syntax-flow@7.26.0(@babel/core@7.26.0))(@babel/plugin-transform-react-jsx@7.25.9(@babel/core@7.26.0))(eslint@8.57.1)(jest@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)))(typescript@4.9.5) + eslint-webpack-plugin: 3.2.0(eslint@8.57.1)(webpack@5.97.1(@swc/core@1.15.3)) + file-loader: 6.2.0(webpack@5.97.1(@swc/core@1.15.3)) fs-extra: 10.1.0 - html-webpack-plugin: 5.6.3(webpack@5.97.1) + html-webpack-plugin: 5.6.3(webpack@5.97.1(@swc/core@1.15.3)) identity-obj-proxy: 3.0.0 - jest: 27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + jest: 27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) jest-resolve: 27.5.1 - jest-watch-typeahead: 1.1.0(jest@27.5.1(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5))) - mini-css-extract-plugin: 2.9.2(webpack@5.97.1) + jest-watch-typeahead: 1.1.0(jest@27.5.1(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5))) + mini-css-extract-plugin: 2.9.2(webpack@5.97.1(@swc/core@1.15.3)) postcss: 8.4.49 postcss-flexbugs-fixes: 5.0.2(postcss@8.4.49) - postcss-loader: 6.2.1(postcss@8.4.49)(webpack@5.97.1) + postcss-loader: 6.2.1(postcss@8.4.49)(webpack@5.97.1(@swc/core@1.15.3)) postcss-normalize: 10.0.1(browserslist@4.24.2)(postcss@8.4.49) postcss-preset-env: 7.8.3(postcss@8.4.49) prompts: 2.4.2 react: 18.3.1 react-app-polyfill: 3.0.0 - react-dev-utils: 12.0.1(eslint@8.57.1)(typescript@4.9.5)(webpack@5.97.1) + react-dev-utils: 12.0.1(eslint@8.57.1)(typescript@4.9.5)(vue-template-compiler@2.7.16)(webpack@5.97.1(@swc/core@1.15.3)) react-refresh: 0.11.0 resolve: 1.22.8 resolve-url-loader: 4.0.0 - sass-loader: 12.6.0(sass@1.54.4)(webpack@5.97.1) + sass-loader: 12.6.0(sass@1.54.4)(webpack@5.97.1(@swc/core@1.15.3)) semver: 7.6.3 - source-map-loader: 3.0.2(webpack@5.97.1) - style-loader: 3.3.4(webpack@5.97.1) - tailwindcss: 3.4.16(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) - terser-webpack-plugin: 5.3.10(webpack@5.97.1) - webpack: 5.97.1 - webpack-dev-server: 4.15.2(webpack@5.97.1) - webpack-manifest-plugin: 4.1.1(webpack@5.97.1) - workbox-webpack-plugin: 6.6.0(@types/babel__core@7.20.5)(webpack@5.97.1) + source-map-loader: 3.0.2(webpack@5.97.1(@swc/core@1.15.3)) + style-loader: 3.3.4(webpack@5.97.1(@swc/core@1.15.3)) + tailwindcss: 3.4.16(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) + terser-webpack-plugin: 5.3.10(@swc/core@1.15.3)(webpack@5.97.1(@swc/core@1.15.3)) + webpack: 5.97.1(@swc/core@1.15.3) + webpack-dev-server: 4.15.2(webpack@5.97.1(@swc/core@1.15.3)) + webpack-manifest-plugin: 4.1.1(webpack@5.97.1(@swc/core@1.15.3)) + workbox-webpack-plugin: 6.6.0(@types/babel__core@7.20.5)(webpack@5.97.1(@swc/core@1.15.3)) optionalDependencies: fsevents: 2.3.3 typescript: 4.9.5 @@ -13943,11 +14095,11 @@ snapshots: sanitize.css@13.0.0: {} - sass-loader@12.6.0(sass@1.54.4)(webpack@5.97.1): + sass-loader@12.6.0(sass@1.54.4)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: klona: 2.0.6 neo-async: 2.6.2 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) optionalDependencies: sass: 1.54.4 @@ -14146,12 +14298,12 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@3.0.2(webpack@5.97.1): + source-map-loader@3.0.2(webpack@5.97.1(@swc/core@1.15.3)): dependencies: abab: 2.0.6 iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) source-map-resolve@0.5.3: dependencies: @@ -14354,9 +14506,9 @@ snapshots: strip-json-comments@3.1.1: {} - style-loader@3.3.4(webpack@5.97.1): + style-loader@3.3.4(webpack@5.97.1(@swc/core@1.15.3)): dependencies: - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) style-mod@4.1.2: {} @@ -14434,7 +14586,7 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.8.1 - tailwindcss@3.4.16(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)): + tailwindcss@3.4.16(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -14453,7 +14605,7 @@ snapshots: postcss: 8.4.49 postcss-import: 15.1.0(postcss@8.4.49) postcss-js: 4.0.1(postcss@8.4.49) - postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@16.18.121)(typescript@4.9.5)) + postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@20.5.1)(typescript@4.9.5)) postcss-nested: 6.2.0(postcss@8.4.49) postcss-selector-parser: 6.1.2 resolve: 1.22.8 @@ -14484,14 +14636,16 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.10(webpack@5.97.1): + terser-webpack-plugin@5.3.10(@swc/core@1.15.3)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.37.0 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) + optionalDependencies: + '@swc/core': 1.15.3 terser@5.37.0: dependencies: @@ -14563,14 +14717,14 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@types/node@20.5.1)(typescript@4.9.5): + ts-node@10.9.2(@swc/core@1.15.3)(@types/node@16.18.121)(typescript@4.9.5): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.5.1 + '@types/node': 16.18.121 acorn: 8.14.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -14580,6 +14734,8 @@ snapshots: typescript: 4.9.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.3 tsconfig-paths@3.15.0: dependencies: @@ -14731,7 +14887,7 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - use-sync-external-store@1.2.2(react@18.3.1): + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 optional: true @@ -14749,6 +14905,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@13.0.0: {} + uuid@8.3.2: {} v8-compile-cache-lib@3.0.1: {} @@ -14768,6 +14926,12 @@ snapshots: void-elements@3.1.0: {} + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + optional: true + w3c-hr-time@1.0.2: dependencies: browser-process-hrtime: 1.0.0 @@ -14801,16 +14965,16 @@ snapshots: webidl-conversions@6.1.0: {} - webpack-dev-middleware@5.3.4(webpack@5.97.1): + webpack-dev-middleware@5.3.4(webpack@5.97.1(@swc/core@1.15.3)): dependencies: colorette: 2.0.20 memfs: 3.5.3 mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.2.0 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) - webpack-dev-server@4.15.2(webpack@5.97.1): + webpack-dev-server@4.15.2(webpack@5.97.1(@swc/core@1.15.3)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -14840,20 +15004,20 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 5.3.4(webpack@5.97.1) + webpack-dev-middleware: 5.3.4(webpack@5.97.1(@swc/core@1.15.3)) ws: 8.18.0 optionalDependencies: - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) transitivePeerDependencies: - bufferutil - debug - supports-color - utf-8-validate - webpack-manifest-plugin@4.1.1(webpack@5.97.1): + webpack-manifest-plugin@4.1.1(webpack@5.97.1(@swc/core@1.15.3)): dependencies: tapable: 2.2.1 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) webpack-sources: 2.3.1 webpack-sources@1.4.3: @@ -14868,7 +15032,7 @@ snapshots: webpack-sources@3.2.3: {} - webpack@5.97.1: + webpack@5.97.1(@swc/core@1.15.3): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -14890,7 +15054,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.97.1) + terser-webpack-plugin: 5.3.10(@swc/core@1.15.3)(webpack@5.97.1(@swc/core@1.15.3)) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -15085,12 +15249,12 @@ snapshots: workbox-sw@6.6.0: {} - workbox-webpack-plugin@6.6.0(@types/babel__core@7.20.5)(webpack@5.97.1): + workbox-webpack-plugin@6.6.0(@types/babel__core@7.20.5)(webpack@5.97.1(@swc/core@1.15.3)): dependencies: fast-json-stable-stringify: 2.1.0 pretty-bytes: 5.6.0 upath: 1.2.0 - webpack: 5.97.1 + webpack: 5.97.1(@swc/core@1.15.3) webpack-sources: 1.4.3 workbox-build: 6.6.0(@types/babel__core@7.20.5) transitivePeerDependencies: @@ -15210,9 +15374,9 @@ snapshots: yocto-queue@0.1.0: {} - zustand@5.0.2(@types/react@18.3.16)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)): + zustand@5.0.2(@types/react@18.3.16)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.16 immer: 9.0.21 react: 18.3.1 - use-sync-external-store: 1.2.2(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 18f251145..862072817 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -83,6 +83,11 @@ export const ADMIN_LIST_STATUS = { }, }; +/** + * ADMIN_NAV_MENUS is the navigation menu for the admin panel. + * pathPrefix is used to activate the menu item when the activeKey starts with the pathPrefix. + */ + export const ADMIN_NAV_MENUS = [ { name: 'dashboard', @@ -92,15 +97,28 @@ export const ADMIN_NAV_MENUS = [ { name: 'contents', icon: 'file-earmark-text-fill', - children: [{ name: 'questions' }, { name: 'answers' }], + children: [ + { name: 'questions', path: 'qa/questions', pathPrefix: 'qa/' }, + { name: 'tags', path: 'tags/settings', pathPrefix: 'tags/' }, + ], }, { - name: 'users', - icon: 'people-fill', + name: 'intelligence', + icon: 'robot', + children: [ + { name: 'ai_settings', path: 'ai-settings' }, + { name: 'ai_assistant', path: 'ai-assistant' }, + { name: 'mcp' }, + ], }, { - name: 'badges', - icon: 'award-fill', + name: 'community', + icon: 'people-fill', + children: [ + { name: 'users', pathPrefix: 'users/' }, + { name: 'badges' }, + { name: 'rules', path: 'rules/privileges', pathPrefix: 'rules/' }, + ], }, { name: 'apperance', @@ -113,20 +131,20 @@ export const ADMIN_NAV_MENUS = [ name: 'customize', }, { name: 'branding' }, + { name: 'interface' }, ], }, { - name: 'settings', + name: 'advanced', icon: 'gear-fill', children: [ { name: 'general' }, - { name: 'interface' }, - { name: 'smtp' }, - { name: 'legal' }, - { name: 'write' }, - { name: 'seo' }, + { name: 'security' }, + { name: 'files' }, { name: 'login' }, - { name: 'privileges' }, + { name: 'seo' }, + { name: 'smtp' }, + { name: 'apikeys' }, ], }, { @@ -141,6 +159,30 @@ export const ADMIN_NAV_MENUS = [ }, ]; +export const ADMIN_QA_NAV_MENUS = [ + { name: 'questions', path: '/admin/qa/questions' }, + { name: 'answers', path: '/admin/qa/answers' }, + { name: 'settings', path: '/admin/qa/settings' }, +]; + +export const ADMIN_TAGS_NAV_MENUS = [ + // { name: 'tags', path: '/admin/tags' }, + { + name: 'settings', + path: '/admin/tags/settings', + }, +]; + +export const ADMIN_USERS_NAV_MENUS = [ + { name: 'users', path: '/admin/users' }, + { name: 'settings', path: '/admin/users/settings' }, +]; + +export const ADMIN_RULES_NAV_MENUS = [ + { name: 'privileges', path: '/admin/rules/privileges' }, + { name: 'policies', path: '/admin/rules/policies' }, +]; + export const TIMEZONES = [ { label: 'Africa', diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index f2901908f..308726e80 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -364,7 +364,6 @@ export interface AdminSettingsGeneral { description: string; site_url: string; contact_email: string; - check_update: boolean; permalink?: number; } @@ -382,8 +381,6 @@ export interface HelmetUpdate extends Omit { export interface AdminSettingsInterface { language: string; time_zone?: string; - default_avatar: string; - gravatar_base_url: string; } export interface AdminSettingsSmtp { @@ -405,6 +402,14 @@ export interface AdminSettingsUsers { allow_update_location: boolean; allow_update_username: boolean; allow_update_website: boolean; + default_avatar: string; + gravatar_base_url: string; +} + +export interface AdminSettingsSecurity { + external_content_display: string; + check_update: boolean; + login_required: boolean; } export interface SiteSettings { @@ -416,10 +421,13 @@ export interface SiteSettings { theme: AdminSettingsTheme; site_seo: AdminSettingsSeo; site_users: AdminSettingsUsers; - site_write: AdminSettingsWrite; + site_advanced: AdminSettingsWrite; + site_questions: AdminQuestionSetting; + site_tags: AdminTagsSetting; version: string; revision: string; - site_legal: AdminSettingsLegal; + site_security: AdminSettingsSecurity; + ai_enabled: boolean; } export interface AdminSettingBranding { @@ -430,7 +438,6 @@ export interface AdminSettingBranding { } export interface AdminSettingsLegal { - external_content_display: string; privacy_policy_original_text?: string; privacy_policy_parsed_text?: string; terms_of_service_original_text?: string; @@ -438,12 +445,6 @@ export interface AdminSettingsLegal { } export interface AdminSettingsWrite { - restrict_answer?: boolean; - min_tags?: number; - min_content?: number; - recommend_tags?: Tag[]; - required_tag?: boolean; - reserved_tags?: Tag[]; max_image_size?: number; max_attachment_size?: number; max_image_megapixel?: number; @@ -469,6 +470,7 @@ export type themeConfig = { export interface AdminSettingsTheme { theme: string; color_scheme: string; + layout: string; theme_options?: { label: string; value: string }[]; theme_config: Record; } @@ -483,7 +485,6 @@ export interface AdminSettingsCustom { export interface AdminSettingsLogin { allow_new_registrations: boolean; - login_required: boolean; allow_email_registrations: boolean; allow_email_domains: string[]; allow_password_login: boolean; @@ -808,3 +809,85 @@ export interface BadgeDetailListRes { count: number; list: BadgeDetailListItem[]; } + +export interface AdminApiKeysItem { + access_key: string; + created_at: number; + description: string; + id: number; + last_used_at: number; + scope: string; +} + +export interface AddOrEditApiKeyParams { + description: string; + scope?: string; + id?: number; +} + +export interface AiConfig { + enabled: boolean; + chosen_provider: string; + ai_providers: Array<{ + provider: string; + api_host: string; + api_key: string; + model: string; + }>; +} + +export interface AiProviderItem { + name: string; + display_name: string; + default_api_host: string; +} + +export interface ConversationListItem { + conversation_id: string; + created_at: number; + topic: string; +} + +export interface AdminConversationListItem { + id: string; + topic: string; + helpful_count: number; + unhelpful_count: number; + created_at: number; + user_info: UserInfoBase; +} + +export interface ConversationDetailItem { + chat_completion_id: string; + content: string; + role: string; + helpful: number; + unhelpful: number; + created_at: number; +} + +export interface ConversationDetail { + conversation_id: string; + created_at: number; + records: ConversationDetailItem[]; + topic: string; + updated_at: number; +} + +export interface VoteConversationParams { + cancel: boolean; + chat_completion_id: string; + vote_type: 'helpful' | 'unhelpful'; +} + +export interface AdminQuestionSetting { + min_tags: number; + min_content: number; + restrict_answer: boolean; +} + +export interface AdminTagsSetting { + recommend_tags: Tag[]; + required_tag: boolean; + reserved_tags: Tag[]; +} diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index ee0819787..583c01987 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -28,16 +28,32 @@ import { floppyNavigation } from '@/utils'; import { Icon } from '@/components'; import './index.css'; +export interface MenuItem { + name: string; + path?: string; + pathPrefix?: string; + icon?: string; + displayName?: string; + badgeContent?: string | number; + children?: MenuItem[]; +} + function MenuNode({ menu, callback, activeKey, expanding = false, path = '/', +}: { + menu: MenuItem; + callback: (evt: any, menu: MenuItem, href: string, isLeaf: boolean) => void; + activeKey: string; + expanding?: boolean; + path?: string; }) { const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); - const isLeaf = !menu.children.length; - const href = isLeaf ? `${path}${menu.path}` : '#'; + const isLeaf = !menu.children || menu.children.length === 0; + const href = isLeaf ? `${path}${menu.path || ''}` : '#'; return ( @@ -51,7 +67,14 @@ function MenuNode({ }} className={classNames( 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, active: activeKey === menu.path }, + { + expanding, + active: + activeKey === menu.path || + (menu.path && activeKey.startsWith(`${menu.path}/`)) || + // if pathPrefix is set, activate when activeKey starts with the pathPrefix + (menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)), + }, )}> {menu?.icon && } @@ -75,7 +98,13 @@ function MenuNode({ }} className={classNames( 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, active: activeKey === menu.path }, + { + expanding, + active: + activeKey === menu.path || + (menu.path && activeKey.startsWith(`${menu.path}/`)) || + (menu.pathPrefix && activeKey.startsWith(menu.pathPrefix)), + }, )}> {menu?.icon && } @@ -90,8 +119,8 @@ function MenuNode({ )} - {menu.children.length ? ( - + {menu.children && menu.children.length > 0 ? ( + <> {menu.children.map((leaf) => { return ( @@ -100,7 +129,7 @@ function MenuNode({ callback={callback} activeKey={activeKey} path={path} - key={leaf.path} + key={leaf.path || leaf.name} /> ); })} @@ -112,7 +141,7 @@ function MenuNode({ } interface AccordionProps { - menus: any[]; + menus: MenuItem[]; path?: string; } const AccordionNav: FC = ({ menus = [], path = '/' }) => { @@ -137,19 +166,27 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { }); const splat = pathMatch && pathMatch.params['*']; - let activeKey = menus[0].path; + let activeKey: string = menus[0]?.path || menus[0]?.name || ''; + if (splat) { activeKey = splat; } + const getOpenKey = () => { let openKey = ''; menus.forEach((li) => { - if (li.children.length) { + if (li.children && li.children.length > 0) { const matchedChild = li.children.find((el) => { - return el.path === activeKey; + // exact match or path prefix match + return ( + el.path === activeKey || + (el.path && activeKey.startsWith(`${el.path}/`)) || + // if pathPrefix is set, activate when activeKey starts with the pathPrefix + (el.pathPrefix && activeKey.startsWith(el.pathPrefix)) + ); }); if (matchedChild) { - openKey = li.path; + openKey = li.path || li.name || ''; } } }); @@ -181,8 +218,8 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { path={path} callback={menuClick} activeKey={activeKey} - expanding={openKey === li.path} - key={li.path} + expanding={openKey === (li.path || li.name)} + key={li.path || li.name} /> ); })} diff --git a/ui/src/components/AdminSideNav/index.tsx b/ui/src/components/AdminSideNav/index.tsx index a2d36fbd4..b6c4d4bca 100644 --- a/ui/src/components/AdminSideNav/index.tsx +++ b/ui/src/components/AdminSideNav/index.tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'; import cloneDeep from 'lodash/cloneDeep'; import { AccordionNav, Icon } from '@/components'; +import type { MenuItem } from '@/components/AccordionNav'; import { ADMIN_NAV_MENUS } from '@/common/constants'; import { useQueryPlugins } from '@/services'; import { interfaceStore } from '@/stores'; @@ -37,16 +38,18 @@ const AdminSideNav = () => { have_config: true, }); - const menus = cloneDeep(ADMIN_NAV_MENUS); + const menus = cloneDeep(ADMIN_NAV_MENUS) as MenuItem[]; if (configurablePlugins && configurablePlugins.length > 0) { menus.forEach((item) => { if (item.name === 'plugins' && item.children) { item.children = [ ...item.children, - ...configurablePlugins.map((plugin) => ({ - name: plugin.slug_name, - displayName: plugin.name, - })), + ...configurablePlugins.map( + (plugin): MenuItem => ({ + name: plugin.slug_name, + displayName: plugin.name, + }), + ), ]; } }); diff --git a/ui/src/components/BubbleAi/index.tsx b/ui/src/components/BubbleAi/index.tsx new file mode 100644 index 000000000..453a61c5b --- /dev/null +++ b/ui/src/components/BubbleAi/index.tsx @@ -0,0 +1,259 @@ +/* + * 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. + */ + +import { FC, useEffect, useState, useRef } from 'react'; +import { Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { marked } from 'marked'; +import copy from 'copy-to-clipboard'; + +import { voteConversation } from '@/services'; +import { Icon, htmlRender } from '@/components'; + +interface IProps { + canType?: boolean; + chatId: string; + isLast: boolean; + isCompleted: boolean; + content: string; + minHeight?: number; + actionData: { + helpful: number; + unhelpful: number; + }; +} + +const BubbleAi: FC = ({ + canType = false, + isLast, + isCompleted, + content, + chatId = '', + actionData, + minHeight = 0, +}) => { + const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + const [displayContent, setDisplayContent] = useState(''); + const [copyText, setCopyText] = useState(t('copy')); + const [isHelpful, setIsHelpful] = useState(false); + const [isUnhelpful, setIsUnhelpful] = useState(false); + const [canShowAction, setCanShowAction] = useState(false); + const typewriterRef = useRef<{ + timer: NodeJS.Timeout | null; + index: number; + isTyping: boolean; + }>({ + timer: null, + index: 0, + isTyping: false, + }); + const fmtContainer = useRef(null); + // add ref for ScrollIntoView + const containerRef = useRef(null); + + const handleCopy = () => { + const res = copy(displayContent); + if (res) { + setCopyText(t('copied', { keyPrefix: 'messages' })); + setTimeout(() => { + setCopyText(t('copy')); + }, 1200); + } + }; + + const handleVote = (voteType: 'helpful' | 'unhelpful') => { + const isCancel = + (voteType === 'helpful' && isHelpful) || + (voteType === 'unhelpful' && isUnhelpful); + voteConversation({ + chat_completion_id: chatId, + cancel: isCancel, + vote_type: voteType, + }).then(() => { + setIsHelpful(voteType === 'helpful' && !isCancel); + setIsUnhelpful(voteType === 'unhelpful' && !isCancel); + }); + }; + + useEffect(() => { + if ((!canType || !isLast) && content) { + // 如果不是最后一个消息,直接返回,不进行打字效果 + if (typewriterRef.current.timer) { + clearInterval(typewriterRef.current.timer); + typewriterRef.current.timer = null; + } + setDisplayContent(content); + setCanShowAction(true); + typewriterRef.current.timer = null; + typewriterRef.current.isTyping = false; + return; + } + // 当内容变化时,清理之前的计时器 + if (typewriterRef.current.timer) { + clearInterval(typewriterRef.current.timer); + typewriterRef.current.timer = null; + } + + // 如果内容为空,则直接返回 + if (!content) { + setDisplayContent(''); + return; + } + + // 如果内容比当前显示的短,则重置 + if (content.length < displayContent.length) { + setDisplayContent(''); + typewriterRef.current.index = 0; + } + + // 如果内容与显示内容相同,不需要做任何事 + if (content === displayContent) { + return; + } + + typewriterRef.current.isTyping = true; + + // start typing animation + typewriterRef.current.timer = setInterval(() => { + const currentIndex = typewriterRef.current.index; + if (currentIndex < content.length) { + const remainingLength = content.length - currentIndex; + const baseRandomNum = Math.floor(Math.random() * 3) + 2; + let randomNum = Math.min(baseRandomNum, remainingLength); + + // 简单的单词边界检查(可选) + const nextChar = content[currentIndex + randomNum]; + const prevChar = content[currentIndex + randomNum - 1]; + + // 如果下一个字符是字母,当前字符也是字母,尝试调整到空格处 + if ( + nextChar && + /[a-zA-Z]/.test(nextChar) && + /[a-zA-Z]/.test(prevChar) + ) { + // 向前找1-2个字符,看看有没有空格 + for ( + let i = 1; + i <= 2 && currentIndex + randomNum - i > currentIndex; + i += 1 + ) { + if (content[currentIndex + randomNum - i] === ' ') { + randomNum = randomNum - i + 1; + break; + } + } + // 向后找1-2个字符,看看有没有空格 + for ( + let i = 1; + i <= 2 && currentIndex + randomNum + i < content.length; + i += 1 + ) { + if (content[currentIndex + randomNum + i] === ' ') { + randomNum = randomNum + i + 1; + break; + } + } + } + + const nextIndex = currentIndex + randomNum; + const newContent = content.substring(0, nextIndex); + setDisplayContent(newContent); + typewriterRef.current.index = nextIndex; + setCanShowAction(false); + } else { + clearInterval(typewriterRef.current.timer as NodeJS.Timeout); + typewriterRef.current.timer = null; + typewriterRef.current.isTyping = false; + setCanShowAction(false); + } + }, 30); + + // eslint-disable-next-line consistent-return + return () => { + if (typewriterRef.current.timer) { + clearInterval(typewriterRef.current.timer); + typewriterRef.current.timer = null; + } + }; + }, [content, isCompleted]); + + useEffect(() => { + setIsHelpful(actionData.helpful > 0); + setIsUnhelpful(actionData.unhelpful > 0); + }, [actionData]); + + useEffect(() => { + if (fmtContainer.current && isCompleted) { + htmlRender(fmtContainer.current, { + copySuccessText: t('copied', { keyPrefix: 'messages' }), + copyText: t('copy', { keyPrefix: 'messages' }), + }); + const links = fmtContainer.current.querySelectorAll('a'); + links.forEach((link) => { + link.setAttribute('target', '_blank'); + }); + setCanShowAction(true); + } + }, [isCompleted, fmtContainer.current]); + + return ( +
+
+
+ + {canShowAction && ( +
+ + + +
+ )} +
+
+ ); +}; + +export default BubbleAi; diff --git a/ui/src/components/BubbleUser/index.scss b/ui/src/components/BubbleUser/index.scss new file mode 100644 index 000000000..6606ac5e4 --- /dev/null +++ b/ui/src/components/BubbleUser/index.scss @@ -0,0 +1,25 @@ +/* + * 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. + */ + +.bubble-user-wrap { + scroll-margin-top: 88px; +} +.bubble-user { + background-color: var(--bs-gray-200); +} diff --git a/ui/src/components/BubbleUser/index.tsx b/ui/src/components/BubbleUser/index.tsx new file mode 100644 index 000000000..b6c52a743 --- /dev/null +++ b/ui/src/components/BubbleUser/index.tsx @@ -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. + */ + +import { FC } from 'react'; +import './index.scss'; + +interface BubbleUserProps { + content?: string; +} + +const BubbleUser: FC = ({ content }) => { + return ( +
+
+ {content} +
+
+ ); +}; + +export default BubbleUser; diff --git a/ui/src/components/Customize/index.tsx b/ui/src/components/Customize/index.tsx index c02db675f..7da85b057 100644 --- a/ui/src/components/Customize/index.tsx +++ b/ui/src/components/Customize/index.tsx @@ -18,6 +18,7 @@ */ import { FC, memo, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { customizeStore } from '@/stores'; @@ -117,6 +118,8 @@ const Index: FC = () => { const { custom_head, custom_header, custom_footer } = customizeStore( (state) => state, ); + const { pathname } = useLocation(); + useEffect(() => { const isSeo = document.querySelector('meta[name="go-template"]'); if (!isSeo) { @@ -125,8 +128,40 @@ const Index: FC = () => { }, 1000); handleCustomHeader(custom_header); handleCustomFooter(custom_footer); + } else { + isSeo.remove(); } }, [custom_head, custom_header, custom_footer]); + + useEffect(() => { + /** + * description: Activate scripts with data-client attribute when route changes + */ + const allScript = document.body.querySelectorAll('script[data-client]'); + allScript.forEach((scriptNode) => { + const script = document.createElement('script'); + script.setAttribute('data-client', 'true'); + // If the script is already wrapped in an IIFE, use it directly; otherwise, wrap it in an IIFE + if ( + /^\s*\(\s*function\s*\(\s*\)\s*{/.test( + (scriptNode as HTMLScriptElement).text, + ) || + /^\s*\(\s*\(\s*\)\s*=>\s*{/.test((scriptNode as HTMLScriptElement).text) + ) { + script.text = (scriptNode as HTMLScriptElement).text; + } else { + script.text = `(() => {${(scriptNode as HTMLScriptElement).text}})();`; + } + for (let i = 0; i < scriptNode.attributes.length; i += 1) { + const attr = scriptNode.attributes[i]; + if (attr.name !== 'data-client') { + script.setAttribute(attr.name, attr.value); + } + } + scriptNode.parentElement?.replaceChild(script, scriptNode); + }); + }, [pathname]); + return null; }; diff --git a/ui/src/components/Editor/EditorContext.ts b/ui/src/components/Editor/EditorContext.ts index 9cfaffe2d..c5c584579 100644 --- a/ui/src/components/Editor/EditorContext.ts +++ b/ui/src/components/Editor/EditorContext.ts @@ -19,6 +19,6 @@ import React from 'react'; -import { IEditorContext } from './types'; +import { Editor } from './types'; -export const EditorContext = React.createContext({}); +export const EditorContext = React.createContext(null); diff --git a/ui/src/components/Editor/MarkdownEditor.tsx b/ui/src/components/Editor/MarkdownEditor.tsx new file mode 100644 index 000000000..068a408f7 --- /dev/null +++ b/ui/src/components/Editor/MarkdownEditor.tsx @@ -0,0 +1,98 @@ +/* + * 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. + */ + +import { useEffect, useRef } from 'react'; + +import { EditorView } from '@codemirror/view'; + +import { BaseEditorProps } from './types'; +import { useEditor } from './utils'; + +interface MarkdownEditorProps extends BaseEditorProps {} + +const MarkdownEditor: React.FC = ({ + value, + onChange, + onFocus, + onBlur, + placeholder, + autoFocus, + onEditorReady, +}) => { + const editorRef = useRef(null); + const lastSyncedValueRef = useRef(value); + const isInitializedRef = useRef(false); + + const editor = useEditor({ + editorRef, + onChange, + onFocus, + onBlur, + placeholder, + autoFocus, + initialValue: value, + }); + + useEffect(() => { + if (!editor || isInitializedRef.current) { + return; + } + + isInitializedRef.current = true; + onEditorReady?.(editor); + }, [editor, onEditorReady]); + + useEffect(() => { + if (!editor || value === lastSyncedValueRef.current) { + return; + } + + const currentValue = editor.getValue(); + if (currentValue !== value) { + editor.setValue(value || ''); + lastSyncedValueRef.current = value || ''; + } + }, [editor, value]); + + useEffect(() => { + lastSyncedValueRef.current = value; + isInitializedRef.current = false; + + return () => { + if (editor) { + const view = editor as unknown as EditorView; + if (view.destroy) { + view.destroy(); + } + } + isInitializedRef.current = false; + }; + }, []); + + return ( +
+
+
+ ); +}; + +export default MarkdownEditor; diff --git a/ui/src/components/Editor/ToolBars/blockquote.tsx b/ui/src/components/Editor/ToolBars/blockquote.tsx index fac2fc5a7..27147ed29 100644 --- a/ui/src/components/Editor/ToolBars/blockquote.tsx +++ b/ui/src/components/Editor/ToolBars/blockquote.tsx @@ -21,9 +21,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const BlockQuote = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); @@ -33,21 +32,9 @@ const BlockQuote = () => { tip: `${t('blockquote.text')} (Ctrl+Q)`, }; - const handleClick = (ctx) => { - context = ctx; - context.replaceLines((line) => { - const FIND_BLOCKQUOTE_RX = /^>\s+?/g; - - if (line === `> ${t('blockquote.text')}`) { - line = ''; - } else if (line.match(FIND_BLOCKQUOTE_RX)) { - line = line.replace(FIND_BLOCKQUOTE_RX, ''); - } else { - line = `> ${line || t('blockquote.text')}`; - } - return line; - }, 2); - context.editor?.focus(); + const handleClick = (editor: Editor) => { + editor.insertBlockquote(t('blockquote.text')); + editor.focus(); }; return ; diff --git a/ui/src/components/Editor/ToolBars/bold.tsx b/ui/src/components/Editor/ToolBars/bold.tsx index 8efe69c5b..2a9a292ef 100644 --- a/ui/src/components/Editor/ToolBars/bold.tsx +++ b/ui/src/components/Editor/ToolBars/bold.tsx @@ -21,9 +21,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const Bold = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { @@ -33,10 +32,9 @@ const Bold = () => { }; const DEFAULTTEXT = t('bold.text'); - const handleClick = (ctx) => { - context = ctx; - context.wrapText('**', '**', DEFAULTTEXT); - context.editor?.focus(); + const handleClick = (editor: Editor) => { + editor.insertBold(DEFAULTTEXT); + editor.focus(); }; return ; diff --git a/ui/src/components/Editor/ToolBars/chart.tsx b/ui/src/components/Editor/ToolBars/chart.tsx deleted file mode 100644 index a26a56388..000000000 --- a/ui/src/components/Editor/ToolBars/chart.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* - * 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. - */ - -import { FC, useEffect, useState, memo } from 'react'; -import { Dropdown } from 'react-bootstrap'; -import { useTranslation } from 'react-i18next'; - -import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; - -const Chart: FC = ({ editor }) => { - const { t } = useTranslation('translation', { keyPrefix: 'editor' }); - - const headerList = [ - { - label: t('chart.flow_chart'), - tpl: `graph TD - A[Christmas] -->|Get money| B(Go shopping) - B --> C{Let me think} - C -->|One| D[Laptop] - C -->|Two| E[iPhone] - C -->|Three| F[fa:fa-car Car]`, - }, - { - label: t('chart.sequence_diagram'), - tpl: `sequenceDiagram - Alice->>+John: Hello John, how are you? - Alice->>+John: John, can you hear me? - John-->>-Alice: Hi Alice, I can hear you! - John-->>-Alice: I feel great! - `, - }, - { - label: t('chart.state_diagram'), - tpl: `stateDiagram-v2 - [*] --> Still - Still --> [*] - Still --> Moving - Moving --> Still - Moving --> Crash - Crash --> [*] - `, - }, - { - label: t('chart.class_diagram'), - tpl: `classDiagram - Animal <|-- Duck - Animal <|-- Fish - Animal <|-- Zebra - Animal : +int age - Animal : +String gender - Animal: +isMammal() - Animal: +mate() - class Duck{ - +String beakColor - +swim() - +quack() - } - class Fish{ - -int sizeInFeet - -canEat() - } - class Zebra{ - +bool is_wild - +run() - } - `, - }, - { - label: t('chart.pie_chart'), - tpl: `pie title Pets adopted by volunteers - "Dogs" : 386 - "Cats" : 85 - "Rats" : 15 - `, - }, - { - label: t('chart.gantt_chart'), - tpl: `gantt - title A Gantt Diagram - dateFormat YYYY-MM-DD - section Section - A task :a1, 2014-01-01, 30d - Another task :after a1 , 20d - section Another - Task in sec :2014-01-12 , 12d - another task : 24d - `, - }, - { - label: t('chart.entity_relationship_diagram'), - tpl: `erDiagram - CUSTOMER }|..|{ DELIVERY-ADDRESS : has - CUSTOMER ||--o{ ORDER : places - CUSTOMER ||--o{ INVOICE : "liable for" - DELIVERY-ADDRESS ||--o{ ORDER : receives - INVOICE ||--|{ ORDER : covers - ORDER ||--|{ ORDER-ITEM : includes - PRODUCT-CATEGORY ||--|{ PRODUCT : contains - PRODUCT ||--o{ ORDER-ITEM : "ordered in" - `, - }, - ]; - const item = { - label: 'chart', - tip: `${t('chart.text')}`, - }; - const [isShow, setShowState] = useState(false); - const [isLocked, setLockState] = useState(false); - - useEffect(() => { - if (!editor) { - return; - } - editor.on('focus', () => { - setShowState(false); - }); - }, []); - - const click = (tpl) => { - const { ch } = editor.getCursor(); - - editor.replaceSelection(`${ch ? '\n' : ''}\`\`\`mermaid\n${tpl}\n\`\`\`\n`); - }; - - const onAddHeader = () => { - setShowState(!isShow); - }; - const handleMouseEnter = () => { - if (isLocked) { - return; - } - setLockState(true); - }; - - const handleMouseLeave = () => { - setLockState(false); - }; - return ( - - - {headerList.map((header) => { - return ( - { - e.preventDefault(); - click(header.tpl); - }}> - {header.label} - - ); - })} - - - ); -}; - -export default memo(Chart); diff --git a/ui/src/components/Editor/ToolBars/code.tsx b/ui/src/components/Editor/ToolBars/code.tsx index afbd7dde9..599ffaea5 100644 --- a/ui/src/components/Editor/ToolBars/code.tsx +++ b/ui/src/components/Editor/ToolBars/code.tsx @@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'; import Select from '../Select'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; const codeLanguageType = [ 'bash', @@ -150,7 +150,6 @@ const codeLanguageType = [ 'yml', ]; -let context: IEditorContext; const Code = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); @@ -170,22 +169,20 @@ const Code = () => { const inputRef = useRef(null); const SINGLELINEMAXLENGTH = 40; - const addCode = (ctx) => { - context = ctx; + const [currentEditor, setCurrentEditor] = useState(null); - const { wrapText, editor } = context; - - const text = context.editor.getSelection(); + const addCode = (editor: Editor) => { + setCurrentEditor(editor); + const text = editor.getSelection(); if (!text) { setVisible(true); - return; } if (text.length > SINGLELINEMAXLENGTH) { - context.wrapText('```\n', '\n```'); + editor.insertCodeBlock('', text); } else { - wrapText('`', '`'); + editor.insertCode(text); } editor.focus(); }; @@ -197,6 +194,10 @@ const Code = () => { }, [visible]); const handleClick = () => { + if (!currentEditor) { + return; + } + if (!code.value.trim()) { setCode({ ...code, @@ -206,17 +207,15 @@ const Code = () => { return; } - let value; - if ( code.value.split('\n').length > 1 || code.value.length >= SINGLELINEMAXLENGTH ) { - value = `\n\`\`\`${lang}\n${code.value}\n\`\`\`\n`; + currentEditor.insertCodeBlock(lang || undefined, code.value); } else { - value = `\`${code.value}\``; + currentEditor.insertCode(code.value); } - context.editor.replaceSelection(value); + setCode({ value: '', isInvalid: false, @@ -224,9 +223,10 @@ const Code = () => { }); setLang(''); setVisible(false); + currentEditor.focus(); }; const onHide = () => setVisible(false); - const onExited = () => context.editor?.focus(); + const onExited = () => currentEditor?.focus(); return ( diff --git a/ui/src/components/Editor/ToolBars/file.tsx b/ui/src/components/Editor/ToolBars/file.tsx index 9e6e7399c..d48c0e6ac 100644 --- a/ui/src/components/Editor/ToolBars/file.tsx +++ b/ui/src/components/Editor/ToolBars/file.tsx @@ -17,31 +17,28 @@ * under the License. */ -import { useState, memo, useRef } from 'react'; +import { memo, useRef, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal as AnswerModal } from '@/components'; import ToolItem from '../toolItem'; -import { IEditorContext, Editor } from '../types'; +import { EditorContext } from '../EditorContext'; import { uploadImage } from '@/services'; import { writeSettingStore } from '@/stores'; -let context: IEditorContext; -const Image = ({ editorInstance }) => { +const File = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const { max_attachment_size = 8, authorized_attachment_extensions = [] } = writeSettingStore((state) => state.write); const fileInputRef = useRef(null); - const [editor, setEditor] = useState(editorInstance); + const editor = useContext(EditorContext); const item = { label: 'paperclip', tip: `${t('file.text')}`, }; - const addLink = (ctx) => { - context = ctx; - setEditor(context.editor); + const addLink = () => { fileInputRef.current?.click?.(); }; @@ -132,4 +129,4 @@ const Image = ({ editorInstance }) => { ); }; -export default memo(Image); +export default memo(File); diff --git a/ui/src/components/Editor/ToolBars/heading.tsx b/ui/src/components/Editor/ToolBars/heading.tsx index 8b0cb6043..bcce69aef 100644 --- a/ui/src/components/Editor/ToolBars/heading.tsx +++ b/ui/src/components/Editor/ToolBars/heading.tsx @@ -22,9 +22,8 @@ import { Dropdown } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor, Level } from '../types'; -let context: IEditorContext; const Heading = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const headerList = [ @@ -61,19 +60,18 @@ const Heading = () => { }; const [isShow, setShowState] = useState(false); const [isLocked, setLockState] = useState(false); + const [currentEditor, setCurrentEditor] = useState(null); - const handleClick = (level = 2, label = '大标题') => { - const { replaceLines } = context; - - replaceLines((line) => { - line = line.trim().replace(/^#*/, '').trim(); - line = `${'#'.repeat(level)} ${line || label}`; - return line; - }, level + 1); + const handleClick = (level: Level = 2, label?: string) => { + if (!currentEditor) { + return; + } + currentEditor.insertHeading(level, label); + currentEditor.focus(); setShowState(false); }; - const onAddHeader = (ctx) => { - context = ctx; + const onAddHeader = (editor: Editor) => { + setCurrentEditor(editor); if (isLocked) { return; } @@ -104,7 +102,7 @@ const Heading = () => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - handleClick(header.level, header.label); + handleClick(header.level as Level, header.label); }} dangerouslySetInnerHTML={{ __html: header.text }} /> diff --git a/ui/src/components/Editor/ToolBars/hr.tsx b/ui/src/components/Editor/ToolBars/hr.tsx index ac988a877..6919eb3bd 100644 --- a/ui/src/components/Editor/ToolBars/hr.tsx +++ b/ui/src/components/Editor/ToolBars/hr.tsx @@ -21,9 +21,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const Hr = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { @@ -31,11 +30,9 @@ const Hr = () => { keyMap: ['Ctrl-r'], tip: `${t('hr.text')} (Ctrl+r)`, }; - const handleClick = (ctx) => { - context = ctx; - const { appendBlock, editor } = context; - appendBlock('----------\n'); - editor?.focus(); + const handleClick = (editor: Editor) => { + editor.insertHorizontalRule(); + editor.focus(); }; return ; diff --git a/ui/src/components/Editor/ToolBars/image.tsx b/ui/src/components/Editor/ToolBars/image.tsx index c9950b069..0783e22e4 100644 --- a/ui/src/components/Editor/ToolBars/image.tsx +++ b/ui/src/components/Editor/ToolBars/image.tsx @@ -17,26 +17,28 @@ * under the License. */ -import { useEffect, useState, memo } from 'react'; +import { useEffect, useState, memo, useContext } from 'react'; import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { Modal as AnswerModal } from '@/components'; import ToolItem from '../toolItem'; -import { IEditorContext, Editor } from '../types'; -import { uploadImage } from '@/services'; -import { writeSettingStore } from '@/stores'; +import { EditorContext } from '../EditorContext'; +import { Editor } from '../types'; +import { useImageUpload } from '../hooks/useImageUpload'; -let context: IEditorContext; -const Image = ({ editorInstance }) => { - const [editor, setEditor] = useState(editorInstance); +const Image = () => { + const editor = useContext(EditorContext); + const [editorState, setEditorState] = useState(editor); + + // Update editor state when editor context changes + // This ensures event listeners are re-bound when switching editor modes + useEffect(() => { + if (editor) { + setEditorState(editor); + } + }, [editor]); const { t } = useTranslation('translation', { keyPrefix: 'editor' }); - const { - max_image_size = 4, - max_attachment_size = 8, - authorized_image_extensions = [], - authorized_attachment_extensions = [], - } = writeSettingStore((state) => state.write); + const { verifyImageSize, uploadFiles } = useImageUpload(); const loadingText = `![${t('image.uploading')}...]()`; @@ -60,89 +62,6 @@ const Image = ({ editorInstance }) => { errorMsg: '', }); - const verifyImageSize = (files: FileList) => { - if (files.length === 0) { - return false; - } - - /** - * When allowing attachments to be uploaded, verification logic for attachment information has been added. In order to avoid abnormal judgment caused by the order of drag and drop upload, the drag and drop upload verification of attachments and the drag and drop upload of images are put together. - * - */ - const canUploadAttachment = authorized_attachment_extensions.length > 0; - const allowedAllType = [ - ...authorized_image_extensions, - ...authorized_attachment_extensions, - ]; - const unSupportFiles = Array.from(files).filter((file) => { - const fileName = file.name.toLowerCase(); - return canUploadAttachment - ? !allowedAllType.find((v) => fileName.endsWith(v)) - : file.type.indexOf('image') === -1; - }); - - if (unSupportFiles.length > 0) { - AnswerModal.confirm({ - content: canUploadAttachment - ? t('file.not_supported', { file_type: allowedAllType.join(', ') }) - : t('image.form_image.fields.file.msg.only_image'), - showCancel: false, - }); - return false; - } - - const otherFiles = Array.from(files).filter((file) => { - return file.type.indexOf('image') === -1; - }); - - if (canUploadAttachment && otherFiles.length > 0) { - const attachmentOverSizeFiles = otherFiles.filter( - (file) => file.size / 1024 / 1024 > max_attachment_size, - ); - if (attachmentOverSizeFiles.length > 0) { - AnswerModal.confirm({ - content: t('file.max_size', { size: max_attachment_size }), - showCancel: false, - }); - return false; - } - } - - const imageFiles = Array.from(files).filter( - (file) => file.type.indexOf('image') > -1, - ); - const oversizedImages = imageFiles.filter( - (file) => file.size / 1024 / 1024 > max_image_size, - ); - if (oversizedImages.length > 0) { - AnswerModal.confirm({ - content: t('image.form_image.fields.file.msg.max_size', { - size: max_image_size, - }), - showCancel: false, - }); - return false; - } - - return true; - }; - - const upload = ( - files: FileList, - ): Promise<{ url: string; name: string; type: string }[]> => { - const promises = Array.from(files).map(async (file) => { - const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment'; - const url = await uploadImage({ file, type }); - - return { - name: file.name, - url, - type, - }; - }); - - return Promise.all(promises); - }; function dragenter(e) { e.stopPropagation(); e.preventDefault(); @@ -160,19 +79,22 @@ const Image = ({ editorInstance }) => { return; } - const startPos = editor.getCursor(); + if (!editorState) { + return; + } + const startPos = editorState.getCursor(); const endPos = { ...startPos, ch: startPos.ch + loadingText.length }; - editor.replaceSelection(loadingText); - editor.setReadOnly(true); - const urls = await upload(fileList) + editorState.replaceSelection(loadingText); + editorState.setReadOnly(true); + const urls = await uploadFiles(fileList) .catch(() => { - editor.replaceRange('', startPos, endPos); + editorState.replaceRange('', startPos, endPos); }) .finally(() => { - editor.setReadOnly(false); - editor.focus(); + editorState?.setReadOnly(false); + editorState?.focus(); }); const text: string[] = []; @@ -184,9 +106,9 @@ const Image = ({ editorInstance }) => { }); } if (text.length) { - editor.replaceRange(text.join('\n'), startPos, endPos); + editorState.replaceRange(text.join('\n'), startPos, endPos); } else { - editor.replaceRange('', startPos, endPos); + editorState?.replaceRange('', startPos, endPos); } }; @@ -197,25 +119,28 @@ const Image = ({ editorInstance }) => { if (bool) { event.preventDefault(); - const startPos = editor.getCursor(); + if (!editorState) { + return; + } + const startPos = editorState.getCursor(); const endPos = { ...startPos, ch: startPos.ch + loadingText.length }; - editor.replaceSelection(loadingText); - editor.setReadOnly(true); - upload(clipboard.files) + editorState?.replaceSelection(loadingText); + editorState?.setReadOnly(true); + uploadFiles(clipboard.files) .then((urls) => { const text = urls.map(({ name, url, type }) => { return `${type === 'post' ? '!' : ''}[${name}](${url})`; }); - editor.replaceRange(text.join('\n'), startPos, endPos); + editorState.replaceRange(text.join('\n'), startPos, endPos); }) .catch(() => { - editor.replaceRange('', startPos, endPos); + editorState.replaceRange('', startPos, endPos); }) .finally(() => { - editor.setReadOnly(false); - editor.focus(); + editorState?.setReadOnly(false); + editorState?.focus(); }); return; @@ -289,7 +214,9 @@ const Image = ({ editorInstance }) => { return match.length > 1 ? '\n\n' : match; }); - editor.replaceSelection(markdownText); + if (editorState) { + editorState.replaceSelection(markdownText); + } }; const handleClick = () => { if (!link.value) { @@ -298,28 +225,33 @@ const Image = ({ editorInstance }) => { } setLink({ ...link, type: '' }); - const text = `![${imageName.value}](${link.value})`; - - editor.replaceSelection(text); + if (editorState) { + editorState.insertImage(link.value, imageName.value || undefined); + } setVisible(false); - editor.focus(); + editorState?.focus(); setLink({ ...link, value: '' }); setImageName({ ...imageName, value: '' }); }; useEffect(() => { - editor?.on('dragenter', dragenter); - editor?.on('dragover', dragover); - editor?.on('drop', drop); - editor?.on('paste', paste); + if (!editorState) { + return undefined; + } + + editorState.on('dragenter', dragenter); + editorState.on('dragover', dragover); + editorState.on('drop', drop); + editorState.on('paste', paste); + return () => { - editor?.off('dragenter', dragenter); - editor?.off('dragover', dragover); - editor?.off('drop', drop); - editor?.off('paste', paste); + editorState.off('dragenter', dragenter); + editorState.off('dragover', dragover); + editorState.off('drop', drop); + editorState.off('paste', paste); }; - }, [editor]); + }, [editorState]); useEffect(() => { if (link.value && link.type === 'drop') { @@ -327,16 +259,17 @@ const Image = ({ editorInstance }) => { } }, [link.value]); - const addLink = (ctx) => { - context = ctx; - setEditor(context.editor); - const text = context.editor?.getSelection(); + const addLink = (editorInstance: Editor) => { + setEditorState(editorInstance); + const text = editorInstance?.getSelection(); setImageName({ ...imageName, value: text }); setVisible(true); }; + const { uploadSingleFile } = useImageUpload(); + const onUpload = async (e) => { if (!editor) { return; @@ -348,7 +281,7 @@ const Image = ({ editorInstance }) => { return; } - uploadImage({ file: e.target.files[0], type: 'post' }).then((url) => { + uploadSingleFile(e.target.files[0]).then((url) => { setLink({ ...link, value: url }); setImageName({ ...imageName, value: files[0].name }); }); diff --git a/ui/src/components/Editor/ToolBars/indent.tsx b/ui/src/components/Editor/ToolBars/indent.tsx index 6ec01dcea..7099fbcb1 100644 --- a/ui/src/components/Editor/ToolBars/indent.tsx +++ b/ui/src/components/Editor/ToolBars/indent.tsx @@ -21,24 +21,17 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const Indent = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { label: 'text-indent-left', tip: t('indent.text'), }; - const handleClick = (ctx) => { - context = ctx; - const { editor, replaceLines } = context; - - replaceLines((line) => { - line = ` ${line}`; - return line; - }); - editor?.focus(); + const handleClick = (editor: Editor) => { + editor.indent(); + editor.focus(); }; return ; diff --git a/ui/src/components/Editor/ToolBars/index.ts b/ui/src/components/Editor/ToolBars/index.ts index 05912bc6e..c3fa24be4 100644 --- a/ui/src/components/Editor/ToolBars/index.ts +++ b/ui/src/components/Editor/ToolBars/index.ts @@ -31,7 +31,6 @@ import Link from './link'; import BlockQuote from './blockquote'; import Image from './image'; import Help from './help'; -import Chart from './chart'; import File from './file'; export { @@ -49,6 +48,5 @@ export { BlockQuote, Image, Help, - Chart, File, }; diff --git a/ui/src/components/Editor/ToolBars/italic.tsx b/ui/src/components/Editor/ToolBars/italic.tsx index cb585893f..4b359d2aa 100644 --- a/ui/src/components/Editor/ToolBars/italic.tsx +++ b/ui/src/components/Editor/ToolBars/italic.tsx @@ -21,9 +21,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const Italic = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { @@ -33,11 +32,9 @@ const Italic = () => { }; const DEFAULTTEXT = t('italic.text'); - const handleClick = (ctx) => { - context = ctx; - const { editor, wrapText } = context; - wrapText('*', '*', DEFAULTTEXT); - editor?.focus(); + const handleClick = (editor: Editor) => { + editor.insertItalic(DEFAULTTEXT); + editor.focus(); }; return ; diff --git a/ui/src/components/Editor/ToolBars/link.tsx b/ui/src/components/Editor/ToolBars/link.tsx index e761ef3d1..be84c1b37 100644 --- a/ui/src/components/Editor/ToolBars/link.tsx +++ b/ui/src/components/Editor/ToolBars/link.tsx @@ -22,9 +22,8 @@ import { Button, Form, Modal } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const Link = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { @@ -33,6 +32,7 @@ const Link = () => { tip: `${t('link.text')} (Ctrl+l)`, }; const [visible, setVisible] = useState(false); + const [currentEditor, setCurrentEditor] = useState(null); const [link, setLink] = useState({ value: 'https://', isInvalid: false, @@ -52,39 +52,32 @@ const Link = () => { } }, [visible]); - const addLink = (ctx) => { - context = ctx; - const { editor } = context; - + const addLink = (editor: Editor) => { + setCurrentEditor(editor); const text = editor.getSelection(); - setName({ ...name, value: text }); - setVisible(true); }; const handleClick = () => { - const { editor } = context; + if (!currentEditor) { + return; + } if (!link.value) { setLink({ ...link, isInvalid: true }); return; } - const newStr = name.value - ? `[${name.value}](${link.value})` - : `<${link.value}>`; - editor.replaceSelection(newStr); + currentEditor.insertLink(link.value, name.value || undefined); setVisible(false); - - editor.focus(); + currentEditor.focus(); setLink({ ...link, value: '' }); setName({ ...name, value: '' }); }; const onHide = () => setVisible(false); const onExited = () => { - const { editor } = context; - editor.focus(); + currentEditor?.focus(); }; return ( diff --git a/ui/src/components/Editor/ToolBars/ol.tsx b/ui/src/components/Editor/ToolBars/ol.tsx index 011c35a3c..a42523487 100644 --- a/ui/src/components/Editor/ToolBars/ol.tsx +++ b/ui/src/components/Editor/ToolBars/ol.tsx @@ -21,9 +21,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const OL = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { @@ -32,20 +31,8 @@ const OL = () => { tip: `${t('ordered_list.text')} (Ctrl+o)`, }; - const handleClick = (ctx) => { - context = ctx; - const { editor, replaceLines } = context; - - replaceLines((line, i) => { - const FIND_OL_RX = /^(\s{0,})(\d+)\.\s/; - - if (line.match(FIND_OL_RX)) { - line = line.replace(FIND_OL_RX, ''); - } else { - line = `${i + 1}. ${line}`; - } - return line; - }); + const handleClick = (editor: Editor) => { + editor.insertOrderedList(); editor.focus(); }; diff --git a/ui/src/components/Editor/ToolBars/outdent.tsx b/ui/src/components/Editor/ToolBars/outdent.tsx index b80670371..484e434cf 100644 --- a/ui/src/components/Editor/ToolBars/outdent.tsx +++ b/ui/src/components/Editor/ToolBars/outdent.tsx @@ -21,9 +21,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const Outdent = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { @@ -31,16 +30,9 @@ const Outdent = () => { keyMap: ['Shift-Tab'], tip: t('outdent.text'), }; - const handleClick = (ctx) => { - context = ctx; - const { editor, replaceLines } = context; - replaceLines((line) => { - line = line.replace(/^(\s{0,})/, (_1, $1) => { - return $1.length > 4 ? $1.substring(4) : ''; - }); - return line; - }); - editor?.focus(); + const handleClick = (editor: Editor) => { + editor.outdent(); + editor.focus(); }; return ; diff --git a/ui/src/components/Editor/ToolBars/table.tsx b/ui/src/components/Editor/ToolBars/table.tsx index 30ca101d1..6381f4ccd 100644 --- a/ui/src/components/Editor/ToolBars/table.tsx +++ b/ui/src/components/Editor/ToolBars/table.tsx @@ -21,77 +21,18 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const Table = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { label: 'table', tip: t('table.text'), }; - const tableData = [ - [`${t('table.heading')} A`], - [`${t('table.heading')} B`], - [`${t('table.cell')} 1`], - [`${t('table.cell')} 2`], - [`${t('table.cell')} 3`], - [`${t('table.cell')} 4`], - ]; - const makeHeader = (col, data) => { - let header = '|'; - let border = '|'; - let index = 0; - - while (col) { - if (data) { - header += ` ${data[index]} |`; - index += 1; - } else { - header += ' |'; - } - - border += ' ----- |'; - - col -= 1; - } - - return `${header}\n${border}\n`; - }; - - const makeBody = (col, row, data) => { - let body = ''; - let index = col; - - for (let irow = 0; irow < row; irow += 1) { - body += '|'; - - for (let icol = 0; icol < col; icol += 1) { - if (data) { - body += ` ${data[index]} |`; - index += 1; - } else { - body += ' |'; - } - } - - body += '\n'; - } - - body = body.replace(/\n$/g, ''); - - return body; - }; - const handleClick = (ctx) => { - context = ctx; - const { editor } = context; - let table = '\n'; - - table += makeHeader(2, tableData); - table += makeBody(2, 2, tableData); - editor?.replaceSelection(table); - editor?.focus(); + const handleClick = (editor: Editor) => { + editor.insertTable(3, 3); + editor.focus(); }; return ; diff --git a/ui/src/components/Editor/ToolBars/ul.tsx b/ui/src/components/Editor/ToolBars/ul.tsx index b906a346b..9651f5a42 100644 --- a/ui/src/components/Editor/ToolBars/ul.tsx +++ b/ui/src/components/Editor/ToolBars/ul.tsx @@ -21,9 +21,8 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import ToolItem from '../toolItem'; -import { IEditorContext } from '../types'; +import { Editor } from '../types'; -let context: IEditorContext; const UL = () => { const { t } = useTranslation('translation', { keyPrefix: 'editor' }); const item = { @@ -32,20 +31,8 @@ const UL = () => { tip: `${t('unordered_list.text')} (Ctrl+u)`, }; - const handleClick = (ctx) => { - context = ctx; - const { editor, replaceLines } = context; - - replaceLines((line) => { - const FIND_UL_RX = /^(\s{0,})(-|\*)\s/; - - if (line.match(FIND_UL_RX)) { - line = line.replace(FIND_UL_RX, ''); - } else { - line = `* ${line}`; - } - return line; - }); + const handleClick = (editor: Editor) => { + editor.insertUnorderedList(); editor.focus(); }; diff --git a/ui/src/components/Editor/hooks/useImageUpload.ts b/ui/src/components/Editor/hooks/useImageUpload.ts new file mode 100644 index 000000000..bf97e24d6 --- /dev/null +++ b/ui/src/components/Editor/hooks/useImageUpload.ts @@ -0,0 +1,129 @@ +/* + * 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. + */ + +import { useTranslation } from 'react-i18next'; + +import { Modal as AnswerModal } from '@/components'; +import { uploadImage } from '@/services'; +import { writeSettingStore } from '@/stores'; + +export const useImageUpload = () => { + const { t } = useTranslation('translation', { keyPrefix: 'editor' }); + const { + max_image_size = 4, + max_attachment_size = 8, + authorized_image_extensions = [], + authorized_attachment_extensions = [], + } = writeSettingStore((state) => state.write); + + const verifyImageSize = (files: FileList | File[]): boolean => { + const fileArray = Array.isArray(files) ? files : Array.from(files); + + if (fileArray.length === 0) { + return false; + } + + const canUploadAttachment = authorized_attachment_extensions.length > 0; + const allowedAllType = [ + ...authorized_image_extensions, + ...authorized_attachment_extensions, + ]; + + const unSupportFiles = fileArray.filter((file) => { + const fileName = file.name.toLowerCase(); + return canUploadAttachment + ? !allowedAllType.find((v) => fileName.endsWith(v)) + : file.type.indexOf('image') === -1; + }); + + if (unSupportFiles.length > 0) { + AnswerModal.confirm({ + content: canUploadAttachment + ? t('file.not_supported', { file_type: allowedAllType.join(', ') }) + : t('image.form_image.fields.file.msg.only_image'), + showCancel: false, + }); + return false; + } + + const otherFiles = fileArray.filter((file) => { + return file.type.indexOf('image') === -1; + }); + + if (canUploadAttachment && otherFiles.length > 0) { + const attachmentOverSizeFiles = otherFiles.filter( + (file) => file.size / 1024 / 1024 > max_attachment_size, + ); + if (attachmentOverSizeFiles.length > 0) { + AnswerModal.confirm({ + content: t('file.max_size', { size: max_attachment_size }), + showCancel: false, + }); + return false; + } + } + + const imageFiles = fileArray.filter( + (file) => file.type.indexOf('image') > -1, + ); + const oversizedImages = imageFiles.filter( + (file) => file.size / 1024 / 1024 > max_image_size, + ); + if (oversizedImages.length > 0) { + AnswerModal.confirm({ + content: t('image.form_image.fields.file.msg.max_size', { + size: max_image_size, + }), + showCancel: false, + }); + return false; + } + + return true; + }; + + const uploadFiles = ( + files: FileList | File[], + ): Promise<{ url: string; name: string; type: string }[]> => { + const fileArray = Array.isArray(files) ? files : Array.from(files); + const promises = fileArray.map(async (file) => { + const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment'; + const url = await uploadImage({ file, type }); + + return { + name: file.name, + url, + type, + }; + }); + + return Promise.all(promises); + }; + + const uploadSingleFile = async (file: File): Promise => { + const type = file.type.indexOf('image') > -1 ? 'post' : 'post_attachment'; + return uploadImage({ file, type }); + }; + + return { + verifyImageSize, + uploadFiles, + uploadSingleFile, + }; +}; diff --git a/ui/src/components/Editor/index.scss b/ui/src/components/Editor/index.scss index afd158715..60a549aaf 100644 --- a/ui/src/components/Editor/index.scss +++ b/ui/src/components/Editor/index.scss @@ -114,6 +114,42 @@ height: 264px; } + .rich-editor-wrap { + height: 264px; + overflow-y: auto; + padding: 0.375rem 0.75rem; + + .tiptap-editor { + outline: none; + min-height: 100%; + + &:focus { + outline: none; + } + + p { + margin: 0.5rem 0; + line-height: 1.6; + + &.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--an-editor-placeholder-color); + pointer-events: none; + height: 0; + } + } + } + + .editor-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--an-text-secondary, #6c757d); + } + } + .CodeMirror { height: auto; font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', diff --git a/ui/src/components/Editor/index.tsx b/ui/src/components/Editor/index.tsx index 45919b2c9..9c12bbb18 100644 --- a/ui/src/components/Editor/index.tsx +++ b/ui/src/components/Editor/index.tsx @@ -18,18 +18,27 @@ */ import { - useEffect, useRef, + useState, ForwardRefRenderFunction, forwardRef, useImperativeHandle, + useCallback, + useEffect, } from 'react'; +import { Spinner } from 'react-bootstrap'; import classNames from 'classnames'; -import { PluginType, useRenderPlugin } from '@/utils/pluginKit'; -import PluginRender from '../PluginRender'; - +import { + PluginType, + useRenderPlugin, + getReplacementPlugin, +} from '@/utils/pluginKit'; +import { writeSettingStore } from '@/stores'; +import PluginRender, { PluginSlot } from '../PluginRender'; + +import { useImageUpload } from './hooks/useImageUpload'; import { BlockQuote, Bold, @@ -47,9 +56,11 @@ import { UL, File, } from './ToolBars'; -import { htmlRender, useEditor } from './utils'; +import { htmlRender } from './utils'; import Viewer from './Viewer'; import { EditorContext } from './EditorContext'; +import MarkdownEditor from './MarkdownEditor'; +import { Editor } from './types'; import './index.scss'; @@ -82,46 +93,107 @@ const MDEditor: ForwardRefRenderFunction = ( }, ref, ) => { - const editorRef = useRef(null); + const [currentEditor, setCurrentEditor] = useState(null); const previewRef = useRef<{ getHtml; element } | null>(null); + const [fullEditorPlugin, setFullEditorPlugin] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { verifyImageSize, uploadSingleFile } = useImageUpload(); + const { + max_image_size = 4, + authorized_image_extensions = [], + authorized_attachment_extensions = [], + } = writeSettingStore((state) => state.write); - useRenderPlugin(previewRef.current?.element); + useEffect(() => { + let mounted = true; - const editor = useEditor({ - editorRef, - onChange, - onFocus, - onBlur, - placeholder: editorPlaceholder, - autoFocus, - }); + const loadPlugin = async () => { + const plugin = await getReplacementPlugin(PluginType.EditorReplacement); + if (mounted) { + setFullEditorPlugin(plugin); + setIsLoading(false); + } + }; + + loadPlugin(); - const getHtml = () => { + return () => { + mounted = false; + }; + }, []); + + useRenderPlugin(previewRef.current?.element); + + const getHtml = useCallback(() => { return previewRef.current?.getHtml(); - }; + }, []); + + useImperativeHandle( + ref, + () => ({ + getHtml, + }), + [getHtml], + ); - useImperativeHandle(ref, () => ({ - getHtml, - })); + const EditorComponent = MarkdownEditor; - useEffect(() => { - if (!editor) { - return; - } - if (editor.getValue() !== value) { - editor.setValue(value || ''); - } - }, [editor, value]); + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (fullEditorPlugin) { + const FullEditorComponent = fullEditorPlugin.component; + + const handleImageUpload = async (file: File | string): Promise => { + if (typeof file === 'string') { + return file; + } + + if (!verifyImageSize([file])) { + throw new Error('File validation failed'); + } + + return uploadSingleFile(file); + }; + + return ( + + ); + } return ( <>
- - {editor && ( +
+ @@ -130,8 +202,8 @@ const MDEditor: ForwardRefRenderFunction = (
- - + +
    @@ -140,17 +212,26 @@ const MDEditor: ForwardRefRenderFunction = (
    + - )} - - -
    -
    +
    + + { + onChange?.(markdown); + }} + onFocus={onFocus} + onBlur={onBlur} + placeholder={editorPlaceholder} + autoFocus={autoFocus} + onEditorReady={(editor) => { + setCurrentEditor(editor); + }} + />
    diff --git a/ui/src/components/Editor/toolItem.tsx b/ui/src/components/Editor/toolItem.tsx index 0c4ca2f10..39f82d700 100644 --- a/ui/src/components/Editor/toolItem.tsx +++ b/ui/src/components/Editor/toolItem.tsx @@ -21,16 +21,11 @@ import { FC, useContext, useEffect } from 'react'; import { Dropdown, Button } from 'react-bootstrap'; import { EditorContext } from './EditorContext'; -import { IEditorContext } from './types'; +import { Editor } from './types'; interface IProps { keyMap?: string[]; - onClick?: ({ - editor, - wrapText, - replaceLines, - appendBlock, - }: IEditorContext) => void; + onClick?: (editor: Editor) => void; tip?: string; className?: string; as?: any; @@ -38,12 +33,7 @@ interface IProps { label?: string; disable?: boolean; isShow?: boolean; - onBlur?: ({ - editor, - wrapText, - replaceLines, - appendBlock, - }: IEditorContext) => void; + onBlur?: (editor: Editor) => void; } const ToolItem: FC = (props) => { const editor = useContext(EditorContext); @@ -72,12 +62,8 @@ const ToolItem: FC = (props) => { keyMap.forEach((key) => { editor?.addKeyMap({ [key]: () => { - onClick?.({ - editor, - wrapText: editor?.wrapText, - replaceLines: editor?.replaceLines, - appendBlock: editor?.appendBlock, - }); + onClick?.(editor); + return true; }, }); }); @@ -94,21 +80,15 @@ const ToolItem: FC = (props) => { tabIndex={-1} onClick={(e) => { e.preventDefault(); - onClick?.({ - editor, - wrapText: editor?.wrapText, - replaceLines: editor?.replaceLines, - appendBlock: editor?.appendBlock, - }); + if (editor) { + onClick?.(editor); + } }} onBlur={(e) => { e.preventDefault(); - onBlur?.({ - editor, - wrapText: editor?.wrapText, - replaceLines: editor?.replaceLines, - appendBlock: editor?.appendBlock, - }); + if (editor) { + onBlur?.(editor); + } }}> diff --git a/ui/src/components/Editor/types.ts b/ui/src/components/Editor/types.ts index ce33833fe..48f69322c 100644 --- a/ui/src/components/Editor/types.ts +++ b/ui/src/components/Editor/types.ts @@ -24,6 +24,9 @@ export interface Position { line: number; sticky?: string | undefined; } + +export type Level = 1 | 2 | 3 | 4 | 5 | 6; + export interface ExtendEditor { addKeyMap: (keyMap: Record) => void; on: ( @@ -53,16 +56,48 @@ export interface ExtendEditor { getSelection: () => string; replaceSelection: (value: string) => void; focus: () => void; + getCursor: () => Position; + replaceRange: (value: string, from: Position, to: Position) => void; + setSelection: (anchor: Position, head?: Position) => void; + setReadOnly: (readOnly: boolean) => void; + wrapText: (before: string, after?: string, defaultText?: string) => void; replaceLines: ( replace: Parameters['map']>[0], symbolLen?: number, ) => void; appendBlock: (content: string) => void; - getCursor: () => Position; - replaceRange: (value: string, from: Position, to: Position) => void; - setSelection: (anchor: Position, head?: Position) => void; - setReadOnly: (readOnly: boolean) => void; + + insertBold: (text?: string) => void; + insertItalic: (text?: string) => void; + insertCode: (text?: string) => void; + insertStrikethrough: (text?: string) => void; + + insertHeading: (level: Level, text?: string) => void; + insertBlockquote: (text?: string) => void; + insertCodeBlock: (language?: string, code?: string) => void; + insertHorizontalRule: () => void; + + insertOrderedList: () => void; + insertUnorderedList: () => void; + toggleOrderedList: () => void; + toggleUnorderedList: () => void; + + insertLink: (url: string, text?: string) => void; + insertImage: (url: string, alt?: string) => void; + + insertTable: (rows?: number, cols?: number) => void; + + indent: () => void; + outdent: () => void; + + isBold: () => boolean; + isItalic: () => boolean; + isHeading: (level?: number) => boolean; + isBlockquote: () => boolean; + isCodeBlock: () => boolean; + isOrderedList: () => boolean; + isUnorderedList: () => boolean; } export type Editor = EditorView & ExtendEditor; @@ -72,9 +107,12 @@ export interface CodeMirrorEditor extends Editor { moduleType; } -export interface IEditorContext { - editor: Editor; - wrapText?; - replaceLines?; - appendBlock?; +export interface BaseEditorProps { + value: string; + onChange?: (value: string) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + autoFocus?: boolean; + onEditorReady?: (editor: Editor) => void; } diff --git a/ui/src/components/Editor/utils/codemirror/adapter.ts b/ui/src/components/Editor/utils/codemirror/adapter.ts new file mode 100644 index 000000000..7e3f681ba --- /dev/null +++ b/ui/src/components/Editor/utils/codemirror/adapter.ts @@ -0,0 +1,59 @@ +/* + * 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. + */ + +import { Editor, ExtendEditor } from '../../types'; + +import { createBaseMethods } from './base'; +import { createEventMethods } from './events'; +import { createCommandMethods } from './commands'; + +/** + * Adapts CodeMirror editor to unified editor interface + * + * This adapter function extends CodeMirror editor with additional methods, + * enabling toolbar components to work properly in Markdown mode. The adapter + * implements the complete `ExtendEditor` interface, including base methods, + * event handling, and command methods. + * + * @param editor - CodeMirror editor instance + * @returns Extended editor instance that implements the unified Editor interface + * + * @example + * ```typescript + * const cmEditor = new EditorView({ ... }); + * const adaptedEditor = createCodeMirrorAdapter(cmEditor as Editor); + * // Now you can use the unified API + * adaptedEditor.insertBold('text'); + * adaptedEditor.insertHeading(1, 'Title'); + * ``` + */ +export function createCodeMirrorAdapter(editor: Editor): Editor { + const baseMethods = createBaseMethods(editor); + const eventMethods = createEventMethods(editor); + const commandMethods = createCommandMethods(editor); + + const editorAdapter: ExtendEditor = { + ...editor, + ...baseMethods, + ...eventMethods, + ...commandMethods, + }; + + return editorAdapter as unknown as Editor; +} diff --git a/ui/src/components/Editor/utils/codemirror/base.ts b/ui/src/components/Editor/utils/codemirror/base.ts new file mode 100644 index 000000000..4a257f043 --- /dev/null +++ b/ui/src/components/Editor/utils/codemirror/base.ts @@ -0,0 +1,110 @@ +/* + * 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. + */ + +import { EditorSelection, StateEffect } from '@codemirror/state'; +import { keymap, KeyBinding, Command } from '@codemirror/view'; + +import { Editor, Position } from '../../types'; + +/** + * Creates base methods module + * + * Provides core base methods for the editor, including: + * - Content getter and setter (getValue, setValue) + * - Selection operations (getSelection, replaceSelection) + * - Cursor and selection position (getCursor, setSelection) + * - Focus and keyboard mapping (focus, addKeyMap) + * + * @param editor - CodeMirror editor instance + * @returns Object containing base methods + */ +export function createBaseMethods(editor: Editor) { + return { + focus: () => { + editor.contentDOM.focus(); + }, + + getCursor: () => { + const range = editor.state.selection.ranges[0]; + const line = editor.state.doc.lineAt(range.from).number; + const { from, to } = editor.state.doc.line(line); + return { from, to, ch: range.from - from, line }; + }, + + addKeyMap: (keyMap: Record) => { + const array = Object.entries(keyMap).map(([key, value]) => { + const keyBinding: KeyBinding = { + key, + preventDefault: true, + run: value, + }; + return keyBinding; + }); + + editor.dispatch({ + effects: StateEffect.appendConfig.of(keymap.of(array)), + }); + }, + + getSelection: () => { + return editor.state.sliceDoc( + editor.state.selection.main.from, + editor.state.selection.main.to, + ); + }, + + replaceSelection: (value: string) => { + editor.dispatch({ + changes: [ + { + from: editor.state.selection.main.from, + to: editor.state.selection.main.to, + insert: value, + }, + ], + selection: EditorSelection.cursor( + editor.state.selection.main.from + value.length, + ), + }); + }, + + setSelection: (anchor: Position, head?: Position) => { + editor.dispatch({ + selection: EditorSelection.create([ + EditorSelection.range( + editor.state.doc.line(anchor.line).from + anchor.ch, + head + ? editor.state.doc.line(head.line).from + head.ch + : editor.state.doc.line(anchor.line).from + anchor.ch, + ), + ]), + }); + }, + + getValue: () => { + return editor.state.doc.toString(); + }, + + setValue: (value: string) => { + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: value }, + }); + }, + }; +} diff --git a/ui/src/components/Editor/utils/codemirror/commands.ts b/ui/src/components/Editor/utils/codemirror/commands.ts new file mode 100644 index 000000000..546382d46 --- /dev/null +++ b/ui/src/components/Editor/utils/codemirror/commands.ts @@ -0,0 +1,279 @@ +/* + * 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. + */ + +import { EditorSelection } from '@codemirror/state'; + +import { Editor, Level } from '../../types'; + +/** + * Creates command methods module + * + * Provides semantic command methods and low-level text manipulation methods: + * - Semantic methods: insertBold, insertHeading, insertImage, etc. (for toolbar use) + * - Low-level methods: wrapText, replaceLines, appendBlock (for internal use) + * - State query methods: isBold, isHeading, etc. + * + * @param editor - CodeMirror editor instance + * @returns Object containing all command methods + */ +export function createCommandMethods(editor: Editor) { + // Create methods object that allows self-reference + const methods = { + wrapText: (before: string, after = before, defaultText) => { + const range = editor.state.selection.ranges[0]; + const selectedText = editor.state.sliceDoc(range.from, range.to); + const text = selectedText || defaultText || ''; + const wrappedText = before + text + after; + const insertFrom = range.from; + const insertTo = range.to; + + editor.dispatch({ + changes: [ + { + from: insertFrom, + to: insertTo, + insert: wrappedText, + }, + ], + selection: selectedText + ? EditorSelection.cursor(insertFrom + before.length + text.length) + : EditorSelection.range( + insertFrom + before.length, + insertFrom + before.length + text.length, + ), + }); + }, + + replaceLines: (replace: Parameters['map']>[0]) => { + const { doc } = editor.state; + const lines: string[] = []; + for (let i = 1; i <= doc.lines; i += 1) { + lines.push(doc.line(i).text); + } + + const newLines = lines.map(replace) as string[]; + const newText = newLines.join('\n'); + editor.dispatch({ + changes: { + from: 0, + to: editor.state.doc.length, + insert: newText, + }, + }); + }, + + appendBlock: (content: string) => { + const { doc } = editor.state; + const currentText = doc.toString(); + const newText = currentText ? `${currentText}\n\n${content}` : content; + editor.dispatch({ + changes: { + from: 0, + to: editor.state.doc.length, + insert: newText, + }, + }); + }, + + insertBold: (text?: string) => { + methods.wrapText('**', '**', text || 'bold text'); + }, + + insertItalic: (text?: string) => { + methods.wrapText('*', '*', text || 'italic text'); + }, + + insertCode: (text?: string) => { + methods.wrapText('`', '`', text || 'code'); + }, + + insertStrikethrough: (text?: string) => { + methods.wrapText('~~', '~~', text || 'strikethrough text'); + }, + + insertHeading: (level: Level, text?: string) => { + const headingText = '#'.repeat(level); + methods.wrapText(`${headingText} `, '', text || 'heading'); + }, + + insertBlockquote: (text?: string) => { + methods.wrapText('> ', '', text || 'quote'); + }, + + insertCodeBlock: (language?: string, code?: string) => { + const lang = language || ''; + const codeText = code || ''; + const block = `\`\`\`${lang}\n${codeText}\n\`\`\``; + methods.appendBlock(block); + }, + + insertHorizontalRule: () => { + methods.appendBlock('---'); + }, + + insertOrderedList: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + if (/^\d+\.\s/.test(lineText)) { + return; + } + methods.replaceLines((lineItem) => { + if (lineItem.trim() === '') { + return lineItem; + } + return `1. ${lineItem}`; + }); + }, + + insertUnorderedList: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + if (/^[-*+]\s/.test(lineText)) { + return; + } + methods.replaceLines((lineItem) => { + if (lineItem.trim() === '') { + return lineItem; + } + return `- ${lineItem}`; + }); + }, + + toggleOrderedList: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + if (/^\d+\.\s/.test(lineText)) { + methods.replaceLines((lineItem) => { + return lineItem.replace(/^\d+\.\s/, ''); + }); + } else { + methods.insertOrderedList(); + } + }, + + toggleUnorderedList: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + if (/^[-*+]\s/.test(lineText)) { + methods.replaceLines((lineItem) => { + return lineItem.replace(/^[-*+]\s/, ''); + }); + } else { + methods.insertUnorderedList(); + } + }, + + insertLink: (url: string, text?: string) => { + const linkText = text || url; + methods.wrapText('[', `](${url})`, linkText); + }, + + insertImage: (url: string, alt?: string) => { + const altText = alt || ''; + methods.wrapText('![', `](${url})`, altText); + }, + + insertTable: (rows = 3, cols = 3) => { + const table: string[] = []; + for (let i = 0; i < rows; i += 1) { + const row: string[] = []; + for (let j = 0; j < cols; j += 1) { + row.push(i === 0 ? 'Header' : 'Cell'); + } + table.push(`| ${row.join(' | ')} |`); + if (i === 0) { + table.push(`| ${'---'.repeat(cols).split('').join(' | ')} |`); + } + } + methods.appendBlock(table.join('\n')); + }, + + indent: () => { + methods.replaceLines((line) => { + if (line.trim() === '') { + return line; + } + return ` ${line}`; + }); + }, + + outdent: () => { + methods.replaceLines((line) => { + if (line.trim() === '') { + return line; + } + return line.replace(/^ {2}/, ''); + }); + }, + + isBold: () => { + const selection = editor.getSelection(); + return /^\*\*.*\*\*$/.test(selection) || /^__.*__$/.test(selection); + }, + + isItalic: () => { + const selection = editor.getSelection(); + return /^\*.*\*$/.test(selection) || /^_.*_$/.test(selection); + }, + + isHeading: (level?: number) => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + if (level) { + return new RegExp(`^#{${level}}\\s`).test(lineText); + } + return /^#{1,6}\s/.test(lineText); + }, + + isBlockquote: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + return /^>\s/.test(lineText); + }, + + isCodeBlock: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + return /^```/.test(lineText); + }, + + isOrderedList: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + return /^\d+\.\s/.test(lineText); + }, + + isUnorderedList: () => { + const cursor = editor.getCursor(); + const line = editor.state.doc.line(cursor.line); + const lineText = line.text.trim(); + return /^[-*+]\s/.test(lineText); + }, + }; + + return methods; +} diff --git a/ui/src/components/Editor/utils/codemirror/events.ts b/ui/src/components/Editor/utils/codemirror/events.ts new file mode 100644 index 000000000..dea832c64 --- /dev/null +++ b/ui/src/components/Editor/utils/codemirror/events.ts @@ -0,0 +1,98 @@ +/* + * 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. + */ + +import { StateEffect } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +import { Editor } from '../../types'; + +/** + * Creates event methods module + * + * Provides event listener registration and removal for the editor. + * Handles various DOM events including focus, blur, drag, drop, and paste. + * + * @param editor - CodeMirror editor instance + * @returns Object containing event methods (on, off) + */ +export function createEventMethods(editor: Editor) { + return { + on: (event, callback) => { + if (event === 'change') { + const change = EditorView.updateListener.of((update) => { + if (update.docChanged) { + callback(); + } + }); + + editor.dispatch({ + effects: StateEffect.appendConfig.of(change), + }); + } + if (event === 'focus') { + editor.contentDOM.addEventListener('focus', callback); + } + if (event === 'blur') { + editor.contentDOM.addEventListener('blur', callback); + } + + if (event === 'dragenter') { + editor.contentDOM.addEventListener('dragenter', callback); + } + + if (event === 'dragover') { + editor.contentDOM.addEventListener('dragover', callback); + } + + if (event === 'drop') { + editor.contentDOM.addEventListener('drop', callback); + } + + if (event === 'paste') { + editor.contentDOM.addEventListener('paste', callback); + } + }, + + off: (event, callback) => { + if (event === 'focus') { + editor.contentDOM.removeEventListener('focus', callback); + } + + if (event === 'blur') { + editor.contentDOM.removeEventListener('blur', callback); + } + + if (event === 'dragenter') { + editor.contentDOM.removeEventListener('dragenter', callback); + } + + if (event === 'dragover') { + editor.contentDOM.removeEventListener('dragover', callback); + } + + if (event === 'drop') { + editor.contentDOM.removeEventListener('drop', callback); + } + + if (event === 'paste') { + editor.contentDOM.removeEventListener('paste', callback); + } + }, + }; +} diff --git a/ui/src/components/Editor/utils/extension.ts b/ui/src/components/Editor/utils/extension.ts deleted file mode 100644 index 4118fed46..000000000 --- a/ui/src/components/Editor/utils/extension.ts +++ /dev/null @@ -1,255 +0,0 @@ -/* - * 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. - */ - -import { EditorSelection, StateEffect } from '@codemirror/state'; -import { EditorView, keymap, KeyBinding } from '@codemirror/view'; - -import { Editor, Position } from '../types'; - -const createEditorUtils = (editor: Editor) => { - editor.focus = () => { - editor.contentDOM.focus(); - }; - - editor.getCursor = () => { - const range = editor.state.selection.ranges[0]; - const line = editor.state.doc.lineAt(range.from).number; - const { from, to } = editor.state.doc.line(line); - return { from, to, ch: range.from - from, line }; - }; - - editor.addKeyMap = (keyMap) => { - const array = Object.entries(keyMap).map(([key, value]) => { - const keyBinding: KeyBinding = { - key, - preventDefault: true, - run: value, - }; - return keyBinding; - }); - - editor.dispatch({ - effects: StateEffect.appendConfig.of(keymap.of(array)), - }); - }; - - editor.getSelection = () => { - return editor.state.sliceDoc( - editor.state.selection.main.from, - editor.state.selection.main.to, - ); - }; - - editor.replaceSelection = (value: string) => { - editor.dispatch({ - changes: [ - { - from: editor.state.selection.main.from, - to: editor.state.selection.main.to, - insert: value, - }, - ], - selection: EditorSelection.cursor( - editor.state.selection.main.from + value.length, - ), - }); - }; - - editor.setSelection = (anchor: Position, head?: Position) => { - editor.dispatch({ - selection: EditorSelection.create([ - EditorSelection.range( - editor.state.doc.line(anchor.line).from + anchor.ch, - head - ? editor.state.doc.line(head.line).from + head.ch - : editor.state.doc.line(anchor.line).from + anchor.ch, - ), - ]), - }); - }; - - editor.on = (event, callback) => { - if (event === 'change') { - const change = EditorView.updateListener.of((update) => { - if (update.docChanged) { - callback(); - } - }); - - editor.dispatch({ - effects: StateEffect.appendConfig.of(change), - }); - } - if (event === 'focus') { - editor.contentDOM.addEventListener('focus', callback); - } - if (event === 'blur') { - editor.contentDOM.addEventListener('blur', callback); - } - - if (event === 'dragenter') { - editor.contentDOM.addEventListener('dragenter', callback); - } - - if (event === 'dragover') { - editor.contentDOM.addEventListener('dragover', callback); - } - - if (event === 'drop') { - editor.contentDOM.addEventListener('drop', callback); - } - - if (event === 'paste') { - editor.contentDOM.addEventListener('paste', callback); - } - }; - - editor.off = (event, callback) => { - if (event === 'focus') { - editor.contentDOM.removeEventListener('focus', callback); - } - - if (event === 'blur') { - editor.contentDOM.removeEventListener('blur', callback); - } - - if (event === 'dragenter') { - editor.contentDOM.removeEventListener('dragenter', callback); - } - - if (event === 'dragover') { - editor.contentDOM.removeEventListener('dragover', callback); - } - - if (event === 'drop') { - editor.contentDOM.removeEventListener('drop', callback); - } - - if (event === 'paste') { - editor.contentDOM.removeEventListener('paste', callback); - } - }; - - editor.getValue = () => { - return editor.state.doc.toString(); - }; - - editor.setValue = (value: string) => { - editor.dispatch({ - changes: { from: 0, to: editor.state.doc.length, insert: value }, - }); - }; - - editor.wrapText = (before: string, after = before, defaultText) => { - const range = editor.state.selection.ranges[0]; - const selection = editor.state.sliceDoc(range.from, range.to); - const text = `${before}${selection || defaultText}${after}`; - - editor.dispatch({ - changes: [ - { - from: range.from, - to: range.to, - insert: text, - }, - ], - selection: EditorSelection.range( - range.from + before.length, - range.to + before.length, - ), - }); - }; - - editor.replaceLines = ( - replace: Parameters['map']>[0], - symbolLen = 0, - ) => { - const range = editor.state.selection.ranges[0]; - const line = editor.state.doc.lineAt(range.from).number; - const { from, to } = editor.state.doc.line(line); - const lines = editor.state.sliceDoc(from, to).split('\n'); - - const insert = lines.map(replace).join('\n'); - const selectionStart = from; - const selectionEnd = from + insert.length; - - editor.dispatch({ - changes: [ - { - from, - to, - insert, - }, - ], - selection: EditorSelection.create([ - EditorSelection.range(selectionStart + symbolLen, selectionEnd), - ]), - }); - }; - - editor.appendBlock = (content: string) => { - const range = editor.state.selection.ranges[0]; - const line = editor.state.doc.lineAt(range.from).number; - const { from, to } = editor.state.doc.line(line); - - let insert = `\n\n${content}`; - - let selection = EditorSelection.single(to, to + content.length); - if (from === to) { - insert = `${content}\n`; - selection = EditorSelection.create([ - EditorSelection.cursor(to + content.length), - ]); - } - - editor.dispatch({ - changes: [ - { - from: to, - insert, - }, - ], - selection, - }); - }; - - editor.replaceRange = ( - value: string, - selectionStart: Position, - selectionEnd: Position, - ) => { - const from = - editor.state.doc.line(selectionStart.line).from + selectionStart.ch; - const to = editor.state.doc.line(selectionEnd.line).from + selectionEnd.ch; - editor.dispatch({ - changes: [ - { - from, - to, - insert: value, - }, - ], - selection: EditorSelection.cursor(from + value.length), - }); - }; - - return editor; -}; - -export default createEditorUtils; diff --git a/ui/src/components/Editor/utils/index.ts b/ui/src/components/Editor/utils/index.ts index 61e95fbb8..eb4ad16e1 100644 --- a/ui/src/components/Editor/utils/index.ts +++ b/ui/src/components/Editor/utils/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { minimalSetup } from 'codemirror'; import { EditorState, Compartment } from '@codemirror/state'; @@ -30,7 +30,7 @@ import Tooltip from 'bootstrap/js/dist/tooltip'; import { Editor } from '../types'; import { isDarkTheme } from '@/utils/common'; -import createEditorUtils from './extension'; +import { createCodeMirrorAdapter } from './codemirror/adapter'; const editableCompartment = new Compartment(); interface htmlRenderConfig { @@ -95,10 +95,8 @@ export function htmlRender(el: HTMLElement | null, config?: htmlRenderConfig) { `; codeTool.innerHTML = str; - // Add copy button to pre tag pre.style.position = 'relative'; - // 将 codeTool 和 pre 插入到 codeWrap 中, 并且使用 codeWrap 替换 pre codeWrap.appendChild(codeTool); pre.parentNode?.replaceChild(codeWrap, pre); codeWrap.appendChild(pre); @@ -129,12 +127,14 @@ export const useEditor = ({ editorRef, placeholder: placeholderText, autoFocus, + initialValue, onChange, onFocus, onBlur, }) => { const [editor, setEditor] = useState(null); - const [value, setValue] = useState(''); + const isInternalUpdateRef = useRef(false); + const init = async () => { const isDark = isDarkTheme(); @@ -162,6 +162,7 @@ export const useEditor = ({ }); const startState = EditorState.create({ + doc: initialValue || '', extensions: [ minimalSetup, markdown({ @@ -190,7 +191,7 @@ export const useEditor = ({ state: startState, }); - const cm = createEditorUtils(view as Editor); + const cm = createCodeMirrorAdapter(view as Editor); cm.setReadOnly = (readOnly: boolean) => { cm.dispatch({ @@ -206,9 +207,20 @@ export const useEditor = ({ }, 10); } + const originalSetValue = cm.setValue; + cm.setValue = (newValue: string) => { + isInternalUpdateRef.current = true; + originalSetValue.call(cm, newValue); + setTimeout(() => { + isInternalUpdateRef.current = false; + }, 0); + }; + cm.on('change', () => { - const newValue = cm.getValue(); - setValue(newValue); + if (!isInternalUpdateRef.current && onChange) { + const newValue = cm.getValue(); + onChange(newValue); + } }); cm.on('focus', () => { @@ -224,10 +236,6 @@ export const useEditor = ({ return cm; }; - useEffect(() => { - onChange?.(value); - }, [value]); - useEffect(() => { if (!editorRef.current) { return; diff --git a/ui/src/components/Header/index.scss b/ui/src/components/Header/index.scss index f1df512d4..e9536b084 100644 --- a/ui/src/components/Header/index.scss +++ b/ui/src/components/Header/index.scss @@ -30,6 +30,11 @@ max-height: 2rem; } + .fixed-width { + padding-left: 28px; + padding-right: 36px; + } + .create-icon { color: var(--bs-nav-link-color); } @@ -120,6 +125,9 @@ @media (max-width: 1199.9px) { #header { + .fixed-width { + padding-right: 48px; + } .nav-grow { flex-grow: 1 !important; } @@ -134,8 +142,21 @@ } } +@media screen and (max-width: 991px) { + #header { + .fixed-width { + padding-left: 40px; + padding-right: 48px; + } + } +} + @media screen and (max-width: 767px) { #header { + .fixed-width { + padding-left: 1.5rem; + padding-right: 1.5rem; + } .nav-text { flex: 1; white-space: nowrap; diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index 2d151b880..22aad5aa1 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -83,7 +83,7 @@ const Header: FC = () => { let navbarStyle = 'theme-light'; let themeMode = 'light'; - const { theme, theme_config } = themeSettingStore((_) => _); + const { theme, theme_config, layout } = themeSettingStore((_) => _); if (theme_config?.[theme]?.navbar_style) { // const color = theme_config[theme].navbar_style.startsWith('#') themeMode = isLight(theme_config[theme].navbar_style) ? 'light' : 'dark'; @@ -113,7 +113,11 @@ const Header: FC = () => { backgroundColor: theme_config[theme].navbar_style, }} id="header"> -
    +
    { diff --git a/ui/src/components/Modal/Modal.tsx b/ui/src/components/Modal/Modal.tsx index cbf98c249..580d51daa 100644 --- a/ui/src/components/Modal/Modal.tsx +++ b/ui/src/components/Modal/Modal.tsx @@ -21,6 +21,8 @@ import React, { FC } from 'react'; import { Button, Modal } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; + export interface Props { id?: string; /** header title */ @@ -77,7 +79,9 @@ const Index: FC = ({ {title || t('title', { keyPrefix: 'modal_confirm' })} - {children} + + {children} + {(showCancel || showConfirm) && ( {showCancel && ( diff --git a/ui/src/components/PluginRender/index.tsx b/ui/src/components/PluginRender/index.tsx index 057f49521..b241bf05c 100644 --- a/ui/src/components/PluginRender/index.tsx +++ b/ui/src/components/PluginRender/index.tsx @@ -17,9 +17,12 @@ * under the License. */ -import React, { FC, ReactNode } from 'react'; +import React, { FC, ReactNode, useEffect, useState } from 'react'; import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit'; + +// Marker component for plugin insertion point +export const PluginSlot: FC = () => null; /** * Note:Please set at least either of the `slug_name` and `type` attributes, otherwise no plugins will be rendered. * @@ -29,13 +32,16 @@ import PluginKit, { Plugin, PluginType } from '@/utils/pluginKit'; * @field type: Used to formulate the rendering of all plugins of this type. * (if the `slug_name` attribute is set, it will be ignored) * @field prop: Any attribute you want to configure, e.g. `className` + * + * For editor type plugins, use component as a marker to indicate where plugins should be inserted. */ interface Props { slug_name?: string; type: PluginType; children?: ReactNode; - [prop: string]: any; + className?: string; + [key: string]: unknown; } const Index: FC = ({ @@ -45,29 +51,70 @@ const Index: FC = ({ className, ...props }) => { - const pluginSlice: Plugin[] = []; - const plugins = PluginKit.getPlugins().filter((plugin) => plugin.activated); + const [pluginSlice, setPluginSlice] = useState([]); + const [isLoading, setIsLoading] = useState(true); - plugins.forEach((plugin) => { - if (type && slug_name) { - if (plugin.info.slug_name === slug_name && plugin.info.type === type) { - pluginSlice.push(plugin); - } - } else if (type) { - if (plugin.info.type === type) { - pluginSlice.push(plugin); - } - } else if (slug_name) { - if (plugin.info.slug_name === slug_name) { - pluginSlice.push(plugin); + useEffect(() => { + let mounted = true; + + const loadPlugins = async () => { + await PluginKit.initialization; + + if (!mounted) return; + + const plugins = PluginKit.getPlugins().filter( + (plugin) => plugin.activated, + ); + console.log( + '[PluginRender] Loaded plugins:', + plugins.map((p) => p.info.slug_name), + ); + const filtered: Plugin[] = []; + + plugins.forEach((plugin) => { + if (type && slug_name) { + if ( + plugin.info.slug_name === slug_name && + plugin.info.type === type + ) { + filtered.push(plugin); + } + } else if (type) { + if (plugin.info.type === type) { + filtered.push(plugin); + } + } else if (slug_name) { + if (plugin.info.slug_name === slug_name) { + filtered.push(plugin); + } + } + }); + + if (mounted) { + setPluginSlice(filtered); + setIsLoading(false); } - } - }); + }; + + loadPlugins(); + + return () => { + mounted = false; + }; + }, [slug_name, type]); /** * TODO: Rendering control for non-builtin plug-ins * ps: Logic such as version compatibility determination can be placed here */ + if (isLoading) { + // Don't render anything while loading to avoid flashing + if (type === 'editor') { + return
    {children}
    ; + } + return null; + } + if (pluginSlice.length === 0) { if (type === 'editor') { return
    {children}
    ; @@ -76,20 +123,17 @@ const Index: FC = ({ } if (type === 'editor') { - // index 16 is the position of the toolbar in the editor for plugins - const nodes = React.Children.map(children, (child, index) => { - if (index === 16) { + // Use PluginSlot marker to insert plugins at the correct position + const nodes = React.Children.map(children, (child) => { + // Check if this is the PluginSlot marker + if (React.isValidElement(child) && child.type === PluginSlot) { return ( <> - {child} {pluginSlice.map((ps) => { - const PluginFC = ps.component; - return ( - // @ts-ignore - - ); + const PluginFC = ps.component as FC; + return ; })} -
    + {pluginSlice.length > 0 &&
    } ); } @@ -102,9 +146,10 @@ const Index: FC = ({ return ( <> {pluginSlice.map((ps) => { - const PluginFC = ps.component; + const PluginFC = ps.component as FC< + { className?: string } & typeof props + >; return ( - // @ts-ignore ); })} diff --git a/ui/src/components/SchemaForm/components/Input.tsx b/ui/src/components/SchemaForm/components/Input.tsx index a1838151b..2f062b616 100644 --- a/ui/src/components/SchemaForm/components/Input.tsx +++ b/ui/src/components/SchemaForm/components/Input.tsx @@ -73,12 +73,16 @@ const Index: FC = ({ } }; + // For number type, use ?? to preserve 0 value; for other types, use || for backward compatibility + const inputValue = + type === 'number' ? (fieldObject?.value ?? '') : fieldObject?.value || ''; + return ( = ({ onChange(state); } }; + return ( = ({ checked={fieldObject?.value || ''} feedback={fieldObject?.errorMsg} feedbackType="invalid" - isInvalid={fieldObject.isInvalid} + isInvalid={fieldObject?.isInvalid} disabled={readOnly} onChange={handleChange} /> diff --git a/ui/src/components/SchemaForm/index.tsx b/ui/src/components/SchemaForm/index.tsx index 171b5f675..01f506813 100644 --- a/ui/src/components/SchemaForm/index.tsx +++ b/ui/src/components/SchemaForm/index.tsx @@ -290,7 +290,7 @@ const SchemaForm: ForwardRefRenderFunction = ( controlId={key} className={classnames( groupClassName, - formData[key].hidden ? 'd-none' : null, + formData[key]?.hidden ? 'd-none' : null, )}> {/* Uniform processing `label` */} {title && !uiSimplify ? {title} : null} @@ -437,12 +437,12 @@ const SchemaForm: ForwardRefRenderFunction = ( /> ) : null} {/* Unified handling of `Feedback` and `Text` */} - - {fieldState?.errorMsg} - {description && widget !== 'tag_selector' ? ( ) : null} + + {fieldState?.errorMsg} + ); })} diff --git a/ui/src/components/SchemaForm/types.ts b/ui/src/components/SchemaForm/types.ts index 55cf8e99d..25e5c56c3 100644 --- a/ui/src/components/SchemaForm/types.ts +++ b/ui/src/components/SchemaForm/types.ts @@ -44,7 +44,7 @@ export interface JSONSchema { required?: string[]; properties: { [key: string]: { - type?: 'string' | 'boolean' | 'number'; + type?: 'string' | 'boolean' | 'number' | Type.Tag[]; title: string; description?: string; enum?: Array; diff --git a/ui/src/components/Sender/index.scss b/ui/src/components/Sender/index.scss new file mode 100644 index 000000000..32c12faa9 --- /dev/null +++ b/ui/src/components/Sender/index.scss @@ -0,0 +1,38 @@ +/* + * 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. + */ + +.sender-wrap { + z-index: 10; + margin-top: auto; + background-color: var(--bs-body-bg); + .input { + resize: none; + overflow-y: auto; + scrollbar-width: thin; + } + .input:focus { + box-shadow: none !important; + border-width: 0 !important; + } + + .form-control-focus { + box-shadow: 0 0 0 0.25rem rgba(0, 51, 255, 0.25) !important; + border-color: rgb(128, 153, 255) !important; + } +} diff --git a/ui/src/components/Sender/index.tsx b/ui/src/components/Sender/index.tsx new file mode 100644 index 000000000..e79cf9b2e --- /dev/null +++ b/ui/src/components/Sender/index.tsx @@ -0,0 +1,172 @@ +/* + * 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. + */ + +import { useEffect, useState, useRef, FC } from 'react'; +import { Form, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import classnames from 'classnames'; + +import { Icon } from '@/components'; + +import './index.scss'; + +interface IProps { + onSubmit?: (value: string) => void; + onCancel?: () => void; + isGenerate: boolean; + hasConversation: boolean; +} + +const Sender: FC = ({ + onSubmit, + onCancel, + isGenerate, + hasConversation, +}) => { + const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + const containerRef = useRef(null); + const textareaRef = useRef(null); + const [initialized, setInitialized] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [isFocus, setIsFocus] = useState(false); + + const handleFocus = () => { + setIsFocus(true); + textareaRef?.current?.focus(); + }; + + const handleBlur = () => { + setIsFocus(false); + }; + + const autoResize = () => { + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.style.height = '32px'; + + const minHeight = 32; // minimum height + const maxHeight = 96; // maximum height + + // calculate the height needed + const { scrollHeight } = textarea; + const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); + + // set the new height + textarea.style.height = `${newHeight}px`; + + // control the scrollbar display + if (scrollHeight > maxHeight) { + textarea.style.overflowY = 'auto'; + } else { + textarea.style.overflowY = 'hidden'; + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + setTimeout(autoResize, 0); + }; + + const handleSubmit = () => { + if (isGenerate || !inputValue.trim()) { + return; + } + onSubmit?.(inputValue); + setInputValue(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); // Prevent default behavior of Enter key + handleSubmit(); + } else if (e.key === 'Escape') { + setInputValue((prev) => `${prev}\n`); // Add a new line on Escape key + } + }; + + useEffect(() => { + setInitialized(true); + }, []); + + useEffect(() => { + const handleOutsideClick = (event) => { + if ( + initialized && + containerRef.current && + !containerRef.current?.contains(event.target) + ) { + handleBlur(); + } + }; + document.addEventListener('click', handleOutsideClick); + return () => { + document.removeEventListener('click', handleOutsideClick); + }; + }, [initialized]); + return ( +
    +
    + +
    + {isGenerate ? ( + + ) : ( + + )} +
    +
    + + {t('ai_generate')} +
    + ); +}; + +export default Sender; diff --git a/ui/src/components/SideNav/index.tsx b/ui/src/components/SideNav/index.tsx index 294389a0b..54d2f6a47 100644 --- a/ui/src/components/SideNav/index.tsx +++ b/ui/src/components/SideNav/index.tsx @@ -22,7 +22,7 @@ import { Nav } from 'react-bootstrap'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { loggedUserInfoStore, sideNavStore } from '@/stores'; +import { loggedUserInfoStore, sideNavStore, aiControlStore } from '@/stores'; import { Icon, PluginRender } from '@/components'; import { PluginType } from '@/utils/pluginKit'; import request from '@/utils/request'; @@ -34,6 +34,7 @@ const Index: FC = () => { const { pathname } = useLocation(); const { user: userInfo } = loggedUserInfoStore(); const { can_revision, revision } = sideNavStore(); + const { ai_enabled } = aiControlStore(); const navigate = useNavigate(); return ( @@ -47,6 +48,17 @@ const Index: FC = () => { {t('header.nav.question')} + {ai_enabled && ( + + pathname === '/ai-assistant' ? 'nav-link active' : 'nav-link' + }> + + {t('ai_assistant', { keyPrefix: 'page_title' })} + + )} + diff --git a/ui/src/components/TabNav/index.tsx b/ui/src/components/TabNav/index.tsx new file mode 100644 index 000000000..6b1b434df --- /dev/null +++ b/ui/src/components/TabNav/index.tsx @@ -0,0 +1,45 @@ +/* + * 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. + */ + +import { FC } from 'react'; +import { Nav } from 'react-bootstrap'; +import { NavLink, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const TabNav: FC<{ menus: { name: string; path: string }[] }> = ({ menus }) => { + const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); + const { pathname } = useLocation(); + return ( + + ); +}; + +export default TabNav; diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 68e863d2f..5c81739bb 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -64,6 +64,10 @@ import CardBadge from './CardBadge'; import PinList from './PinList'; import MobileSideNav from './MobileSideNav'; import AdminSideNav from './AdminSideNav'; +import BubbleAi from './BubbleAi'; +import BubbleUser from './BubbleUser'; +import Sender from './Sender'; +import TabNav from './TabNav'; export { Avatar, @@ -115,5 +119,9 @@ export { PinList, MobileSideNav, AdminSideNav, + BubbleAi, + BubbleUser, + Sender, + TabNav, }; export type { EditorRef, JSONSchema, UISchema }; diff --git a/ui/src/pages/403/index.tsx b/ui/src/pages/404/403/index.tsx similarity index 100% rename from ui/src/pages/403/index.tsx rename to ui/src/pages/404/403/index.tsx diff --git a/ui/src/pages/Admin/AiAssistant/components/Action/index.tsx b/ui/src/pages/Admin/AiAssistant/components/Action/index.tsx new file mode 100644 index 000000000..75b8661a3 --- /dev/null +++ b/ui/src/pages/Admin/AiAssistant/components/Action/index.tsx @@ -0,0 +1,72 @@ +/* + * 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. + */ + +import { Dropdown } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { Modal, Icon } from '@/components'; +import { deleteAdminConversation } from '@/services'; +import { useToast } from '@/hooks'; + +interface Props { + id: string; + refreshList?: () => void; +} +const ConversationsOperation = ({ id, refreshList }: Props) => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.conversations', + }); + const toast = useToast(); + + const handleAction = (eventKey: string | null) => { + if (eventKey === 'delete') { + Modal.confirm({ + title: t('delete_modal.title'), + content: t('delete_modal.content'), + cancelBtnVariant: 'link', + confirmBtnVariant: 'danger', + confirmText: t('delete', { keyPrefix: 'btns' }), + onConfirm: () => { + deleteAdminConversation(id).then(() => { + refreshList?.(); + toast.onShow({ + variant: 'success', + msg: t('delete_modal.delete_success'), + }); + }); + }, + }); + } + }; + + return ( + + + + + + + {t('delete', { keyPrefix: 'btns' })} + + + + ); +}; + +export default ConversationsOperation; diff --git a/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx b/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx new file mode 100644 index 000000000..ec4bd7d79 --- /dev/null +++ b/ui/src/pages/Admin/AiAssistant/components/DetailModal/index.tsx @@ -0,0 +1,86 @@ +/* + * 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. + */ + +import { FC, memo } from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { BubbleAi, BubbleUser } from '@/components'; +import { useQueryAdminConversationDetail } from '@/services'; + +interface IProps { + visible: boolean; + id: string; + onClose?: () => void; +} + +const Index: FC = ({ visible, id, onClose }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.conversations', + }); + + const { data: conversationDetail } = useQueryAdminConversationDetail(id); + + const handleClose = () => { + onClose?.(); + }; + return ( + + +
    + {conversationDetail?.topic} +
    +
    + + {conversationDetail?.records.map((item, index) => { + const isLastMessage = + index === Number(conversationDetail?.records.length) - 1; + return ( +
    + {item.role === 'user' ? ( + + ) : ( + + )} +
    + ); + })} +
    + + + +
    + ); +}; + +export default memo(Index); diff --git a/ui/src/pages/Admin/AiAssistant/index.tsx b/ui/src/pages/Admin/AiAssistant/index.tsx new file mode 100644 index 000000000..7fc3e9d9b --- /dev/null +++ b/ui/src/pages/Admin/AiAssistant/index.tsx @@ -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. + */ + +import { useState } from 'react'; +import { Table, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +import { BaseUserCard, FormatTime, Pagination, Empty } from '@/components'; +import { useQueryAdminConversationList } from '@/services'; + +import DetailModal from './components/DetailModal'; +import Action from './components/Action'; + +const Index = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.conversations', + }); + const [urlSearchParams] = useSearchParams(); + const curPage = Number(urlSearchParams.get('page') || '1'); + const PAGE_SIZE = 20; + const [detailModal, setDetailModal] = useState({ + visible: false, + id: '', + }); + const { + data: conversations, + isLoading, + mutate: refreshList, + } = useQueryAdminConversationList({ + page: curPage, + page_size: PAGE_SIZE, + }); + + const handleShowDetailModal = (data) => { + setDetailModal({ + visible: true, + id: data.id, + }); + }; + + const handleHideDetailModal = () => { + setDetailModal({ + visible: false, + id: '', + }); + }; + + return ( +
    +

    {t('ai_assistant', { keyPrefix: 'nav_menus' })}

    +
+ + + + + + + + + + + {conversations?.list.map((item) => { + return ( + + + + + + + + ); + })} + +
{t('topic')}{t('helpful')}{t('unhelpful')}{t('created')} + {t('action')} +
+ + {item.helpful_count}{item.unhelpful_count} +
+ + +
+
+ +
+ {!isLoading && Number(conversations?.count) <= 0 && ( + {t('empty')} + )} + +
+ +
+ +
+ ); +}; +export default Index; diff --git a/ui/src/pages/Admin/AiSettings/index.tsx b/ui/src/pages/Admin/AiSettings/index.tsx new file mode 100644 index 000000000..2270aa5c5 --- /dev/null +++ b/ui/src/pages/Admin/AiSettings/index.tsx @@ -0,0 +1,486 @@ +/* + * 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. + */ + +import { useEffect, useState, useRef } from 'react'; +import { Form, InputGroup, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { + getAiConfig, + useQueryAiProvider, + checkAiConfig, + saveAiConfig, +} from '@/services'; +import { aiControlStore } from '@/stores'; +import { handleFormError } from '@/utils'; +import { useToast } from '@/hooks'; +import * as Type from '@/common/interface'; + +const Index = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.ai_settings', + }); + const toast = useToast(); + const historyConfigRef = useRef(); + // const [historyConfig, setHistoryConfig] = useState(); + const { data: aiProviders } = useQueryAiProvider(); + + const [formData, setFormData] = useState({ + enabled: { + value: false, + isInvalid: false, + errorMsg: '', + }, + provider: { + value: '', + isInvalid: false, + errorMsg: '', + }, + api_host: { + value: '', + isInvalid: false, + errorMsg: '', + }, + api_key: { + value: '', + isInvalid: false, + isValid: false, + errorMsg: '', + }, + model: { + value: '', + isInvalid: false, + errorMsg: '', + }, + }); + const [apiHostPlaceholder, setApiHostPlaceholder] = useState(''); + const [modelsData, setModels] = useState<{ id: string }[]>([]); + const [isChecking, setIsChecking] = useState(false); + + const getCurrentProviderData = (provider) => { + const findHistoryProvider = + historyConfigRef.current?.ai_providers.find( + (v) => v.provider === provider, + ) || historyConfigRef.current?.ai_providers[0]; + + return findHistoryProvider; + }; + + const checkAiConfigData = (data) => { + const params = data || { + api_host: formData.api_host.value || apiHostPlaceholder, + api_key: formData.api_key.value, + }; + setIsChecking(true); + + checkAiConfig(params) + .then((res) => { + setModels(res); + const findHistoryProvider = getCurrentProviderData( + formData.provider.value, + ); + + setIsChecking(false); + + if (!data) { + setFormData({ + ...formData, + api_key: { + ...formData.api_key, + errorMsg: t('api_key.check_success'), + isInvalid: false, + isValid: true, + }, + model: { + value: findHistoryProvider?.model || res[0].id, + errorMsg: '', + isInvalid: false, + }, + }); + } + }) + .catch((err) => { + console.error('Checking AI config:', err); + setIsChecking(false); + }); + }; + + const handleProviderChange = (value) => { + const findHistoryProvider = getCurrentProviderData(value); + setFormData({ + ...formData, + provider: { + value, + isInvalid: false, + errorMsg: '', + }, + api_host: { + value: findHistoryProvider?.api_host || '', + isInvalid: false, + errorMsg: '', + }, + api_key: { + value: findHistoryProvider?.api_key || '', + isInvalid: false, + isValid: false, + errorMsg: '', + }, + model: { + value: findHistoryProvider?.model || '', + isInvalid: false, + errorMsg: '', + }, + }); + const provider = aiProviders?.find((item) => item.name === value); + const host = findHistoryProvider?.api_host || provider?.default_api_host; + if (findHistoryProvider?.model) { + checkAiConfigData({ + api_host: host, + api_key: findHistoryProvider.api_key, + }); + } else { + setModels([]); + } + }; + + const handleValueChange = (value) => { + setFormData((prev) => ({ + ...prev, + ...value, + })); + }; + + const checkValidate = () => { + let bol = true; + + const { api_host, api_key, model } = formData; + + if (!api_host.value) { + bol = false; + formData.api_host = { + value: '', + isInvalid: true, + errorMsg: t('api_host.msg'), + }; + } + + if (!api_key.value) { + bol = false; + formData.api_key = { + value: '', + isInvalid: true, + isValid: false, + errorMsg: t('api_key.msg'), + }; + } + + if (!model.value) { + bol = false; + formData.model = { + value: '', + isInvalid: true, + errorMsg: t('model.msg'), + }; + } + + setFormData({ + ...formData, + }); + + return bol; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + if (!checkValidate()) { + return; + } + const newProviders = historyConfigRef.current?.ai_providers.map((v) => { + if (v.provider === formData.provider.value) { + return { + provider: formData.provider.value, + api_host: formData.api_host.value, + api_key: formData.api_key.value, + model: formData.model.value, + }; + } + return v; + }); + + const params = { + enabled: formData.enabled.value, + chosen_provider: formData.provider.value, + ai_providers: newProviders, + }; + saveAiConfig(params) + .then(() => { + aiControlStore.getState().update({ + ai_enabled: formData.enabled.value, + }); + + historyConfigRef.current = { + ...params, + ai_providers: params.ai_providers || [], + }; + + toast.onShow({ + msg: t('add_success'), + variant: 'success', + }); + }) + .catch((err) => { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + ele?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }; + + const getAiConfigData = async () => { + const aiConfig = await getAiConfig(); + historyConfigRef.current = aiConfig; + + const currentAiConfig = getCurrentProviderData(aiConfig.chosen_provider); + if (currentAiConfig?.model) { + const provider = aiProviders?.find( + (item) => item.name === formData.provider.value, + ); + const host = currentAiConfig.api_host || provider?.default_api_host; + checkAiConfigData({ + api_host: host, + api_key: currentAiConfig.api_key, + }); + } + + setFormData({ + enabled: { + value: aiConfig.enabled || false, + isInvalid: false, + errorMsg: '', + }, + provider: { + value: currentAiConfig?.provider || '', + isInvalid: false, + errorMsg: '', + }, + api_host: { + value: currentAiConfig?.api_host || '', + isInvalid: false, + errorMsg: '', + }, + api_key: { + value: currentAiConfig?.api_key || '', + isInvalid: false, + isValid: false, + errorMsg: '', + }, + model: { + value: currentAiConfig?.model || '', + isInvalid: false, + errorMsg: '', + }, + }); + }; + + useEffect(() => { + getAiConfigData(); + }, []); + + useEffect(() => { + if (formData.provider.value) { + const provider = aiProviders?.find( + (item) => item.name === formData.provider.value, + ); + if (provider) { + setApiHostPlaceholder(provider.default_api_host || ''); + } + } + if (!formData.provider.value && aiProviders) { + setFormData((prev) => ({ + ...prev, + provider: { + ...prev.provider, + value: aiProviders[0].name, + }, + })); + } + }, [aiProviders, formData]); + + return ( +
+

{t('ai_settings', { keyPrefix: 'nav_menus' })}

+
+
+ + {t('enabled.label')} + + handleValueChange({ + enabled: { + value: e.target.checked, + errorMsg: '', + isInvalid: false, + }, + }) + } + /> + {t('enabled.text')} + + {formData.enabled.errorMsg} + + + + + {t('provider.label')} + handleProviderChange(e.target.value)}> + {aiProviders?.map((provider) => ( + + ))} + + + {formData.provider.errorMsg} + + + + + {t('api_host.label')} + + handleValueChange({ + api_host: { + value: e.target.value, + errorMsg: '', + isInvalid: false, + }, + }) + } + /> + + {formData.api_host.errorMsg} + + + + + {t('api_key.label')} + + + handleValueChange({ + api_key: { + value: e.target.value, + errorMsg: '', + isInvalid: false, + isValid: false, + }, + }) + } + /> + + + {formData.api_key.errorMsg} + + + + +
+ + {/* + handleValueChange({ + model: { + value: e.target.value, + errorMsg: '', + isInvalid: false, + }, + }) + }> + {modelsData?.map((model) => { + return ( + + ); + })} + */} + + handleValueChange({ + model: { + value: e.target.value, + errorMsg: '', + isInvalid: false, + }, + }) + } + /> + + {modelsData?.map((model) => { + return ( + + ); + })} + + +
{formData.model.errorMsg}
+
+ + +
+
+
+ ); +}; +export default Index; diff --git a/ui/src/pages/Admin/Answers/index.tsx b/ui/src/pages/Admin/Answers/index.tsx index 07190024f..9909739f5 100644 --- a/ui/src/pages/Admin/Answers/index.tsx +++ b/ui/src/pages/Admin/Answers/index.tsx @@ -32,8 +32,9 @@ import { Empty, QueryGroup, Modal, + TabNav, } from '@/components'; -import { ADMIN_LIST_STATUS } from '@/common/constants'; +import { ADMIN_LIST_STATUS, ADMIN_QA_NAV_MENUS } from '@/common/constants'; import * as Type from '@/common/interface'; import { deletePermanently, useAnswerSearch } from '@/services'; import { escapeRemove } from '@/utils'; @@ -96,7 +97,10 @@ const Answers: FC = () => { }; return ( <> -

{t('page_title')}

+

+ {t('page_title', { keyPrefix: 'admin.questions' })} +

+
{ + const { t } = useTranslation('translation', { + keyPrefix: 'admin.apikeys.delete_modal', + }); + + const handleAction = (type) => { + if (type === 'delete') { + Modal.confirm({ + title: t('title'), + content: t('content'), + cancelBtnVariant: 'link', + confirmBtnVariant: 'danger', + confirmText: t('delete', { keyPrefix: 'btns' }), + onConfirm: () => { + deleteApiKey(itemData.id).then(() => { + toastStore.getState().show({ + msg: t('api_key_deleted', { keyPrefix: 'messages' }), + variant: 'success', + }); + refreshList(); + }); + }, + }); + } + + if (type === 'edit') { + showModal(true); + } + }; + + return ( + + + + + + handleAction('edit')}> + {t('edit', { keyPrefix: 'btns' })} + + handleAction('delete')}> + {t('delete', { keyPrefix: 'btns' })} + + + + ); +}; + +export default ApiActions; diff --git a/ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx b/ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx new file mode 100644 index 000000000..044a30173 --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/components/AddOrEditModal/index.tsx @@ -0,0 +1,184 @@ +/* + * 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. + */ + +import { useState } from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { handleFormError } from '@/utils'; +import { addApiKey, updateApiKey } from '@/services'; + +const initFormData = { + description: { + value: '', + isInvalid: false, + errorMsg: '', + }, + scope: { + value: 'read-only', + isInvalid: false, + errorMsg: '', + }, +}; + +const Index = ({ data, visible = false, onClose, callback }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.apikeys.add_or_edit_modal', + }); + const [formData, setFormData] = useState(initFormData); + + const handleValueChange = (value) => { + setFormData({ + ...formData, + ...value, + }); + }; + + const handleAdd = () => { + const { description, scope } = formData; + if (!description.value) { + setFormData({ + ...formData, + description: { + ...description, + isInvalid: true, + errorMsg: t('description_required'), + }, + }); + return; + } + addApiKey({ + description: description.value, + scope: scope.value, + }) + .then((res) => { + callback('add', res.access_key); + setFormData(initFormData); + }) + .catch((error) => { + const obj = handleFormError(error, formData); + setFormData({ ...obj }); + }); + }; + + const handleEdit = () => { + const { description } = formData; + if (!description.value) { + setFormData({ + ...formData, + description: { + ...description, + isInvalid: true, + errorMsg: t('description_required'), + }, + }); + return; + } + updateApiKey({ + description: description.value, + id: data?.id, + }) + .then(() => { + callback('edit', null); + setFormData(initFormData); + }) + .catch((error) => { + const obj = handleFormError(error, formData); + setFormData({ ...obj }); + }); + }; + + const handleSubmit = () => { + if (data?.id) { + handleEdit(); + return; + } + handleAdd(); + }; + + const closeModal = () => { + setFormData(initFormData); + onClose(false, null); + }; + return ( + + + {data?.id ? t('edit_title') : t('add_title')} + + +
+ + {t('description')} + { + handleValueChange({ + description: { + value: e.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + + {formData.description.errorMsg} + + + + {!data?.id && visible && ( + + {t('scope')} + { + handleValueChange({ + scope: { + value: e.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }}> + + + + + {formData.scope.errorMsg} + + + )} +
+
+ + + + +
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx b/ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx new file mode 100644 index 000000000..83f782a10 --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/components/CreatedModal/index.tsx @@ -0,0 +1,55 @@ +/* + * 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. + */ + +import { Modal, Form, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +const Index = ({ visible, api_key = '', onClose }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.apikeys.created_modal', + }); + + return ( + + {t('title')} + +
+ + {t('api_key')} + + + +
{t('description')}
+
+
+ + + +
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Admin/Apikeys/components/index.ts b/ui/src/pages/Admin/Apikeys/components/index.ts new file mode 100644 index 000000000..46bda3f5d --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/components/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +import Action from './Action'; +import AddOrEditModal from './AddOrEditModal'; +import CreatedModal from './CreatedModal'; + +export { Action, AddOrEditModal, CreatedModal }; diff --git a/ui/src/pages/Admin/Apikeys/index.tsx b/ui/src/pages/Admin/Apikeys/index.tsx new file mode 100644 index 000000000..a143cfa8f --- /dev/null +++ b/ui/src/pages/Admin/Apikeys/index.tsx @@ -0,0 +1,138 @@ +/* + * 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. + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Table } from 'react-bootstrap'; + +import dayjs from 'dayjs'; + +import { useQueryApiKeys } from '@/services'; + +import { Action, AddOrEditModal, CreatedModal } from './components'; + +const Index = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.apikeys', + }); + const [showModal, setShowModal] = useState({ + visible: false, + item: null, + }); + const [showCreatedModal, setShowCreatedModal] = useState({ + visible: false, + api_key: '', + }); + const { data: apiKeysList, mutate: refreshList } = useQueryApiKeys(); + + const handleAddModalState = (bol, item) => { + setShowModal({ + visible: bol, + item, + }); + }; + + const handleCreatedModalState = (visible, api_key) => { + setShowCreatedModal({ + visible, + api_key, + }); + }; + + const addOrEditCallback = (type, key) => { + handleAddModalState(false, null); + refreshList(); + if (type === 'add') { + handleCreatedModalState(true, key); + } + }; + + return ( +
+

{t('title')}

+ + + + + + + + + + + + {apiKeysList?.map((item) => { + return ( + + + + + + + + + ); + })} + +
{t('desc')}{t('scope')}{t('key')}{t('created')}{t('last_used')} + {t('action', { keyPrefix: 'admin.questions' })} +
{item.description} + {t(item.scope, { + keyPrefix: 'admin.apikeys.add_or_edit_modal', + })} + {item.access_key} + {dayjs + .unix(item?.created_at) + .tz() + .format(t('long_date_with_time', { keyPrefix: 'dates' }))} + + {item?.last_used_at && + dayjs + .unix(item?.last_used_at) + .tz() + .format(t('long_date_with_time', { keyPrefix: 'dates' }))} + + handleAddModalState(true, item)} + refreshList={refreshList} + /> +
+ + + handleCreatedModalState(false, '')} + /> +
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Admin/Branding/index.tsx b/ui/src/pages/Admin/Branding/index.tsx index bec7fc0f3..ce2417cb6 100644 --- a/ui/src/pages/Admin/Branding/index.tsx +++ b/ui/src/pages/Admin/Branding/index.tsx @@ -169,13 +169,15 @@ const Index: FC = () => { return (

{t('page_title')}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/CssAndHtml/index.tsx b/ui/src/pages/Admin/CssAndHtml/index.tsx index 422659afc..557ffa3c6 100644 --- a/ui/src/pages/Admin/CssAndHtml/index.tsx +++ b/ui/src/pages/Admin/CssAndHtml/index.tsx @@ -151,13 +151,15 @@ const Index: FC = () => { return ( <>

{t('customize', { keyPrefix: 'nav_menus' })}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx index 137fcd2e1..9a79edd92 100644 --- a/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx +++ b/ui/src/pages/Admin/Dashboard/components/HealthStatus/index.tsx @@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import type * as Type from '@/common/interface'; -import { siteInfoStore } from '@/stores'; +import { siteSecurityStore } from '@/stores'; const { gt, gte } = require('semver'); @@ -34,7 +34,7 @@ interface IProps { const HealthStatus: FC = ({ data }) => { const { t } = useTranslation('translation', { keyPrefix: 'admin.dashboard' }); const { version, remote_version } = data.version_info || {}; - const { siteInfo } = siteInfoStore(); + const { check_update } = siteSecurityStore.getState(); let isLatest = false; let hasNewerVersion = false; const downloadUrl = `https://answer.apache.org/download?from_version=${version}`; @@ -68,7 +68,7 @@ const HealthStatus: FC = ({ data }) => { {t('update_to')} {remote_version} )} - {!isLatest && !remote_version && siteInfo.check_update && ( + {!isLatest && !remote_version && check_update && ( { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.write', + }); + const Toast = useToast(); + + const [formData, setFormData] = useState(initFormData); + + const handleValueChange = (value) => { + setFormData({ + ...formData, + ...value, + }); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + + const reqParams: Type.AdminSettingsWrite = { + max_image_size: Number(formData.max_image_size.value), + max_attachment_size: Number(formData.max_attachment_size.value), + max_image_megapixel: Number(formData.max_image_megapixel.value), + authorized_image_extensions: + formData.authorized_image_extensions.value?.length > 0 + ? formData.authorized_image_extensions.value + .split(',') + ?.map((item) => item.trim().toLowerCase()) + : [], + authorized_attachment_extensions: + formData.authorized_attachment_extensions.value?.length > 0 + ? formData.authorized_attachment_extensions.value + .split(',') + ?.map((item) => item.trim().toLowerCase()) + : [], + }; + updateAdminFilesSetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + writeSettingStore.getState().update({ ...reqParams }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + const initData = () => { + getAdminFilesSetting().then((res) => { + formData.max_image_size.value = res.max_image_size; + formData.max_attachment_size.value = res.max_attachment_size; + formData.max_image_megapixel.value = res.max_image_megapixel; + formData.authorized_image_extensions.value = + res.authorized_image_extensions?.join(', ').toLowerCase(); + formData.authorized_attachment_extensions.value = + res.authorized_attachment_extensions?.join(', ').toLowerCase(); + setFormData({ ...formData }); + }); + }; + + useEffect(() => { + initData(); + }, []); + + return ( + <> +

{t('page_title')}

+
+
+ + {t('image_size.label')} + { + handleValueChange({ + max_image_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('image_size.text')} + + {formData.max_image_size.errorMsg} + + + + + {t('attachment_size.label')} + { + handleValueChange({ + max_attachment_size: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('attachment_size.text')} + + {formData.max_attachment_size.errorMsg} + + + + + {t('image_megapixels.label')} + { + handleValueChange({ + max_image_megapixel: { + value: evt.target.value, + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('image_megapixels.text')} + + {formData.max_image_megapixel.errorMsg} + + + + + {t('image_extensions.label')} + { + handleValueChange({ + authorized_image_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('image_extensions.text')} + + {formData.authorized_image_extensions.errorMsg} + + + + + {t('attachment_extensions.label')} + { + handleValueChange({ + authorized_attachment_extensions: { + value: evt.target.value.toLowerCase(), + errorMsg: '', + isInvalid: false, + }, + }); + }} + /> + {t('attachment_extensions.text')} + + {formData.authorized_attachment_extensions.errorMsg} + + + + + + +
+
+ + ); +}; + +export default Index; diff --git a/ui/src/pages/Admin/General/index.tsx b/ui/src/pages/Admin/General/index.tsx index ce2cdeb59..57602f453 100644 --- a/ui/src/pages/Admin/General/index.tsx +++ b/ui/src/pages/Admin/General/index.tsx @@ -70,11 +70,6 @@ const General: FC = () => { title: t('contact_email.label'), description: t('contact_email.text'), }, - check_update: { - type: 'boolean', - title: t('check_update.label'), - default: true, - }, }, }; const uiSchema: UISchema = { @@ -114,12 +109,6 @@ const General: FC = () => { }, }, }, - check_update: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('check_update.text'), - }, - }, }; const [formData, setFormData] = useState( initFormData(schema), @@ -134,7 +123,6 @@ const General: FC = () => { short_description: formData.short_description.value, site_url: formData.site_url.value, contact_email: formData.contact_email.value, - check_update: formData.check_update.value, }; updateGeneralSetting(reqParams) @@ -149,7 +137,6 @@ const General: FC = () => { formData.short_description.value = res.short_description; formData.site_url.value = res.site_url; formData.contact_email.value = res.contact_email; - formData.check_update.value = res.check_update; } setFormData({ ...formData }); @@ -183,13 +170,15 @@ const General: FC = () => { return ( <>

{t('page_title')}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/Interface/index.tsx b/ui/src/pages/Admin/Interface/index.tsx index 865029e9c..49e5175d0 100644 --- a/ui/src/pages/Admin/Interface/index.tsx +++ b/ui/src/pages/Admin/Interface/index.tsx @@ -28,7 +28,7 @@ import { } from '@/common/interface'; import { interfaceStore, loggedUserInfoStore } from '@/stores'; import { JSONSchema, SchemaForm, UISchema } from '@/components'; -import { DEFAULT_TIMEZONE, SYSTEM_AVATAR_OPTIONS } from '@/common/constants'; +import { DEFAULT_TIMEZONE } from '@/common/constants'; import { updateInterfaceSetting, useInterfaceSetting, @@ -68,20 +68,6 @@ const Interface: FC = () => { description: t('time_zone.text'), default: setting?.time_zone || DEFAULT_TIMEZONE, }, - default_avatar: { - type: 'string', - title: t('avatar.label'), - description: t('avatar.text'), - enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value), - enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label), - default: setting?.default_avatar || 'system', - }, - gravatar_base_url: { - type: 'string', - title: t('gravatar_base_url.label'), - description: t('gravatar_base_url.text'), - default: setting?.gravatar_base_url || '', - }, }, }; @@ -96,16 +82,6 @@ const Interface: FC = () => { isInvalid: false, errorMsg: '', }, - default_avatar: { - value: setting?.default_avatar || 'system', - isInvalid: false, - errorMsg: '', - }, - gravatar_base_url: { - value: setting?.gravatar_base_url || '', - isInvalid: false, - errorMsg: '', - }, }); const uiSchema: UISchema = { @@ -115,15 +91,6 @@ const Interface: FC = () => { time_zone: { 'ui:widget': 'timezone', }, - default_avatar: { - 'ui:widget': 'select', - }, - gravatar_base_url: { - 'ui:widget': 'input', - 'ui:options': { - placeholder: 'https://www.gravatar.com/avatar/', - }, - }, }; const getLangs = async () => { const res: LangsType[] = await loadLanguageOptions(true); @@ -156,8 +123,6 @@ const Interface: FC = () => { const reqParams: AdminSettingsInterface = { language: formData.language.value, time_zone: formData.time_zone.value, - default_avatar: formData.default_avatar.value, - gravatar_base_url: formData.gravatar_base_url.value, }; updateInterfaceSetting(reqParams) @@ -185,20 +150,18 @@ const Interface: FC = () => { useEffect(() => { if (setting) { - const formMeta = {}; - Object.keys(setting).forEach((k) => { - let v = setting[k]; - if (k === 'default_avatar' && !v) { - v = 'system'; - } - if (k === 'gravatar_base_url' && !v) { - v = ''; - } - formMeta[k] = { ...formData[k], value: v }; - }); + const formMeta = { ...formData }; + if (setting.language) { + formMeta.language.value = setting.language; + } else { + formMeta.language.value = storeInterface.language || langs?.[0]?.value; + } + if (setting.time_zone) { + formMeta.time_zone.value = setting.time_zone; + } setFormData({ ...formData, ...formMeta }); } - }, [setting]); + }, [setting, langs]); useEffect(() => { getLangs(); }, []); @@ -209,13 +172,15 @@ const Interface: FC = () => { return ( <>

{t('page_title')}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/Login/index.tsx b/ui/src/pages/Admin/Login/index.tsx index c43cfcffb..a4a61152f 100644 --- a/ui/src/pages/Admin/Login/index.tsx +++ b/ui/src/pages/Admin/Login/index.tsx @@ -58,12 +58,6 @@ const Index: FC = () => { title: t('allowed_email_domains.title'), description: t('allowed_email_domains.text'), }, - login_required: { - type: 'boolean', - title: t('private.title'), - description: t('private.text'), - default: false, - }, }, }; const uiSchema: UISchema = { @@ -88,12 +82,6 @@ const Index: FC = () => { allow_email_domains: { 'ui:widget': 'textarea', }, - login_required: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('private.label'), - }, - }, }; const [formData, setFormData] = useState(initFormData(schema)); const { update: updateLoginSetting } = loginSettingStore((_) => _); @@ -116,7 +104,6 @@ const Index: FC = () => { allow_new_registrations: formData.allow_new_registrations.value, allow_email_registrations: formData.allow_email_registrations.value, allow_email_domains: allowedEmailDomains, - login_required: formData.login_required.value, allow_password_login: formData.allow_password_login.value, }; @@ -151,7 +138,6 @@ const Index: FC = () => { formMeta.allow_email_domains.value = setting.allow_email_domains.join('\n'); } - formMeta.login_required.value = setting.login_required; formMeta.allow_password_login.value = setting.allow_password_login; setFormData({ ...formMeta }); } @@ -165,13 +151,15 @@ const Index: FC = () => { return ( <>

{t('page_title')}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/Mcp/index.tsx b/ui/src/pages/Admin/Mcp/index.tsx new file mode 100644 index 000000000..39d5375d1 --- /dev/null +++ b/ui/src/pages/Admin/Mcp/index.tsx @@ -0,0 +1,113 @@ +/* + * 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. + */ + +import { FormEvent, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Form, Button } from 'react-bootstrap'; + +import { useToast } from '@/hooks'; +import { getMcpConfig, saveMcpConfig } from '@/services'; + +const Mcp = () => { + const toast = useToast(); + const { t } = useTranslation('translation', { + keyPrefix: 'admin.mcp', + }); + const [formData, setFormData] = useState({ + enabled: true, + type: '', + url: '', + http_header: '', + }); + const [isLoading, setIsLoading] = useState(false); + + const handleOnChange = (form) => { + setFormData({ ...formData, ...form }); + }; + const onSubmit = (evt: FormEvent) => { + evt.preventDefault(); + evt.stopPropagation(); + saveMcpConfig({ enabled: formData.enabled }).then(() => { + toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + }); + }; + + useEffect(() => { + getMcpConfig() + .then((resp) => { + setIsLoading(false); + setFormData(resp); + }) + .catch(() => { + setIsLoading(false); + }); + }, []); + if (isLoading) { + return null; + } + return ( + <> +

{t('mcp', { keyPrefix: 'nav_menus' })}

+
+
+ + {t('mcp_server.label')} + handleOnChange({ enabled: e.target.checked })} + /> + + {formData.enabled && ( + <> + + {t('type.label')} + + + + {t('url.label')} + + + + {t('http_header.label')} + + + {t('http_header.text')} + + + + )} + +
+
+ + ); +}; + +export default Mcp; diff --git a/ui/src/pages/Admin/Plugins/Config/index.tsx b/ui/src/pages/Admin/Plugins/Config/index.tsx index 7c47d0162..44d83ad68 100644 --- a/ui/src/pages/Admin/Plugins/Config/index.tsx +++ b/ui/src/pages/Admin/Plugins/Config/index.tsx @@ -114,14 +114,16 @@ const Config = () => { return ( <>

{data?.name}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/Legal/index.tsx b/ui/src/pages/Admin/Policies/index.tsx similarity index 70% rename from ui/src/pages/Admin/Legal/index.tsx rename to ui/src/pages/Admin/Policies/index.tsx index 4a4e4ea1a..7170c39ff 100644 --- a/ui/src/pages/Admin/Legal/index.tsx +++ b/ui/src/pages/Admin/Policies/index.tsx @@ -23,11 +23,17 @@ import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; import type * as Type from '@/common/interface'; -import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components'; +import { + SchemaForm, + JSONSchema, + initFormData, + UISchema, + TabNav, +} from '@/components'; import { useToast } from '@/hooks'; -import { getLegalSetting, putLegalSetting } from '@/services'; +import { getPoliciesSetting, putPoliciesSetting } from '@/services'; import { handleFormError, scrollToElementTop } from '@/utils'; -import { siteLealStore } from '@/stores'; +import { ADMIN_RULES_NAV_MENUS } from '@/common/constants'; const Legal: FC = () => { const { t } = useTranslation('translation', { @@ -35,29 +41,10 @@ const Legal: FC = () => { }); const Toast = useToast(); - const externalContent = [ - { - value: 'always_display', - label: t('external_content_display.always_display'), - }, - { - value: 'ask_before_display', - label: t('external_content_display.ask_before_display'), - }, - ]; - const schema: JSONSchema = { title: t('page_title'), required: ['terms_of_service', 'privacy_policy'], properties: { - external_content_display: { - type: 'string', - title: t('external_content_display.label'), - description: t('external_content_display.text'), - enum: externalContent?.map((lang) => lang.value), - enumNames: externalContent?.map((lang) => lang.label), - default: 0, - }, terms_of_service: { type: 'string', title: t('terms_of_service.label'), @@ -71,9 +58,6 @@ const Legal: FC = () => { }, }; const uiSchema: UISchema = { - external_content_display: { - 'ui:widget': 'select', - }, terms_of_service: { 'ui:widget': 'textarea', 'ui:options': { @@ -94,7 +78,6 @@ const Legal: FC = () => { evt.stopPropagation(); const reqParams: Type.AdminSettingsLegal = { - external_content_display: formData.external_content_display.value, terms_of_service_original_text: formData.terms_of_service.value, terms_of_service_parsed_text: marked.parse( formData.terms_of_service.value, @@ -103,15 +86,12 @@ const Legal: FC = () => { privacy_policy_parsed_text: marked.parse(formData.privacy_policy.value), }; - putLegalSetting(reqParams) + putPoliciesSetting(reqParams) .then(() => { Toast.onShow({ msg: t('update', { keyPrefix: 'toast' }), variant: 'success', }); - siteLealStore.getState().update({ - external_content_display: reqParams.external_content_display, - }); }) .catch((err) => { if (err.isError) { @@ -124,11 +104,9 @@ const Legal: FC = () => { }; useEffect(() => { - getLegalSetting().then((setting) => { + getPoliciesSetting().then((setting) => { if (setting) { const formMeta = { ...formData }; - formMeta.external_content_display.value = - setting.external_content_display; formMeta.terms_of_service.value = setting.terms_of_service_original_text; formMeta.privacy_policy.value = setting.privacy_policy_original_text; @@ -143,14 +121,17 @@ const Legal: FC = () => { return ( <> -

{t('page_title')}

- +

{t('rules', { keyPrefix: 'nav_menus' })}

+ +
+ +
); }; diff --git a/ui/src/pages/Admin/Privileges/index.tsx b/ui/src/pages/Admin/Privileges/index.tsx index f0930c7af..9ab775dc4 100644 --- a/ui/src/pages/Admin/Privileges/index.tsx +++ b/ui/src/pages/Admin/Privileges/index.tsx @@ -22,7 +22,13 @@ import { useTranslation } from 'react-i18next'; import { useToast } from '@/hooks'; import { FormDataType } from '@/common/interface'; -import { JSONSchema, SchemaForm, UISchema, initFormData } from '@/components'; +import { + JSONSchema, + SchemaForm, + UISchema, + initFormData, + TabNav, +} from '@/components'; import { getPrivilegeSetting, putPrivilegeSetting, @@ -30,7 +36,10 @@ import { AdminSettingsPrivilegeReq, } from '@/services'; import { handleFormError, scrollToElementTop } from '@/utils'; -import { ADMIN_PRIVILEGE_CUSTOM_LEVEL } from '@/common/constants'; +import { + ADMIN_PRIVILEGE_CUSTOM_LEVEL, + ADMIN_RULES_NAV_MENUS, +} from '@/common/constants'; const Index: FC = () => { const { t } = useTranslation('translation', { @@ -187,14 +196,17 @@ const Index: FC = () => { return ( <> -

{t('title')}

- +

{t('rules', { keyPrefix: 'nav_menus' })}

+ +
+ +
); }; diff --git a/ui/src/pages/Admin/QaSettings/index.tsx b/ui/src/pages/Admin/QaSettings/index.tsx new file mode 100644 index 000000000..67685ba88 --- /dev/null +++ b/ui/src/pages/Admin/QaSettings/index.tsx @@ -0,0 +1,153 @@ +/* + * 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. + */ + +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + SchemaForm, + JSONSchema, + UISchema, + initFormData, + TabNav, +} from '@/components'; +import { ADMIN_QA_NAV_MENUS } from '@/common/constants'; +import * as Type from '@/common/interface'; +import { writeSettingStore } from '@/stores'; +import { + getQuestionSetting, + updateQuestionSetting, +} from '@/services/admin/question'; +import { handleFormError, scrollToElementTop } from '@/utils'; +import { useToast } from '@/hooks'; + +const QaSettings = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.write', + }); + const Toast = useToast(); + const schema: JSONSchema = { + title: t('page_title'), + properties: { + min_tags: { + type: 'number', + title: t('min_tags.label'), + description: t('min_tags.text'), + default: 0, + }, + min_content: { + type: 'number', + title: t('min_content.label'), + description: t('min_content.text'), + }, + restrict_answer: { + type: 'boolean', + title: t('restrict_answer.label'), + description: t('restrict_answer.text'), + }, + }, + }; + const uiSchema: UISchema = { + min_tags: { + 'ui:widget': 'input', + 'ui:options': { + inputType: 'number', + }, + }, + min_content: { + 'ui:widget': 'input', + 'ui:options': { + inputType: 'number', + }, + }, + restrict_answer: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('restrict_answer.label'), + }, + }, + }; + const [formData, setFormData] = useState( + initFormData(schema), + ); + + const handleValueChange = (data: Type.FormDataType) => { + setFormData(data); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + // TODO: submit data + const reqParams: Type.AdminQuestionSetting = { + min_tags: formData.min_tags.value, + min_content: formData.min_content.value, + restrict_answer: formData.restrict_answer.value, + }; + updateQuestionSetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + writeSettingStore.getState().update({ ...reqParams }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + getQuestionSetting().then((res) => { + if (res) { + const formMeta = { ...formData }; + formMeta.min_tags.value = res.min_tags; + formMeta.min_content.value = res.min_content; + formMeta.restrict_answer.value = res.restrict_answer; + console.log('res', res, formMeta); + setFormData(formMeta); + } + }); + }, []); + + return ( + <> +

+ {t('page_title', { keyPrefix: 'admin.questions' })} +

+ +
+ +
+ + ); +}; + +export default QaSettings; diff --git a/ui/src/pages/Admin/Questions/index.tsx b/ui/src/pages/Admin/Questions/index.tsx index 494effe68..ffd9f6096 100644 --- a/ui/src/pages/Admin/Questions/index.tsx +++ b/ui/src/pages/Admin/Questions/index.tsx @@ -32,8 +32,9 @@ import { Empty, QueryGroup, Modal, + TabNav, } from '@/components'; -import { ADMIN_LIST_STATUS } from '@/common/constants'; +import { ADMIN_LIST_STATUS, ADMIN_QA_NAV_MENUS } from '@/common/constants'; import * as Type from '@/common/interface'; import { deletePermanently, useQuestionSearch } from '@/services'; import { pathFactory } from '@/router/pathFactory'; @@ -95,6 +96,7 @@ const Questions: FC = () => { return ( <>

{t('page_title')}

+
{ + const { t } = useTranslation('translation', { + keyPrefix: 'admin.security', + }); + const Toast = useToast(); + const externalContent = [ + { + value: 'always_display', + label: t('external_content_display.always_display', { + keyPrefix: 'admin.legal', + }), + }, + { + value: 'ask_before_display', + label: t('external_content_display.ask_before_display', { + keyPrefix: 'admin.legal', + }), + }, + ]; + + const schema: JSONSchema = { + title: t('page_title'), + properties: { + login_required: { + type: 'boolean', + title: t('private.title', { keyPrefix: 'admin.login' }), + description: t('private.text', { keyPrefix: 'admin.login' }), + default: false, + }, + external_content_display: { + type: 'string', + title: t('external_content_display.label', { + keyPrefix: 'admin.legal', + }), + description: t('external_content_display.text', { + keyPrefix: 'admin.legal', + }), + enum: externalContent?.map((lang) => lang.value), + enumNames: externalContent?.map((lang) => lang.label), + default: 0, + }, + check_update: { + type: 'boolean', + title: t('check_update.label', { keyPrefix: 'admin.general' }), + default: true, + }, + }, + }; + const uiSchema: UISchema = { + login_required: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('private.label', { keyPrefix: 'admin.login' }), + }, + }, + external_content_display: { + 'ui:widget': 'select', + 'ui:options': { + label: t('external_content_display.label', { + keyPrefix: 'admin.legal', + }), + }, + }, + check_update: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('check_update.label', { keyPrefix: 'admin.general' }), + }, + }, + }; + const [formData, setFormData] = useState(initFormData(schema)); + + const handleValueChange = (data: Type.FormDataType) => { + setFormData(data); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const reqParams = { + login_required: formData.login_required.value, + external_content_display: formData.external_content_display.value, + check_update: formData.check_update.value, + }; + putSecuritySetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + siteSecurityStore.getState().update(reqParams); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + getSecuritySetting().then((setting) => { + if (setting) { + const formMeta = { ...formData }; + formMeta.login_required.value = setting.login_required; + formMeta.external_content_display.value = + setting.external_content_display; + formMeta.check_update.value = setting.check_update; + setFormData(formMeta); + } + }); + }, []); + + return ( + <> +

{t('security', { keyPrefix: 'nav_menus' })}

+
+ +
+ + ); +}; + +export default Security; diff --git a/ui/src/pages/Admin/Seo/index.tsx b/ui/src/pages/Admin/Seo/index.tsx index e539595bd..0675479d7 100644 --- a/ui/src/pages/Admin/Seo/index.tsx +++ b/ui/src/pages/Admin/Seo/index.tsx @@ -117,13 +117,15 @@ const Index: FC = () => { return ( <>

{t('page_title')}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/Smtp/index.tsx b/ui/src/pages/Admin/Smtp/index.tsx index 85a1b9760..595387512 100644 --- a/ui/src/pages/Admin/Smtp/index.tsx +++ b/ui/src/pages/Admin/Smtp/index.tsx @@ -224,13 +224,15 @@ const Smtp: FC = () => { return ( <>

{t('page_title')}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/TagsSettings/index.tsx b/ui/src/pages/Admin/TagsSettings/index.tsx new file mode 100644 index 000000000..b857d9899 --- /dev/null +++ b/ui/src/pages/Admin/TagsSettings/index.tsx @@ -0,0 +1,189 @@ +/* + * 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. + */ + +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + SchemaForm, + JSONSchema, + UISchema, + initFormData, + TabNav, +} from '@/components'; +import { ADMIN_TAGS_NAV_MENUS } from '@/common/constants'; +import * as Type from '@/common/interface'; +import { handleFormError, scrollToElementTop } from '@/utils'; +import { writeSettingStore } from '@/stores'; +import { getAdminTagsSetting, updateAdminTagsSetting } from '@/services/admin'; +import { useToast } from '@/hooks'; + +const QaSettings = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'admin.write', + }); + const Toast = useToast(); + const schema: JSONSchema = { + title: t('page_title'), + properties: { + reserved_tags: { + type: 'string', + title: t('reserved_tags.label'), + description: t('reserved_tags.text'), + }, + recommend_tags: { + type: 'string', + title: t('recommend_tags.label'), + description: t('recommend_tags.text'), + }, + required_tag: { + type: 'boolean', + title: t('required_tag.title'), + description: t('required_tag.text'), + }, + }, + }; + const uiSchema: UISchema = { + reserved_tags: { + 'ui:widget': 'tag_selector', + 'ui:options': { + label: t('reserved_tags.label'), + }, + }, + recommend_tags: { + 'ui:widget': 'tag_selector', + 'ui:options': { + label: t('recommend_tags.label'), + }, + }, + required_tag: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('required_tag.label'), + }, + }, + }; + const [formData, setFormData] = useState( + initFormData(schema), + ); + + const handleValueChange = (data: Type.FormDataType) => { + setFormData(data); + }; + + const checkValidated = (): boolean => { + let bol = true; + const { recommend_tags, reserved_tags } = formData; + // 找出 recommend_tags 和 reserved_tags 中是否有重复的标签 + // 通过标签中的 slug_name 来去重 + const repeatTag = recommend_tags.value.filter((tag) => + reserved_tags.value.some((rTag) => rTag?.slug_name === tag?.slug_name), + ); + if (repeatTag.length > 0) { + handleValueChange({ + ...formData, + recommend_tags: { + ...recommend_tags, + errorMsg: t('recommend_tags.msg.contain_reserved'), + isInvalid: true, + }, + }); + bol = false; + const ele = document.getElementById('recommend_tags'); + scrollToElementTop(ele); + } else { + handleValueChange({ + ...formData, + recommend_tags: { + ...recommend_tags, + errorMsg: '', + isInvalid: false, + }, + }); + } + return bol; + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + if (!checkValidated()) { + return; + } + const reqParams: Type.AdminTagsSetting = { + recommend_tags: formData.recommend_tags.value, + reserved_tags: formData.reserved_tags.value, + required_tag: formData.required_tag.value, + }; + updateAdminTagsSetting(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + writeSettingStore.getState().update({ ...reqParams }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + getAdminTagsSetting().then((res) => { + if (res) { + const formMeta = { ...formData }; + if (Array.isArray(res.recommend_tags)) { + formData.recommend_tags.value = res.recommend_tags; + } else { + formData.recommend_tags.value = []; + } + if (Array.isArray(res.reserved_tags)) { + formData.reserved_tags.value = res.reserved_tags; + } else { + formData.reserved_tags.value = []; + } + formMeta.required_tag.value = res.required_tag; + setFormData(formMeta); + } + }); + }, []); + + return ( + <> +

{t('tags', { keyPrefix: 'nav_menus' })}

+ +
+ +
+ + ); +}; + +export default QaSettings; diff --git a/ui/src/pages/Admin/Themes/index.tsx b/ui/src/pages/Admin/Themes/index.tsx index 94eccbca1..c6983cbec 100644 --- a/ui/src/pages/Admin/Themes/index.tsx +++ b/ui/src/pages/Admin/Themes/index.tsx @@ -46,6 +46,13 @@ const Index: FC = () => { enumNames: themeSetting?.theme_options?.map((_) => _.label), default: themeSetting?.theme_options?.[0]?.value, }, + layout: { + type: 'string', + title: t('layout.label'), + enum: ['Full-width', 'Fixed-width'], + enumNames: [t('layout.full_width'), t('layout.fixed_width')], + default: themeSetting?.layout, + }, color_scheme: { type: 'string', title: t('color_scheme.label'), @@ -77,6 +84,9 @@ const Index: FC = () => { color_scheme: { 'ui:widget': 'select', }, + layout: { + 'ui:widget': 'select', + }, navbar_style: { 'ui:widget': 'input_group', 'ui:options': { @@ -131,6 +141,7 @@ const Index: FC = () => { const reqParams: Type.AdminSettingsTheme = { theme: themeName, color_scheme: formData.color_scheme.value, + layout: formData.layout.value, theme_config: { [themeName]: { navbar_style: formData.navbar_style.value, @@ -171,6 +182,7 @@ const Index: FC = () => { : DEFAULT_THEME_COLOR; formMeta.primary_color.value = themeConfig?.primary_color; formData.color_scheme.value = setting?.color_scheme || 'system'; + formData.layout.value = setting?.layout || 'Full-width'; setFormData({ ...formMeta }); } }); @@ -193,13 +205,15 @@ const Index: FC = () => { return ( <>

{t('page_title')}

- +
+ +
); }; diff --git a/ui/src/pages/Admin/Users/index.tsx b/ui/src/pages/Admin/Users/index.tsx index 7e8e4fd61..200aacf38 100644 --- a/ui/src/pages/Admin/Users/index.tsx +++ b/ui/src/pages/Admin/Users/index.tsx @@ -32,6 +32,7 @@ import { Empty, QueryGroup, Modal, + TabNav, } from '@/components'; import * as Type from '@/common/interface'; import { useUserModal } from '@/hooks'; @@ -45,6 +46,7 @@ import { deletePermanently, } from '@/services'; import { formatCount } from '@/utils'; +import { ADMIN_USERS_NAV_MENUS } from '@/common/constants'; import DeleteUserModal from './components/DeleteUserModal'; import Action from './components/Action'; @@ -109,9 +111,14 @@ const Users: FC = () => { return new Promise((resolve, reject) => { addUsers(userModel) .then(() => { - if (/all|staff/.test(curFilter) && curPage === 1) { - refreshUsers(); - } + toastStore.getState().show({ + msg: t('user_added', { keyPrefix: 'messages' }), + variant: 'success', + }); + urlSearchParams.set('filter', 'normal'); + urlSearchParams.delete('page'); + setUrlSearchParams(urlSearchParams); + refreshUsers(); resolve(true); }) .catch((e) => { @@ -203,6 +210,7 @@ const Users: FC = () => { return ( <>

{t('title')}

+
{ + const { t } = useTranslation('translation', { + keyPrefix: 'admin.interface', + }); + const { data: setting } = useAdminUsersSettings(); + const Toast = useToast(); + const schema: JSONSchema = { + title: t('page_title'), + properties: { + default_avatar: { + type: 'string', + title: t('avatar.label'), + description: t('avatar.text'), + enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value), + enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label), + default: setting?.default_avatar || 'system', + }, + gravatar_base_url: { + type: 'string', + title: t('gravatar_base_url.label'), + description: t('gravatar_base_url.text'), + default: setting?.gravatar_base_url || '', + }, + }, + }; + + const [formData, setFormData] = useState({ + default_avatar: { + value: setting?.default_avatar || 'system', + isInvalid: false, + errorMsg: '', + }, + gravatar_base_url: { + value: setting?.gravatar_base_url || '', + isInvalid: false, + errorMsg: '', + }, + }); + + const uiSchema: UISchema = { + default_avatar: { + 'ui:widget': 'select', + }, + gravatar_base_url: { + 'ui:widget': 'input', + 'ui:options': { + placeholder: 'https://www.gravatar.com/avatar/', + }, + }, + }; + + const handleValueChange = (data: FormDataType) => { + setFormData(data); + }; + + const onSubmit = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const reqParams = { + default_avatar: formData.default_avatar.value, + gravatar_base_url: formData.gravatar_base_url.value, + }; + updateAdminUsersSettings(reqParams) + .then(() => { + Toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + siteInfoStore.getState().updateUsers({ + ...siteInfoStore.getState().users, + ...reqParams, + }); + }) + .catch((err) => { + if (err.isError) { + const data = handleFormError(err, formData); + setFormData({ ...data }); + const ele = document.getElementById(err.list[0].error_field); + scrollToElementTop(ele); + } + }); + }; + + useEffect(() => { + if (setting) { + const formMeta = {}; + Object.keys(setting).forEach((k) => { + let v = setting[k]; + if (k === 'default_avatar' && !v) { + v = 'system'; + } + if (k === 'gravatar_base_url' && !v) { + v = ''; + } + formMeta[k] = { ...formData[k], value: v }; + }); + setFormData({ ...formData, ...formMeta }); + } + }, [setting]); + + return ( + <> +

{t('tags', { keyPrefix: 'nav_menus' })}

+ +
+ +
+ + ); +}; + +export default UsersSettings; diff --git a/ui/src/pages/Admin/Write/index.tsx b/ui/src/pages/Admin/Write/index.tsx deleted file mode 100644 index fee2d28cc..000000000 --- a/ui/src/pages/Admin/Write/index.tsx +++ /dev/null @@ -1,473 +0,0 @@ -/* - * 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. - */ - -import { FC, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Form, Button } from 'react-bootstrap'; - -import { TagSelector } from '@/components'; -import type * as Type from '@/common/interface'; -import { useToast } from '@/hooks'; -import { - getRequireAndReservedTag, - postRequireAndReservedTag, -} from '@/services'; -import { handleFormError, scrollToElementTop } from '@/utils'; -import { writeSettingStore } from '@/stores'; - -const initFormData = { - reserved_tags: { - value: [] as Type.Tag[], // Replace `Type.Tag` with the correct type for `reserved_tags.value` - errorMsg: '', - isInvalid: false, - }, - min_content: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - min_tags: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - recommend_tags: { - value: [] as Type.Tag[], - errorMsg: '', - isInvalid: false, - }, - required_tag: { - value: false, - errorMsg: '', - isInvalid: false, - }, - restrict_answer: { - value: false, - errorMsg: '', - isInvalid: false, - }, - max_image_size: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - max_attachment_size: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - max_image_megapixel: { - value: 0, - errorMsg: '', - isInvalid: false, - }, - authorized_image_extensions: { - value: '', - errorMsg: '', - isInvalid: false, - }, - authorized_attachment_extensions: { - value: '', - errorMsg: '', - isInvalid: false, - }, -}; - -const Index: FC = () => { - const { t } = useTranslation('translation', { - keyPrefix: 'admin.write', - }); - const Toast = useToast(); - - const [formData, setFormData] = useState(initFormData); - - const handleValueChange = (value) => { - setFormData({ - ...formData, - ...value, - }); - }; - - const checkValidated = (): boolean => { - let bol = true; - const { recommend_tags, reserved_tags } = formData; - // 找出 recommend_tags 和 reserved_tags 中是否有重复的标签 - // 通过标签中的 slug_name 来去重 - const repeatTag = recommend_tags.value.filter((tag) => - reserved_tags.value.some((rTag) => rTag?.slug_name === tag?.slug_name), - ); - if (repeatTag.length > 0) { - handleValueChange({ - recommend_tags: { - ...recommend_tags, - errorMsg: t('recommend_tags.msg.contain_reserved'), - isInvalid: true, - }, - }); - bol = false; - const ele = document.getElementById('recommend_tags'); - scrollToElementTop(ele); - } else { - handleValueChange({ - recommend_tags: { - ...recommend_tags, - errorMsg: '', - isInvalid: false, - }, - }); - } - return bol; - }; - - const onSubmit = (evt) => { - evt.preventDefault(); - evt.stopPropagation(); - if (!checkValidated()) { - return; - } - const reqParams: Type.AdminSettingsWrite = { - recommend_tags: formData.recommend_tags.value, - min_tags: Number(formData.min_tags.value), - reserved_tags: formData.reserved_tags.value, - required_tag: formData.required_tag.value, - restrict_answer: formData.restrict_answer.value, - min_content: Number(formData.min_content.value), - max_image_size: Number(formData.max_image_size.value), - max_attachment_size: Number(formData.max_attachment_size.value), - max_image_megapixel: Number(formData.max_image_megapixel.value), - authorized_image_extensions: - formData.authorized_image_extensions.value?.length > 0 - ? formData.authorized_image_extensions.value - .split(',') - ?.map((item) => item.trim().toLowerCase()) - : [], - authorized_attachment_extensions: - formData.authorized_attachment_extensions.value?.length > 0 - ? formData.authorized_attachment_extensions.value - .split(',') - ?.map((item) => item.trim().toLowerCase()) - : [], - }; - postRequireAndReservedTag(reqParams) - .then(() => { - Toast.onShow({ - msg: t('update', { keyPrefix: 'toast' }), - variant: 'success', - }); - writeSettingStore - .getState() - .update({ restrict_answer: reqParams.restrict_answer, ...reqParams }); - }) - .catch((err) => { - if (err.isError) { - const data = handleFormError(err, formData); - setFormData({ ...data }); - const ele = document.getElementById(err.list[0].error_field); - scrollToElementTop(ele); - } - }); - }; - - const initData = () => { - getRequireAndReservedTag().then((res) => { - if (Array.isArray(res.recommend_tags)) { - formData.recommend_tags.value = res.recommend_tags; - } - formData.min_content.value = res.min_content; - formData.min_tags.value = res.min_tags; - formData.required_tag.value = res.required_tag; - formData.restrict_answer.value = res.restrict_answer; - if (Array.isArray(res.reserved_tags)) { - formData.reserved_tags.value = res.reserved_tags; - } - formData.max_image_size.value = res.max_image_size; - formData.max_attachment_size.value = res.max_attachment_size; - formData.max_image_megapixel.value = res.max_image_megapixel; - formData.authorized_image_extensions.value = - res.authorized_image_extensions?.join(', ').toLowerCase(); - formData.authorized_attachment_extensions.value = - res.authorized_attachment_extensions?.join(', ').toLowerCase(); - setFormData({ ...formData }); - }); - }; - - useEffect(() => { - initData(); - }, []); - - return ( - <> -

{t('page_title')}

-
- - {t('reserved_tags.label')} - { - handleValueChange({ - reserved_tags: { - value: val, - errorMsg: '', - isInvalid: false, - }, - }); - }} - showRequiredTag={false} - maxTagLength={0} - tagStyleMode="simple" - formText={t('reserved_tags.text')} - isInvalid={formData.reserved_tags.isInvalid} - errMsg={formData.reserved_tags.errorMsg} - /> - - - - {t('recommend_tags.label')} - { - handleValueChange({ - recommend_tags: { - value: val, - errorMsg: '', - isInvalid: false, - }, - }); - }} - showRequiredTag={false} - tagStyleMode="simple" - formText={t('recommend_tags.text')} - isInvalid={formData.recommend_tags.isInvalid} - errMsg={formData.recommend_tags.errorMsg} - /> - - - {t('min_tags.label')} - { - handleValueChange({ - min_tags: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('min_tags.text')} - - {formData.min_tags.errorMsg} - - - - {t('required_tag.title')} - { - handleValueChange({ - required_tag: { - value: evt.target.checked, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('required_tag.text')} - - {formData.required_tag.errorMsg} - - - - {t('min_content.label')} - { - handleValueChange({ - min_content: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('min_content.text')} - - {formData.min_content.errorMsg} - - - - {t('restrict_answer.title')} - { - handleValueChange({ - restrict_answer: { - value: evt.target.checked, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('restrict_answer.text')} - - {formData.restrict_answer.errorMsg} - - - - - {t('image_size.label')} - { - handleValueChange({ - max_image_size: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('image_size.text')} - - {formData.max_image_size.errorMsg} - - - - - {t('attachment_size.label')} - { - handleValueChange({ - max_attachment_size: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('attachment_size.text')} - - {formData.max_attachment_size.errorMsg} - - - - - {t('image_megapixels.label')} - { - handleValueChange({ - max_image_megapixel: { - value: evt.target.value, - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('image_megapixels.text')} - - {formData.max_image_megapixel.errorMsg} - - - - - {t('image_extensions.label')} - { - handleValueChange({ - authorized_image_extensions: { - value: evt.target.value.toLowerCase(), - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('image_extensions.text')} - - {formData.authorized_image_extensions.errorMsg} - - - - - {t('attachment_extensions.label')} - { - handleValueChange({ - authorized_attachment_extensions: { - value: evt.target.value.toLowerCase(), - errorMsg: '', - isInvalid: false, - }, - }); - }} - /> - {t('attachment_extensions.text')} - - {formData.authorized_attachment_extensions.errorMsg} - - - - - - -
- - ); -}; - -export default Index; diff --git a/ui/src/pages/Admin/index.scss b/ui/src/pages/Admin/index.scss index 5748d0532..c33e92be6 100644 --- a/ui/src/pages/Admin/index.scss +++ b/ui/src/pages/Admin/index.scss @@ -25,6 +25,10 @@ max-width: 30rem; } +.max-w-748 { + max-width: 748px; +} + @media screen and (max-width: 768px) { .max-w-30 { max-width: 15rem; diff --git a/ui/src/pages/Admin/index.tsx b/ui/src/pages/Admin/index.tsx index 27503296c..da167ac40 100644 --- a/ui/src/pages/Admin/index.tsx +++ b/ui/src/pages/Admin/index.tsx @@ -20,7 +20,7 @@ import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { Row, Col } from 'react-bootstrap'; -import { Outlet, useMatch } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import { usePageTags } from '@/hooks'; import { AdminSideNav, Footer } from '@/components'; @@ -28,19 +28,8 @@ import { AdminSideNav, Footer } from '@/components'; import '@/common/sideNavLayout.scss'; import './index.scss'; -const g10Paths = [ - 'dashboard', - 'questions', - 'answers', - 'users', - 'badges', - 'flags', - 'installed-plugins', -]; const Index: FC = () => { const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); - const pathMatch = useMatch('/admin/:path'); - const curPath = pathMatch?.params.path || 'dashboard'; usePageTags({ title: t('admin'), @@ -59,9 +48,6 @@ const Index: FC = () => { - {g10Paths.find((v) => curPath === v) ? null : ( - - )}
diff --git a/ui/src/pages/AiAssistant/components/ConversationList/index.tsx b/ui/src/pages/AiAssistant/components/ConversationList/index.tsx new file mode 100644 index 000000000..1ddb96259 --- /dev/null +++ b/ui/src/pages/AiAssistant/components/ConversationList/index.tsx @@ -0,0 +1,70 @@ +/* + * 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. + */ + +import { FC, memo } from 'react'; +import { Card, ListGroup } from 'react-bootstrap'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +interface ConversationListItem { + conversation_id: string; + topic: string; +} + +interface IProps { + data: { + count: number; + list: ConversationListItem[]; + }; + loadMore: (e: React.MouseEvent) => void; +} + +const Index: FC = ({ data, loadMore }) => { + const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + + if (Number(data?.list.length) <= 0) return null; + return ( + + + {t('recent_conversations')} + + + {data?.list.map((item) => { + return ( + + {item.topic} + + ); + })} + {Number(data?.count) > data?.list.length && ( + + {t('show_more')} + + )} + + + ); +}; + +export default memo(Index); diff --git a/ui/src/pages/AiAssistant/index.tsx b/ui/src/pages/AiAssistant/index.tsx new file mode 100644 index 000000000..e2329f85c --- /dev/null +++ b/ui/src/pages/AiAssistant/index.tsx @@ -0,0 +1,380 @@ +/* + * 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. + */ + +import { useEffect, useState } from 'react'; +import { Row, Col, Spinner, Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { useParams, useNavigate } from 'react-router-dom'; + +import classNames from 'classnames'; +import { v4 as uuidv4 } from 'uuid'; + +import * as Type from '@/common/interface'; +import requestAi, { cancelCurrentRequest } from '@/utils/requestAi'; +import { Sender, BubbleUser, BubbleAi, Icon } from '@/components'; +import { getConversationDetail, getConversationList } from '@/services'; +import { usePageTags } from '@/hooks'; +import { Storage } from '@/utils'; + +import ConversationsList from './components/ConversationList'; + +interface ConversationListItem { + conversation_id: string; + topic: string; +} + +const Index = () => { + const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + const [isShowConversationList, setIsShowConversationList] = useState(false); + const [isGenerate, setIsGenerate] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [recentNewItem, setRecentNewItem] = useState(null); + const [conversions, setConversions] = useState({ + records: [], + conversation_id: '', + created_at: 0, + topic: '', + updated_at: 0, + }); + const navigate = useNavigate(); + const { id = '' } = useParams<{ id: string }>(); + const [temporaryBottomSpace, setTemporaryBottomSpace] = useState(0); + const [conversationsPage, setConversationsPage] = useState(1); + + const [conversationsList, setConversationsList] = useState<{ + count: number; + list: ConversationListItem[]; + }>({ + count: 0, + list: [], + }); + + const calculateTemporarySpace = () => { + const viewportHeight = window.innerHeight; + const navHeight = 64; + const senderHeight = (document.querySelector('.sender-wrap') as HTMLElement) + ?.offsetHeight; + const neededSpace = viewportHeight - senderHeight - navHeight - 120; + const height = neededSpace; + console.log('lasMsgHeight', height); + + setTemporaryBottomSpace(height); + }; + + const resetPageState = () => { + setConversions({ + records: [], + conversation_id: '', + created_at: 0, + topic: '', + updated_at: 0, + }); + setIsGenerate(false); + setRecentNewItem(null); + }; + + const handleNewConversation = (e) => { + e.preventDefault(); + navigate('/ai-assistant', { replace: true }); + }; + + const fetchDetail = () => { + getConversationDetail(id).then((res) => { + setConversions(res); + }); + }; + + const handleSubmit = async (userMsg) => { + setIsLoading(true); + if (conversions?.records.length === 0) { + setRecentNewItem({ + conversation_id: id, + topic: userMsg, + }); + } + const chatId = Date.now(); + setConversions((prev) => ({ + ...prev, + topic: userMsg, + conversation_id: id, + records: [ + ...prev.records, + { + id: chatId, + role: 'user', + content: userMsg, + chat_completion_id: String(chatId), // Add required properties + helpful: 0, + unhelpful: 0, + created_at: chatId, + }, + ], + })); + + // scroll to user message after the page height is stable + requestAnimationFrame(() => { + const userBubbles = document.querySelectorAll('.bubble-user-wrap'); + const lastUserBubble = userBubbles[userBubbles.length - 1]; + if (lastUserBubble) { + lastUserBubble.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + }); + + calculateTemporarySpace(); + + const params = { + conversation_id: id, + messages: [ + { + role: 'user', + content: userMsg, + }, + ], + }; + + await requestAi('/answer/api/v1/chat/completions', { + body: JSON.stringify(params), + onMessage: (res) => { + if (!res.choices[0].delta?.content) { + return; + } + setIsLoading(false); + setIsGenerate(true); + setConversions((prev) => { + const updatedRecords = [...prev.records]; + const lastConversion = updatedRecords[updatedRecords.length - 1]; + if (lastConversion?.chat_completion_id === res?.chat_completion_id) { + updatedRecords[updatedRecords.length - 1] = { + ...lastConversion, + content: lastConversion.content + res.choices[0].delta.content, + }; + } else { + updatedRecords.push({ + chat_completion_id: res.chat_completion_id, + role: res.choices[0].delta.role || 'assistant', + content: res.choices[0].delta.content, + helpful: 0, + unhelpful: 0, + created_at: Date.now(), + }); + } + return { + ...prev, + conversation_id: params.conversation_id, + records: updatedRecords, + }; + }); + }, + onError: (error) => { + setIsLoading(false); + setIsGenerate(false); + console.error('Error:', error); + }, + onComplete: () => { + setIsGenerate(false); + setIsLoading(false); + }, + }); + }; + + const handleSender = (userMsg) => { + if (conversions?.records.length <= 0) { + const newConversationId = uuidv4(); + navigate(`/ai-assistant/${newConversationId}`); + Storage.set('_a_once_msg', userMsg); + } else { + handleSubmit(userMsg); + } + }; + + const handleCancel = () => { + if (cancelCurrentRequest()) { + setIsGenerate(false); + } + }; + + usePageTags({ + title: conversions?.topic || t('ai_assistant', { keyPrefix: 'page_title' }), + }); + + useEffect(() => { + if (id) { + const msg = Storage.get('_a_once_msg'); + Storage.remove('_a_once_msg'); + if (msg) { + if (msg) { + handleSubmit(msg); + } + return; + } + fetchDetail(); + } else { + resetPageState(); + } + }, [id]); + + const getList = (p) => { + getConversationList({ + page: p, + page_size: 10, + }).then((res) => { + setConversationsList({ + count: res.count, + list: [...conversationsList.list, ...res.list], + }); + }); + }; + + const getMore = (e) => { + e.preventDefault(); + setConversationsPage((prev) => prev + 1); + getList(conversationsPage + 1); + }; + + useEffect(() => { + getList(1); + + return () => { + setConversationsList({ + count: 0, + list: [], + }); + setConversationsPage(1); + }; + }, []); + + useEffect(() => { + if (recentNewItem && recentNewItem.conversation_id) { + setConversationsList((prev) => ({ + ...prev, + list: [ + recentNewItem, + ...prev.list.filter( + (item) => item.conversation_id !== recentNewItem.conversation_id, + ), + ], + })); + } + }, [recentNewItem]); + + return ( +
+
+

+ {t('ai_assistant', { keyPrefix: 'page_title' })} +

+
+ + +
+
+ + + {conversions?.records.length > 0 && ( +
+ {conversions?.records.map((item, index) => { + const isLastMessage = + index === Number(conversions?.records.length) - 1; + return ( +
+ {item.role === 'user' ? ( + + ) : ( + + )} +
+ ); + })} + + {temporaryBottomSpace > 0 && isLoading && ( +
+ {isLoading && ( + + )} +
+ )} +
+ )} + {conversions?.conversation_id ? null : ( +
{t('description')}
+ )} + + + {isShowConversationList && ( + + + + )} +
+
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index 984e13f40..048ca812e 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -22,12 +22,14 @@ import { Outlet, useLocation, ScrollRestoration } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import { SWRConfig } from 'swr'; +import classnames from 'classnames'; import { toastStore, loginToContinueStore, errorCodeStore, - siteLealStore, + siteSecurityStore, + themeSettingStore, } from '@/stores'; import { Header, @@ -47,7 +49,7 @@ const Layout: FC = () => { const location = useLocation(); const { msg: toastMsg, variant, clear: toastClear } = toastStore(); const externalToast = useExternalToast(); - const externalContentDisplay = siteLealStore( + const externalContentDisplay = siteSecurityStore( (state) => state.external_content_display, ); const closeToast = () => { @@ -56,7 +58,7 @@ const Layout: FC = () => { const { code: httpStatusCode, reset: httpStatusReset } = errorCodeStore(); const { show: showLoginToContinueModal } = loginToContinueStore(); const { data: notificationData } = useQueryNotificationStatus(); - + const layout = themeSettingStore((state) => state.layout); useEffect(() => { // handle footnote links const fixFootnoteLinks = () => { @@ -209,7 +211,11 @@ const Layout: FC = () => { revalidateOnFocus: false, }}>
-
+
{httpStatusCode ? ( ) : ( diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index cac75dd87..ab680c495 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -267,10 +267,10 @@ const Ask = () => { } }; const handleContentChange = (value: string) => { - setFormData({ - ...formData, + setFormData((prev) => ({ + ...prev, content: { value, errorMsg: '', isInvalid: false }, - }); + })); }; const handleTagsChange = (value) => setFormData({ @@ -279,10 +279,10 @@ const Ask = () => { }); const handleAnswerChange = (value: string) => - setFormData({ - ...formData, + setFormData((prev) => ({ + ...prev, answer_content: { value, errorMsg: '', isInvalid: false }, - }); + })); const handleSummaryChange = (evt: React.ChangeEvent) => setFormData({ diff --git a/ui/src/pages/Questions/Detail/index.scss b/ui/src/pages/Questions/Detail/index.scss index a5726b6a5..da379ba91 100644 --- a/ui/src/pages/Questions/Detail/index.scss +++ b/ui/src/pages/Questions/Detail/index.scss @@ -47,11 +47,12 @@ [data-bs-theme='dark'] & { color: var(--bs-gray-400) !important; background-color: var(--bs-gray-800); - &:active, &.active { - background-color: #626E79 !important; + &:active, + &.active { + background-color: #626e79 !important; } &:hover { - background-color: #57616B !important; + background-color: #57616b !important; } } } @@ -78,4 +79,4 @@ font-size: calc(1.275rem + 0.3vw) !important; } } -} \ No newline at end of file +} diff --git a/ui/src/pages/Questions/EditAnswer/index.tsx b/ui/src/pages/Questions/EditAnswer/index.tsx index 3be7befcf..11b0411a5 100644 --- a/ui/src/pages/Questions/EditAnswer/index.tsx +++ b/ui/src/pages/Questions/EditAnswer/index.tsx @@ -116,10 +116,10 @@ const Index = () => { }, [formData.content.value, formData.description.value]); const handleAnswerChange = (value: string) => - setFormData({ - ...formData, - content: { ...formData.content, value }, - }); + setFormData((prev) => ({ + ...prev, + content: { ...prev.content, value }, + })); const handleSummaryChange = (evt) => { const v = evt.currentTarget.value; setFormData({ diff --git a/ui/src/pages/Search/components/AiCard/index.tsx b/ui/src/pages/Search/components/AiCard/index.tsx new file mode 100644 index 000000000..2da5cb919 --- /dev/null +++ b/ui/src/pages/Search/components/AiCard/index.tsx @@ -0,0 +1,189 @@ +/* + * 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. + */ + +import { useState, useEffect } from 'react'; +import { Card, Spinner } from 'react-bootstrap'; +import { useSearchParams, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { v4 as uuidv4 } from 'uuid'; + +import { BubbleAi, BubbleUser } from '@/components'; +import { aiControlStore } from '@/stores'; +import * as Type from '@/common/interface'; +import requestAi from '@/utils/requestAi'; + +const Index = () => { + const { t } = useTranslation('translation', { keyPrefix: 'ai_assistant' }); + const { ai_enabled } = aiControlStore((state) => state); + const [searchParams] = useSearchParams(); + const [isLoading, setIsLoading] = useState(false); + const [isGenerate, setIsGenerate] = useState(false); + const [isCompleted, setIsCompleted] = useState(false); + const [conversions, setConversions] = useState({ + records: [], + conversation_id: '', + created_at: 0, + topic: '', + updated_at: 0, + }); + + const handleSubmit = async (userMsg) => { + setIsLoading(true); + setIsCompleted(false); + const newConversationId = uuidv4(); + setConversions({ + conversation_id: newConversationId, + created_at: 0, + topic: '', + updated_at: 0, + records: [ + { + chat_completion_id: Date.now().toString(), + role: 'user', + content: userMsg, + helpful: 0, + unhelpful: 0, + created_at: Date.now(), + }, + ], + }); + + const params = { + conversation_id: newConversationId, + messages: [ + { + role: 'user', + content: userMsg, + }, + ], + }; + + await requestAi('/answer/api/v1/chat/completions', { + body: JSON.stringify(params), + onMessage: (res) => { + if (!res.choices[0].delta?.content) { + return; + } + setIsLoading(false); + setIsGenerate(true); + + setConversions((prev) => { + const updatedRecords = [...prev.records]; + const lastConversion = updatedRecords[updatedRecords.length - 1]; + if (lastConversion?.chat_completion_id === res?.chat_completion_id) { + updatedRecords[updatedRecords.length - 1] = { + ...lastConversion, + content: lastConversion.content + res.choices[0].delta.content, + }; + } else { + updatedRecords.push({ + chat_completion_id: res.chat_completion_id, + role: res.choices[0].delta.role || 'assistant', + content: res.choices[0].delta.content, + helpful: 0, + unhelpful: 0, + created_at: Date.now(), + }); + } + return { + ...prev, + conversation_id: params.conversation_id, + records: updatedRecords, + }; + }); + }, + onError: (error) => { + setIsGenerate(false); + setIsLoading(false); + setIsCompleted(true); + console.error('Error:', error); + }, + onComplete: () => { + setIsCompleted(true); + setIsGenerate(false); + }, + }); + }; + + useEffect(() => { + const q = searchParams.get('q') || ''; + if (ai_enabled && q) { + handleSubmit(q); + } + }, [searchParams]); + + if (!ai_enabled) { + return null; + } + return ( + + + {t('ai_assistant', { keyPrefix: 'page_title' })} + + + {conversions?.records.map((item, index) => { + const isLastMessage = + index === Number(conversions?.records.length) - 1; + return ( +
+ {item.role === 'user' ? ( + + ) : ( + + )} +
+ ); + })} + {isLoading && ( + + )} +
+ {isCompleted && !isLoading && ( + + + {t('ask_a_follow_up')} + + {t('ai_generate')} + + )} +
+ ); +}; + +export default Index; diff --git a/ui/src/pages/Search/components/index.ts b/ui/src/pages/Search/components/index.ts index 1ea0e9910..c04ecba02 100644 --- a/ui/src/pages/Search/components/index.ts +++ b/ui/src/pages/Search/components/index.ts @@ -23,5 +23,6 @@ import Tips from './Tips'; import Empty from './Empty'; import SearchHead from './SearchHead'; import ListLoader from './ListLoader'; +import AiCard from './AiCard'; -export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader }; +export { Head, SearchItem, Tips, Empty, SearchHead, ListLoader, AiCard }; diff --git a/ui/src/pages/Search/index.tsx b/ui/src/pages/Search/index.tsx index 8b6cc1e20..bcc734b21 100644 --- a/ui/src/pages/Search/index.tsx +++ b/ui/src/pages/Search/index.tsx @@ -27,6 +27,7 @@ import { useCaptchaPlugin } from '@/utils/pluginKit'; import { Pagination } from '@/components'; import { getSearchResult } from '@/services'; import type { SearchParams, SearchRes } from '@/common/interface'; +import { logged } from '@/utils/guard'; import { Head, @@ -35,10 +36,12 @@ import { Tips, Empty, ListLoader, + AiCard, } from './components'; const Index = () => { const { t } = useTranslation('translation'); + const isLogged = logged().ok; const [searchParams] = useSearchParams(); const page = searchParams.get('page') || 1; const q = searchParams.get('q') || ''; @@ -106,6 +109,7 @@ const Index = () => { + {isLogged && } {isSkeletonShow ? ( diff --git a/ui/src/pages/SideNavLayoutWithoutFooter/index.tsx b/ui/src/pages/SideNavLayoutWithoutFooter/index.tsx new file mode 100644 index 000000000..5f931f78a --- /dev/null +++ b/ui/src/pages/SideNavLayoutWithoutFooter/index.tsx @@ -0,0 +1,46 @@ +/* + * 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. + */ + +import { FC, memo } from 'react'; +import { Outlet } from 'react-router-dom'; + +import { SideNav } from '@/components'; + +import '@/common/sideNavLayout.scss'; + +const Index: FC = () => { + return ( +
+
+ +
+
+
+
+ +
+
+
+
+ ); +}; + +export default memo(Index); diff --git a/ui/src/pages/Tags/Create/index.tsx b/ui/src/pages/Tags/Create/index.tsx index b0564f193..428ba9414 100644 --- a/ui/src/pages/Tags/Create/index.tsx +++ b/ui/src/pages/Tags/Create/index.tsx @@ -100,10 +100,10 @@ const Index = () => { ]); const handleDescriptionChange = (value: string) => - setFormData({ - ...formData, - description: { ...formData.description, value, isInvalid: false }, - }); + setFormData((prev) => ({ + ...prev, + description: { value, isInvalid: false, errorMsg: '' }, + })); const checkValidated = (): boolean => { let bol = true; diff --git a/ui/src/pages/Tags/Edit/index.tsx b/ui/src/pages/Tags/Edit/index.tsx index 0670b9467..021ce1ce3 100644 --- a/ui/src/pages/Tags/Edit/index.tsx +++ b/ui/src/pages/Tags/Edit/index.tsx @@ -117,10 +117,10 @@ const Index = () => { ]); const handleDescriptionChange = (value: string) => - setFormData({ - ...formData, - description: { ...formData.description, value }, - }); + setFormData((prev) => ({ + ...prev, + description: { value, isInvalid: false, errorMsg: '' }, + })); const checkValidated = (): boolean => { let bol = true; diff --git a/ui/src/pages/Users/Settings/Profile/index.tsx b/ui/src/pages/Users/Settings/Profile/index.tsx index 7fb8247c1..62bd6fb8e 100644 --- a/ui/src/pages/Users/Settings/Profile/index.tsx +++ b/ui/src/pages/Users/Settings/Profile/index.tsx @@ -25,7 +25,7 @@ import { sha256 } from 'js-sha256'; import type { FormDataType } from '@/common/interface'; import { UploadImg, Avatar, Icon, ImgViewer } from '@/components'; -import { loggedUserInfoStore, userCenterStore, interfaceStore } from '@/stores'; +import { loggedUserInfoStore, userCenterStore, siteInfoStore } from '@/stores'; import { useToast } from '@/hooks'; import { modifyUserInfo, @@ -42,7 +42,7 @@ const Index: React.FC = () => { const toast = useToast(); const { user, update } = loggedUserInfoStore(); const { agent: ucAgent } = userCenterStore(); - const { interface: interfaceSetting } = interfaceStore(); + const { users: usersSettings } = siteInfoStore(); const [mailHash, setMailHash] = useState(''); const [count] = useState(0); const [profileAgent, setProfileAgent] = useState(); @@ -384,7 +384,7 @@ const Index: React.FC = () => { {t('avatar.gravatar_text')}
{ className="ms-1" target="_blank" rel="noreferrer"> - {interfaceSetting.gravatar_base_url.includes( - 'gravatar.cn', - ) + {usersSettings.gravatar_base_url.includes('gravatar.cn') ? 'gravatar.cn' : 'gravatar.com'} diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 3ca8431fc..8423fb7a3 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -358,9 +358,25 @@ const routes: RouteNode[] = [ page: 'pages/Admin/Dashboard', }, { - path: 'answers', + path: 'qa/questions', + page: 'pages/Admin/Questions', + }, + { + path: 'qa/answers', page: 'pages/Admin/Answers', }, + { + path: 'qa/settings', + page: 'pages/Admin/QaSettings', + }, + { + path: 'tags/settings', + page: 'pages/Admin/TagsSettings', + }, + { + path: 'security', + page: 'pages/Admin/Security', + }, { path: 'themes', page: 'pages/Admin/Themes', @@ -377,14 +393,14 @@ const routes: RouteNode[] = [ path: 'interface', page: 'pages/Admin/Interface', }, - { - path: 'questions', - page: 'pages/Admin/Questions', - }, { path: 'users', page: 'pages/Admin/Users', }, + { + path: 'users/settings', + page: 'pages/Admin/UsersSettings', + }, { path: 'users/:user_id', page: 'pages/Admin/UserOverview', @@ -398,12 +414,12 @@ const routes: RouteNode[] = [ page: 'pages/Admin/Branding', }, { - path: 'legal', - page: 'pages/Admin/Legal', + path: 'rules/policies', + page: 'pages/Admin/Policies', }, { - path: 'write', - page: 'pages/Admin/Write', + path: 'files', + page: 'pages/Admin/Files', }, { path: 'seo', @@ -414,7 +430,7 @@ const routes: RouteNode[] = [ page: 'pages/Admin/Login', }, { - path: 'privileges', + path: 'rules/privileges', page: 'pages/Admin/Privileges', }, { @@ -429,6 +445,22 @@ const routes: RouteNode[] = [ path: 'badges', page: 'pages/Admin/Badges', }, + { + path: 'ai-assistant', + page: 'pages/Admin/AiAssistant', + }, + { + path: 'ai-settings', + page: 'pages/Admin/AiSettings', + }, + { + path: 'apikeys', + page: 'pages/Admin/Apikeys', + }, + { + path: 'mcp', + page: 'pages/Admin/Mcp', + }, ], }, { @@ -451,6 +483,26 @@ const routes: RouteNode[] = [ path: '50x', page: 'pages/50X', }, + // ai + { + page: 'pages/SideNavLayoutWithoutFooter', + children: [ + { + path: '/ai-assistant', + page: 'pages/AiAssistant', + guard: () => { + return guard.logged(); + }, + }, + { + path: '/ai-assistant/:id', + page: 'pages/AiAssistant', + guard: () => { + return guard.logged(); + }, + }, + ], + }, ], }, { diff --git a/ui/src/services/admin/ai.ts b/ui/src/services/admin/ai.ts new file mode 100644 index 000000000..e20da7ac6 --- /dev/null +++ b/ui/src/services/admin/ai.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +import useSWR from 'swr'; +import qs from 'qs'; + +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; + +export const getAiConfig = () => { + return request.get('/answer/admin/api/ai-config'); +}; + +export const useQueryAiProvider = () => { + const apiUrl = `/answer/admin/api/ai-provider`; + const { data, error, mutate } = useSWR( + apiUrl, + request.instance.get, + ); + return { + data, + isLoading: !data && !error, + error, + mutate, + }; +}; + +export const checkAiConfig = (params) => { + return request.post('/answer/admin/api/ai-models', params); +}; + +export const saveAiConfig = (params) => { + return request.put('/answer/admin/api/ai-config', params); +}; + +export const useQueryAdminConversationDetail = (id: string) => { + const apiUrl = !id + ? null + : `/answer/admin/api/ai/conversation?conversation_id=${id}`; + + const { data, error, mutate } = useSWR( + apiUrl, + request.instance.get, + ); + return { + data, + isLoading: !data && !error, + error, + mutate, + }; +}; + +export const useQueryAdminConversationList = (params: Type.Paging) => { + const apiUrl = `/answer/admin/api/ai/conversation/page?${qs.stringify(params)}`; + const { data, error, mutate } = useSWR< + { count: number; list: Type.AdminConversationListItem[] }, + Error + >(apiUrl, request.instance.get); + return { + data, + isLoading: !data && !error, + error, + mutate, + }; +}; + +export const deleteAdminConversation = (id: string) => { + return request.delete('/answer/admin/api/ai/conversation', { + conversation_id: id, + }); +}; diff --git a/ui/src/services/admin/apikeys.ts b/ui/src/services/admin/apikeys.ts new file mode 100644 index 000000000..8271e024e --- /dev/null +++ b/ui/src/services/admin/apikeys.ts @@ -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. + */ + +import useSWR from 'swr'; + +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; + +export const useQueryApiKeys = () => { + const apiUrl = `/answer/admin/api/api-key/all`; + const { data, error, mutate } = useSWR( + apiUrl, + request.instance.get, + ); + return { + data, + isLoading: !data && !error, + error, + mutate, + }; +}; + +export const addApiKey = (params: Type.AddOrEditApiKeyParams) => { + return request.post('/answer/admin/api/api-key', params); +}; + +export const updateApiKey = (params: Type.AddOrEditApiKeyParams) => { + return request.put('/answer/admin/api/api-key', params); +}; + +export const deleteApiKey = (id: string) => { + return request.delete('/answer/admin/api/api-key', { + id, + }); +}; diff --git a/ui/src/services/admin/index.ts b/ui/src/services/admin/index.ts index af83d365b..76d6ef90a 100644 --- a/ui/src/services/admin/index.ts +++ b/ui/src/services/admin/index.ts @@ -25,3 +25,7 @@ export * from './users'; export * from './dashboard'; export * from './plugins'; export * from './badges'; +export * from './ai'; +export * from './tags'; +export * from './apikeys'; +export * from './mcp'; diff --git a/ui/src/services/admin/mcp.ts b/ui/src/services/admin/mcp.ts new file mode 100644 index 000000000..6fbbd735d --- /dev/null +++ b/ui/src/services/admin/mcp.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +import request from '@/utils/request'; + +type McpConfig = { + enabled: boolean; + type: string; + url: string; + http_header: string; +}; + +export const getMcpConfig = () => { + return request.get(`/answer/admin/api/mcp-config`); +}; + +export const saveMcpConfig = (params: { enabled: boolean }) => { + return request.put(`/answer/admin/api/mcp-config`, params); +}; diff --git a/ui/src/services/admin/question.ts b/ui/src/services/admin/question.ts index 670534e26..bcd3ac7fc 100644 --- a/ui/src/services/admin/question.ts +++ b/ui/src/services/admin/question.ts @@ -46,3 +46,13 @@ export const changeQuestionStatus = ( status, }); }; + +export const getQuestionSetting = () => { + return request.get( + '/answer/admin/api/siteinfo/question', + ); +}; + +export const updateQuestionSetting = (params: Type.AdminQuestionSetting) => { + return request.put('/answer/admin/api/siteinfo/question', params); +}; diff --git a/ui/src/services/admin/settings.ts b/ui/src/services/admin/settings.ts index f2b9e598c..c1f99d40b 100644 --- a/ui/src/services/admin/settings.ts +++ b/ui/src/services/admin/settings.ts @@ -122,22 +122,12 @@ export const brandSetting = (params: Type.AdminSettingBranding) => { return request.put('/answer/admin/api/siteinfo/branding', params); }; -export const getRequireAndReservedTag = () => { - return request.get('/answer/admin/api/siteinfo/write'); +export const getAdminFilesSetting = () => { + return request.get('/answer/admin/api/siteinfo/advanced'); }; -export const postRequireAndReservedTag = (params) => { - return request.put('/answer/admin/api/siteinfo/write', params); -}; - -export const getLegalSetting = () => { - return request.get( - '/answer/admin/api/siteinfo/legal', - ); -}; - -export const putLegalSetting = (params: Type.AdminSettingsLegal) => { - return request.put('/answer/admin/api/siteinfo/legal', params); +export const updateAdminFilesSetting = (params: Type.AdminSettingsWrite) => { + return request.put('/answer/admin/api/siteinfo/advanced', params); }; export const getSeoSetting = () => { @@ -195,3 +185,23 @@ export const getPrivilegeSetting = () => { export const putPrivilegeSetting = (params: AdminSettingsPrivilegeReq) => { return request.put('/answer/admin/api/setting/privileges', params); }; + +export const getPoliciesSetting = () => { + return request.get( + '/answer/admin/api/siteinfo/polices', + ); +}; + +export const putPoliciesSetting = (params: Type.AdminSettingsLegal) => { + return request.put('/answer/admin/api/siteinfo/polices', params); +}; + +export const getSecuritySetting = () => { + return request.get( + '/answer/admin/api/siteinfo/security', + ); +}; + +export const putSecuritySetting = (params: Type.AdminSettingsSecurity) => { + return request.put('/answer/admin/api/siteinfo/security', params); +}; diff --git a/ui/src/services/admin/tags.ts b/ui/src/services/admin/tags.ts new file mode 100644 index 000000000..c7d6bc773 --- /dev/null +++ b/ui/src/services/admin/tags.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; + +export const getAdminTagsSetting = () => { + return request.get('/answer/admin/api/siteinfo/tag'); +}; + +export const updateAdminTagsSetting = (params: Type.AdminTagsSetting) => { + return request.put('/answer/admin/api/siteinfo/tag', params); +}; diff --git a/ui/src/services/admin/users.ts b/ui/src/services/admin/users.ts index ee8cad5d0..c7aeafe51 100644 --- a/ui/src/services/admin/users.ts +++ b/ui/src/services/admin/users.ts @@ -94,3 +94,22 @@ export const postUserActivation = (userId: string) => { user_id: userId, }); }; + +export const useAdminUsersSettings = () => { + const apiUrl = `/answer/admin/api/siteinfo/users-settings`; + const { data, error } = useSWR< + { + default_avatar: string; + gravatar_base_url: string; + }, + Error + >(apiUrl, request.instance.get); + return { data, isLoading: !data && !error, error }; +}; + +export const updateAdminUsersSettings = (params: { + default_avatar: string; + gravatar_base_url: string; +}) => { + return request.put('/answer/admin/api/siteinfo/users-settings', params); +}; diff --git a/ui/src/services/client/ai.ts b/ui/src/services/client/ai.ts new file mode 100644 index 000000000..28cc6398f --- /dev/null +++ b/ui/src/services/client/ai.ts @@ -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. + */ + +import qs from 'qs'; + +import request from '@/utils/request'; +import type * as Type from '@/common/interface'; + +export const getConversationList = (params: Type.Paging) => { + return request.get<{ count: number; list: Type.ConversationListItem[] }>( + `/answer/api/v1/ai/conversation/page?${qs.stringify(params)}`, + ); +}; + +export const getConversationDetail = (id: string) => { + return request.get( + `/answer/api/v1/ai/conversation?conversation_id=${id}`, + ); +}; + +// /answer/api/v1/ai/conversation/vote +export const voteConversation = (params: Type.VoteConversationParams) => { + return request.post('/answer/api/v1/ai/conversation/vote', params); +}; diff --git a/ui/src/services/client/index.ts b/ui/src/services/client/index.ts index 005bc26d7..4d5c85c10 100644 --- a/ui/src/services/client/index.ts +++ b/ui/src/services/client/index.ts @@ -31,3 +31,4 @@ export * from './user'; export * from './Oauth'; export * from './review'; export * from './badges'; +export * from './ai'; diff --git a/ui/src/stores/aiControl.ts b/ui/src/stores/aiControl.ts new file mode 100644 index 000000000..c9f0afbc7 --- /dev/null +++ b/ui/src/stores/aiControl.ts @@ -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. + */ + +import { create } from 'zustand'; + +interface AiControlStore { + ai_enabled: boolean; + update: (params: { ai_enabled: boolean }) => void; + reset: () => void; +} + +const aiControlStore = create((set) => ({ + ai_enabled: false, + update: (params: { ai_enabled: boolean }) => + set((state) => { + return { + ...state, + ...params, + }; + }), + reset: () => set({ ai_enabled: false }), +})); + +export default aiControlStore; diff --git a/ui/src/stores/index.ts b/ui/src/stores/index.ts index 66d59b32f..41af8d55e 100644 --- a/ui/src/stores/index.ts +++ b/ui/src/stores/index.ts @@ -33,7 +33,8 @@ import loginToContinueStore from './loginToContinue'; import errorCodeStore from './errorCode'; import sideNavStore from './sideNav'; import commentReplyStore from './commentReply'; -import siteLealStore from './siteLegal'; +import siteSecurityStore from './siteSecurity'; +import aiControlStore from './aiControl'; export { toastStore, @@ -52,5 +53,6 @@ export { sideNavStore, commentReplyStore, writeSettingStore, - siteLealStore, + siteSecurityStore, + aiControlStore, }; diff --git a/ui/src/stores/interface.ts b/ui/src/stores/interface.ts index cceb425cd..fdb4ab961 100644 --- a/ui/src/stores/interface.ts +++ b/ui/src/stores/interface.ts @@ -35,9 +35,9 @@ const interfaceSetting = create((set) => ({ gravatar_base_url: '', }, update: (params) => - set(() => { + set((state) => { return { - interface: params, + interface: { ...state.interface, ...params }, }; }), })); diff --git a/ui/src/stores/loginSetting.ts b/ui/src/stores/loginSetting.ts index 73fd48807..7acf765ee 100644 --- a/ui/src/stores/loginSetting.ts +++ b/ui/src/stores/loginSetting.ts @@ -29,7 +29,6 @@ interface IType { const loginSetting = create((set) => ({ login: { allow_new_registrations: true, - login_required: false, allow_email_registrations: true, allow_email_domains: [], allow_password_login: true, diff --git a/ui/src/stores/siteInfo.ts b/ui/src/stores/siteInfo.ts index 725546d64..c529cc624 100644 --- a/ui/src/stores/siteInfo.ts +++ b/ui/src/stores/siteInfo.ts @@ -39,6 +39,8 @@ const defaultUsersConf: AdminSettingsUsers = { allow_update_location: false, allow_update_username: false, allow_update_website: false, + default_avatar: 'system', + gravatar_base_url: '', }; const siteInfo = create((set) => ({ @@ -48,7 +50,6 @@ const siteInfo = create((set) => ({ short_description: '', site_url: '', contact_email: '', - check_update: true, permalink: 1, }, users: defaultUsersConf, diff --git a/ui/src/stores/siteLegal.ts b/ui/src/stores/siteSecurity.ts similarity index 75% rename from ui/src/stores/siteLegal.ts rename to ui/src/stores/siteSecurity.ts index 29a26ea35..e4e5bb52b 100644 --- a/ui/src/stores/siteLegal.ts +++ b/ui/src/stores/siteSecurity.ts @@ -19,12 +19,20 @@ import { create } from 'zustand'; -interface LealStore { +interface SecurityStore { + login_required: boolean; + check_update: boolean; external_content_display: string; - update: (params: { external_content_display: string }) => void; + update: (params: { + external_content_display: string; + check_update: boolean; + login_required: boolean; + }) => void; } -const siteLealStore = create((set) => ({ +const siteSecurityStore = create((set) => ({ + login_required: false, + check_update: true, external_content_display: 'always_display', update: (params) => set((state) => { @@ -35,4 +43,4 @@ const siteLealStore = create((set) => ({ }), })); -export default siteLealStore; +export default siteSecurityStore; diff --git a/ui/src/stores/themeSetting.ts b/ui/src/stores/themeSetting.ts index 4b13d818f..2f1b1edb4 100644 --- a/ui/src/stores/themeSetting.ts +++ b/ui/src/stores/themeSetting.ts @@ -27,6 +27,7 @@ interface IType { theme_config: AdminSettingsTheme['theme_config']; theme_options: AdminSettingsTheme['theme_options']; color_scheme: AdminSettingsTheme['color_scheme']; + layout: AdminSettingsTheme['layout']; update: (params: AdminSettingsTheme) => void; } @@ -40,6 +41,7 @@ const store = create((set) => ({ primary_color: DEFAULT_THEME_COLOR, }, }, + layout: 'full', update: (params) => set((state) => { // Compatibility default value is colored or light before v1.5.1 diff --git a/ui/src/stores/writeSetting.ts b/ui/src/stores/writeSetting.ts index 9f6542d27..576979a2e 100644 --- a/ui/src/stores/writeSetting.ts +++ b/ui/src/stores/writeSetting.ts @@ -19,11 +19,17 @@ import { create } from 'zustand'; -import { AdminSettingsWrite } from '@/common/interface'; +import { + AdminSettingsWrite, + AdminQuestionSetting, + AdminTagsSetting, +} from '@/common/interface'; interface IProps { - write: AdminSettingsWrite; - update: (params: AdminSettingsWrite) => void; + write: AdminSettingsWrite & AdminQuestionSetting & AdminTagsSetting; + update: ( + params: AdminSettingsWrite | AdminQuestionSetting | AdminTagsSetting, + ) => void; } const Index = create((set) => ({ diff --git a/ui/src/utils/guard.ts b/ui/src/utils/guard.ts index 24064669a..fc78fa122 100644 --- a/ui/src/utils/guard.ts +++ b/ui/src/utils/guard.ts @@ -30,7 +30,8 @@ import { loginToContinueStore, pageTagStore, writeSettingStore, - siteLealStore, + siteSecurityStore, + aiControlStore, } from '@/stores'; import { RouteAlias } from '@/router/alias'; import { @@ -263,8 +264,8 @@ export const singUpAgent = () => { export const shouldLoginRequired = () => { const gr: TGuardResult = { ok: true }; - const loginSetting = loginSettingStore.getState().login; - if (!loginSetting.login_required) { + const { login_required } = siteSecurityStore.getState(); + if (!login_required) { return gr; } const us = deriveLoginState(); @@ -382,12 +383,14 @@ export const initAppSettingsStore = async () => { themeSettingStore.getState().update(appSettings.theme); seoSettingStore.getState().update(appSettings.site_seo); writeSettingStore.getState().update({ - restrict_answer: appSettings.site_write.restrict_answer, - ...appSettings.site_write, + ...appSettings.site_advanced, + ...appSettings.site_questions, + ...appSettings.site_tags, }); - siteLealStore.getState().update({ - external_content_display: appSettings.site_legal.external_content_display, + aiControlStore.getState().update({ + ai_enabled: appSettings.ai_enabled, }); + siteSecurityStore.getState().update(appSettings.site_security); } }; diff --git a/ui/src/utils/pluginKit/index.ts b/ui/src/utils/pluginKit/index.ts index 0219da3f7..b62d3f59d 100644 --- a/ui/src/utils/pluginKit/index.ts +++ b/ui/src/utils/pluginKit/index.ts @@ -49,22 +49,47 @@ class Plugins { initialization: Promise; + private isInitialized = false; + + private initializationError: Error | null = null; + + private replacementPlugins: Map = new Map(); + constructor() { this.initialization = this.init(); } async init() { - this.registerBuiltin(); + if (this.isInitialized) { + return; + } - // Note: The /install stage does not allow access to the getPluginsStatus api, so an initial value needs to be given - const plugins = (await getPluginsStatus().catch(() => [])) || []; - this.registeredPlugins = plugins.filter((p) => p.enabled); - await this.registerPlugins(); + try { + this.registerBuiltin(); + + // Note: The /install stage does not allow access to the getPluginsStatus api, so an initial value needs to be given + const plugins = + (await getPluginsStatus().catch((error) => { + console.warn('Failed to get plugins status:', error); + return []; + })) || []; + this.registeredPlugins = plugins.filter((p) => p.enabled); + await this.registerPlugins(); + this.isInitialized = true; + this.initializationError = null; + } catch (error) { + this.initializationError = error as Error; + console.error('Plugin initialization failed:', error); + throw error; + } } - refresh() { + async refresh() { this.plugins = []; - this.init(); + this.isInitialized = false; + this.initializationError = null; + this.initialization = this.init(); + await this.initialization; } validate(plugin: Plugin) { @@ -95,17 +120,46 @@ class Plugins { }); } - registerPlugins() { - const plugins = this.registeredPlugins + async registerPlugins() { + console.log( + '[PluginKit] Registered plugins from API:', + this.registeredPlugins.map((p) => p.slug_name), + ); + + const pluginLoaders = this.registeredPlugins .map((p) => { const func = allPlugins[p.slug_name]; - - return func; + if (!func) { + console.warn( + `[PluginKit] Plugin loader not found for: ${p.slug_name}`, + ); + } + return { slug_name: p.slug_name, loader: func }; }) - .filter((p) => p); - return Promise.all(plugins.map((p) => p())).then((resolvedPlugins) => { - resolvedPlugins.forEach((plugin) => this.register(plugin)); - return true; + .filter((p) => p.loader); + + console.log( + '[PluginKit] Found plugin loaders:', + pluginLoaders.map((p) => p.slug_name), + ); + + // Use Promise.allSettled to prevent one plugin failure from breaking all plugins + const results = await Promise.allSettled( + pluginLoaders.map((p) => p.loader()), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + console.log( + `[PluginKit] Successfully loaded plugin: ${pluginLoaders[index].slug_name}`, + ); + this.register(result.value); + } else { + console.error( + `[PluginKit] Failed to load plugin ${pluginLoaders[index].slug_name}:`, + result.reason, + ); + } }); } @@ -114,6 +168,33 @@ class Plugins { if (!bool) { return; } + + // Prevent duplicate registration + const exists = this.plugins.some( + (p) => p.info.slug_name === plugin.info.slug_name, + ); + if (exists) { + console.warn(`Plugin ${plugin.info.slug_name} is already registered`); + return; + } + + // Handle singleton plugins (only one per type allowed) + const mode = plugin.info.registrationMode || 'multiple'; + if (mode === 'singleton') { + const existingPlugin = this.replacementPlugins.get(plugin.info.type); + if (existingPlugin) { + const error = new Error( + `[PluginKit] Plugin conflict: ` + + `Cannot register '${plugin.info.slug_name}' because '${existingPlugin.info.slug_name}' ` + + `is already registered as a singleton plugin of type '${plugin.info.type}'. ` + + `Only one singleton plugin per type is allowed.`, + ); + console.error(error.message); + throw error; + } + this.replacementPlugins.set(plugin.info.type, plugin); + } + if (plugin.i18nConfig) { initI18nResource(plugin.i18nConfig); } @@ -133,6 +214,22 @@ class Plugins { getPlugins() { return this.plugins; } + + async getPluginsAsync() { + await this.initialization; + return this.plugins; + } + + getInitializationStatus() { + return { + isInitialized: this.isInitialized, + error: this.initializationError, + }; + } + + getReplacementPlugin(type: PluginType): Plugin | null { + return this.replacementPlugins.get(type) || null; + } } const plugins = new Plugins(); @@ -168,6 +265,21 @@ const validateRoutePlugin = async (slugName) => { return Boolean(registeredPlugin?.enabled); }; +const getReplacementPlugin = async ( + type: PluginType, +): Promise => { + try { + await plugins.initialization; + return plugins.getReplacementPlugin(type); + } catch (error) { + console.error( + `[PluginKit] Failed to get replacement plugin of type ${type}:`, + error, + ); + return null; + } +}; + const mergeRoutePlugins = async (routes) => { const routePlugins = await getRoutePlugins(); if (routePlugins.length === 0) { @@ -274,6 +386,7 @@ export { mergeRoutePlugins, useCaptchaPlugin, useRenderPlugin, + getReplacementPlugin, PluginType, }; export default plugins; diff --git a/ui/src/utils/pluginKit/interface.ts b/ui/src/utils/pluginKit/interface.ts index a1b641d86..1a2c4bee7 100644 --- a/ui/src/utils/pluginKit/interface.ts +++ b/ui/src/utils/pluginKit/interface.ts @@ -26,6 +26,7 @@ export enum PluginType { Connector = 'connector', Search = 'search', Editor = 'editor', + EditorReplacement = 'editor_replacement', Route = 'route', Captcha = 'captcha', Render = 'render', @@ -38,6 +39,7 @@ export interface PluginInfo { name?: string; description?: string; route?: string; + registrationMode?: 'multiple' | 'singleton'; } export interface Plugin { diff --git a/ui/src/utils/requestAi.ts b/ui/src/utils/requestAi.ts new file mode 100644 index 000000000..df2d1ee7a --- /dev/null +++ b/ui/src/utils/requestAi.ts @@ -0,0 +1,319 @@ +/* + * 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. + */ + +import { Modal } from '@/components'; +import { loggedUserInfoStore, toastStore, errorCodeStore } from '@/stores'; +import { LOGGED_TOKEN_STORAGE_KEY } from '@/common/constants'; +import { RouteAlias } from '@/router/alias'; +import { getCurrentLang } from '@/utils/localize'; +import Storage from '@/utils/storage'; +import { floppyNavigation } from '@/utils/floppyNavigation'; +import { isIgnoredPath, IGNORE_PATH_LIST } from '@/utils/guard'; + +interface RequestAiOptions extends RequestInit { + onMessage?: (text: any) => void; + onError?: (error: Error) => void; + onComplete?: () => void; + signal?: AbortSignal; + // 添加项目配置选项 + allow404?: boolean; + ignoreError?: '403' | '50X'; + passingError?: boolean; +} + +// create a object to track the current request state +const requestState = { + currentReader: null as ReadableStreamDefaultReader | null, + abortController: null as AbortController | null, + isProcessing: false, +}; + +// HTTP error handling function (based on request.ts logic) +const handleHttpError = async ( + response: Response, + options: RequestAiOptions, +): Promise => { + const { status } = response; + let errBody: any = {}; + + try { + const text = await response.text(); + errBody = text ? JSON.parse(text) : {}; + } catch { + errBody = { msg: response.statusText }; + } + + const { data = {}, msg = '', config } = errBody || {}; + + const errorObject = { + code: status, + msg, + data, + }; + + if (status === 400) { + if (data?.err_type && options?.passingError) { + return Promise.reject(errorObject); + } + + if (data?.err_type) { + if (data.err_type === 'toast') { + toastStore.getState().show({ + msg, + variant: 'danger', + }); + } + + if (data.err_type === 'alert') { + return Promise.reject({ msg, ...data }); + } + + if (data.err_type === 'modal') { + Modal.confirm({ + content: msg, + }); + } + return Promise.reject(false); + } + + if (Array.isArray(data) && data.length > 0) { + return Promise.reject({ + ...errorObject, + isError: true, + list: data, + }); + } + + if (!data || Object.keys(data).length <= 0) { + Modal.confirm({ + content: msg, + showConfirm: false, + cancelText: 'close', + }); + return Promise.reject(false); + } + } + + // 401: 重新登录 + if (status === 401) { + errorCodeStore.getState().reset(); + loggedUserInfoStore.getState().clear(); + floppyNavigation.navigateToLogin(); + return Promise.reject(false); + } + + if (status === 403) { + // Permission interception + if (data?.type === 'url_expired') { + // url expired + floppyNavigation.navigate(RouteAlias.activationFailed, { + handler: 'replace', + }); + return Promise.reject(false); + } + if (data?.type === 'inactive') { + // inactivated + floppyNavigation.navigate(RouteAlias.inactive); + return Promise.reject(false); + } + + if (data?.type === 'suspended') { + loggedUserInfoStore.getState().clear(); + floppyNavigation.navigate(RouteAlias.suspended, { + handler: 'replace', + }); + return Promise.reject(false); + } + + if (isIgnoredPath(IGNORE_PATH_LIST)) { + return Promise.reject(false); + } + if (config?.url.includes('/admin/api')) { + errorCodeStore.getState().update('403'); + return Promise.reject(false); + } + + if (msg) { + toastStore.getState().show({ + msg, + variant: 'danger', + }); + } + return Promise.reject(false); + } + + if (status === 404 && config?.allow404) { + if (isIgnoredPath(IGNORE_PATH_LIST)) { + return Promise.reject(false); + } + errorCodeStore.getState().update('404'); + return Promise.reject(false); + } + + if (status >= 500) { + if (isIgnoredPath(IGNORE_PATH_LIST)) { + return Promise.reject(false); + } + + if (config?.ignoreError !== '50X') { + errorCodeStore.getState().update('50X'); + } + + console.error(`Request failed with status code ${status}, ${msg || ''}`); + } + return Promise.reject(errorObject); +}; +const requestAi = async (url: string, options: RequestAiOptions) => { + try { + // if there is a previous request being processed, cancel it + if (requestState.isProcessing && requestState.abortController) { + requestState.abortController.abort(); + } + + // create a new AbortController + const abortController = new AbortController(); + requestState.abortController = abortController; + + // merge the incoming signal with the new created signal + const combinedSignal = options.signal || abortController.signal; + + // mark as being processed + requestState.isProcessing = true; + + // get the authentication information and language settings (consistent with request.ts) + const token = Storage.get(LOGGED_TOKEN_STORAGE_KEY) || ''; + console.log(token); + const lang = getCurrentLang(); + + const response = await fetch(url, { + ...options, + method: 'POST', + signal: combinedSignal, + headers: { + Authorization: token, + 'Accept-Language': lang, + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + // unified error handling (based on request.ts logic) + if (!response.ok) { + await handleHttpError(response, options); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('ReadableStream not supported'); + } + + // store the current reader so it can be cancelled later + requestState.currentReader = reader; + + const decoder = new TextDecoder(); + let buffer = ''; + + const processStream = async (): Promise => { + try { + const { value, done } = await reader.read(); + + if (done) { + options.onComplete?.(); + requestState.isProcessing = false; + requestState.currentReader = null; + return; + } + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + lines.forEach((line) => { + if (line.trim()) { + try { + // handle the special [DONE] signal + const cleanedLine = line.replace(/^data: /, '').trim(); + if (cleanedLine === '[DONE]') { + return; // skip the [DONE] signal processing + } + + if (cleanedLine) { + const parsedLine = JSON.parse(cleanedLine); + options.onMessage?.(parsedLine); + } + } catch (error) { + console.debug('Error parsing line:', line); + } + } + }); + + // check if it has been cancelled + if (combinedSignal.aborted) { + requestState.isProcessing = false; + requestState.currentReader = null; + throw new Error('Request was aborted'); + } + + await processStream(); + } catch (error) { + if ((error as Error).message === 'Request was aborted') { + options.onComplete?.(); + } else { + throw error; // rethrow other errors + } + } + }; + + await processStream(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + // if the error is caused by cancellation, do not treat it as an error + if ( + errorMessage !== 'The user aborted a request' && + errorMessage !== 'Request was aborted' + ) { + console.error('Request AI Error:', errorMessage); + options.onError?.(new Error(errorMessage)); + } else { + console.log('Request was cancelled by user'); + options.onComplete?.(); // cancellation is also considered complete + } + } finally { + requestState.isProcessing = false; + requestState.currentReader = null; + } +}; + +// add a function to cancel the current request +const cancelCurrentRequest = () => { + if (requestState.abortController) { + requestState.abortController.abort(); + console.log('AI request cancelled by user'); + return true; + } + return false; +}; + +export { cancelCurrentRequest }; +export default requestAi;