From b2e6e511cd3dca75bf71786bd914e0b37c5422e3 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 21 Apr 2026 17:32:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=99=BE=E7=A7=91=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E5=85=A5=E5=90=91=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/v1/plant/ai_chat_api.go | 65 ++++ api/v1/plant/enter.go | 2 + api/v1/plant/wiki.go | 42 +++ api/v1/system/enter.go | 2 + api/v1/system/sys_ai_config.go | 98 ++++++ go.mod | 22 +- go.sum | 64 ++-- initialize/gorm.go | 1 + initialize/router.go | 12 +- model/plant/wiki.go | 3 +- model/system/sys_ai_config.go | 23 ++ router/plant/ai_chat_router.go | 15 + router/plant/enter.go | 2 + router/plant/wiki_router.go | 2 + router/system/enter.go | 2 + router/system/sys_ai_config_router.go | 17 + service/plant/ai_rag_service.go | 395 ++++++++++++++++++++++++ service/plant/enter.go | 1 + service/plant/wiki.go | 19 +- service/system/enter.go | 1 + service/system/sys_ai_config_service.go | 49 +++ 21 files changed, 802 insertions(+), 35 deletions(-) create mode 100644 api/v1/plant/ai_chat_api.go create mode 100644 api/v1/system/sys_ai_config.go create mode 100644 model/system/sys_ai_config.go create mode 100644 router/plant/ai_chat_router.go create mode 100644 router/system/sys_ai_config_router.go create mode 100644 service/plant/ai_rag_service.go create mode 100644 service/system/sys_ai_config_service.go diff --git a/api/v1/plant/ai_chat_api.go b/api/v1/plant/ai_chat_api.go new file mode 100644 index 0000000..98f54ec --- /dev/null +++ b/api/v1/plant/ai_chat_api.go @@ -0,0 +1,65 @@ +package plant + +import ( + "sundynix-go/global" + "sundynix-go/model/commom/response" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type AiChatApi struct{} + +// ChatStreamPlant 接收用户输入,基于植物百科 RAG 产生流式 SSE 回答 +// @Summary 植物助手聊天(SSE 流式输出) +// @Tags Plant-AiChat +// @Param query query string true "用户提问内容" +// @Produce text/event-stream +// @Router /plant/chat/stream [get] +func (a *AiChatApi) ChatStreamPlant(c *gin.Context) { + query := c.Query("query") + if query == "" { + response.FailWithMsg("参数 query 不能为空", c) + return + } + + // SSE Headers(微信小程序通过 enableChunked: true 配合实现打字机效果) + w := c.Writer + header := w.Header() + header.Set("Content-Type", "text/event-stream") + header.Set("Cache-Control", "no-cache") + header.Set("Connection", "keep-alive") + header.Set("Transfer-Encoding", "chunked") + w.WriteHeader(200) + + err := aiRagService.PlantChatStreamRAG(c.Request.Context(), query, func(chunk string) error { + _, writeErr := w.WriteString("data: " + chunk + "\n\n") + w.Flush() + return writeErr + }) + + if err != nil { + global.Logger.Error("PlantChatStreamRAG error", zap.Error(err)) + _, _ = w.WriteString("data: [ERROR]" + err.Error() + "\n\n") + w.Flush() + } else { + // 流结束标志 + _, _ = w.WriteString("data: [DONE]\n\n") + w.Flush() + } +} + +// SyncWikiToQdrant 手动触发全量植物百科同步到 Qdrant +// @Summary 同步植物百科数据到 Qdrant 向量库 +// @Tags System-SysAiConfig +// @Produce json +// @Success 200 {object} response.Response +// @Router /plant/chat/sync [post] +func (a *AiChatApi) SyncWikiToQdrant(c *gin.Context) { + if err := aiRagService.SyncWikiToQdrant(); err != nil { + global.Logger.Error("SyncWikiToQdrant error", zap.Error(err)) + response.FailWithMsg("同步失败: "+err.Error(), c) + return + } + response.OkWithMsg("同步成功", c) +} diff --git a/api/v1/plant/enter.go b/api/v1/plant/enter.go index 2f8f8b2..19f7d88 100644 --- a/api/v1/plant/enter.go +++ b/api/v1/plant/enter.go @@ -14,6 +14,7 @@ type ApiGroup struct { BadgeConfigApi CallbackApi ExchangeApi + AiChatApi } var ( @@ -28,4 +29,5 @@ var ( badgeConfigService = service.GroupApp.PlantServiceGroup.BadgeConfigService callbackService = service.GroupApp.PlantServiceGroup.CallbackService exchangeService = service.GroupApp.PlantServiceGroup.ExchangeService + aiRagService = service.GroupApp.PlantServiceGroup.AiRagService ) diff --git a/api/v1/plant/wiki.go b/api/v1/plant/wiki.go index cea8e09..e668a06 100644 --- a/api/v1/plant/wiki.go +++ b/api/v1/plant/wiki.go @@ -139,6 +139,48 @@ func (a *WikiApi) DeleteWiki(c *gin.Context) { response.OkWithMsg("删除成功", c) } +// SyncWikiQdrant 单条百科同步到 Qdrant +// @Tags 百科 +// @Summary 百科同步到Qdrant +// @Security BearerAuth +// @Produce application/json +// @Param data body common.GetById true "单条百科" +// @Router /wiki/sync-qdrant [post] +func (a *WikiApi) SyncWikiQdrant(c *gin.Context) { + var req common.GetById + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + if err := aiRagService.SyncSingleWiki(req.ID); err != nil { + global.Logger.Error("同步 Qdrant 失败", zap.Error(err)) + response.FailWithMsg("同步失败: "+err.Error(), c) + return + } + response.OkWithMsg("同步成功", c) +} + +// DeleteWikiQdrant 从 Qdrant 移除单条百科向量 +// @Tags 百科 +// @Summary 百科移除Qdrant +// @Security BearerAuth +// @Produce application/json +// @Param data body common.GetById true "单条百科" +// @Router /wiki/delete-qdrant [post] +func (a *WikiApi) DeleteWikiQdrant(c *gin.Context) { + var req common.GetById + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMsg("请求参数错误", c) + return + } + if err := aiRagService.DeleteFromQdrant(req.ID); err != nil { + global.Logger.Error("从 Qdrant 删除向量失败", zap.Error(err)) + response.FailWithMsg("移除失败: "+err.Error(), c) + return + } + response.OkWithMsg("移除成功", c) +} + // StarWiki 收藏百科 // @Tags 百科 // @Summary 收藏百科 diff --git a/api/v1/system/enter.go b/api/v1/system/enter.go index 343112f..3e9d350 100644 --- a/api/v1/system/enter.go +++ b/api/v1/system/enter.go @@ -10,6 +10,7 @@ type ApiGroup struct { MenuApi OperationRecordApi OssApi + SysAiConfigApi } var ( @@ -20,4 +21,5 @@ var ( menuService = service.GroupApp.SystemServiceGroup.MenuService operationRecordService = service.GroupApp.SystemServiceGroup.OperationRecordService ossService = service.GroupApp.SystemServiceGroup.OssService + sysAiConfigService = service.GroupApp.SystemServiceGroup.SysAiConfigService ) diff --git a/api/v1/system/sys_ai_config.go b/api/v1/system/sys_ai_config.go new file mode 100644 index 0000000..746a851 --- /dev/null +++ b/api/v1/system/sys_ai_config.go @@ -0,0 +1,98 @@ +package system + +import ( + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "sundynix-go/global" + "sundynix-go/model/commom/response" + "sundynix-go/model/system" +) + +type SysAiConfigApi struct{} + +// CreateAiConfig 创建 AI 配置 +// @Summary 创建 AI 配置 +// @Tags System-SysAiConfig +// @accept json +// @Produce json +// @Param data body system.SysAiConfig true "配置模型" +// @Success 200 {object} response.Response +// @Router /aiConfig/create [post] +func (a *SysAiConfigApi) CreateAiConfig(c *gin.Context) { + var cfg system.SysAiConfig + if err := c.ShouldBindJSON(&cfg); err != nil { + response.FailWithMsg("参数错误:"+err.Error(), c) + return + } + if err := sysAiConfigService.Create(&cfg); err != nil { + global.Logger.Error("创建AI配置失败", zap.Error(err)) + response.FailWithMsg("创建失败:"+err.Error(), c) + return + } + response.OkWithMsg("创建成功", c) +} + +// UpdateAiConfig 更新 AI 配置 +// @Summary 更新 AI 配置 +// @Tags System-SysAiConfig +// @accept json +// @Produce json +// @Param data body system.SysAiConfig true "配置模型" +// @Success 200 {object} response.Response +// @Router /aiConfig/update [put] +func (a *SysAiConfigApi) UpdateAiConfig(c *gin.Context) { + var cfg system.SysAiConfig + if err := c.ShouldBindJSON(&cfg); err != nil { + response.FailWithMsg("参数错误:"+err.Error(), c) + return + } + if err := sysAiConfigService.Update(&cfg); err != nil { + global.Logger.Error("更新AI配置失败", zap.Error(err)) + response.FailWithMsg("更新失败:"+err.Error(), c) + return + } + response.OkWithMsg("更新成功", c) +} + +// SetActive 设置激活配置 +// @Summary 设置激活配置(同一时间只有一条激活) +// @Tags System-SysAiConfig +// @accept json +// @Produce json +// @Param data body object true "{ \"id\": \"xxx\" }" +// @Success 200 {object} response.Response +// @Router /aiConfig/setActive [post] +func (a *SysAiConfigApi) SetActive(c *gin.Context) { + var body struct { + Id string `json:"id" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.FailWithMsg("参数错误:"+err.Error(), c) + return + } + if err := sysAiConfigService.SetActive(body.Id); err != nil { + global.Logger.Error("设置激活AI配置失败", zap.Error(err)) + response.FailWithMsg("设置失败:"+err.Error(), c) + return + } + response.OkWithMsg("设置成功", c) +} + +// GetList 获取配置列表 +// @Summary 获取 AI 配置列表 +// @Tags System-SysAiConfig +// @Produce json +// @Success 200 {object} response.Response +// @Router /aiConfig/list [get] +func (a *SysAiConfigApi) GetList(c *gin.Context) { + list, err := sysAiConfigService.GetList() + if err != nil { + global.Logger.Error("获取AI配置列表失败", zap.Error(err)) + response.FailWithMsg("获取失败:"+err.Error(), c) + return + } + response.OkWithData(response.PageResult{ + List: list, + Total: int64(len(list)), + }, c) +} diff --git a/go.mod b/go.mod index 798a7b8..3c18692 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,10 @@ require ( github.com/google/uuid v1.6.0 github.com/minio/minio-go/v7 v7.0.95 github.com/mojocn/base64Captcha v1.3.8 + github.com/qdrant/go-client v1.17.1 github.com/redis/go-redis/v9 v9.7.3 github.com/robfig/cron/v3 v3.0.1 + github.com/sashabaranov/go-openai v1.41.2 github.com/spf13/viper v1.20.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 @@ -22,8 +24,9 @@ require ( github.com/tencentyun/cos-go-sdk-v5 v0.7.70 github.com/wechatpay-apiv3/wechatpay-go v0.2.21 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.45.0 - golang.org/x/sync v0.18.0 + golang.org/x/crypto v0.48.0 + golang.org/x/sync v0.19.0 + google.golang.org/grpc v1.78.0 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.26.0 @@ -75,7 +78,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -104,12 +107,13 @@ require ( golang.org/x/arch v0.21.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/image v0.26.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.64.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 2cd1e9c..8ad0c2d 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,10 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= @@ -96,6 +100,8 @@ github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJD github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -123,8 +129,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -163,6 +169,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qdrant/go-client v1.17.1 h1:7QmPwDddrHL3hC4NfycwtQlraVKRLcRi++BX6TTm+3g= +github.com/qdrant/go-client v1.17.1/go.mod h1:n1h6GhkdAzcohoXt/5Z19I2yxbCkMA6Jejob3S6NZT8= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -176,6 +184,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +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/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -218,6 +228,18 @@ github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -231,8 +253,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= @@ -243,8 +265,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -254,8 +276,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -263,8 +285,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -276,8 +298,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -295,19 +317,25 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/initialize/gorm.go b/initialize/gorm.go index f6bb3cb..1a1986a 100644 --- a/initialize/gorm.go +++ b/initialize/gorm.go @@ -38,6 +38,7 @@ func MigrateTable() { system.Menu{}, system.SysOperationRecord{}, system.Oss{}, + system.SysAiConfig{}, plant.MyPlant{}, //我的植物 plant.CarePlan{}, //植物养护计划 diff --git a/initialize/router.go b/initialize/router.go index c1052b7..a58fee5 100644 --- a/initialize/router.go +++ b/initialize/router.go @@ -41,11 +41,12 @@ func Routers() { { //需要鉴权的路由 - systemRouter.InitUserRouter(NeedAuthGroup) //用户相关 - systemRouter.InitClientRouter(NeedAuthGroup) //客户端相关 - systemRouter.InitRoleRouter(NeedAuthGroup) //角色相关 - systemRouter.InitMenuRouter(NeedAuthGroup) //菜单相关 - systemRouter.InitOssRouter(NeedAuthGroup) //OSS相关 + systemRouter.InitUserRouter(NeedAuthGroup) //用户相关 + systemRouter.InitClientRouter(NeedAuthGroup) //客户端相关 + systemRouter.InitRoleRouter(NeedAuthGroup) //角色相关 + systemRouter.InitMenuRouter(NeedAuthGroup) //菜单相关 + systemRouter.InitOssRouter(NeedAuthGroup) //OSS相关 + systemRouter.InitSysAiConfigRouter(NeedAuthGroup) //AI配置相关 } { @@ -60,6 +61,7 @@ func Routers() { plantGroup.InitBadgeConfigRouter(NeedAuthGroup) //徽章配置 plantGroup.InitUserProfileRouter(NeedAuthGroup) //用户资料 plantGroup.InitExchangeRouter(NeedAuthGroup) //兑换中心 + plantGroup.InitAiChatRouter(NeedAuthGroup) //AI聊天 } diff --git a/model/plant/wiki.go b/model/plant/wiki.go index 6a59831..ecc5f01 100644 --- a/model/plant/wiki.go +++ b/model/plant/wiki.go @@ -7,7 +7,8 @@ import ( type Wiki struct { global.BaseModel - IsHot int `json:"isHot" form:"isHot" gorm:"column:is_hot;comment:是否推荐植物"` + IsHot int `json:"isHot" form:"isHot" gorm:"column:is_hot;comment:是否推荐植物"` + IsVectorSynced int `json:"isVectorSynced" form:"isVectorSynced" gorm:"column:is_vector_synced;type:tinyint;default:0;comment:是否已同步到向量库(0否1是)"` //基本信息 Name string `json:"name" form:"name" gorm:"column:name;size:50;comment:名称"` LatinName string `json:"latinName" form:"latinName" gorm:"size:100;column:latin_name;comment:拉丁名"` diff --git a/model/system/sys_ai_config.go b/model/system/sys_ai_config.go new file mode 100644 index 0000000..c27a02b --- /dev/null +++ b/model/system/sys_ai_config.go @@ -0,0 +1,23 @@ +package system + +import "sundynix-go/global" + +type SysAiConfig struct { + global.BaseModel + IsActive int `gorm:"column:is_active;type:tinyint;default:0;comment:是否激活(1是0否)" json:"isActive" form:"isActive"` + // Qdrant 向量库配置 + QdrantUrl string `gorm:"column:qdrant_url;type:varchar(255);comment:Qdrant接口地址" json:"qdrantUrl" form:"qdrantUrl"` + QdrantApiKey string `gorm:"column:qdrant_api_key;type:varchar(255);comment:Qdrant密钥" json:"qdrantApiKey" form:"qdrantApiKey"` + QdrantCollection string `gorm:"column:qdrant_collection;type:varchar(100);comment:Qdrant集合名" json:"qdrantCollection" form:"qdrantCollection"` + VectorDimension int `gorm:"column:vector_dimension;type:int;comment:向量维度(默认104)" json:"vectorDimension" form:"vectorDimension"` + // 对话大模型配置(如 deepseek-chat、qwen-max、ollama 本地等) + ChatProvider string `gorm:"column:chat_provider;type:varchar(50);comment:对话模型供应商(deepseek/qwen/local等)" json:"chatProvider" form:"chatProvider"` + ChatApiUrl string `gorm:"column:chat_api_url;type:varchar(255);comment:对话模型接口地址" json:"chatApiUrl" form:"chatApiUrl"` + ChatApiKey string `gorm:"column:chat_api_key;type:varchar(255);comment:对话模型ApiKey" json:"chatApiKey" form:"chatApiKey"` + ChatModelName string `gorm:"column:chat_model_name;type:varchar(100);comment:对话模型名称" json:"chatModelName" form:"chatModelName"` + // Embedding 向量化模型配置(可与对话模型用不同供应商,如 bge-m3 本地 + deepseek 对话) + EmbeddingProvider string `gorm:"column:embedding_provider;type:varchar(50);comment:Embedding模型供应商" json:"embeddingProvider" form:"embeddingProvider"` + EmbeddingApiUrl string `gorm:"column:embedding_api_url;type:varchar(255);comment:Embedding模型接口地址" json:"embeddingApiUrl" form:"embeddingApiUrl"` + EmbeddingApiKey string `gorm:"column:embedding_api_key;type:varchar(255);comment:Embedding模型ApiKey" json:"embeddingApiKey" form:"embeddingApiKey"` + EmbeddingModelName string `gorm:"column:embedding_model_name;type:varchar(100);comment:Embedding模型名称" json:"embeddingModelName" form:"embeddingModelName"` +} diff --git a/router/plant/ai_chat_router.go b/router/plant/ai_chat_router.go new file mode 100644 index 0000000..54c6c5e --- /dev/null +++ b/router/plant/ai_chat_router.go @@ -0,0 +1,15 @@ +package plant + +import "github.com/gin-gonic/gin" + +type AiChatRouter struct{} + +func (s *AiChatRouter) InitAiChatRouter(Router *gin.RouterGroup) (R gin.IRoutes) { + aiChatRouter := Router.Group("plant/chat") + aiChatApi := aiChatApi + { + aiChatRouter.GET("stream", aiChatApi.ChatStreamPlant) // SSE 对话流 + aiChatRouter.POST("sync", aiChatApi.SyncWikiToQdrant) // 后台知识库同步 + } + return aiChatRouter +} diff --git a/router/plant/enter.go b/router/plant/enter.go index 5890a00..e5cd2b0 100644 --- a/router/plant/enter.go +++ b/router/plant/enter.go @@ -14,6 +14,7 @@ type RouterGroup struct { UserProfileRouter CallbackRouter ExchangeRouter + AiChatRouter } // 初始化路由 @@ -29,4 +30,5 @@ var ( badgeConfigApi = v1.ApiGroupApp.PlantApiGroup.BadgeConfigApi callbackApi = v1.ApiGroupApp.PlantApiGroup.CallbackApi exchangeApi = v1.ApiGroupApp.PlantApiGroup.ExchangeApi + aiChatApi = v1.ApiGroupApp.PlantApiGroup.AiChatApi ) diff --git a/router/plant/wiki_router.go b/router/plant/wiki_router.go index 28f0a92..5017542 100644 --- a/router/plant/wiki_router.go +++ b/router/plant/wiki_router.go @@ -13,6 +13,8 @@ func (p *WikiRouter) InitWikiRouter(Router *gin.RouterGroup) { wikiRouter.GET("/detail", wikiApi.WikiDetail) wikiRouter.POST("/delete", wikiApi.DeleteWiki) wikiRouter.POST("/uploadImg", wikiApi.UploadImg) + wikiRouter.POST("/sync-qdrant", wikiApi.SyncWikiQdrant) + wikiRouter.POST("/delete-qdrant", wikiApi.DeleteWikiQdrant) //用户端 wikiRouter.GET("/star", wikiApi.StarWiki) //收藏或者取消收藏 diff --git a/router/system/enter.go b/router/system/enter.go index ad78fb8..ee6ff66 100644 --- a/router/system/enter.go +++ b/router/system/enter.go @@ -10,6 +10,7 @@ type SysRouterGroup struct { MenuRouter OperationRecordRouter OssRouter + SysAiConfigRouter } // 初始化路由 @@ -21,4 +22,5 @@ var ( menuApi = v1.ApiGroupApp.SystemApiGroup.MenuApi operationRecordApi = v1.ApiGroupApp.SystemApiGroup.OperationRecordApi ossApi = v1.ApiGroupApp.SystemApiGroup.OssApi + sysAiConfigApi = v1.ApiGroupApp.SystemApiGroup.SysAiConfigApi ) diff --git a/router/system/sys_ai_config_router.go b/router/system/sys_ai_config_router.go new file mode 100644 index 0000000..b403165 --- /dev/null +++ b/router/system/sys_ai_config_router.go @@ -0,0 +1,17 @@ +package system + +import "github.com/gin-gonic/gin" + +type SysAiConfigRouter struct{} + +func (s *SysAiConfigRouter) InitSysAiConfigRouter(Router *gin.RouterGroup) (R gin.IRoutes) { + sysAiConfigRouter := Router.Group("aiConfig") + sysAiConfigApi := sysAiConfigApi + { + sysAiConfigRouter.POST("create", sysAiConfigApi.CreateAiConfig) // 创建配置 + sysAiConfigRouter.PUT("update", sysAiConfigApi.UpdateAiConfig) // 更新配置 + sysAiConfigRouter.POST("setActive", sysAiConfigApi.SetActive) // 设置激活状态 + sysAiConfigRouter.GET("list", sysAiConfigApi.GetList) // 获取列表 + } + return sysAiConfigRouter +} diff --git a/service/plant/ai_rag_service.go b/service/plant/ai_rag_service.go new file mode 100644 index 0000000..fd4f837 --- /dev/null +++ b/service/plant/ai_rag_service.go @@ -0,0 +1,395 @@ +package plant + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/google/uuid" + qdrant "github.com/qdrant/go-client/qdrant" + openai "github.com/sashabaranov/go-openai" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "sundynix-go/global" + plantModel "sundynix-go/model/plant" + "sundynix-go/model/system" + systemService "sundynix-go/service/system" +) + +type AiRagService struct{} + +var sysAiConfigService = systemService.SysAiConfigService{} + +// ────────────────────────────────────────────────────────────── +// OpenAI 客户端构建 +// ────────────────────────────────────────────────────────────── + +func getChatClient(cfg *system.SysAiConfig) *openai.Client { + config := openai.DefaultConfig(cfg.ChatApiKey) + if cfg.ChatApiUrl != "" { + config.BaseURL = cfg.ChatApiUrl + } + return openai.NewClientWithConfig(config) +} + +func getEmbeddingClient(cfg *system.SysAiConfig) *openai.Client { + config := openai.DefaultConfig(cfg.EmbeddingApiKey) + if cfg.EmbeddingApiUrl != "" { + config.BaseURL = cfg.EmbeddingApiUrl + } + return openai.NewClientWithConfig(config) +} + +// ────────────────────────────────────────────────────────────── +// Qdrant gRPC 连接 +// ────────────────────────────────────────────────────────────── + +func newQdrantConn(cfg *system.SysAiConfig) (*grpc.ClientConn, context.Context, error) { + addr := strings.TrimPrefix(cfg.QdrantUrl, "http://") + addr = strings.TrimPrefix(addr, "https://") + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, nil, fmt.Errorf("qdrant grpc dial failed: %w", err) + } + ctx := context.Background() + if cfg.QdrantApiKey != "" { + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("api-key", cfg.QdrantApiKey)) + } + return conn, ctx, nil +} + +// EnsureCollection 确保 Collection 存在,不存在则创建 +func EnsureCollection(cfg *system.SysAiConfig) error { + conn, ctx, err := newQdrantConn(cfg) + if err != nil { + return err + } + defer conn.Close() + dim := uint64(cfg.VectorDimension) + if dim == 0 { + dim = 104 + } + collClient := qdrant.NewCollectionsClient(conn) + if _, getErr := collClient.Get(ctx, &qdrant.GetCollectionInfoRequest{CollectionName: cfg.QdrantCollection}); getErr == nil { + return nil // 已存在 + } + _, err = collClient.Create(ctx, &qdrant.CreateCollection{ + CollectionName: cfg.QdrantCollection, + VectorsConfig: &qdrant.VectorsConfig{ + Config: &qdrant.VectorsConfig_Params{ + Params: &qdrant.VectorParams{Size: dim, Distance: qdrant.Distance_Cosine}, + }, + }, + }) + if err != nil { + return fmt.Errorf("qdrant create collection failed: %w", err) + } + global.Logger.Info("Qdrant collection created", zap.String("collection", cfg.QdrantCollection)) + return nil +} + +// wikiID → Qdrant point UUID(确保幂等) +func wikiToQdrantID(wikiId string) string { + return uuid.NewMD5(uuid.NameSpaceOID, []byte(wikiId)).String() +} + +// buildWikiText 拼接用于向量化的文本语料 +func buildWikiText(w plantModel.Wiki) string { + return fmt.Sprintf( + "植物名字:%s. 拉丁名:%s. 科属:%s. 生命周期:%s. 生长习性:%s. 病虫害:%s. 光照类型:%s. 最佳温度:%s.", + w.Name, w.LatinName, w.Genus, w.LifeCycle, w.GrowthHabit, + w.PestsDiseases, w.LightType, w.OptimalTempPeriod, + ) +} + +// ────────────────────────────────────────────────────────────── +// SyncSingleWikiAsync 异步同步单条百科到 Qdrant(新增/更新时调用) +// 同步成功后将 is_vector_synced 置为 1 +// ────────────────────────────────────────────────────────────── +func (s *AiRagService) SyncSingleWikiAsync(wikiId string) { + go func() { + if err := s.syncSingleWiki(wikiId); err != nil { + global.Logger.Error("Async sync wiki to Qdrant failed", zap.String("wiki_id", wikiId), zap.Error(err)) + } + }() +} + +// SyncSingleWiki 同步同步单条百科到 Qdrant(用于API直接调用) +func (s *AiRagService) SyncSingleWiki(wikiId string) error { + return s.syncSingleWiki(wikiId) +} + +func (s *AiRagService) syncSingleWiki(wikiId string) error { + cfg, err := sysAiConfigService.GetActiveAiConfig() + if err != nil { + return fmt.Errorf("no active ai config: %w", err) + } + if err = EnsureCollection(cfg); err != nil { + global.Logger.Warn("EnsureCollection warn", zap.Error(err)) + } + + var w plantModel.Wiki + if err = global.DB.Where("id = ?", wikiId).First(&w).Error; err != nil { + return fmt.Errorf("wiki not found: %w", err) + } + + text := buildWikiText(w) + embClient := getEmbeddingClient(cfg) + embResp, err := embClient.CreateEmbeddings(context.Background(), openai.EmbeddingRequest{ + Input: []string{text}, + Model: openai.EmbeddingModel(cfg.EmbeddingModelName), + }) + if err != nil { + return fmt.Errorf("embedding failed: %w", err) + } + + conn, qdCtx, err := newQdrantConn(cfg) + if err != nil { + return err + } + defer conn.Close() + ptsClient := qdrant.NewPointsClient(conn) + + _, err = ptsClient.Upsert(qdCtx, &qdrant.UpsertPoints{ + CollectionName: cfg.QdrantCollection, + Points: []*qdrant.PointStruct{{ + Id: qdrant.NewID(wikiToQdrantID(wikiId)), + Vectors: qdrant.NewVectors(embResp.Data[0].Embedding...), + Payload: map[string]*qdrant.Value{ + "wiki_id": qdrant.NewValueString(w.Id), + "name": qdrant.NewValueString(w.Name), + "full_text": qdrant.NewValueString(text), + }, + }}, + }) + if err != nil { + return fmt.Errorf("qdrant upsert failed: %w", err) + } + + // 更新同步状态 + _ = global.DB.Model(&plantModel.Wiki{}).Where("id = ?", wikiId).Update("is_vector_synced", 1).Error + global.Logger.Info("Wiki synced to Qdrant", zap.String("wiki_id", wikiId)) + return nil +} + +// ────────────────────────────────────────────────────────────── +// DeleteFromQdrant 从 Qdrant 删除单条植物的向量点位 +// 删除成功后将 is_vector_synced 置为 0 +// ────────────────────────────────────────────────────────────── +func (s *AiRagService) DeleteFromQdrant(wikiId string) error { + cfg, err := sysAiConfigService.GetActiveAiConfig() + if err != nil { + return fmt.Errorf("no active ai config: %w", err) + } + conn, qdCtx, err := newQdrantConn(cfg) + if err != nil { + return err + } + defer conn.Close() + ptsClient := qdrant.NewPointsClient(conn) + + qID := wikiToQdrantID(wikiId) + _, err = ptsClient.Delete(qdCtx, &qdrant.DeletePoints{ + CollectionName: cfg.QdrantCollection, + Points: &qdrant.PointsSelector{ + PointsSelectorOneOf: &qdrant.PointsSelector_Points{ + Points: &qdrant.PointsIdsList{ + Ids: []*qdrant.PointId{qdrant.NewID(qID)}, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("qdrant delete failed: %w", err) + } + _ = global.DB.Model(&plantModel.Wiki{}).Where("id = ?", wikiId).Update("is_vector_synced", 0).Error + global.Logger.Info("Wiki deleted from Qdrant", zap.String("wiki_id", wikiId)) + return nil +} + +// DeleteFromQdrantBatch 批量从 Qdrant 删除(用于批量删除百科时) +func (s *AiRagService) DeleteFromQdrantBatch(wikiIds []string) { + go func() { + cfg, err := sysAiConfigService.GetActiveAiConfig() + if err != nil { + global.Logger.Warn("No active ai config for batch qdrant delete", zap.Error(err)) + return + } + conn, qdCtx, err := newQdrantConn(cfg) + if err != nil { + global.Logger.Warn("Qdrant connect failed for batch delete", zap.Error(err)) + return + } + defer conn.Close() + ptsClient := qdrant.NewPointsClient(conn) + + var ids []*qdrant.PointId + for _, wid := range wikiIds { + ids = append(ids, qdrant.NewID(wikiToQdrantID(wid))) + } + _, err = ptsClient.Delete(qdCtx, &qdrant.DeletePoints{ + CollectionName: cfg.QdrantCollection, + Points: &qdrant.PointsSelector{ + PointsSelectorOneOf: &qdrant.PointsSelector_Points{ + Points: &qdrant.PointsIdsList{Ids: ids}, + }, + }, + }) + if err != nil { + global.Logger.Error("Qdrant batch delete failed", zap.Error(err)) + } else { + global.Logger.Info("Qdrant batch delete done", zap.Int("count", len(wikiIds))) + } + }() +} + +// ────────────────────────────────────────────────────────────── +// SyncWikiToQdrant 全量同步(后台操作/手动触发) +// ────────────────────────────────────────────────────────────── +func (s *AiRagService) SyncWikiToQdrant() error { + cfg, err := sysAiConfigService.GetActiveAiConfig() + if err != nil { + return err + } + if err = EnsureCollection(cfg); err != nil { + global.Logger.Warn("EnsureCollection failed, continuing", zap.Error(err)) + } + + var wikis []plantModel.Wiki + if err = global.DB.Find(&wikis).Error; err != nil { + return err + } + + embClient := getEmbeddingClient(cfg) + conn, qdCtx, err := newQdrantConn(cfg) + if err != nil { + return err + } + defer conn.Close() + ptsClient := qdrant.NewPointsClient(conn) + + var successIds []string + for _, w := range wikis { + text := buildWikiText(w) + embResp, embErr := embClient.CreateEmbeddings(context.Background(), openai.EmbeddingRequest{ + Input: []string{text}, + Model: openai.EmbeddingModel(cfg.EmbeddingModelName), + }) + if embErr != nil { + global.Logger.Error("Embedding failed", zap.String("wiki_id", w.Id), zap.Error(embErr)) + continue + } + _, upsertErr := ptsClient.Upsert(qdCtx, &qdrant.UpsertPoints{ + CollectionName: cfg.QdrantCollection, + Points: []*qdrant.PointStruct{{ + Id: qdrant.NewID(wikiToQdrantID(w.Id)), + Vectors: qdrant.NewVectors(embResp.Data[0].Embedding...), + Payload: map[string]*qdrant.Value{ + "wiki_id": qdrant.NewValueString(w.Id), + "name": qdrant.NewValueString(w.Name), + "full_text": qdrant.NewValueString(text), + }, + }}, + }) + if upsertErr != nil { + global.Logger.Error("Qdrant upsert failed", zap.String("wiki_id", w.Id), zap.Error(upsertErr)) + } else { + successIds = append(successIds, w.Id) + } + } + + // 批量更新同步状态 + if len(successIds) > 0 { + _ = global.DB.Model(&plantModel.Wiki{}).Where("id IN ?", successIds).Update("is_vector_synced", 1).Error + } + global.Logger.Info("SyncWikiToQdrant done", zap.Int("total", len(wikis)), zap.Int("success", len(successIds))) + return nil +} + +// ────────────────────────────────────────────────────────────── +// PlantChatStreamRAG 向量检索 + 大模型流式对话 +// ────────────────────────────────────────────────────────────── +func (s *AiRagService) PlantChatStreamRAG(ctx context.Context, userQuery string, onData func(chunk string) error) error { + cfg, err := sysAiConfigService.GetActiveAiConfig() + if err != nil { + return err + } + + embClient := getEmbeddingClient(cfg) + chatClient := getChatClient(cfg) + + embResp, err := embClient.CreateEmbeddings(ctx, openai.EmbeddingRequest{ + Input: []string{userQuery}, + Model: openai.EmbeddingModel(cfg.EmbeddingModelName), + }) + if err != nil { + return fmt.Errorf("向量化查询失败: %w", err) + } + + conn, qdCtx, connErr := newQdrantConn(cfg) + var contextText string + if connErr == nil { + defer conn.Close() + ptsClient := qdrant.NewPointsClient(conn) + limit := uint64(3) + searchRes, searchErr := ptsClient.Search(qdCtx, &qdrant.SearchPoints{ + CollectionName: cfg.QdrantCollection, + Vector: embResp.Data[0].Embedding, + Limit: limit, + WithPayload: &qdrant.WithPayloadSelector{ + SelectorOptions: &qdrant.WithPayloadSelector_Enable{Enable: true}, + }, + }) + if searchErr != nil { + global.Logger.Warn("Qdrant search failed, using empty context", zap.Error(searchErr)) + } else { + for _, pt := range searchRes.GetResult() { + if txt, ok := pt.GetPayload()["full_text"]; ok { + contextText += txt.GetStringValue() + "\n" + } + } + } + } else { + global.Logger.Warn("Qdrant connect failed, skipping RAG", zap.Error(connErr)) + } + + systemPrompt := "你是一个专业的植物百科助手,请基于以下知识库信息回答用户问题。如果知识库无相关信息,结合你的通用知识作答。\n" + if contextText != "" { + systemPrompt += "--- 知识库 ---\n" + contextText + "\n--------------\n" + } + + stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ + Model: cfg.ChatModelName, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, + {Role: openai.ChatMessageRoleUser, Content: userQuery}, + }, + Stream: true, + }) + if err != nil { + return fmt.Errorf("大模型调用失败: %w", err) + } + defer stream.Close() + + for { + resp, recvErr := stream.Recv() + if errors.Is(recvErr, io.EOF) { + break + } + if recvErr != nil { + return recvErr + } + if len(resp.Choices) > 0 { + if content := resp.Choices[0].Delta.Content; content != "" { + if writeErr := onData(content); writeErr != nil { + return writeErr + } + } + } + } + return nil +} diff --git a/service/plant/enter.go b/service/plant/enter.go index 2cca249..400b8b4 100644 --- a/service/plant/enter.go +++ b/service/plant/enter.go @@ -12,4 +12,5 @@ type ServiceGroup struct { UserProfileService CallbackService ExchangeService + AiRagService } diff --git a/service/plant/wiki.go b/service/plant/wiki.go index 87ac115..c498d18 100644 --- a/service/plant/wiki.go +++ b/service/plant/wiki.go @@ -11,13 +11,15 @@ import ( "gorm.io/gorm" ) +var aiRagService = AiRagService{} + type WikiService struct{} var WikiServiceApp = new(WikiClassService) // CreateWiki 创建百科 func (s *WikiService) CreateWiki(req plantReq.CreateWiki) error { - return global.DB.Transaction(func(tx *gorm.DB) error { + err := global.DB.Transaction(func(tx *gorm.DB) error { //1.先模糊查询name是否存在 如果存在 则返回错误 if !errors.Is(tx.Where("name like ?", "%"+req.Name+"%").First(&plant.Wiki{}).Error, gorm.ErrRecordNotFound) { return errors.New("植物已经存在") @@ -117,6 +119,14 @@ func (s *WikiService) CreateWiki(req plantReq.CreateWiki) error { return nil }) + if err == nil { + // 异步同步到 Qdrant(事务提交后,wiki.Id 已可用) + var created plant.Wiki + if dbErr := global.DB.Where("name = ?", req.Name).First(&created).Error; dbErr == nil { + aiRagService.SyncSingleWikiAsync(created.Id) + } + } + return err } // UpdateWiki 修改百科 @@ -288,7 +298,7 @@ func (s *WikiService) UploadImg(req common.UploadOss) error { // DeleteWiki 删除百科 func (s *WikiService) DeleteWiki(req common.IdsReq) error { - return global.DB.Transaction(func(tx *gorm.DB) error { + err := global.DB.Transaction(func(tx *gorm.DB) error { var imgIds []string tx.Table("sundynix_wiki_oss").Where("wiki_id IN ?", req.Ids).Pluck("oss_id", &imgIds) // 3. 物理删除图片记录本身 @@ -311,4 +321,9 @@ func (s *WikiService) DeleteWiki(req common.IdsReq) error { //删除百科本身 return tx.Unscoped().Where("id IN ?", req.Ids).Delete(&plant.Wiki{}).Error }) + if err == nil { + // 异步清理 Qdrant 向量点位 + aiRagService.DeleteFromQdrantBatch(req.Ids) + } + return err } diff --git a/service/system/enter.go b/service/system/enter.go index f9f4f48..4c9254b 100644 --- a/service/system/enter.go +++ b/service/system/enter.go @@ -8,4 +8,5 @@ type ServiceGroup struct { MenuService OperationRecordService OssService + SysAiConfigService } diff --git a/service/system/sys_ai_config_service.go b/service/system/sys_ai_config_service.go new file mode 100644 index 0000000..8514c43 --- /dev/null +++ b/service/system/sys_ai_config_service.go @@ -0,0 +1,49 @@ +package system + +import ( + "errors" + "go.uber.org/zap" + "sundynix-go/global" + "sundynix-go/model/system" +) + +type SysAiConfigService struct{} + +// Create 创建AI配置 +func (s *SysAiConfigService) Create(cfg *system.SysAiConfig) error { + return global.DB.Create(cfg).Error +} + +// Update 更新AI配置 +func (s *SysAiConfigService) Update(cfg *system.SysAiConfig) error { + return global.DB.Updates(cfg).Error +} + +// SetActive 设置激活配置 +func (s *SysAiConfigService) SetActive(id string) error { + // 先将所有的设为 0 + err := global.DB.Model(&system.SysAiConfig{}).Where("1 = 1").Update("is_active", 0).Error + if err != nil { + return err + } + // 再将指定的设为 1 + return global.DB.Model(&system.SysAiConfig{}).Where("id = ?", id).Update("is_active", 1).Error +} + +// GetActiveAiConfig 获取当前激活的AI配置 +func (s *SysAiConfigService) GetActiveAiConfig() (*system.SysAiConfig, error) { + var cfg system.SysAiConfig + err := global.DB.Where("is_active = 1").First(&cfg).Error + if err != nil { + global.Logger.Error("No active AI Config found", zap.Error(err)) + return nil, errors.New("无激活状态的AI配置") + } + return &cfg, nil +} + +// GetList 获取所有配置 +func (s *SysAiConfigService) GetList() ([]system.SysAiConfig, error) { + var list []system.SysAiConfig + err := global.DB.Order("created_at desc").Find(&list).Error + return list, err +}