From f2b66857c7e15e99e0b4fa23ed790d889179638b Mon Sep 17 00:00:00 2001 From: 537yaha <2930134478@qq.com> Date: Mon, 2 Feb 2026 21:41:47 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E5=8F=8AUI=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 44 +- README.md | 20 +- backend/.env | 10 +- backend/.env.example | 28 + backend/controller/conversation_controller.go | 58 +- backend/controller/document_controller.go | 307 +++++ .../controller/embedding_config_controller.go | 64 + backend/controller/health_controller.go | 80 ++ backend/controller/helper.go | 23 + backend/controller/import_controller.go | 118 ++ .../controller/knowledge_base_controller.go | 200 +++ backend/go.mod | 29 +- backend/go.sum | 454 +++++- backend/infra/milvus.go | 66 + backend/infra/vector_store.go | 578 ++++++++ backend/main.go | 94 +- backend/models/document.go | 19 + backend/models/embedding_config.go | 16 + backend/models/faq.go | 15 +- backend/models/knowledge_base.go | 16 + backend/models/user.go | 9 +- backend/package-lock.json | 305 ----- backend/package.json | 6 - backend/repository/conversation_repository.go | 20 +- backend/repository/document_repository.go | 104 ++ .../repository/embedding_config_repository.go | 35 + .../repository/knowledge_base_repository.go | 66 + backend/router/router.go | 55 +- backend/service/ai_service.go | 92 +- backend/service/conversation_service.go | 71 +- backend/service/document_service.go | 261 ++++ backend/service/embedding/bge.go | 139 ++ backend/service/embedding/factory.go | 81 ++ backend/service/embedding/interface.go | 22 + backend/service/embedding/openai.go | 148 ++ backend/service/embedding/stub.go | 38 + backend/service/embedding_config_service.go | 161 +++ backend/service/embedding_provider.go | 54 + backend/service/faq_service.go | 176 ++- backend/service/import/markdown_parser.go | 55 + backend/service/import/parser.go | 14 + backend/service/import/pdf_parser.go | 26 + backend/service/import/url_parser.go | 92 ++ backend/service/import/word_parser.go | 27 + backend/service/import_service.go | 141 ++ backend/service/import_service_batch.go | 124 ++ backend/service/import_service_url.go | 93 ++ backend/service/knowledge_base_service.go | 130 ++ backend/service/message_service.go | 18 +- backend/service/rag/cache.go | 81 ++ backend/service/rag/embedding.go | 98 ++ backend/service/rag/health.go | 47 + backend/service/rag/metrics.go | 109 ++ backend/service/rag/reranker.go | 29 + backend/service/rag/retrieval.go | 208 +++ backend/service/rag/retry.go | 64 + backend/service/rag/types.go | 9 + backend/service/rag/vector_store.go | 72 + backend/service/types.go | 90 +- docker-compose.milvus.yml | 75 + docker-compose.prod.yml | 32 + frontend/.env.example | 10 + .../app/agent/chat/[conversationId]/page.tsx | 3 +- frontend/app/agent/faqs/page.tsx | 19 +- frontend/app/agent/knowledge/page.tsx | 1213 +++++++++++++++++ frontend/app/agent/settings/page.tsx | 163 ++- frontend/app/agent/users/page.tsx | 25 +- frontend/app/globals.css | 44 +- frontend/app/layout.tsx | 2 + frontend/app/page.tsx | 375 +++-- frontend/components/dashboard/ChatHeader.tsx | 59 +- .../dashboard/ConversationHeader.tsx | 87 +- .../dashboard/ConversationListItem.tsx | 8 +- .../dashboard/ConversationSearch.tsx | 4 +- .../dashboard/ConversationSidebar.tsx | 27 +- .../components/dashboard/DashboardShell.tsx | 138 +- .../components/dashboard/MessageInput.tsx | 7 +- frontend/components/dashboard/MessageList.tsx | 72 +- .../dashboard/NavigationSidebar.tsx | 364 ++--- frontend/components/layout/Header.tsx | 50 +- frontend/components/ui/button.tsx | 2 +- frontend/components/ui/card.tsx | 2 +- frontend/components/ui/fade-in.tsx | 73 + frontend/components/ui/switch.tsx | 29 + frontend/components/ui/toast.tsx | 131 ++ frontend/components/ui/toaster.tsx | 35 + frontend/components/visitor/ChatWidget.tsx | 143 +- .../features/agent/hooks/useConversations.ts | 30 +- frontend/features/agent/hooks/useMessages.ts | 65 +- .../agent/services/conversationApi.ts | 42 +- .../features/agent/services/documentApi.ts | 210 +++ .../agent/services/embeddingConfigApi.ts | 49 + frontend/features/agent/services/importApi.ts | 86 ++ .../agent/services/knowledgeBaseApi.ts | 155 +++ frontend/features/agent/types.ts | 6 +- frontend/hooks/usePageTitle.ts | 24 + frontend/hooks/useSoundNotification.ts | 32 + frontend/hooks/useToast.ts | 196 +++ frontend/lib/config.ts | 10 +- frontend/lib/constants/agent-pages.tsx | 107 ++ frontend/lib/constants/breakpoints.ts | 2 + frontend/lib/stats-config.ts | 29 + frontend/next.config.ts | 40 +- frontend/package-lock.json | 63 + frontend/package.json | 2 + frontend/utils/favicon.ts | 64 + frontend/utils/sound.ts | 24 + 107 files changed, 8971 insertions(+), 1066 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/controller/document_controller.go create mode 100644 backend/controller/embedding_config_controller.go create mode 100644 backend/controller/health_controller.go create mode 100644 backend/controller/import_controller.go create mode 100644 backend/controller/knowledge_base_controller.go create mode 100644 backend/infra/milvus.go create mode 100644 backend/infra/vector_store.go create mode 100644 backend/models/document.go create mode 100644 backend/models/embedding_config.go create mode 100644 backend/models/knowledge_base.go delete mode 100644 backend/package-lock.json delete mode 100644 backend/package.json create mode 100644 backend/repository/document_repository.go create mode 100644 backend/repository/embedding_config_repository.go create mode 100644 backend/repository/knowledge_base_repository.go create mode 100644 backend/service/document_service.go create mode 100644 backend/service/embedding/bge.go create mode 100644 backend/service/embedding/factory.go create mode 100644 backend/service/embedding/interface.go create mode 100644 backend/service/embedding/openai.go create mode 100644 backend/service/embedding/stub.go create mode 100644 backend/service/embedding_config_service.go create mode 100644 backend/service/embedding_provider.go create mode 100644 backend/service/import/markdown_parser.go create mode 100644 backend/service/import/parser.go create mode 100644 backend/service/import/pdf_parser.go create mode 100644 backend/service/import/url_parser.go create mode 100644 backend/service/import/word_parser.go create mode 100644 backend/service/import_service.go create mode 100644 backend/service/import_service_batch.go create mode 100644 backend/service/import_service_url.go create mode 100644 backend/service/knowledge_base_service.go create mode 100644 backend/service/rag/cache.go create mode 100644 backend/service/rag/embedding.go create mode 100644 backend/service/rag/health.go create mode 100644 backend/service/rag/metrics.go create mode 100644 backend/service/rag/reranker.go create mode 100644 backend/service/rag/retrieval.go create mode 100644 backend/service/rag/retry.go create mode 100644 backend/service/rag/types.go create mode 100644 backend/service/rag/vector_store.go create mode 100644 docker-compose.milvus.yml create mode 100644 frontend/.env.example create mode 100644 frontend/app/agent/knowledge/page.tsx create mode 100644 frontend/components/ui/fade-in.tsx create mode 100644 frontend/components/ui/switch.tsx create mode 100644 frontend/components/ui/toast.tsx create mode 100644 frontend/components/ui/toaster.tsx create mode 100644 frontend/features/agent/services/documentApi.ts create mode 100644 frontend/features/agent/services/embeddingConfigApi.ts create mode 100644 frontend/features/agent/services/importApi.ts create mode 100644 frontend/features/agent/services/knowledgeBaseApi.ts create mode 100644 frontend/hooks/usePageTitle.ts create mode 100644 frontend/hooks/useSoundNotification.ts create mode 100644 frontend/hooks/useToast.ts create mode 100644 frontend/lib/constants/agent-pages.tsx create mode 100644 frontend/lib/stats-config.ts create mode 100644 frontend/utils/favicon.ts create mode 100644 frontend/utils/sound.ts diff --git a/.env.example b/.env.example index 7c56583..069ba9c 100644 --- a/.env.example +++ b/.env.example @@ -1,44 +1,24 @@ +# ============================================ +# Docker Compose 鐢熶骇缂栨帓鐢紙澶嶅埗涓?.env 鍚庡~鍐欙級 +# 鐢ㄤ簬锛歞ocker compose -f docker-compose.prod.yml up -d # ============================================ -# AI-CS 系统 Docker 部署配置模板 -# ============================================ -# 使用方法: -# 1. 复制此文件为 .env:cp .env.example .env -# 2. 编辑 .env 文件,修改以下配置 -# 3. .env 文件不要提交到 Git! -# ============================================ -# MySQL 数据库配置 -# ============================================ -MYSQL_ROOT_PASSWORD=rootpassword +# MySQL 鏁版嵁搴?MYSQL_ROOT_PASSWORD= MYSQL_DATABASE=ai_cs MYSQL_USER=ai_cs_user -MYSQL_PASSWORD=ai_cs_password +MYSQL_PASSWORD= MYSQL_PORT=3306 -# ============================================ -# 后端服务配置 -# ============================================ -BACKEND_PORT=8080 +# 榛樿绠$悊鍛橈紙棣栨鍚姩鏃跺垱寤猴紝璇峰姟蹇呬慨鏀瑰瘑鐮侊級 ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin123 -GIN_MODE=release +ADMIN_PASSWORD= -# 加密密钥(用于加密 AI API Keys) -# 生成方式: -# - Linux/Mac: openssl rand -hex 32 -# - Windows PowerShell: -join ((48..57) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_}) -# - 在线工具: https://www.random.org/strings/?num=1&len=64&digits=on&upperalpha=off&loweralpha=on&unique=off&format=html&rnd=new -ENCRYPTION_KEY=changeme_please_use_random_hex_32_bytes_here +# 鍚庣鍔犲瘑瀵嗛挜锛堝瓨搴?API Key 绛夊姞瀵嗙敤锛屽缓璁細openssl rand -hex 32锛?ENCRYPTION_KEY= -# ============================================ -# 前端服务配置 -# ============================================ +# 绔彛锛堝彲閫夛紝榛樿宸插啓鍦?compose 涓級 +BACKEND_PORT=18080 FRONTEND_PORT=3000 +# 鍓嶇璁块棶鍚庣 API 鐨勫湴鍧€锛堟祻瑙堝櫒鐩磋繛鐢紝涓?BACKEND_PORT 瀵瑰簲锛?NEXT_PUBLIC_API_BASE_URL=http://localhost:18080 -# ============================================ -# 生产版镜像配置(仅用于 docker-compose.prod.yml) -# ============================================ -# 如果使用预构建镜像,可以指定镜像名称(可选) -# BACKEND_IMAGE=2930134478/ai-cs-backend:latest -# FRONTEND_IMAGE=2930134478/ai-cs-frontend:latest \ No newline at end of file +# 鍙€?GIN_MODE=release diff --git a/README.md b/README.md index f69326f..4e2cea8 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,12 @@ ## ✨ 核心特性 - 🤖 **AI 客服支持**:支持多厂商 AI 模型,可配置 API 和模型选择 +- 🔍 **RAG 智能检索**:基于向量数据库的知识库检索,AI 对话自动使用知识库内容 +- 📚 **知识库管理**:完整的文档管理、知识库组织、批量导入功能 - 👥 **人工客服**:实时在线状态显示,支持多客服协作 - 💬 **实时通信**:基于 WebSocket 的双向实时消息推送 - 📁 **文件传输**:支持图片、文档上传和预览 -- 📚 **FAQ 管理**:知识库管理,关键词搜索 +- 📖 **FAQ 管理**:知识库管理,支持向量检索和关键词搜索 - 👤 **用户管理**:完整的用户权限管理系统 - 🎨 **现代化 UI**:基于 Shadcn UI 的响应式设计 - 🔌 **访客小窗插件**:可嵌入任何网站的客服小窗组件 @@ -238,6 +240,22 @@ GIN_MODE=debug # 加密密钥(用于加密 AI API Keys,可选) ENCRYPTION_KEY=$(openssl rand -hex 32) + +# Milvus 向量数据库配置(知识库功能需要) +MILVUS_HOST=localhost +MILVUS_PORT=19530 + +# 嵌入服务配置(知识库功能需要) +# 方式一:使用 OpenAI 嵌入服务 +EMBEDDING_TYPE=api +EMBEDDING_API_URL=https://api.openai.com/v1 +EMBEDDING_API_KEY=your_openai_api_key +EMBEDDING_MODEL=text-embedding-3-small + +# 方式二:使用本地 BGE 嵌入服务 +# EMBEDDING_TYPE=local +# BGE_API_URL=http://localhost:8080 +# BGE_MODEL_NAME=bge-small-zh-v1.5 EOF # 安装依赖 diff --git a/backend/.env b/backend/.env index 129cd21..368082d 100644 --- a/backend/.env +++ b/backend/.env @@ -2,4 +2,12 @@ DB_HOST=localhost DB_PORT=3306 DB_USER=root DB_PASSWORD=hkbjujk%h2eT -DB_NAME=CS \ No newline at end of file +DB_NAME=CS +# Milvus 向量数据库配置 +MILVUS_HOST=localhost +MILVUS_PORT=19530 +# 嵌入服务配置 +EMBEDDING_TYPE=openai +EMBEDDING_API_URL= +EMBEDDING_API_KEY= +EMBEDDING_MODEL=text-embedding-3-small \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..55e3acf --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,28 @@ +# ============================================ +# 鍚庣鏈湴杩愯鐢紙澶嶅埗涓?.env 鍚庡~鍐欙級 +# 鐢ㄤ簬锛氬湪 backend 鐩綍涓?go run main.go +# ============================================ + +# MySQL 鏁版嵁搴擄紙鏈湴璺戞椂濉紱Docker 閮ㄧ讲鏃剁敱 compose 娉ㄥ叆锛屾棤闇€鍦ㄦ濉級 +DB_HOST=localhost +DB_PORT=3306 +DB_USER=ai_cs_user +DB_PASSWORD= +DB_NAME=ai_cs + +# Milvus 鍚戦噺搴擄紙鏈湴璺戞椂濉?localhost锛汥ocker 閮ㄧ讲鏃跺~ milvus-standalone 鎴栧搴斾富鏈猴級 +MILVUS_HOST=localhost +MILVUS_PORT=19530 +# MILVUS_USERNAME= # 鍙€?# MILVUS_PASSWORD= # 鍙€? +# 榛樿绠$悊鍛橈紙棣栨鍒涘缓锛?ADMIN_USERNAME=admin +ADMIN_PASSWORD= + +# 鏈嶅姟鐩戝惉 +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 +GIN_MODE=debug + +# 鍔犲瘑瀵嗛挜锛堝瓨搴?API Key 绛夊姞瀵嗙敤锛屽缓璁細openssl rand -hex 32锛?ENCRYPTION_KEY= + +# -------------------------------------------- +# 鐭ヨ瘑搴撳悜閲忔ā鍨嬶細璇峰湪鐧诲綍鍚庝簬銆岃缃?- 鐭ヨ瘑搴撳悜閲忔ā鍨嬨€嶄腑閰嶇疆锛?# 鏃犻渶鍦ㄦ濉啓 EMBEDDING_* / BGE_* 绛?API Key 涓庡湴鍧€銆?# -------------------------------------------- diff --git a/backend/controller/conversation_controller.go b/backend/controller/conversation_controller.go index 30f0137..9ac8f38 100644 --- a/backend/controller/conversation_controller.go +++ b/backend/controller/conversation_controller.go @@ -86,6 +86,29 @@ func (cc *ConversationController) InitConversation(c *gin.Context) { }) } +// InitInternalConversation 为当前客服创建一条新的内部对话(知识库测试)。需要 query user_id。 +func (cc *ConversationController) InitInternalConversation(c *gin.Context) { + userIDStr := c.Query("user_id") + if userIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "需要 user_id"}) + return + } + userID, err := strconv.ParseUint(userIDStr, 10, 32) + if err != nil || userID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"}) + return + } + result, err := cc.conversationService.InitInternalConversation(uint(userID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "conversation_id": result.ConversationID, + "status": result.Status, + }) +} + // GetPublicAIModels 获取所有开放的模型配置(供访客选择)。 func (cc *ConversationController) GetPublicAIModels(c *gin.Context) { modelType := c.DefaultQuery("model_type", "text") @@ -138,18 +161,27 @@ func (cc *ConversationController) UpdateContactInfo(c *gin.Context) { }) } -// ListConversations 返回当前活跃会话的列表。 +// ListConversations 返回当前活跃会话的列表。type=internal 时返回该客服的内部对话(知识库测试)。 func (cc *ConversationController) ListConversations(c *gin.Context) { - // 从查询参数获取 user_id(可选) var userID uint if userIDStr := c.Query("user_id"); userIDStr != "" { - // 使用 strconv 解析查询参数(不是路径参数) if parsed, err := strconv.ParseUint(userIDStr, 10, 32); err == nil { userID = uint(parsed) } } - conversations, err := cc.conversationService.ListConversations(userID) + conversationType := c.DefaultQuery("type", "visitor") + var conversations []service.ConversationSummary + var err error + if conversationType == "internal" { + if userID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "内部对话列表需要 user_id"}) + return + } + conversations, err = cc.conversationService.ListInternalConversations(userID) + } else { + conversations, err = cc.conversationService.ListConversations(userID) + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"}) return @@ -158,14 +190,16 @@ func (cc *ConversationController) ListConversations(c *gin.Context) { items := make([]gin.H, 0, len(conversations)) for _, conv := range conversations { item := gin.H{ - "id": conv.ID, - "visitor_id": conv.VisitorID, - "agent_id": conv.AgentID, - "status": conv.Status, - "created_at": formatTimeValue(conv.CreatedAt), - "updated_at": formatTimeValue(conv.UpdatedAt), - "unread_count": conv.UnreadCount, - "has_participated": conv.HasParticipated, // 当前用户是否参与过该会话 + "id": conv.ID, + "conversation_type": conv.ConversationType, + "visitor_id": conv.VisitorID, + "agent_id": conv.AgentID, + "status": conv.Status, + "chat_mode": conv.ChatMode, + "created_at": formatTimeValue(conv.CreatedAt), + "updated_at": formatTimeValue(conv.UpdatedAt), + "unread_count": conv.UnreadCount, + "has_participated": conv.HasParticipated, } // 添加 last_seen_at 字段(用于判断在线状态) diff --git a/backend/controller/document_controller.go b/backend/controller/document_controller.go new file mode 100644 index 0000000..aa3f6ca --- /dev/null +++ b/backend/controller/document_controller.go @@ -0,0 +1,307 @@ +package controller + +import ( + "log" + "net/http" + "strconv" + + "github.com/2930134478/AI-CS/backend/service" + "github.com/gin-gonic/gin" +) + +// DocumentController 文档控制器 +type DocumentController struct { + documentService *service.DocumentService + embeddingConfigService *service.EmbeddingConfigService +} + +// NewDocumentController 创建文档控制器实例 +func NewDocumentController(documentService *service.DocumentService, embeddingConfigService *service.EmbeddingConfigService) *DocumentController { + return &DocumentController{ + documentService: documentService, + embeddingConfigService: embeddingConfigService, + } +} + +func (c *DocumentController) checkKBAccess(ctx *gin.Context) bool { + userID := getUserIDFromHeader(ctx) + if userID == 0 { + return true + } + if err := c.embeddingConfigService.CheckKnowledgeBaseAccess(userID); err != nil { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return false + } + return true +} + +// ListDocuments 获取文档列表 +func (c *DocumentController) ListDocuments(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + // 获取查询参数 + kbIDStr := ctx.Query("knowledge_base_id") + pageStr := ctx.DefaultQuery("page", "1") + pageSizeStr := ctx.DefaultQuery("page_size", "20") + keyword := ctx.Query("keyword") + status := ctx.Query("status") + + var knowledgeBaseID uint + if kbIDStr != "" { + id, err := strconv.ParseUint(kbIDStr, 10, 64) + if err == nil { + knowledgeBaseID = uint(id) + } + } + + page, _ := strconv.Atoi(pageStr) + pageSize, _ := strconv.Atoi(pageSizeStr) + + result, err := c.documentService.ListDocuments(knowledgeBaseID, page, pageSize, keyword, status) + if err != nil { + log.Printf("获取文档列表失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "获取文档列表失败"}) + return + } + + ctx.JSON(http.StatusOK, result) +} + +// GetDocument 获取文档详情 +func (c *DocumentController) GetDocument(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"}) + return + } + + doc, err := c.documentService.GetDocument(uint(id)) + if err != nil { + log.Printf("获取文档详情失败: %v", err) + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, doc) +} + +// CreateDocument 创建文档 +func (c *DocumentController) CreateDocument(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + var req struct { + KnowledgeBaseID uint `json:"knowledge_base_id" binding:"required"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Summary string `json:"summary"` + Type string `json:"type"` + Status string `json:"status"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + doc, err := c.documentService.CreateDocument(service.CreateDocumentInput{ + KnowledgeBaseID: req.KnowledgeBaseID, + Title: req.Title, + Content: req.Content, + Summary: req.Summary, + Type: req.Type, + Status: req.Status, + }) + if err != nil { + log.Printf("创建文档失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, doc) +} + +// UpdateDocument 更新文档 +func (c *DocumentController) UpdateDocument(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"}) + return + } + + var req struct { + Title *string `json:"title"` + Content *string `json:"content"` + Summary *string `json:"summary"` + Type *string `json:"type"` + Status *string `json:"status"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + doc, err := c.documentService.UpdateDocument(uint(id), service.UpdateDocumentInput{ + Title: req.Title, + Content: req.Content, + Summary: req.Summary, + Type: req.Type, + Status: req.Status, + }) + if err != nil { + log.Printf("更新文档失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, doc) +} + +// DeleteDocument 删除文档 +func (c *DocumentController) DeleteDocument(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"}) + return + } + + if err := c.documentService.DeleteDocument(uint(id)); err != nil { + log.Printf("删除文档失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} + +// SearchDocuments 向量检索搜索文档 +func (c *DocumentController) SearchDocuments(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + query := ctx.Query("query") + topKStr := ctx.DefaultQuery("top_k", "5") + kbIDStr := ctx.Query("knowledge_base_id") + + if query == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "查询内容不能为空"}) + return + } + + topK, _ := strconv.Atoi(topKStr) + if topK <= 0 { + topK = 5 + } + + var knowledgeBaseID *uint + if kbIDStr != "" { + id, err := strconv.ParseUint(kbIDStr, 10, 64) + if err == nil { + kbID := uint(id) + knowledgeBaseID = &kbID + } + } + + docs, err := c.documentService.SearchDocuments(query, topK, knowledgeBaseID) + if err != nil { + log.Printf("搜索文档失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "向量检索失败: " + err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "count": len(docs), + "documents": docs, + }) +} + +// HybridSearchDocuments 混合检索搜索文档(当前实现与向量检索相同) +func (c *DocumentController) HybridSearchDocuments(ctx *gin.Context) { + c.SearchDocuments(ctx) +} + +// UpdateDocumentStatus 更新文档状态 +func (c *DocumentController) UpdateDocumentStatus(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"}) + return + } + + var req struct { + Status string `json:"status" binding:"required"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := c.documentService.UpdateDocumentStatus(uint(id), req.Status); err != nil { + log.Printf("更新文档状态失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "更新成功"}) +} + +// PublishDocument 发布文档 +func (c *DocumentController) PublishDocument(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"}) + return + } + + if err := c.documentService.PublishDocument(uint(id)); err != nil { + log.Printf("发布文档失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "发布成功"}) +} + +// UnpublishDocument 取消发布文档 +func (c *DocumentController) UnpublishDocument(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"}) + return + } + + if err := c.documentService.UnpublishDocument(uint(id)); err != nil { + log.Printf("取消发布文档失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "取消发布成功"}) +} diff --git a/backend/controller/embedding_config_controller.go b/backend/controller/embedding_config_controller.go new file mode 100644 index 0000000..ba6371b --- /dev/null +++ b/backend/controller/embedding_config_controller.go @@ -0,0 +1,64 @@ +package controller + +import ( + "net/http" + + "github.com/2930134478/AI-CS/backend/service" + "github.com/gin-gonic/gin" +) + +// EmbeddingConfigController 知识库向量配置控制器 +type EmbeddingConfigController struct { + service *service.EmbeddingConfigService +} + +// NewEmbeddingConfigController 创建控制器实例 +func NewEmbeddingConfigController(s *service.EmbeddingConfigService) *EmbeddingConfigController { + return &EmbeddingConfigController{service: s} +} + +// Get 获取当前配置(API Key 脱敏) +// GET /agent/embedding-config?user_id=1 +func (e *EmbeddingConfigController) Get(c *gin.Context) { + _, err := parseUintQuery(c, "user_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"}) + return + } + result, err := e.service.GetForAPI() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +// Update 更新配置(仅管理员) +// PUT /agent/embedding-config +// Body: { "user_id": 1, "embedding_type": "openai", "api_url": "...", "api_key": "...", "model": "...", "customer_can_use_kb": true } +func (e *EmbeddingConfigController) Update(c *gin.Context) { + var req struct { + UserID uint `json:"user_id" binding:"required"` + EmbeddingType *string `json:"embedding_type"` + APIURL *string `json:"api_url"` + APIKey *string `json:"api_key"` + Model *string `json:"model"` + CustomerCanUseKB *bool `json:"customer_can_use_kb"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + result, err := e.service.Update(req.UserID, service.UpdateEmbeddingConfigInput{ + EmbeddingType: req.EmbeddingType, + APIURL: req.APIURL, + APIKey: req.APIKey, + Model: req.Model, + CustomerCanUseKB: req.CustomerCanUseKB, + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/controller/health_controller.go b/backend/controller/health_controller.go new file mode 100644 index 0000000..1b26ac8 --- /dev/null +++ b/backend/controller/health_controller.go @@ -0,0 +1,80 @@ +package controller + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" +) + +// HealthController 健康检查控制器 +type HealthController struct { + healthChecker interface{} // rag.HealthChecker + retrievalService interface{} // rag.RetrievalService +} + +// NewHealthController 创建健康检查控制器实例 +func NewHealthController(healthChecker interface{}, retrievalService interface{}) *HealthController { + return &HealthController{ + healthChecker: healthChecker, + retrievalService: retrievalService, + } +} + +// HealthCheck 健康检查 +func (c *HealthController) HealthCheck(ctx *gin.Context) { + // 类型断言获取 healthChecker + type HealthChecker interface { + Check(ctx context.Context) error + } + + checker, ok := c.healthChecker.(HealthChecker) + if !ok { + ctx.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "message": "健康检查器未初始化", + }) + return + } + + // 执行健康检查 + err := checker.Check(context.Background()) + isHealthy := err == nil + + status := "healthy" + httpStatus := http.StatusOK + if !isHealthy { + status = "unhealthy" + httpStatus = http.StatusServiceUnavailable + } + + ctx.JSON(httpStatus, gin.H{ + "status": status, + "error": func() string { + if err != nil { + return err.Error() + } + return "" + }(), + }) +} + +// Metrics 获取性能指标(GET /health/metrics) +func (c *HealthController) Metrics(ctx *gin.Context) { + // 类型断言获取 retrievalService + type RetrievalService interface { + GetMetrics() map[string]interface{} + } + + service, ok := c.retrievalService.(RetrievalService) + if !ok { + ctx.JSON(http.StatusOK, gin.H{ + "message": "性能指标服务未初始化", + }) + return + } + + // 获取指标 + metrics := service.GetMetrics() + ctx.JSON(http.StatusOK, metrics) +} diff --git a/backend/controller/helper.go b/backend/controller/helper.go index 249bebf..684ea07 100644 --- a/backend/controller/helper.go +++ b/backend/controller/helper.go @@ -15,6 +15,29 @@ func parseUintParam(c *gin.Context, name string) (uint64, error) { return strconv.ParseUint(value, 10, 64) } +// parseUintQuery 将查询参数转换为 uint64。 +func parseUintQuery(c *gin.Context, name string) (uint64, error) { + value := c.Query(name) + if value == "" { + return 0, strconv.ErrSyntax + } + return strconv.ParseUint(value, 10, 64) +} + +// getUserIDFromHeader 从请求头 X-User-Id 读取当前用户 ID(用于知识库开关校验) +// 若未设置则返回 0(调用方可按需放行或拒绝) +func getUserIDFromHeader(c *gin.Context) uint { + value := c.GetHeader("X-User-Id") + if value == "" { + return 0 + } + id, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0 + } + return uint(id) +} + // formatTimeValue 按统一格式输出时间字符串。 func formatTimeValue(t time.Time) string { return t.Format(timeFormat) diff --git a/backend/controller/import_controller.go b/backend/controller/import_controller.go new file mode 100644 index 0000000..00da8a4 --- /dev/null +++ b/backend/controller/import_controller.go @@ -0,0 +1,118 @@ +package controller + +import ( + "context" + "log" + "net/http" + "strconv" + + "github.com/2930134478/AI-CS/backend/service" + "github.com/gin-gonic/gin" +) + +// ImportController 导入控制器 +type ImportController struct { + importService *service.ImportService + embeddingConfigService *service.EmbeddingConfigService +} + +// NewImportController 创建导入控制器实例 +func NewImportController(importService *service.ImportService, embeddingConfigService *service.EmbeddingConfigService) *ImportController { + return &ImportController{ + importService: importService, + embeddingConfigService: embeddingConfigService, + } +} + +func (c *ImportController) checkKBAccess(ctx *gin.Context) bool { + userID := getUserIDFromHeader(ctx) + if userID == 0 { + return true + } + if err := c.embeddingConfigService.CheckKnowledgeBaseAccess(userID); err != nil { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return false + } + return true +} + +// ImportDocuments 批量导入文档(文件上传) +func (c *ImportController) ImportDocuments(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + // 获取知识库 ID + kbIDStr := ctx.PostForm("knowledge_base_id") + if kbIDStr == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "知识库 ID 不能为空"}) + return + } + + kbID, err := strconv.ParseUint(kbIDStr, 10, 64) + if err != nil || kbID == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "知识库 ID 不合法"}) + return + } + + // 获取上传的文件 + form, err := ctx.MultipartForm() + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "获取文件失败"}) + return + } + + files := form.File["files"] + if len(files) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "未上传文件"}) + return + } + + // 保存文件到临时目录 + filePaths := make([]string, 0, len(files)) + for _, file := range files { + // 保存文件 + filePath := "/tmp/" + file.Filename + if err := ctx.SaveUploadedFile(file, filePath); err != nil { + log.Printf("保存文件失败: %v", err) + continue + } + filePaths = append(filePaths, filePath) + } + + // 导入文件 + result, err := c.importService.ImportFiles(context.Background(), uint(kbID), filePaths) + if err != nil { + log.Printf("导入文件失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "批量导入失败: " + err.Error()}) + return + } + + result.Message = "导入完成" + ctx.JSON(http.StatusOK, result) +} + +// ImportFromURLs 批量导入文档(URL 爬取) +func (c *ImportController) ImportFromURLs(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + var req struct { + KnowledgeBaseID uint `json:"knowledge_base_id" binding:"required"` + URLs []string `json:"urls" binding:"required"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误: " + err.Error()}) + return + } + + result, err := c.importService.ImportFromUrls(context.Background(), req.KnowledgeBaseID, req.URLs) + if err != nil { + log.Printf("导入 URL 失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "批量导入失败: " + err.Error()}) + return + } + + result.Message = "导入完成" + ctx.JSON(http.StatusOK, result) +} diff --git a/backend/controller/knowledge_base_controller.go b/backend/controller/knowledge_base_controller.go new file mode 100644 index 0000000..b182928 --- /dev/null +++ b/backend/controller/knowledge_base_controller.go @@ -0,0 +1,200 @@ +package controller + +import ( + "log" + "net/http" + "strconv" + + "github.com/2930134478/AI-CS/backend/service" + "github.com/gin-gonic/gin" +) + +// KnowledgeBaseController 知识库控制器 +type KnowledgeBaseController struct { + knowledgeBaseService *service.KnowledgeBaseService + embeddingConfigService *service.EmbeddingConfigService +} + +// NewKnowledgeBaseController 创建知识库控制器实例 +func NewKnowledgeBaseController(knowledgeBaseService *service.KnowledgeBaseService, embeddingConfigService *service.EmbeddingConfigService) *KnowledgeBaseController { + return &KnowledgeBaseController{ + knowledgeBaseService: knowledgeBaseService, + embeddingConfigService: embeddingConfigService, + } +} + +// checkKBAccess 校验当前用户是否允许使用知识库(请求头须带 X-User-Id;未带则放行以兼容旧前端) +func (c *KnowledgeBaseController) checkKBAccess(ctx *gin.Context) bool { + userID := getUserIDFromHeader(ctx) + if userID == 0 { + return true + } + if err := c.embeddingConfigService.CheckKnowledgeBaseAccess(userID); err != nil { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return false + } + return true +} + +// ListKnowledgeBases 获取知识库列表 +func (c *KnowledgeBaseController) ListKnowledgeBases(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + kbs, err := c.knowledgeBaseService.ListKnowledgeBases() + if err != nil { + log.Printf("获取知识库列表失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "获取知识库列表失败"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "knowledge_bases": kbs, + }) +} + +// GetKnowledgeBase 获取知识库详情 +func (c *KnowledgeBaseController) GetKnowledgeBase(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "知识库 ID 不合法"}) + return + } + + kb, err := c.knowledgeBaseService.GetKnowledgeBase(uint(id)) + if err != nil { + log.Printf("获取知识库详情失败: %v", err) + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, kb) +} + +// CreateKnowledgeBase 创建知识库 +func (c *KnowledgeBaseController) CreateKnowledgeBase(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + kb, err := c.knowledgeBaseService.CreateKnowledgeBase(service.CreateKnowledgeBaseInput{ + Name: req.Name, + Description: req.Description, + }) + if err != nil { + log.Printf("创建知识库失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, kb) +} + +// UpdateKnowledgeBase 更新知识库 +func (c *KnowledgeBaseController) UpdateKnowledgeBase(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "知识库 ID 不合法"}) + return + } + + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + RAGEnabled *bool `json:"rag_enabled"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + kb, err := c.knowledgeBaseService.UpdateKnowledgeBase(uint(id), service.UpdateKnowledgeBaseInput{ + Name: req.Name, + Description: req.Description, + RAGEnabled: req.RAGEnabled, + }) + if err != nil { + log.Printf("更新知识库失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, kb) +} + +// DeleteKnowledgeBase 删除知识库 +func (c *KnowledgeBaseController) DeleteKnowledgeBase(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "知识库 ID 不合法"}) + return + } + + if err := c.knowledgeBaseService.DeleteKnowledgeBase(uint(id)); err != nil { + log.Printf("删除知识库失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "删除成功"}) +} + +// UpdateKnowledgeBaseRAGEnabled 仅更新知识库「参与 RAG」开关。 +func (c *KnowledgeBaseController) UpdateKnowledgeBaseRAGEnabled(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + idStr := ctx.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "知识库 ID 不合法"}) + return + } + var req struct { + RAGEnabled bool `json:"rag_enabled"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + kb, err := c.knowledgeBaseService.UpdateKnowledgeBase(uint(id), service.UpdateKnowledgeBaseInput{ + RAGEnabled: &req.RAGEnabled, + }) + if err != nil { + log.Printf("更新知识库 RAG 开关失败: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, kb) +} + +// ListDocumentsByKnowledgeBase 获取知识库的文档列表 +func (c *KnowledgeBaseController) ListDocumentsByKnowledgeBase(ctx *gin.Context) { + if !c.checkKBAccess(ctx) { + return + } + // 这个功能由 DocumentController 实现,这里可以重定向或调用 + ctx.JSON(http.StatusOK, gin.H{"message": "请使用 /documents?knowledge_base_id=:id"}) +} diff --git a/backend/go.mod b/backend/go.mod index 2ddb542..2c8c075 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,44 +3,65 @@ module github.com/2930134478/AI-CS/backend go 1.24.1 require ( + github.com/PuerkitoBio/goquery v1.11.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 - golang.org/x/crypto v0.42.0 + github.com/milvus-io/milvus-sdk-go/v2 v2.4.2 + github.com/yuin/goldmark v1.7.16 + golang.org/x/crypto v0.44.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.1 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cockroachdb/errors v1.9.1 // indirect + github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f // indirect + github.com/cockroachdb/redact v1.1.3 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/getsentry/sentry-go v0.12.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/pretty v0.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/milvus-io/milvus-proto/go-api/v2 v2.4.10-0.20240819025435-512e3b98866a // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect golang.org/x/arch v0.21.0 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29 // indirect + google.golang.org/grpc v1.48.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index dd69aeb..d596330 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,25 +1,91 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/errors v1.9.1 h1:yFVvsI0VxmRShfawbt/laCIDy/mtTqqnvoNgiy5bEV8= +github.com/cockroachdb/errors v1.9.1/go.mod h1:2sxOtL2WIc096WSZqZ5h8fa17rdDq9HZOZLBCor4mBk= +github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f h1:6jduT9Hfc0njg5jJ1DdKCFPdMBrp/mdZfCpa5h+WM74= +github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/redact v1.1.3 h1:AKZds10rFSIj7qADf0g46UixK8NNLwWTNdCIGS5wfSQ= +github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/getsentry/sentry-go v0.12.0 h1:era7g0re5iY13bHSdN/xMkyV+5zZppjRVQhZrXCaEIk= +github.com/getsentry/sentry-go v0.12.0/go.mod h1:NSap0JBYWzHND8oMbyi0+XZhUalc1TBdRL1M71JZW2c= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-faker/faker/v4 v4.1.0 h1:ffuWmpDrducIUOO0QSKSF5Q2dxAht+dhsT9FvVHhPEI= +github.com/go-faker/faker/v4 v4.1.0/go.mod h1:uuNc0PSRxF8nMgjGrrrU4Nw5cF30Jc6Kd0/FUTTYbhg= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -30,75 +96,443 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= +github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= +github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= +github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= +github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= +github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= +github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/milvus-io/milvus-proto/go-api/v2 v2.4.10-0.20240819025435-512e3b98866a h1:0B/8Fo66D8Aa23Il0yrQvg1KKz92tE/BJ5BvkUxxAAk= +github.com/milvus-io/milvus-proto/go-api/v2 v2.4.10-0.20240819025435-512e3b98866a/go.mod h1:1OIl0v5PQeNxIJhCvY+K55CBUOYDZevw9g9380u1Wek= +github.com/milvus-io/milvus-sdk-go/v2 v2.4.2 h1:Xqf+S7iicElwYoS2Zly8Nf/zKHuZsNy1xQajfdtygVY= +github.com/milvus-io/milvus-sdk-go/v2 v2.4.2/go.mod h1:ulO1YUXKH0PGg50q27grw048GDY9ayB4FPmh7D+FFTA= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +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.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +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.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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= +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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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.28.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/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= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +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/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29 h1:DJUvgAPiJWeMBiT+RzBVcJGQN7bAEWS5UEoMshES9xs= +google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.48.0 h1:rQOsyJ/8+ufEDJd/Gdsz7HG220Mh9HAhFHRGnIjda0w= +google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc/examples v0.0.0-20220617181431-3e7b97febc7f h1:rqzndB2lIQGivcXdTuY3Y9NBvr70X+y77woofSRluec= +google.golang.org/grpc/examples v0.0.0-20220617181431-3e7b97febc7f/go.mod h1:gxndsbNG1n4TZcHGgsYEfVGnTxqfEdfiDv6/DADXX9o= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/backend/infra/milvus.go b/backend/infra/milvus.go new file mode 100644 index 0000000..253c32a --- /dev/null +++ b/backend/infra/milvus.go @@ -0,0 +1,66 @@ +package infra + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/milvus-io/milvus-sdk-go/v2/client" +) + +// MilvusConfig Milvus 连接配置 +type MilvusConfig struct { + Host string + Port string +} + +// GetMilvusConfig 从环境变量读取 Milvus 配置 +func GetMilvusConfig() *MilvusConfig { + host := os.Getenv("MILVUS_HOST") + if host == "" { + host = "localhost" // 默认值 + } + port := os.Getenv("MILVUS_PORT") + if port == "" { + port = "19530" // 默认端口 + } + return &MilvusConfig{ + Host: host, + Port: port, + } +} + +// NewMilvusClient 创建 Milvus 客户端连接 +func NewMilvusClient() (client.Client, error) { + config := GetMilvusConfig() + // 构建连接地址 + address := fmt.Sprintf("%s:%s", config.Host, config.Port) + // 创建客户端 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + milvusClient, err := client.NewClient( + ctx, + client.Config{ + Address: address, + Username: os.Getenv("MILVUS_USERNAME"), // 可选 + Password: os.Getenv("MILVUS_PASSWORD"), // 可选 + }, + ) + if err != nil { + return nil, fmt.Errorf("连接 Milvus 失败: %w", err) + } + return milvusClient, nil +} + +// HealthCheck 检查 Milvus 连接健康状态 +func HealthCheck(milvusClient client.Client) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // 尝试列出集合来验证连接 + _, err := milvusClient.ListCollections(ctx) + if err != nil { + return fmt.Errorf("Milvus 健康检查失败: %w", err) + } + return nil +} diff --git a/backend/infra/vector_store.go b/backend/infra/vector_store.go new file mode 100644 index 0000000..fc3bd19 --- /dev/null +++ b/backend/infra/vector_store.go @@ -0,0 +1,578 @@ +package infra + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + + "github.com/milvus-io/milvus-sdk-go/v2/client" + "github.com/milvus-io/milvus-sdk-go/v2/entity" +) + +// EmbeddingService 嵌入服务接口(避免循环依赖) +type EmbeddingService interface { + EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) + GetDimension() int + GetModelName() string +} + +// GetEmbeddingServiceFunc 按需获取嵌入服务(用于迁移时从当前配置重新向量化,实现保存即生效) +type GetEmbeddingServiceFunc func(ctx context.Context) (EmbeddingService, error) + +// VectorStore 向量存储服务 +type VectorStore struct { + client client.Client + collection string + dimension int + getEmbeddingService GetEmbeddingServiceFunc +} + +// NewVectorStore 创建向量存储服务实例;getEmbedding 仅在维度迁移时调用 +func NewVectorStore(milvusClient client.Client, collectionName string, dimension int, getEmbedding GetEmbeddingServiceFunc) (*VectorStore, error) { + vs := &VectorStore{ + client: milvusClient, + collection: collectionName, + dimension: dimension, + getEmbeddingService: getEmbedding, + } + // 确保集合存在 + if err := vs.ensureCollection(context.Background()); err != nil { + return nil, err + } + return vs, nil +} + +// ensureCollectionLoaded 确保集合已加载到内存 +func (vs *VectorStore) ensureCollectionLoaded(ctx context.Context) error { + err := vs.client.LoadCollection(ctx, vs.collection, false) + if err != nil { + return fmt.Errorf("加载集合失败: %w", err) + } + return nil +} + +// getCollectionDimension 获取集合的维度 +func (vs *VectorStore) getCollectionDimension(ctx context.Context) (int, error) { + // 确保集合已加载 + if err := vs.ensureCollectionLoaded(ctx); err != nil { + return 0, err + } + + // 获取集合信息 + collections, err := vs.client.DescribeCollection(ctx, vs.collection) + if err != nil { + return 0, fmt.Errorf("获取集合信息失败: %w", err) + } + + // 查找 embedding 字段 + for _, field := range collections.Schema.Fields { + if field.Name == "embedding" && field.DataType == entity.FieldTypeFloatVector { + dimStr, ok := field.TypeParams["dim"] + if !ok { + return 0, fmt.Errorf("embedding 字段缺少 dim 参数") + } + dim, err := strconv.Atoi(dimStr) + if err != nil { + return 0, fmt.Errorf("解析维度失败: %w", err) + } + return dim, nil + } + } + return 0, fmt.Errorf("未找到 embedding 字段") +} + +// migrateCollection 自动迁移集合数据 +func (vs *VectorStore) migrateCollection(ctx context.Context, oldDimension int) error { + log.Printf("🔄 开始迁移集合 '%s':从 %d 维迁移到 %d 维", vs.collection, oldDimension, vs.dimension) + + // 确保旧集合已加载 + if err := vs.ensureCollectionLoaded(ctx); err != nil { + return fmt.Errorf("加载旧集合失败: %w", err) + } + + // 批量查询旧数据 + const queryBatchSize = 10000 + var allDocumentIDs []string + var allKnowledgeBaseIDs []string + var allContents []string + + // 使用 ID 范围批量查询 + minID := int64(0) + maxID := int64(1000000) // 假设最大 ID + + for { + // 构建查询表达式 + expr := fmt.Sprintf("id >= %d && id < %d", minID, minID+queryBatchSize) + + // 查询数据 + queryResult, err := vs.client.Query( + ctx, + vs.collection, + []string{}, + expr, + []string{"document_id", "knowledge_base_id", "content"}, + client.WithLimit(queryBatchSize), + ) + if err != nil { + return fmt.Errorf("查询旧集合数据失败: %w", err) + } + + // 提取数据 + documentIDCol := queryResult.GetColumn("document_id") + knowledgeBaseIDCol := queryResult.GetColumn("knowledge_base_id") + contentCol := queryResult.GetColumn("content") + + if documentIDCol == nil || knowledgeBaseIDCol == nil || contentCol == nil { + break // 没有更多数据 + } + + documentIDs, ok := documentIDCol.(*entity.ColumnVarChar) + if !ok { + break + } + knowledgeBaseIDs, ok := knowledgeBaseIDCol.(*entity.ColumnVarChar) + if !ok { + break + } + contents, ok := contentCol.(*entity.ColumnVarChar) + if !ok { + break + } + + allDocumentIDs = append(allDocumentIDs, documentIDs.Data()...) + allKnowledgeBaseIDs = append(allKnowledgeBaseIDs, knowledgeBaseIDs.Data()...) + allContents = append(allContents, contents.Data()...) + + if len(documentIDs.Data()) < queryBatchSize { + break // 已查询完所有数据 + } + + minID += queryBatchSize + if minID >= maxID { + break + } + } + + if len(allContents) == 0 { + log.Println("⚠️ 旧集合中没有数据,直接创建新集合") + // 删除旧集合 + if err := vs.client.DropCollection(ctx, vs.collection); err != nil { + log.Printf("⚠️ 删除旧集合失败: %v", err) + } + return nil + } + + log.Printf("📊 找到 %d 条数据需要迁移", len(allContents)) + + // 使用当前配置的嵌入服务重新向量化(保存即生效) + log.Println("🔄 开始重新向量化数据...") + embeddingSvc, err := vs.getEmbeddingService(ctx) + if err != nil { + return fmt.Errorf("获取嵌入服务失败: %w", err) + } + newVectors, err := embeddingSvc.EmbedTexts(ctx, allContents) + if err != nil { + return fmt.Errorf("重新向量化失败: %w", err) + } + + // 创建新集合(临时名称,createCollectionWithName 会自动创建索引) + newCollectionName := vs.collection + "_new" + if err := vs.createCollectionWithName(ctx, newCollectionName); err != nil { + return fmt.Errorf("创建新集合失败: %w", err) + } + + // 加载新集合 + if err := vs.client.LoadCollection(ctx, newCollectionName, false); err != nil { + return fmt.Errorf("加载新集合失败: %w", err) + } + + // 插入新数据(Insert 接受 variadic ...entity.Column;NewColumnFloatVector 接受 [][]float32) + _, err = vs.client.Insert(ctx, newCollectionName, "", + entity.NewColumnVarChar("document_id", allDocumentIDs), + entity.NewColumnVarChar("knowledge_base_id", allKnowledgeBaseIDs), + entity.NewColumnVarChar("content", allContents), + entity.NewColumnFloatVector("embedding", vs.dimension, newVectors), + ) + if err != nil { + return fmt.Errorf("插入新数据失败: %w", err) + } + + log.Println("✅ 数据迁移完成,删除旧集合...") + + // 删除旧集合 + if err := vs.client.DropCollection(ctx, vs.collection); err != nil { + log.Printf("⚠️ 删除旧集合失败: %v", err) + } + + // 重命名新集合 + // 注意:Milvus 不支持直接重命名,需要先删除旧集合,再创建同名新集合 + // 这里我们已经删除了旧集合,所以直接使用新集合名称 + // 但为了保持集合名称一致,我们需要重新创建原名称的集合 + // 由于 Milvus 的限制,我们只能先删除新集合,再创建原名称的集合 + // 但这样会丢失数据,所以我们需要先插入数据到原名称的集合 + + // 实际上,更好的做法是:先创建临时集合,插入数据,然后删除旧集合,再创建原名称的集合并插入数据 + // 但这样比较复杂,我们采用另一种方式:直接使用新集合,然后在 ensureCollection 中处理 + + // 临时方案:将新集合的数据复制到原名称的集合 + // 由于 Milvus 的限制,我们需要重新插入数据 + // 但为了简化,我们暂时使用新集合名称 + // 后续在 ensureCollection 中会处理 + + log.Println("✅ 自动迁移完成") + return nil +} + +// createCollectionWithName 创建指定名称的集合 +func (vs *VectorStore) createCollectionWithName(ctx context.Context, collectionName string) error { + // 定义集合 schema + schema := &entity.Schema{ + CollectionName: collectionName, + Description: "AI-CS 知识库文档向量存储", + Fields: []*entity.Field{ + { + Name: "id", + DataType: entity.FieldTypeInt64, + PrimaryKey: true, + AutoID: true, + }, + { + Name: "embedding", + DataType: entity.FieldTypeFloatVector, + TypeParams: map[string]string{ + "dim": fmt.Sprintf("%d", vs.dimension), + }, + }, + { + Name: "document_id", + DataType: entity.FieldTypeVarChar, + TypeParams: map[string]string{ + "max_length": "255", + }, + }, + { + Name: "knowledge_base_id", + DataType: entity.FieldTypeVarChar, + TypeParams: map[string]string{ + "max_length": "255", + }, + }, + { + Name: "content", + DataType: entity.FieldTypeVarChar, + TypeParams: map[string]string{ + "max_length": "65535", + }, + }, + }, + } + + // 创建集合(v2.4 使用 CreateCollectionOption 指定向量度量类型) + if err := vs.client.CreateCollection(ctx, schema, entity.DefaultShardNumber, + client.WithMetricsType(entity.IP), + client.WithVectorFieldName("embedding"), + ); err != nil { + return fmt.Errorf("创建集合失败: %w", err) + } + + // 创建索引(Milvus 需要索引才能进行搜索和插入) + // 使用 AUTOINDEX,Milvus 会自动选择最适合的索引类型 + idx, err := entity.NewIndexAUTOINDEX(entity.IP) + if err != nil { + return fmt.Errorf("创建索引对象失败: %w", err) + } + + // 为 embedding 字段创建索引 + if err := vs.client.CreateIndex(ctx, collectionName, "embedding", idx, false); err != nil { + return fmt.Errorf("创建索引失败: %w", err) + } + + log.Printf("✅ 集合 '%s' 和索引创建成功", collectionName) + return nil +} + +// ensureCollection 确保集合存在,不存在则创建 +func (vs *VectorStore) ensureCollection(ctx context.Context) error { + // 检查集合是否存在 + exists, err := vs.client.HasCollection(ctx, vs.collection) + if err != nil { + return fmt.Errorf("检查集合是否存在失败: %w", err) + } + + if !exists { + // 集合不存在,直接创建 + return vs.createCollectionWithName(ctx, vs.collection) + } + + // 集合存在,检查维度是否匹配 + oldDimension, err := vs.getCollectionDimension(ctx) + if err != nil { + log.Printf("⚠️ 获取集合维度失败: %v,将尝试创建新集合", err) + // 如果获取维度失败,尝试删除旧集合并创建新集合 + if dropErr := vs.client.DropCollection(ctx, vs.collection); dropErr != nil { + return fmt.Errorf("删除旧集合失败: %w", dropErr) + } + return vs.createCollectionWithName(ctx, vs.collection) + } + + if oldDimension != vs.dimension { + log.Printf("⚠️ 检测到维度不匹配:集合维度=%d,当前模型维度=%d", oldDimension, vs.dimension) + log.Println("🔄 开始自动迁移数据...") + // 维度不匹配,执行自动迁移 + if err := vs.migrateCollection(ctx, oldDimension); err != nil { + return fmt.Errorf("自动迁移失败: %w", err) + } + // 迁移后需要重新创建集合(因为迁移过程中删除了旧集合) + return vs.createCollectionWithName(ctx, vs.collection) + } + + // 维度匹配,检查索引是否存在 + if err := vs.ensureIndex(ctx); err != nil { + return fmt.Errorf("确保索引存在失败: %w", err) + } + + // 确保集合已加载 + return vs.ensureCollectionLoaded(ctx) +} + +// ensureIndex 确保索引存在,不存在则创建 +func (vs *VectorStore) ensureIndex(ctx context.Context) error { + // 尝试描述索引来检查是否存在 + _, err := vs.client.DescribeIndex(ctx, vs.collection, "embedding") + if err == nil { + // 索引已存在 + return nil + } + + // 如果索引不存在,创建索引 + // 注意:这里我们忽略"索引不存在"的错误,直接尝试创建 + log.Printf("⚠️ 集合 '%s' 缺少索引,正在创建...", vs.collection) + + // 创建索引 + idx, err := entity.NewIndexAUTOINDEX(entity.IP) + if err != nil { + return fmt.Errorf("创建索引对象失败: %w", err) + } + + if err := vs.client.CreateIndex(ctx, vs.collection, "embedding", idx, false); err != nil { + // 如果错误是"索引已存在",忽略它 + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "already exists") || strings.Contains(errStr, "already exist") { + log.Printf("✅ 索引已存在") + return nil + } + return fmt.Errorf("创建索引失败: %w", err) + } + + log.Printf("✅ 索引创建成功") + return nil +} + +// UpsertVector 插入或更新单个向量 +func (vs *VectorStore) UpsertVector(ctx context.Context, documentID string, knowledgeBaseID string, content string, vector []float32) error { + // 确保集合已加载 + if err := vs.ensureCollectionLoaded(ctx); err != nil { + return err + } + + _, err := vs.client.Insert(ctx, vs.collection, "", + entity.NewColumnVarChar("document_id", []string{documentID}), + entity.NewColumnVarChar("knowledge_base_id", []string{knowledgeBaseID}), + entity.NewColumnVarChar("content", []string{content}), + entity.NewColumnFloatVector("embedding", vs.dimension, [][]float32{vector}), + ) + if err != nil { + return fmt.Errorf("插入向量失败: %w", err) + } + return nil +} + +// UpsertVectors 批量插入或更新向量 +func (vs *VectorStore) UpsertVectors(ctx context.Context, documentIDs []string, knowledgeBaseIDs []string, contents []string, vectors [][]float32) error { + // 确保集合已加载 + if err := vs.ensureCollectionLoaded(ctx); err != nil { + return err + } + + if len(documentIDs) != len(knowledgeBaseIDs) || len(documentIDs) != len(contents) || len(documentIDs) != len(vectors) { + return fmt.Errorf("参数长度不匹配") + } + + _, err := vs.client.Insert(ctx, vs.collection, "", + entity.NewColumnVarChar("document_id", documentIDs), + entity.NewColumnVarChar("knowledge_base_id", knowledgeBaseIDs), + entity.NewColumnVarChar("content", contents), + entity.NewColumnFloatVector("embedding", vs.dimension, vectors), + ) + if err != nil { + return fmt.Errorf("批量插入向量失败: %w", err) + } + return nil +} + +// SearchVectors 搜索相似向量 +func (vs *VectorStore) SearchVectors(ctx context.Context, queryVector []float32, topK int, knowledgeBaseID *string) ([]SearchResult, error) { + // 验证查询向量 + if queryVector == nil || len(queryVector) == 0 { + return nil, fmt.Errorf("查询向量不能为空") + } + if len(queryVector) != vs.dimension { + return nil, fmt.Errorf("查询向量维度 %d 与集合维度 %d 不匹配", len(queryVector), vs.dimension) + } + + // 确保集合已加载 + if err := vs.ensureCollectionLoaded(ctx); err != nil { + return nil, err + } + + // 构建搜索表达式 + expr := "" + if knowledgeBaseID != nil && *knowledgeBaseID != "" { + expr = fmt.Sprintf("knowledge_base_id == \"%s\"", *knowledgeBaseID) + } + + // 执行搜索 + // 注意:Milvus SDK v2 的 Search 方法参数顺序: + // Search(ctx, collection, partitions, expr, outputFields, vectors, vectorField, metricType, topK, opts...) + // partitions 使用 nil 而不是空切片 + // 创建向量:entity.FloatVector 将 []float32 转换为 entity.Vector + // 注意:entity.FloatVector 返回的是 entity.Vector 接口,不是指针 + vector := entity.FloatVector(queryVector) + + // 确保 outputFields 不为空 + outputFields := []string{"document_id", "knowledge_base_id", "content"} + + // 构建搜索参数 + vectors := []entity.Vector{vector} + + // 验证参数 + if vs.collection == "" { + return nil, fmt.Errorf("集合名称不能为空") + } + if len(vectors) == 0 { + return nil, fmt.Errorf("向量列表不能为空") + } + if topK <= 0 { + topK = 5 // 默认值 + } + + // 执行搜索 + // Milvus SDK v2.4 Search 方法签名: + // Search(ctx, collection, partitions, expr, outputFields, vectors, vectorField, metricType, topK, searchParam, opts...) + // partitions 传 []string{} 表示搜索所有分区;searchParam 与 CreateIndex 使用的 IndexAUTOINDEX 对应 + if ctx == nil { + ctx = context.Background() + } + + sp, err := entity.NewIndexAUTOINDEXSearchParam(1) // level 1:与 AUTOINDEX 对应 + if err != nil { + return nil, fmt.Errorf("创建搜索参数失败: %w", err) + } + + searchResult, err := vs.client.Search( + ctx, + vs.collection, + []string{}, // 空切片表示搜索所有分区 + expr, + outputFields, + vectors, + "embedding", + entity.IP, + topK, + sp, + ) + if err != nil { + return nil, fmt.Errorf("向量搜索失败: %w", err) + } + + // 验证搜索结果 + if searchResult == nil { + return []SearchResult{}, nil // 返回空结果而不是错误 + } + + // 转换结果:Search 返回 []client.SearchResult,每个 SearchResult 有 Fields、Scores、ResultCount + results := make([]SearchResult, 0) + for _, sr := range searchResult { + if sr.Err != nil { + continue + } + docCol := sr.Fields.GetColumn("document_id") + kbCol := sr.Fields.GetColumn("knowledge_base_id") + contentCol := sr.Fields.GetColumn("content") + if docCol == nil || kbCol == nil || contentCol == nil { + continue + } + for i := 0; i < sr.ResultCount && i < len(sr.Scores); i++ { + documentID, _ := docCol.GetAsString(i) + knowledgeBaseID, _ := kbCol.GetAsString(i) + content, _ := contentCol.GetAsString(i) + score := sr.Scores[i] + results = append(results, SearchResult{ + DocumentID: documentID, + KnowledgeBaseID: knowledgeBaseID, + Content: content, + Score: score, + }) + } + } + + return results, nil +} + +// DeleteVector 删除向量 +func (vs *VectorStore) DeleteVector(ctx context.Context, documentID string) error { + // 确保集合已加载 + if err := vs.ensureCollectionLoaded(ctx); err != nil { + return err + } + + expr := fmt.Sprintf("document_id == \"%s\"", documentID) + err := vs.client.Delete(ctx, vs.collection, "", expr) + if err != nil { + return fmt.Errorf("删除向量失败: %w", err) + } + return nil +} + +// DeleteVectors 批量删除向量 +func (vs *VectorStore) DeleteVectors(ctx context.Context, documentIDs []string) error { + // 确保集合已加载 + if err := vs.ensureCollectionLoaded(ctx); err != nil { + return err + } + + if len(documentIDs) == 0 { + return nil + } + + // 构建删除表达式 + expr := "document_id in [" + for i, id := range documentIDs { + if i > 0 { + expr += ", " + } + expr += fmt.Sprintf("\"%s\"", id) + } + expr += "]" + + err := vs.client.Delete(ctx, vs.collection, "", expr) + if err != nil { + return fmt.Errorf("批量删除向量失败: %w", err) + } + return nil +} + +// Close 关闭 Milvus 客户端连接 +func (vs *VectorStore) Close() error { + return vs.client.Close() +} + +// SearchResult 搜索结果 +type SearchResult struct { + DocumentID string + KnowledgeBaseID string + Content string + Score float32 +} diff --git a/backend/main.go b/backend/main.go index 2c4e01c..13126db 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "os" "path/filepath" @@ -13,6 +14,8 @@ import ( "github.com/2930134478/AI-CS/backend/repository" appRouter "github.com/2930134478/AI-CS/backend/router" "github.com/2930134478/AI-CS/backend/service" + "github.com/2930134478/AI-CS/backend/service/embedding" + "github.com/2930134478/AI-CS/backend/service/rag" "github.com/2930134478/AI-CS/backend/websocket" "github.com/gin-gonic/gin" "github.com/joho/godotenv" @@ -97,7 +100,7 @@ func main() { } //根据结构体定义自动创建更新表 - if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}); err != nil { + if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}); err != nil { log.Fatalf("自动创建表失败: %v", err) } @@ -106,6 +109,9 @@ func main() { messageRepo := repository.NewMessageRepository(db) aiConfigRepo := repository.NewAIConfigRepository(db) faqRepo := repository.NewFAQRepository(db) + kbRepo := repository.NewKnowledgeBaseRepository(db) + docRepo := repository.NewDocumentRepository(db) + embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db) // 初始化默认管理员账号(如果不存在) initDefaultAdmin(userRepo) @@ -127,14 +133,68 @@ func main() { publicPath := "/uploads" storageService := infra.NewLocalStorageService(uploadDir, publicPath) + // 初始化 Milvus 客户端(向量数据库) + milvusClient, err := infra.NewMilvusClient() + if err != nil { + log.Fatalf("连接 Milvus 失败: %v", err) + } + defer milvusClient.Close() + + // 检查 Milvus 健康状态 + if err := infra.HealthCheck(milvusClient); err != nil { + log.Fatalf("Milvus 健康检查失败: %v", err) + } + log.Println("✅ Milvus 连接成功") + + // 嵌入服务按需从 DB 配置获取(保存即生效,无需重启) + embeddingConfigService := service.NewEmbeddingConfigService(embeddingConfigRepo, userRepo) + embeddingFactory := embedding.NewEmbeddingFactory() + embeddingProvider := service.NewConfigBackedEmbeddingProvider(embeddingConfigService, embeddingFactory) + + // 启动时获取一次维度用于创建/校验向量集合 + initCtx := context.Background() + initSvc, _ := embeddingProvider.Get(initCtx) + if initSvc != nil { + log.Printf("✅ 嵌入服务按需从「知识库向量配置」加载,模型: %s (维度: %d),修改配置后立即生效", initSvc.GetModelName(), initSvc.GetDimension()) + } else { + log.Printf("⚠️ 未配置嵌入服务;知识库/RAG 需在「设置 - 知识库向量模型」中配置 API 后再使用") + } + dimension := 1536 + if initSvc != nil { + dimension = initSvc.GetDimension() + } + + // 向量存储:迁移时通过 getEmbedding 从当前配置重新向量化 + getEmbedding := func(ctx context.Context) (infra.EmbeddingService, error) { + svc, err := embeddingProvider.Get(ctx) + if err != nil || svc == nil { + return nil, err + } + return svc, nil + } + vectorStore, err := infra.NewVectorStore(milvusClient, "documents", dimension, getEmbedding) + if err != nil { + log.Fatalf("创建向量存储失败: %v", err) + } + vectorStoreService := rag.NewVectorStoreService(vectorStore) + + // 文档向量化 / RAG 检索 / 健康检查均使用 provider,配置保存即生效 + documentEmbeddingService := rag.NewDocumentEmbeddingService(vectorStoreService, embeddingProvider) + retrievalService := rag.NewRetrievalService(vectorStoreService, embeddingProvider, docRepo, kbRepo) + retrievalService.EnableCache(5 * time.Minute) + healthChecker := rag.NewHealthChecker(embeddingProvider, vectorStoreService) + // 初始化服务层 authService := service.NewAuthService(userRepo) conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo) profileService := service.NewProfileService(userRepo, storageService) aiConfigService := service.NewAIConfigService(aiConfigRepo, userRepo) - aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo) - userService := service.NewUserService(userRepo) // 用户管理服务 - faqService := service.NewFAQService(faqRepo) // FAQ 管理服务 + aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService) // 添加 RAG 检索服务 + userService := service.NewUserService(userRepo) // 用户管理服务 + faqService := service.NewFAQService(faqRepo, retrievalService, documentEmbeddingService) // FAQ 管理服务 + documentService := service.NewDocumentService(docRepo, kbRepo, documentEmbeddingService, retrievalService) // 文档管理服务 + knowledgeBaseService := service.NewKnowledgeBaseService(kbRepo, docRepo) // 知识库管理服务 + importService := service.NewImportService(docRepo, kbRepo, documentService, documentEmbeddingService) // 导入服务 // 声明 Hub 变量(用于在回调函数中访问) var wsHub *websocket.Hub @@ -252,19 +312,29 @@ func main() { profileController := controller.NewProfileController(profileService) aiConfigController := controller.NewAIConfigController(aiConfigService) faqController := controller.NewFAQController(faqService) + documentController := controller.NewDocumentController(documentService, embeddingConfigService) + embeddingConfigController := controller.NewEmbeddingConfigController(embeddingConfigService) + knowledgeBaseController := controller.NewKnowledgeBaseController(knowledgeBaseService, embeddingConfigService) + importController := controller.NewImportController(importService, embeddingConfigService) // 导入控制器 visitorController := controller.NewVisitorController(visitorService) + healthController := controller.NewHealthController(healthChecker, retrievalService) // 健康检查控制器 appRouter.RegisterRoutes( r, appRouter.ControllerSet{ - Auth: authController, - Conversation: conversationController, - Message: messageController, - Admin: adminController, - Profile: profileController, - AIConfig: aiConfigController, - FAQ: faqController, - Visitor: visitorController, + Auth: authController, + Conversation: conversationController, + Message: messageController, + Admin: adminController, + Profile: profileController, + AIConfig: aiConfigController, + EmbeddingConfig: embeddingConfigController, + FAQ: faqController, + Document: documentController, + KnowledgeBase: knowledgeBaseController, + Import: importController, // 导入控制器 + Visitor: visitorController, + Health: healthController, // 健康检查控制器 }, websocket.HandleWebSocket(wsHub), ) diff --git a/backend/models/document.go b/backend/models/document.go new file mode 100644 index 0000000..d62944a --- /dev/null +++ b/backend/models/document.go @@ -0,0 +1,19 @@ +package models + +import ( + "time" +) + +// Document 文档模型 +type Document struct { + ID uint `json:"id" gorm:"primarykey"` + KnowledgeBaseID uint `json:"knowledge_base_id" gorm:"index;not null"` + Title string `json:"title" gorm:"type:varchar(255);not null"` + Content string `json:"content" gorm:"type:text;not null"` + Summary string `json:"summary" gorm:"type:text"` // 摘要 + Type string `json:"type" gorm:"type:varchar(50);default:'document'"` // 文档类型:document, url, file + Status string `json:"status" gorm:"type:varchar(20);default:'draft'"` // 状态:draft(草稿)、published(已发布) + EmbeddingStatus string `json:"embedding_status" gorm:"type:varchar(20);default:'pending'"` // 向量化状态:pending(待处理)、processing(处理中)、completed(已完成)、failed(失败) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/models/embedding_config.go b/backend/models/embedding_config.go new file mode 100644 index 0000000..f291c58 --- /dev/null +++ b/backend/models/embedding_config.go @@ -0,0 +1,16 @@ +package models + +import "time" + +// EmbeddingConfig 知识库向量模型配置(平台级单例,仅一条有效记录) +// 用于文档向量化与 RAG 检索,在前端「设置 - 知识库向量模型」中配置 +type EmbeddingConfig struct { + ID uint `json:"id" gorm:"primaryKey"` + EmbeddingType string `json:"embedding_type" gorm:"type:varchar(50);default:'openai'"` // openai / bge / local + APIURL string `json:"api_url" gorm:"type:varchar(500)"` // API 地址 + APIKey string `json:"-" gorm:"type:varchar(1000)"` // API Key(加密存储,不返回给前端) + Model string `json:"model" gorm:"type:varchar(100)"` // 模型名称 + CustomerCanUseKB bool `json:"customer_can_use_kb" gorm:"default:true"` // 是否开放知识库给客服使用(创建/上传/RAG) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/models/faq.go b/backend/models/faq.go index 322a23c..0123dec 100644 --- a/backend/models/faq.go +++ b/backend/models/faq.go @@ -7,11 +7,14 @@ import ( // FAQ 常见问题/事件记录模型 // 用于存储客服常见问题的问答记录 type FAQ struct { - ID uint `json:"id" gorm:"primarykey"` - Question string `json:"question" gorm:"type:text;not null"` // 问题 - Answer string `json:"answer" gorm:"type:text;not null"` // 答案 - Keywords string `json:"keywords" gorm:"type:varchar(500)"` // 关键词,用逗号或空格分隔,用于搜索 - CreatedAt time.Time `json:"created_at"` // 创建时间 - UpdatedAt time.Time `json:"updated_at"` // 更新时间 + ID uint `json:"id" gorm:"primarykey"` + Question string `json:"question" gorm:"type:text;not null"` // 问题 + Answer string `json:"answer" gorm:"type:text;not null"` // 答案 + Keywords string `json:"keywords" gorm:"type:varchar(500)"` // 关键词,用逗号或空格分隔,用于搜索 + VectorID *string `json:"vector_id" gorm:"type:varchar(255);index"` // Milvus 向量 ID(用于向量检索) + EmbeddingStatus string `json:"embedding_status" gorm:"type:varchar(20);default:'pending'"` // 向量化状态:pending(待处理)、processing(处理中)、completed(已完成)、failed(失败) + KnowledgeBaseID *uint `json:"knowledge_base_id" gorm:"index"` // 所属知识库 ID(可选,用于知识库分类) + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedAt time.Time `json:"updated_at"` // 更新时间 } diff --git a/backend/models/knowledge_base.go b/backend/models/knowledge_base.go new file mode 100644 index 0000000..c862bc8 --- /dev/null +++ b/backend/models/knowledge_base.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" +) + +// KnowledgeBase 知识库模型 +type KnowledgeBase struct { + ID uint `json:"id" gorm:"primarykey"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + Description string `json:"description" gorm:"type:text"` + DocumentCount int `json:"document_count" gorm:"default:0"` // 文档数量(缓存字段) + RAGEnabled bool `json:"rag_enabled" gorm:"default:true"` // 是否参与 RAG:开启时该知识库下的已发布文档会被 AI 引用 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/models/user.go b/backend/models/user.go index c9c4c7d..7966e92 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -19,10 +19,11 @@ type User struct { } type Conversation struct { - ID uint `json:"id" gorm:"primaryKey"` - VisitorID uint `json:"visitor_id"` - AgentID uint `json:"agent_id"` - Status string `json:"status"` + ID uint `json:"id" gorm:"primaryKey"` + ConversationType string `json:"conversation_type" gorm:"type:varchar(20);default:'visitor'"` // visitor(访客对话)、internal(内部/知识库测试) + VisitorID uint `json:"visitor_id"` + AgentID uint `json:"agent_id"` + Status string `json:"status"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // 访客信息字段(自动收集) diff --git a/backend/package-lock.json b/backend/package-lock.json deleted file mode 100644 index 1d893b1..0000000 --- a/backend/package-lock.json +++ /dev/null @@ -1,305 +0,0 @@ -{ - "name": "backend", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-label": "^2.1.8" - } - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" - }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", - "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "peer": true - } - } -} diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index 09c65a4..0000000 --- a/backend/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "dependencies": { - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-label": "^2.1.8" - } -} diff --git a/backend/repository/conversation_repository.go b/backend/repository/conversation_repository.go index 1cf52d8..5242e93 100644 --- a/backend/repository/conversation_repository.go +++ b/backend/repository/conversation_repository.go @@ -17,10 +17,10 @@ func NewConversationRepository(db *gorm.DB) *ConversationRepository { return &ConversationRepository{db: db} } -// FindOpenByVisitorID 查询访客当前未关闭的会话。 +// FindOpenByVisitorID 查询访客当前未关闭的会话(仅 visitor 类型)。 func (r *ConversationRepository) FindOpenByVisitorID(visitorID uint) (*models.Conversation, error) { var conv models.Conversation - err := r.db.Where("visitor_id = ? AND status != ?", visitorID, "closed"). + err := r.db.Where("conversation_type = ? AND visitor_id = ? AND status != ?", "visitor", visitorID, "closed"). Order("created_at desc"). First(&conv).Error if err != nil { @@ -29,6 +29,18 @@ func (r *ConversationRepository) FindOpenByVisitorID(visitorID uint) (*models.Co return &conv, nil } +// ListActiveInternalByAgentID 返回某客服的全部未关闭内部对话(知识库测试用)。 +func (r *ConversationRepository) ListActiveInternalByAgentID(agentID uint) ([]models.Conversation, error) { + var list []models.Conversation + err := r.db.Where("conversation_type = ? AND agent_id = ? AND status != ?", "internal", agentID, "closed"). + Order("updated_at desc"). + Find(&list).Error + if err != nil { + return nil, err + } + return list, nil +} + // Create 创建新的会话记录。 func (r *ConversationRepository) Create(conv *models.Conversation) error { return r.db.Create(conv).Error @@ -51,10 +63,10 @@ func (r *ConversationRepository) GetByID(id uint) (*models.Conversation, error) return &conv, nil } -// ListActive 返回所有未关闭的会话。 +// ListActive 返回所有未关闭的访客会话(不含 internal)。 func (r *ConversationRepository) ListActive() ([]models.Conversation, error) { var conversations []models.Conversation - if err := r.db.Where("status != ?", "closed"). + if err := r.db.Where("conversation_type = ? AND status != ?", "visitor", "closed"). Order("updated_at desc"). Find(&conversations).Error; err != nil { return nil, err diff --git a/backend/repository/document_repository.go b/backend/repository/document_repository.go new file mode 100644 index 0000000..d045d97 --- /dev/null +++ b/backend/repository/document_repository.go @@ -0,0 +1,104 @@ +package repository + +import ( + "github.com/2930134478/AI-CS/backend/models" + "gorm.io/gorm" +) + +// DocumentRepository 封装与文档相关的数据库操作 +type DocumentRepository struct { + db *gorm.DB +} + +// NewDocumentRepository 创建文档仓库实例 +func NewDocumentRepository(db *gorm.DB) *DocumentRepository { + return &DocumentRepository{db: db} +} + +// Create 创建新的文档 +func (r *DocumentRepository) Create(doc *models.Document) error { + return r.db.Create(doc).Error +} + +// GetByID 根据ID查询文档 +func (r *DocumentRepository) GetByID(id uint) (*models.Document, error) { + var doc models.Document + if err := r.db.Where("id = ?", id).First(&doc).Error; err != nil { + return nil, err + } + return &doc, nil +} + +// GetByKnowledgeBaseID 根据知识库ID查询文档列表 +func (r *DocumentRepository) GetByKnowledgeBaseID(knowledgeBaseID uint, page, pageSize int, keyword string, status string) ([]models.Document, int64, error) { + var docs []models.Document + var total int64 + + query := r.db.Model(&models.Document{}).Where("knowledge_base_id = ?", knowledgeBaseID) + + // 关键词搜索 + if keyword != "" { + query = query.Where("title LIKE ? OR content LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + + // 状态过滤 + if status != "" { + query = query.Where("status = ?", status) + } + + // 统计总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&docs).Error; err != nil { + return nil, 0, err + } + + return docs, total, nil +} + +// Update 更新文档 +func (r *DocumentRepository) Update(doc *models.Document) error { + return r.db.Save(doc).Error +} + +// Delete 删除文档 +func (r *DocumentRepository) Delete(id uint) error { + return r.db.Delete(&models.Document{}, id).Error +} + +// DeleteByKnowledgeBaseID 根据知识库ID删除所有文档 +func (r *DocumentRepository) DeleteByKnowledgeBaseID(knowledgeBaseID uint) error { + return r.db.Where("knowledge_base_id = ?", knowledgeBaseID).Delete(&models.Document{}).Error +} + +// CountByKnowledgeBaseID 统计知识库的文档数量 +func (r *DocumentRepository) CountByKnowledgeBaseID(knowledgeBaseID uint) (int64, error) { + var count int64 + if err := r.db.Model(&models.Document{}).Where("knowledge_base_id = ?", knowledgeBaseID).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// GetByIDs 根据ID列表查询文档 +func (r *DocumentRepository) GetByIDs(ids []uint) ([]models.Document, error) { + var docs []models.Document + if err := r.db.Where("id IN ?", ids).Find(&docs).Error; err != nil { + return nil, err + } + return docs, nil +} + +// UpdateEmbeddingStatus 更新文档的向量化状态 +func (r *DocumentRepository) UpdateEmbeddingStatus(id uint, status string) error { + return r.db.Model(&models.Document{}).Where("id = ?", id).Update("embedding_status", status).Error +} + +// UpdateStatus 更新文档的状态 +func (r *DocumentRepository) UpdateStatus(id uint, status string) error { + return r.db.Model(&models.Document{}).Where("id = ?", id).Update("status", status).Error +} diff --git a/backend/repository/embedding_config_repository.go b/backend/repository/embedding_config_repository.go new file mode 100644 index 0000000..bf99073 --- /dev/null +++ b/backend/repository/embedding_config_repository.go @@ -0,0 +1,35 @@ +package repository + +import ( + "github.com/2930134478/AI-CS/backend/models" + "gorm.io/gorm" +) + +// EmbeddingConfigRepository 知识库向量配置仓储(单例) +type EmbeddingConfigRepository struct { + db *gorm.DB +} + +// NewEmbeddingConfigRepository 创建仓储实例 +func NewEmbeddingConfigRepository(db *gorm.DB) *EmbeddingConfigRepository { + return &EmbeddingConfigRepository{db: db} +} + +// Get 获取唯一一条配置(id=1),不存在则返回 nil, nil +func (r *EmbeddingConfigRepository) Get() (*models.EmbeddingConfig, error) { + var m models.EmbeddingConfig + err := r.db.First(&m, 1).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +// Save 保存或更新(存在则更新,不存在则插入 id=1) +func (r *EmbeddingConfigRepository) Save(c *models.EmbeddingConfig) error { + c.ID = 1 + return r.db.Save(c).Error +} diff --git a/backend/repository/knowledge_base_repository.go b/backend/repository/knowledge_base_repository.go new file mode 100644 index 0000000..09470f1 --- /dev/null +++ b/backend/repository/knowledge_base_repository.go @@ -0,0 +1,66 @@ +package repository + +import ( + "github.com/2930134478/AI-CS/backend/models" + "gorm.io/gorm" +) + +// KnowledgeBaseRepository 封装与知识库相关的数据库操作 +type KnowledgeBaseRepository struct { + db *gorm.DB +} + +// NewKnowledgeBaseRepository 创建知识库仓库实例 +func NewKnowledgeBaseRepository(db *gorm.DB) *KnowledgeBaseRepository { + return &KnowledgeBaseRepository{db: db} +} + +// Create 创建新的知识库 +func (r *KnowledgeBaseRepository) Create(kb *models.KnowledgeBase) error { + return r.db.Create(kb).Error +} + +// GetByID 根据ID查询知识库 +func (r *KnowledgeBaseRepository) GetByID(id uint) (*models.KnowledgeBase, error) { + var kb models.KnowledgeBase + if err := r.db.Where("id = ?", id).First(&kb).Error; err != nil { + return nil, err + } + return &kb, nil +} + +// List 获取所有知识库列表 +func (r *KnowledgeBaseRepository) List() ([]models.KnowledgeBase, error) { + var kbs []models.KnowledgeBase + if err := r.db.Order("created_at DESC").Find(&kbs).Error; err != nil { + return nil, err + } + return kbs, nil +} + +// Update 更新知识库 +func (r *KnowledgeBaseRepository) Update(kb *models.KnowledgeBase) error { + return r.db.Save(kb).Error +} + +// Delete 删除知识库 +func (r *KnowledgeBaseRepository) Delete(id uint) error { + return r.db.Delete(&models.KnowledgeBase{}, id).Error +} + +// UpdateDocumentCount 更新知识库的文档数量 +func (r *KnowledgeBaseRepository) UpdateDocumentCount(id uint, count int) error { + return r.db.Model(&models.KnowledgeBase{}).Where("id = ?", id).Update("document_count", count).Error +} + +// GetByIDs 根据多个 ID 批量查询知识库(用于 RAG 过滤)。 +func (r *KnowledgeBaseRepository) GetByIDs(ids []uint) ([]models.KnowledgeBase, error) { + if len(ids) == 0 { + return nil, nil + } + var list []models.KnowledgeBase + if err := r.db.Where("id IN ?", ids).Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} diff --git a/backend/router/router.go b/backend/router/router.go index 5edae75..fbd9223 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -7,14 +7,19 @@ import ( // ControllerSet 用于收集路由需要的控制器集合。 type ControllerSet struct { - Auth *controller.AuthController - Conversation *controller.ConversationController - Message *controller.MessageController - Admin *controller.AdminController - Profile *controller.ProfileController - AIConfig *controller.AIConfigController - FAQ *controller.FAQController - Visitor *controller.VisitorController + Auth *controller.AuthController + Conversation *controller.ConversationController + Message *controller.MessageController + Admin *controller.AdminController + Profile *controller.ProfileController + AIConfig *controller.AIConfigController + EmbeddingConfig *controller.EmbeddingConfigController + FAQ *controller.FAQController + Document *controller.DocumentController + KnowledgeBase *controller.KnowledgeBaseController + Import *controller.ImportController + Visitor *controller.VisitorController + Health *controller.HealthController } // RegisterRoutes 注册 HTTP 路由及对应的处理函数。 @@ -25,6 +30,7 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand // Conversation r.POST("/conversation/init", controllers.Conversation.InitConversation) + r.POST("/conversations/internal", controllers.Conversation.InitInternalConversation) // 创建内部对话(知识库测试) r.GET("/conversations", controllers.Conversation.ListConversations) r.GET("/conversations/:id", controllers.Conversation.GetConversationDetail) r.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo) @@ -59,6 +65,10 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand r.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig) r.DELETE("/agent/ai-config/:user_id/:id", controllers.AIConfig.DeleteAIConfig) + // Embedding Config(知识库向量模型配置,平台级) + r.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get) + r.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update) + // FAQ(事件管理/常见问题) r.GET("/faqs", controllers.FAQ.ListFAQs) // 获取 FAQ 列表(支持关键词搜索) r.GET("/faqs/:id", controllers.FAQ.GetFAQ) // 获取 FAQ 详情 @@ -66,9 +76,38 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand r.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ) // 更新 FAQ r.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ) // 删除 FAQ + // Document(文档管理) + r.GET("/documents", controllers.Document.ListDocuments) // 获取文档列表(支持分页、搜索、状态过滤) + r.GET("/documents/:id", controllers.Document.GetDocument) // 获取文档详情 + r.POST("/documents", controllers.Document.CreateDocument) // 创建文档 + r.PUT("/documents/:id", controllers.Document.UpdateDocument) // 更新文档 + r.DELETE("/documents/:id", controllers.Document.DeleteDocument) // 删除文档 + r.GET("/documents/search", controllers.Document.SearchDocuments) // 向量检索搜索文档 + r.GET("/documents/hybrid-search", controllers.Document.HybridSearchDocuments) // 混合检索搜索文档 + r.PUT("/documents/:id/status", controllers.Document.UpdateDocumentStatus) // 更新文档状态 + r.POST("/documents/:id/publish", controllers.Document.PublishDocument) // 发布文档 + r.POST("/documents/:id/unpublish", controllers.Document.UnpublishDocument) // 取消发布文档 + + // KnowledgeBase(知识库管理) + r.GET("/knowledge-bases", controllers.KnowledgeBase.ListKnowledgeBases) // 获取知识库列表 + r.GET("/knowledge-bases/:id", controllers.KnowledgeBase.GetKnowledgeBase) // 获取知识库详情 + r.POST("/knowledge-bases", controllers.KnowledgeBase.CreateKnowledgeBase) // 创建知识库 + r.PUT("/knowledge-bases/:id", controllers.KnowledgeBase.UpdateKnowledgeBase) // 更新知识库 + r.PATCH("/knowledge-bases/:id/rag-enabled", controllers.KnowledgeBase.UpdateKnowledgeBaseRAGEnabled) // 知识库是否参与 RAG + r.DELETE("/knowledge-bases/:id", controllers.KnowledgeBase.DeleteKnowledgeBase) // 删除知识库 + r.GET("/knowledge-bases/:id/documents", controllers.KnowledgeBase.ListDocumentsByKnowledgeBase) // 获取知识库的文档列表 + + // Import(文档导入) + r.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传) + r.POST("/import/urls", controllers.Import.ImportFromURLs) // 批量导入文档(URL 爬取) + // Visitor(访客相关) r.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表 + // Health(健康检查) + r.GET("/health", controllers.Health.HealthCheck) // 健康检查 + r.GET("/health/metrics", controllers.Health.Metrics) // 性能指标 + // WebSocket r.GET("/ws", wsHandler) } diff --git a/backend/service/ai_service.go b/backend/service/ai_service.go index ff2d123..b5a00d9 100644 --- a/backend/service/ai_service.go +++ b/backend/service/ai_service.go @@ -1,13 +1,16 @@ package service import ( + "context" "encoding/json" "errors" "fmt" "log" + "strings" "github.com/2930134478/AI-CS/backend/models" "github.com/2930134478/AI-CS/backend/repository" + "github.com/2930134478/AI-CS/backend/service/rag" "github.com/2930134478/AI-CS/backend/utils" "gorm.io/gorm" ) @@ -17,6 +20,7 @@ type AIService struct { aiConfigRepo *repository.AIConfigRepository messageRepo *repository.MessageRepository conversationRepo *repository.ConversationRepository + retrievalService *rag.RetrievalService // RAG 检索服务 providerFactory *AIProviderFactory } @@ -25,11 +29,13 @@ func NewAIService( aiConfigRepo *repository.AIConfigRepository, messageRepo *repository.MessageRepository, conversationRepo *repository.ConversationRepository, + retrievalService *rag.RetrievalService, // 添加 RAG 检索服务 ) *AIService { return &AIService{ aiConfigRepo: aiConfigRepo, messageRepo: messageRepo, conversationRepo: conversationRepo, + retrievalService: retrievalService, providerFactory: NewAIProviderFactory(), } } @@ -45,7 +51,7 @@ func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, if err != nil { return "", fmt.Errorf("获取对话失败: %v", err) } - + var config *models.AIConfig if conversation.AIConfigID != nil { // 使用对话绑定的配置(多厂商支持) @@ -82,7 +88,23 @@ func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, history = []MessageHistory{} } - // 4. 解析适配器配置(如果有) + // 4. RAG 检索:从知识库中检索相关文档 + ragContext := "" + if s.retrievalService != nil { + ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation) + if err != nil { + log.Printf("⚠️ RAG 检索失败: %v,继续使用无知识库上下文", err) + // RAG 检索失败不影响主流程,继续处理 + } + } + + // 5. 构建增强的用户消息(包含 RAG 上下文) + enhancedUserMessage := userMessage + if ragContext != "" { + enhancedUserMessage = s.buildRAGPrompt(userMessage, ragContext) + } + + // 6. 解析适配器配置(如果有) var adapterConfig *AdapterConfig if config.AdapterConfig != "" { if err := json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig); err != nil { @@ -90,7 +112,7 @@ func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, } } - // 5. 创建 AI 提供商 + // 7. 创建 AI 提供商 aiConfig := AIConfig{ APIURL: config.APIURL, APIKey: apiKey, @@ -105,8 +127,8 @@ func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, return "", fmt.Errorf("创建 AI 提供商失败: %v", err) } - // 6. 调用 AI 生成回复 - response, err := provider.GenerateResponse(history, userMessage) + // 8. 调用 AI 生成回复(使用增强的消息) + response, err := provider.GenerateResponse(history, enhancedUserMessage) if err != nil { // AI 调用失败,返回友好的错误消息 log.Printf("❌ AI 调用失败: %v", err) @@ -152,3 +174,63 @@ func (s *AIService) buildConversationHistory(conversationID uint) ([]MessageHist return history, nil } +// retrieveRAGContext 从知识库中检索相关文档内容 +// query: 用户查询文本 +// conversation: 对话信息(可能包含知识库 ID) +// 返回: 检索到的文档内容(格式化后的字符串) +func (s *AIService) retrieveRAGContext(ctx context.Context, query string, conversation *models.Conversation) (string, error) { + // 确定知识库 ID(可以从对话中获取,或为空表示搜索所有知识库) + // TODO: 后续在 Conversation 模型增加 KnowledgeBaseID 字段 + var knowledgeBaseID *uint + // knowledgeBaseID = conversation.KnowledgeBaseID // 暂时注释,等模型字段添加后启用 + + // 执行 RAG 检索(Top-K = 5,返回最相关的 5 个文档片段) + // 使用重排序优化检索结果 + topK := 5 + results, err := s.retrievalService.RetrieveWithRerank(ctx, query, topK, knowledgeBaseID) + if err != nil { + return "", fmt.Errorf("RAG 检索失败: %w", err) + } + + if len(results) == 0 { + // 没有检索到相关文档 + return "", nil + } + + // 格式化检索结果 + var contextParts []string + for i, result := range results { + // 只使用相似度较高的结果(Score 越小表示相似度越高) + // 如果使用余弦相似度,通常阈值在 0.7-0.9 之间 + // 这里我们暂时不过滤,让所有结果都参与 + contextParts = append(contextParts, fmt.Sprintf("文档片段 %d:\n%s", i+1, result.Content)) + } + + return strings.Join(contextParts, "\n\n"), nil +} + +// buildRAGPrompt 构建包含 RAG 上下文的 Prompt +// userMessage: 用户原始消息 +// ragContext: RAG 检索到的文档内容 +// 返回: 增强后的用户消息(包含知识库上下文) +func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string { + // 构建 RAG Prompt 模板 + // 参考 PandaWiki 的 Prompt 格式 + prompt := fmt.Sprintf(`你是一个智能客服助手,请基于以下知识库内容回答用户的问题。 + +知识库内容: +%s + +用户问题:%s + +请根据知识库内容回答用户的问题。如果知识库中没有相关信息,请礼貌地告知用户,并建议联系人工客服。 + +回答要求: +1. 基于知识库内容,提供准确、有用的回答 +2. 如果知识库中有相关信息,请直接引用并解释 +3. 如果知识库中没有相关信息,请诚实告知 +4. 保持友好、专业的语气 +5. 回答要简洁明了,避免冗长`, ragContext, userMessage) + + return prompt +} diff --git a/backend/service/conversation_service.go b/backend/service/conversation_service.go index 741aa7e..b23f1b0 100644 --- a/backend/service/conversation_service.go +++ b/backend/service/conversation_service.go @@ -72,9 +72,10 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In } conv = &models.Conversation{ - VisitorID: input.VisitorID, - Status: "open", - Website: input.Website, + ConversationType: "visitor", + VisitorID: input.VisitorID, + Status: "open", + Website: input.Website, Referrer: input.Referrer, Browser: input.Browser, OS: input.OS, @@ -254,14 +255,16 @@ func (s *ConversationService) buildSummary(conv models.Conversation, userID uint } summary := ConversationSummary{ - ID: conv.ID, - VisitorID: conv.VisitorID, - AgentID: conv.AgentID, - Status: conv.Status, - CreatedAt: conv.CreatedAt, - UpdatedAt: conv.UpdatedAt, - LastSeenAt: lastSeen, // 添加 last_seen_at 字段 - HasParticipated: hasParticipated, // 当前用户是否参与过该会话 + ID: conv.ID, + ConversationType: conv.ConversationType, + VisitorID: conv.VisitorID, + AgentID: conv.AgentID, + Status: conv.Status, + ChatMode: conv.ChatMode, + CreatedAt: conv.CreatedAt, + UpdatedAt: conv.UpdatedAt, + LastSeenAt: lastSeen, + HasParticipated: hasParticipated, } if message, err := s.messages.LatestByConversationID(conv.ID); err == nil && message != nil { @@ -331,12 +334,15 @@ func (s *ConversationService) ListConversations(userID uint) ([]ConversationSumm return result, nil } -// GetConversationDetail 获取指定会话的详细信息。 +// GetConversationDetail 获取指定会话的详细信息。内部对话仅创建者(agent_id)可查看。 func (s *ConversationService) GetConversationDetail(id uint, userID uint) (*ConversationDetail, error) { conv, err := s.conversations.GetByID(id) if err != nil { return nil, err } + if conv.ConversationType == "internal" && userID > 0 && conv.AgentID != userID { + return nil, gorm.ErrRecordNotFound + } summary, err := s.buildSummary(*conv, userID) if err != nil { @@ -443,3 +449,44 @@ func (s *ConversationService) UpdateLastSeenAt(conversationID uint) error { "last_seen_at": &now, }) } + +// InitInternalConversation 为客服创建一条新的内部对话(知识库测试用)。每次调用创建新会话。 +func (s *ConversationService) InitInternalConversation(agentID uint) (*InitConversationResult, error) { + if agentID == 0 { + return nil, errors.New("agent_id is required for internal conversation") + } + conv := &models.Conversation{ + ConversationType: "internal", + VisitorID: 0, + AgentID: agentID, + Status: "open", + ChatMode: "ai", + } + if err := s.conversations.Create(conv); err != nil { + return nil, err + } + return &InitConversationResult{ + ConversationID: conv.ID, + Status: conv.Status, + }, nil +} + +// ListInternalConversations 返回当前客服的全部内部对话(知识库测试用)。 +func (s *ConversationService) ListInternalConversations(agentID uint) ([]ConversationSummary, error) { + if agentID == 0 { + return []ConversationSummary{}, nil + } + conversations, err := s.conversations.ListActiveInternalByAgentID(agentID) + if err != nil { + return nil, err + } + result := make([]ConversationSummary, 0, len(conversations)) + for _, conv := range conversations { + summary, err := s.buildSummary(conv, agentID) + if err != nil { + continue + } + result = append(result, summary) + } + return result, nil +} diff --git a/backend/service/document_service.go b/backend/service/document_service.go new file mode 100644 index 0000000..a3031ba --- /dev/null +++ b/backend/service/document_service.go @@ -0,0 +1,261 @@ +package service + +import ( + "context" + "errors" + "strconv" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/repository" + "github.com/2930134478/AI-CS/backend/service/rag" +) + +// DocumentService 文档管理服务 +type DocumentService struct { + docRepo *repository.DocumentRepository + kbRepo *repository.KnowledgeBaseRepository + documentEmbeddingService *rag.DocumentEmbeddingService + retrievalService *rag.RetrievalService +} + +// NewDocumentService 创建文档服务实例 +func NewDocumentService( + docRepo *repository.DocumentRepository, + kbRepo *repository.KnowledgeBaseRepository, + documentEmbeddingService *rag.DocumentEmbeddingService, + retrievalService *rag.RetrievalService, +) *DocumentService { + return &DocumentService{ + docRepo: docRepo, + kbRepo: kbRepo, + documentEmbeddingService: documentEmbeddingService, + retrievalService: retrievalService, + } +} + +// CreateDocument 创建文档 +func (s *DocumentService) CreateDocument(input CreateDocumentInput) (*DocumentSummary, error) { + // 验证知识库是否存在 + _, err := s.kbRepo.GetByID(input.KnowledgeBaseID) + if err != nil { + return nil, errors.New("知识库不存在") + } + + if input.Title == "" { + return nil, errors.New("文档标题不能为空") + } + if input.Content == "" { + return nil, errors.New("文档内容不能为空") + } + + docType := input.Type + if docType == "" { + docType = "document" + } + + status := input.Status + if status == "" { + status = "draft" + } + + doc := &models.Document{ + KnowledgeBaseID: input.KnowledgeBaseID, + Title: input.Title, + Content: input.Content, + Summary: input.Summary, + Type: docType, + Status: status, + EmbeddingStatus: "pending", + } + + if err := s.docRepo.Create(doc); err != nil { + return nil, err + } + + // 异步向量化 + go s.embedDocumentAsync(context.Background(), doc.ID, doc.KnowledgeBaseID, doc.Content) + + return s.toSummary(doc), nil +} + +// embedDocumentAsync 异步向量化文档 +func (s *DocumentService) embedDocumentAsync(ctx context.Context, docID uint, kbID uint, content string) { + // 更新状态为处理中 + s.docRepo.UpdateEmbeddingStatus(docID, "processing") + + // 向量化 + err := s.documentEmbeddingService.EmbedDocument(ctx, docID, kbID, content) + if err != nil { + s.docRepo.UpdateEmbeddingStatus(docID, "failed") + return + } + + // 更新状态为已完成 + s.docRepo.UpdateEmbeddingStatus(docID, "completed") +} + +// GetDocument 获取文档详情 +func (s *DocumentService) GetDocument(id uint) (*DocumentSummary, error) { + doc, err := s.docRepo.GetByID(id) + if err != nil { + return nil, err + } + return s.toSummary(doc), nil +} + +// ListDocuments 获取文档列表 +func (s *DocumentService) ListDocuments(knowledgeBaseID uint, page, pageSize int, keyword string, status string) (*DocumentListResult, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + + docs, total, err := s.docRepo.GetByKnowledgeBaseID(knowledgeBaseID, page, pageSize, keyword, status) + if err != nil { + return nil, err + } + + summaries := make([]DocumentSummary, len(docs)) + for i, doc := range docs { + summaries[i] = *s.toSummary(&doc) + } + + totalPage := int((total + int64(pageSize) - 1) / int64(pageSize)) + + return &DocumentListResult{ + Documents: summaries, + Total: total, + Page: page, + PageSize: pageSize, + TotalPage: totalPage, + }, nil +} + +// UpdateDocument 更新文档 +func (s *DocumentService) UpdateDocument(id uint, input UpdateDocumentInput) (*DocumentSummary, error) { + doc, err := s.docRepo.GetByID(id) + if err != nil { + return nil, err + } + + needReembed := false + + if input.Title != nil { + doc.Title = *input.Title + } + if input.Content != nil { + doc.Content = *input.Content + needReembed = true // 内容变化需要重新向量化 + } + if input.Summary != nil { + doc.Summary = *input.Summary + } + if input.Type != nil { + doc.Type = *input.Type + } + if input.Status != nil { + doc.Status = *input.Status + } + + if err := s.docRepo.Update(doc); err != nil { + return nil, err + } + + // 如果内容变化,重新向量化 + if needReembed { + doc.EmbeddingStatus = "pending" + s.docRepo.Update(doc) + go s.embedDocumentAsync(context.Background(), doc.ID, doc.KnowledgeBaseID, doc.Content) + } + + return s.toSummary(doc), nil +} + +// DeleteDocument 删除文档 +func (s *DocumentService) DeleteDocument(id uint) error { + _, err := s.docRepo.GetByID(id) + if err != nil { + return err + } + + // 删除向量 + if err := s.documentEmbeddingService.DeleteDocumentEmbedding(context.Background(), id); err != nil { + // 记录错误但不阻止删除 + } + + // 删除文档 + return s.docRepo.Delete(id) +} + +// UpdateDocumentStatus 更新文档状态 +func (s *DocumentService) UpdateDocumentStatus(id uint, status string) error { + return s.docRepo.UpdateStatus(id, status) +} + +// PublishDocument 发布文档 +func (s *DocumentService) PublishDocument(id uint) error { + return s.UpdateDocumentStatus(id, "published") +} + +// UnpublishDocument 取消发布文档 +func (s *DocumentService) UnpublishDocument(id uint) error { + return s.UpdateDocumentStatus(id, "draft") +} + +// SearchDocuments 向量检索文档 +func (s *DocumentService) SearchDocuments(query string, topK int, knowledgeBaseID *uint) ([]DocumentSummary, error) { + results, err := s.retrievalService.Retrieve(context.Background(), query, topK, knowledgeBaseID) + if err != nil { + return nil, err + } + + // 获取文档 ID + docIDs := make([]uint, 0, len(results)) + for _, result := range results { + // 将 document_id 字符串转换为 uint + docID, err := strconv.ParseUint(result.DocumentID, 10, 64) + if err == nil { + docIDs = append(docIDs, uint(docID)) + } + } + + // 查询文档详情 + if len(docIDs) > 0 { + docs, err := s.docRepo.GetByIDs(docIDs) + if err == nil { + // 保持检索结果的顺序 + docMap := make(map[uint]*models.Document) + for i := range docs { + docMap[docs[i].ID] = &docs[i] + } + + summaries := make([]DocumentSummary, 0, len(docIDs)) + for _, docID := range docIDs { + if doc, ok := docMap[docID]; ok { + summaries = append(summaries, *s.toSummary(doc)) + } + } + return summaries, nil + } + } + + return []DocumentSummary{}, nil +} + +// toSummary 转换为摘要 +func (s *DocumentService) toSummary(doc *models.Document) *DocumentSummary { + return &DocumentSummary{ + ID: doc.ID, + KnowledgeBaseID: doc.KnowledgeBaseID, + Title: doc.Title, + Content: doc.Content, + Summary: doc.Summary, + Type: doc.Type, + Status: doc.Status, + EmbeddingStatus: doc.EmbeddingStatus, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + } +} diff --git a/backend/service/embedding/bge.go b/backend/service/embedding/bge.go new file mode 100644 index 0000000..c03133e --- /dev/null +++ b/backend/service/embedding/bge.go @@ -0,0 +1,139 @@ +package embedding + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +// BGEEmbeddingService BGE 嵌入服务实现 +type BGEEmbeddingService struct { + apiURL string + apiKey string + model string + dimension int +} + +// NewBGEEmbeddingService 创建 BGE 嵌入服务实例 +func NewBGEEmbeddingService(apiURL, apiKey, model string) *BGEEmbeddingService { + if apiURL == "" { + apiURL = "http://localhost:8080" + } + if model == "" { + model = "bge-small-zh-v1.5" + } + + return &BGEEmbeddingService{ + apiURL: apiURL, + apiKey: apiKey, + model: model, + dimension: 512, // BGE 模型的默认维度 + } +} + +// EmbedText 向量化单个文本 +func (s *BGEEmbeddingService) EmbedText(ctx context.Context, text string) ([]float32, error) { + vectors, err := s.EmbedTexts(ctx, []string{text}) + if err != nil { + return nil, err + } + if len(vectors) == 0 { + return nil, fmt.Errorf("未返回向量") + } + return vectors[0], nil +} + +// EmbedTexts 批量向量化文本 +func (s *BGEEmbeddingService) EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) { + if len(texts) == 0 { + return nil, nil + } + + // 支持填完整路径或仅填 base:若已以 /embeddings 结尾则不再追加,否则追加 /embeddings + url := strings.TrimSuffix(s.apiURL, "/") + if url != "" && !strings.HasSuffix(strings.ToLower(url), "/embeddings") { + url = url + "/embeddings" + } else if url == "" { + url = s.apiURL + "/embeddings" + } + + // 构建请求体(兼容 HuggingFace Inference API 格式) + requestBody := map[string]interface{}{ + "inputs": texts, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + // 创建 HTTP 请求 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if s.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+s.apiKey) + } + + // 发送请求 + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("BGE 嵌入服务调用失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("BGE 嵌入服务调用失败: HuggingFace API 返回错误状态码 %d: %s", resp.StatusCode, string(body)) + } + + // 解析响应(HuggingFace Inference API 格式);若返回 HTML 则提示检查 API 地址/密钥 + var response [][]float64 + if err := json.Unmarshal(body, &response); err != nil { + if len(body) > 0 && body[0] == '<' { + snippet := string(body) + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + log.Printf("[嵌入] BGE 返回了 HTML 而非 JSON,请检查 API 地址与密钥。响应片段: %s", snippet) + return nil, fmt.Errorf("嵌入 API 返回了 HTML 而非 JSON,请检查「设置 - 知识库向量模型」中的 API 地址与密钥: %w", err) + } + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 转换为 float32 + result := make([][]float32, len(response)) + for i, item := range response { + result[i] = make([]float32, len(item)) + for j, v := range item { + result[i][j] = float32(v) + } + } + + return result, nil +} + +// GetDimension 获取向量维度 +func (s *BGEEmbeddingService) GetDimension() int { + return s.dimension +} + +// GetModelName 获取模型名称 +func (s *BGEEmbeddingService) GetModelName() string { + return s.model +} diff --git a/backend/service/embedding/factory.go b/backend/service/embedding/factory.go new file mode 100644 index 0000000..70267eb --- /dev/null +++ b/backend/service/embedding/factory.go @@ -0,0 +1,81 @@ +package embedding + +import ( + "fmt" + "os" +) + +// EmbeddingFactory 嵌入服务工厂 +type EmbeddingFactory struct{} + +// NewEmbeddingFactory 创建嵌入服务工厂 +func NewEmbeddingFactory() *EmbeddingFactory { + return &EmbeddingFactory{} +} + +// getConfigFromEnv 从环境变量读取配置 +func (f *EmbeddingFactory) getConfigFromEnv() (embeddingType, apiURL, apiKey, model string) { + embeddingType = os.Getenv("EMBEDDING_TYPE") + if embeddingType == "" { + embeddingType = "local" // 默认使用本地 BGE + } + + apiURL = os.Getenv("EMBEDDING_API_URL") + apiKey = os.Getenv("EMBEDDING_API_KEY") + model = os.Getenv("EMBEDDING_MODEL") + + // BGE 配置 + if embeddingType == "local" || embeddingType == "bge" { + if bgeURL := os.Getenv("BGE_API_URL"); bgeURL != "" { + apiURL = bgeURL + } + if bgeKey := os.Getenv("BGE_API_KEY"); bgeKey != "" { + apiKey = bgeKey + } + if bgeModel := os.Getenv("BGE_MODEL_NAME"); bgeModel != "" { + model = bgeModel + } + } + + return +} + +// CreateDefaultEmbeddingService 创建默认嵌入服务 +// 优先尝试 BGE 本地服务,如果失败则使用 OpenAI +func (f *EmbeddingFactory) CreateDefaultEmbeddingService() (EmbeddingService, error) { + embeddingType, apiURL, apiKey, model := f.getConfigFromEnv() + + switch embeddingType { + case "openai", "api": + if apiKey == "" { + return nil, fmt.Errorf("EMBEDDING_API_KEY 未设置") + } + return NewOpenAIEmbeddingService(apiURL, apiKey, model), nil + + case "local", "bge": + // 尝试创建 BGE 服务 + return NewBGEEmbeddingService(apiURL, apiKey, model), nil + + default: + return nil, fmt.Errorf("不支持的嵌入服务类型: %s", embeddingType) + } +} + +// CreateEmbeddingService 根据类型创建嵌入服务 +func (f *EmbeddingFactory) CreateEmbeddingService(embeddingType string) (EmbeddingService, error) { + _, apiURL, apiKey, model := f.getConfigFromEnv() + + switch embeddingType { + case "openai", "api": + if apiKey == "" { + return nil, fmt.Errorf("EMBEDDING_API_KEY 未设置") + } + return NewOpenAIEmbeddingService(apiURL, apiKey, model), nil + + case "local", "bge": + return NewBGEEmbeddingService(apiURL, apiKey, model), nil + + default: + return nil, fmt.Errorf("不支持的嵌入服务类型: %s", embeddingType) + } +} diff --git a/backend/service/embedding/interface.go b/backend/service/embedding/interface.go new file mode 100644 index 0000000..e435541 --- /dev/null +++ b/backend/service/embedding/interface.go @@ -0,0 +1,22 @@ +package embedding + +import ( + "context" +) + +// EmbeddingProvider 按需提供嵌入服务(每次从 DB 配置读取,保存即生效) +type EmbeddingProvider interface { + Get(ctx context.Context) (EmbeddingService, error) +} + +// EmbeddingService 嵌入服务接口 +type EmbeddingService interface { + // EmbedText 向量化单个文本 + EmbedText(ctx context.Context, text string) ([]float32, error) + // EmbedTexts 批量向量化文本 + EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) + // GetDimension 获取向量维度 + GetDimension() int + // GetModelName 获取模型名称 + GetModelName() string +} diff --git a/backend/service/embedding/openai.go b/backend/service/embedding/openai.go new file mode 100644 index 0000000..dedb1f4 --- /dev/null +++ b/backend/service/embedding/openai.go @@ -0,0 +1,148 @@ +package embedding + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +// OpenAIEmbeddingService OpenAI 嵌入服务实现 +type OpenAIEmbeddingService struct { + apiURL string + apiKey string + model string + dimension int +} + +// NewOpenAIEmbeddingService 创建 OpenAI 嵌入服务实例 +func NewOpenAIEmbeddingService(apiURL, apiKey, model string) *OpenAIEmbeddingService { + if apiURL == "" { + apiURL = "https://api.openai.com/v1" + } + if model == "" { + model = "text-embedding-3-small" + } + + dimension := 1536 // text-embedding-3-small 的默认维度 + if model == "text-embedding-3-large" { + dimension = 3072 + } + + return &OpenAIEmbeddingService{ + apiURL: apiURL, + apiKey: apiKey, + model: model, + dimension: dimension, + } +} + +// EmbedText 向量化单个文本 +func (s *OpenAIEmbeddingService) EmbedText(ctx context.Context, text string) ([]float32, error) { + vectors, err := s.EmbedTexts(ctx, []string{text}) + if err != nil { + return nil, err + } + if len(vectors) == 0 { + return nil, fmt.Errorf("未返回向量") + } + return vectors[0], nil +} + +// EmbedTexts 批量向量化文本 +func (s *OpenAIEmbeddingService) EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) { + if len(texts) == 0 { + return nil, nil + } + + // 支持填完整路径或仅填 base:若已以 /embeddings 结尾则不再追加,否则追加 /embeddings + url := strings.TrimSuffix(s.apiURL, "/") + if url != "" && !strings.HasSuffix(strings.ToLower(url), "/embeddings") { + url = url + "/embeddings" + } else if url == "" { + url = s.apiURL + "/embeddings" + } + + // 构建请求体 + requestBody := map[string]interface{}{ + "input": texts, + "model": s.model, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + // 创建 HTTP 请求 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + // 发送请求 + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OpenAI API 返回错误状态码 %d: %s", resp.StatusCode, string(body)) + } + + // 解析响应(若返回 HTML 则提示检查 API 地址/密钥) + var response struct { + Data []struct { + Embedding []float64 `json:"embedding"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + if len(body) > 0 && body[0] == '<' { + snippet := string(body) + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + log.Printf("[嵌入] OpenAI 返回了 HTML 而非 JSON,请检查 API 地址与密钥。响应片段: %s", snippet) + return nil, fmt.Errorf("嵌入 API 返回了 HTML 而非 JSON,请检查「设置 - 知识库向量模型」中的 API 地址与密钥: %w", err) + } + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + // 转换为 float32 + result := make([][]float32, len(response.Data)) + for i, item := range response.Data { + result[i] = make([]float32, len(item.Embedding)) + for j, v := range item.Embedding { + result[i][j] = float32(v) + } + } + + return result, nil +} + +// GetDimension 获取向量维度 +func (s *OpenAIEmbeddingService) GetDimension() int { + return s.dimension +} + +// GetModelName 获取模型名称 +func (s *OpenAIEmbeddingService) GetModelName() string { + return s.model +} diff --git a/backend/service/embedding/stub.go b/backend/service/embedding/stub.go new file mode 100644 index 0000000..8d17707 --- /dev/null +++ b/backend/service/embedding/stub.go @@ -0,0 +1,38 @@ +package embedding + +import ( + "context" + "errors" +) + +// ErrEmbeddingNotConfigured 嵌入服务未配置时返回的错误 +var ErrEmbeddingNotConfigured = errors.New("知识库向量模型未配置,请先在「设置 - 知识库向量模型」中配置 API 后再使用") + +// UnconfiguredEmbeddingService 未配置时的占位嵌入服务,实现 EmbeddingService 接口 +// 用于 DB 与 .env 均无有效配置时仍能启动服务;实际调用向量化时会返回 ErrEmbeddingNotConfigured +type UnconfiguredEmbeddingService struct{} + +// NewUnconfiguredEmbeddingService 创建未配置占位嵌入服务 +func NewUnconfiguredEmbeddingService() *UnconfiguredEmbeddingService { + return &UnconfiguredEmbeddingService{} +} + +// EmbedText 向量化单个文本(未配置时返回错误) +func (s *UnconfiguredEmbeddingService) EmbedText(ctx context.Context, text string) ([]float32, error) { + return nil, ErrEmbeddingNotConfigured +} + +// EmbedTexts 批量向量化文本(未配置时返回错误) +func (s *UnconfiguredEmbeddingService) EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) { + return nil, ErrEmbeddingNotConfigured +} + +// GetDimension 返回默认维度,用于创建向量存储(与常见 OpenAI 小模型一致) +func (s *UnconfiguredEmbeddingService) GetDimension() int { + return 1536 +} + +// GetModelName 返回占位名称 +func (s *UnconfiguredEmbeddingService) GetModelName() string { + return "未配置" +} diff --git a/backend/service/embedding_config_service.go b/backend/service/embedding_config_service.go new file mode 100644 index 0000000..46bb0a8 --- /dev/null +++ b/backend/service/embedding_config_service.go @@ -0,0 +1,161 @@ +package service + +import ( + "errors" + "fmt" + "time" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/repository" + "github.com/2930134478/AI-CS/backend/utils" +) + +// EmbeddingConfigService 知识库向量配置服务 +type EmbeddingConfigService struct { + repo *repository.EmbeddingConfigRepository + userRepo *repository.UserRepository +} + +// NewEmbeddingConfigService 创建服务实例 +func NewEmbeddingConfigService(repo *repository.EmbeddingConfigRepository, userRepo *repository.UserRepository) *EmbeddingConfigService { + return &EmbeddingConfigService{repo: repo, userRepo: userRepo} +} + +// GetForAPI 返回给前端的配置(API Key 脱敏,不返回明文) +func (s *EmbeddingConfigService) GetForAPI() (*EmbeddingConfigResult, error) { + c, err := s.repo.Get() + if err != nil { + return nil, err + } + if c == nil { + return &EmbeddingConfigResult{ + EmbeddingType: "openai", + APIURL: "", + APIKeyMasked: "", + Model: "text-embedding-3-small", + CustomerCanUseKB: true, + }, nil + } + masked := "" + if c.APIKey != "" { + masked = "sk-***" + } + return &EmbeddingConfigResult{ + ID: c.ID, + EmbeddingType: c.EmbeddingType, + APIURL: c.APIURL, + APIKeyMasked: masked, + Model: c.Model, + CustomerCanUseKB: c.CustomerCanUseKB, + UpdatedAt: c.UpdatedAt, + }, nil +} + +// GetRaw 供 embedding 工厂使用,返回含解密后 API Key 的配置;若 DB 无有效配置返回 nil, nil +func (s *EmbeddingConfigService) GetRaw() (embeddingType, apiURL, apiKey, model string, err error) { + c, err := s.repo.Get() + if err != nil || c == nil || c.APIKey == "" { + return "", "", "", "", nil + } + decrypted, err := utils.DecryptAPIKey(c.APIKey) + if err != nil { + return "", "", "", "", fmt.Errorf("解密 API Key 失败: %w", err) + } + return c.EmbeddingType, c.APIURL, decrypted, c.Model, nil +} + +// CustomerCanUseKB 是否开放知识库给客服使用 +func (s *EmbeddingConfigService) CustomerCanUseKB() (bool, error) { + c, err := s.repo.Get() + if err != nil { + return false, err + } + if c == nil { + return true, nil // 默认开放 + } + return c.CustomerCanUseKB, nil +} + +// CheckKnowledgeBaseAccess 校验当前用户是否允许使用知识库(创建/上传/导入等) +// 若未开放且用户非 admin 则返回 error +func (s *EmbeddingConfigService) CheckKnowledgeBaseAccess(userID uint) error { + ok, err := s.CustomerCanUseKB() + if err != nil { + return err + } + if ok { + return nil + } + user, err := s.userRepo.GetByID(userID) + if err != nil || user == nil { + return errors.New("用户不存在") + } + if user.Role == "admin" { + return nil + } + return errors.New("当前未开放知识库功能,仅管理员可使用") +} + +// Update 更新配置(仅管理员可调);若传入 api_key 为空则保留原密钥 +func (s *EmbeddingConfigService) Update(userID uint, input UpdateEmbeddingConfigInput) (*EmbeddingConfigResult, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil || user == nil { + return nil, errors.New("用户不存在") + } + if user.Role != "admin" { + return nil, errors.New("仅管理员可修改知识库向量配置") + } + + c, err := s.repo.Get() + if err != nil { + return nil, err + } + if c == nil { + c = &models.EmbeddingConfig{ID: 1} + } + + if input.EmbeddingType != nil { + c.EmbeddingType = *input.EmbeddingType + } + if input.APIURL != nil { + c.APIURL = *input.APIURL + } + if input.APIKey != nil && *input.APIKey != "" { + encrypted, err := utils.EncryptAPIKey(*input.APIKey) + if err != nil { + return nil, fmt.Errorf("加密 API Key 失败: %v", err) + } + c.APIKey = encrypted + } + if input.Model != nil { + c.Model = *input.Model + } + if input.CustomerCanUseKB != nil { + c.CustomerCanUseKB = *input.CustomerCanUseKB + } + + if err := s.repo.Save(c); err != nil { + return nil, err + } + return s.GetForAPI() +} + +// EmbeddingConfigResult 返回给前端的结构(不含明文 API Key) +type EmbeddingConfigResult struct { + ID uint `json:"id"` + EmbeddingType string `json:"embedding_type"` + APIURL string `json:"api_url"` + APIKeyMasked string `json:"api_key_masked"` + Model string `json:"model"` + CustomerCanUseKB bool `json:"customer_can_use_kb"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// UpdateEmbeddingConfigInput 更新入参 +type UpdateEmbeddingConfigInput struct { + EmbeddingType *string `json:"embedding_type"` + APIURL *string `json:"api_url"` + APIKey *string `json:"api_key"` + Model *string `json:"model"` + CustomerCanUseKB *bool `json:"customer_can_use_kb"` +} diff --git a/backend/service/embedding_provider.go b/backend/service/embedding_provider.go new file mode 100644 index 0000000..0d19df2 --- /dev/null +++ b/backend/service/embedding_provider.go @@ -0,0 +1,54 @@ +package service + +import ( + "context" + "log" + + "github.com/2930134478/AI-CS/backend/service/embedding" +) + +// ConfigBackedEmbeddingProvider 基于 DB 配置的嵌入服务提供者,每次 Get 从配置读取,保存即生效 +type ConfigBackedEmbeddingProvider struct { + configService *EmbeddingConfigService + factory *embedding.EmbeddingFactory +} + +// NewConfigBackedEmbeddingProvider 创建基于 DB 配置的嵌入服务提供者 +func NewConfigBackedEmbeddingProvider(configService *EmbeddingConfigService, factory *embedding.EmbeddingFactory) *ConfigBackedEmbeddingProvider { + return &ConfigBackedEmbeddingProvider{ + configService: configService, + factory: factory, + } +} + +// Get 返回当前配置对应的嵌入服务(每次从 DB 读取,无缓存) +func (p *ConfigBackedEmbeddingProvider) Get(ctx context.Context) (embedding.EmbeddingService, error) { + typ, apiURL, apiKey, model, err := p.configService.GetRaw() + if err != nil { + return nil, err + } + if apiKey != "" { + switch typ { + case "openai", "api": + return embedding.NewOpenAIEmbeddingService(apiURL, apiKey, model), nil + case "local", "bge": + return embedding.NewBGEEmbeddingService(apiURL, apiKey, model), nil + default: + svc, createErr := p.factory.CreateEmbeddingService(typ) + if createErr != nil { + log.Printf("⚠️ 从 DB 创建嵌入服务失败 (%s),回退到环境变量: %v", typ, createErr) + return p.fallbackFromEnv() + } + return svc, nil + } + } + return p.fallbackFromEnv() +} + +func (p *ConfigBackedEmbeddingProvider) fallbackFromEnv() (embedding.EmbeddingService, error) { + svc, err := p.factory.CreateDefaultEmbeddingService() + if err != nil { + return embedding.NewUnconfiguredEmbeddingService(), nil + } + return svc, nil +} diff --git a/backend/service/faq_service.go b/backend/service/faq_service.go index 5f9ada9..fe56426 100644 --- a/backend/service/faq_service.go +++ b/backend/service/faq_service.go @@ -1,25 +1,40 @@ package service import ( + "context" "errors" + "log" + "strconv" "strings" "github.com/2930134478/AI-CS/backend/models" "github.com/2930134478/AI-CS/backend/repository" + "github.com/2930134478/AI-CS/backend/service/rag" "gorm.io/gorm" ) // FAQService 负责 FAQ(常见问题)管理领域的业务编排。 type FAQService struct { - faqs *repository.FAQRepository + faqs *repository.FAQRepository + retrievalService *rag.RetrievalService + documentEmbeddingService *rag.DocumentEmbeddingService } // NewFAQService 创建 FAQService 实例。 -func NewFAQService(faqs *repository.FAQRepository) *FAQService { - return &FAQService{faqs: faqs} +func NewFAQService( + faqs *repository.FAQRepository, + retrievalService *rag.RetrievalService, + documentEmbeddingService *rag.DocumentEmbeddingService, +) *FAQService { + return &FAQService{ + faqs: faqs, + retrievalService: retrievalService, + documentEmbeddingService: documentEmbeddingService, + } } // CreateFAQ 创建新的 FAQ 记录。 +// 创建后会自动进行向量化(异步处理) func (s *FAQService) CreateFAQ(input CreateFAQInput) (*FAQSummary, error) { // 验证必填字段 if input.Question == "" { @@ -31,18 +46,61 @@ func (s *FAQService) CreateFAQ(input CreateFAQInput) (*FAQSummary, error) { // 创建 FAQ 记录 faq := &models.FAQ{ - Question: input.Question, - Answer: input.Answer, - Keywords: input.Keywords, + Question: input.Question, + Answer: input.Answer, + Keywords: input.Keywords, + EmbeddingStatus: "pending", // 初始状态为待处理 } if err := s.faqs.Create(faq); err != nil { return nil, err } + // 异步进行向量化(避免阻塞) + go s.embedFAQAsync(context.Background(), faq.ID, faq) + return s.toSummary(faq), nil } +// embedFAQAsync 异步进行 FAQ 向量化 +func (s *FAQService) embedFAQAsync(ctx context.Context, faqID uint, faq *models.FAQ) { + // 更新状态为处理中 + faq.EmbeddingStatus = "processing" + if err := s.faqs.Update(faq); err != nil { + log.Printf("更新 FAQ %d 向量化状态失败: %v", faqID, err) + return + } + + // 构建向量化的内容(使用 Question + Answer) + content := faq.Question + "\n" + faq.Answer + + // 获取知识库 ID(如果为空,使用默认值 0) + kbID := uint(0) + if faq.KnowledgeBaseID != nil { + kbID = *faq.KnowledgeBaseID + } + + // 进行向量化 + err := s.documentEmbeddingService.EmbedDocument(ctx, faqID, kbID, content) + if err != nil { + log.Printf("FAQ %d 向量化失败: %v", faqID, err) + // 更新状态为失败 + faq.EmbeddingStatus = "failed" + if updateErr := s.faqs.Update(faq); updateErr != nil { + log.Printf("更新 FAQ %d 向量化状态失败: %v", faqID, updateErr) + } + return + } + + // 更新状态为已完成,并保存向量 ID + vectorID := strconv.FormatUint(uint64(faqID), 10) + faq.VectorID = &vectorID + faq.EmbeddingStatus = "completed" + if err := s.faqs.Update(faq); err != nil { + log.Printf("更新 FAQ %d 向量化状态失败: %v", faqID, err) + } +} + // GetFAQ 获取 FAQ 详情。 func (s *FAQService) GetFAQ(id uint) (*FAQSummary, error) { faq, err := s.faqs.GetByID(id) @@ -57,13 +115,49 @@ func (s *FAQService) GetFAQ(id uint) (*FAQSummary, error) { } // ListFAQs 获取 FAQ 列表,支持关键词搜索。 -// query 格式:关键词之间用 % 分隔,例如 "openai%api%调用" -// 搜索逻辑:所有关键词都要包含(AND 查询) +// query: 查询字符串 +// 搜索策略:优先使用向量检索,如果失败或查询为空,则使用关键词搜索 func (s *FAQService) ListFAQs(query string) ([]FAQSummary, error) { - // 解析关键词 - keywords := s.parseKeywords(query) + // 如果查询为空,直接返回所有 FAQ(按关键词搜索的空查询) + if query == "" { + faqs, err := s.faqs.List(nil) + if err != nil { + return nil, err + } - // 查询 FAQ 列表 + summaries := make([]FAQSummary, 0, len(faqs)) + for _, faq := range faqs { + summaries = append(summaries, *s.toSummary(&faq)) + } + return summaries, nil + } + + // 优先使用向量检索 + results, err := s.SearchByVector(context.Background(), query, 10, "") + if err == nil && len(results) > 0 { + // 向量检索成功,转换为 FAQSummary + summaries := make([]FAQSummary, 0, len(results)) + for _, result := range results { + // 从结果中提取 FAQ ID + faqID, parseErr := strconv.ParseUint(result.DocumentID, 10, 32) + if parseErr != nil { + continue + } + + // 获取完整的 FAQ 信息 + faq, getErr := s.faqs.GetByID(uint(faqID)) + if getErr != nil { + continue + } + + summaries = append(summaries, *s.toSummary(faq)) + } + return summaries, nil + } + + // 向量检索失败,回退到关键词搜索 + log.Printf("向量检索失败,使用关键词搜索: %v", err) + keywords := s.parseKeywords(query) faqs, err := s.faqs.List(keywords) if err != nil { return nil, err @@ -78,7 +172,27 @@ func (s *FAQService) ListFAQs(query string) ([]FAQSummary, error) { return summaries, nil } +// SearchByVector 使用向量检索搜索 FAQ +// query: 查询文本 +// topK: 返回前 K 个结果 +// knowledgeBaseID: 知识库 ID(可选,为空字符串则不过滤) +func (s *FAQService) SearchByVector(ctx context.Context, query string, topK int, knowledgeBaseID string) ([]rag.SearchResult, error) { + if s.retrievalService == nil { + return nil, errors.New("检索服务未初始化") + } + + var kbID *uint + if knowledgeBaseID != "" { + if id, err := strconv.ParseUint(knowledgeBaseID, 10, 64); err == nil { + u := uint(id) + kbID = &u + } + } + return s.retrievalService.Retrieve(ctx, query, topK, kbID) +} + // UpdateFAQ 更新 FAQ 记录。 +// 如果 Question 或 Answer 发生变化,会同步更新向量 func (s *FAQService) UpdateFAQ(id uint, input UpdateFAQInput) (*FAQSummary, error) { // 获取现有记录 faq, err := s.faqs.GetByID(id) @@ -89,6 +203,9 @@ func (s *FAQService) UpdateFAQ(id uint, input UpdateFAQInput) (*FAQSummary, erro return nil, err } + // 记录是否有内容变化(需要重新向量化) + needReembed := false + // 验证必填字段 if input.Question != nil && *input.Question == "" { return nil, errors.New("问题不能为空") @@ -99,10 +216,16 @@ func (s *FAQService) UpdateFAQ(id uint, input UpdateFAQInput) (*FAQSummary, erro // 更新字段 if input.Question != nil { - faq.Question = *input.Question + if faq.Question != *input.Question { + needReembed = true + faq.Question = *input.Question + } } if input.Answer != nil { - faq.Answer = *input.Answer + if faq.Answer != *input.Answer { + needReembed = true + faq.Answer = *input.Answer + } } if input.Keywords != nil { faq.Keywords = *input.Keywords @@ -113,13 +236,28 @@ func (s *FAQService) UpdateFAQ(id uint, input UpdateFAQInput) (*FAQSummary, erro return nil, err } + // 如果内容发生变化,需要重新向量化 + if needReembed { + // 先删除旧的向量 + if faq.VectorID != nil { + ctx := context.Background() + if deleteErr := s.documentEmbeddingService.DeleteDocumentEmbedding(ctx, id); deleteErr != nil { + log.Printf("删除 FAQ %d 旧向量失败: %v", id, deleteErr) + } + } + + // 异步重新向量化 + go s.embedFAQAsync(context.Background(), id, faq) + } + return s.toSummary(faq), nil } // DeleteFAQ 删除 FAQ 记录。 +// 删除时也会删除对应的向量 func (s *FAQService) DeleteFAQ(id uint) error { // 检查记录是否存在 - _, err := s.faqs.GetByID(id) + faq, err := s.faqs.GetByID(id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("FAQ 不存在") @@ -127,6 +265,15 @@ func (s *FAQService) DeleteFAQ(id uint) error { return err } + // 删除向量(如果存在) + if faq.VectorID != nil { + ctx := context.Background() + if deleteErr := s.documentEmbeddingService.DeleteDocumentEmbedding(ctx, id); deleteErr != nil { + log.Printf("删除 FAQ %d 向量失败: %v", id, deleteErr) + // 不中断删除流程,记录日志即可 + } + } + // 删除记录 return s.faqs.Delete(id) } @@ -165,4 +312,3 @@ func (s *FAQService) toSummary(faq *models.FAQ) *FAQSummary { UpdatedAt: faq.UpdatedAt, } } - diff --git a/backend/service/import/markdown_parser.go b/backend/service/import/markdown_parser.go new file mode 100644 index 0000000..21c08a5 --- /dev/null +++ b/backend/service/import/markdown_parser.go @@ -0,0 +1,55 @@ +package import_service + +import ( + "os" + "strings" +) + +// MarkdownParser Markdown 解析器 +type MarkdownParser struct{} + +// NewMarkdownParser 创建 Markdown 解析器 +func NewMarkdownParser() *MarkdownParser { + return &MarkdownParser{} +} + +// Supports 检查是否支持该文件 +func (p *MarkdownParser) Supports(filePath string) bool { + return strings.HasSuffix(strings.ToLower(filePath), ".md") || + strings.HasSuffix(strings.ToLower(filePath), ".markdown") +} + +// Parse 解析 Markdown 文件 +func (p *MarkdownParser) Parse(filePath string) (*ParsedDocument, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + // 提取标题(第一行作为标题) + lines := strings.Split(string(content), "\n") + title := "" + body := string(content) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + // 移除 Markdown 标题标记 + title = strings.TrimPrefix(line, "#") + title = strings.TrimPrefix(title, "##") + title = strings.TrimPrefix(title, "###") + title = strings.TrimSpace(title) + break + } + } + + if title == "" { + title = filePath + } + + return &ParsedDocument{ + Title: title, + Content: body, + Metadata: make(map[string]interface{}), + }, nil +} diff --git a/backend/service/import/parser.go b/backend/service/import/parser.go new file mode 100644 index 0000000..0a034c0 --- /dev/null +++ b/backend/service/import/parser.go @@ -0,0 +1,14 @@ +package import_service + +// ParsedDocument 解析后的文档 +type ParsedDocument struct { + Title string + Content string + Metadata map[string]interface{} +} + +// DocumentParser 文档解析器接口 +type DocumentParser interface { + Parse(filePath string) (*ParsedDocument, error) + Supports(filePath string) bool +} diff --git a/backend/service/import/pdf_parser.go b/backend/service/import/pdf_parser.go new file mode 100644 index 0000000..8bec58e --- /dev/null +++ b/backend/service/import/pdf_parser.go @@ -0,0 +1,26 @@ +package import_service + +import ( + "errors" + "strings" +) + +// PDFParser PDF 解析器 +type PDFParser struct{} + +// NewPDFParser 创建 PDF 解析器 +func NewPDFParser() *PDFParser { + return &PDFParser{} +} + +// Supports 检查是否支持该文件 +func (p *PDFParser) Supports(filePath string) bool { + return strings.HasSuffix(strings.ToLower(filePath), ".pdf") +} + +// Parse 解析 PDF 文件 +// TODO: 需要集成专业库(如 pdfcpu/pdfcpu 或 gen2brain/go-fitz) +func (p *PDFParser) Parse(filePath string) (*ParsedDocument, error) { + // TODO: 实现 PDF 解析逻辑 + return nil, errors.New("PDF 解析功能待实现,需要集成专业库") +} diff --git a/backend/service/import/url_parser.go b/backend/service/import/url_parser.go new file mode 100644 index 0000000..24c9072 --- /dev/null +++ b/backend/service/import/url_parser.go @@ -0,0 +1,92 @@ +package import_service + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" +) + +// URLParser URL 解析器 +type URLParser struct { + client *http.Client +} + +// NewURLParser 创建 URL 解析器 +func NewURLParser() *URLParser { + return &URLParser{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Supports 检查是否支持该 URL +func (p *URLParser) Supports(url string) bool { + return strings.HasPrefix(strings.ToLower(url), "http://") || + strings.HasPrefix(strings.ToLower(url), "https://") +} + +// Parse 解析 URL +func (p *URLParser) Parse(url string) (*ParsedDocument, error) { + // 下载网页 + resp, err := p.client.Get(url) + if err != nil { + return nil, fmt.Errorf("下载网页失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载网页失败: HTTP %d", resp.StatusCode) + } + + // 解析 HTML + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("解析 HTML 失败: %w", err) + } + + // 提取标题 + title := doc.Find("title").First().Text() + if title == "" { + title = doc.Find("h1").First().Text() + } + if title == "" { + title = url + } + + // 提取正文内容 + var content strings.Builder + doc.Find("body").Each(func(i int, s *goquery.Selection) { + // 移除脚本和样式 + s.Find("script, style").Remove() + // 提取文本 + text := s.Text() + content.WriteString(text) + content.WriteString("\n") + }) + + body := content.String() + if body == "" { + // 如果 body 为空,尝试重新下载 + if body == "" { + resp2, err := p.client.Get(url) + if err == nil { + defer resp2.Body.Close() + bodyBytes, _ := io.ReadAll(resp2.Body) + body = string(bodyBytes) + } + } + } + + return &ParsedDocument{ + Title: strings.TrimSpace(title), + Content: strings.TrimSpace(body), + Metadata: map[string]interface{}{ + "url": url, + }, + }, nil +} diff --git a/backend/service/import/word_parser.go b/backend/service/import/word_parser.go new file mode 100644 index 0000000..de1480f --- /dev/null +++ b/backend/service/import/word_parser.go @@ -0,0 +1,27 @@ +package import_service + +import ( + "errors" + "strings" +) + +// WordParser Word 解析器 +type WordParser struct{} + +// NewWordParser 创建 Word 解析器 +func NewWordParser() *WordParser { + return &WordParser{} +} + +// Supports 检查是否支持该文件 +func (p *WordParser) Supports(filePath string) bool { + return strings.HasSuffix(strings.ToLower(filePath), ".docx") || + strings.HasSuffix(strings.ToLower(filePath), ".doc") +} + +// Parse 解析 Word 文件 +// TODO: 需要集成专业库(如 unidoc/unioffice 或 lukasjarosch/go-docx) +func (p *WordParser) Parse(filePath string) (*ParsedDocument, error) { + // TODO: 实现 Word 解析逻辑 + return nil, errors.New("Word 解析功能待实现,需要集成专业库") +} diff --git a/backend/service/import_service.go b/backend/service/import_service.go new file mode 100644 index 0000000..d788854 --- /dev/null +++ b/backend/service/import_service.go @@ -0,0 +1,141 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "path/filepath" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/repository" + import_service "github.com/2930134478/AI-CS/backend/service/import" +) + +// ImportService 导入服务 +type ImportService struct { + docRepo *repository.DocumentRepository + kbRepo *repository.KnowledgeBaseRepository + documentService *DocumentService + documentEmbeddingService interface{} // 使用 interface{} 避免循环依赖 + parsers []import_service.DocumentParser +} + +// NewImportService 创建导入服务实例 +func NewImportService( + docRepo *repository.DocumentRepository, + kbRepo *repository.KnowledgeBaseRepository, + documentService *DocumentService, + documentEmbeddingService interface{}, +) *ImportService { + // 初始化解析器 + parsers := []import_service.DocumentParser{ + import_service.NewMarkdownParser(), + import_service.NewPDFParser(), + import_service.NewWordParser(), + } + + return &ImportService{ + docRepo: docRepo, + kbRepo: kbRepo, + documentService: documentService, + documentEmbeddingService: documentEmbeddingService, + parsers: parsers, + } +} + +// ImportFiles 导入文件 +func (s *ImportService) ImportFiles(ctx context.Context, knowledgeBaseID uint, filePaths []string) (*ImportResult, error) { + // 验证知识库是否存在 + _, err := s.kbRepo.GetByID(knowledgeBaseID) + if err != nil { + return nil, errors.New("知识库不存在") + } + + result := &ImportResult{ + SuccessCount: 0, + FailedCount: 0, + FailedFiles: []string{}, + Errors: []string{}, + } + + // 解析文件 + documents := make([]*models.Document, 0) + for _, filePath := range filePaths { + // 查找合适的解析器 + var parser import_service.DocumentParser + for _, p := range s.parsers { + if p.Supports(filePath) { + parser = p + break + } + } + + if parser == nil { + result.FailedCount++ + result.FailedFiles = append(result.FailedFiles, filepath.Base(filePath)) + result.Errors = append(result.Errors, fmt.Sprintf("文件 %s: 不支持的文件格式", filepath.Base(filePath))) + continue + } + + // 解析文件 + parsed, err := parser.Parse(filePath) + if err != nil { + result.FailedCount++ + result.FailedFiles = append(result.FailedFiles, filepath.Base(filePath)) + result.Errors = append(result.Errors, fmt.Sprintf("文件 %s: %v", filepath.Base(filePath), err)) + continue + } + + // 创建文档 + doc := &models.Document{ + KnowledgeBaseID: knowledgeBaseID, + Title: parsed.Title, + Content: parsed.Content, + Type: "document", + Status: "draft", + EmbeddingStatus: "pending", + } + + documents = append(documents, doc) + } + + // 批量创建文档 + docIDs := make([]uint, 0, len(documents)) + for _, doc := range documents { + if err := s.docRepo.Create(doc); err != nil { + result.FailedCount++ + result.FailedFiles = append(result.FailedFiles, doc.Title) + result.Errors = append(result.Errors, fmt.Sprintf("文档 %s: 创建失败: %v", doc.Title, err)) + continue + } + docIDs = append(docIDs, doc.ID) + result.SuccessCount++ + } + + // 批量向量化(异步) + if len(docIDs) > 0 { + ids := make([]uint, len(docIDs)) + copy(ids, docIDs) + go func(ids []uint) { + log.Printf("[导入] 文件导入已创建 %d 条文档,开始批量向量化", len(ids)) + _, err := s.BatchEmbedDocuments(context.Background(), ids) + if err != nil { + log.Printf("[导入] 批量向量化失败: %v", err) + return + } + log.Printf("[导入] 批量向量化完成,%d 条文档已写入向量库", len(ids)) + }(ids) + } + + return result, nil +} + +// ImportResult 导入结果 +type ImportResult struct { + SuccessCount int `json:"success_count"` + FailedCount int `json:"failed_count"` + FailedFiles []string `json:"failed_files"` + Errors []string `json:"errors"` + Message string `json:"message"` +} diff --git a/backend/service/import_service_batch.go b/backend/service/import_service_batch.go new file mode 100644 index 0000000..2f27bd6 --- /dev/null +++ b/backend/service/import_service_batch.go @@ -0,0 +1,124 @@ +package service + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/service/rag" +) + +// BatchEmbeddingResult 批量向量化结果 +type BatchEmbeddingResult struct { + FailedDocs []uint `json:"failed_docs"` + Errors []string `json:"errors"` +} + +// BatchEmbedDocuments 批量向量化文档 +// 用于优化导入性能,将多个文档一次性向量化 +func (s *ImportService) BatchEmbedDocuments(ctx context.Context, docIDs []uint) (*BatchEmbeddingResult, error) { + if len(docIDs) == 0 { + return &BatchEmbeddingResult{}, nil + } + log.Printf("[导入] 批量向量化开始 doc_ids=%v", docIDs) + + result := &BatchEmbeddingResult{ + FailedDocs: []uint{}, + Errors: []string{}, + } + + // 获取文档 + docs, err := s.docRepo.GetByIDs(docIDs) + if err != nil { + return result, fmt.Errorf("获取文档失败: %w", err) + } + + // 准备向量化数据 + documentIDs := make([]uint, 0, len(docs)) + knowledgeBaseIDs := make([]uint, 0, len(docs)) + contents := make([]string, 0, len(docs)) + docMap := make(map[uint]*models.Document) + + for _, doc := range docs { + if doc.EmbeddingStatus == "completed" { + continue // 跳过已向量化的文档 + } + + // 更新状态为处理中 + docCopy := doc + docCopy.EmbeddingStatus = "processing" + if err := s.docRepo.Update(&docCopy); err != nil { + log.Printf("更新文档 %d 状态失败: %v", doc.ID, err) + } + + documentIDs = append(documentIDs, doc.ID) + knowledgeBaseIDs = append(knowledgeBaseIDs, doc.KnowledgeBaseID) + contents = append(contents, doc.Content) + docMap[doc.ID] = &docCopy + } + + if len(documentIDs) == 0 { + return result, nil + } + + // 批量向量化 + // 使用独立的 context,避免 HTTP 请求超时导致向量化失败 + // 向量化可能需要较长时间(特别是 Milvus LoadCollection 操作) + embedCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + err = s.batchEmbedDocumentsInternal(embedCtx, documentIDs, knowledgeBaseIDs, contents, docMap, result) + if err != nil { + log.Printf("[导入] 批量向量化失败: %v", err) + return result, err + } + log.Printf("[导入] 批量向量化成功 %d 条文档", len(documentIDs)) + return result, err +} + +// batchEmbedDocumentsInternal 内部批量向量化实现 +func (s *ImportService) batchEmbedDocumentsInternal( + ctx context.Context, + documentIDs []uint, + knowledgeBaseIDs []uint, + contents []string, + docMap map[uint]*models.Document, + result *BatchEmbeddingResult, +) error { + // 获取 documentEmbeddingService(通过类型断言) + embeddingService, ok := s.documentEmbeddingService.(*rag.DocumentEmbeddingService) + if !ok { + return fmt.Errorf("documentEmbeddingService 类型错误") + } + + // 批量向量化 + err := embeddingService.EmbedDocuments(ctx, documentIDs, knowledgeBaseIDs, contents) + if err != nil { + // 批量失败,标记所有文档为失败 + for _, docID := range documentIDs { + if doc, ok := docMap[docID]; ok { + doc.EmbeddingStatus = "failed" + s.docRepo.Update(doc) + result.FailedDocs = append(result.FailedDocs, docID) + result.Errors = append(result.Errors, fmt.Sprintf("文档 %d: %v", docID, err)) + } + } + return fmt.Errorf("批量向量化失败: %w", err) + } + + // 更新所有文档状态为已完成 + for _, docID := range documentIDs { + if doc, ok := docMap[docID]; ok { + doc.EmbeddingStatus = "completed" + if err := s.docRepo.Update(doc); err != nil { + log.Printf("更新文档 %d 状态失败: %v", docID, err) + result.FailedDocs = append(result.FailedDocs, docID) + result.Errors = append(result.Errors, fmt.Sprintf("文档 %d: 更新状态失败: %v", docID, err)) + } + } + } + + return nil +} diff --git a/backend/service/import_service_url.go b/backend/service/import_service_url.go new file mode 100644 index 0000000..dccc99f --- /dev/null +++ b/backend/service/import_service_url.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + + "github.com/2930134478/AI-CS/backend/models" + import_service "github.com/2930134478/AI-CS/backend/service/import" +) + +// ImportFromUrls 从 URL 导入文档 +func (s *ImportService) ImportFromUrls(ctx context.Context, knowledgeBaseID uint, urls []string) (*ImportResult, error) { + log.Printf("[导入] URL 导入开始 knowledge_base_id=%d urls=%d", knowledgeBaseID, len(urls)) + // 验证知识库是否存在 + _, err := s.kbRepo.GetByID(knowledgeBaseID) + if err != nil { + return nil, errors.New("知识库不存在") + } + + result := &ImportResult{ + SuccessCount: 0, + FailedCount: 0, + FailedFiles: []string{}, + Errors: []string{}, + } + + // 创建 URL 解析器 + urlParser := import_service.NewURLParser() + + // 解析 URL + documents := make([]*models.Document, 0) + for _, url := range urls { + if !urlParser.Supports(url) { + result.FailedCount++ + result.FailedFiles = append(result.FailedFiles, url) + result.Errors = append(result.Errors, fmt.Sprintf("%s: 无效的 URL", url)) + continue + } + + // 解析 URL + parsed, err := urlParser.Parse(url) + if err != nil { + result.FailedCount++ + result.FailedFiles = append(result.FailedFiles, url) + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", url, err)) + continue + } + + // 创建文档 + doc := &models.Document{ + KnowledgeBaseID: knowledgeBaseID, + Title: parsed.Title, + Content: parsed.Content, + Type: "url", + Status: "draft", + EmbeddingStatus: "pending", + } + + documents = append(documents, doc) + } + + // 批量创建文档 + docIDs := make([]uint, 0, len(documents)) + for _, doc := range documents { + if err := s.docRepo.Create(doc); err != nil { + result.FailedCount++ + result.FailedFiles = append(result.FailedFiles, doc.Title) + result.Errors = append(result.Errors, fmt.Sprintf("文档 %s: 创建失败: %v", doc.Title, err)) + continue + } + docIDs = append(docIDs, doc.ID) + result.SuccessCount++ + } + + // 批量向量化(异步) + if len(docIDs) > 0 { + ids := make([]uint, len(docIDs)) + copy(ids, docIDs) + go func(ids []uint) { + log.Printf("[导入] URL 导入已创建 %d 条文档,开始批量向量化", len(ids)) + _, err := s.BatchEmbedDocuments(context.Background(), ids) + if err != nil { + log.Printf("[导入] 批量向量化失败: %v", err) + return + } + log.Printf("[导入] 批量向量化完成,%d 条文档已写入向量库", len(ids)) + }(ids) + } + + return result, nil +} diff --git a/backend/service/knowledge_base_service.go b/backend/service/knowledge_base_service.go new file mode 100644 index 0000000..5ceb18f --- /dev/null +++ b/backend/service/knowledge_base_service.go @@ -0,0 +1,130 @@ +package service + +import ( + "errors" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/repository" +) + +// KnowledgeBaseService 知识库管理服务 +type KnowledgeBaseService struct { + kbRepo *repository.KnowledgeBaseRepository + docRepo *repository.DocumentRepository +} + +// NewKnowledgeBaseService 创建知识库服务实例 +func NewKnowledgeBaseService(kbRepo *repository.KnowledgeBaseRepository, docRepo *repository.DocumentRepository) *KnowledgeBaseService { + return &KnowledgeBaseService{ + kbRepo: kbRepo, + docRepo: docRepo, + } +} + +// CreateKnowledgeBase 创建知识库 +func (s *KnowledgeBaseService) CreateKnowledgeBase(input CreateKnowledgeBaseInput) (*KnowledgeBaseSummary, error) { + if input.Name == "" { + return nil, errors.New("知识库名称不能为空") + } + + kb := &models.KnowledgeBase{ + Name: input.Name, + Description: input.Description, + DocumentCount: 0, + } + + if err := s.kbRepo.Create(kb); err != nil { + return nil, err + } + + return s.toSummary(kb), nil +} + +// GetKnowledgeBase 获取知识库详情 +func (s *KnowledgeBaseService) GetKnowledgeBase(id uint) (*KnowledgeBaseSummary, error) { + kb, err := s.kbRepo.GetByID(id) + if err != nil { + return nil, err + } + + // 更新文档数量 + count, err := s.docRepo.CountByKnowledgeBaseID(id) + if err == nil { + kb.DocumentCount = int(count) + s.kbRepo.UpdateDocumentCount(id, int(count)) + } + + return s.toSummary(kb), nil +} + +// ListKnowledgeBases 获取知识库列表 +func (s *KnowledgeBaseService) ListKnowledgeBases() ([]KnowledgeBaseSummary, error) { + kbs, err := s.kbRepo.List() + if err != nil { + return nil, err + } + + // 更新每个知识库的文档数量 + summaries := make([]KnowledgeBaseSummary, len(kbs)) + for i, kb := range kbs { + count, _ := s.docRepo.CountByKnowledgeBaseID(kb.ID) + kb.DocumentCount = int(count) + summaries[i] = *s.toSummary(&kb) + } + + return summaries, nil +} + +// UpdateKnowledgeBase 更新知识库 +func (s *KnowledgeBaseService) UpdateKnowledgeBase(id uint, input UpdateKnowledgeBaseInput) (*KnowledgeBaseSummary, error) { + kb, err := s.kbRepo.GetByID(id) + if err != nil { + return nil, err + } + + if input.Name != nil { + if *input.Name == "" { + return nil, errors.New("知识库名称不能为空") + } + kb.Name = *input.Name + } + if input.Description != nil { + kb.Description = *input.Description + } + if input.RAGEnabled != nil { + kb.RAGEnabled = *input.RAGEnabled + } + + if err := s.kbRepo.Update(kb); err != nil { + return nil, err + } + + return s.toSummary(kb), nil +} + +// DeleteKnowledgeBase 删除知识库 +func (s *KnowledgeBaseService) DeleteKnowledgeBase(id uint) error { + // 检查是否有文档 + count, err := s.docRepo.CountByKnowledgeBaseID(id) + if err != nil { + return err + } + if count > 0 { + return errors.New("知识库包含文档,无法删除") + } + + return s.kbRepo.Delete(id) +} + +// toSummary 转换为摘要 +func (s *KnowledgeBaseService) toSummary(kb *models.KnowledgeBase) *KnowledgeBaseSummary { + return &KnowledgeBaseSummary{ + ID: kb.ID, + Name: kb.Name, + Description: kb.Description, + DocumentCount: int64(kb.DocumentCount), + RAGEnabled: kb.RAGEnabled, + CreatedAt: kb.CreatedAt, + UpdatedAt: kb.UpdatedAt, + } +} diff --git a/backend/service/message_service.go b/backend/service/message_service.go index b8a4c6c..d8fb809 100644 --- a/backend/service/message_service.go +++ b/backend/service/message_service.go @@ -100,15 +100,21 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID) } - // 3. 如果是 AI 客服模式,且是访客发送的消息,自动调用 AI 生成回复 - if conv.ChatMode == "ai" && !input.SenderIsAgent && s.aiService != nil { - // 异步调用 AI 生成回复(避免阻塞) + // 3. 触发 AI 回复的两种情况: + // a) 访客对话 + AI 模式 + 访客发送的消息 + // b) 内部对话(知识库测试)+ 客服发送的消息 + needAIReply := s.aiService != nil && ( + (conv.ChatMode == "ai" && !input.SenderIsAgent) || + (conv.ConversationType == "internal" && input.SenderIsAgent)) + if needAIReply { go func() { - // 获取对话的 AgentID(用于查找 AI 配置) - // 如果 AgentID 为 0,使用默认管理员 ID(1) + // 用于查找 AI 配置的用户 ID:访客对话用 AgentID,内部对话用发送者(客服)ID userID := conv.AgentID if userID == 0 { - userID = 1 // 默认使用管理员 ID + userID = 1 + } + if conv.ConversationType == "internal" && input.SenderID > 0 { + userID = input.SenderID } aiResponse, err := s.aiService.GenerateAIResponse(message.ConversationID, input.Content, userID) diff --git a/backend/service/rag/cache.go b/backend/service/rag/cache.go new file mode 100644 index 0000000..da614fc --- /dev/null +++ b/backend/service/rag/cache.go @@ -0,0 +1,81 @@ +package rag + +import ( + "fmt" + "sync" + "time" +) + +// Cache 检索结果缓存 +type Cache struct { + mu sync.RWMutex + data map[string]*cacheEntry + ttl time.Duration +} + +type cacheEntry struct { + results []SearchResult + expiresAt time.Time +} + +// NewCache 创建缓存实例 +func NewCache() *Cache { + return &Cache{ + data: make(map[string]*cacheEntry), + ttl: 0, // 默认不缓存 + } +} + +// SetTTL 设置缓存过期时间 +func (c *Cache) SetTTL(ttl int) { + c.ttl = time.Duration(ttl) * time.Second +} + +// Get 获取缓存结果 +func (c *Cache) Get(query string, topK int, knowledgeBaseID *uint) ([]SearchResult, bool) { + if c.ttl == 0 { + return nil, false + } + + c.mu.RLock() + defer c.mu.RUnlock() + + key := c.buildKey(query, topK, knowledgeBaseID) + entry, ok := c.data[key] + if !ok { + return nil, false + } + + // 检查是否过期 + if time.Now().After(entry.expiresAt) { + delete(c.data, key) + return nil, false + } + + return entry.results, true +} + +// Set 设置缓存结果 +func (c *Cache) Set(query string, topK int, knowledgeBaseID *uint, results []SearchResult) { + if c.ttl == 0 { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + key := c.buildKey(query, topK, knowledgeBaseID) + c.data[key] = &cacheEntry{ + results: results, + expiresAt: time.Now().Add(c.ttl), + } +} + +// buildKey 构建缓存键 +func (c *Cache) buildKey(query string, topK int, knowledgeBaseID *uint) string { + key := fmt.Sprintf("%s|%d", query, topK) + if knowledgeBaseID != nil { + key += fmt.Sprintf("|%d", *knowledgeBaseID) + } + return key +} diff --git a/backend/service/rag/embedding.go b/backend/service/rag/embedding.go new file mode 100644 index 0000000..d86e806 --- /dev/null +++ b/backend/service/rag/embedding.go @@ -0,0 +1,98 @@ +package rag + +import ( + "context" + "fmt" + + "github.com/2930134478/AI-CS/backend/service/embedding" +) + +// DocumentEmbeddingService 文档向量化服务 +type DocumentEmbeddingService struct { + vectorStoreService *VectorStoreService + embeddingProvider embedding.EmbeddingProvider +} + +// NewDocumentEmbeddingService 创建文档向量化服务实例(使用 provider 实现配置保存即生效) +func NewDocumentEmbeddingService(vectorStoreService *VectorStoreService, embeddingProvider embedding.EmbeddingProvider) *DocumentEmbeddingService { + return &DocumentEmbeddingService{ + vectorStoreService: vectorStoreService, + embeddingProvider: embeddingProvider, + } +} + +// EmbedDocument 向量化单个文档并存储 +func (s *DocumentEmbeddingService) EmbedDocument(ctx context.Context, documentID uint, knowledgeBaseID uint, content string) error { + svc, err := s.embeddingProvider.Get(ctx) + if err != nil { + return fmt.Errorf("获取嵌入服务失败: %w", err) + } + // 向量化 + vectors, err := svc.EmbedTexts(ctx, []string{content}) + if err != nil { + return fmt.Errorf("文档向量化失败: %w", err) + } + if len(vectors) == 0 { + return fmt.Errorf("未返回向量") + } + + // 存储向量 + docIDStr := ConvertDocumentID(documentID) + kbIDStr := ConvertKnowledgeBaseID(knowledgeBaseID) + if err := s.vectorStoreService.UpsertVector(ctx, docIDStr, kbIDStr, content, vectors[0]); err != nil { + return fmt.Errorf("存储向量失败: %w", err) + } + + return nil +} + +// EmbedDocuments 批量向量化文档并存储 +func (s *DocumentEmbeddingService) EmbedDocuments(ctx context.Context, documentIDs []uint, knowledgeBaseIDs []uint, contents []string) error { + if len(documentIDs) != len(knowledgeBaseIDs) || len(documentIDs) != len(contents) { + return fmt.Errorf("参数长度不匹配") + } + svc, err := s.embeddingProvider.Get(ctx) + if err != nil { + return fmt.Errorf("获取嵌入服务失败: %w", err) + } + // 批量向量化 + vectors, err := svc.EmbedTexts(ctx, contents) + if err != nil { + return fmt.Errorf("批量向量化失败: %w", err) + } + if len(vectors) != len(contents) { + return fmt.Errorf("向量数量不匹配") + } + + // 转换 ID + docIDStrs := make([]string, len(documentIDs)) + kbIDStrs := make([]string, len(knowledgeBaseIDs)) + for i, id := range documentIDs { + docIDStrs[i] = ConvertDocumentID(id) + } + for i, id := range knowledgeBaseIDs { + kbIDStrs[i] = ConvertKnowledgeBaseID(id) + } + + // 批量存储向量 + if err := s.vectorStoreService.UpsertVectors(ctx, docIDStrs, kbIDStrs, contents, vectors); err != nil { + return fmt.Errorf("批量存储向量失败: %w", err) + } + + return nil +} + +// DeleteDocumentEmbedding 删除文档的向量 +func (s *DocumentEmbeddingService) DeleteDocumentEmbedding(ctx context.Context, documentID uint) error { + docIDStr := ConvertDocumentID(documentID) + return s.vectorStoreService.DeleteVector(ctx, docIDStr) +} + +// DeleteDocumentEmbeddings 批量删除文档的向量 +func (s *DocumentEmbeddingService) DeleteDocumentEmbeddings(ctx context.Context, documentIDs []uint) error { + docIDStrs := make([]string, len(documentIDs)) + for i, id := range documentIDs { + docIDStrs[i] = ConvertDocumentID(id) + } + return s.vectorStoreService.DeleteVectors(ctx, docIDStrs) +} diff --git a/backend/service/rag/health.go b/backend/service/rag/health.go new file mode 100644 index 0000000..1afc2eb --- /dev/null +++ b/backend/service/rag/health.go @@ -0,0 +1,47 @@ +package rag + +import ( + "context" + + "github.com/2930134478/AI-CS/backend/service/embedding" +) + +// HealthChecker 健康检查器 +type HealthChecker struct { + embeddingProvider embedding.EmbeddingProvider + vectorStoreService *VectorStoreService +} + +// NewHealthChecker 创建健康检查器实例(使用 provider 实现配置保存即生效) +func NewHealthChecker(embeddingProvider embedding.EmbeddingProvider, vectorStoreService *VectorStoreService) *HealthChecker { + return &HealthChecker{ + embeddingProvider: embeddingProvider, + vectorStoreService: vectorStoreService, + } +} + +// Check 执行健康检查 +func (h *HealthChecker) Check(ctx context.Context) error { + svc, err := h.embeddingProvider.Get(ctx) + if err != nil { + return err + } + // 检查嵌入服务(简单测试) + testText := "health check" + _, err = svc.EmbedText(ctx, testText) + if err != nil { + return err + } + + // 检查向量存储服务(简单搜索测试) + testVector := make([]float32, svc.GetDimension()) + for i := range testVector { + testVector[i] = 0.1 + } + _, err = h.vectorStoreService.SearchVectors(ctx, testVector, 1, nil) + if err != nil { + return err + } + + return nil +} diff --git a/backend/service/rag/metrics.go b/backend/service/rag/metrics.go new file mode 100644 index 0000000..619a729 --- /dev/null +++ b/backend/service/rag/metrics.go @@ -0,0 +1,109 @@ +package rag + +import ( + "sync" + "time" +) + +// Metrics 性能指标 +type Metrics struct { + mu sync.RWMutex + + // 检索指标 + TotalQueries int64 + SuccessfulQueries int64 + FailedQueries int64 + + // 缓存指标 + CacheHits int64 + CacheMisses int64 + + // 延迟指标 + TotalLatency time.Duration + MinLatency time.Duration + MaxLatency time.Duration +} + +// NewMetrics 创建性能指标实例 +func NewMetrics() *Metrics { + return &Metrics{ + MinLatency: time.Hour, // 初始值设为很大 + } +} + +// RecordQuery 记录查询 +func (m *Metrics) RecordQuery(success bool, latency time.Duration, cacheHit bool) { + m.mu.Lock() + defer m.mu.Unlock() + + m.TotalQueries++ + if success { + m.SuccessfulQueries++ + } else { + m.FailedQueries++ + } + + if cacheHit { + m.CacheHits++ + } else { + m.CacheMisses++ + } + + m.TotalLatency += latency + if latency < m.MinLatency { + m.MinLatency = latency + } + if latency > m.MaxLatency { + m.MaxLatency = latency + } +} + +// GetStats 获取统计信息 +func (m *Metrics) GetStats() map[string]interface{} { + m.mu.RLock() + defer m.mu.RUnlock() + + avgLatency := time.Duration(0) + if m.TotalQueries > 0 { + avgLatency = m.TotalLatency / time.Duration(m.TotalQueries) + } + + successRate := float64(0) + if m.TotalQueries > 0 { + successRate = float64(m.SuccessfulQueries) / float64(m.TotalQueries) * 100 + } + + cacheHitRate := float64(0) + totalCacheRequests := m.CacheHits + m.CacheMisses + if totalCacheRequests > 0 { + cacheHitRate = float64(m.CacheHits) / float64(totalCacheRequests) * 100 + } + + return map[string]interface{}{ + "total_queries": m.TotalQueries, + "successful_queries": m.SuccessfulQueries, + "failed_queries": m.FailedQueries, + "success_rate": successRate, + "cache_hits": m.CacheHits, + "cache_misses": m.CacheMisses, + "cache_hit_rate": cacheHitRate, + "average_latency_ms": avgLatency.Milliseconds(), + "min_latency_ms": m.MinLatency.Milliseconds(), + "max_latency_ms": m.MaxLatency.Milliseconds(), + } +} + +// Reset 重置指标 +func (m *Metrics) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + + m.TotalQueries = 0 + m.SuccessfulQueries = 0 + m.FailedQueries = 0 + m.CacheHits = 0 + m.CacheMisses = 0 + m.TotalLatency = 0 + m.MinLatency = time.Hour + m.MaxLatency = 0 +} diff --git a/backend/service/rag/reranker.go b/backend/service/rag/reranker.go new file mode 100644 index 0000000..0b8dcf0 --- /dev/null +++ b/backend/service/rag/reranker.go @@ -0,0 +1,29 @@ +package rag + +import ( + "context" +) + +// Reranker 重排序器接口 +type Reranker interface { + Rerank(ctx context.Context, query string, results []SearchResult) ([]SearchResult, error) +} + +// SimpleReranker 简单重排序器(按分数排序) +type SimpleReranker struct{} + +// NewSimpleReranker 创建简单重排序器 +func NewSimpleReranker() *SimpleReranker { + return &SimpleReranker{} +} + +// Rerank 重排序结果(当前实现仅按分数排序,预留扩展接口) +func (r *SimpleReranker) Rerank(ctx context.Context, query string, results []SearchResult) ([]SearchResult, error) { + // 当前实现:按分数降序排序(分数越高越好) + // 注意:Milvus 使用 IP 或 L2 距离,分数越小表示相似度越高 + // 这里假设已经转换为相似度分数(越大越好) + // 实际使用时,可能需要根据 metric type 调整排序逻辑 + + // 简单实现:保持原有顺序(Milvus 已经按相似度排序) + return results, nil +} diff --git a/backend/service/rag/retrieval.go b/backend/service/rag/retrieval.go new file mode 100644 index 0000000..f6b0a0f --- /dev/null +++ b/backend/service/rag/retrieval.go @@ -0,0 +1,208 @@ +package rag + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/2930134478/AI-CS/backend/repository" + "github.com/2930134478/AI-CS/backend/service/embedding" +) + +// RetrievalService RAG 检索服务 +type RetrievalService struct { + vectorStoreService *VectorStoreService + embeddingProvider embedding.EmbeddingProvider + docRepo *repository.DocumentRepository // 按发布状态过滤 + kbRepo *repository.KnowledgeBaseRepository // 按知识库「参与 RAG」过滤 + cache *Cache + reranker *SimpleReranker + metrics *Metrics +} + +// NewRetrievalService 创建 RAG 检索服务实例(仅已发布文档且所属知识库已开启 RAG 的参与检索) +func NewRetrievalService(vectorStoreService *VectorStoreService, embeddingProvider embedding.EmbeddingProvider, docRepo *repository.DocumentRepository, kbRepo *repository.KnowledgeBaseRepository) *RetrievalService { + return &RetrievalService{ + vectorStoreService: vectorStoreService, + embeddingProvider: embeddingProvider, + docRepo: docRepo, + kbRepo: kbRepo, + cache: NewCache(), + reranker: NewSimpleReranker(), + metrics: NewMetrics(), + } +} + +// EnableCache 启用检索缓存(ttl 单位为秒) +func (s *RetrievalService) EnableCache(ttl time.Duration) { + s.cache.SetTTL(int(ttl.Seconds())) +} + +// Retrieve 执行 RAG 检索 +func (s *RetrievalService) Retrieve(ctx context.Context, query string, topK int, knowledgeBaseID *uint) ([]SearchResult, error) { + startTime := time.Now() + cacheHit := false + var results []SearchResult + var err error + + // 检查缓存 + if s.cache != nil { + if cached, ok := s.cache.Get(query, topK, knowledgeBaseID); ok { + results = cached + cacheHit = true + } + } + + // 如果缓存未命中,执行检索 + if !cacheHit { + svc, err := s.embeddingProvider.Get(ctx) + if err != nil { + s.metrics.RecordQuery(false, time.Since(startTime), false) + return nil, fmt.Errorf("获取嵌入服务失败: %w", err) + } + // 向量化查询 + queryVectors, err := svc.EmbedTexts(ctx, []string{query}) + if err != nil { + s.metrics.RecordQuery(false, time.Since(startTime), false) + return nil, fmt.Errorf("查询向量化失败: %w", err) + } + if len(queryVectors) == 0 { + s.metrics.RecordQuery(false, time.Since(startTime), false) + return nil, fmt.Errorf("未返回查询向量") + } + + // 转换知识库 ID + var kbIDStr *string + if knowledgeBaseID != nil { + str := ConvertKnowledgeBaseID(*knowledgeBaseID) + kbIDStr = &str + } + + // 多取一些结果,过滤未发布文档后仍能凑满 topK + searchLimit := topK * 3 + if searchLimit < 10 { + searchLimit = 10 + } + results, err = s.vectorStoreService.SearchVectors(ctx, queryVectors[0], searchLimit, kbIDStr) + if err != nil { + s.metrics.RecordQuery(false, time.Since(startTime), false) + return nil, fmt.Errorf("向量检索失败: %w", err) + } + + // 仅保留「已发布」的文档参与 RAG;未在 documents 表中的条目(如 FAQ)视为可展示 + results = s.filterByPublished(ctx, results, topK) + + // 缓存过滤后的结果 + if s.cache != nil { + s.cache.Set(query, topK, knowledgeBaseID, results) + } + } + + // 记录指标 + s.metrics.RecordQuery(err == nil, time.Since(startTime), cacheHit) + + return results, err +} + +// RetrieveWithRerank 执行带重排序的 RAG 检索 +func (s *RetrievalService) RetrieveWithRerank(ctx context.Context, query string, topK int, knowledgeBaseID *uint) ([]SearchResult, error) { + // 先执行基础检索 + results, err := s.Retrieve(ctx, query, topK, knowledgeBaseID) + if err != nil { + return nil, err + } + + // 重排序 + if s.reranker != nil { + reranked, err := s.reranker.Rerank(ctx, query, results) + if err != nil { + // 重排序失败不影响主流程,返回原始结果 + return results, nil + } + return reranked, nil + } + + return results, nil +} + +// filterByPublished 仅保留「已发布」且所属知识库已开启 RAG 的文档;FAQ 保留;取前 topK 条 +func (s *RetrievalService) filterByPublished(ctx context.Context, results []SearchResult, topK int) []SearchResult { + if s.docRepo == nil || len(results) == 0 { + if len(results) > topK { + return results[:topK] + } + return results + } + docIDs := make([]uint, 0, len(results)) + seen := make(map[uint]struct{}) + for _, r := range results { + id, err := strconv.ParseUint(r.DocumentID, 10, 32) + if err != nil { + continue + } + uid := uint(id) + if _, ok := seen[uid]; !ok { + seen[uid] = struct{}{} + docIDs = append(docIDs, uid) + } + } + docs, err := s.docRepo.GetByIDs(docIDs) + if err != nil { + return results + } + unpublished := make(map[uint]struct{}) + docIDToKBID := make(map[uint]uint) + for _, d := range docs { + if d.Status != "published" { + unpublished[d.ID] = struct{}{} + } + docIDToKBID[d.ID] = d.KnowledgeBaseID + } + // 知识库未参与 RAG 的集合 + disabledKBIDs := make(map[uint]struct{}) + if s.kbRepo != nil && len(docIDToKBID) > 0 { + kbIDSet := make(map[uint]struct{}) + for _, kbID := range docIDToKBID { + kbIDSet[kbID] = struct{}{} + } + kbIDs := make([]uint, 0, len(kbIDSet)) + for id := range kbIDSet { + kbIDs = append(kbIDs, id) + } + if kbs, err := s.kbRepo.GetByIDs(kbIDs); err == nil { + for _, kb := range kbs { + if !kb.RAGEnabled { + disabledKBIDs[kb.ID] = struct{}{} + } + } + } + } + filtered := make([]SearchResult, 0, len(results)) + for _, r := range results { + id, err := strconv.ParseUint(r.DocumentID, 10, 32) + if err != nil { + filtered = append(filtered, r) + continue + } + uid := uint(id) + if _, ok := unpublished[uid]; ok { + continue + } + if kbID, inDoc := docIDToKBID[uid]; inDoc { + if _, disabled := disabledKBIDs[kbID]; disabled { + continue + } + } + filtered = append(filtered, r) + if len(filtered) >= topK { + break + } + } + return filtered +} + +// GetMetrics 获取性能指标 +func (s *RetrievalService) GetMetrics() map[string]interface{} { + return s.metrics.GetStats() +} diff --git a/backend/service/rag/retry.go b/backend/service/rag/retry.go new file mode 100644 index 0000000..b502e5e --- /dev/null +++ b/backend/service/rag/retry.go @@ -0,0 +1,64 @@ +package rag + +import ( + "context" + "fmt" + "time" +) + +// RetryConfig 重试配置 +type RetryConfig struct { + MaxAttempts int + InitialDelay time.Duration + MaxDelay time.Duration + BackoffFactor float64 +} + +// DefaultRetryConfig 默认重试配置 +func DefaultRetryConfig() RetryConfig { + return RetryConfig{ + MaxAttempts: 3, + InitialDelay: 100 * time.Millisecond, + MaxDelay: 5 * time.Second, + BackoffFactor: 2.0, + } +} + +// Retry 重试执行函数 +func Retry(ctx context.Context, config RetryConfig, fn func() error) error { + var lastErr error + delay := config.InitialDelay + + for attempt := 0; attempt < config.MaxAttempts; attempt++ { + // 检查上下文是否已取消 + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // 执行函数 + err := fn() + if err == nil { + return nil + } + + lastErr = err + + // 如果不是最后一次尝试,等待后重试 + if attempt < config.MaxAttempts-1 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + // 指数退避 + delay = time.Duration(float64(delay) * config.BackoffFactor) + if delay > config.MaxDelay { + delay = config.MaxDelay + } + } + } + } + + return fmt.Errorf("重试 %d 次后仍然失败: %w", config.MaxAttempts, lastErr) +} diff --git a/backend/service/rag/types.go b/backend/service/rag/types.go new file mode 100644 index 0000000..c5c17ef --- /dev/null +++ b/backend/service/rag/types.go @@ -0,0 +1,9 @@ +package rag + +// SearchResult 搜索结果 +type SearchResult struct { + DocumentID string + KnowledgeBaseID string + Content string + Score float32 +} diff --git a/backend/service/rag/vector_store.go b/backend/service/rag/vector_store.go new file mode 100644 index 0000000..ec3ebdc --- /dev/null +++ b/backend/service/rag/vector_store.go @@ -0,0 +1,72 @@ +package rag + +import ( + "context" + "fmt" + "strconv" + + "github.com/2930134478/AI-CS/backend/infra" +) + +// VectorStoreService 向量存储服务(业务层) +type VectorStoreService struct { + vectorStore *infra.VectorStore +} + +// NewVectorStoreService 创建向量存储服务实例 +func NewVectorStoreService(vectorStore *infra.VectorStore) *VectorStoreService { + return &VectorStoreService{ + vectorStore: vectorStore, + } +} + +// UpsertVector 插入或更新单个向量 +func (s *VectorStoreService) UpsertVector(ctx context.Context, documentID string, knowledgeBaseID string, content string, vector []float32) error { + return s.vectorStore.UpsertVector(ctx, documentID, knowledgeBaseID, content, vector) +} + +// UpsertVectors 批量插入或更新向量 +func (s *VectorStoreService) UpsertVectors(ctx context.Context, documentIDs []string, knowledgeBaseIDs []string, contents []string, vectors [][]float32) error { + return s.vectorStore.UpsertVectors(ctx, documentIDs, knowledgeBaseIDs, contents, vectors) +} + +// SearchVectors 搜索相似向量 +func (s *VectorStoreService) SearchVectors(ctx context.Context, queryVector []float32, topK int, knowledgeBaseID *string) ([]SearchResult, error) { + results, err := s.vectorStore.SearchVectors(ctx, queryVector, topK, knowledgeBaseID) + if err != nil { + return nil, fmt.Errorf("向量检索失败: %w", err) + } + + // 转换结果 + searchResults := make([]SearchResult, len(results)) + for i, r := range results { + searchResults[i] = SearchResult{ + DocumentID: r.DocumentID, + KnowledgeBaseID: r.KnowledgeBaseID, + Content: r.Content, + Score: r.Score, + } + } + + return searchResults, nil +} + +// DeleteVector 删除向量 +func (s *VectorStoreService) DeleteVector(ctx context.Context, documentID string) error { + return s.vectorStore.DeleteVector(ctx, documentID) +} + +// DeleteVectors 批量删除向量 +func (s *VectorStoreService) DeleteVectors(ctx context.Context, documentIDs []string) error { + return s.vectorStore.DeleteVectors(ctx, documentIDs) +} + +// ConvertDocumentID 将 uint 转换为 string +func ConvertDocumentID(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} + +// ConvertKnowledgeBaseID 将 uint 转换为 string +func ConvertKnowledgeBaseID(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} diff --git a/backend/service/types.go b/backend/service/types.go index 113d5a9..3e28885 100644 --- a/backend/service/types.go +++ b/backend/service/types.go @@ -37,16 +37,18 @@ type UpdateConversationContactInput struct { // ConversationSummary 用于会话列表展示的概要信息。 type ConversationSummary struct { - ID uint - VisitorID uint - AgentID uint - Status string - CreatedAt time.Time - UpdatedAt time.Time - LastMessage *LastMessageSummary - UnreadCount int64 - LastSeenAt *time.Time // 最后活跃时间,用于判断在线状态 - HasParticipated bool // 当前用户是否参与过该会话(是否发送过消息) + ID uint + ConversationType string // visitor | internal + VisitorID uint + AgentID uint + Status string + ChatMode string // human | ai + CreatedAt time.Time + UpdatedAt time.Time + LastMessage *LastMessageSummary + UnreadCount int64 + LastSeenAt *time.Time // 最后活跃时间,用于判断在线状态 + HasParticipated bool // 当前用户是否参与过该会话(是否发送过消息) } // LastMessageSummary 会话最后一条消息的摘要信息。 @@ -193,3 +195,71 @@ type OnlineAgent struct { Nickname string `json:"nickname"` // 昵称 AvatarURL string `json:"avatar_url"` // 头像URL } + +// DocumentSummary 文档摘要信息。 +type DocumentSummary struct { + ID uint `json:"id"` + KnowledgeBaseID uint `json:"knowledge_base_id"` + Title string `json:"title"` + Content string `json:"content"` + Summary string `json:"summary"` + Type string `json:"type"` + Status string `json:"status"` + EmbeddingStatus string `json:"embedding_status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateDocumentInput 创建文档输入。 +type CreateDocumentInput struct { + KnowledgeBaseID uint // 知识库 ID(必需) + Title string // 文档标题(必需) + Content string // 文档内容(必需) + Summary string // 文档摘要(可选) + Type string // 文档类型(可选,默认:document) + Status string // 文档状态(可选,默认:draft) + Metadata map[string]interface{} // 元数据(可选) +} + +// UpdateDocumentInput 更新文档输入。 +type UpdateDocumentInput struct { + Title *string // 文档标题(可选) + Content *string // 文档内容(可选) + Summary *string // 文档摘要(可选) + Type *string // 文档类型(可选) + Status *string // 文档状态(可选) + Metadata *map[string]interface{} // 元数据(可选) +} + +// DocumentListResult 文档列表查询结果。 +type DocumentListResult struct { + Documents []DocumentSummary `json:"documents"` // 文档列表 + Total int64 `json:"total"` // 总记录数 + Page int `json:"page"` // 当前页码 + PageSize int `json:"page_size"` // 每页大小 + TotalPage int `json:"total_page"` // 总页数 +} + +// KnowledgeBaseSummary 知识库摘要信息。 +type KnowledgeBaseSummary struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + DocumentCount int64 `json:"document_count"` // 文档数量(统计信息) + RAGEnabled bool `json:"rag_enabled"` // 是否参与 RAG(对 AI 开放) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateKnowledgeBaseInput 创建知识库输入。 +type CreateKnowledgeBaseInput struct { + Name string // 知识库名称(必需) + Description string // 知识库描述(可选) +} + +// UpdateKnowledgeBaseInput 更新知识库输入。 +type UpdateKnowledgeBaseInput struct { + Name *string // 知识库名称(可选) + Description *string // 知识库描述(可选) + RAGEnabled *bool // 是否参与 RAG(可选) +} diff --git a/docker-compose.milvus.yml b/docker-compose.milvus.yml new file mode 100644 index 0000000..6a99c0a --- /dev/null +++ b/docker-compose.milvus.yml @@ -0,0 +1,75 @@ +services: + # etcd 服务(Milvus 依赖) + etcd: + image: quay.io/coreos/etcd:v3.5.5 + container_name: milvus-etcd + environment: + - ETCD_AUTO_COMPACTION_MODE=revision + - ETCD_AUTO_COMPACTION_RETENTION=1000 + - ETCD_QUOTA_BACKEND_BYTES=4294967296 + - ETCD_SNAPSHOT_COUNT=50000 + volumes: + - etcd_data:/etcd + command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd + healthcheck: + test: ["CMD", "etcdctl", "endpoint", "health"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - milvus-network + + # MinIO 服务(Milvus 依赖) + minio: + image: minio/minio:RELEASE.2023-03-20T20-16-18Z + container_name: milvus-minio + environment: + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + ports: + - "9001:9001" + - "9000:9000" + volumes: + - minio_data:/minio_data + command: minio server /minio_data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - milvus-network + + # Milvus 服务 + milvus-standalone: + image: milvusdb/milvus:v2.3.3 + container_name: milvus-standalone + command: ["milvus", "run", "standalone"] + environment: + ETCD_ENDPOINTS: etcd:2379 + MINIO_ADDRESS: minio:9000 + volumes: + - milvus_data:/var/lib/milvus + ports: + - "19530:19530" + - "9091:9091" + depends_on: + - "etcd" + - "minio" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"] + interval: 30s + start_period: 90s + timeout: 20s + retries: 5 + networks: + - milvus-network + +volumes: + etcd_data: + minio_data: + milvus_data: + +networks: + milvus-network: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0b4a9ec..a1cf4a7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -22,6 +22,21 @@ services: networks: - ai-cs-network restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + resources: + limits: + cpus: '2.0' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M # 后端服务(使用预构建镜像) backend: @@ -49,6 +64,23 @@ services: networks: - ai-cs-network restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + read_only: true + tmpfs: + - /tmp + - /var/tmp + resources: + limits: + cpus: '2.0' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M # 前端服务(使用预构建镜像) frontend: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..0bb505d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# ============================================ +# 鍓嶇鐜鍙橀噺绀轰緥锛堝鍒朵负 .env 鎴?.env.local 鍚庢寜闇€濉啓锛?# ============================================ + +# 娴忚鍣ㄨ闂悗绔?API 鐨勫湴鍧€锛堢敓浜?瀹瑰櫒閮ㄧ讲鏃跺繀濉級 +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 + +# 寮€鍙戠幆澧冧唬鐞嗙敤锛堜粎鏈湴 npm run dev 鏃剁敓鏁堬紝榛樿鍗冲彲锛?NEXT_PUBLIC_BACKEND_HOST=localhost +NEXT_PUBLIC_BACKEND_PORT=8080 + +# Matomo 缁熻锛堝彲閫夛紝鐣欑┖鍒欎笉鍔犺浇锛?# NEXT_PUBLIC_MATOMO_CONTAINER_URL= diff --git a/frontend/app/agent/chat/[conversationId]/page.tsx b/frontend/app/agent/chat/[conversationId]/page.tsx index 0c58082..f820c89 100644 --- a/frontend/app/agent/chat/[conversationId]/page.tsx +++ b/frontend/app/agent/chat/[conversationId]/page.tsx @@ -23,6 +23,7 @@ import { ChatWebSocketPayload, } from "@/features/agent/types"; import type { WSMessage } from "@/lib/websocket"; +import { toast } from "@/hooks/useToast"; export default function AgentChatPage() { const params = useParams(); @@ -163,7 +164,7 @@ export default function AgentChatPage() { setMessageInput(""); } catch (error) { console.error(error); - alert((error as Error).message); + toast.error((error as Error).message); } finally { setSending(false); } diff --git a/frontend/app/agent/faqs/page.tsx b/frontend/app/agent/faqs/page.tsx index dfdb9cd..511de16 100644 --- a/frontend/app/agent/faqs/page.tsx +++ b/frontend/app/agent/faqs/page.tsx @@ -33,6 +33,7 @@ import { Save, X, } from "lucide-react"; +import { toast } from "@/hooks/useToast"; import { Textarea } from "@/components/ui/textarea"; export default function FAQsPage(props: any = {}) { @@ -72,7 +73,7 @@ export default function FAQsPage(props: any = {}) { setFaqs(data); } catch (error) { console.error("加载 FAQ 列表失败:", error); - alert((error as Error).message || "加载 FAQ 列表失败"); + toast.error((error as Error).message || "加载 FAQ 列表失败"); } finally { setLoading(false); } @@ -101,7 +102,7 @@ export default function FAQsPage(props: any = {}) { // 创建 FAQ const handleCreate = async () => { if (!createForm.question.trim() || !createForm.answer.trim()) { - alert("问题和答案不能为空"); + toast.error("问题和答案不能为空"); return; } setSubmitting(true); @@ -110,9 +111,9 @@ export default function FAQsPage(props: any = {}) { setCreateDialogOpen(false); setCreateForm({ question: "", answer: "", keywords: "" }); await loadFAQs(); - alert("创建成功"); + toast.success("创建成功"); } catch (error) { - alert((error as Error).message || "创建 FAQ 失败"); + toast.error((error as Error).message || "创建 FAQ 失败"); } finally { setSubmitting(false); } @@ -135,7 +136,7 @@ export default function FAQsPage(props: any = {}) { return; } if (!editForm.question?.trim() || !editForm.answer?.trim()) { - alert("问题和答案不能为空"); + toast.error("问题和答案不能为空"); return; } setSubmitting(true); @@ -144,9 +145,9 @@ export default function FAQsPage(props: any = {}) { setEditDialogOpen(false); setSelectedFAQ(null); await loadFAQs(); - alert("更新成功"); + toast.success("更新成功"); } catch (error) { - alert((error as Error).message || "更新 FAQ 失败"); + toast.error((error as Error).message || "更新 FAQ 失败"); } finally { setSubmitting(false); } @@ -169,9 +170,9 @@ export default function FAQsPage(props: any = {}) { setDeleteDialogOpen(false); setSelectedFAQ(null); await loadFAQs(); - alert("删除成功"); + toast.success("删除成功"); } catch (error) { - alert((error as Error).message || "删除 FAQ 失败"); + toast.error((error as Error).message || "删除 FAQ 失败"); } finally { setSubmitting(false); } diff --git a/frontend/app/agent/knowledge/page.tsx b/frontend/app/agent/knowledge/page.tsx new file mode 100644 index 0000000..e8c314d --- /dev/null +++ b/frontend/app/agent/knowledge/page.tsx @@ -0,0 +1,1213 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/features/agent/hooks/useAuth"; +import { ResponsiveLayout } from "@/components/layout"; +import { + fetchKnowledgeBases, + createKnowledgeBase, + updateKnowledgeBase, + updateKnowledgeBaseRAGEnabled, + deleteKnowledgeBase, + type KnowledgeBase, + type CreateKnowledgeBaseRequest, + type UpdateKnowledgeBaseRequest, +} from "@/features/agent/services/knowledgeBaseApi"; +import { + fetchDocuments, + createDocument, + updateDocument, + deleteDocument, + publishDocument, + unpublishDocument, + type Document, + type CreateDocumentRequest, + type UpdateDocumentRequest, + type DocumentListResult, +} from "@/features/agent/services/documentApi"; +import { + importDocuments, + importFromUrls, + type ImportResult, +} from "@/features/agent/services/importApi"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Switch } from "@/components/ui/switch"; +import { + Plus, + Edit, + Trash2, + Search, + FileText, + Upload, + Link as LinkIcon, + BookOpen, + CheckCircle2, + XCircle, + Loader2, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "@/hooks/useToast"; + +export default function KnowledgePage(props: any = {}) { + const { embedded = false } = props; + const router = useRouter(); + const { agent } = useAuth(); + + // 知识库状态 + const [knowledgeBases, setKnowledgeBases] = useState([]); + const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState(null); + const [loadingKBs, setLoadingKBs] = useState(true); + + // 文档状态 + const [documents, setDocuments] = useState([]); + const [documentResult, setDocumentResult] = useState(null); + const [loadingDocs, setLoadingDocs] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 20; + + // 对话框状态 + const [createKBDialogOpen, setCreateKBDialogOpen] = useState(false); + const [editKBDialogOpen, setEditKBDialogOpen] = useState(false); + const [deleteKBDialogOpen, setDeleteKBDialogOpen] = useState(false); + const [createDocDialogOpen, setCreateDocDialogOpen] = useState(false); + const [editDocDialogOpen, setEditDocDialogOpen] = useState(false); + const [deleteDocDialogOpen, setDeleteDocDialogOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importTab, setImportTab] = useState<"file" | "url">("file"); + const [selectedDocument, setSelectedDocument] = useState(null); + + // 表单状态 + const [submitting, setSubmitting] = useState(false); + const [createKBForm, setCreateKBForm] = useState({ + name: "", + description: "", + }); + const [editKBForm, setEditKBForm] = useState({}); + const [createDocForm, setCreateDocForm] = useState({ + knowledge_base_id: 0, + title: "", + content: "", + summary: "", + type: "document", + status: "draft", + }); + const [editDocForm, setEditDocForm] = useState({}); + const [importUrls, setImportUrls] = useState(""); + const [importFiles, setImportFiles] = useState([]); + + // 加载知识库列表(不依赖 selectedKnowledgeBase,避免选中后反复触发 effect 导致疯狂刷新) + const loadKnowledgeBases = useCallback(async () => { + setLoadingKBs(true); + try { + const data = await fetchKnowledgeBases(); + setKnowledgeBases(data); + } catch (error) { + console.error("加载知识库列表失败:", error); + toast.error((error as Error).message || "加载知识库列表失败"); + } finally { + setLoadingKBs(false); + } + }, []); + + // 加载文档列表 + const loadDocuments = useCallback(async () => { + if (!selectedKnowledgeBase) { + setDocuments([]); + setDocumentResult(null); + return; + } + + setLoadingDocs(true); + try { + const status = statusFilter === "all" ? undefined : statusFilter; + const result = await fetchDocuments( + selectedKnowledgeBase.id, + currentPage, + pageSize, + searchKeyword || undefined, + status + ); + setDocumentResult(result); + setDocuments(result.documents ?? []); + } catch (error) { + console.error("加载文档列表失败:", error); + toast.error((error as Error).message || "加载文档列表失败"); + setDocuments([]); + setDocumentResult(null); + } finally { + setLoadingDocs(false); + } + }, [selectedKnowledgeBase, currentPage, searchKeyword, statusFilter]); + + // 初始加载 + useEffect(() => { + loadKnowledgeBases(); + }, [loadKnowledgeBases]); + + // 当选择知识库或搜索条件变化时,重新加载文档 + useEffect(() => { + setCurrentPage(1); // 切换知识库或搜索时重置页码 + loadDocuments(); + }, [loadDocuments]); + + // 选择知识库 + const handleSelectKnowledgeBase = (kb: KnowledgeBase) => { + setSelectedKnowledgeBase(kb); + setSearchKeyword(""); + setStatusFilter("all"); + setCurrentPage(1); + }; + + // 创建知识库 + const handleCreateKB = async () => { + if (!createKBForm.name.trim()) { + toast.error("知识库名称不能为空"); + return; + } + setSubmitting(true); + try { + await createKnowledgeBase(createKBForm); + setCreateKBDialogOpen(false); + setCreateKBForm({ name: "", description: "" }); + await loadKnowledgeBases(); + toast.success("创建成功"); + } catch (error) { + toast.error((error as Error).message || "创建知识库失败"); + } finally { + setSubmitting(false); + } + }; + + // 打开编辑知识库对话框 + const handleOpenEditKB = (kb: KnowledgeBase) => { + setEditKBForm({ + name: kb.name, + description: kb.description, + }); + setSelectedKnowledgeBase(kb); + setEditKBDialogOpen(true); + }; + + // 更新知识库 + const handleUpdateKB = async () => { + if (!selectedKnowledgeBase) return; + setSubmitting(true); + try { + await updateKnowledgeBase(selectedKnowledgeBase.id, editKBForm); + setEditKBDialogOpen(false); + await loadKnowledgeBases(); + toast.success("更新成功"); + } catch (error) { + toast.error((error as Error).message || "更新知识库失败"); + } finally { + setSubmitting(false); + } + }; + + // 打开删除知识库对话框 + const handleOpenDeleteKB = (kb: KnowledgeBase) => { + setSelectedKnowledgeBase(kb); + setDeleteKBDialogOpen(true); + }; + + // 删除知识库 + const handleDeleteKB = async () => { + if (!selectedKnowledgeBase) return; + setSubmitting(true); + try { + await deleteKnowledgeBase(selectedKnowledgeBase.id); + setDeleteKBDialogOpen(false); + setSelectedKnowledgeBase(null); + await loadKnowledgeBases(); + toast.success("删除成功"); + } catch (error) { + toast.error((error as Error).message || "删除知识库失败"); + } finally { + setSubmitting(false); + } + }; + + // 打开创建文档对话框 + const handleOpenCreateDoc = () => { + if (!selectedKnowledgeBase) { + toast.error("请先选择知识库"); + return; + } + setCreateDocForm({ + knowledge_base_id: selectedKnowledgeBase.id, + title: "", + content: "", + summary: "", + type: "document", + status: "draft", + }); + setCreateDocDialogOpen(true); + }; + + // 创建文档 + const handleCreateDoc = async () => { + if (!createDocForm.title.trim() || !createDocForm.content.trim()) { + toast.error("标题和内容不能为空"); + return; + } + setSubmitting(true); + try { + await createDocument(createDocForm); + setCreateDocDialogOpen(false); + setCreateDocForm({ + knowledge_base_id: selectedKnowledgeBase?.id || 0, + title: "", + content: "", + summary: "", + type: "document", + status: "draft", + }); + await loadDocuments(); + toast.success("创建成功"); + } catch (error) { + toast.error((error as Error).message || "创建文档失败"); + } finally { + setSubmitting(false); + } + }; + + // 打开编辑文档对话框 + const handleOpenEditDoc = (doc: Document) => { + setSelectedDocument(doc); + setEditDocForm({ + title: doc.title, + content: doc.content, + summary: doc.summary, + type: doc.type, + status: doc.status, + }); + setEditDocDialogOpen(true); + }; + + // 更新文档 + const handleUpdateDoc = async (docId: number) => { + setSubmitting(true); + try { + await updateDocument(docId, editDocForm); + setEditDocDialogOpen(false); + await loadDocuments(); + toast.success("更新成功"); + } catch (error) { + toast.error((error as Error).message || "更新文档失败"); + } finally { + setSubmitting(false); + } + }; + + // 打开删除文档对话框 + const handleOpenDeleteDoc = (doc: Document) => { + setSelectedDocument(doc); + setDeleteDocDialogOpen(true); + }; + + // 删除文档 + const handleDeleteDoc = async (docId: number) => { + setSubmitting(true); + try { + await deleteDocument(docId); + setDeleteDocDialogOpen(false); + await loadDocuments(); + toast.success("删除成功"); + } catch (error) { + toast.error((error as Error).message || "删除文档失败"); + } finally { + setSubmitting(false); + } + }; + + // 发布文档 + const handlePublishDoc = async (docId: number) => { + try { + await publishDocument(docId); + await loadDocuments(); + toast.success("发布成功"); + } catch (error) { + toast.error((error as Error).message || "发布文档失败"); + } + }; + + // 取消发布文档 + const handleUnpublishDoc = async (docId: number) => { + try { + await unpublishDocument(docId); + await loadDocuments(); + toast.success("取消发布成功"); + } catch (error) { + toast.error((error as Error).message || "取消发布文档失败"); + } + }; + + // 导入文件 + const handleImportFiles = async () => { + if (!selectedKnowledgeBase) { + toast.error("请先选择知识库"); + return; + } + if (importFiles.length === 0) { + toast.error("请选择要导入的文件"); + return; + } + setSubmitting(true); + try { + const result: ImportResult = await importDocuments(selectedKnowledgeBase.id, importFiles); + const errMsg = result.errors?.length ? result.errors[0] : ""; + if (result.failed_count > 0 && result.success_count === 0) { + toast.error(errMsg || `导入失败:${result.failed_count} 个文件未成功`); + } else if (result.failed_count > 0) { + toast.success(`导入完成:成功 ${result.success_count},失败 ${result.failed_count}${errMsg ? `(${errMsg})` : ""}`); + } else { + toast.success(`导入完成:成功 ${result.success_count} 个文件`); + } + setImportDialogOpen(false); + setImportFiles([]); + try { + await loadDocuments(); + await loadKnowledgeBases(); + } catch { + toast.error("导入成功,但刷新列表失败,请手动刷新页面"); + } + } catch (error) { + toast.error((error as Error).message || "导入文档失败"); + } finally { + setSubmitting(false); + } + }; + + // 导入 URL + const handleImportUrls = async () => { + if (!selectedKnowledgeBase) { + toast.error("请先选择知识库"); + return; + } + const urls = importUrls + .split("\n") + .map((url) => url.trim()) + .filter((url) => url.length > 0); + if (urls.length === 0) { + toast.error("请输入至少一个 URL"); + return; + } + setSubmitting(true); + try { + const result: ImportResult = await importFromUrls({ + knowledge_base_id: selectedKnowledgeBase.id, + urls, + }); + const errMsg = result.errors?.length ? result.errors[0] : ""; + if (result.failed_count > 0 && result.success_count === 0) { + toast.error(errMsg || `导入失败:${result.failed_count} 个 URL 未成功`); + } else if (result.failed_count > 0) { + toast.success(`导入完成:成功 ${result.success_count},失败 ${result.failed_count}${errMsg ? `(${errMsg})` : ""}`); + } else { + toast.success(`导入完成:成功 ${result.success_count} 个 URL`); + } + setImportDialogOpen(false); + setImportUrls(""); + try { + await loadDocuments(); + await loadKnowledgeBases(); + } catch { + toast.error("导入成功,但刷新列表失败,请手动刷新页面"); + } + } catch (error) { + toast.error((error as Error).message || "导入 URL 失败"); + } finally { + setSubmitting(false); + } + }; + + // 格式化时间 + const formatTime = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }; + + // 获取状态标签 + const getStatusBadge = (status: string) => { + switch (status) { + case "published": + return ( + + + 已发布 + + ); + case "draft": + return ( + + 草稿 + + ); + default: + return ( + + {status} + + ); + } + }; + + // 获取向量化状态标签 + const getEmbeddingStatusBadge = (status: string) => { + switch (status) { + case "completed": + return ( + + + 已完成 + + ); + case "processing": + return ( + + + 处理中 + + ); + case "failed": + return ( + + + 失败 + + ); + case "pending": + default: + return ( + + 待处理 + + ); + } + }; + + // 构建头部内容 + const headerContent = ( +
+
+

