mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
知识库及UI更新
This commit is contained in:
+12
-32
@@ -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
|
||||
# 鍙€?GIN_MODE=release
|
||||
|
||||
@@ -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
|
||||
|
||||
# 安装依赖
|
||||
|
||||
+9
-1
@@ -2,4 +2,12 @@ DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=hkbjujk%h2eT
|
||||
DB_NAME=CS
|
||||
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
|
||||
@@ -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 涓庡湴鍧€銆?# --------------------------------------------
|
||||
@@ -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 字段(用于判断在线状态)
|
||||
|
||||
@@ -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": "取消发布成功"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
+25
-4
@@ -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
|
||||
)
|
||||
|
||||
+444
-10
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+82
-12
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"` // 更新时间
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
// 访客信息字段(自动收集)
|
||||
|
||||
Generated
-305
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-label": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "未配置"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+161
-15
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 解析功能待实现,需要集成专业库")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 解析功能待实现,需要集成专业库")
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package rag
|
||||
|
||||
// SearchResult 搜索结果
|
||||
type SearchResult struct {
|
||||
DocumentID string
|
||||
KnowledgeBaseID string
|
||||
Content string
|
||||
Score float32
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+80
-10
@@ -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(可选)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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=
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,17 @@ import {
|
||||
type CreateAIConfigRequest,
|
||||
type UpdateAIConfigRequest,
|
||||
} from "@/features/agent/services/aiConfigApi";
|
||||
import {
|
||||
fetchEmbeddingConfig,
|
||||
updateEmbeddingConfig,
|
||||
type EmbeddingConfig,
|
||||
type UpdateEmbeddingConfigRequest,
|
||||
} from "@/features/agent/services/embeddingConfigApi";
|
||||
import { useProfile } from "@/features/agent/hooks/useProfile";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
|
||||
export default function SettingsPage(props: any = {}) {
|
||||
const { embedded = false } = props;
|
||||
@@ -40,6 +47,19 @@ export default function SettingsPage(props: any = {}) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 知识库向量配置(平台级,仅管理员可修改)
|
||||
const [embeddingConfig, setEmbeddingConfig] = useState<EmbeddingConfig | null>(null);
|
||||
const [embeddingForm, setEmbeddingForm] = useState({
|
||||
embedding_type: "openai",
|
||||
api_url: "",
|
||||
api_key: "",
|
||||
model: "text-embedding-3-small",
|
||||
customer_can_use_kb: true,
|
||||
});
|
||||
const [embeddingLoading, setEmbeddingLoading] = useState(false);
|
||||
const [embeddingSubmitting, setEmbeddingSubmitting] = useState(false);
|
||||
const [embeddingError, setEmbeddingError] = useState("");
|
||||
|
||||
// 检查登录状态
|
||||
useEffect(() => {
|
||||
const storedUserId = localStorage.getItem("agent_user_id");
|
||||
@@ -81,6 +101,60 @@ export default function SettingsPage(props: any = {}) {
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// 加载知识库向量配置
|
||||
const loadEmbeddingConfig = async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
setEmbeddingLoading(true);
|
||||
const data = await fetchEmbeddingConfig(userId);
|
||||
setEmbeddingConfig(data);
|
||||
setEmbeddingForm({
|
||||
embedding_type: data.embedding_type || "openai",
|
||||
api_url: data.api_url || "",
|
||||
api_key: "",
|
||||
model: data.model || "text-embedding-3-small",
|
||||
customer_can_use_kb: data.customer_can_use_kb ?? true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("加载知识库向量配置失败:", e);
|
||||
setEmbeddingError("加载失败");
|
||||
} finally {
|
||||
setEmbeddingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
loadEmbeddingConfig();
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// 保存知识库向量配置(仅管理员;保存后立即生效,无需重启)
|
||||
const handleSaveEmbeddingConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!userId) return;
|
||||
setEmbeddingSubmitting(true);
|
||||
setEmbeddingError("");
|
||||
try {
|
||||
const data: UpdateEmbeddingConfigRequest = {
|
||||
embedding_type: embeddingForm.embedding_type,
|
||||
api_url: embeddingForm.api_url || undefined,
|
||||
model: embeddingForm.model || undefined,
|
||||
customer_can_use_kb: embeddingForm.customer_can_use_kb,
|
||||
};
|
||||
if (embeddingForm.api_key) {
|
||||
data.api_key = embeddingForm.api_key;
|
||||
}
|
||||
await updateEmbeddingConfig(userId, data);
|
||||
await loadEmbeddingConfig();
|
||||
toast.success("保存成功,配置已立即生效。");
|
||||
} catch (err) {
|
||||
setEmbeddingError((err as Error).message);
|
||||
} finally {
|
||||
setEmbeddingSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
@@ -235,7 +309,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("更新设置失败:", error);
|
||||
alert("更新设置失败,请重试");
|
||||
toast.error("更新设置失败,请重试");
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -255,6 +329,93 @@ export default function SettingsPage(props: any = {}) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 知识库向量模型(平台级,仅管理员可修改;保存后立即生效) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>知识库向量模型</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用于知识库文档向量化与 RAG 检索。仅管理员可修改;保存后立即生效,无需重启。
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{embeddingLoading ? (
|
||||
<div className="text-center py-6 text-muted-foreground">加载中...</div>
|
||||
) : (
|
||||
<form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
|
||||
{embeddingError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{embeddingError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">类型</Label>
|
||||
<select
|
||||
value={embeddingForm.embedding_type}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({ ...embeddingForm, embedding_type: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-input rounded-md text-sm bg-background"
|
||||
>
|
||||
<option value="openai">OpenAI / 兼容 API</option>
|
||||
<option value="bge">BGE 本地</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">API 地址</Label>
|
||||
<Input
|
||||
value={embeddingForm.api_url}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({ ...embeddingForm, api_url: e.target.value })
|
||||
}
|
||||
placeholder="https://api.openai.com/v1 或兼容地址"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={embeddingForm.api_key}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({ ...embeddingForm, api_key: e.target.value })
|
||||
}
|
||||
placeholder={embeddingConfig?.api_key_masked ? "留空则不更新" : "输入 API Key"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">模型</Label>
|
||||
<Input
|
||||
value={embeddingForm.model}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({ ...embeddingForm, model: e.target.value })
|
||||
}
|
||||
placeholder="text-embedding-3-small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="customer_can_use_kb"
|
||||
checked={embeddingForm.customer_can_use_kb}
|
||||
onCheckedChange={(checked) =>
|
||||
setEmbeddingForm({
|
||||
...embeddingForm,
|
||||
customer_can_use_kb: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="customer_can_use_kb" className="text-sm cursor-pointer">
|
||||
开放知识库给客服使用(允许创建知识库、上传文档、对话中引用)
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" disabled={embeddingSubmitting}>
|
||||
{embeddingSubmitting ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 配置表单 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
@@ -92,7 +93,7 @@ export default function UsersPage(props: any = {}) {
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error("加载用户列表失败:", error);
|
||||
alert((error as Error).message || "加载用户列表失败");
|
||||
toast.error((error as Error).message || "加载用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -134,7 +135,7 @@ export default function UsersPage(props: any = {}) {
|
||||
return;
|
||||
}
|
||||
if (!createForm.username.trim() || !createForm.password.trim()) {
|
||||
alert("用户名和密码不能为空");
|
||||
toast.error("用户名和密码不能为空");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -142,9 +143,9 @@ export default function UsersPage(props: any = {}) {
|
||||
await createUser(createForm, agent.id);
|
||||
setCreateDialogOpen(false);
|
||||
await loadUsers();
|
||||
alert("创建成功");
|
||||
toast.success("创建成功");
|
||||
} catch (error) {
|
||||
alert((error as Error).message || "创建用户失败");
|
||||
toast.error((error as Error).message || "创建用户失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -173,9 +174,9 @@ export default function UsersPage(props: any = {}) {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
await loadUsers();
|
||||
alert("更新成功");
|
||||
toast.success("更新成功");
|
||||
} catch (error) {
|
||||
alert((error as Error).message || "更新用户失败");
|
||||
toast.error((error as Error).message || "更新用户失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -197,13 +198,13 @@ export default function UsersPage(props: any = {}) {
|
||||
return;
|
||||
}
|
||||
if (!passwordForm.new_password.trim()) {
|
||||
alert("新密码不能为空");
|
||||
toast.error("新密码不能为空");
|
||||
return;
|
||||
}
|
||||
// 如果修改的是当前用户,需要旧密码;如果是其他用户,不需要旧密码
|
||||
const isCurrentUser = selectedUser.id === agent.id;
|
||||
if (isCurrentUser && !passwordForm.old_password?.trim()) {
|
||||
alert("修改自己的密码需要提供旧密码");
|
||||
toast.error("修改自己的密码需要提供旧密码");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -217,9 +218,9 @@ export default function UsersPage(props: any = {}) {
|
||||
setPasswordDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
setPasswordForm({ old_password: "", new_password: "" });
|
||||
alert("密码更新成功");
|
||||
toast.success("密码更新成功");
|
||||
} catch (error) {
|
||||
alert((error as Error).message || "更新密码失败");
|
||||
toast.error((error as Error).message || "更新密码失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -242,9 +243,9 @@ export default function UsersPage(props: any = {}) {
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
await loadUsers();
|
||||
alert("删除成功");
|
||||
toast.success("删除成功");
|
||||
} catch (error) {
|
||||
alert((error as Error).message || "删除用户失败");
|
||||
toast.error((error as Error).message || "删除用户失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -19,14 +19,20 @@
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 217.2 91.2% 59.8%; /* 与 primary 颜色一致,使用蓝色而不是黑色 */
|
||||
--radius: 0.5rem;
|
||||
--ring: 217.2 91.2% 59.8%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* 增强视觉细节,让 Shadcn UI 迁移更明显 */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
/* Chatbase 风格 - 更大阴影(用于卡片 hover) */
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
/* 渐变背景变量(用于 Hero 等) */
|
||||
--gradient-primary: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.7) 100%);
|
||||
--gradient-hero: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.2) 100%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -82,10 +88,42 @@
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Chatbase 风格 - Hero 渐变背景动画 */
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 15s ease infinite;
|
||||
}
|
||||
|
||||
/* 卡片 hover 过渡(可与 Tailwind 类组合使用) */
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 - 更隐蔽、更现代 */
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import MatomoTracker from "@/components/MatomoTracker";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -42,6 +43,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
{MATOMO_CONTAINER_URL && <MatomoTracker containerUrl={MATOMO_CONTAINER_URL} />}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+257
-118
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
MessageSquare,
|
||||
Bot,
|
||||
@@ -26,6 +25,8 @@ import { ChatWidget } from "@/components/visitor/ChatWidget";
|
||||
import { FloatingButton } from "@/components/visitor/FloatingButton";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FadeIn, FadeInStagger, FadeInItem } from "@/components/ui/fade-in";
|
||||
import { stats, testimonials, partnerLogos } from "@/lib/stats-config";
|
||||
|
||||
/**
|
||||
* AI-CS 智能客服系统 - 产品官网首页
|
||||
@@ -70,222 +71,284 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
||||
{/* 顶部导航栏 */}
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
{/* Hero 区域 */}
|
||||
<section className="container mx-auto px-4 py-20 md:py-32">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<Badge className="mb-4" variant="secondary">
|
||||
AI 驱动的智能客服解决方案
|
||||
</Badge>
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-6 bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
AI-CS 智能客服系统
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-muted-foreground mb-8 max-w-2xl mx-auto">
|
||||
融合 AI 技术与人工客服,为企业提供高效、智能的客户服务解决方案
|
||||
{/* Hero - Chatbase 风格:简洁、留白、一句主标题 + 副标题 + CTA */}
|
||||
<section className="container mx-auto px-4 pt-20 pb-28 md:pt-28 md:pb-36 lg:pt-36 lg:pb-44 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-muted/30 to-transparent pointer-events-none" />
|
||||
<div className="max-w-3xl mx-auto text-center relative z-10">
|
||||
<p className="text-sm font-medium text-muted-foreground tracking-wide uppercase mb-4">
|
||||
AI 智能客服
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button size="lg" className="text-lg px-8" onClick={handleOpenChat}>
|
||||
立即体验
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-foreground tracking-tight mb-6 leading-[1.15]">
|
||||
让客户服务更简单、更高效
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-muted-foreground mb-10 max-w-xl mx-auto leading-relaxed">
|
||||
7×24 小时智能应答,AI 与人工无缝切换,释放团队时间专注更有价值的事
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto text-base px-8 py-6 rounded-xl shadow-sm hover:shadow transition-shadow"
|
||||
onClick={handleOpenChat}
|
||||
>
|
||||
免费试用
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline" className="text-lg px-8">
|
||||
<Link href="/agent/login">
|
||||
客服登录
|
||||
</Link>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto text-base px-8 py-6 rounded-xl border border-border hover:bg-muted/50"
|
||||
>
|
||||
<Link href="/agent/login">客服登录</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">无需信用卡,立即可用</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 信任数据 - 轻量展示 */}
|
||||
<section className="py-12 md:py-16 border-t border-border/50">
|
||||
<FadeIn>
|
||||
<div className="container mx-auto px-4">
|
||||
<p className="text-xs font-medium text-muted-foreground text-center mb-8 tracking-wide">
|
||||
深受企业信赖
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-4xl mx-auto">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-2xl md:text-3xl font-semibold text-foreground">{stat.value}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</section>
|
||||
|
||||
{/* Logo 墙 - 有数据时展示 */}
|
||||
{partnerLogos.length > 0 && (
|
||||
<section className="py-12 md:py-16 border-t border-border/50">
|
||||
<FadeIn>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-wrap items-center justify-center gap-10 md:gap-14 opacity-50">
|
||||
{partnerLogos.map((partner, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm text-muted-foreground font-medium hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{partner.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 核心功能 */}
|
||||
<section id="features" className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">核心功能</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
强大的功能组合,满足企业客户服务的各种需求
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<section id="features" className="container mx-auto px-4 py-20 md:py-28">
|
||||
<FadeIn>
|
||||
<div className="text-center mb-14 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">
|
||||
功能特性
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base max-w-xl mx-auto">
|
||||
专业、及时的服务体验,让每个客户都感受到高效与可靠
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 max-w-6xl mx-auto px-4">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Bot className="w-6 h-6 text-primary" />
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Bot className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>AI 智能客服</CardTitle>
|
||||
<CardTitle>7×24 小时智能应答</CardTitle>
|
||||
<CardDescription>
|
||||
支持多种 AI 模型,7x24 小时自动回复客户咨询
|
||||
让 AI 帮你处理常见问题,释放团队时间,专注于更有价值的工作
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
多厂商 AI 模型支持
|
||||
支持多种 AI 模型,选择最适合你的
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
智能对话理解
|
||||
智能理解客户意图,准确回答
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
自动上下文记忆
|
||||
记住对话上下文,体验更自然
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Users className="w-6 h-6 text-primary" />
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Users className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>人工客服协作</CardTitle>
|
||||
<CardTitle>无缝切换 AI 与人工</CardTitle>
|
||||
<CardDescription>
|
||||
无缝切换 AI 与人工模式,提供最佳服务体验
|
||||
复杂问题一键转人工,让客户随时得到最合适的帮助
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
实时消息同步
|
||||
消息实时同步,无缝衔接
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
在线状态显示
|
||||
清晰显示在线状态,客户一目了然
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
多客服协作支持
|
||||
多客服协作,高效处理复杂问题
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
|
||||
<MessageSquare className="w-6 h-6 text-primary" />
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<MessageSquare className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>实时通信</CardTitle>
|
||||
<CardTitle>消息即时到达</CardTitle>
|
||||
<CardDescription>
|
||||
WebSocket 实时消息推送,确保消息即时到达
|
||||
毫秒级响应,让客户感受到专业、及时的服务体验
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
双向实时通信
|
||||
双向实时通信,就像面对面交流
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
消息已读状态
|
||||
消息已读状态,沟通更透明
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
文件/图片传输
|
||||
支持文件图片,沟通更便捷
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Zap className="w-6 h-6 text-primary" />
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Zap className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>快速响应</CardTitle>
|
||||
<CardTitle>极速响应,稳定可靠</CardTitle>
|
||||
<CardDescription>
|
||||
优化的系统架构,确保低延迟、高并发
|
||||
毫秒级响应速度,轻松应对高并发,让服务永不中断
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
毫秒级响应
|
||||
毫秒级响应,客户无需等待
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
高并发支持
|
||||
高并发支持,业务增长无压力
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
自动重连机制
|
||||
自动重连,服务永不中断
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Shield className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>安全可靠</CardTitle>
|
||||
<CardTitle>企业级安全保障</CardTitle>
|
||||
<CardDescription>
|
||||
企业级安全保障,数据加密存储
|
||||
数据加密存储,权限精细管理,让您的业务数据安全无忧
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
API 密钥加密
|
||||
API 密钥加密,安全可靠
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
权限管理
|
||||
精细权限管理,灵活可控
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
数据备份
|
||||
自动数据备份,万无一失
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
|
||||
<BarChart3 className="w-6 h-6 text-primary" />
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<BarChart3 className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>数据统计</CardTitle>
|
||||
<CardTitle>数据驱动决策</CardTitle>
|
||||
<CardDescription>
|
||||
全面的数据分析和报表功能
|
||||
全面的数据分析,帮助您了解客户需求,优化服务质量
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
会话统计
|
||||
会话统计分析,洞察客户需求
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
客服工作量
|
||||
客服工作量统计,合理分配资源
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
响应时间分析
|
||||
响应时间分析,持续优化服务
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</FadeInItem>
|
||||
</FadeInStagger>
|
||||
</section>
|
||||
|
||||
{/* 界面展示 */}
|
||||
<section id="screenshots" className="container mx-auto px-4 py-16 md:py-24">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">界面展示</h2>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
直观易用的界面设计,提升工作效率
|
||||
</p>
|
||||
<FadeIn>
|
||||
<section id="screenshots" className="container mx-auto px-4 py-20 md:py-28 border-t border-border/50">
|
||||
<div className="text-center mb-14 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">界面展示</h2>
|
||||
<p className="text-muted-foreground text-base max-w-xl mx-auto">精心设计的界面,让管理更轻松</p>
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Tabs defaultValue="dashboard" className="w-full">
|
||||
@@ -349,113 +412,189 @@ export default function HomePage() {
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
</FadeIn>
|
||||
|
||||
{/* 客户评价 */}
|
||||
<section id="testimonials" className="container mx-auto px-4 py-12 md:py-16 lg:py-24">
|
||||
<FadeIn>
|
||||
<div className="text-center mb-8 md:mb-12 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-3 md:mb-4">客户评价</h2>
|
||||
<p className="text-muted-foreground text-base sm:text-lg max-w-2xl mx-auto">
|
||||
已有众多企业选择我们,让客户服务变得更简单
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 max-w-6xl mx-auto px-4">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<FadeInItem key={index}>
|
||||
<Card className="border-2 hover:border-primary/50 hover:scale-[1.02] hover:shadow-xl shadow-lg transition-all duration-300 h-full flex flex-col">
|
||||
<CardContent className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className="w-4 h-4 fill-yellow-400 text-yellow-400"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 flex-1 leading-relaxed">
|
||||
"{testimonial.content}"
|
||||
</p>
|
||||
<div className="flex items-center gap-3 pt-4 border-t">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{testimonial.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{testimonial.company}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
))}
|
||||
</FadeInStagger>
|
||||
</section>
|
||||
|
||||
{/* 常见问题 */}
|
||||
<section id="faq" className="container mx-auto px-4 py-16 md:py-24 bg-muted/30 rounded-3xl my-16">
|
||||
<section id="faq" className="container mx-auto px-4 py-20 md:py-28 border-t border-border/50">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">常见问题</h2>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
快速了解 AI-CS 智能客服系统
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<FadeIn>
|
||||
<div className="text-center mb-14 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">常见问题</h2>
|
||||
<p className="text-muted-foreground text-base max-w-xl mx-auto">快速了解 AI-CS,解答您的疑问</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="grid grid-cols-1 md:grid-cols-2 gap-4 px-4">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">AI-CS 支持哪些 AI 模型?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
AI-CS 支持多种 AI 大模型,包括 OpenAI、DeepSeek、百智云等主流平台,支持自定义 API 配置。
|
||||
支持 OpenAI、DeepSeek、百智云等主流 AI 平台,您可以选择最适合的模型,也可以自定义 API 配置,灵活便捷。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">如何实现 AI 和人工客服的切换?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
访客可以在聊天界面一键切换 AI 客服和人工客服模式,系统会自动处理会话转移。
|
||||
访客只需一键即可切换,复杂问题转人工,简单问题 AI 处理,系统会自动无缝衔接,让客户体验更流畅。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">系统支持文件上传吗?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
支持,访客和客服都可以上传图片和文件,支持图片预览和文件下载功能。
|
||||
完全支持!访客和客服都可以上传图片和文件,支持图片预览和文件下载,让沟通更直观、更高效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">如何集成到现有网站?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
提供网页挂件(Widget)功能,只需在网站中嵌入一段代码即可,支持自定义样式和位置。
|
||||
只需在网站中嵌入一段代码即可,支持自定义样式和位置,几分钟就能完成集成,无需复杂配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">系统支持多客服协作吗?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
支持,多个客服可以同时在线,系统支持会话分配、转接和协作功能。
|
||||
完全支持!多个客服可以同时在线,支持会话智能分配、一键转接和团队协作,让服务更高效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">数据安全如何保障?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
系统采用 API 密钥加密存储,支持权限管理,所有数据都经过安全加密处理。
|
||||
企业级安全保障,API 密钥加密存储,精细权限管理,所有数据都经过安全加密处理,让您放心使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</FadeInItem>
|
||||
</FadeInStagger>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 底部 CTA - Chatbase 风格 */}
|
||||
<section className="border-t border-border/50 py-20 md:py-28">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">
|
||||
让客户体验成为你的竞争力
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base mb-8 max-w-lg mx-auto">
|
||||
用 AI-CS 提供更专业、更高效的客户服务,拉开与竞品的差距
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-xl px-8 py-6 shadow-sm hover:shadow transition-shadow"
|
||||
onClick={handleOpenChat}
|
||||
>
|
||||
免费试用
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
<p className="mt-4 text-sm text-muted-foreground">无需信用卡,立即可用</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ interface ChatHeaderProps {
|
||||
unreadCount: number;
|
||||
onMarkAllRead: () => void;
|
||||
onRefresh: () => void;
|
||||
includeAIMessages?: boolean; // 是否包含 AI 消息
|
||||
onToggleAIMessages?: () => void; // 切换 AI 消息显示/隐藏
|
||||
includeAIMessages?: boolean;
|
||||
onToggleAIMessages?: () => void;
|
||||
soundEnabled?: boolean;
|
||||
onToggleSound?: () => void;
|
||||
hideAIToggle?: boolean; // 内部对话时隐藏「显示 AI 消息」切换
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
@@ -22,6 +25,9 @@ export function ChatHeader({
|
||||
onRefresh,
|
||||
includeAIMessages = false,
|
||||
onToggleAIMessages,
|
||||
soundEnabled = false,
|
||||
onToggleSound,
|
||||
hideAIToggle = false,
|
||||
}: ChatHeaderProps) {
|
||||
return (
|
||||
<div className="h-16 flex items-center justify-between px-4 bg-background flex-shrink-0 relative">
|
||||
@@ -34,8 +40,8 @@ export function ChatHeader({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 显示/隐藏 AI 消息切换按钮 */}
|
||||
{onToggleAIMessages && (
|
||||
{/* 显示/隐藏 AI 消息切换按钮(内部对话不显示,默认始终包含 AI 消息) */}
|
||||
{onToggleAIMessages && !hideAIToggle && (
|
||||
<Button
|
||||
variant={includeAIMessages ? "default" : "outline"}
|
||||
size="sm"
|
||||
@@ -46,6 +52,51 @@ export function ChatHeader({
|
||||
{includeAIMessages ? "隐藏 AI 消息" : "显示 AI 消息"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 声音开关按钮 */}
|
||||
{onToggleSound && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={soundEnabled ? "关闭声音提示" : "开启声音提示"}
|
||||
onClick={onToggleSound}
|
||||
>
|
||||
{soundEnabled ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export type ConversationFilter = "all" | "mine" | "others";
|
||||
|
||||
@@ -9,50 +10,64 @@ interface ConversationHeaderProps {
|
||||
onFilterChange: (filter: ConversationFilter) => void;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: { value: ConversationFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部对话" },
|
||||
{ value: "mine", label: "我的对话" },
|
||||
{ value: "others", label: "他人对话" },
|
||||
];
|
||||
|
||||
export function ConversationHeader({
|
||||
filter,
|
||||
onFilterChange,
|
||||
}: ConversationHeaderProps) {
|
||||
const getFilterLabel = (f: ConversationFilter) => {
|
||||
switch (f) {
|
||||
case "all":
|
||||
return "全部";
|
||||
case "mine":
|
||||
return "自己的";
|
||||
case "others":
|
||||
return "其他的";
|
||||
default:
|
||||
return "全部";
|
||||
}
|
||||
};
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentLabel = FILTER_OPTIONS.find((o) => o.value === filter)?.label ?? "全部对话";
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="h-16 flex items-center justify-between px-4 bg-background flex-shrink-0 relative">
|
||||
<div className="flex items-center gap-1 relative">
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => onFilterChange(e.target.value as ConversationFilter)}
|
||||
className="font-semibold text-foreground bg-transparent border-none outline-none cursor-pointer appearance-none pr-6 min-w-fit"
|
||||
<div className="h-14 flex items-center px-3 border-b border-border bg-background flex-shrink-0">
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border bg-background hover:bg-muted/50 text-sm font-medium text-foreground transition-colors min-w-0"
|
||||
>
|
||||
<option value="all">All chats</option>
|
||||
<option value="mine">My chats</option>
|
||||
<option value="others">Others chats</option>
|
||||
</select>
|
||||
<svg
|
||||
className="w-4 h-4 text-muted-foreground pointer-events-none flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
<span className="truncate">{currentLabel}</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted-foreground flex-shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1 py-1 rounded-lg border border-border bg-popover shadow-md z-50 min-w-[theme(spacing.32)]">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onFilterChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
|
||||
filter === opt.value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="absolute bottom-0 left-0 right-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ConversationListItem({
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
className={`p-4 mb-2 cursor-pointer transition-all select-none border-0 shadow-sm hover:shadow-md ${
|
||||
className={`p-4 mb-2 cursor-pointer transition-all select-none border border-border shadow-sm hover:shadow-md ${
|
||||
selected
|
||||
? "bg-primary/5 border-l-4 border-l-primary shadow-md"
|
||||
: "hover:bg-accent/50"
|
||||
@@ -91,9 +91,9 @@ export function ConversationListItem({
|
||||
)}
|
||||
<span className="truncate">{lastMessagePreview}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>访客 #{conversation.visitor_id}</span>
|
||||
<span>{formatConversationTime(conversation.updated_at)}</span>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground min-w-0">
|
||||
<span className="truncate">访客 #{conversation.visitor_id}</span>
|
||||
<span className="flex-shrink-0 whitespace-nowrap">{formatConversationTime(conversation.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ export function ConversationSearch({
|
||||
onChange,
|
||||
}: ConversationSearchProps) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="relative">
|
||||
<div className="p-4 min-w-0">
|
||||
<div className="relative min-w-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Q Search"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ConversationSummary } from "@/features/agent/types";
|
||||
import { ConversationHeader, type ConversationFilter } from "./ConversationHeader";
|
||||
import { ConversationSearch } from "./ConversationSearch";
|
||||
import { ConversationList } from "./ConversationList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface ConversationSidebarProps {
|
||||
conversations: ConversationSummary[];
|
||||
@@ -13,6 +15,9 @@ interface ConversationSidebarProps {
|
||||
onSelectConversation: (id: number) => void;
|
||||
filter: ConversationFilter;
|
||||
onFilterChange: (filter: ConversationFilter) => void;
|
||||
/** 内部对话(知识库测试)模式:显示「新建内部对话」按钮,隐藏筛选 */
|
||||
mode?: "visitor" | "internal";
|
||||
onNewClick?: () => void;
|
||||
}
|
||||
|
||||
export function ConversationSidebar({
|
||||
@@ -23,11 +28,27 @@ export function ConversationSidebar({
|
||||
onSelectConversation,
|
||||
filter,
|
||||
onFilterChange,
|
||||
mode = "visitor",
|
||||
onNewClick,
|
||||
}: ConversationSidebarProps) {
|
||||
return (
|
||||
<div className="w-80 bg-white border-r border-gray-200 flex flex-col min-h-0">
|
||||
<ConversationHeader filter={filter} onFilterChange={onFilterChange} />
|
||||
<ConversationSearch value={searchQuery} onChange={onSearchChange} />
|
||||
<div className="w-80 min-w-0 flex-1 flex flex-col bg-white border-r border-gray-200 min-h-0 overflow-hidden">
|
||||
{mode === "internal" ? (
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-border bg-background flex-shrink-0">
|
||||
<span className="text-sm font-medium text-foreground truncate">知识库测试</span>
|
||||
{onNewClick && (
|
||||
<Button size="sm" variant="outline" onClick={onNewClick} className="flex-shrink-0 gap-1">
|
||||
<Plus className="w-4 h-4" />
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ConversationHeader filter={filter} onFilterChange={onFilterChange} />
|
||||
)}
|
||||
<div className="flex-shrink-0 px-2 min-w-0">
|
||||
<ConversationSearch value={searchQuery} onChange={onSearchChange} />
|
||||
</div>
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
selectedConversationId={selectedConversationId}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/features/agent/hooks/useAuth";
|
||||
import { useConversations } from "@/features/agent/hooks/useConversations";
|
||||
import { useMessages } from "@/features/agent/hooks/useMessages";
|
||||
import { initInternalConversation } from "@/features/agent/services/conversationApi";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useProfile } from "@/features/agent/hooks/useProfile";
|
||||
import { Profile } from "@/features/agent/types";
|
||||
import { ResponsiveLayout } from "@/components/layout";
|
||||
import { LAYOUT } from "@/lib/constants/breakpoints";
|
||||
import {
|
||||
getPageFromSearchParams,
|
||||
getAgentPage,
|
||||
} from "@/lib/constants/agent-pages";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { ConversationSidebar } from "./ConversationSidebar";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
@@ -17,18 +24,17 @@ import { MessageList } from "./MessageList";
|
||||
import { NavigationSidebar, type NavigationPage } from "./NavigationSidebar";
|
||||
import { ProfileModal } from "./ProfileModal";
|
||||
import { VisitorDetailPanel } from "./VisitorDetailPanel";
|
||||
|
||||
// 动态导入其他页面组件
|
||||
const FAQsPage = dynamic(() => import("@/app/agent/faqs/page").then(mod => ({ default: mod.default })), { ssr: false });
|
||||
const UsersPage = dynamic(() => import("@/app/agent/users/page").then(mod => ({ default: mod.default })), { ssr: false });
|
||||
const SettingsPage = dynamic(() => import("@/app/agent/settings/page").then(mod => ({ default: mod.default })), { ssr: false });
|
||||
import { useSoundNotification } from "@/hooks/useSoundNotification";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
|
||||
export function DashboardShell() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const currentPage = getPageFromSearchParams(searchParams);
|
||||
|
||||
// 登录状态:负责从本地存储读取客服信息,并提供登出方法
|
||||
const { agent, loading: authLoading, logout } = useAuth();
|
||||
|
||||
// 页面状态管理(必须在所有其他 Hooks 之前声明,确保 Hooks 调用顺序一致)
|
||||
const [currentPage, setCurrentPage] = useState<NavigationPage>("dashboard");
|
||||
|
||||
// 个人资料状态
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
@@ -46,23 +52,38 @@ export function DashboardShell() {
|
||||
// 会话过滤状态
|
||||
const [conversationFilter, setConversationFilter] = useState<"all" | "mine" | "others">("all");
|
||||
|
||||
// 会话状态:包含会话列表、搜索关键字、选中的会话等
|
||||
// 声音通知开关(客服端)
|
||||
const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(false);
|
||||
|
||||
const currentPageMeta = getAgentPage(currentPage);
|
||||
const isInternalChat = currentPage === "internal-chat";
|
||||
const isChatPage = currentPageMeta?.isChatPage ?? false;
|
||||
// 会话状态:访客对话或内部对话(知识库测试)根据 currentPage 切换
|
||||
const {
|
||||
conversations,
|
||||
filteredConversations,
|
||||
selectedConversationId,
|
||||
searchQuery,
|
||||
loading,
|
||||
isInitialLoad,
|
||||
setSearchQuery,
|
||||
selectConversation,
|
||||
updateConversation,
|
||||
refresh: refreshConversations,
|
||||
hasConversation,
|
||||
} = useConversations({
|
||||
agentId: agent?.id ?? null, // 传递客服ID,用于建立全局 WebSocket 连接
|
||||
filter: conversationFilter, // 传递过滤类型
|
||||
});
|
||||
conversations,
|
||||
filteredConversations,
|
||||
selectedConversationId,
|
||||
searchQuery,
|
||||
loading,
|
||||
isInitialLoad,
|
||||
setSearchQuery,
|
||||
selectConversation,
|
||||
updateConversation,
|
||||
refresh: refreshConversations,
|
||||
hasConversation,
|
||||
} = useConversations({
|
||||
agentId: agent?.id ?? null,
|
||||
filter: conversationFilter,
|
||||
listType: isInternalChat ? "internal" : "visitor",
|
||||
});
|
||||
|
||||
// 计算总未读消息数
|
||||
const totalUnreadCount = useMemo(() => {
|
||||
return conversations.reduce((sum, conv) => sum + (conv.unread_count ?? 0), 0);
|
||||
}, [conversations]);
|
||||
|
||||
// 更新页面标题显示未读消息数
|
||||
usePageTitle(totalUnreadCount, "AI-CS");
|
||||
|
||||
// 输入框内容与搜索高亮关键字
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
@@ -90,12 +111,15 @@ export function DashboardShell() {
|
||||
updateContactInfo,
|
||||
includeAIMessages,
|
||||
toggleAIMessages,
|
||||
aiThinking,
|
||||
} = useMessages({
|
||||
conversationId: selectedConversationId,
|
||||
agentId: agent?.id ?? null,
|
||||
updateConversation,
|
||||
refreshConversations,
|
||||
hasConversation,
|
||||
soundEnabled,
|
||||
forceIncludeAIMessages: isInternalChat,
|
||||
});
|
||||
|
||||
// 左侧选择会话时,记录关键字用于消息高亮
|
||||
@@ -118,7 +142,7 @@ export function DashboardShell() {
|
||||
await sendMessage(content, fileInfo);
|
||||
setMessageInput("");
|
||||
} catch (error) {
|
||||
alert((error as Error).message);
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
}, [messageInput, sendMessage]);
|
||||
|
||||
@@ -162,14 +186,26 @@ export function DashboardShell() {
|
||||
[refreshProfile]
|
||||
);
|
||||
|
||||
// 处理导航切换(必须在所有条件返回之前声明)
|
||||
// 处理导航切换:更新 URL ?page=,与访客端路由一致,刷新后保留当前页
|
||||
const handleNavigate = useCallback((page: NavigationPage) => {
|
||||
setCurrentPage(page);
|
||||
// 如果切换到非 dashboard 页面,清空选中的对话
|
||||
if (page !== "dashboard") {
|
||||
router.push(pathname + "?page=" + page);
|
||||
if (page !== "dashboard" && page !== "internal-chat") {
|
||||
selectConversation(null);
|
||||
}
|
||||
}, [selectConversation]);
|
||||
}, [pathname, router, selectConversation]);
|
||||
|
||||
// 新建内部对话(知识库测试)- 必须在条件 return 之前声明,保证 Hooks 顺序一致
|
||||
const handleNewInternalConversation = useCallback(async () => {
|
||||
if (!agent?.id) return;
|
||||
try {
|
||||
const { conversation_id } = await initInternalConversation(agent.id);
|
||||
refreshConversations();
|
||||
selectConversation(conversation_id);
|
||||
} catch (e) {
|
||||
console.error("创建内部对话失败:", e);
|
||||
toast.error((e as Error).message || "创建内部对话失败");
|
||||
}
|
||||
}, [agent?.id, refreshConversations, selectConversation]);
|
||||
|
||||
if (authLoading || (loading && isInitialLoad)) {
|
||||
return (
|
||||
@@ -183,12 +219,9 @@ export function DashboardShell() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建侧边栏内容(包含导航栏和对话列表)
|
||||
// 在 dashboard 页面时,显示导航栏 + 对话列表
|
||||
// 在其他页面时,只显示导航栏
|
||||
const sidebarContent = currentPage === "dashboard" ? (
|
||||
const sidebarContent = isChatPage ? (
|
||||
<div className="flex h-full">
|
||||
<NavigationSidebar
|
||||
<NavigationSidebar
|
||||
currentPage={currentPage}
|
||||
onNavigate={handleNavigate}
|
||||
onProfileClick={() => setProfileModalOpen(true)}
|
||||
@@ -203,6 +236,8 @@ export function DashboardShell() {
|
||||
onSelectConversation={handleConversationSelect}
|
||||
filter={conversationFilter}
|
||||
onFilterChange={setConversationFilter}
|
||||
mode={isInternalChat ? "internal" : "visitor"}
|
||||
onNewClick={isInternalChat ? handleNewInternalConversation : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -217,10 +252,9 @@ export function DashboardShell() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 构建主内容区
|
||||
const mainContent = (
|
||||
<div className="flex-1 flex flex-col bg-background min-h-0">
|
||||
{currentPage === "dashboard" ? (
|
||||
{isChatPage ? (
|
||||
selectedConversationId ? (
|
||||
<>
|
||||
<ChatHeader
|
||||
@@ -231,6 +265,9 @@ export function DashboardShell() {
|
||||
onRefresh={handleRefreshChat}
|
||||
includeAIMessages={includeAIMessages}
|
||||
onToggleAIMessages={toggleAIMessages}
|
||||
soundEnabled={soundEnabled}
|
||||
onToggleSound={toggleSound}
|
||||
hideAIToggle={isInternalChat}
|
||||
/>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
@@ -240,6 +277,17 @@ export function DashboardShell() {
|
||||
currentUserIsAgent={true}
|
||||
conversationId={selectedConversationId ?? null}
|
||||
onMarkMessagesRead={markMessagesAsRead}
|
||||
internalChatMode={isInternalChat}
|
||||
bottomSlot={
|
||||
isInternalChat && aiThinking ? (
|
||||
<div className="flex justify-start mt-2">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-card border border-border/50 shadow-sm text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||
<span>AI 正在思考...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<MessageInput
|
||||
value={messageInput}
|
||||
@@ -251,20 +299,22 @@ export function DashboardShell() {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
选择一个对话开始聊天
|
||||
{isInternalChat ? "选择或新建内部对话,测试知识库效果" : "选择一个对话开始聊天"}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{currentPage === "faqs" && <FAQsPage embedded={true} />}
|
||||
{currentPage === "users" && <UsersPage embedded={true} />}
|
||||
{currentPage === "settings" && <SettingsPage embedded={true} />}
|
||||
{(() => {
|
||||
const PageComponent = currentPageMeta?.component;
|
||||
return PageComponent != null ? (
|
||||
<PageComponent embedded={true} />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 构建右侧面板(仅在 dashboard 页面且选中对话时显示)
|
||||
const rightPanelContent = currentPage === "dashboard" && selectedConversationId ? (
|
||||
<VisitorDetailPanel
|
||||
conversation={selectedConversation}
|
||||
@@ -280,7 +330,7 @@ export function DashboardShell() {
|
||||
sidebar={sidebarContent}
|
||||
main={mainContent}
|
||||
rightPanel={rightPanelContent}
|
||||
sidebarWidth={currentPage === "dashboard" ? undefined : LAYOUT.navigationWidth}
|
||||
sidebarWidth={isChatPage ? LAYOUT.dashboardSidebarWidth : LAYOUT.navigationWidth}
|
||||
/>
|
||||
|
||||
{/* 个人资料弹窗 */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
|
||||
import { X, Paperclip, Image as ImageIcon } from "lucide-react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
|
||||
interface MessageInputProps {
|
||||
value: string;
|
||||
@@ -57,7 +58,7 @@ export function MessageInput({
|
||||
// 验证文件大小(10MB)
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert("文件大小超过限制(最大10MB)");
|
||||
toast.error("文件大小超过限制(最大10MB)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ export function MessageInput({
|
||||
const ext = file.name.toLowerCase().split(".").pop();
|
||||
const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
|
||||
if (!ext || !allowedExts.includes(ext)) {
|
||||
alert("不支持的文件类型");
|
||||
toast.error("不支持的文件类型");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +178,7 @@ export function MessageInput({
|
||||
try {
|
||||
fileInfo = await uploadFile(filePreview.file, conversationId);
|
||||
} catch (error) {
|
||||
alert((error as Error).message || "文件上传失败");
|
||||
toast.error((error as Error).message || "文件上传失败");
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ interface MessageListProps {
|
||||
disableAutoScroll?: boolean;
|
||||
conversationId?: number | null;
|
||||
onMarkMessagesRead?: (conversationId: number, readerIsAgent: boolean) => void;
|
||||
/** 底部插槽(如 AI 正在输入提示),会渲染在消息列表最下方并参与滚动 */
|
||||
bottomSlot?: React.ReactNode;
|
||||
/** 知识库测试(内部对话)模式:AI 回复(sender_id=0)显示在左侧,客服消息显示在右侧 */
|
||||
internalChatMode?: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
@@ -30,6 +34,8 @@ export function MessageList({
|
||||
disableAutoScroll = false,
|
||||
conversationId = null,
|
||||
onMarkMessagesRead,
|
||||
bottomSlot,
|
||||
internalChatMode = false,
|
||||
}: MessageListProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
@@ -77,11 +83,11 @@ export function MessageList({
|
||||
markReadTimerRef.current = setTimeout(() => {
|
||||
// 检查是否有未读的消息(对方发送的消息)
|
||||
const unreadMessages = messages.filter((msg) => {
|
||||
// 对于客服端:检查访客发送的未读消息
|
||||
// 对于访客端:检查客服发送的未读消息
|
||||
const isFromOther = currentUserIsAgent
|
||||
? !msg.sender_is_agent
|
||||
: msg.sender_is_agent;
|
||||
const isFromOther = internalChatMode
|
||||
? msg.sender_is_agent && msg.sender_id === 0 // 内部对话:AI 回复视为对方
|
||||
: currentUserIsAgent
|
||||
? !msg.sender_is_agent
|
||||
: msg.sender_is_agent;
|
||||
return isFromOther && !msg.is_read;
|
||||
});
|
||||
|
||||
@@ -107,7 +113,7 @@ export function MessageList({
|
||||
clearTimeout(markReadTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [conversationId, onMarkMessagesRead, messages, currentUserIsAgent]);
|
||||
}, [conversationId, onMarkMessagesRead, messages, currentUserIsAgent, internalChatMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
@@ -174,8 +180,9 @@ export function MessageList({
|
||||
const shouldAutoScroll =
|
||||
isInitialLoad ||
|
||||
(hasNewMessage &&
|
||||
(isLastMessageFromCurrentUser || isNearBottom));
|
||||
|
||||
(isLastMessageFromCurrentUser ||
|
||||
isNearBottom ||
|
||||
(!currentUserIsAgent && !isLastMessageFromCurrentUser)));
|
||||
|
||||
if (keyword) {
|
||||
const keywordLower = keyword.toLowerCase();
|
||||
@@ -227,26 +234,35 @@ export function MessageList({
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果 scrollHeight === clientHeight,说明没有滚动条,强制设置高度
|
||||
// 这通常发生在 flex 布局中,子元素高度没有正确限制时
|
||||
if (container.scrollHeight === container.clientHeight && container.parentElement) {
|
||||
const parent = container.parentElement;
|
||||
const parentHeight = parent.offsetHeight;
|
||||
container.style.height = `${parentHeight}px`;
|
||||
container.style.maxHeight = `${parentHeight}px`;
|
||||
}
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
|
||||
});
|
||||
// 标记初始滚动已完成
|
||||
// 访客端收到对方(如 AI)的新消息时:从该气泡头部开始显示,长消息无需往上翻
|
||||
const lastMsgEl = messageRefs.current[lastMessage.id];
|
||||
if (
|
||||
lastMsgEl &&
|
||||
!currentUserIsAgent &&
|
||||
!isLastMessageFromCurrentUser
|
||||
) {
|
||||
lastMsgEl.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: isInitialLoad ? "auto" : "smooth",
|
||||
inline: "nearest",
|
||||
});
|
||||
} else {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: isInitialLoad ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
if (isInitialLoad) {
|
||||
hasInitialScrolledRef.current = true;
|
||||
}
|
||||
};
|
||||
setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
|
||||
setTimeout(scrollBottom, isInitialLoad ? 0 : 100);
|
||||
}
|
||||
|
||||
// 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
|
||||
@@ -264,9 +280,11 @@ export function MessageList({
|
||||
}
|
||||
|
||||
const unreadMessages = messages.filter((msg) => {
|
||||
const isFromOther = currentUserIsAgent
|
||||
? !msg.sender_is_agent
|
||||
: msg.sender_is_agent;
|
||||
const isFromOther = internalChatMode
|
||||
? msg.sender_is_agent && msg.sender_id === 0
|
||||
: currentUserIsAgent
|
||||
? !msg.sender_is_agent
|
||||
: msg.sender_is_agent;
|
||||
return isFromOther && !msg.is_read;
|
||||
});
|
||||
|
||||
@@ -318,6 +336,7 @@ export function MessageList({
|
||||
currentUserIsAgent,
|
||||
conversationId,
|
||||
onMarkMessagesRead,
|
||||
internalChatMode,
|
||||
]);
|
||||
|
||||
if (loading) {
|
||||
@@ -393,11 +412,13 @@ export function MessageList({
|
||||
);
|
||||
}
|
||||
|
||||
// 确保 sender_is_agent 是布尔值
|
||||
const isSenderAgent = Boolean(message.sender_is_agent);
|
||||
const isCurrentUser = currentUserIsAgent
|
||||
? isSenderAgent
|
||||
: !isSenderAgent;
|
||||
// 内部对话(知识库测试):AI 回复 sender_id=0 显示左侧,客服消息显示右侧
|
||||
const isCurrentUser = internalChatMode
|
||||
? isSenderAgent && message.sender_id !== 0
|
||||
: currentUserIsAgent
|
||||
? isSenderAgent
|
||||
: !isSenderAgent;
|
||||
const alignment = isCurrentUser ? "justify-end" : "justify-start";
|
||||
const bubbleColor = isCurrentUser
|
||||
? "bg-primary text-primary-foreground shadow-md"
|
||||
@@ -527,6 +548,7 @@ export function MessageList({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{bottomSlot}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,12 @@ import { useAuth } from "@/features/agent/hooks/useAuth";
|
||||
import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { websiteConfig } from "@/lib/website-config";
|
||||
import {
|
||||
AGENT_PAGES,
|
||||
type NavigationPage,
|
||||
} from "@/lib/constants/agent-pages";
|
||||
|
||||
export type NavigationPage = "dashboard" | "faqs" | "users" | "settings";
|
||||
export type { NavigationPage };
|
||||
|
||||
interface NavigationSidebarProps {
|
||||
currentPage?: NavigationPage;
|
||||
@@ -16,7 +20,7 @@ interface NavigationSidebarProps {
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export function NavigationSidebar({
|
||||
export function NavigationSidebar({
|
||||
currentPage = "dashboard",
|
||||
onNavigate,
|
||||
onProfileClick,
|
||||
@@ -26,17 +30,13 @@ export function NavigationSidebar({
|
||||
const { agent } = useAuth();
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 检查当前用户是否是管理员
|
||||
|
||||
const isAdmin = agent?.role === "admin";
|
||||
|
||||
const handleNavigate = (page: NavigationPage) => {
|
||||
if (onNavigate) {
|
||||
onNavigate(page);
|
||||
}
|
||||
onNavigate?.(page);
|
||||
};
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
@@ -53,255 +53,150 @@ export function NavigationSidebar({
|
||||
};
|
||||
}, [profileMenuOpen]);
|
||||
|
||||
// 头像相关
|
||||
const avatarColor = getAvatarColor(agent?.username || "");
|
||||
const displayInitial = getAvatarInitial(agent?.username || "");
|
||||
const fullAvatarUrl = getAvatarUrl(avatarUrl);
|
||||
|
||||
const visiblePages = AGENT_PAGES.filter(
|
||||
(p) => !p.adminOnly || (p.adminOnly && isAdmin)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-16 bg-gray-50 flex flex-col items-center py-4 border-r border-gray-200 h-full">
|
||||
<button
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
|
||||
currentPage === "dashboard"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
title="对话"
|
||||
onClick={() => handleNavigate("dashboard")}
|
||||
>
|
||||
<svg
|
||||
className={`w-6 h-6 ${
|
||||
currentPage === "dashboard" ? "text-white" : "text-gray-600"
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="w-10 h-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center mb-4 hover:bg-gray-100 transition-colors"
|
||||
title="知识库"
|
||||
disabled
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5s3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18s-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
|
||||
currentPage === "faqs"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
title="事件管理"
|
||||
onClick={() => handleNavigate("faqs")}
|
||||
>
|
||||
<svg
|
||||
className={`w-6 h-6 ${
|
||||
currentPage === "faqs" ? "text-white" : "text-gray-600"
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
|
||||
currentPage === "users"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
title="用户管理"
|
||||
onClick={() => handleNavigate("users")}
|
||||
>
|
||||
<svg
|
||||
className={`w-6 h-6 ${
|
||||
currentPage === "users" ? "text-white" : "text-gray-600"
|
||||
{visiblePages.map((page) => {
|
||||
const isActive = currentPage === page.id;
|
||||
const Icon = page.Icon;
|
||||
return (
|
||||
<button
|
||||
key={page.id}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
|
||||
isActive
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
title={page.title}
|
||||
onClick={() => handleNavigate(page.id as NavigationPage)}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
<Icon
|
||||
className={`w-6 h-6 ${
|
||||
isActive ? "text-white" : "text-gray-600"
|
||||
}`}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
|
||||
currentPage === "settings"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
title="AI 配置"
|
||||
onClick={() => handleNavigate("settings")}
|
||||
>
|
||||
<svg
|
||||
className={`w-6 h-6 ${
|
||||
currentPage === "settings" ? "text-white" : "text-gray-600"
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 个人资料按钮和 GitHub 按钮(固定在底部) */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2">
|
||||
{/* 个人资料按钮 */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
|
||||
profileMenuOpen
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
title="个人资料"
|
||||
onClick={() => setProfileMenuOpen(!profileMenuOpen)}
|
||||
>
|
||||
{fullAvatarUrl ? (
|
||||
<img
|
||||
src={fullAvatarUrl}
|
||||
alt={agent?.username || "用户"}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold"
|
||||
style={{ backgroundColor: avatarColor }}
|
||||
>
|
||||
{displayInitial}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
|
||||
profileMenuOpen
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-white border border-gray-200 hover:bg-gray-100"
|
||||
}`}
|
||||
title="个人资料"
|
||||
onClick={() => setProfileMenuOpen(!profileMenuOpen)}
|
||||
>
|
||||
{fullAvatarUrl ? (
|
||||
<img
|
||||
src={fullAvatarUrl}
|
||||
alt={agent?.username || "用户"}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold"
|
||||
style={{ backgroundColor: avatarColor }}
|
||||
>
|
||||
{displayInitial}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{profileMenuOpen && (
|
||||
<div className="absolute bottom-12 left-0 w-64 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
|
||||
{/* 用户信息 */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{fullAvatarUrl ? (
|
||||
<img
|
||||
src={fullAvatarUrl}
|
||||
alt={agent?.username || "用户"}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-white text-sm font-semibold"
|
||||
style={{ backgroundColor: avatarColor }}
|
||||
>
|
||||
{displayInitial}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{agent?.username || "用户"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{agent?.role === "admin" ? "管理员" : "客服"}
|
||||
{profileMenuOpen && (
|
||||
<div className="absolute bottom-12 left-0 w-64 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{fullAvatarUrl ? (
|
||||
<img
|
||||
src={fullAvatarUrl}
|
||||
alt={agent?.username || "用户"}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-white text-sm font-semibold"
|
||||
style={{ backgroundColor: avatarColor }}
|
||||
>
|
||||
{displayInitial}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{agent?.username || "用户"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{agent?.role === "admin" ? "管理员" : "客服"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setProfileMenuOpen(false);
|
||||
onProfileClick?.();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setProfileMenuOpen(false);
|
||||
onProfileClick?.();
|
||||
}}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
个人资料
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setProfileMenuOpen(false);
|
||||
onLogout?.();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
个人资料
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setProfileMenuOpen(false);
|
||||
onLogout?.();
|
||||
}}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
退出登录
|
||||
</Button>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GitHub 链接按钮(个人资料下方) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -327,4 +222,3 @@ export function NavigationSidebar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,76 +11,56 @@ import { websiteConfig } from "@/lib/website-config";
|
||||
*/
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-md">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo 和品牌名称 */}
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-lg">AI</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
AI-CS
|
||||
</span>
|
||||
<div className="flex h-14 md:h-16 items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-semibold text-sm">AI</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-foreground tracking-tight">AI-CS</span>
|
||||
</Link>
|
||||
|
||||
{/* 右侧:导航链接和操作按钮 */}
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* 导航链接 */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
<div className="flex items-center gap-6 md:gap-8">
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link
|
||||
href="#features"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
功能特性
|
||||
</Link>
|
||||
<Link
|
||||
href="#screenshots"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
界面展示
|
||||
</Link>
|
||||
<Link
|
||||
href="#faq"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
常见问题
|
||||
</Link>
|
||||
<Link
|
||||
href="/agent/login"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
客服登录
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* GitHub 链接 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<Button variant="ghost" size="sm" asChild className="hidden sm:flex text-muted-foreground hover:text-foreground">
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
{/* 移动端 GitHub 图标按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
className="sm:hidden"
|
||||
>
|
||||
<Button variant="ghost" size="icon" asChild className="sm:hidden text-muted-foreground hover:text-foreground">
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
target="_blank"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-sm hover:shadow-md active:scale-[0.98]",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-sm hover:shadow-md hover:scale-105 active:scale-95",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm transition-all duration-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface FadeInProps {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeIn({ children, delay = 0, className = "" }: FadeInProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.6, delay, ease: "easeOut" }}
|
||||
className={className}
|
||||
style={{ willChange: "opacity, transform" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FadeInStaggerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeInStagger({ children, className = "" }: FadeInStaggerProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FadeInItemProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeInItem({ children, className = "" }: FadeInItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className={className}
|
||||
style={{ willChange: "opacity, transform" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
success:
|
||||
"border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,9 @@ import { fetchOnlineAgents } from "@/features/visitor/services/visitorApi";
|
||||
import { fetchPublicAIModels } from "@/features/agent/services/aiConfigApi";
|
||||
import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
|
||||
import type { WSMessage } from "@/lib/websocket";
|
||||
import { useSoundNotification } from "@/hooks/useSoundNotification";
|
||||
import { playNotificationSound } from "@/utils/sound";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ChatWidgetProps {
|
||||
visitorId: number;
|
||||
@@ -82,6 +85,11 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
>([]);
|
||||
const [onlineAgents, setOnlineAgents] = useState<OnlineAgent[]>([]);
|
||||
const [loadingAgents, setLoadingAgents] = useState(false);
|
||||
/** AI 模式下发消息后等待回复时显示「正在输入」提示 */
|
||||
const [aiTyping, setAiTyping] = useState(false);
|
||||
|
||||
// 声音通知开关(访客端)
|
||||
const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(true);
|
||||
|
||||
const noopHighlight = useCallback(() => {}, []);
|
||||
|
||||
@@ -216,28 +224,27 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
[conversationId]
|
||||
);
|
||||
|
||||
// 拉取历史消息
|
||||
// 拉取历史消息(AI 模式时包含 AI 对话记录,人工模式时仅人工消息)
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
const data = await fetchMessages(conversationId);
|
||||
const includeAIMessages = chatMode === "ai";
|
||||
const data = await fetchMessages(conversationId, includeAIMessages);
|
||||
const normalizedMessages = data.map((msg) => ({
|
||||
...msg,
|
||||
is_read: msg.is_read ?? false,
|
||||
read_at: msg.read_at ?? null,
|
||||
}));
|
||||
// 刷新消息列表时,移除所有临时消息(ID 大于 1000000000000 的消息是临时消息)
|
||||
// 临时消息使用 Date.now() 作为 ID,真实消息的 ID 通常较小
|
||||
setMessages(normalizedMessages);
|
||||
} catch (error) {
|
||||
console.error("拉取消息失败:", error);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, [conversationId]);
|
||||
}, [conversationId, chatMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && conversationId) {
|
||||
@@ -252,6 +259,12 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
if (!conversationId || message.conversation_id !== conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是客服发送的消息(不是访客自己发送的),播放提示音
|
||||
if (message.sender_is_agent) {
|
||||
playNotificationSound(soundEnabled);
|
||||
}
|
||||
|
||||
setMessages((prev) => {
|
||||
// 检查是否已存在相同ID的消息(真实消息)
|
||||
const exists = prev.some((item) => item.id === message.id);
|
||||
@@ -382,7 +395,12 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "new_message" && event.data) {
|
||||
handleNewMessage(event.data as MessageItem);
|
||||
const msg = event.data as MessageItem;
|
||||
handleNewMessage(msg);
|
||||
// AI 模式下收到对方(客服/AI)回复时关闭「正在输入」提示
|
||||
if (chatMode === "ai" && msg.sender_is_agent) {
|
||||
setAiTyping(false);
|
||||
}
|
||||
} else if (event.type === "messages_read") {
|
||||
const payload = event.data as MessagesReadPayload;
|
||||
if (!payload.conversation_id && event.conversation_id) {
|
||||
@@ -390,9 +408,9 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
}
|
||||
handleMessagesReadEvent(payload);
|
||||
}
|
||||
},
|
||||
[handleMessagesReadEvent, handleNewMessage]
|
||||
);
|
||||
},
|
||||
[handleMessagesReadEvent, handleNewMessage, soundEnabled, chatMode]
|
||||
);
|
||||
|
||||
useWebSocket<ChatWebSocketPayload>({
|
||||
conversationId,
|
||||
@@ -436,7 +454,10 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
setMessages((prev) => [...prev, tempMessage]);
|
||||
setInput("");
|
||||
setSending(true);
|
||||
|
||||
if (chatMode === "ai") {
|
||||
setAiTyping(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
conversationId,
|
||||
@@ -455,6 +476,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
} catch (error) {
|
||||
// 发送失败,移除临时消息
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== tempMessage.id));
|
||||
if (chatMode === "ai") setAiTyping(false);
|
||||
console.error("❌ 发送消息失败:", error);
|
||||
alert((error as Error).message || "发送消息失败,请稍后重试");
|
||||
// 恢复输入内容
|
||||
@@ -493,29 +515,76 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-white">客服聊天</h2>
|
||||
</div>
|
||||
{/* GitHub 链接按钮(替换关闭按钮) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
|
||||
aria-label="GitHub"
|
||||
title="查看 GitHub 仓库"
|
||||
>
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 声音开关按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleSound}
|
||||
className="text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
|
||||
aria-label={soundEnabled ? "关闭声音" : "开启声音"}
|
||||
title={soundEnabled ? "关闭声音提示" : "开启声音提示"}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{soundEnabled ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
{/* GitHub 链接按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
|
||||
aria-label="GitHub"
|
||||
title="查看 GitHub 仓库"
|
||||
>
|
||||
<a
|
||||
href={websiteConfig.github.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Button>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式切换和在线客服列表 */}
|
||||
@@ -586,7 +655,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-hidden min-h-0 bg-gradient-to-b from-background to-muted/20">
|
||||
<MessageList
|
||||
key={`messages-${conversationId}`} // 简化 key,只使用 conversationId,避免不必要的重新挂载
|
||||
key={`messages-${conversationId}`}
|
||||
messages={messages}
|
||||
loading={loadingMessages}
|
||||
highlightKeyword=""
|
||||
@@ -595,6 +664,16 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
||||
disableAutoScroll={false}
|
||||
conversationId={conversationId}
|
||||
onMarkMessagesRead={handleMarkAgentMessagesRead}
|
||||
bottomSlot={
|
||||
chatMode === "ai" && aiTyping ? (
|
||||
<div className="flex justify-start mt-2">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-card border border-border/50 shadow-sm text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
||||
<span>AI 正在思考...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fetchConversations,
|
||||
searchConversations,
|
||||
} from "../../agent/services/conversationApi";
|
||||
import type { ConversationListType } from "../../agent/services/conversationApi";
|
||||
import { ConversationSummary, VisitorStatusUpdatePayload } from "../../agent/types";
|
||||
import { useWebSocket } from "./useWebSocket";
|
||||
import { WSMessage } from "@/lib/websocket";
|
||||
@@ -20,12 +21,14 @@ const sortByUpdatedAtDesc = (list: ConversationSummary[]) =>
|
||||
import type { ConversationFilter } from "@/components/dashboard/ConversationHeader";
|
||||
|
||||
interface UseConversationsOptions {
|
||||
agentId?: number | null; // 客服ID(用于建立 WebSocket 连接接收全局事件)
|
||||
filter?: ConversationFilter; // 会话过滤类型
|
||||
agentId?: number | null;
|
||||
filter?: ConversationFilter;
|
||||
/** 内部对话(知识库测试)时传 "internal",默认访客对话 "visitor" */
|
||||
listType?: ConversationListType;
|
||||
}
|
||||
|
||||
export function useConversations(options?: UseConversationsOptions) {
|
||||
const { agentId, filter = "all" } = options || {};
|
||||
const { agentId, filter = "all", listType = "visitor" } = options || {};
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [filteredConversations, setFilteredConversations] = useState<
|
||||
ConversationSummary[]
|
||||
@@ -65,9 +68,9 @@ export function useConversations(options?: UseConversationsOptions) {
|
||||
const loadConversations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchConversations(agentId ?? undefined);
|
||||
const data = await fetchConversations(agentId ?? undefined, listType === "internal" ? { type: "internal" } : undefined);
|
||||
setConversations(data);
|
||||
const filtered = applyFilter(data);
|
||||
const filtered = listType === "internal" ? data : applyFilter(data);
|
||||
if (!searchRef.current.trim()) {
|
||||
setFilteredConversations(filtered);
|
||||
}
|
||||
@@ -83,20 +86,20 @@ export function useConversations(options?: UseConversationsOptions) {
|
||||
setLoading(false);
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
}, [applyFilter, agentId, filter]);
|
||||
}, [applyFilter, agentId, filter, listType]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, [loadConversations]);
|
||||
|
||||
// 当 filter 改变时,重新应用过滤(不重新加载数据)
|
||||
// 当 filter / listType 改变时,重新应用过滤(不重新加载数据)
|
||||
useEffect(() => {
|
||||
if (isInitialLoad) {
|
||||
return;
|
||||
}
|
||||
const filtered = applyFilter(conversations);
|
||||
const filtered = listType === "internal" ? conversations : applyFilter(conversations);
|
||||
setFilteredConversations(sortByUpdatedAtDesc(filtered));
|
||||
}, [filter, conversations, isInitialLoad, applyFilter]);
|
||||
}, [filter, listType, conversations, isInitialLoad, applyFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoad) {
|
||||
@@ -106,10 +109,15 @@ export function useConversations(options?: UseConversationsOptions) {
|
||||
const query = searchQuery.trim();
|
||||
searchRef.current = query;
|
||||
if (!query) {
|
||||
const filtered = applyFilter(conversations);
|
||||
const filtered = listType === "internal" ? conversations : applyFilter(conversations);
|
||||
setFilteredConversations(sortByUpdatedAtDesc(filtered));
|
||||
return;
|
||||
}
|
||||
if (listType === "internal") {
|
||||
setFilteredConversations(sortByUpdatedAtDesc(conversations.filter((c) => (c.last_message?.content ?? "").toLowerCase().includes(query.toLowerCase()))));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await searchConversations(query, agentId ?? undefined);
|
||||
@@ -124,7 +132,7 @@ export function useConversations(options?: UseConversationsOptions) {
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery, conversations, isInitialLoad, applyFilter, agentId]);
|
||||
}, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType]);
|
||||
|
||||
const selectConversation = useCallback((conversationId: number | null) => {
|
||||
setSelectedConversationId((prev) =>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { useWebSocket } from "./useWebSocket";
|
||||
import { WSMessage } from "@/lib/websocket";
|
||||
import { buildMessagePreview } from "@/utils/format";
|
||||
import { playNotificationSound } from "@/utils/sound";
|
||||
|
||||
interface UseMessagesOptions {
|
||||
conversationId: number | null;
|
||||
@@ -33,8 +34,11 @@ interface UseMessagesOptions {
|
||||
updater: (conversation: ConversationSummary) => ConversationSummary,
|
||||
options?: { skipResort?: boolean }
|
||||
) => void;
|
||||
refreshConversations?: () => void; // 刷新对话列表(用于新对话的情况)
|
||||
hasConversation?: (conversationId: number) => boolean; // 检查对话是否存在
|
||||
refreshConversations?: () => void;
|
||||
hasConversation?: (conversationId: number) => boolean;
|
||||
soundEnabled?: boolean;
|
||||
/** 内部对话(知识库测试)时强制包含 AI 消息 */
|
||||
forceIncludeAIMessages?: boolean;
|
||||
}
|
||||
|
||||
export function useMessages({
|
||||
@@ -43,18 +47,21 @@ export function useMessages({
|
||||
updateConversation,
|
||||
refreshConversations,
|
||||
hasConversation,
|
||||
soundEnabled = false,
|
||||
forceIncludeAIMessages = false,
|
||||
}: UseMessagesOptions) {
|
||||
// 消息列表、请求状态、访客详情等基础状态
|
||||
const [messages, setMessages] = useState<MessageItem[]>([]);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [conversationDetail, setConversationDetail] =
|
||||
useState<ConversationDetail | null>(null);
|
||||
const [includeAIMessages, setIncludeAIMessages] = useState(false); // 是否包含 AI 消息(默认不包含)
|
||||
const [includeAIMessages, setIncludeAIMessages] = useState(forceIncludeAIMessages);
|
||||
/** 内部对话(知识库测试)下发消息后等待 AI 回复时显示「正在思考」(与访客小窗逻辑一致) */
|
||||
const [aiThinking, setAiThinking] = useState(false);
|
||||
|
||||
const refreshConversationDetail = useCallback(
|
||||
async (id: number) => {
|
||||
const detail = await fetchConversationDetail(id);
|
||||
const detail = await fetchConversationDetail(id, agentId ?? undefined);
|
||||
setConversationDetail(detail);
|
||||
// 同时更新对话列表中的 last_seen_at(用于判断在线状态)
|
||||
if (detail) {
|
||||
@@ -159,20 +166,21 @@ export function useMessages({
|
||||
[updateConversation]
|
||||
);
|
||||
|
||||
const effectiveIncludeAIMessages = forceIncludeAIMessages || includeAIMessages;
|
||||
const loadMessages = useCallback(
|
||||
async (id: number, includeAI: boolean = includeAIMessages) => {
|
||||
async (id: number, includeAI?: boolean) => {
|
||||
const include = includeAI ?? effectiveIncludeAIMessages;
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
const data = await fetchMessages(id, includeAI);
|
||||
const data = await fetchMessages(id, include);
|
||||
setMessages(data);
|
||||
// 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
|
||||
} catch (error) {
|
||||
console.error("拉取消息失败:", error);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
},
|
||||
[includeAIMessages]
|
||||
[effectiveIncludeAIMessages]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,9 +189,9 @@ export function useMessages({
|
||||
setConversationDetail(null);
|
||||
return;
|
||||
}
|
||||
loadMessages(conversationId, includeAIMessages);
|
||||
loadMessages(conversationId, effectiveIncludeAIMessages);
|
||||
refreshConversationDetail(conversationId);
|
||||
}, [conversationId, agentId, includeAIMessages, loadMessages, refreshConversationDetail]);
|
||||
}, [conversationId, agentId, effectiveIncludeAIMessages, loadMessages, refreshConversationDetail]);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string, fileInfo?: { file_url: string; file_type: string; file_name: string; file_size: number; mime_type: string }) => {
|
||||
@@ -195,6 +203,9 @@ export function useMessages({
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
if (forceIncludeAIMessages) {
|
||||
setAiThinking(true);
|
||||
}
|
||||
try {
|
||||
await sendMessage({
|
||||
conversationId,
|
||||
@@ -208,16 +219,24 @@ export function useMessages({
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (forceIncludeAIMessages) {
|
||||
setAiThinking(false);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[agentId, conversationId, sending]
|
||||
[agentId, conversationId, sending, forceIncludeAIMessages]
|
||||
);
|
||||
|
||||
const handleNewMessage = useCallback(
|
||||
(message: MessageItem) => {
|
||||
// 如果是访客发送的消息(不是客服自己发送的),播放提示音
|
||||
if (!message.sender_is_agent && soundEnabled) {
|
||||
playNotificationSound(soundEnabled);
|
||||
}
|
||||
|
||||
// 检查对话是否存在
|
||||
const conversationExists = hasConversation
|
||||
? hasConversation(message.conversation_id)
|
||||
@@ -269,12 +288,12 @@ export function useMessages({
|
||||
// 根据 includeAIMessages 状态过滤 AI 消息
|
||||
// 如果隐藏 AI 消息(includeAIMessages === false)且消息的 chat_mode === "ai",则不添加到消息列表
|
||||
const messageChatMode = message.chat_mode || "human"; // 兼容历史数据,默认为 human
|
||||
const shouldHideAIMessage = !includeAIMessages && messageChatMode === "ai";
|
||||
const shouldHideAIMessage = !effectiveIncludeAIMessages && messageChatMode === "ai";
|
||||
|
||||
setMessages((prev) => {
|
||||
const exists = prev.some((item) => item.id === message.id);
|
||||
if (exists) {
|
||||
// 消息已存在,需要根据 includeAIMessages 决定是否保留
|
||||
// 消息已存在,需要根据 effectiveIncludeAIMessages 决定是否保留
|
||||
if (shouldHideAIMessage) {
|
||||
// 如果应该隐藏 AI 消息,则从列表中移除
|
||||
return prev.filter((msg) => msg.id !== message.id);
|
||||
@@ -301,10 +320,18 @@ export function useMessages({
|
||||
return [...prev, message];
|
||||
});
|
||||
|
||||
// 内部对话(知识库测试):收到 AI 回复时关闭「正在思考」(与访客小窗一致:收到对方回复即关闭)
|
||||
if (forceIncludeAIMessages && message.conversation_id === conversationId) {
|
||||
const msgChatMode = message.chat_mode || "human";
|
||||
if (msgChatMode === "ai") {
|
||||
setAiThinking(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
|
||||
// 不再调用 refreshConversationDetail,避免不必要的重新加载和状态丢失
|
||||
},
|
||||
[conversationId, updateConversation, refreshConversations, hasConversation, includeAIMessages]
|
||||
[conversationId, updateConversation, refreshConversations, hasConversation, effectiveIncludeAIMessages, soundEnabled, forceIncludeAIMessages]
|
||||
);
|
||||
|
||||
const handleMessagesReadBroadcast = useCallback(
|
||||
@@ -491,8 +518,10 @@ export function useMessages({
|
||||
sendMessage: handleSendMessage,
|
||||
markMessagesAsRead: handleMarkMessagesRead,
|
||||
updateContactInfo,
|
||||
includeAIMessages,
|
||||
includeAIMessages: effectiveIncludeAIMessages,
|
||||
toggleAIMessages,
|
||||
forceIncludeAIMessages,
|
||||
aiThinking,
|
||||
}),
|
||||
[
|
||||
conversationDetail,
|
||||
@@ -504,8 +533,10 @@ export function useMessages({
|
||||
refreshConversationDetail,
|
||||
sending,
|
||||
updateContactInfo,
|
||||
includeAIMessages,
|
||||
effectiveIncludeAIMessages,
|
||||
toggleAIMessages,
|
||||
forceIncludeAIMessages,
|
||||
aiThinking,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ import {
|
||||
ConversationSummary,
|
||||
} from "../types";
|
||||
|
||||
export async function fetchConversations(userId?: number): Promise<ConversationSummary[]> {
|
||||
const url = userId
|
||||
? `${API_BASE_URL}/conversations?user_id=${userId}`
|
||||
: `${API_BASE_URL}/conversations`;
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
});
|
||||
export type ConversationListType = "visitor" | "internal";
|
||||
|
||||
export async function fetchConversations(
|
||||
userId?: number,
|
||||
opts?: { type?: ConversationListType }
|
||||
): Promise<ConversationSummary[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set("user_id", String(userId));
|
||||
if (opts?.type) params.set("type", opts.type);
|
||||
const url = `${API_BASE_URL}/conversations?${params.toString()}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error("获取对话列表失败");
|
||||
}
|
||||
@@ -25,6 +29,20 @@ export async function fetchConversations(userId?: number): Promise<ConversationS
|
||||
}));
|
||||
}
|
||||
|
||||
/** 创建一条内部对话(知识库测试),返回新对话 ID */
|
||||
export async function initInternalConversation(userId: number): Promise<{ conversation_id: number }> {
|
||||
const res = await fetch(`${API_BASE_URL}/conversations/internal?user_id=${userId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error || "创建内部对话失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
return { conversation_id: data.conversation_id };
|
||||
}
|
||||
|
||||
export async function searchConversations(
|
||||
query: string,
|
||||
userId?: number
|
||||
@@ -50,11 +68,13 @@ export async function searchConversations(
|
||||
}
|
||||
|
||||
export async function fetchConversationDetail(
|
||||
conversationId: number
|
||||
conversationId: number,
|
||||
userId?: number
|
||||
): Promise<ConversationDetail | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/conversations/${conversationId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const url = userId
|
||||
? `${API_BASE_URL}/conversations/${conversationId}?user_id=${userId}`
|
||||
: `${API_BASE_URL}/conversations/${conversationId}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { API_BASE_URL, getAgentHeaders } from "@/lib/config";
|
||||
|
||||
// 文档摘要信息
|
||||
export interface Document {
|
||||
id: number;
|
||||
knowledge_base_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
type: string;
|
||||
status: string;
|
||||
embedding_status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 创建文档请求
|
||||
export interface CreateDocumentRequest {
|
||||
knowledge_base_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// 更新文档请求
|
||||
export interface UpdateDocumentRequest {
|
||||
title?: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// 文档列表结果
|
||||
export interface DocumentListResult {
|
||||
documents: Document[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_page: number;
|
||||
}
|
||||
|
||||
// 获取文档列表
|
||||
export async function fetchDocuments(
|
||||
knowledgeBaseId?: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
keyword?: string,
|
||||
status?: string
|
||||
): Promise<DocumentListResult> {
|
||||
let url = `${API_BASE_URL}/documents?page=${page}&page_size=${pageSize}`;
|
||||
if (knowledgeBaseId) {
|
||||
url += `&knowledge_base_id=${knowledgeBaseId}`;
|
||||
}
|
||||
if (keyword) {
|
||||
url += `&keyword=${encodeURIComponent(keyword)}`;
|
||||
}
|
||||
if (status) {
|
||||
url += `&status=${encodeURIComponent(status)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("获取文档列表失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 获取文档详情
|
||||
export async function fetchDocument(id: number): Promise<Document> {
|
||||
const res = await fetch(`${API_BASE_URL}/documents/${id}`, {
|
||||
cache: "no-store",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error("文档不存在");
|
||||
}
|
||||
throw new Error("获取文档详情失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 创建文档
|
||||
export async function createDocument(data: CreateDocumentRequest): Promise<Document> {
|
||||
const res = await fetch(`${API_BASE_URL}/documents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "创建文档失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 更新文档
|
||||
export async function updateDocument(
|
||||
id: number,
|
||||
data: UpdateDocumentRequest
|
||||
): Promise<Document> {
|
||||
const res = await fetch(`${API_BASE_URL}/documents/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...getAgentHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
if (res.status === 404) {
|
||||
throw new Error("文档不存在");
|
||||
}
|
||||
throw new Error(error.error || "更新文档失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 删除文档
|
||||
export async function deleteDocument(id: number): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/documents/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
if (res.status === 404) {
|
||||
throw new Error("文档不存在");
|
||||
}
|
||||
throw new Error(error.error || "删除文档失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文档状态
|
||||
export async function updateDocumentStatus(id: number, status: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/documents/${id}/status`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...getAgentHeaders() },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "更新文档状态失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 发布文档
|
||||
export async function publishDocument(id: number): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/documents/${id}/publish`, {
|
||||
method: "POST",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "发布文档失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 取消发布文档
|
||||
export async function unpublishDocument(id: number): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/documents/${id}/unpublish`, {
|
||||
method: "POST",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "取消发布文档失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索文档(向量检索)
|
||||
export async function searchDocuments(
|
||||
query: string,
|
||||
topK: number = 5,
|
||||
knowledgeBaseId?: number
|
||||
): Promise<Document[]> {
|
||||
let url = `${API_BASE_URL}/documents/search?query=${encodeURIComponent(query)}&top_k=${topK}`;
|
||||
if (knowledgeBaseId) {
|
||||
url += `&knowledge_base_id=${knowledgeBaseId}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "搜索文档失败");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.documents || [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
|
||||
// 知识库向量配置(API 返回,不含明文 API Key)
|
||||
export interface EmbeddingConfig {
|
||||
id?: number;
|
||||
embedding_type: string;
|
||||
api_url: string;
|
||||
api_key_masked?: string;
|
||||
model: string;
|
||||
customer_can_use_kb: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 更新入参(api_key 可选,不传则保留原密钥)
|
||||
export interface UpdateEmbeddingConfigRequest {
|
||||
embedding_type?: string;
|
||||
api_url?: string;
|
||||
api_key?: string;
|
||||
model?: string;
|
||||
customer_can_use_kb?: boolean;
|
||||
}
|
||||
|
||||
/** 获取当前知识库向量配置(需传 user_id 以通过代理) */
|
||||
export async function fetchEmbeddingConfig(userId: number): Promise<EmbeddingConfig> {
|
||||
const res = await fetch(`${API_BASE_URL}/agent/embedding-config?user_id=${userId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("获取知识库向量配置失败");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 更新知识库向量配置(仅管理员);修改后需重启后端生效 */
|
||||
export async function updateEmbeddingConfig(
|
||||
userId: number,
|
||||
data: UpdateEmbeddingConfigRequest
|
||||
): Promise<EmbeddingConfig> {
|
||||
const res = await fetch(`${API_BASE_URL}/agent/embedding-config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: userId, ...data }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "更新知识库向量配置失败");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { API_BASE_URL, getAgentHeaders } from "@/lib/config";
|
||||
|
||||
// 导入结果
|
||||
export interface ImportResult {
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
failed_files: string[];
|
||||
errors: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// 导入文档(文件上传)
|
||||
export async function importDocuments(
|
||||
knowledgeBaseId: number,
|
||||
files: File[]
|
||||
): Promise<ImportResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("knowledge_base_id", knowledgeBaseId.toString());
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/import/documents`, {
|
||||
method: "POST",
|
||||
headers: getAgentHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error((error as { error?: string }).error || "导入文档失败");
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text || text.trim() === "") {
|
||||
throw new Error("服务器返回为空,请检查后端是否正常");
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(text) as ImportResult;
|
||||
return {
|
||||
success_count: data.success_count ?? 0,
|
||||
failed_count: data.failed_count ?? 0,
|
||||
failed_files: data.failed_files ?? [],
|
||||
errors: data.errors ?? [],
|
||||
message: data.message,
|
||||
};
|
||||
} catch {
|
||||
throw new Error("服务器返回格式错误,请检查后端接口");
|
||||
}
|
||||
}
|
||||
|
||||
// 从 URL 导入文档
|
||||
export interface ImportFromUrlsRequest {
|
||||
knowledge_base_id: number;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export async function importFromUrls(data: ImportFromUrlsRequest): Promise<ImportResult> {
|
||||
const res = await fetch(`${API_BASE_URL}/import/urls`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAgentHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error((error as { error?: string }).error || "导入 URL 失败");
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text || text.trim() === "") {
|
||||
throw new Error("服务器返回为空,请检查后端是否正常");
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(text) as ImportResult;
|
||||
return {
|
||||
success_count: data.success_count ?? 0,
|
||||
failed_count: data.failed_count ?? 0,
|
||||
failed_files: data.failed_files ?? [],
|
||||
errors: data.errors ?? [],
|
||||
message: data.message,
|
||||
};
|
||||
} catch {
|
||||
throw new Error("服务器返回格式错误,请检查后端接口");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { API_BASE_URL, getAgentHeaders } from "@/lib/config";
|
||||
|
||||
// 知识库摘要信息
|
||||
export interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
rag_enabled?: boolean; // 是否参与 RAG(对 AI 开放),默认 true
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 创建知识库请求
|
||||
export interface CreateKnowledgeBaseRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 更新知识库请求
|
||||
export interface UpdateKnowledgeBaseRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
rag_enabled?: boolean;
|
||||
}
|
||||
|
||||
// 获取知识库列表
|
||||
export async function fetchKnowledgeBases(): Promise<KnowledgeBase[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/knowledge-bases`, {
|
||||
cache: "no-store",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("获取知识库列表失败");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data.knowledge_bases || [];
|
||||
}
|
||||
|
||||
// 获取知识库详情
|
||||
export async function fetchKnowledgeBase(id: number): Promise<KnowledgeBase> {
|
||||
const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}`, {
|
||||
cache: "no-store",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error("知识库不存在");
|
||||
}
|
||||
throw new Error("获取知识库详情失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 创建知识库
|
||||
export async function createKnowledgeBase(data: CreateKnowledgeBaseRequest): Promise<KnowledgeBase> {
|
||||
const res = await fetch(`${API_BASE_URL}/knowledge-bases`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAgentHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error || "创建知识库失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 更新知识库「参与 RAG」开关
|
||||
export async function updateKnowledgeBaseRAGEnabled(
|
||||
id: number,
|
||||
ragEnabled: boolean
|
||||
): Promise<KnowledgeBase> {
|
||||
const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}/rag-enabled`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...getAgentHeaders() },
|
||||
body: JSON.stringify({ rag_enabled: ragEnabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error((error as { error?: string }).error || "更新失败");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 更新知识库
|
||||
export async function updateKnowledgeBase(
|
||||
id: number,
|
||||
data: UpdateKnowledgeBaseRequest
|
||||
): Promise<KnowledgeBase> {
|
||||
const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...getAgentHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
if (res.status === 404) {
|
||||
throw new Error("知识库不存在");
|
||||
}
|
||||
throw new Error(error.error || "更新知识库失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 删除知识库
|
||||
export async function deleteKnowledgeBase(id: number): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
if (res.status === 404) {
|
||||
throw new Error("知识库不存在");
|
||||
}
|
||||
throw new Error(error.error || "删除知识库失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 获取知识库的文档列表
|
||||
export async function fetchDocumentsByKnowledgeBase(
|
||||
knowledgeBaseId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
keyword?: string,
|
||||
status?: string
|
||||
): Promise<any> {
|
||||
let url = `${API_BASE_URL}/documents?knowledge_base_id=${knowledgeBaseId}&page=${page}&page_size=${pageSize}`;
|
||||
if (keyword) {
|
||||
url += `&keyword=${encodeURIComponent(keyword)}`;
|
||||
}
|
||||
if (status) {
|
||||
url += `&status=${encodeURIComponent(status)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
headers: getAgentHeaders(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("获取文档列表失败");
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
@@ -10,15 +10,17 @@ export interface LastMessage {
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: number;
|
||||
conversation_type?: string; // "visitor" | "internal"
|
||||
visitor_id: number;
|
||||
agent_id: number;
|
||||
status: string;
|
||||
chat_mode?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_message?: LastMessage;
|
||||
unread_count?: number;
|
||||
last_seen_at?: string | null; // 最后活跃时间,用于判断在线状态
|
||||
has_participated?: boolean; // 当前用户是否参与过该会话(是否发送过消息)
|
||||
last_seen_at?: string | null;
|
||||
has_participated?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageItem {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
import { updateFaviconWithBadge, updateFavicon, DEFAULT_FAVICON } from "@/utils/favicon";
|
||||
|
||||
/**
|
||||
* 根据未读数更新页面标题和 favicon 红色数字徽章
|
||||
* @param unreadCount 未读消息数
|
||||
* @param baseTitle 基础标题(如 "AI-CS"),未读 > 0 时标题为 "(n) baseTitle"
|
||||
*/
|
||||
export function usePageTitle(unreadCount: number, baseTitle: string) {
|
||||
useEffect(() => {
|
||||
const title = unreadCount > 0 ? `(${unreadCount}) ${baseTitle}` : baseTitle;
|
||||
document.title = title;
|
||||
|
||||
if (unreadCount > 0) {
|
||||
// 延迟更新 favicon,保证首屏或切回标签时也能刷到
|
||||
const t = setTimeout(() => {
|
||||
updateFaviconWithBadge(unreadCount);
|
||||
}, 100);
|
||||
return () => clearTimeout(t);
|
||||
} else {
|
||||
updateFavicon(DEFAULT_FAVICON);
|
||||
}
|
||||
}, [unreadCount, baseTitle]);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { playNotificationSound } from "@/utils/sound";
|
||||
|
||||
export function useSoundNotification(enabled: boolean = true) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio("/notification.mp3");
|
||||
audioRef.current.volume = 0.5;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const play = () => {
|
||||
if (enabled && audioRef.current) {
|
||||
audioRef.current.play().catch(() => {
|
||||
// 忽略播放错误
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { play };
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 3;
|
||||
const TOAST_REMOVE_DELAY = 5000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast(props: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 成功提示(绿色) */
|
||||
toast.success = (message: string) =>
|
||||
toast({ title: message, variant: "success" });
|
||||
|
||||
/** 失败/错误提示(红色) */
|
||||
toast.error = (message: string) =>
|
||||
toast({ title: message, variant: "destructive" });
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -1,5 +1,13 @@
|
||||
// 统一的 API 配置
|
||||
// 使用相对路径,自动适配当前域名(无论是否绑定域名都能工作)
|
||||
// 前端和后端通过 Nginx 代理在同一域名下,所以使用相对路径即可
|
||||
export const API_BASE_URL = '';
|
||||
export const API_BASE_URL = "";
|
||||
|
||||
/** 知识库/文档/导入等接口需带当前用户 ID,供后端校验「是否开放知识库」开关 */
|
||||
export function getAgentHeaders(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {};
|
||||
const id = window.localStorage.getItem("agent_user_id");
|
||||
if (!id) return {};
|
||||
return { "X-User-Id": id };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComponentType } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
MessageCircle,
|
||||
Lightbulb,
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
Users,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 嵌入在 dashboard 内的页面组件(懒加载) */
|
||||
const KnowledgePage = dynamic(
|
||||
() => import("@/app/agent/knowledge/page").then((mod) => ({ default: mod.default })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const FAQsPage = dynamic(
|
||||
() => import("@/app/agent/faqs/page").then((mod) => ({ default: mod.default })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const UsersPage = dynamic(
|
||||
() => import("@/app/agent/users/page").then((mod) => ({ default: mod.default })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const SettingsPage = dynamic(
|
||||
() => import("@/app/agent/settings/page").then((mod) => ({ default: mod.default })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export interface AgentPageItem {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
Icon: LucideIcon;
|
||||
adminOnly?: boolean;
|
||||
/** 对话类页面:展示会话列表 + 聊天区,无独立主内容 */
|
||||
isChatPage?: boolean;
|
||||
/** 非对话类页面的嵌入组件;对话类不填 */
|
||||
component?: ComponentType<{ embedded?: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服端侧栏功能页配置(单一数据源)
|
||||
* 新增功能:在此数组增加一项即可,无需改 NavigationSidebar / DashboardShell 的罗列逻辑
|
||||
*/
|
||||
export const AGENT_PAGES = [
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "对话",
|
||||
title: "对话",
|
||||
Icon: MessageCircle,
|
||||
isChatPage: true,
|
||||
},
|
||||
{
|
||||
id: "internal-chat",
|
||||
label: "知识库测试",
|
||||
title: "知识库测试",
|
||||
Icon: Lightbulb,
|
||||
isChatPage: true,
|
||||
},
|
||||
{
|
||||
id: "knowledge",
|
||||
label: "知识库",
|
||||
title: "知识库",
|
||||
Icon: BookOpen,
|
||||
component: KnowledgePage,
|
||||
},
|
||||
{
|
||||
id: "faqs",
|
||||
label: "事件管理",
|
||||
title: "事件管理",
|
||||
Icon: ClipboardList,
|
||||
component: FAQsPage,
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "用户管理",
|
||||
title: "用户管理",
|
||||
Icon: Users,
|
||||
adminOnly: true,
|
||||
component: UsersPage,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "AI 配置",
|
||||
title: "AI 配置",
|
||||
Icon: Settings,
|
||||
component: SettingsPage,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type NavigationPage = (typeof AGENT_PAGES)[number]["id"];
|
||||
|
||||
const VALID_PAGE_IDS = new Set(AGENT_PAGES.map((p) => p.id));
|
||||
|
||||
export function getPageFromSearchParams(searchParams: URLSearchParams | null): NavigationPage {
|
||||
const p = searchParams?.get("page") ?? null;
|
||||
if (p && VALID_PAGE_IDS.has(p)) return p as NavigationPage;
|
||||
return "dashboard";
|
||||
}
|
||||
|
||||
export function getAgentPage(pageId: NavigationPage): AgentPageItem | undefined {
|
||||
return AGENT_PAGES.find((p) => p.id === pageId);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user