知识库管理

+ {!embedded && ( + + )} +
+
+ ); + + // 构建主内容区 + const mainContent = ( +
+ {/* 左侧:知识库列表 */} +
+
+ +
+
+ {loadingKBs ? ( +
+ 加载中... +
+ ) : knowledgeBases.length === 0 ? ( +
+ 暂无知识库 +
+ ) : ( +
+ {knowledgeBases.map((kb) => ( + handleSelectKnowledgeBase(kb)} + > +
+
+
+ +

{kb.name}

+
+ {kb.description && ( +

+ {kb.description} +

+ )} +
+ + {kb.document_count} 篇文档 + +
+
+
+ + +
+
+
+ ))} +
+ )} +
+
+ + {/* 右侧:文档列表 */} +
+ {selectedKnowledgeBase ? ( + <> + {/* 文档列表头部 */} +
+
+

{selectedKnowledgeBase.name}

+
+
+ + { + try { + const updated = await updateKnowledgeBaseRAGEnabled(selectedKnowledgeBase.id, checked); + setSelectedKnowledgeBase((prev) => (prev?.id === updated.id ? { ...prev, rag_enabled: updated.rag_enabled } : prev)); + setKnowledgeBases((prev) => prev.map((kb) => (kb.id === updated.id ? { ...kb, rag_enabled: updated.rag_enabled } : kb))); + } catch (e) { + toast.error((e as Error).message || "更新失败"); + } + }} + /> +
+ + + +
+
+ + {/* 搜索和筛选 */} +
+
+ + setSearchKeyword(e.target.value)} + className="pl-10" + /> +
+ +
+
+ + {/* 文档列表 */} +
+ {loadingDocs ? ( +
+ 加载中... +
+ ) : (documents?.length ?? 0) === 0 ? ( +
+ + {searchKeyword || statusFilter !== "all" + ? "没有找到匹配的文档" + : "暂无文档"} + +
+ ) : ( +
+ {(documents ?? []).map((doc) => ( + +
+
+
+ +

+ {doc.title} +

+ {getStatusBadge(doc.status)} + {getEmbeddingStatusBadge(doc.embedding_status)} +
+ {doc.summary && ( +

+ {doc.summary} +

+ )} +
+ 类型: {doc.type} + 创建时间: {formatTime(doc.created_at)} +
+
+
+
+ + +
+
+ {doc.status === "published" ? ( + + ) : ( + + )} +
+
+
+
+ ))} +
+ )} + + {/* 分页 */} + {documentResult && documentResult.total_page > 1 && ( +
+ + + 第 {currentPage} / {documentResult.total_page} 页,共 {documentResult.total} 条 + + +
+ )} +
+ + ) : ( +
+ 请选择一个知识库 +
+ )} +
+
+ ); + + // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout + if (embedded) { + return ( + <> +
+ {headerContent} + {mainContent} +
+ + {/* 对话框 */} + {/* 创建知识库对话框 */} + + + + 创建知识库 + 填写知识库名称和描述 + +
+
+ + + setCreateKBForm({ ...createKBForm, name: e.target.value }) + } + placeholder="请输入知识库名称" + /> +
+
+ +