From 0fc7147821306b8ae47ac2533048650ed8b71ccd Mon Sep 17 00:00:00 2001 From: 537yaha <2930134478@qq.com> Date: Wed, 25 Mar 2026 18:50:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=B7=A5=E7=A8=8B+U?= =?UTF-8?q?I=E6=9B=B4=E6=96=B0+=E6=97=A5=E5=BF=97+=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 101 ++- .github/workflows/docker-build.yml | 4 +- README.md | 483 ++++--------- backend/.env | 13 - backend/.env.example | 28 - backend/controller/analytics_controller.go | 60 ++ .../controller/embedding_config_controller.go | 26 +- backend/controller/helper.go | 10 + backend/controller/message_controller.go | 28 +- .../controller/prompt_config_controller.go | 54 ++ backend/controller/system_log_controller.go | 108 +++ backend/controller/visitor_controller.go | 19 +- backend/go.mod | 9 +- backend/go.sum | 23 +- backend/infra/mcp/client.go | 105 +++ backend/infra/mcp/serper.go | 28 + backend/infra/milvus.go | 18 + backend/infra/search/provider.go | 10 + backend/infra/search/serper.go | 85 +++ backend/infra/storage.go | 29 + backend/main.go | 253 +++++-- backend/middleware/middleware.go | 74 +- backend/models/ai_config.go | 23 +- backend/models/analytics.go | 14 + backend/models/embedding_config.go | 8 +- backend/models/prompt_config.go | 21 + backend/models/system_log.go | 21 + backend/models/user.go | 4 + .../repository/prompt_config_repository.go | 42 ++ backend/repository/system_log_repository.go | 24 + backend/repository/widget_open_repository.go | 19 + backend/router/router.go | 167 +++-- backend/service/ai_provider.go | 458 ++++++++++-- backend/service/ai_service.go | 675 ++++++++++++++++-- backend/service/analytics_service.go | 357 +++++++++ backend/service/conversation_service.go | 53 ++ backend/service/embedding_config_service.go | 98 ++- backend/service/message_service.go | 90 ++- backend/service/prompt_config_service.go | 238 ++++++ backend/service/rag/health.go | 18 +- backend/service/rag/vector_store.go | 26 +- backend/service/system_log_service.go | 164 +++++ backend/service/types.go | 31 + docker-compose.prod.yml | 63 +- docker-compose.yml | 56 +- frontend/.env.example | 10 - frontend/Dockerfile | 10 +- frontend/app/agent/analytics/page.tsx | 214 ++++++ frontend/app/agent/conversations/page.tsx | 6 +- frontend/app/agent/login/page.tsx | 4 +- frontend/app/agent/logs/page.tsx | 288 ++++++++ frontend/app/agent/prompts/page.tsx | 190 +++++ frontend/app/agent/settings/page.tsx | 70 +- frontend/app/api/agent/prompts/route.ts | 46 ++ frontend/app/layout.tsx | 3 +- frontend/app/page.tsx | 625 +--------------- frontend/app/robots.ts | 15 + frontend/app/sitemap.ts | 12 + .../components/dashboard/DashboardShell.tsx | 56 +- frontend/components/dashboard/MessageList.tsx | 58 +- .../dashboard/NavigationSidebar.tsx | 25 +- frontend/components/layout/Footer.tsx | 6 +- frontend/components/layout/Header.tsx | 22 +- .../components/marketing/HomePageClient.tsx | 410 +++++++++++ frontend/components/ui/fade-in.tsx | 42 +- .../components/visitor/ChatModeSelector.tsx | 150 ++-- frontend/components/visitor/ChatWidget.tsx | 286 ++++++-- .../components/visitor/OnlineAgentsList.tsx | 13 +- .../visitor/VisitorMessageInput.tsx | 215 ++++++ .../features/agent/hooks/useConversations.ts | 7 + frontend/features/agent/hooks/useMessages.ts | 14 +- .../features/agent/services/aiConfigApi.ts | 14 +- .../features/agent/services/analyticsApi.ts | 54 ++ frontend/features/agent/services/authApi.ts | 4 +- .../agent/services/conversationApi.ts | 16 +- .../features/agent/services/documentApi.ts | 20 +- .../agent/services/embeddingConfigApi.ts | 24 +- frontend/features/agent/services/faqApi.ts | 12 +- frontend/features/agent/services/importApi.ts | 6 +- .../agent/services/knowledgeBaseApi.ts | 16 +- .../features/agent/services/messageApi.ts | 51 +- .../features/agent/services/profileApi.ts | 8 +- .../features/agent/services/promptsApi.ts | 47 ++ .../features/agent/services/systemLogApi.ts | 95 +++ frontend/features/agent/services/userApi.ts | 14 +- frontend/features/agent/types.ts | 4 +- .../features/visitor/services/analyticsApi.ts | 14 + .../visitor/services/conversationApi.ts | 13 +- .../features/visitor/services/visitorApi.ts | 4 +- frontend/hooks/useSoundNotification.ts | 13 +- frontend/lib/config.ts | 10 +- frontend/lib/constants/agent-pages.tsx | 39 + frontend/lib/seo/home-json-ld.ts | 34 + frontend/lib/site.ts | 6 + frontend/lib/website-config.ts | 3 + frontend/next.config.mjs | 19 + frontend/next.config.ts | 32 +- frontend/utils/avatar.ts | 2 + frontend/utils/sound.ts | 88 ++- 99 files changed, 5963 insertions(+), 1734 deletions(-) delete mode 100644 backend/.env delete mode 100644 backend/.env.example create mode 100644 backend/controller/analytics_controller.go create mode 100644 backend/controller/prompt_config_controller.go create mode 100644 backend/controller/system_log_controller.go create mode 100644 backend/infra/mcp/client.go create mode 100644 backend/infra/mcp/serper.go create mode 100644 backend/infra/search/provider.go create mode 100644 backend/infra/search/serper.go create mode 100644 backend/models/analytics.go create mode 100644 backend/models/prompt_config.go create mode 100644 backend/models/system_log.go create mode 100644 backend/repository/prompt_config_repository.go create mode 100644 backend/repository/system_log_repository.go create mode 100644 backend/repository/widget_open_repository.go create mode 100644 backend/service/analytics_service.go create mode 100644 backend/service/prompt_config_service.go create mode 100644 backend/service/system_log_service.go delete mode 100644 frontend/.env.example create mode 100644 frontend/app/agent/analytics/page.tsx create mode 100644 frontend/app/agent/logs/page.tsx create mode 100644 frontend/app/agent/prompts/page.tsx create mode 100644 frontend/app/api/agent/prompts/route.ts create mode 100644 frontend/app/robots.ts create mode 100644 frontend/app/sitemap.ts create mode 100644 frontend/components/marketing/HomePageClient.tsx create mode 100644 frontend/components/visitor/VisitorMessageInput.tsx create mode 100644 frontend/features/agent/services/analyticsApi.ts create mode 100644 frontend/features/agent/services/promptsApi.ts create mode 100644 frontend/features/agent/services/systemLogApi.ts create mode 100644 frontend/features/visitor/services/analyticsApi.ts create mode 100644 frontend/lib/seo/home-json-ld.ts create mode 100644 frontend/lib/site.ts diff --git a/.env.example b/.env.example index 069ba9c..832a342 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,97 @@ -# ============================================ -# Docker Compose 鐢熶骇缂栨帓鐢紙澶嶅埗涓?.env 鍚庡~鍐欙級 -# 鐢ㄤ簬锛歞ocker compose -f docker-compose.prod.yml up -d +# ============================================ +# AI-CS 统一配置模板(唯一真源) +# 使用方法: +# 1) 复制为项目根目录 .env +# 2) 按注释填写必填项 +# 3) Docker 与本地启动都读取这同一份配置 # ============================================ -# MySQL 鏁版嵁搴?MYSQL_ROOT_PASSWORD= -MYSQL_DATABASE=ai_cs -MYSQL_USER=ai_cs_user -MYSQL_PASSWORD= +# ========================= +# 运行画像(建议配置) +# ========================= +# 可选:docker / local +# - docker:DB_HOST/MILVUS_HOST 通常填写服务名(mysql、milvus-standalone) +# - local:DB_HOST/MILVUS_HOST 通常填写 localhost 或远程地址 +APP_PROFILE=docker + +# ========================= +# 服务监听(必填) +# ========================= +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 +# 建议:生产 release,开发 debug +GIN_MODE=release + +# ========================= +# 数据库(必填) +# ========================= +DB_HOST=mysql +DB_PORT=3306 +DB_USER=ai_cs_user +DB_PASSWORD=please_change_me +DB_NAME=ai_cs + +# MySQL 容器 root 密码(Docker 部署必填) +MYSQL_ROOT_PASSWORD=please_change_root_password +# MySQL 对外端口(非必须) MYSQL_PORT=3306 -# 榛樿绠$悊鍛橈紙棣栨鍚姩鏃跺垱寤猴紝璇峰姟蹇呬慨鏀瑰瘑鐮侊級 +# ========================= +# 管理员与安全(必填) +# ========================= ADMIN_USERNAME=admin -ADMIN_PASSWORD= +ADMIN_PASSWORD=please_change_admin_password +# 64 位十六进制密钥(必填) +# 生成示例:openssl rand -hex 32 +ENCRYPTION_KEY=please_generate_64_hex_chars -# 鍚庣鍔犲瘑瀵嗛挜锛堝瓨搴?API Key 绛夊姞瀵嗙敤锛屽缓璁細openssl rand -hex 32锛?ENCRYPTION_KEY= - -# 绔彛锛堝彲閫夛紝榛樿宸插啓鍦?compose 涓級 +# ========================= +# 端口映射(非必须) +# ========================= +# 后端映射到宿主机端口(host:container = BACKEND_PORT:SERVER_PORT) BACKEND_PORT=18080 +# 前端映射到宿主机端口 FRONTEND_PORT=3000 -# 鍓嶇璁块棶鍚庣 API 鐨勫湴鍧€锛堟祻瑙堝櫒鐩磋繛鐢紝涓?BACKEND_PORT 瀵瑰簲锛?NEXT_PUBLIC_API_BASE_URL=http://localhost:18080 +# ========================= +# 向量库 Milvus(按需配置) +# 仅在使用知识库/RAG时需要 +# ========================= +MILVUS_HOST=milvus-standalone +MILVUS_PORT=19530 +MILVUS_USERNAME= +MILVUS_PASSWORD= -# 鍙€?GIN_MODE=release +# true 表示禁用向量库(不连接 Milvus) +MILVUS_DISABLED=false +# 与 MILVUS_DISABLED 含义相同,保留为兼容开关 +VECTOR_STORE_DISABLED=false +# true 表示强依赖 Milvus(连接失败则启动失败) +MILVUS_REQUIRED=false + +# ========================= +# 联网搜索(按需配置) +# 使用联网搜索功能时至少配置一种 +# ========================= +# 方式一:Serper MCP 服务地址 +SERPER_MCP_URL= +# 方式二:Serper API Key +SERPER_API_KEY= + +# ========================= +# 前端公开变量(按需配置) +# 说明:NEXT_PUBLIC_* 会暴露到浏览器端 +# ========================= +# 非必填:站点对外的绝对地址(https://你的域名),用于 SEO(canonical、OG、sitemap)。未填时前端默认使用演示站域名。 +NEXT_PUBLIC_SITE_URL= + +NEXT_PUBLIC_API_BASE_URL=http://localhost:18080 +NEXT_PUBLIC_BACKEND_HOST=localhost +NEXT_PUBLIC_BACKEND_PORT=8080 +NEXT_PUBLIC_MATOMO_CONTAINER_URL= + +# ========================= +# 预构建镜像(docker-compose.prod.yml 必填) +# ========================= +BACKEND_IMAGE=537yaha/ai-cs-backend:latest +FRONTEND_IMAGE=537yaha/ai-cs-frontend:latest diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index aaacf2f..b2ecfcb 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -54,6 +54,4 @@ jobs: push: true tags: 537yaha/ai-cs-frontend:latest platforms: linux/amd64,linux/arm64 - build-args: | - NEXT_PUBLIC_API_BASE_URL=http://localhost:18080 - NEXT_PUBLIC_BACKEND_PORT=18080 \ No newline at end of file + # 前端统一走同域 /api(由反向代理转发到后端),无需在构建时注入 localhost/端口 \ No newline at end of file diff --git a/README.md b/README.md index a560fc8..e3f106f 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,55 @@ # AI-CS 智能客服系统 -> 一个融合 AI 技术与人工客服的现代化智能客服解决方案 +> 开源的 AI 客服系统:**AI + 人工一体**、可私有化部署、可配置、可观测。 +> 适合把“官网右下角客服小窗”与“客服工作台”一起落地的团队。 -## 🌐 在线演示 +## 在线演示 -**Demo 站点**: https://demo.cscorp.top +- **官网首页(产品介绍 + SEO)**:`https://demo.cscorp.top` +- **访客聊天页**:`https://demo.cscorp.top/chat`(也可从首页右下角按钮进入) +- **客服登录**:`https://demo.cscorp.top/agent/login` -- **官网首页**: https://demo.cscorp.top -- **访客聊天**: 点击首页右下角客服插件按钮 -- **客服登录**: https://demo.cscorp.top/agent/login +## 你能用它做什么(功能回顾) -## ✨ 核心特性 +- **访客侧(嵌入小窗)** + - 右下角聊天小窗,可嵌入任意网站(iframe 方式) + - 支持 AI 模式 / 人工模式切换、消息提示音、文件上传 + - 可选“本回合联网搜索”开关(是否对访客展示可在后台控制) +- **客服侧(工作台)** + - 会话列表、实时消息(WebSocket)、未读角标提示 + - 多模型管理(文本/绘画等)与对话配置 + - **提示词配置**(Prompt 管理) + - **知识库管理 + RAG**(向量检索,可按需启用;向量库不可用时可不影响启动) + - **日志中心**:结构化日志落库,支持按级别/分类/事件/trace_id/关键字筛选排障 + - **数据报表**:按日/区间查看访客打开小窗、会话与消息、AI 回复与失败率、知识库命中率、转人工等指标 +- **官网与 SEO(面向获客)** + - 蓝白主题官网首页,分段渐变与滚动进场动效 + - `metadata` / Open Graph / JSON-LD / `sitemap.xml` / `robots.txt`,便于搜索引擎收录与社交分享 +- **可选联网搜索(Web Search)** + - 支持 **Serper**:MCP 接入(`SERPER_MCP_URL`)或直连 API(`SERPER_API_KEY`) + - 也支持“厂商内置 web search”(由模型自己决定是否搜)的 function calling 流程(按模型能力与供应商而定) -- 🤖 **AI 客服支持**:支持多厂商 AI 模型,可配置 API 和模型选择 -- 🔍 **RAG 智能检索**:基于向量数据库的知识库检索,AI 对话自动使用知识库内容 -- 📚 **知识库管理**:完整的文档管理、知识库组织、批量导入功能 -- 👥 **人工客服**:实时在线状态显示,支持多客服协作 -- 💬 **实时通信**:基于 WebSocket 的双向实时消息推送 -- 📁 **文件传输**:支持图片、文档上传和预览 -- 📖 **FAQ 管理**:知识库管理,支持向量检索和关键词搜索 -- 👤 **用户管理**:完整的用户权限管理系统 -- 🎨 **现代化 UI**:基于 Shadcn UI 的响应式设计 -- 🔌 **访客小窗插件**:可嵌入任何网站的客服小窗组件 -- 🌐 **产品官网**:内置产品展示页面 +## 快速开始(只维护根目录 `/.env`) -## 🚀 快速开始 +> 统一配置真源:**只维护项目根目录 `/.env`**。Docker 与本地启动都读这一份。 +> 初始化:复制 `/.env.example` 为 `/.env` 并填写必填项。 -### 方式一:预构建镜像一键部署(推荐,最简单)⭐ +### 方式 A:预构建镜像部署(推荐,最省事) -> **最简单快捷的方式**,直接使用预构建的 Docker 镜像,无需构建,一行命令启动 - -#### 前置要求 - -- Docker Desktop(Windows/Mac)或 Docker + Docker Compose(Linux) - -#### 部署步骤 - -1. **克隆项目并进入目录** +#### 1)准备配置 ```bash git clone https://github.com/2930134478/AI-CS.git cd AI-CS -``` - -2. **配置环境变量** - -```bash -# 复制环境变量模板 cp .env.example .env - -# 编辑 .env 文件,至少修改以下配置: -# - MYSQL_ROOT_PASSWORD: MySQL root 密码 -# - ADMIN_PASSWORD: 管理员密码(首次登录使用) -# - ENCRYPTION_KEY: 加密密钥(生成 64 位十六进制字符串) ``` -生成加密密钥: +至少要改(必填): +- **数据库**:`MYSQL_ROOT_PASSWORD`、`DB_PASSWORD` +- **管理员**:`ADMIN_PASSWORD` +- **安全密钥**:`ENCRYPTION_KEY`(64 位 hex) + +生成 `ENCRYPTION_KEY`: ```bash # Linux/Mac @@ -65,383 +59,148 @@ openssl rand -hex 32 -join ((48..57) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_}) ``` -3. **一键启动** +#### 2)启动 ```bash -# 使用预构建镜像启动(自动从 Docker Hub 拉取镜像) docker-compose -f docker-compose.prod.yml up -d ``` -就这么简单!🎉 +#### 3)访问 -4. **访问应用** +- **官网首页**:`http://localhost:3000` +- **访客聊天**:`http://localhost:3000/chat` +- **客服登录**:`http://localhost:3000/agent/login` + - 用户名:`admin`(或 `.env` 中 `ADMIN_USERNAME`) + - 密码:`.env` 中 `ADMIN_PASSWORD` -- **前端首页**: http://localhost:3000 -- **访客聊天**: http://localhost:3000/chat -- **客服登录**: http://localhost:3000/agent/login - - 用户名:`admin`(或 `.env` 中配置的 `ADMIN_USERNAME`) - - 密码:`.env` 中配置的 `ADMIN_PASSWORD` +#### 端口修改(重要说明) -#### 端口配置 +- 默认端口:前端 `3000`,后端对外 `18080` +- 修改:在 `.env` 里改 `FRONTEND_PORT` / `BACKEND_PORT` -**默认端口**:后端 `18080`,前端 `3000` +> 说明:预构建镜像在某些静态资源/图片路径场景可能与端口强绑定(历史兼容原因)。如果你需要彻底自定义端口并确保所有资源路径一致,建议用下面的“方式 B 本地构建”。 -**修改端口**:在 `.env` 文件中设置 `BACKEND_PORT` 和 `FRONTEND_PORT` - -⚠️ **注意**:预构建镜像的图片加载已硬编码为 `18080` 端口,如需修改端口,请使用方式二(本地构建)重新构建镜像。 - -#### 常用命令 - -```bash -# 查看日志 -docker-compose -f docker-compose.prod.yml logs -f - -# 查看服务状态 -docker-compose -f docker-compose.prod.yml ps - -# 停止服务 -docker-compose -f docker-compose.prod.yml stop - -# 停止并删除容器(保留数据) -docker-compose -f docker-compose.prod.yml down - -# 完全重置(删除所有数据) -docker-compose -f docker-compose.prod.yml down -v -``` - ---- - -### 方式二:Docker 本地构建部署 - -> 适合需要自定义构建或网络无法访问 Docker Hub 的情况。 - -#### 前置要求 - -- Docker Desktop(Windows/Mac)或 Docker + Docker Compose(Linux) -- Git - -#### 部署步骤 - -1. **克隆项目** +### 方式 B:Docker 本地构建部署(可自定义) ```bash git clone https://github.com/2930134478/AI-CS.git cd AI-CS +cp .env.example .env +docker-compose up -d --build ``` -2. **配置环境变量** +### 方式 C:传统部署(本地开发/手动安装) + +环境要求: +- Go 1.24+ +- Node.js 20.9.0+ +- MySQL 8.0+ ```bash -# 复制环境变量模板 +git clone https://github.com/2930134478/AI-CS.git +cd AI-CS cp .env.example .env -# 编辑 .env 文件,至少修改以下配置: -# - MYSQL_ROOT_PASSWORD: MySQL root 密码 -# - ADMIN_PASSWORD: 管理员密码(首次登录使用) -# - ENCRYPTION_KEY: 加密密钥(生成 64 位十六进制字符串) -``` - -生成加密密钥: - -```bash -# Linux/Mac -openssl rand -hex 32 - -# Windows PowerShell --join ((48..57) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_}) -``` - -3. **构建并启动服务** - -```bash -# 构建并启动所有服务(首次构建需要一些时间) -docker-compose up -d --build - -# 查看日志 -docker-compose logs -f - -# 查看服务状态 -docker-compose ps -``` - -4. **访问应用** - -- **前端首页**: http://localhost:3000 -- **访客聊天**: http://localhost:3000/chat -- **客服登录**: http://localhost:3000/agent/login - - 用户名:`admin`(或 `.env` 中配置的 `ADMIN_USERNAME`) - - 密码:`.env` 中配置的 `ADMIN_PASSWORD` - -#### 端口配置 - -**默认端口**:后端 `18080`,前端 `3000` - -**修改端口**:在 `.env` 文件中设置 `BACKEND_PORT` 和 `FRONTEND_PORT`,然后重新构建: -```bash -docker-compose up -d --build -``` - -#### 常用命令 - -```bash -# 停止服务 -docker-compose stop - -# 停止并删除容器(保留数据) -docker-compose down - -# 完全重置(删除所有数据) -docker-compose down -v - -# 查看日志 -docker-compose logs -f backend -docker-compose logs -f frontend -``` - ---- - -### 方式三:传统部署(手动安装) - -#### 环境要求 - -- Go 1.24 或更高版本 -- Node.js 20.9.0+ 和 npm/yarn(Next.js 16 要求) -- MySQL 8.0 或更高版本 - -#### 1. 克隆项目 - -```bash -git clone https://github.com/2930134478/AI-CS.git -cd AI-CS -``` - -#### 2. 配置后端 - -```bash +# 1) 后端 cd backend - -# 创建 .env 文件 -cat > .env << EOF -# 数据库配置 -DB_HOST=localhost -DB_PORT=3306 -DB_USER=root -DB_PASSWORD=your_password -DB_NAME=ai_cs - -# 管理员账号配置(必填) -ADMIN_USERNAME=admin -ADMIN_PASSWORD=your_admin_password - -# 服务器配置 -SERVER_HOST=0.0.0.0 -SERVER_PORT=18080 # 默认端口 18080,可修改 -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 - -# 安装依赖 go mod tidy - -# 启动服务(默认端口 8080) go run main.go -``` -> ⚠️ **重要**:`ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建默认管理员账号。 - -#### 3. 配置前端 - -```bash -cd frontend - -# 安装依赖 +# 2) 前端(新开终端) +cd ../frontend npm install - -# 启动开发服务器(默认端口 3000) npm run dev ``` -**端口配置**: -- 后端端口:修改 `backend/.env` 中的 `SERVER_PORT`(默认 `8080`) -- 前端端口:启动时通过 `PORT` 环境变量修改,如 `PORT=4000 npm run dev` -- 图片加载端口:创建 `frontend/.env.local`,设置 `NEXT_PUBLIC_BACKEND_PORT=你的后端端口` +## 配置字典(根目录 `/.env`) -#### 4. 访问应用 +> 下面表格以 `/.env.example` 为准,帮助你快速判断“必填/可选/什么时候需要填”。 -- **官网首页**: http://localhost:3000 -- **访客聊天**: - - 直接访问:http://localhost:3000/chat - - 或点击首页右下角的客服插件按钮 -- **客服登录**: http://localhost:3000/agent/login +| 变量 | 用途 | 是否必填 | 默认值(示例) | 示例 | +|---|---|---|---|---| +| `APP_PROFILE` | 部署画像(`docker/local`) | 否 | `docker` | `local` | +| `SERVER_HOST` | 后端监听地址 | 是 | `0.0.0.0` | `127.0.0.1` | +| `SERVER_PORT` | 后端容器内端口 | 是 | `8080` | `8080` | +| `GIN_MODE` | 后端模式 | 建议 | `release` | `debug` | +| `DB_HOST` | 后端数据库地址 | 是 | `mysql` | `localhost` | +| `DB_PORT` | 后端数据库端口 | 是 | `3306` | `3306` | +| `DB_USER` | 数据库用户名 | 是 | `ai_cs_user` | `root` | +| `DB_PASSWORD` | 数据库密码 | 是 | 无 | `StrongPwd` | +| `DB_NAME` | 数据库名 | 是 | `ai_cs` | `ai_cs` | +| `MYSQL_ROOT_PASSWORD` | MySQL root 密码(compose) | 是(Docker) | 无 | `RootPwd` | +| `MYSQL_PORT` | MySQL 对外端口(compose) | 否 | `3306` | `13306` | +| `ADMIN_USERNAME` | 默认管理员用户名 | 否 | `admin` | `admin` | +| `ADMIN_PASSWORD` | 默认管理员密码 | 是 | 无 | `AdminPwd` | +| `ENCRYPTION_KEY` | 后端加密密钥(64位 hex) | 是 | 无 | `openssl rand -hex 32` | +| `BACKEND_PORT` | 后端映射到宿主机端口 | 否 | `18080` | `28080` | +| `FRONTEND_PORT` | 前端映射到宿主机端口 | 否 | `3000` | `13000` | +| `MILVUS_HOST` | 向量库地址 | 可选(启用 RAG) | `milvus-standalone` | `localhost` | +| `MILVUS_PORT` | 向量库端口 | 可选(启用 RAG) | `19530` | `19530` | +| `MILVUS_USERNAME` | Milvus 用户名 | 可选 | 空 | `user` | +| `MILVUS_PASSWORD` | Milvus 密码 | 可选 | 空 | `pass` | +| `MILVUS_DISABLED` | 禁用向量库(不连接) | 否 | `false` | `true` | +| `VECTOR_STORE_DISABLED` | 同上(兼容开关) | 否 | `false` | `true` | +| `MILVUS_REQUIRED` | 强依赖向量库(失败即退出) | 否 | `false` | `true` | +| `SERPER_MCP_URL` | 联网搜索 MCP 地址 | 可选(启用联网) | 空 | `http://host:3000/sse` | +| `SERPER_API_KEY` | 联网搜索 API Key | 可选(启用联网) | 空 | `xxxxx` | +| `NEXT_PUBLIC_SITE_URL` | 站点对外绝对地址(用于 SEO) | 否 | 空(默认 demo 域名) | `https://www.example.com` | +| `NEXT_PUBLIC_API_BASE_URL` | 前端公开 API 地址 | 建议 | `http://localhost:18080` | `https://api.example.com` | +| `NEXT_PUBLIC_BACKEND_HOST` | 前端 dev 代理目标 host | 否 | `localhost` | `127.0.0.1` | +| `NEXT_PUBLIC_BACKEND_PORT` | 前端 dev 代理目标 port | 否 | `8080` | `18080` | +| `NEXT_PUBLIC_MATOMO_CONTAINER_URL` | Matomo 脚本地址 | 可选 | 空 | `https://.../container.js` | +| `BACKEND_IMAGE` | 预构建后端镜像(prod compose) | 是(prod) | `537yaha/ai-cs-backend:latest` | `your/backend:tag` | +| `FRONTEND_IMAGE` | 预构建前端镜像(prod compose) | 是(prod) | `537yaha/ai-cs-frontend:latest` | `your/frontend:tag` | -#### 5. 默认管理员账号 +## 启用/关闭知识库(RAG)的推荐做法 -⚠️ **重要说明**: +- **你暂时不想用知识库**:把 `.env` 里 `MILVUS_DISABLED=true`(或 `VECTOR_STORE_DISABLED=true`) + - 应用仍可启动,AI 对话与人工客服不受影响 +- **你必须依赖知识库**(生产强约束):把 `.env` 里 `MILVUS_REQUIRED=true` + - 此时如果 Milvus 不可用,会落库一条错误日志后退出,避免“半残服务上线” -系统会在首次启动时**自动创建**管理员账号(如果不存在),但**必须先在 `backend/.env` 文件中配置 `ADMIN_PASSWORD` 环境变量**。 +## 集成访客小窗到你的网站(iframe) -**配置步骤**: - -1. 在 `backend/.env` 文件中设置: - ```env - ADMIN_USERNAME=admin # 可选,默认为 admin - ADMIN_PASSWORD=your_password # ⚠️ 必填,首次登录后请立即修改密码 - ``` - -2. 启动后端服务,系统会自动创建管理员账号 - -3. 使用配置的用户名和密码登录 - -**安全提示**: -- 生产环境请使用强密码 -- 首次登录后请立即修改密码 -- `ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建管理员账号 - -### 后端环境变量 - -在 `backend/.env` 中配置: - -```env -# 数据库配置 -DB_HOST=localhost # 数据库主机 -DB_PORT=3306 # 数据库端口 -DB_USER=root # 数据库用户名 -DB_PASSWORD=your_password # 数据库密码 -DB_NAME=ai_cs # 数据库名称 - -# 管理员账号配置 -ADMIN_USERNAME=admin # 管理员用户名(可选,默认为 admin) -ADMIN_PASSWORD=your_admin_password # ⚠️ 管理员密码(必填) - -# 服务器配置 -SERVER_HOST=0.0.0.0 # 服务器监听地址 -SERVER_PORT=8080 # 服务器端口 -GIN_MODE=debug # 运行模式(debug/release) - -# 加密密钥(用于加密 AI API Keys) -ENCRYPTION_KEY=your_32_byte_key # 使用 openssl rand -hex 32 生成 -``` - -**重要提示**: -- `ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建默认管理员账号 -- 生产环境请使用强密码并设置 `GIN_MODE=release` - -## 🔌 集成客服插件到你的网站 - -#### 步骤 1:在 HTML 中添加代码 - -在你的网站 HTML 的 `` 标签之前添加: +把下面代码放到你网站的 `` 前,核心是把 `src` 指向你自己的部署域名的 `/chat`: ```html -
- - - - -
``` -#### 步骤 2:修改域名 +## 常见问题与排障(先看这里) -将代码中的 `https://demo.cscorp.top` 替换为你的实际域名(部署 AI-CS 的域名)。 +- **提示音听不到**:浏览器通常需要“用户一次交互”才能解锁音频;请先点一下页面任意按钮/再打开喇叭开关测试 +- **向量库连不上导致启动失败**:检查 `.env` 的 `MILVUS_REQUIRED` 是否误开;不需要知识库时建议 `MILVUS_DISABLED=true` +- **搜不到站点/分享卡片不正确**:设置 `NEXT_PUBLIC_SITE_URL=https://你的域名`,用于 canonical / OG / sitemap 生成 -**示例**: -```html - - -``` +## 贡献 -### 响应式设计 +欢迎提交 Issue 和 Pull Request。 -插件会自动适配不同设备: - -- **移动端**:小窗宽度自适应,最大高度优化 -- **平板端**:中等尺寸窗口 -- **桌面端**:完整尺寸窗口 - - -### 自定义样式 - -如果需要自定义样式,可以通过 CSS 覆盖: - -```css -/* 自定义浮动按钮 */ -#ai-cs-toggle-btn { - background-color: #your-color !important; - width: 60px !important; - height: 60px !important; -} - -/* 自定义聊天窗口 */ -#ai-cs-chat-iframe { - border-radius: 16px !important; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important; -} -``` - - -## 🤝 贡献 - -欢迎提交 Issue 和 Pull Request! - -## 📄 许可证 +## 许可证 [MIT](LICENSE) © 2025 2930134478 -## 🙏 致谢 - -感谢所有为这个项目做出贡献的开发者! - --- -**最后更新**: 2025-01-12 +**最后更新**:2026-03-25 diff --git a/backend/.env b/backend/.env deleted file mode 100644 index 368082d..0000000 --- a/backend/.env +++ /dev/null @@ -1,13 +0,0 @@ -DB_HOST=localhost -DB_PORT=3306 -DB_USER=root -DB_PASSWORD=hkbjujk%h2eT -DB_NAME=CS -# Milvus 向量数据库配置 -MILVUS_HOST=localhost -MILVUS_PORT=19530 -# 嵌入服务配置 -EMBEDDING_TYPE=openai -EMBEDDING_API_URL= -EMBEDDING_API_KEY= -EMBEDDING_MODEL=text-embedding-3-small \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 55e3acf..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# ============================================ -# 鍚庣鏈湴杩愯鐢紙澶嶅埗涓?.env 鍚庡~鍐欙級 -# 鐢ㄤ簬锛氬湪 backend 鐩綍涓?go run main.go -# ============================================ - -# MySQL 鏁版嵁搴擄紙鏈湴璺戞椂濉紱Docker 閮ㄧ讲鏃剁敱 compose 娉ㄥ叆锛屾棤闇€鍦ㄦ濉級 -DB_HOST=localhost -DB_PORT=3306 -DB_USER=ai_cs_user -DB_PASSWORD= -DB_NAME=ai_cs - -# Milvus 鍚戦噺搴擄紙鏈湴璺戞椂濉?localhost锛汥ocker 閮ㄧ讲鏃跺~ milvus-standalone 鎴栧搴斾富鏈猴級 -MILVUS_HOST=localhost -MILVUS_PORT=19530 -# MILVUS_USERNAME= # 鍙€?# MILVUS_PASSWORD= # 鍙€? -# 榛樿绠$悊鍛橈紙棣栨鍒涘缓锛?ADMIN_USERNAME=admin -ADMIN_PASSWORD= - -# 鏈嶅姟鐩戝惉 -SERVER_HOST=0.0.0.0 -SERVER_PORT=8080 -GIN_MODE=debug - -# 鍔犲瘑瀵嗛挜锛堝瓨搴?API Key 绛夊姞瀵嗙敤锛屽缓璁細openssl rand -hex 32锛?ENCRYPTION_KEY= - -# -------------------------------------------- -# 鐭ヨ瘑搴撳悜閲忔ā鍨嬶細璇峰湪鐧诲綍鍚庝簬銆岃缃?- 鐭ヨ瘑搴撳悜閲忔ā鍨嬨€嶄腑閰嶇疆锛?# 鏃犻渶鍦ㄦ濉啓 EMBEDDING_* / BGE_* 绛?API Key 涓庡湴鍧€銆?# -------------------------------------------- diff --git a/backend/controller/analytics_controller.go b/backend/controller/analytics_controller.go new file mode 100644 index 0000000..30553da --- /dev/null +++ b/backend/controller/analytics_controller.go @@ -0,0 +1,60 @@ +package controller + +import ( + "net/http" + "time" + + "github.com/2930134478/AI-CS/backend/service" + "github.com/gin-gonic/gin" +) + +// AnalyticsController 数据分析报表(客服端查询 + 访客端埋点) +type AnalyticsController struct { + analytics *service.AnalyticsService +} + +func NewAnalyticsController(analytics *service.AnalyticsService) *AnalyticsController { + return &AnalyticsController{analytics: analytics} +} + +// GetSummary GET /agent/analytics/summary?from=YYYY-MM-DD&to=YYYY-MM-DD +func (ac *AnalyticsController) GetSummary(c *gin.Context) { + userID := getUserIDFromHeader(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权,请提供 X-User-Id"}) + return + } + from := c.Query("from") + to := c.Query("to") + if from == "" || to == "" { + // 默认最近 7 天(含今天) + loc, _ := time.LoadLocation("Asia/Shanghai") + now := time.Now().In(loc) + to = now.Format("2006-01-02") + from = now.AddDate(0, 0, -6).Format("2006-01-02") + } + res, err := ac.analytics.GetSummary(from, to) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + +type widgetOpenRequest struct { + VisitorID uint `json:"visitor_id"` +} + +// PostWidgetOpen POST /visitor/analytics/widget-open — 访客打开小窗时上报(无需登录) +func (ac *AnalyticsController) PostWidgetOpen(c *gin.Context) { + var req widgetOpenRequest + if err := c.ShouldBindJSON(&req); err != nil || req.VisitorID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 visitor_id"}) + return + } + if err := ac.analytics.RecordWidgetOpen(req.VisitorID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/controller/embedding_config_controller.go b/backend/controller/embedding_config_controller.go index ba6371b..8cfc25a 100644 --- a/backend/controller/embedding_config_controller.go +++ b/backend/controller/embedding_config_controller.go @@ -38,23 +38,27 @@ func (e *EmbeddingConfigController) Get(c *gin.Context) { // 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"` + 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"` + VisitorWebSearchEnabled *bool `json:"visitor_web_search_enabled"` + WebSearchSource *string `json:"web_search_source"` } 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, + EmbeddingType: req.EmbeddingType, + APIURL: req.APIURL, + APIKey: req.APIKey, + Model: req.Model, + CustomerCanUseKB: req.CustomerCanUseKB, + VisitorWebSearchEnabled: req.VisitorWebSearchEnabled, + WebSearchSource: req.WebSearchSource, }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/backend/controller/helper.go b/backend/controller/helper.go index 684ea07..bb26c80 100644 --- a/backend/controller/helper.go +++ b/backend/controller/helper.go @@ -50,3 +50,13 @@ func formatTimePointer(t *time.Time) string { } return t.Format(timeFormat) } + +// getTraceID 从请求上下文读取 trace_id(由中间件注入)。 +func getTraceID(c *gin.Context) string { + if v, ok := c.Get("trace_id"); ok { + if s, ok2 := v.(string); ok2 { + return s + } + } + return "" +} diff --git a/backend/controller/message_controller.go b/backend/controller/message_controller.go index cb9f647..33c1925 100644 --- a/backend/controller/message_controller.go +++ b/backend/controller/message_controller.go @@ -35,12 +35,16 @@ type createMessageRequest struct { Content string `json:"content"` SenderIsAgent bool `json:"sender_is_agent"` SenderID uint `json:"sender_id"` - // 文件相关字段(可选) FileURL *string `json:"file_url"` FileType *string `json:"file_type"` FileName *string `json:"file_name"` FileSize *int64 `json:"file_size"` MimeType *string `json:"mime_type"` + // 回复数据源开关(仅 AI 模式有效),不传则默认:知识库+大模型开,联网关 + UseKnowledgeBase *bool `json:"use_knowledge_base"` + UseLLM *bool `json:"use_llm"` + UseWebSearch *bool `json:"use_web_search"` + NeedWebSearch bool `json:"need_web_search"` } // CreateMessage 处理发送消息的请求。 @@ -58,15 +62,19 @@ func (mc *MessageController) CreateMessage(c *gin.Context) { } _, err := mc.messageService.CreateMessage(service.CreateMessageInput{ - ConversationID: req.ConversationID, - Content: req.Content, - SenderID: req.SenderID, - SenderIsAgent: req.SenderIsAgent, - FileURL: req.FileURL, - FileType: req.FileType, - FileName: req.FileName, - FileSize: req.FileSize, - MimeType: req.MimeType, + ConversationID: req.ConversationID, + Content: req.Content, + SenderID: req.SenderID, + SenderIsAgent: req.SenderIsAgent, + FileURL: req.FileURL, + FileType: req.FileType, + FileName: req.FileName, + FileSize: req.FileSize, + MimeType: req.MimeType, + UseKnowledgeBase: req.UseKnowledgeBase, + UseLLM: req.UseLLM, + UseWebSearch: req.UseWebSearch, + NeedWebSearch: req.NeedWebSearch, }) if err != nil { log.Printf("❌ 创建消息失败: 对话ID=%d, 错误=%v", req.ConversationID, err) diff --git a/backend/controller/prompt_config_controller.go b/backend/controller/prompt_config_controller.go new file mode 100644 index 0000000..863b4f4 --- /dev/null +++ b/backend/controller/prompt_config_controller.go @@ -0,0 +1,54 @@ +package controller + +import ( + "net/http" + + "github.com/2930134478/AI-CS/backend/service" + "github.com/gin-gonic/gin" +) + +// PromptConfigController 提示词配置控制器(供「提示词」页) +type PromptConfigController struct { + service *service.PromptConfigService +} + +// NewPromptConfigController 创建控制器实例 +func NewPromptConfigController(s *service.PromptConfigService) *PromptConfigController { + return &PromptConfigController{service: s} +} + +// Get 获取所有提示词项(含默认内容) +// GET /agent/prompts?user_id=1 +func (p *PromptConfigController) Get(c *gin.Context) { + _, err := parseUintQuery(c, "user_id") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"}) + return + } + list, err := p.service.GetAllForAPI() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"prompts": list}) +} + +// Update 更新单条提示词(仅管理员) +// PUT /agent/prompts +// Body: { "user_id": 1, "key": "rag_prompt", "content": "..." } +func (p *PromptConfigController) Update(c *gin.Context) { + var req struct { + UserID uint `json:"user_id" binding:"required"` + Key string `json:"key" binding:"required"` + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + if err := p.service.Update(req.UserID, req.Key, req.Content); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "保存成功"}) +} diff --git a/backend/controller/system_log_controller.go b/backend/controller/system_log_controller.go new file mode 100644 index 0000000..e1247d2 --- /dev/null +++ b/backend/controller/system_log_controller.go @@ -0,0 +1,108 @@ +package controller + +import ( + "net/http" + "strconv" + "strings" + + "github.com/2930134478/AI-CS/backend/service" + "github.com/gin-gonic/gin" +) + +type SystemLogController struct { + logs *service.SystemLogService +} + +func NewSystemLogController(logs *service.SystemLogService) *SystemLogController { + return &SystemLogController{logs: logs} +} + +// GetLogs 查询日志(客服端)。 +func (lc *SystemLogController) GetLogs(c *gin.Context) { + userID := getUserIDFromHeader(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权,请提供 X-User-Id"}) + return + } + var convID *uint + if v := strings.TrimSpace(c.Query("conversation_id")); v != "" { + if id, err := strconv.ParseUint(v, 10, 64); err == nil { + t := uint(id) + convID = &t + } + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + + res, err := lc.logs.Query(service.QuerySystemLogsInput{ + From: c.Query("from"), + To: c.Query("to"), + Level: c.Query("level"), + Category: c.Query("category"), + Event: c.Query("event"), + Source: c.Query("source"), + ConversationID: convID, + Keyword: c.Query("keyword"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "查询日志失败"}) + return + } + c.JSON(http.StatusOK, res) +} + +type reportFrontendLogRequest struct { + Level string `json:"level"` + Category string `json:"category"` + Event string `json:"event"` + TraceID string `json:"trace_id"` + ConversationID *uint `json:"conversation_id"` + VisitorID *uint `json:"visitor_id"` + Message string `json:"message"` + Meta map[string]interface{} `json:"meta"` +} + +// ReportFrontendLog 前端上报日志(用于捕获页面异常与关键请求失败)。 +func (lc *SystemLogController) ReportFrontendLog(c *gin.Context) { + var req reportFrontendLogRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) + return + } + + userID := getUserIDFromHeader(c) + var pUserID *uint + if userID > 0 { + pUserID = &userID + } + // 基础防护:限制 message/meta 体量,避免日志接口被刷爆。 + if len(req.Message) > 2000 { + req.Message = req.Message[:2000] + } + traceID := strings.TrimSpace(req.TraceID) + if traceID == "" { + traceID = getTraceID(c) + } + if req.Meta != nil { + req.Meta["truncated"] = false + } + if err := lc.logs.Create(service.CreateSystemLogInput{ + Level: req.Level, + Category: req.Category, + Event: req.Event, + Source: "frontend", + TraceID: traceID, + ConversationID: req.ConversationID, + UserID: pUserID, + VisitorID: req.VisitorID, + Message: req.Message, + Meta: req.Meta, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "写入日志失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + diff --git a/backend/controller/visitor_controller.go b/backend/controller/visitor_controller.go index 361ec16..893a7e9 100644 --- a/backend/controller/visitor_controller.go +++ b/backend/controller/visitor_controller.go @@ -9,13 +9,15 @@ import ( // VisitorController 负责处理访客相关的 HTTP 请求。 type VisitorController struct { - visitorService *service.VisitorService + visitorService *service.VisitorService + embeddingConfigService *service.EmbeddingConfigService } // NewVisitorController 创建 VisitorController 实例。 -func NewVisitorController(visitorService *service.VisitorService) *VisitorController { +func NewVisitorController(visitorService *service.VisitorService, embeddingConfigService *service.EmbeddingConfigService) *VisitorController { return &VisitorController{ - visitorService: visitorService, + visitorService: visitorService, + embeddingConfigService: embeddingConfigService, } } @@ -33,3 +35,14 @@ func (v *VisitorController) GetOnlineAgents(c *gin.Context) { }) } +// GetWidgetConfig 获取访客小窗配置(联网设置等,无需登录)。 +// GET /visitor/widget-config +func (v *VisitorController) GetWidgetConfig(c *gin.Context) { + cfg, err := v.embeddingConfigService.GetVisitorWebSearchConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, cfg) +} + diff --git a/backend/go.mod b/backend/go.mod index 2c8c075..cae68f4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,7 +9,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/milvus-io/milvus-sdk-go/v2 v2.4.2 - github.com/yuin/goldmark v1.7.16 + github.com/modelcontextprotocol/go-sdk v1.4.0 golang.org/x/crypto v0.44.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.0 @@ -35,6 +35,7 @@ require ( 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/google/jsonschema-go v0.4.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 @@ -50,15 +51,19 @@ require ( 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/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // 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 + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.21.0 // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sys v0.40.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 diff --git a/backend/go.sum b/backend/go.sum index d596330..2530fe4 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -108,7 +108,10 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a 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 h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= @@ -139,6 +142,8 @@ 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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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= @@ -215,6 +220,8 @@ github.com/milvus-io/milvus-sdk-go/v2 v2.4.2 h1:Xqf+S7iicElwYoS2Zly8Nf/zKHuZsNy1 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/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= 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= @@ -250,6 +257,10 @@ github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4 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/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= 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= @@ -301,6 +312,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: 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/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/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= @@ -308,8 +321,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de 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= @@ -374,6 +385,8 @@ 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/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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= @@ -423,8 +436,8 @@ 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/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.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= @@ -466,6 +479,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= diff --git a/backend/infra/mcp/client.go b/backend/infra/mcp/client.go new file mode 100644 index 0000000..a02e27a --- /dev/null +++ b/backend/infra/mcp/client.go @@ -0,0 +1,105 @@ +// Package mcp 提供 MCP(Model Context Protocol)客户端,用于通过 MCP 协议调用远程工具(如 Serper 搜索)。 +package mcp + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ErrNotConfigured 表示未配置 MCP 服务 URL 或未成功连接。 +var ErrNotConfigured = errors.New("mcp: server URL not configured or not connected") + +// Client 封装对单个 MCP 服务端的连接,支持 CallTool。 +type Client struct { + serverURL string + impl *mcp.Implementation + transport *mcp.StreamableClientTransport + client *mcp.Client + session *mcp.ClientSession + mu sync.Mutex +} + +// NewClient 创建一个 MCP 客户端。serverURL 为 MCP 服务 HTTP/SSE 地址(如 http://localhost:3000/sse)。 +// 若 serverURL 为空,后续 Connect/CallTool 将返回 ErrNotConfigured。 +func NewClient(serverURL string) *Client { + url := strings.TrimSpace(serverURL) + return &Client{ + serverURL: url, + impl: &mcp.Implementation{Name: "ai-cs-backend", Version: "v1.0.0"}, + } +} + +// Connect 连接到 MCP 服务端。应在调用 CallTool 前成功调用一次。 +func (c *Client) Connect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.serverURL == "" { + return ErrNotConfigured + } + if c.session != nil { + return nil // 已连接 + } + c.transport = &mcp.StreamableClientTransport{Endpoint: c.serverURL} + c.client = mcp.NewClient(c.impl, nil) + session, err := c.client.Connect(ctx, c.transport, nil) + if err != nil { + return fmt.Errorf("mcp connect: %w", err) + } + c.session = session + return nil +} + +// CallTool 调用远程工具。name 为工具名(如 google_search),args 为参数(如 map[string]any{"query": "..."})。 +// 返回工具结果中的文本内容拼接;若未连接或工具返回错误则返回错误。 +func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (string, error) { + c.mu.Lock() + session := c.session + c.mu.Unlock() + if c.serverURL == "" { + return "", ErrNotConfigured + } + if session == nil { + return "", ErrNotConfigured + } + params := &mcp.CallToolParams{Name: name, Arguments: args} + result, err := session.CallTool(ctx, params) + if err != nil { + return "", fmt.Errorf("mcp call tool %s: %w", name, err) + } + if result.IsError { + // 工具侧错误,从 Content 中取错误信息 + text := extractTextContent(result.Content) + if text != "" { + return "", fmt.Errorf("mcp tool error: %s", text) + } + return "", fmt.Errorf("mcp tool error (no content)") + } + return extractTextContent(result.Content), nil +} + +// Close 关闭与 MCP 服务端的会话。 +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.session == nil { + return nil + } + err := c.session.Close() + c.session = nil + return err +} + +func extractTextContent(content []mcp.Content) string { + var b strings.Builder + for _, c := range content { + if tc, ok := c.(*mcp.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} diff --git a/backend/infra/mcp/serper.go b/backend/infra/mcp/serper.go new file mode 100644 index 0000000..d95a4d0 --- /dev/null +++ b/backend/infra/mcp/serper.go @@ -0,0 +1,28 @@ +// Serper 通过 MCP 调用 Serper 搜索的 WebSearchProvider 实现。 +// 需配合 Serper MCP 服务端(如 garylab/serper-mcp-server)使用,工具名一般为 google_search,参数为 query。 + +package mcp + +import ( + "context" + + "github.com/2930134478/AI-CS/backend/infra/search" +) + +// SerperWebSearchProvider 通过 MCP 调用 Serper 的 google_search 工具,实现 search.WebSearchProvider。 +type SerperWebSearchProvider struct { + client *Client +} + +// NewSerperWebSearchProvider 创建基于 MCP 的 Serper 联网搜索提供方。client 需已 Connect。 +func NewSerperWebSearchProvider(client *Client) search.WebSearchProvider { + return &SerperWebSearchProvider{client: client} +} + +// Search 执行搜索:调用 MCP 工具 google_search,将结果文本返回供 LLM 使用。 +func (p *SerperWebSearchProvider) Search(ctx context.Context, query string) (string, error) { + if p.client == nil { + return "", nil + } + return p.client.CallTool(ctx, "google_search", map[string]any{"query": query}) +} diff --git a/backend/infra/milvus.go b/backend/infra/milvus.go index 253c32a..1841bf7 100644 --- a/backend/infra/milvus.go +++ b/backend/infra/milvus.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/milvus-io/milvus-sdk-go/v2/client" @@ -31,6 +32,23 @@ func GetMilvusConfig() *MilvusConfig { } } +func envTruthy(key string) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + +// IsMilvusDisabled 为 true 时跳过连接 Milvus(不参与 RAG / 向量化,核心 HTTP 仍可启动)。 +// 环境变量:MILVUS_DISABLED 或 VECTOR_STORE_DISABLED。 +func IsMilvusDisabled() bool { + return envTruthy("MILVUS_DISABLED") || envTruthy("VECTOR_STORE_DISABLED") +} + +// IsMilvusRequired 为 true 时,若 Milvus 不可用则启动失败(便于生产环境强依赖向量库时 fail-fast)。 +// 环境变量:MILVUS_REQUIRED。 +func IsMilvusRequired() bool { + return envTruthy("MILVUS_REQUIRED") +} + // NewMilvusClient 创建 Milvus 客户端连接 func NewMilvusClient() (client.Client, error) { config := GetMilvusConfig() diff --git a/backend/infra/search/provider.go b/backend/infra/search/provider.go new file mode 100644 index 0000000..8673ca6 --- /dev/null +++ b/backend/infra/search/provider.go @@ -0,0 +1,10 @@ +// Package search 提供联网搜索等能力的抽象与实现,便于后续扩展(爬虫、生成文档等)。 +// 当前仅实现 Serper 联网搜索;新增能力时在此包增加 Provider 接口与具体实现即可。 +package search + +import "context" + +// WebSearchProvider 联网搜索能力抽象。Serper 实现此接口;后续可增加其他实现或新能力(如 CrawlProvider)。 +type WebSearchProvider interface { + Search(ctx context.Context, query string) (string, error) +} diff --git a/backend/infra/search/serper.go b/backend/infra/search/serper.go new file mode 100644 index 0000000..a055832 --- /dev/null +++ b/backend/infra/search/serper.go @@ -0,0 +1,85 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + serperBaseURL = "https://google.serper.dev/search" + defaultTimeout = 15 * time.Second +) + +// SerperProvider 使用 Serper API 的联网搜索实现(项目内直接调用,无需代理)。 +type SerperProvider struct { + apiKey string + client *http.Client +} + +// NewSerperProvider 创建 Serper 搜索提供方。apiKey 为空时 Search 返回空字符串与 nil error(调用方回退)。 +func NewSerperProvider(apiKey string) *SerperProvider { + return &SerperProvider{ + apiKey: strings.TrimSpace(apiKey), + client: &http.Client{Timeout: defaultTimeout}, + } +} + +// Search 执行搜索并返回格式化后的文本摘要,供 LLM 使用。未配置 apiKey 或请求失败时返回空字符串。 +func (p *SerperProvider) Search(ctx context.Context, query string) (string, error) { + if p.apiKey == "" { + return "", nil + } + reqBody := map[string]interface{}{"q": query} + bodyBytes, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, serperBaseURL, strings.NewReader(string(bodyBytes))) + if err != nil { + return "", fmt.Errorf("serper request: %w", err) + } + req.Header.Set("X-API-KEY", p.apiKey) + req.Header.Set("Content-Type", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("serper http: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bs, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("serper api %d: %s", resp.StatusCode, string(bs)) + } + var result serperResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("serper decode: %w", err) + } + return result.FormatOrganic(), nil +} + +type serperResponse struct { + Organic []struct { + Title string `json:"title"` + Link string `json:"link"` + Snippet string `json:"snippet"` + } `json:"organic"` +} + +func (r *serperResponse) FormatOrganic() string { + if len(r.Organic) == 0 { + return "" + } + var b strings.Builder + for i, o := range r.Organic { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString(o.Title) + b.WriteString("\n") + b.WriteString(o.Link) + b.WriteString("\n") + b.WriteString(o.Snippet) + } + return b.String() +} diff --git a/backend/infra/storage.go b/backend/infra/storage.go index 84808ba..ea8523e 100644 --- a/backend/infra/storage.go +++ b/backend/infra/storage.go @@ -3,8 +3,10 @@ package infra import ( "fmt" "io" + "net/url" "os" "path/filepath" + "strings" "time" ) @@ -17,6 +19,9 @@ type StorageService interface { // file: 文件内容 // filename: 原始文件名 SaveMessageFile(conversationID uint, file io.Reader, filename string) (string, error) + // ReadMessageFile 根据消息文件的 URL 或路径读取文件内容(用于多模态:识图等) + // fileURLOrPath 为创建消息时返回的 file_url,可为相对路径如 /uploads/messages/1/xxx.jpg 或完整 URL + ReadMessageFile(fileURLOrPath string) ([]byte, error) // DeleteFile 删除文件 DeleteFile(fileURL string) error // GetFileURL 获取文件的完整URL @@ -78,6 +83,30 @@ func (s *LocalStorageService) SaveAvatar(userID uint, file io.Reader, filename s return s.GetFileURL(relativePath), nil } +// ReadMessageFile 根据消息文件的 URL 或路径读取文件内容。 +func (s *LocalStorageService) ReadMessageFile(fileURLOrPath string) ([]byte, error) { + pathPart := fileURLOrPath + if strings.Contains(fileURLOrPath, "://") { + u, err := url.Parse(fileURLOrPath) + if err != nil { + return nil, fmt.Errorf("解析文件 URL 失败: %w", err) + } + pathPart = u.Path + } + pathPart = strings.TrimPrefix(pathPart, "/") + publicPathTrimmed := strings.TrimPrefix(strings.TrimSuffix(s.publicPath, "/"), "/") + if publicPathTrimmed != "" && strings.HasPrefix(pathPart, publicPathTrimmed+"/") { + pathPart = strings.TrimPrefix(pathPart, publicPathTrimmed+"/") + } else if publicPathTrimmed != "" && pathPart == publicPathTrimmed { + pathPart = "" + } + if pathPart == "" { + return nil, fmt.Errorf("无法从 URL 解析出相对路径: %s", fileURLOrPath) + } + fullPath := filepath.Join(s.baseDir, pathPart) + return os.ReadFile(fullPath) +} + // DeleteFile 删除文件 func (s *LocalStorageService) DeleteFile(fileURL string) error { // 从URL中提取文件路径 diff --git a/backend/main.go b/backend/main.go index 55361ff..7c6312b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -9,6 +9,8 @@ import ( "github.com/2930134478/AI-CS/backend/controller" "github.com/2930134478/AI-CS/backend/infra" + "github.com/2930134478/AI-CS/backend/infra/mcp" + infra_search "github.com/2930134478/AI-CS/backend/infra/search" "github.com/2930134478/AI-CS/backend/middleware" "github.com/2930134478/AI-CS/backend/models" "github.com/2930134478/AI-CS/backend/repository" @@ -19,6 +21,7 @@ import ( "github.com/2930134478/AI-CS/backend/websocket" "github.com/gin-gonic/gin" "github.com/joho/godotenv" + milvus "github.com/milvus-io/milvus-sdk-go/v2/client" "golang.org/x/crypto/bcrypt" ) @@ -68,30 +71,65 @@ func initDefaultAdmin(userRepo *repository.UserRepository) { log.Println(" ⚠️ 请首次登录后立即修改密码!") } +// logVectorStartup 将向量库(Milvus)启动相关事件写入 system_logs,供前端「日志中心」查询;失败时仅打控制台,不影响启动。 +func logVectorStartup(sys *service.SystemLogService, level, event, message string, meta map[string]interface{}) { + if sys == nil { + return + } + if meta == nil { + meta = map[string]interface{}{} + } + if err := sys.Create(service.CreateSystemLogInput{ + Level: level, + Category: "vector", + Event: event, + Source: "backend", + Message: message, + Meta: meta, + }); err != nil { + log.Printf("写入 system_logs 失败 (event=%s): %v", event, err) + } +} + +// fatalVectorStartup 在启动阶段先写入一条 vector error 日志,再执行 fatal 退出。 +func fatalVectorStartup(sys *service.SystemLogService, event, message string, meta map[string]interface{}) { + logVectorStartup(sys, "error", event, message, meta) + log.Fatalf("%s", message) +} + func main() { - // 加载 .env 文件 - // 获取当前工作目录 + // 加载 .env 文件(统一配置真源:优先当前目录 .env,其次上级目录 .env) wd, _ := os.Getwd() - envPath := filepath.Join(wd, ".env") - - // 检查文件是否存在 - if _, err := os.Stat(envPath); os.IsNotExist(err) { - log.Printf("⚠️ .env 文件不存在: %s", envPath) - log.Println("当前工作目录:", wd) + candidates := []string{ + filepath.Join(wd, ".env"), + filepath.Join(wd, "..", ".env"), + } + envPath := "" + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + envPath = p + break + } + } + if envPath == "" { + log.Printf("⚠️ 未找到 .env 文件(已检查: %v)", candidates) + log.Println("将仅使用系统环境变量") } else { log.Printf("✅ 找到 .env 文件: %s", envPath) } // 尝试加载 .env 文件 // 注意:godotenv 不支持 UTF-8 BOM,如果文件有 BOM 会失败 - if err := godotenv.Load(envPath); err != nil { - log.Printf("❌ 加载 .env 文件失败: %v", err) - log.Println("⚠️ 提示:如果看到 'unexpected character' 错误,可能是文件编码问题(UTF-8 BOM)") - log.Println(" 解决方法:用文本编辑器(如 VS Code)打开 .env,另存为 UTF-8 编码(不要 BOM)") - log.Println("将使用系统环境变量") - } else { - log.Println("✅ .env 文件加载成功") + if envPath != "" { + if err := godotenv.Load(envPath); err != nil { + log.Printf("❌ 加载 .env 文件失败: %v", err) + log.Println("⚠️ 提示:如果看到 'unexpected character' 错误,可能是文件编码问题(UTF-8 BOM)") + log.Println(" 解决方法:用文本编辑器(如 VS Code)打开 .env,另存为 UTF-8 编码(不要 BOM)") + log.Println("将使用系统环境变量") + } else { + log.Println("✅ .env 文件加载成功") + } } db, err := infra.NewDB() @@ -100,7 +138,7 @@ func main() { } //根据结构体定义自动创建更新表 - if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}); err != nil { + if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}, &models.PromptConfig{}, &models.WidgetOpenEvent{}, &models.SystemLog{}); err != nil { log.Fatalf("自动创建表失败: %v", err) } @@ -112,14 +150,20 @@ func main() { kbRepo := repository.NewKnowledgeBaseRepository(db) docRepo := repository.NewDocumentRepository(db) embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db) + promptConfigRepo := repository.NewPromptConfigRepository(db) + systemLogRepo := repository.NewSystemLogRepository(db) + systemLogService := service.NewSystemLogService(systemLogRepo) // 初始化默认管理员账号(如果不存在) initDefaultAdmin(userRepo) //gin路由初始化 + gin.SetMode(gin.ReleaseMode) r := gin.Default() - //使用日志中间件 + // trace_id + 结构化 HTTP 日志 + 控制台日志 + r.Use(middleware.TraceID()) + r.Use(middleware.StructuredHTTPLogger(systemLogService)) r.Use(middleware.Logger()) //跨域配置 @@ -133,21 +177,82 @@ func main() { publicPath := "/uploads" storageService := infra.NewLocalStorageService(uploadDir, publicPath) - // 初始化 Milvus 客户端(向量数据库) - milvusClient, err := infra.NewMilvusClient() - if err != nil { - log.Fatalf("连接 Milvus 失败: %v", err) + // 初始化 Milvus(向量数据库):默认连接失败时降级为「无向量库」启动;MILVUS_REQUIRED=true 时失败则退出 + milvusDisabled := infra.IsMilvusDisabled() + milvusRequired := infra.IsMilvusRequired() + var milvusClient milvus.Client + defer func() { + if milvusClient != nil { + if err := milvusClient.Close(); err != nil { + log.Printf("关闭 Milvus 客户端: %v", err) + } + } + }() + var vectorStore *infra.VectorStore + milvusCfg := infra.GetMilvusConfig() + milvusMeta := map[string]interface{}{ + "milvus_host": milvusCfg.Host, + "milvus_port": milvusCfg.Port, + "milvus_required": milvusRequired, + "milvus_disabled": milvusDisabled, } - defer milvusClient.Close() - // 检查 Milvus 健康状态 - if err := infra.HealthCheck(milvusClient); err != nil { - log.Fatalf("Milvus 健康检查失败: %v", err) + if milvusDisabled { + log.Println("ℹ️ 已设置 MILVUS_DISABLED / VECTOR_STORE_DISABLED,跳过 Milvus;知识库 RAG 与向量化不可用,直至启用并重启。") + logVectorStartup(systemLogService, "info", "milvus_disabled", + "已跳过 Milvus(MILVUS_DISABLED/VECTOR_STORE_DISABLED);知识库 RAG 与向量化不可用,启用后需重启", + milvusMeta) + } else { + c, err := infra.NewMilvusClient() + if err != nil { + if milvusRequired { + m := map[string]interface{}{} + for k, v := range milvusMeta { + m[k] = v + } + m["error"] = err.Error() + fatalVectorStartup(systemLogService, "milvus_required_connect_failed", + "连接 Milvus 失败(已设置 MILVUS_REQUIRED)", m) + } + log.Printf("⚠️ 连接 Milvus 失败,将以「无向量库」模式启动: %v", err) + m := map[string]interface{}{} + for k, v := range milvusMeta { + m[k] = v + } + m["error"] = err.Error() + logVectorStartup(systemLogService, "warn", "milvus_connect_failed", + "连接 Milvus 失败,已降级为无向量库模式启动", m) + } else { + milvusClient = c + if err := infra.HealthCheck(milvusClient); err != nil { + _ = milvusClient.Close() + milvusClient = nil + if milvusRequired { + m := map[string]interface{}{} + for k, v := range milvusMeta { + m[k] = v + } + m["error"] = err.Error() + fatalVectorStartup(systemLogService, "milvus_required_health_check_failed", + "Milvus 健康检查失败(已设置 MILVUS_REQUIRED)", m) + } + log.Printf("⚠️ Milvus 健康检查失败,将以「无向量库」模式启动: %v", err) + m := map[string]interface{}{} + for k, v := range milvusMeta { + m[k] = v + } + m["error"] = err.Error() + logVectorStartup(systemLogService, "warn", "milvus_health_check_failed", + "Milvus 健康检查失败,已降级为无向量库模式启动", m) + } else { + log.Println("✅ Milvus 连接成功") + } + } } - log.Println("✅ Milvus 连接成功") // 嵌入服务按需从 DB 配置获取(保存即生效,无需重启) embeddingConfigService := service.NewEmbeddingConfigService(embeddingConfigRepo, userRepo) + promptConfigService := service.NewPromptConfigService(promptConfigRepo, userRepo) embeddingFactory := embedding.NewEmbeddingFactory() embeddingProvider := service.NewConfigBackedEmbeddingProvider(embeddingConfigService, embeddingFactory) @@ -172,9 +277,40 @@ func main() { } return svc, nil } - vectorStore, err := infra.NewVectorStore(milvusClient, "documents", dimension, getEmbedding) - if err != nil { - log.Fatalf("创建向量存储失败: %v", err) + if milvusClient != nil { + vs, err := infra.NewVectorStore(milvusClient, "documents", dimension, getEmbedding) + if err != nil { + _ = milvusClient.Close() + milvusClient = nil + if milvusRequired { + m := map[string]interface{}{} + for k, v := range milvusMeta { + m[k] = v + } + m["error"] = err.Error() + fatalVectorStartup(systemLogService, "milvus_required_vector_store_init_failed", + "创建向量存储失败(已设置 MILVUS_REQUIRED)", m) + } + log.Printf("⚠️ 创建向量存储失败,将以「无向量库」模式启动: %v", err) + m := map[string]interface{}{} + for k, v := range milvusMeta { + m[k] = v + } + m["error"] = err.Error() + logVectorStartup(systemLogService, "warn", "milvus_vector_store_init_failed", + "创建向量存储(集合)失败,已降级为无向量库模式启动", m) + } else { + vectorStore = vs + } + } + if vectorStore != nil { + okMeta := map[string]interface{}{} + for k, v := range milvusMeta { + okMeta[k] = v + } + okMeta["collection"] = "documents" + logVectorStartup(systemLogService, "info", "milvus_ready", + "Milvus 已连接且向量集合可用", okMeta) } vectorStoreService := rag.NewVectorStoreService(vectorStore) @@ -184,12 +320,30 @@ func main() { retrievalService.EnableCache(5 * time.Minute) healthChecker := rag.NewHealthChecker(embeddingProvider, vectorStoreService) + // 联网搜索(可选):优先通过 MCP 调用 Serper(SERPER_MCP_URL),否则使用 Serper HTTP API(SERPER_API_KEY) + var webSearchProvider infra_search.WebSearchProvider + if mcpURL := os.Getenv("SERPER_MCP_URL"); mcpURL != "" { + mcpClient := mcp.NewClient(mcpURL) + if err := mcpClient.Connect(initCtx); err != nil { + log.Printf("⚠️ Serper MCP 连接失败(SERPER_MCP_URL=%s): %v,联网搜索将不可用", mcpURL, err) + } else { + webSearchProvider = mcp.NewSerperWebSearchProvider(mcpClient) + log.Println("✅ 联网搜索已通过 MCP(Serper)接入") + } + } + if webSearchProvider == nil { + if apiKey := os.Getenv("SERPER_API_KEY"); apiKey != "" { + webSearchProvider = infra_search.NewSerperProvider(apiKey) + log.Println("✅ 联网搜索已通过 Serper HTTP API 接入") + } + } + // 初始化服务层 authService := service.NewAuthService(userRepo) - conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo) + conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo, systemLogService) profileService := service.NewProfileService(userRepo, storageService) aiConfigService := service.NewAIConfigService(aiConfigRepo, userRepo) - aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService) // 添加 RAG 检索服务 + aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService, webSearchProvider, embeddingConfigService, promptConfigService, storageService, systemLogService) userService := service.NewUserService(userRepo) // 用户管理服务 faqService := service.NewFAQService(faqRepo, retrievalService, documentEmbeddingService) // FAQ 管理服务 documentService := service.NewDocumentService(docRepo, kbRepo, documentEmbeddingService, retrievalService) // 文档管理服务 @@ -314,27 +468,36 @@ func main() { faqController := controller.NewFAQController(faqService) documentController := controller.NewDocumentController(documentService, embeddingConfigService) embeddingConfigController := controller.NewEmbeddingConfigController(embeddingConfigService) + promptConfigController := controller.NewPromptConfigController(promptConfigService) knowledgeBaseController := controller.NewKnowledgeBaseController(knowledgeBaseService, embeddingConfigService) importController := controller.NewImportController(importService, embeddingConfigService) // 导入控制器 - visitorController := controller.NewVisitorController(visitorService) + visitorController := controller.NewVisitorController(visitorService, embeddingConfigService) healthController := controller.NewHealthController(healthChecker, retrievalService) // 健康检查控制器 + widgetOpenRepo := repository.NewWidgetOpenRepository(db) + analyticsService := service.NewAnalyticsService(db, widgetOpenRepo) + analyticsController := controller.NewAnalyticsController(analyticsService) + systemLogController := controller.NewSystemLogController(systemLogService) + appRouter.RegisterRoutes( r, appRouter.ControllerSet{ - 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, // 健康检查控制器 + Auth: authController, + Conversation: conversationController, + Message: messageController, + Admin: adminController, + Profile: profileController, + AIConfig: aiConfigController, + EmbeddingConfig: embeddingConfigController, + PromptConfig: promptConfigController, + FAQ: faqController, + Document: documentController, + KnowledgeBase: knowledgeBaseController, + Import: importController, // 导入控制器 + Visitor: visitorController, + Health: healthController, // 健康检查控制器 + Analytics: analyticsController, + SystemLog: systemLogController, }, websocket.HandleWebSocket(wsHub), ) diff --git a/backend/middleware/middleware.go b/backend/middleware/middleware.go index ab46889..3f9043c 100644 --- a/backend/middleware/middleware.go +++ b/backend/middleware/middleware.go @@ -1,15 +1,39 @@ package middleware import ( + "crypto/rand" + "encoding/hex" "log" "net/http" "strconv" "time" + "github.com/2930134478/AI-CS/backend/service" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) +func newTraceID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + return hex.EncodeToString(b[:]) +} + +// TraceID 为每个请求注入 trace_id,便于链路排障。 +func TraceID() gin.HandlerFunc { + return func(c *gin.Context) { + traceID := c.GetHeader("X-Trace-Id") + if traceID == "" { + traceID = newTraceID() + } + c.Set("trace_id", traceID) + c.Writer.Header().Set("X-Trace-Id", traceID) + c.Next() + } +} + func Logger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() @@ -20,11 +44,59 @@ func Logger() gin.HandlerFunc { } } +// StructuredHTTPLogger 将 HTTP 请求结构化落库(分类: http)。 +func StructuredHTTPLogger(logSvc *service.SystemLogService) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + if logSvc == nil { + return + } + latencyMs := time.Since(start).Milliseconds() + status := c.Writer.Status() + level := "info" + if status >= 500 { + level = "error" + } else if status >= 400 || latencyMs >= 2000 { + level = "warn" + } + var userID *uint + if v := c.GetHeader("X-User-Id"); v != "" { + if id, err := strconv.ParseUint(v, 10, 64); err == nil && id > 0 { + t := uint(id) + userID = &t + } + } + traceID := "" + if v, ok := c.Get("trace_id"); ok { + if s, ok2 := v.(string); ok2 { + traceID = s + } + } + _ = logSvc.Create(service.CreateSystemLogInput{ + Level: level, + Category: "http", + Event: "http_request", + Source: "backend", + TraceID: traceID, + UserID: userID, + Message: c.Request.Method + " " + c.Request.URL.Path, + Meta: map[string]interface{}{ + "status": status, + "latency_ms": latencyMs, + "path": c.Request.URL.Path, + "method": c.Request.Method, + "query": c.Request.URL.RawQuery, + }, + }) + } +} + func CORS() gin.HandlerFunc { return cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "X-User-Id", "X-Trace-Id"}, AllowCredentials: false, }) } diff --git a/backend/models/ai_config.go b/backend/models/ai_config.go index e7ed62a..e3041d7 100644 --- a/backend/models/ai_config.go +++ b/backend/models/ai_config.go @@ -7,20 +7,19 @@ import ( // AIConfig AI 配置模型 // 支持多种模型类型(文本、图片、语音、视频)和不同的协议路径 type AIConfig struct { - ID uint `json:"id" gorm:"primaryKey"` - UserID uint `json:"user_id"` // 配置所属的用户(管理员) - Provider string `json:"provider" gorm:"type:varchar(50)"` // 服务提供商(如:openai、claude、custom,仅用于标识) - APIURL string `json:"api_url" gorm:"type:varchar(500)"` // API 地址(支持不同的协议路径) - APIKey string `json:"api_key" gorm:"type:varchar(1000)"` // API Key(加密存储) - Model string `json:"model" gorm:"type:varchar(100)"` // 模型名称(如:gpt-3.5-turbo、gpt-4) - ModelType string `json:"model_type" gorm:"type:varchar(20);default:'text'"` // 模型类型:text、image、audio、video - IsActive bool `json:"is_active" gorm:"default:true"` // 是否启用(服务商级别) - IsPublic bool `json:"is_public" gorm:"default:false"` // 是否开放给访客使用(模型级别) - Description string `json:"description" gorm:"type:varchar(500)"` // 配置描述 + ID uint `json:"id" gorm:"primaryKey"` + UserID uint `json:"user_id"` // 配置所属的用户(管理员) + Provider string `json:"provider" gorm:"type:varchar(50)"` // 服务提供商(如:openai、claude、custom,仅用于标识) + APIURL string `json:"api_url" gorm:"type:varchar(500)"` // API 地址(支持不同的协议路径) + APIKey string `json:"api_key" gorm:"type:varchar(1000)"` // API Key(加密存储) + Model string `json:"model" gorm:"type:varchar(100)"` // 模型名称(如:gpt-3.5-turbo、gpt-4) + ModelType string `json:"model_type" gorm:"type:varchar(20);default:'text'"` // 模型类型:text、image、audio、video + IsActive bool `json:"is_active" gorm:"default:true"` // 是否启用(服务商级别) + IsPublic bool `json:"is_public" gorm:"default:false"` // 是否开放给访客使用(模型级别) + Description string `json:"description" gorm:"type:varchar(500)"` // 配置描述 // 可选的适配参数(JSON 格式,用于适配不同服务商的细微差异) // 例如:{"auth_header": "X-API-Key", "response_path": "data.choices[0].message.content"} - AdapterConfig string `json:"adapter_config" gorm:"type:text"` // 适配器配置(JSON 格式) + AdapterConfig string `json:"adapter_config" gorm:"type:text"` // 适配器配置(JSON 格式) CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } - diff --git a/backend/models/analytics.go b/backend/models/analytics.go new file mode 100644 index 0000000..59bb995 --- /dev/null +++ b/backend/models/analytics.go @@ -0,0 +1,14 @@ +package models + +import "time" + +// WidgetOpenEvent 访客打开客服小窗埋点(每次打开弹窗记一条,用于统计访问次数) +type WidgetOpenEvent struct { + ID uint `json:"id" gorm:"primaryKey"` + VisitorID uint `json:"visitor_id" gorm:"index;not null"` + CreatedAt time.Time `json:"created_at"` +} + +func (WidgetOpenEvent) TableName() string { + return "widget_open_events" +} diff --git a/backend/models/embedding_config.go b/backend/models/embedding_config.go index f291c58..a3793d7 100644 --- a/backend/models/embedding_config.go +++ b/backend/models/embedding_config.go @@ -11,6 +11,10 @@ type EmbeddingConfig struct { 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"` + // 访客端是否显示「本回合联网搜索」选项(由配置页控制) + VisitorWebSearchEnabled bool `json:"visitor_web_search_enabled" gorm:"default:false"` + // 联网方式:vendor(厂商内置 web_search)/ custom(自建 Serper,后端执行) + WebSearchSource string `json:"web_search_source" gorm:"type:varchar(20);default:'custom'"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/models/prompt_config.go b/backend/models/prompt_config.go new file mode 100644 index 0000000..d44ec79 --- /dev/null +++ b/backend/models/prompt_config.go @@ -0,0 +1,21 @@ +package models + +import "time" + +// PromptConfig 系统提示词配置(按 key 存储,供客服/管理员在「提示词」页配置) +// 用于 RAG、联网等场景的 prompt 模板;支持占位符 {{rag_context}}、{{user_message}} +type PromptConfig struct { + Key string `json:"key" gorm:"primaryKey;type:varchar(64)"` + Content string `json:"content" gorm:"type:text"` + UpdatedAt time.Time `json:"updated_at"` +} + +// 已知的 prompt key(与前端、AIService 一致) +const ( + PromptKeyRAG = "rag_prompt" + PromptKeyRAGWithWebOptional = "rag_prompt_with_web_optional" + PromptKeyNoKB = "no_kb_prompt" // 无知识库时,仅用模型自身知识 + PromptKeyWebSearchResult = "web_search_result_prompt" // 联网结果拼接(占位符 {{web_context}}、{{user_message}}),当前流程未使用 + PromptKeyNoSourceReply = "no_source_reply" // 无任何来源时直接返回给用户的一句话 + PromptKeyAIFailReply = "ai_fail_reply" // AI 调用失败时返回给用户的一句话 +) diff --git a/backend/models/system_log.go b/backend/models/system_log.go new file mode 100644 index 0000000..cab361c --- /dev/null +++ b/backend/models/system_log.go @@ -0,0 +1,21 @@ +package models + +import "time" + +// SystemLog 结构化系统日志(用于排障与日志中心展示)。 +type SystemLog struct { + ID uint `json:"id" gorm:"primaryKey"` + Timestamp time.Time `json:"timestamp" gorm:"index"` + Level string `json:"level" gorm:"type:varchar(20);index"` // info / warn / error + Category string `json:"category" gorm:"type:varchar(40);index"` // http / ai / rag / business / system / frontend + Event string `json:"event" gorm:"type:varchar(80);index"` // 事件编码 + Source string `json:"source" gorm:"type:varchar(20);index"` // backend / frontend + TraceID string `json:"trace_id" gorm:"type:varchar(100);index"` + ConversationID *uint `json:"conversation_id" gorm:"index"` + UserID *uint `json:"user_id" gorm:"index"` + VisitorID *uint `json:"visitor_id" gorm:"index"` + Message string `json:"message" gorm:"type:text"` + MetaJSON string `json:"meta_json" gorm:"type:text"` // 扩展信息(JSON 字符串) + CreatedAt time.Time `json:"created_at" gorm:"index"` +} + diff --git a/backend/models/user.go b/backend/models/user.go index 7966e92..e72bb47 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -62,4 +62,8 @@ type Message struct { FileName *string `json:"file_name" gorm:"type:varchar(255)"` // 原始文件名 FileSize *int64 `json:"file_size"` // 文件大小(字节) MimeType *string `json:"mime_type" gorm:"type:varchar(100)"` // MIME类型(如 image/jpeg) + // AI 回复使用的数据源,用于前端展示「已使用知识库」等,逗号分隔:knowledge_base, llm, web + SourcesUsed string `json:"sources_used" gorm:"type:varchar(100)"` + // IsAIGenerationFailed 为 true 表示本次 AI 消息为生成失败后的兜底文案(用于统计失败率) + IsAIGenerationFailed bool `json:"is_ai_generation_failed" gorm:"default:false"` } diff --git a/backend/repository/prompt_config_repository.go b/backend/repository/prompt_config_repository.go new file mode 100644 index 0000000..07112ed --- /dev/null +++ b/backend/repository/prompt_config_repository.go @@ -0,0 +1,42 @@ +package repository + +import ( + "github.com/2930134478/AI-CS/backend/models" + "gorm.io/gorm" +) + +// PromptConfigRepository 提示词配置仓储(按 key 读写) +type PromptConfigRepository struct { + db *gorm.DB +} + +// NewPromptConfigRepository 创建仓储实例 +func NewPromptConfigRepository(db *gorm.DB) *PromptConfigRepository { + return &PromptConfigRepository{db: db} +} + +// Get 按 key 获取一条配置,不存在返回 nil, nil +// 使用反引号包裹 key,避免 MySQL 保留字导致 SQL 语法错误 +func (r *PromptConfigRepository) Get(key string) (*models.PromptConfig, error) { + var m models.PromptConfig + err := r.db.Where("`key` = ?", key).First(&m).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +// GetAll 获取所有配置(用于管理页展示) +func (r *PromptConfigRepository) GetAll() ([]models.PromptConfig, error) { + var list []models.PromptConfig + err := r.db.Order("`key`").Find(&list).Error + return list, err +} + +// Save 按 key 保存或更新(upsert) +func (r *PromptConfigRepository) Save(c *models.PromptConfig) error { + return r.db.Save(c).Error +} diff --git a/backend/repository/system_log_repository.go b/backend/repository/system_log_repository.go new file mode 100644 index 0000000..e5fb6fc --- /dev/null +++ b/backend/repository/system_log_repository.go @@ -0,0 +1,24 @@ +package repository + +import ( + "github.com/2930134478/AI-CS/backend/models" + "gorm.io/gorm" +) + +// SystemLogRepository 系统日志仓储。 +type SystemLogRepository struct { + db *gorm.DB +} + +func NewSystemLogRepository(db *gorm.DB) *SystemLogRepository { + return &SystemLogRepository{db: db} +} + +func (r *SystemLogRepository) Create(item *models.SystemLog) error { + return r.db.Create(item).Error +} + +func (r *SystemLogRepository) DB() *gorm.DB { + return r.db +} + diff --git a/backend/repository/widget_open_repository.go b/backend/repository/widget_open_repository.go new file mode 100644 index 0000000..4544464 --- /dev/null +++ b/backend/repository/widget_open_repository.go @@ -0,0 +1,19 @@ +package repository + +import ( + "github.com/2930134478/AI-CS/backend/models" + "gorm.io/gorm" +) + +// WidgetOpenRepository 访客小窗打开埋点 +type WidgetOpenRepository struct { + db *gorm.DB +} + +func NewWidgetOpenRepository(db *gorm.DB) *WidgetOpenRepository { + return &WidgetOpenRepository{db: db} +} + +func (r *WidgetOpenRepository) Create(e *models.WidgetOpenEvent) error { + return r.db.Create(e).Error +} diff --git a/backend/router/router.go b/backend/router/router.go index c467a88..7ed1157 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -14,100 +14,121 @@ type ControllerSet struct { Profile *controller.ProfileController AIConfig *controller.AIConfigController EmbeddingConfig *controller.EmbeddingConfigController + PromptConfig *controller.PromptConfigController FAQ *controller.FAQController Document *controller.DocumentController KnowledgeBase *controller.KnowledgeBaseController Import *controller.ImportController Visitor *controller.VisitorController Health *controller.HealthController + Analytics *controller.AnalyticsController + SystemLog *controller.SystemLogController } // RegisterRoutes 注册 HTTP 路由及对应的处理函数。 func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.HandlerFunc) { - // Auth - r.POST("/login", controllers.Auth.Login) - r.POST("/logout", controllers.Auth.Logout) + register := func(routes gin.IRoutes) { + // Auth + routes.POST("/login", controllers.Auth.Login) + routes.POST("/logout", controllers.Auth.Logout) - // 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) - r.GET("/conversations/search", controllers.Conversation.SearchConversations) - r.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择) + // Conversation + routes.POST("/conversation/init", controllers.Conversation.InitConversation) + routes.POST("/conversations/internal", controllers.Conversation.InitInternalConversation) // 创建内部对话(知识库测试) + routes.GET("/conversations", controllers.Conversation.ListConversations) + routes.GET("/conversations/:id", controllers.Conversation.GetConversationDetail) + routes.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo) + routes.GET("/conversations/search", controllers.Conversation.SearchConversations) + routes.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择) - // Message - r.POST("/messages", controllers.Message.CreateMessage) - r.POST("/messages/upload", controllers.Message.UploadFile) // 文件上传接口(支持客服和访客上传) - r.GET("/messages", controllers.Message.ListMessages) - r.PUT("/messages/read", controllers.Message.MarkMessagesRead) + // Message + routes.POST("/messages", controllers.Message.CreateMessage) + routes.POST("/messages/upload", controllers.Message.UploadFile) // 文件上传接口(支持客服和访客上传) + routes.GET("/messages", controllers.Message.ListMessages) + routes.PUT("/messages/read", controllers.Message.MarkMessagesRead) - // Admin(用户管理) - r.GET("/admin/users", controllers.Admin.ListUsers) // 获取所有用户列表 - r.GET("/admin/users/:id", controllers.Admin.GetUser) // 获取用户详情 - r.POST("/admin/users", controllers.Admin.CreateUser) // 创建新用户 - r.PUT("/admin/users/:id", controllers.Admin.UpdateUser) // 更新用户信息 - r.DELETE("/admin/users/:id", controllers.Admin.DeleteUser) // 删除用户 - r.PUT("/admin/users/:id/password", controllers.Admin.UpdateUserPassword) // 更新用户密码 - // 兼容旧接口 - r.POST("/admin/agents", controllers.Admin.CreateAgent) // 创建客服(兼容旧接口) + // Admin(用户管理) + routes.GET("/admin/users", controllers.Admin.ListUsers) // 获取所有用户列表 + routes.GET("/admin/users/:id", controllers.Admin.GetUser) // 获取用户详情 + routes.POST("/admin/users", controllers.Admin.CreateUser) // 创建新用户 + routes.PUT("/admin/users/:id", controllers.Admin.UpdateUser) // 更新用户信息 + routes.DELETE("/admin/users/:id", controllers.Admin.DeleteUser) // 删除用户 + routes.PUT("/admin/users/:id/password", controllers.Admin.UpdateUserPassword) // 更新用户密码 + // 兼容旧接口 + routes.POST("/admin/agents", controllers.Admin.CreateAgent) // 创建客服(兼容旧接口) - // Profile(个人资料) - r.GET("/agent/profile/:user_id", controllers.Profile.GetProfile) - r.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile) - r.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar) + // Profile(个人资料) + routes.GET("/agent/profile/:user_id", controllers.Profile.GetProfile) + routes.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile) + routes.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar) - // AI Config(AI 配置) - r.POST("/agent/ai-config/:user_id", controllers.AIConfig.CreateAIConfig) - r.GET("/agent/ai-config/:user_id", controllers.AIConfig.ListAIConfigs) - r.GET("/agent/ai-config/:user_id/:id", controllers.AIConfig.GetAIConfig) - r.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig) - r.DELETE("/agent/ai-config/:user_id/:id", controllers.AIConfig.DeleteAIConfig) + // AI Config(AI 配置) + routes.POST("/agent/ai-config/:user_id", controllers.AIConfig.CreateAIConfig) + routes.GET("/agent/ai-config/:user_id", controllers.AIConfig.ListAIConfigs) + routes.GET("/agent/ai-config/:user_id/:id", controllers.AIConfig.GetAIConfig) + routes.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig) + routes.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) + // Embedding Config(知识库向量模型配置,平台级) + routes.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get) + routes.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update) - // FAQ(事件管理/常见问题) - r.GET("/faqs", controllers.FAQ.ListFAQs) // 获取 FAQ 列表(支持关键词搜索) - r.GET("/faqs/:id", controllers.FAQ.GetFAQ) // 获取 FAQ 详情 - r.POST("/faqs", controllers.FAQ.CreateFAQ) // 创建 FAQ - r.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ) // 更新 FAQ - r.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ) // 删除 FAQ + // Prompt Config(提示词配置,平台级,仅管理员可更新) + routes.GET("/agent/prompts", controllers.PromptConfig.Get) + routes.PUT("/agent/prompts", controllers.PromptConfig.Update) - // 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) // 取消发布文档 + // FAQ(事件管理/常见问题) + routes.GET("/faqs", controllers.FAQ.ListFAQs) // 获取 FAQ 列表(支持关键词搜索) + routes.GET("/faqs/:id", controllers.FAQ.GetFAQ) // 获取 FAQ 详情 + routes.POST("/faqs", controllers.FAQ.CreateFAQ) // 创建 FAQ + routes.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ) // 更新 FAQ + routes.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ) // 删除 FAQ - // 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) // 获取知识库的文档列表 + // Document(文档管理) + routes.GET("/documents", controllers.Document.ListDocuments) // 获取文档列表(支持分页、搜索、状态过滤) + routes.GET("/documents/:id", controllers.Document.GetDocument) // 获取文档详情 + routes.POST("/documents", controllers.Document.CreateDocument) // 创建文档 + routes.PUT("/documents/:id", controllers.Document.UpdateDocument) // 更新文档 + routes.DELETE("/documents/:id", controllers.Document.DeleteDocument) // 删除文档 + routes.GET("/documents/search", controllers.Document.SearchDocuments) // 向量检索搜索文档 + routes.GET("/documents/hybrid-search", controllers.Document.HybridSearchDocuments) // 混合检索搜索文档 + routes.PUT("/documents/:id/status", controllers.Document.UpdateDocumentStatus) // 更新文档状态 + routes.POST("/documents/:id/publish", controllers.Document.PublishDocument) // 发布文档 + routes.POST("/documents/:id/unpublish", controllers.Document.UnpublishDocument) // 取消发布文档 - // Import(文档导入) - r.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传) - r.POST("/import/urls", controllers.Import.ImportFromURLs) // 批量导入文档(URL 爬取) + // KnowledgeBase(知识库管理) + routes.GET("/knowledge-bases", controllers.KnowledgeBase.ListKnowledgeBases) // 获取知识库列表 + routes.GET("/knowledge-bases/:id", controllers.KnowledgeBase.GetKnowledgeBase) // 获取知识库详情 + routes.POST("/knowledge-bases", controllers.KnowledgeBase.CreateKnowledgeBase) // 创建知识库 + routes.PUT("/knowledge-bases/:id", controllers.KnowledgeBase.UpdateKnowledgeBase) // 更新知识库 + routes.PATCH("/knowledge-bases/:id/rag-enabled", controllers.KnowledgeBase.UpdateKnowledgeBaseRAGEnabled) // 知识库是否参与 RAG + routes.DELETE("/knowledge-bases/:id", controllers.KnowledgeBase.DeleteKnowledgeBase) // 删除知识库 + routes.GET("/knowledge-bases/:id/documents", controllers.KnowledgeBase.ListDocumentsByKnowledgeBase) // 获取知识库的文档列表 - // Visitor(访客相关) - r.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表 + // Import(文档导入) + routes.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传) + routes.POST("/import/urls", controllers.Import.ImportFromURLs) // 批量导入文档(URL 爬取) - // Health(健康检查) - r.GET("/health", controllers.Health.HealthCheck) // 健康检查 - r.GET("/health/metrics", controllers.Health.Metrics) // 性能指标 + // Visitor(访客相关) + routes.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表 + routes.GET("/visitor/widget-config", controllers.Visitor.GetWidgetConfig) // 访客小窗配置(联网设置等,无需登录) + routes.POST("/visitor/analytics/widget-open", controllers.Analytics.PostWidgetOpen) // 访客打开小窗埋点 - // WebSocket - r.GET("/ws", wsHandler) + // Analytics(数据分析报表,需客服 X-User-Id) + routes.GET("/agent/analytics/summary", controllers.Analytics.GetSummary) + routes.GET("/agent/logs/api", controllers.SystemLog.GetLogs) // 日志查询(避免与前端 /agent/logs 页面路径冲突) + routes.POST("/agent/logs/frontend", controllers.SystemLog.ReportFrontendLog) // 前端日志上报 + + // Health(健康检查) + routes.GET("/health", controllers.Health.HealthCheck) // 健康检查 + routes.GET("/health/metrics", controllers.Health.Metrics) // 性能指标 + + // WebSocket + routes.GET("/ws", wsHandler) + } + + // 兼容旧路径(无前缀) + register(r) + // 新路径:/api 前缀,便于反向代理“同域 API” + register(r.Group("/api")) } diff --git a/backend/service/ai_provider.go b/backend/service/ai_provider.go index 131a89b..b352f37 100644 --- a/backend/service/ai_provider.go +++ b/backend/service/ai_provider.go @@ -2,11 +2,14 @@ package service import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" "io" + "log" "net/http" + "strings" "time" ) @@ -14,10 +17,11 @@ import ( // 不同的 AI 服务提供商需要实现这个接口 type AIProvider interface { // GenerateResponse 生成 AI 回复 - // conversationHistory: 对话历史(用于上下文) - // userMessage: 用户当前消息 - // 返回: AI 回复内容 - GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error) + // imageBase64、imageMimeType 非空时表示当前用户消息带一张图(多模态识图),将与本条文本一起作为 user 消息发送 + GenerateResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error) + // GenerateResponseWithTools 带工具调用的生成;messages 与 tools 为 OpenAI 格式。返回 content、tool_calls、error。 + // 若某实现不支持,可返回 ( "", nil, err ) 或仅返回 content。 + GenerateResponseWithTools(messages []map[string]interface{}, tools []map[string]interface{}) (content string, toolCalls []ToolCall, err error) } // AdapterConfig 适配器配置(用于适配不同服务商的 API 格式差异) @@ -36,6 +40,13 @@ type MessageHistory struct { Content string `json:"content"` // 消息内容 } +// ToolCall 模型返回的工具调用(OpenAI 格式) +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON 字符串 +} + // AIConfig 用于 AI 调用的配置信息 type AIConfig struct { APIURL string @@ -50,8 +61,8 @@ type AIConfig struct { // 通过适配器配置来适配不同服务商的细微差异 // 这样 90% 的服务商都可以用同一个 Provider,无需单独实现 type UniversalAIProvider struct { - config AIConfig - client *http.Client + config AIConfig + client *http.Client adapter *AdapterConfig } @@ -61,8 +72,8 @@ func NewUniversalAIProvider(config AIConfig) *UniversalAIProvider { adapter := config.AdapterConfig if adapter == nil { adapter = &AdapterConfig{ - AuthHeader: "Bearer", // 默认使用 Bearer Token - ResponsePath: "choices[0].message.content", // 默认 OpenAI 格式 + AuthHeader: "Bearer", // 默认使用 Bearer Token + ResponsePath: "choices[0].message.content", // 默认 OpenAI 格式 } } else { // 设置默认值 @@ -75,57 +86,74 @@ func NewUniversalAIProvider(config AIConfig) *UniversalAIProvider { } return &UniversalAIProvider{ - config: config, + config: config, client: &http.Client{ - Timeout: 30 * time.Second, // 30 秒超时 + Timeout: 60 * time.Second, // 60 秒超时 }, adapter: adapter, } } +// isResponsesAPI 判断是否为 OpenAI Responses API(/v1/responses),请求/响应格式与 Chat Completions 不同。 +func isResponsesAPI(apiURL string) bool { + return strings.Contains(apiURL, "/v1/responses") +} + // GenerateResponse 生成 AI 回复(支持 OpenAI 兼容格式,通过适配器适配不同服务商)。 -func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error) { - // 根据模型类型选择不同的处理逻辑 +func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error) { switch p.config.ModelType { case "text": - return p.generateTextResponse(conversationHistory, userMessage) + return p.generateTextResponse(conversationHistory, userMessage, imageBase64, imageMimeType) case "image": - // 图片生成(未来扩展) - return "", fmt.Errorf("图片模型暂未支持") + return "", fmt.Errorf("图片模型请使用生图接口") case "audio": - // 语音识别/合成(未来扩展) return "", fmt.Errorf("语音模型暂未支持") case "video": - // 视频生成(未来扩展) return "", fmt.Errorf("视频模型暂未支持") default: return "", fmt.Errorf("不支持的模型类型: %s", p.config.ModelType) } } -// generateTextResponse 生成文本回复(通用实现,支持所有 OpenAI 兼容格式)。 -func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string) (string, error) { - // 构建消息列表(包含历史对话和当前消息) - messages := make([]map[string]string, 0) - - // 添加历史对话 - for _, history := range conversationHistory { - messages = append(messages, map[string]string{ - "role": history.Role, - "content": history.Content, - }) +// buildUserContent 构建当前用户消息的 content:纯文本或 text+image(多模态) +func buildUserContent(userMessage string, imageBase64 string, imageMimeType string) interface{} { + if imageBase64 == "" { + return userMessage } + // OpenAI 多模态:content 为数组,text + image_url(data URL) + dataURL := "data:" + imageMimeType + ";base64," + imageBase64 + if imageMimeType == "" { + dataURL = "data:image/jpeg;base64," + imageBase64 + } + parts := []map[string]interface{}{ + {"type": "text", "text": userMessage}, + {"type": "image_url", "image_url": map[string]string{"url": dataURL}}, + } + return parts +} - // 添加当前用户消息 - messages = append(messages, map[string]string{ - "role": "user", - "content": userMessage, - }) +// generateTextResponse 生成文本回复(支持多模态:当前用户消息可带图)。 +func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error) { + // 使用 interface{} 以支持最后一条 user 消息的 content 为数组(多模态) + messages := make([]map[string]interface{}, 0) + for _, history := range conversationHistory { + messages = append(messages, map[string]interface{}{"role": history.Role, "content": history.Content}) + } + lastContent := buildUserContent(userMessage, imageBase64, imageMimeType) + messages = append(messages, map[string]interface{}{"role": "user", "content": lastContent}) - // 构建请求体(OpenAI 兼容格式) - requestBody := map[string]interface{}{ - "model": p.config.Model, - "messages": messages, + var requestBody map[string]interface{} + if isResponsesAPI(p.config.APIURL) { + requestBody = map[string]interface{}{ + "model": p.config.Model, + "input": messages, + "stream": false, + } + } else { + requestBody = map[string]interface{}{ + "model": p.config.Model, + "messages": messages, + } } jsonData, err := json.Marshal(requestBody) @@ -141,7 +169,7 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message // 设置请求头 req.Header.Set("Content-Type", "application/json") - + // 根据适配器配置设置认证头 authValue := p.config.APIKey if p.adapter.AuthHeader == "Bearer" { @@ -154,9 +182,11 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message req.Header.Set("Authorization", "Bearer "+p.config.APIKey) } - // 发送请求 + // 发送请求(若发生重定向,req.URL 会被 Client 更新为最终 URL;失败日志便于与配置里的 api_url 对照) resp, err := p.client.Do(req) if err != nil { + log.Printf("⚠️ AI generateTextResponse 请求失败: config.api_url=%s 实际 req.URL=%s err=%v", + p.config.APIURL, req.URL.String(), err) return "", fmt.Errorf("请求失败: %v", err) } defer resp.Body.Close() @@ -198,12 +228,194 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message return content, nil } +// GenerateResponseWithTools 带工具调用的生成(OpenAI 兼容:tools + tool_calls)。 +// messages 为 OpenAI 格式消息数组(可含 role, content, tool_calls, tool_call_id 等)。 +// tools 为工具定义数组(如 [{"type":"function","function":{...}}] 或 [{"type":"web_search"}])。 +// 返回 content、tool_calls(若有)、error。 +func (p *UniversalAIProvider) GenerateResponseWithTools(messages []map[string]interface{}, tools []map[string]interface{}) (content string, toolCalls []ToolCall, err error) { + if p.config.ModelType != "text" { + return "", nil, fmt.Errorf("带工具调用仅支持 text 模型") + } + var requestBody map[string]interface{} + if isResponsesAPI(p.config.APIURL) { + requestBody = map[string]interface{}{ + "model": p.config.Model, + "input": messages, + "stream": false, + "tool_choice": "auto", + } + if len(tools) > 0 { + requestBody["tools"] = tools + } + } else { + requestBody = map[string]interface{}{ + "model": p.config.Model, + "messages": messages, + } + if len(tools) > 0 { + requestBody["tools"] = tools + } + } + jsonData, err := json.Marshal(requestBody) + if err != nil { + return "", nil, fmt.Errorf("序列化请求失败: %v", err) + } + req, err := http.NewRequest("POST", p.config.APIURL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", nil, fmt.Errorf("创建请求失败: %v", err) + } + req.Header.Set("Content-Type", "application/json") + authValue := p.config.APIKey + if p.adapter.AuthHeader == "Bearer" { + authValue = "Bearer " + p.config.APIKey + req.Header.Set("Authorization", authValue) + } else if p.adapter.AuthHeader == "X-API-Key" { + req.Header.Set("X-API-Key", p.config.APIKey) + } else { + req.Header.Set("Authorization", "Bearer "+p.config.APIKey) + } + resp, err := p.client.Do(req) + if err != nil { + log.Printf("⚠️ AI GenerateResponseWithTools 请求失败: config.api_url=%s 实际 req.URL=%s err=%v", + p.config.APIURL, req.URL.String(), err) + return "", nil, fmt.Errorf("请求失败: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("读取响应失败: %v", err) + } + if resp.StatusCode != http.StatusOK { + return "", nil, fmt.Errorf("API 返回错误: %s (状态码: %d)", string(body), resp.StatusCode) + } + var responseData map[string]interface{} + if err := json.Unmarshal(body, &responseData); err != nil { + return "", nil, fmt.Errorf("解析响应失败: %v", err) + } + if errorMsg, ok := responseData["error"].(map[string]interface{}); ok { + if msg, ok := errorMsg["message"].(string); ok { + return "", nil, fmt.Errorf("API 错误: %s", msg) + } + } + content, toolCalls = p.extractContentAndToolCalls(responseData) + return content, toolCalls, nil +} + +// extractContentAndToolCalls 从响应中提取 content 与 tool_calls +// 支持 Chat Completions(choices[0].message)与 Responses API(output[]) +func (p *UniversalAIProvider) extractContentAndToolCalls(data map[string]interface{}) (content string, toolCalls []ToolCall) { + // Responses API:output 数组内 message 的 content 含 output_text 与 tool_use + if output, ok := data["output"].([]interface{}); ok { + var textParts []string + for _, item := range output { + obj, _ := item.(map[string]interface{}) + if obj == nil || getStr(obj, "type") != "message" { + continue + } + contentParts, _ := obj["content"].([]interface{}) + for _, part := range contentParts { + pm, _ := part.(map[string]interface{}) + if pm == nil { + continue + } + switch getStr(pm, "type") { + case "output_text": + if t, ok := pm["text"].(string); ok && t != "" { + textParts = append(textParts, t) + } + case "tool_use": + args := getStr(pm, "input") + if args == "" { + args = "{}" + } + toolCalls = append(toolCalls, ToolCall{ + ID: getStr(pm, "id"), + Name: getStr(pm, "name"), + Arguments: args, + }) + } + } + } + if len(textParts) > 0 { + content = strings.Join(textParts, "") + } + return content, toolCalls + } + + // Chat Completions 格式 + choices, _ := data["choices"].([]interface{}) + if len(choices) == 0 { + return "", nil + } + choice, _ := choices[0].(map[string]interface{}) + message, _ := choice["message"].(map[string]interface{}) + if message != nil { + if c, ok := message["content"].(string); ok { + content = c + } + tcList, _ := message["tool_calls"].([]interface{}) + for _, t := range tcList { + tm, _ := t.(map[string]interface{}) + fn, _ := tm["function"].(map[string]interface{}) + args := "" + if fn != nil { + if a, ok := fn["arguments"].(string); ok { + args = a + } + } + toolCalls = append(toolCalls, ToolCall{ + ID: getStr(tm, "id"), + Name: getStr(fn, "name"), + Arguments: args, + }) + } + } + return content, toolCalls +} + +func getStr(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + v, _ := m[key].(string) + return v +} + // extractResponseContent 根据响应路径提取内容(支持灵活的路径配置)。 -// 例如:"choices[0].message.content" 或 "data.text" 或 "result.content" +// 例如:"choices[0].message.content"(Chat Completions)或 Responses API 的 output[].content[].text func (p *UniversalAIProvider) extractResponseContent(data map[string]interface{}, path string) (string, error) { - // 默认路径:choices[0].message.content(OpenAI 格式) + // Responses API:output 数组内 message 的 content 中 output_text 的 text + if output, ok := data["output"].([]interface{}); ok && len(output) > 0 { + for _, item := range output { + obj, _ := item.(map[string]interface{}) + if obj == nil { + continue + } + if getStr(obj, "type") != "message" { + continue + } + contentParts, _ := obj["content"].([]interface{}) + var textParts []string + for _, part := range contentParts { + pm, _ := part.(map[string]interface{}) + if pm == nil { + continue + } + if getStr(pm, "type") == "output_text" { + if t, ok := pm["text"].(string); ok && t != "" { + textParts = append(textParts, t) + } + } + } + if len(textParts) > 0 { + return strings.Join(textParts, ""), nil + } + } + return "", errors.New("Responses API 的 output 中未找到 message 文本") + } + + // 默认路径:choices[0].message.content(OpenAI Chat Completions 格式) if path == "" || path == "choices[0].message.content" { - // 尝试 OpenAI 格式 if choices, ok := data["choices"].([]interface{}); ok && len(choices) > 0 { if choice, ok := choices[0].(map[string]interface{}); ok { if message, ok := choice["message"].(map[string]interface{}); ok { @@ -251,12 +463,166 @@ func NewAIProviderFactory() *AIProviderFactory { return &AIProviderFactory{} } +// ImageGenerationProvider 生图接口(用于 chat_mode=image 渠道) +type ImageGenerationProvider interface { + // GenerateImage 根据文本描述生成图片,返回图片二进制与 MIME 类型 + GenerateImage(prompt string) (imageData []byte, mimeType string, err error) +} + // CreateProvider 根据配置创建对应的 AI 提供商。 -// 设计理念: -// 所有主流 AI 服务商都使用 REST API(HTTP/HTTPS),统一使用 UniversalAIProvider 处理 -// 通过 AdapterConfig 适配不同服务商的细微差异(认证头、响应路径等) func (f *AIProviderFactory) CreateProvider(config AIConfig) (AIProvider, error) { - // 所有服务商都使用 REST API,统一处理 return NewUniversalAIProvider(config), nil } +// isPoixeGeminiImageAPI 判断是否为 Poixe 的 Google Gemini Content 生图接口(需 x-goog-api-key 且请求/响应格式不同) +func isPoixeGeminiImageAPI(apiURL string) bool { + lower := strings.ToLower(apiURL) + return (strings.Contains(lower, "poixe.com") && strings.Contains(lower, "generatecontent")) || + (strings.Contains(lower, "poixe.com") && strings.Contains(lower, "v1beta")) +} + +// GenerateImage 实现生图(model_type=image 的配置使用)。支持 OpenAI Images 与 Poixe Nano Banana(Gemini Content)两种协议。 +func (p *UniversalAIProvider) GenerateImage(prompt string) (imageData []byte, mimeType string, err error) { + if p.config.ModelType != "image" { + return nil, "", fmt.Errorf("当前配置不是生图模型,model_type=%s", p.config.ModelType) + } + useGemini := isPoixeGeminiImageAPI(p.config.APIURL) + var jsonData []byte + if useGemini { + // Poixe Nano Banana:Google Gemini Content 协议,见 https://docs.poixe.com/cn/docs/models-pricing/nano-banana + body := map[string]interface{}{ + "contents": []map[string]interface{}{ + {"parts": []map[string]interface{}{{"text": prompt}}}, + }, + "generationConfig": map[string]interface{}{ + "responseModalities": []string{"Text", "Image"}, + "imageConfig": map[string]interface{}{ + "aspectRatio": "1:1", + "imageSize": "1K", + }, + }, + } + jsonData, err = json.Marshal(body) + if err != nil { + return nil, "", err + } + } else { + // OpenAI 兼容:/v1/images/generations + body := map[string]interface{}{ + "model": p.config.Model, + "prompt": prompt, + "n": 1, + } + if strings.Contains(strings.ToLower(p.config.APIURL), "images") { + body["response_format"] = "b64_json" + body["size"] = "1024x1024" + } + jsonData, err = json.Marshal(body) + if err != nil { + return nil, "", err + } + } + req, err := http.NewRequest("POST", p.config.APIURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, "", err + } + req.Header.Set("Content-Type", "application/json") + // Poixe Gemini Content 要求 x-goog-api-key;适配器可显式指定,或按 URL 自动识别 + useGoogKey := (p.adapter != nil && strings.ToLower(p.adapter.AuthHeader) == "x-goog-api-key") || useGemini + if useGoogKey { + req.Header.Set("x-goog-api-key", p.config.APIKey) + } else if p.adapter != nil && p.adapter.AuthHeader == "X-API-Key" { + req.Header.Set("X-API-Key", p.config.APIKey) + } else { + req.Header.Set("Authorization", "Bearer "+p.config.APIKey) + } + resp, err := p.client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("生图 API 错误: %s (状态码: %d)", string(data), resp.StatusCode) + } + if useGemini { + return p.parseGeminiImageResponse(data) + } + return p.parseOpenAIImageResponse(data) +} + +// parseGeminiImageResponse 解析 Poixe/Gemini Content 生图响应:candidates[0].content.parts 中的 inlineData +func (p *UniversalAIProvider) parseGeminiImageResponse(data []byte) (imageData []byte, mimeType string, err error) { + var parsed struct { + Candidates []struct { + Content struct { + Parts []struct { + InlineData *struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` + } `json:"inlineData"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + return nil, "", fmt.Errorf("解析 Gemini 生图响应失败: %w", err) + } + if len(parsed.Candidates) == 0 { + return nil, "", errors.New("Gemini 生图未返回 candidates") + } + for _, part := range parsed.Candidates[0].Content.Parts { + if part.InlineData != nil && part.InlineData.Data != "" { + decoded, err := base64.StdEncoding.DecodeString(part.InlineData.Data) + if err != nil { + return nil, "", fmt.Errorf("base64 解码失败: %w", err) + } + mt := part.InlineData.MimeType + if mt == "" { + mt = "image/png" + } + return decoded, mt, nil + } + } + return nil, "", errors.New("Gemini 生图响应中无 inlineData") +} + +// parseOpenAIImageResponse 解析 OpenAI 风格生图响应:data[0].b64_json 或 data[0].url +func (p *UniversalAIProvider) parseOpenAIImageResponse(data []byte) (imageData []byte, mimeType string, err error) { + var parsed struct { + Data []struct { + B64JSON *string `json:"b64_json"` + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + return nil, "", fmt.Errorf("解析生图响应失败: %w", err) + } + if len(parsed.Data) == 0 { + return nil, "", errors.New("生图 API 未返回图片") + } + first := parsed.Data[0] + if first.B64JSON != nil && *first.B64JSON != "" { + decoded, err := base64.StdEncoding.DecodeString(*first.B64JSON) + if err != nil { + return nil, "", fmt.Errorf("base64 解码失败: %w", err) + } + return decoded, "image/png", nil + } + if first.URL != "" { + getResp, err := http.Get(first.URL) + if err != nil { + return nil, "", fmt.Errorf("下载生成图片失败: %w", err) + } + defer getResp.Body.Close() + decoded, err := io.ReadAll(getResp.Body) + if err != nil { + return nil, "", err + } + return decoded, "image/png", nil + } + return nil, "", errors.New("生图响应中无 b64_json 或 url") +} diff --git a/backend/service/ai_service.go b/backend/service/ai_service.go index b5a00d9..c0c9c15 100644 --- a/backend/service/ai_service.go +++ b/backend/service/ai_service.go @@ -1,13 +1,18 @@ package service import ( + "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" "log" "strings" + "time" + "github.com/2930134478/AI-CS/backend/infra" + "github.com/2930134478/AI-CS/backend/infra/search" "github.com/2930134478/AI-CS/backend/models" "github.com/2930134478/AI-CS/backend/repository" "github.com/2930134478/AI-CS/backend/service/rag" @@ -17,102 +22,181 @@ import ( // AIService AI 服务(负责调用 AI 生成回复) type AIService struct { - aiConfigRepo *repository.AIConfigRepository - messageRepo *repository.MessageRepository - conversationRepo *repository.ConversationRepository - retrievalService *rag.RetrievalService // RAG 检索服务 - providerFactory *AIProviderFactory + aiConfigRepo *repository.AIConfigRepository + messageRepo *repository.MessageRepository + conversationRepo *repository.ConversationRepository + retrievalService *rag.RetrievalService + providerFactory *AIProviderFactory + webSearchProvider search.WebSearchProvider // 可选,自建联网时用 + embeddingConfigSvc *EmbeddingConfigService // 读取联网方式:厂商内置 / 自建 + promptConfigSvc *PromptConfigService // 可选,提示词配置(为空则用代码内默认) + storageService infra.StorageService // 可选,用于多模态识图时读取消息附件 + systemLogSvc *SystemLogService // 可选,结构化日志服务 } -// NewAIService 创建 AI 服务实例。 +// NewAIService 创建 AI 服务实例。webSearchProvider、storageService 可为 nil。 func NewAIService( aiConfigRepo *repository.AIConfigRepository, messageRepo *repository.MessageRepository, conversationRepo *repository.ConversationRepository, - retrievalService *rag.RetrievalService, // 添加 RAG 检索服务 + retrievalService *rag.RetrievalService, + webSearchProvider search.WebSearchProvider, + embeddingConfigSvc *EmbeddingConfigService, + promptConfigSvc *PromptConfigService, + storageService infra.StorageService, + systemLogSvc *SystemLogService, ) *AIService { return &AIService{ - aiConfigRepo: aiConfigRepo, - messageRepo: messageRepo, - conversationRepo: conversationRepo, - retrievalService: retrievalService, - providerFactory: NewAIProviderFactory(), + aiConfigRepo: aiConfigRepo, + messageRepo: messageRepo, + conversationRepo: conversationRepo, + retrievalService: retrievalService, + providerFactory: NewAIProviderFactory(), + webSearchProvider: webSearchProvider, + embeddingConfigSvc: embeddingConfigSvc, + promptConfigSvc: promptConfigSvc, + storageService: storageService, + systemLogSvc: systemLogSvc, } } -// GenerateAIResponse 为对话生成 AI 回复。 -// conversationID: 对话ID -// userMessage: 用户消息 -// userID: 用户ID(用于回退查找 AI 配置) -// 返回: AI 回复内容,如果失败返回错误 +// GenerateAIResponse 为对话生成 AI 回复(兼容旧调用,使用默认数据源选项)。 +// 返回: AI 回复内容,若失败返回错误。 func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, userID uint) (string, error) { - // 1. 获取对话信息,优先使用对话绑定的 AI 配置 - conversation, err := s.conversationRepo.GetByID(conversationID) + res, err := s.GenerateAIResponseWithOptions(conversationID, userMessage, userID, nil) if err != nil { - return "", fmt.Errorf("获取对话失败: %v", err) + return "", err + } + return res.Content, nil +} + +// GenerateAIResponseWithOptions 根据数据源开关生成一条合成回复,并返回使用的来源标记。 +// opts 为 nil 时使用默认:知识库+大模型开,联网关。 +func (s *AIService) GenerateAIResponseWithOptions(conversationID uint, userMessage string, userID uint, opts *GenerateAIResponseInput) (*GenerateAIResponseResult, error) { + useKB := true + useLLM := true + useWeb := false + needWeb := false + if opts != nil { + if opts.UseKnowledgeBase != nil { + useKB = *opts.UseKnowledgeBase + } + if opts.UseLLM != nil { + useLLM = *opts.UseLLM + } + if opts.UseWebSearch != nil { + useWeb = *opts.UseWebSearch + } + needWeb = opts.NeedWebSearch } + conversation, err := s.conversationRepo.GetByID(conversationID) + if err != nil { + return nil, fmt.Errorf("获取对话失败: %v", err) + } + + // 以下 config 为「AI 配置」:对话/联网均使用此接口;与「知识库向量配置」(embedding,如 nekoai)无关。 var config *models.AIConfig if conversation.AIConfigID != nil { - // 使用对话绑定的配置(多厂商支持) config, err = s.aiConfigRepo.GetByID(*conversation.AIConfigID) if err != nil { - return "", fmt.Errorf("获取 AI 配置失败: %v", err) + return nil, fmt.Errorf("获取 AI 配置失败: %v", err) } - // 验证配置是否启用 if !config.IsActive { - return "", errors.New("该模型配置已禁用") + return nil, errors.New("该模型配置已禁用") } } else { - // 回退:使用用户默认配置(向后兼容) config, err = s.aiConfigRepo.GetActiveByUserID(userID, "text") if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return "", errors.New("未找到 AI 配置,请先在设置中配置 AI 服务") + return nil, errors.New("未找到 AI 配置,请先在设置中配置 AI 服务") } - return "", fmt.Errorf("获取 AI 配置失败: %v", err) + return nil, fmt.Errorf("获取 AI 配置失败: %v", err) } } - // 2. 解密 API Key apiKey, err := utils.DecryptAPIKey(config.APIKey) if err != nil { - return "", fmt.Errorf("解密 API Key 失败: %v", err) + return nil, fmt.Errorf("解密 API Key 失败: %v", err) + } + + // 若当前 AI 配置为生图模型(model_type=image),则直接走生图逻辑, + // 不参与 RAG/联网与文本对话流程。前端仍显示在「AI 客服」渠道下。 + if config.ModelType == "image" { + log.Printf("[生图] 对话ID=%d 使用 model_type=image 配置 id=%d,走 GenerateImageReply", conversationID, config.ID) + return s.GenerateImageReply(conversationID, userMessage, userID) + } + + // 调试:确认本条对话实际使用的 AI 配置(便于排查联网/厂商内置是否走对接口) + if needWeb || useWeb { + convAIConfigID := "nil" + if conversation.AIConfigID != nil { + convAIConfigID = fmt.Sprintf("%d", *conversation.AIConfigID) + } + apiURLMask := config.APIURL + if len(apiURLMask) > 50 { + apiURLMask = apiURLMask[:50] + "..." + } + log.Printf("[联网] 对话ID=%d 使用的AI配置: conversation.ai_config_id=%s, config.id=%d, provider=%s, api_url=%s", + conversationID, convAIConfigID, config.ID, config.Provider, apiURLMask) } - // 3. 获取对话历史(用于上下文) history, err := s.buildConversationHistory(conversationID) if err != nil { log.Printf("⚠️ 获取对话历史失败: %v", err) - // 即使获取历史失败,也继续处理(使用空历史) history = []MessageHistory{} } - // 4. RAG 检索:从知识库中检索相关文档 - ragContext := "" - if s.retrievalService != nil { + // 多模态识图:当前条带图时读取文件并转 base64 供 provider 使用 + var imageBase64, imageMimeType string + if opts != nil && opts.Attachment != nil && opts.Attachment.FileType == "image" && opts.Attachment.FileURL != "" && s.storageService != nil { + data, err := s.storageService.ReadMessageFile(opts.Attachment.FileURL) + if err != nil { + log.Printf("⚠️ 读取消息图片失败: %v", err) + } else { + imageBase64 = base64.StdEncoding.EncodeToString(data) + imageMimeType = opts.Attachment.MimeType + if imageMimeType == "" { + imageMimeType = "image/jpeg" + } + } + } + + var ragContext string + ragStartedAt := time.Now() + if useKB && s.retrievalService != nil { ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation) if err != nil { - log.Printf("⚠️ RAG 检索失败: %v,继续使用无知识库上下文", err) - // RAG 检索失败不影响主流程,继续处理 + log.Printf("⚠️ RAG 检索失败: %v", err) + } + if s.systemLogSvc != nil { + hit := strings.TrimSpace(ragContext) != "" + convID := conversationID + uID := userID + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "info", + Category: "rag", + Event: "rag_context_result", + Source: "backend", + ConversationID: &convID, + UserID: &uID, + Message: "RAG 检索完成", + Meta: map[string]interface{}{ + "hit": hit, + "context_len": len(ragContext), + "elapsed_ms": time.Since(ragStartedAt).Milliseconds(), + "use_kb": useKB, + "need_web": needWeb, + "use_web": useWeb, + }, + }) } } - // 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 { - log.Printf("⚠️ 解析适配器配置失败: %v,使用默认配置", err) - } + _ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig) } - - // 7. 创建 AI 提供商 aiConfig := AIConfig{ APIURL: config.APIURL, APIKey: apiKey, @@ -121,21 +205,330 @@ func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, Provider: config.Provider, AdapterConfig: adapterConfig, } - provider, err := s.providerFactory.CreateProvider(aiConfig) if err != nil { - return "", fmt.Errorf("创建 AI 提供商失败: %v", err) + return nil, fmt.Errorf("创建 AI 提供商失败: %v", err) } - // 8. 调用 AI 生成回复(使用增强的消息) - response, err := provider.GenerateResponse(history, enhancedUserMessage) + var sources []string + enhancedMessage := userMessage + + // 1) 有知识库匹配:以知识库为主生成;若本回合允许联网,则用增强 prompt + 联网工具,由模型在无关/不足时用自身知识或联网 + if ragContext != "" { + sources = append(sources, "knowledge_base") + if needWeb && useWeb { + webSource := "custom" + if s.embeddingConfigSvc != nil { + webSource, _ = s.embeddingConfigSvc.GetWebSearchSource() + } + enhancedMessage = s.buildRAGPromptWithWebOptional(userMessage, ragContext) + content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, enhancedMessage, webSource, imageBase64, imageMimeType) + if err != nil { + log.Printf("⚠️ RAG+联网(function calling)失败: %v,回退到仅 RAG", err) + if s.systemLogSvc != nil { + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "warn", + Category: "ai", + Event: "rag_web_fallback", + Source: "backend", + ConversationID: &conversationID, + UserID: &userID, + Message: "RAG+联网失败,回退到仅RAG", + Meta: map[string]interface{}{ + "error": err.Error(), + "web_source": webSource, + "ai_config": config.ID, + }, + }) + } + if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) { + log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。") + } + enhancedMessage = s.buildRAGPrompt(userMessage, ragContext) + } else if content != "" { + sources = append(sources, "llm") + if usedWeb { + sources = append(sources, "web") + } + if s.systemLogSvc != nil { + convID := conversationID + uID := userID + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "info", + Category: "ai", + Event: "ai_web_success", + Source: "backend", + ConversationID: &convID, + UserID: &uID, + Message: "RAG+联网生成成功", + Meta: map[string]interface{}{ + "sources": strings.Join(sources, ","), + }, + }) + } + return &GenerateAIResponseResult{ + Content: content, + SourcesUsed: strings.Join(sources, ","), + }, nil + } else { + enhancedMessage = s.buildRAGPrompt(userMessage, ragContext) + } + } else { + enhancedMessage = s.buildRAGPrompt(userMessage, ragContext) + } + } else { + // 2) 无知识库匹配:本回合允许联网时走「模型决定搜」function calling;否则仅用大模型知识 + if needWeb && useWeb { + webSource := "custom" + if s.embeddingConfigSvc != nil { + webSource, _ = s.embeddingConfigSvc.GetWebSearchSource() + } + content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, userMessage, webSource, imageBase64, imageMimeType) + if err != nil { + log.Printf("⚠️ 联网(function calling)失败: %v,回退到仅大模型", err) + if s.systemLogSvc != nil { + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "warn", + Category: "ai", + Event: "web_fallback_to_llm", + Source: "backend", + ConversationID: &conversationID, + UserID: &userID, + Message: "联网失败,回退到仅大模型", + Meta: map[string]interface{}{ + "error": err.Error(), + "web_source": webSource, + "ai_config": config.ID, + }, + }) + } + if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) { + log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。") + } + } else if content != "" { + sources = append(sources, "llm") + if usedWeb { + sources = append(sources, "web") + } + if s.systemLogSvc != nil { + convID := conversationID + uID := userID + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "info", + Category: "ai", + Event: "ai_web_success", + Source: "backend", + ConversationID: &convID, + UserID: &uID, + Message: "联网生成成功", + Meta: map[string]interface{}{ + "sources": strings.Join(sources, ","), + }, + }) + } + return &GenerateAIResponseResult{ + Content: content, + SourcesUsed: strings.Join(sources, ","), + }, nil + } + } + if useLLM && len(sources) == 0 { + enhancedMessage = s.buildNoKBPrompt(userMessage) + sources = append(sources, "llm") + } else if useLLM && len(sources) > 0 { + sources = append(sources, "llm") + } + } + + // 无任何来源时(例如 useKB 且无匹配,useLLM 关):使用可配置回复语 + if len(sources) == 0 { + reply := s.getNoSourceReply() + return &GenerateAIResponseResult{ + Content: reply, + SourcesUsed: "", + }, nil + } + + response, err := provider.GenerateResponse(history, enhancedMessage, imageBase64, imageMimeType) if err != nil { - // AI 调用失败,返回友好的错误消息 log.Printf("❌ AI 调用失败: %v", err) - return "AI客服好像出了点差错,请联系人工客服解决", nil + if s.systemLogSvc != nil { + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "error", + Category: "ai", + Event: "ai_generate_failed", + Source: "backend", + ConversationID: &conversationID, + UserID: &userID, + Message: "AI 调用失败,返回兜底回复", + Meta: map[string]interface{}{ + "error": err.Error(), + "ai_config": config.ID, + }, + }) + } + return &GenerateAIResponseResult{ + Content: s.getAIFailReply(), + SourcesUsed: strings.Join(sources, ","), + GenerationFailed: true, + }, nil + } + if s.systemLogSvc != nil { + convID := conversationID + uID := userID + event := "ai_llm_success" + if strings.Contains(strings.Join(sources, ","), "knowledge_base") { + event = "ai_rag_success" + } + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "info", + Category: "ai", + Event: event, + Source: "backend", + ConversationID: &convID, + UserID: &uID, + Message: "AI 生成成功", + Meta: map[string]interface{}{ + "sources": strings.Join(sources, ","), + }, + }) } - return response, nil + return &GenerateAIResponseResult{ + Content: response, + SourcesUsed: strings.Join(sources, ","), + }, nil +} + +// GenerateImageReply 生图渠道专用:根据用户描述生成图片并保存到存储,返回说明文案与图片 URL。 +func (s *AIService) GenerateImageReply(conversationID uint, prompt string, userID uint) (*GenerateAIResponseResult, error) { + conversation, err := s.conversationRepo.GetByID(conversationID) + if err != nil { + return nil, fmt.Errorf("获取对话失败: %v", err) + } + if conversation.AIConfigID == nil { + return nil, errors.New("生图渠道需要选择生图模型,请先在渠道中选择「生图绘画」并选择模型") + } + config, err := s.aiConfigRepo.GetByID(*conversation.AIConfigID) + if err != nil { + return nil, fmt.Errorf("获取 AI 配置失败: %v", err) + } + if !config.IsActive { + return nil, errors.New("该生图模型已禁用") + } + if config.ModelType != "image" { + return nil, fmt.Errorf("当前选择的不是生图模型,model_type=%s", config.ModelType) + } + apiKey, err := utils.DecryptAPIKey(config.APIKey) + if err != nil { + return nil, fmt.Errorf("解密 API Key 失败: %v", err) + } + var adapterConfig *AdapterConfig + if config.AdapterConfig != "" { + _ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig) + } + aiConfig := AIConfig{ + APIURL: config.APIURL, + APIKey: apiKey, + Model: config.Model, + ModelType: config.ModelType, + Provider: config.Provider, + AdapterConfig: adapterConfig, + } + provider, err := s.providerFactory.CreateProvider(aiConfig) + if err != nil { + return nil, err + } + imgProvider, ok := provider.(ImageGenerationProvider) + if !ok { + return nil, errors.New("当前提供商不支持生图") + } + imageData, mimeType, err := imgProvider.GenerateImage(prompt) + if err != nil { + return nil, err + } + if s.storageService == nil { + return nil, errors.New("存储服务未配置,无法保存生成图片") + } + ext := ".png" + if strings.Contains(mimeType, "jpeg") || strings.Contains(mimeType, "jpg") { + ext = ".jpg" + } + fileURL, err := s.storageService.SaveMessageFile(conversationID, bytes.NewReader(imageData), "generated"+ext) + if err != nil { + return nil, fmt.Errorf("保存生成图片失败: %v", err) + } + content := "已根据您的描述生成图片。" + return &GenerateAIResponseResult{ + Content: content, + SourcesUsed: "", + GeneratedFileURL: &fileURL, + }, nil +} + +func (s *AIService) buildNoKBPrompt(userMessage string) string { + if s.promptConfigSvc != nil { + tpl, err := s.promptConfigSvc.GetNoKBPromptTemplate() + if err == nil && tpl != "" { + return replaceUserMessageOnly(tpl, userMessage) + } + } + return fmt.Sprintf(`你是一个智能客服助手。当前未使用知识库,请仅基于你的知识回答用户问题。 + +用户问题:%s + +请简洁、友好地回答。若无法回答,可建议用户联系人工客服。`, userMessage) +} + +func (s *AIService) buildWebSearchPrompt(userMessage string, webContext string) string { + if s.promptConfigSvc != nil { + tpl, err := s.promptConfigSvc.GetWebSearchResultPromptTemplate() + if err == nil && tpl != "" { + return replaceWebSearchPlaceholders(tpl, webContext, userMessage) + } + } + return fmt.Sprintf(`你是一个智能客服助手。请结合以下联网搜索结果回答用户问题。 + +联网搜索结果: +%s + +用户问题:%s + +请基于以上内容给出简洁、准确的回答。`, webContext, userMessage) +} + +// replaceUserMessageOnly 仅替换 {{user_message}} +func replaceUserMessageOnly(template, userMessage string) string { + return strings.ReplaceAll(template, "{{user_message}}", userMessage) +} + +// replaceWebSearchPlaceholders 替换 {{web_context}}、{{user_message}} +func replaceWebSearchPlaceholders(template, webContext, userMessage string) string { + template = strings.ReplaceAll(template, "{{web_context}}", webContext) + template = strings.ReplaceAll(template, "{{user_message}}", userMessage) + return template +} + +// getNoSourceReply 无任何来源时返回给用户的一句话(可配置) +func (s *AIService) getNoSourceReply() string { + if s.promptConfigSvc != nil { + reply, err := s.promptConfigSvc.GetNoSourceReply() + if err == nil && strings.TrimSpace(reply) != "" { + return strings.TrimSpace(reply) + } + } + return "当前知识库暂无与此问题相关的内容,您可以尝试联系人工客服。" +} + +// getAIFailReply AI 调用失败时返回给用户的一句话(可配置) +func (s *AIService) getAIFailReply() string { + if s.promptConfigSvc != nil { + reply, err := s.promptConfigSvc.GetAIFailReply() + if err == nil && strings.TrimSpace(reply) != "" { + return strings.TrimSpace(reply) + } + } + return "AI客服好像出了点差错,请联系人工客服解决" } // buildConversationHistory 构建对话历史(用于 AI 上下文)。 @@ -212,11 +605,20 @@ func (s *AIService) retrieveRAGContext(ctx context.Context, query string, conver // buildRAGPrompt 构建包含 RAG 上下文的 Prompt // userMessage: 用户原始消息 // ragContext: RAG 检索到的文档内容 -// 返回: 增强后的用户消息(包含知识库上下文) +// 返回: 增强后的用户消息(包含知识库上下文)。若已配置提示词服务则使用可配置模板(占位符 {{rag_context}}、{{user_message}}),否则使用代码内默认。 func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string { - // 构建 RAG Prompt 模板 - // 参考 PandaWiki 的 Prompt 格式 - prompt := fmt.Sprintf(`你是一个智能客服助手,请基于以下知识库内容回答用户的问题。 + if s.promptConfigSvc != nil { + tpl, err := s.promptConfigSvc.GetRAGPromptTemplate() + if err == nil && tpl != "" { + return replacePromptPlaceholders(tpl, ragContext, userMessage) + } + } + return s.buildRAGPromptFallback(userMessage, ragContext) +} + +// buildRAGPromptFallback 代码内默认 RAG 提示词(与 prompt_config_service 默认一致,用于 promptConfigSvc 为空或出错时) +func (s *AIService) buildRAGPromptFallback(userMessage string, ragContext string) string { + return fmt.Sprintf(`你是一个智能客服助手,请基于以下知识库内容回答用户的问题。 知识库内容: %s @@ -231,6 +633,157 @@ func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string 3. 如果知识库中没有相关信息,请诚实告知 4. 保持友好、专业的语气 5. 回答要简洁明了,避免冗长`, ragContext, userMessage) - - return prompt +} + +// replacePromptPlaceholders 将模板中的 {{rag_context}}、{{user_message}} 替换为实际值 +func replacePromptPlaceholders(template, ragContext, userMessage string) string { + template = strings.ReplaceAll(template, "{{rag_context}}", ragContext) + template = strings.ReplaceAll(template, "{{user_message}}", userMessage) + return template +} + +// buildRAGPromptWithWebOptional 构建 RAG prompt,并允许在知识库无关或不足时用自身知识或联网。 +// 与 buildRAGPrompt 区别:明确说明可先基于知识库,若无关/弱相关可基于自身知识,若仍不足可由模型决定是否联网(需配合传入 web_search 工具使用)。 +func (s *AIService) buildRAGPromptWithWebOptional(userMessage string, ragContext string) string { + if s.promptConfigSvc != nil { + tpl, err := s.promptConfigSvc.GetRAGPromptWithWebOptionalTemplate() + if err == nil && tpl != "" { + return replacePromptPlaceholders(tpl, ragContext, userMessage) + } + } + return s.buildRAGPromptWithWebOptionalFallback(userMessage, ragContext) +} + +// buildRAGPromptWithWebOptionalFallback 代码内默认(RAG+联网可选) +func (s *AIService) buildRAGPromptWithWebOptionalFallback(userMessage string, ragContext string) string { + return fmt.Sprintf(`你是一个智能客服助手。请优先基于以下知识库内容回答用户的问题。 + +知识库内容: +%s + +用户问题:%s + +回答要求: +1. 若知识库内容与问题明确相关,请基于知识库给出准确、简洁的回答。 +2. 若知识库内容与问题无关或仅弱相关,可先基于你自身的知识回答,不必拘泥于知识库。 +3. 若你自身知识仍不足以回答(例如需要最新资讯、实时数据),你可决定是否使用联网搜索获取信息后再回答。 +4. 保持友好、专业,回答简洁明了。`, ragContext, userMessage) +} + +const maxWebToolRounds = 5 + +// webSearchToolDefinition 返回 type: "function" 的 web_search 工具定义,仅用于「自建」联网(Serper 执行)。 +func (s *AIService) webSearchToolDefinition() []map[string]interface{} { + return []map[string]interface{}{ + { + "type": "function", + "function": map[string]interface{}{ + "name": "web_search", + "description": "Search the web for current information. Use when you need up-to-date or external information to answer the user.", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]string{"type": "string", "description": "Search query"}, + }, + "required": []string{"query"}, + }, + }, + }, + } +} + +// generateWithWebTools 使用 function calling 做联网(模型决定是否搜)。webSource: vendor / custom。 +// 联网请求始终发往当前对话的「AI 配置」对话接口(与知识库向量配置/embedding 无关)。 +// - vendor(模式一:厂商内置):在 tools 里传 type "web_search",由厂商在自家 API 内封装并执行搜索,无需自建。 +// - custom(模式二:自建):在 tools 里传 type "function" 的自定义函数(如 web_search),由本服务调用 Serper 等执行并回填。 +func (s *AIService) generateWithWebTools(ctx context.Context, provider AIProvider, history []MessageHistory, userMessage string, webSource string, imageBase64 string, imageMimeType string) (content string, usedWeb bool, err error) { + messages := s.historyToOpenAIMessages(history, userMessage, imageBase64, imageMimeType) + var tools []map[string]interface{} + useFunctionFormat := false + switch webSource { + case "vendor": + // 模式一:厂商内置,仅传 web_search,由厂商执行 + tools = []map[string]interface{}{ + {"type": "web_search"}, + } + case "custom": + if s.webSearchProvider == nil { + return "", false, nil + } + useFunctionFormat = true + tools = s.webSearchToolDefinition() + default: + tools = nil + } + if len(tools) == 0 { + return "", false, nil + } + + rounds := 0 + for rounds < maxWebToolRounds { + rounds++ + respContent, toolCalls, callErr := provider.GenerateResponseWithTools(messages, tools) + if callErr != nil { + return "", usedWeb, callErr + } + if len(toolCalls) == 0 { + return respContent, usedWeb, nil + } + if useFunctionFormat { + usedWeb = true + } + // 追加 assistant 消息(含 tool_calls) + assistantMsg := map[string]interface{}{"role": "assistant", "content": respContent} + tcList := make([]map[string]interface{}, 0, len(toolCalls)) + for _, tc := range toolCalls { + tcList = append(tcList, map[string]interface{}{ + "id": tc.ID, + "type": "function", + "function": map[string]interface{}{"name": tc.Name, "arguments": tc.Arguments}, + }) + } + assistantMsg["tool_calls"] = tcList + messages = append(messages, assistantMsg) + + for _, tc := range toolCalls { + toolResult := "" + if useFunctionFormat && tc.Name == "web_search" && s.webSearchProvider != nil { + var args struct { + Query string `json:"query"` + } + _ = json.Unmarshal([]byte(tc.Arguments), &args) + query := args.Query + if query == "" { + query = userMessage + } + toolResult, _ = s.webSearchProvider.Search(ctx, query) + } + messages = append(messages, map[string]interface{}{ + "role": "tool", + "tool_call_id": tc.ID, + "content": toolResult, + }) + } + } + return "", usedWeb, fmt.Errorf("联网工具调用超过 %d 轮", maxWebToolRounds) +} + +func (s *AIService) historyToOpenAIMessages(history []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(history)+1) + for _, h := range history { + out = append(out, map[string]interface{}{"role": h.Role, "content": h.Content}) + } + var lastContent interface{} = userMessage + if imageBase64 != "" { + dataURL := "data:" + imageMimeType + ";base64," + imageBase64 + if imageMimeType == "" { + dataURL = "data:image/jpeg;base64," + imageBase64 + } + lastContent = []map[string]interface{}{ + {"type": "text", "text": userMessage}, + {"type": "image_url", "image_url": map[string]string{"url": dataURL}}, + } + } + out = append(out, map[string]interface{}{"role": "user", "content": lastContent}) + return out } diff --git a/backend/service/analytics_service.go b/backend/service/analytics_service.go new file mode 100644 index 0000000..66f8526 --- /dev/null +++ b/backend/service/analytics_service.go @@ -0,0 +1,357 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/repository" + "gorm.io/gorm" +) + +// AnalyticsSummaryResponse 报表汇总(按日 + 区间总计) +type AnalyticsSummaryResponse struct { + From string `json:"from"` + To string `json:"to"` + Totals AnalyticsTotals `json:"totals"` + Daily []AnalyticsDailyRow `json:"daily"` + Note string `json:"note"` +} + +// AnalyticsTotals 区间内汇总指标 +type AnalyticsTotals struct { + WidgetOpens int64 `json:"widget_opens"` + Sessions int64 `json:"sessions"` + Messages int64 `json:"messages"` + AIReplies int64 `json:"ai_replies"` + AIFailed int64 `json:"ai_failed"` + AIFailureRatePercent float64 `json:"ai_failure_rate_percent"` + KBHits int64 `json:"kb_hits"` + KBHitRatePercent float64 `json:"kb_hit_rate_percent"` + MaxAIRounds int `json:"max_ai_rounds"` + // SessionsWithAI 区间内新建的访客会话中,至少使用过 AI(访客 AI 发言或收到 AI 回复)的会话数 + SessionsWithAI int64 `json:"sessions_with_ai"` + AIParticipationRatePercent float64 `json:"ai_participation_rate_percent"` + AIToHumanSessions int64 `json:"ai_to_human_sessions"` + AIToHumanRatePercent float64 `json:"ai_to_human_rate_percent"` + HumanToAISessions int64 `json:"human_to_ai_sessions"` + HumanToAIRatePercent float64 `json:"human_to_ai_rate_percent"` + // 以下为转人工率分母说明用(区间内有活动的会话中统计) + SessionsWithAIUserMsg int64 `json:"sessions_with_ai_user_msg"` + SessionsWithHumanUserMsg int64 `json:"sessions_with_human_user_msg"` +} + +// AnalyticsDailyRow 单日指标(用于折线/柱状图) +type AnalyticsDailyRow struct { + Date string `json:"date"` + WidgetOpens int64 `json:"widget_opens"` + Sessions int64 `json:"sessions"` + Messages int64 `json:"messages"` + AIReplies int64 `json:"ai_replies"` +} + +// AnalyticsService 数据分析报表(访客会话,不含内部知识库测试) +type AnalyticsService struct { + db *gorm.DB + widgetOpens *repository.WidgetOpenRepository + analyticsLoc *time.Location +} + +// NewAnalyticsService 创建报表服务;loc 用于按自然日切分,默认上海时区 +func NewAnalyticsService(db *gorm.DB, widgetOpens *repository.WidgetOpenRepository) *AnalyticsService { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + return &AnalyticsService{db: db, widgetOpens: widgetOpens, analyticsLoc: loc} +} + +// RecordWidgetOpen 记录一次访客打开客服小窗 +func (s *AnalyticsService) RecordWidgetOpen(visitorID uint) error { + if visitorID == 0 { + return fmt.Errorf("visitor_id 无效") + } + return s.widgetOpens.Create(&models.WidgetOpenEvent{VisitorID: visitorID}) +} + +// GetSummary 查询 [fromDate, toDate] 闭区间内的统计(按上海时区日历日) +func (s *AnalyticsService) GetSummary(fromDate, toDate string) (*AnalyticsSummaryResponse, error) { + start, endExclusive, err := parseInclusiveDateRange(fromDate, toDate, s.analyticsLoc) + if err != nil { + return nil, err + } + if !endExclusive.After(start) { + return nil, fmt.Errorf("结束日期须不早于开始日期") + } + + totals := s.computeTotals(start, endExclusive) + daily := s.computeDailySeries(start, endExclusive) + + return &AnalyticsSummaryResponse{ + From: fromDate, + To: toDate, + Totals: totals, + Daily: daily, + Note: "访客会话统计;时区按 Asia/Shanghai 切日。知识库命中率分母为「非失败的 AI 回复数」。转人工率分母为「有过 AI 模式访客发言的会话数」。", + }, nil +} + +func parseInclusiveDateRange(fromStr, toStr string, loc *time.Location) (start, endExclusive time.Time, err error) { + fromStr = strings.TrimSpace(fromStr) + toStr = strings.TrimSpace(toStr) + if fromStr == "" || toStr == "" { + return time.Time{}, time.Time{}, fmt.Errorf("请提供 from 与 to,格式 YYYY-MM-DD") + } + d0, e1 := time.ParseInLocation("2006-01-02", fromStr, loc) + d1, e2 := time.ParseInLocation("2006-01-02", toStr, loc) + if e1 != nil || e2 != nil { + return time.Time{}, time.Time{}, fmt.Errorf("日期格式应为 YYYY-MM-DD") + } + start = time.Date(d0.Year(), d0.Month(), d0.Day(), 0, 0, 0, 0, loc) + endDay := time.Date(d1.Year(), d1.Month(), d1.Day(), 0, 0, 0, 0, loc) + endExclusive = endDay.AddDate(0, 0, 1) + return start, endExclusive, nil +} + +func (s *AnalyticsService) computeTotals(start, endExclusive time.Time) AnalyticsTotals { + var out AnalyticsTotals + + // 小窗打开次数 + s.db.Model(&models.WidgetOpenEvent{}). + Where("created_at >= ? AND created_at < ?", start, endExclusive). + Count(&out.WidgetOpens) + + // 新建访客会话(区间内创建的) + s.db.Model(&models.Conversation{}). + Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive). + Count(&out.Sessions) + + // 区间内产生的消息(仅访客会话) + s.db.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.conversation_type = ?", "visitor"). + Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive). + Count(&out.Messages) + + // AI 回复:客服侧且 sender_id=0 + s.db.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.conversation_type = ?", "visitor"). + Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0). + Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive). + Count(&out.AIReplies) + + s.db.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.conversation_type = ?", "visitor"). + Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0). + Where("messages.is_ai_generation_failed = ?", true). + Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive). + Count(&out.AIFailed) + + aiOK := out.AIReplies - out.AIFailed + if out.AIReplies > 0 { + out.AIFailureRatePercent = round2(float64(out.AIFailed) * 100 / float64(out.AIReplies)) + } + + s.db.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.conversation_type = ?", "visitor"). + Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0). + Where("messages.is_ai_generation_failed = ?", false). + Where("messages.sources_used LIKE ?", "%knowledge_base%"). + Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive). + Count(&out.KBHits) + + if aiOK > 0 { + out.KBHitRatePercent = round2(float64(out.KBHits) * 100 / float64(aiOK)) + } + + // 需要全量消息的会话:区间内新建或有消息活动的访客会话 + convIDs := s.visitorConversationIDsTouchingRange(start, endExclusive) + if len(convIDs) > 0 { + var all []models.Message + s.db.Where("conversation_id IN ?", convIDs). + Order("conversation_id ASC, created_at ASC"). + Find(&all) + byConv := groupMessagesByConversation(all) + maxRounds := 0 + seenAI := make(map[uint]struct{}) + seenHuman := make(map[uint]struct{}) + seenATH := make(map[uint]struct{}) + seenHTA := make(map[uint]struct{}) + for cid, msgs := range byConv { + r := countAIRounds(msgs) + if r > maxRounds { + maxRounds = r + } + ath, hta, hasAIUser, hasHumanUser := detectModeTransitions(msgs) + if hasAIUser { + seenAI[cid] = struct{}{} + } + if hasHumanUser { + seenHuman[cid] = struct{}{} + } + if ath { + seenATH[cid] = struct{}{} + } + if hta { + seenHTA[cid] = struct{}{} + } + } + out.MaxAIRounds = maxRounds + out.SessionsWithAIUserMsg = int64(len(seenAI)) + out.SessionsWithHumanUserMsg = int64(len(seenHuman)) + + var createdInRange []uint + s.db.Model(&models.Conversation{}). + Select("id"). + Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive). + Pluck("id", &createdInRange) + var sessionsWithAI int64 + for _, cid := range createdInRange { + if conversationUsedAI(byConv[cid]) { + sessionsWithAI++ + } + } + out.SessionsWithAI = sessionsWithAI + if out.Sessions > 0 { + out.AIParticipationRatePercent = round2(float64(sessionsWithAI) * 100 / float64(out.Sessions)) + } + if len(seenAI) > 0 { + out.AIToHumanSessions = int64(len(seenATH)) + out.AIToHumanRatePercent = round2(float64(len(seenATH)) * 100 / float64(len(seenAI))) + } + if len(seenHuman) > 0 { + out.HumanToAISessions = int64(len(seenHTA)) + out.HumanToAIRatePercent = round2(float64(len(seenHTA)) * 100 / float64(len(seenHuman))) + } + } + + return out +} + +func (s *AnalyticsService) visitorConversationIDsTouchingRange(start, endExclusive time.Time) []uint { + var created []uint + s.db.Model(&models.Conversation{}). + Select("id"). + Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", start, endExclusive). + Pluck("id", &created) + + var fromMessages []uint + s.db.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.conversation_type = ?", "visitor"). + Where("messages.created_at >= ? AND messages.created_at < ?", start, endExclusive). + Pluck("messages.conversation_id", &fromMessages) + + uniq := make(map[uint]struct{}) + for _, id := range created { + uniq[id] = struct{}{} + } + for _, id := range fromMessages { + uniq[id] = struct{}{} + } + out := make([]uint, 0, len(uniq)) + for id := range uniq { + out = append(out, id) + } + return out +} + +func groupMessagesByConversation(msgs []models.Message) map[uint][]models.Message { + m := make(map[uint][]models.Message) + for _, msg := range msgs { + m[msg.ConversationID] = append(m[msg.ConversationID], msg) + } + return m +} + +// countAIRounds 同一会话内:访客在 AI 模式下一条消息 + 紧随其后的 AI 回复算一轮 +func countAIRounds(msgs []models.Message) int { + n := 0 + for i := 0; i < len(msgs)-1; i++ { + a, b := msgs[i], msgs[i+1] + if !a.SenderIsAgent && a.ChatMode == "ai" && b.SenderIsAgent && b.SenderID == 0 { + n++ + } + } + return n +} + +// detectModeTransitions 仅看访客用户消息(非客服)的 chat_mode 变化 +func conversationUsedAI(msgs []models.Message) bool { + for _, m := range msgs { + if m.SenderIsAgent && m.SenderID == 0 { + return true + } + if !m.SenderIsAgent && m.ChatMode == "ai" { + return true + } + } + return false +} + +func detectModeTransitions(msgs []models.Message) (aiToHuman, humanToAI, hasAIUser, hasHumanUser bool) { + var prev string + for _, m := range msgs { + if m.SenderIsAgent { + continue + } + mode := m.ChatMode + if mode != "ai" && mode != "human" { + continue + } + if mode == "ai" { + hasAIUser = true + } + if mode == "human" { + hasHumanUser = true + } + if prev == "ai" && mode == "human" { + aiToHuman = true + } + if prev == "human" && mode == "ai" { + humanToAI = true + } + prev = mode + } + return +} + +func (s *AnalyticsService) computeDailySeries(start, endExclusive time.Time) []AnalyticsDailyRow { + var rows []AnalyticsDailyRow + for d := start; d.Before(endExclusive); d = d.AddDate(0, 0, 1) { + dayEnd := d.AddDate(0, 0, 1) + dateStr := d.Format("2006-01-02") + var w, sess, msg, ai int64 + s.db.Model(&models.WidgetOpenEvent{}). + Where("created_at >= ? AND created_at < ?", d, dayEnd).Count(&w) + s.db.Model(&models.Conversation{}). + Where("conversation_type = ? AND created_at >= ? AND created_at < ?", "visitor", d, dayEnd).Count(&sess) + s.db.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.conversation_type = ?", "visitor"). + Where("messages.created_at >= ? AND messages.created_at < ?", d, dayEnd). + Count(&msg) + s.db.Model(&models.Message{}). + Joins("JOIN conversations ON conversations.id = messages.conversation_id"). + Where("conversations.conversation_type = ?", "visitor"). + Where("messages.sender_is_agent = ? AND messages.sender_id = ?", true, 0). + Where("messages.created_at >= ? AND messages.created_at < ?", d, dayEnd). + Count(&ai) + rows = append(rows, AnalyticsDailyRow{ + Date: dateStr, + WidgetOpens: w, + Sessions: sess, + Messages: msg, + AIReplies: ai, + }) + } + return rows +} + +func round2(x float64) float64 { + return float64(int64(x*100+0.5)) / 100 +} diff --git a/backend/service/conversation_service.go b/backend/service/conversation_service.go index b23f1b0..0eb7f94 100644 --- a/backend/service/conversation_service.go +++ b/backend/service/conversation_service.go @@ -16,6 +16,7 @@ type ConversationService struct { messages *repository.MessageRepository aiConfigRepo *repository.AIConfigRepository // 用于验证 AI 配置 userRepo *repository.UserRepository // 用于查询用户设置 + systemLogSvc *SystemLogService // 可选,结构化日志 } // NewConversationService 创建 ConversationService 实例。 @@ -24,12 +25,14 @@ func NewConversationService( messages *repository.MessageRepository, aiConfigRepo *repository.AIConfigRepository, userRepo *repository.UserRepository, + systemLogSvc *SystemLogService, ) *ConversationService { return &ConversationService{ conversations: conversations, messages: messages, aiConfigRepo: aiConfigRepo, userRepo: userRepo, + systemLogSvc: systemLogSvc, } } @@ -88,6 +91,21 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In if err := s.conversations.Create(conv); err != nil { return nil, err } + if s.systemLogSvc != nil { + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "info", + Category: "business", + Event: "conversation_created", + Source: "backend", + Message: "访客会话已创建", + ConversationID: &conv.ID, + VisitorID: &input.VisitorID, + Meta: map[string]interface{}{ + "chat_mode": conv.ChatMode, + "ai_config": conv.AIConfigID, + }, + }) + } isNewConversation = true } else { return nil, err @@ -123,6 +141,7 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In // 这样访客可以在人工客服和 AI 客服之间切换 if input.ChatMode != "" && input.ChatMode != conv.ChatMode { chatMode := input.ChatMode + oldMode := conv.ChatMode updates["chat_mode"] = chatMode // 如果是 AI 模式,验证并更新 AI 配置 @@ -146,6 +165,40 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In // 切换到人工客服模式,清除 AI 配置 updates["ai_config_id"] = nil } + if s.systemLogSvc != nil { + convID := conv.ID + visitorID := conv.VisitorID + _ = s.systemLogSvc.Create(CreateSystemLogInput{ + Level: "info", + Category: "business", + Event: "conversation_mode_switch", + Source: "backend", + ConversationID: &convID, + VisitorID: &visitorID, + Message: "会话模式切换", + Meta: map[string]interface{}{ + "from": oldMode, + "to": chatMode, + }, + }) + } + } + + // 已在 AI 模式时,若用户在下拉中切换了模型(对话↔绘画),也要更新 ai_config_id + if input.ChatMode == "ai" && conv.ChatMode == "ai" && input.AIConfigID != nil && *input.AIConfigID != 0 { + if conv.AIConfigID == nil || *conv.AIConfigID != *input.AIConfigID { + config, err := s.aiConfigRepo.GetByID(*input.AIConfigID) + if err != nil { + return nil, errors.New("模型配置不存在") + } + if !config.IsPublic { + return nil, errors.New("该模型未开放给访客使用") + } + if !config.IsActive { + return nil, errors.New("该模型配置已禁用") + } + updates["ai_config_id"] = input.AIConfigID + } } if err := s.conversations.UpdateFields(conv.ID, updates); err != nil { diff --git a/backend/service/embedding_config_service.go b/backend/service/embedding_config_service.go index 46bb0a8..1a74d09 100644 --- a/backend/service/embedding_config_service.go +++ b/backend/service/embedding_config_service.go @@ -29,11 +29,13 @@ func (s *EmbeddingConfigService) GetForAPI() (*EmbeddingConfigResult, error) { } if c == nil { return &EmbeddingConfigResult{ - EmbeddingType: "openai", - APIURL: "", - APIKeyMasked: "", - Model: "text-embedding-3-small", - CustomerCanUseKB: true, + EmbeddingType: "openai", + APIURL: "", + APIKeyMasked: "", + Model: "text-embedding-3-small", + CustomerCanUseKB: true, + VisitorWebSearchEnabled: false, + WebSearchSource: "custom", }, nil } masked := "" @@ -41,16 +43,25 @@ func (s *EmbeddingConfigService) GetForAPI() (*EmbeddingConfigResult, error) { masked = "sk-***" } return &EmbeddingConfigResult{ - ID: c.ID, - EmbeddingType: c.EmbeddingType, - APIURL: c.APIURL, - APIKeyMasked: masked, - Model: c.Model, - CustomerCanUseKB: c.CustomerCanUseKB, - UpdatedAt: c.UpdatedAt, + ID: c.ID, + EmbeddingType: c.EmbeddingType, + APIURL: c.APIURL, + APIKeyMasked: masked, + Model: c.Model, + CustomerCanUseKB: c.CustomerCanUseKB, + VisitorWebSearchEnabled: c.VisitorWebSearchEnabled, + WebSearchSource: normalizeWebSearchSource(c.WebSearchSource), + UpdatedAt: c.UpdatedAt, }, nil } +func normalizeWebSearchSource(v string) string { + if v == "vendor" || v == "custom" { + return v + } + return "custom" +} + // GetRaw 供 embedding 工厂使用,返回含解密后 API Key 的配置;若 DB 无有效配置返回 nil, nil func (s *EmbeddingConfigService) GetRaw() (embeddingType, apiURL, apiKey, model string, err error) { c, err := s.repo.Get() @@ -76,6 +87,30 @@ func (s *EmbeddingConfigService) CustomerCanUseKB() (bool, error) { return c.CustomerCanUseKB, nil } +// GetVisitorWebSearchConfig 返回访客端联网设置(供小窗拉取,无需登录) +func (s *EmbeddingConfigService) GetVisitorWebSearchConfig() (*VisitorWebSearchConfig, error) { + c, err := s.repo.Get() + if err != nil { + return nil, err + } + if c == nil { + return &VisitorWebSearchConfig{WebSearchEnabled: false}, nil + } + return &VisitorWebSearchConfig{WebSearchEnabled: c.VisitorWebSearchEnabled}, nil +} + +// GetWebSearchSource 返回联网方式:vendor(厂商内置)/ custom(自建 Serper) +func (s *EmbeddingConfigService) GetWebSearchSource() (string, error) { + c, err := s.repo.Get() + if err != nil { + return "custom", err + } + if c == nil { + return "custom", nil + } + return normalizeWebSearchSource(c.WebSearchSource), nil +} + // CheckKnowledgeBaseAccess 校验当前用户是否允许使用知识库(创建/上传/导入等) // 若未开放且用户非 admin 则返回 error func (s *EmbeddingConfigService) CheckKnowledgeBaseAccess(userID uint) error { @@ -133,6 +168,12 @@ func (s *EmbeddingConfigService) Update(userID uint, input UpdateEmbeddingConfig if input.CustomerCanUseKB != nil { c.CustomerCanUseKB = *input.CustomerCanUseKB } + if input.VisitorWebSearchEnabled != nil { + c.VisitorWebSearchEnabled = *input.VisitorWebSearchEnabled + } + if input.WebSearchSource != nil { + c.WebSearchSource = normalizeWebSearchSource(*input.WebSearchSource) + } if err := s.repo.Save(c); err != nil { return nil, err @@ -142,20 +183,29 @@ func (s *EmbeddingConfigService) Update(userID uint, input UpdateEmbeddingConfig // 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"` + 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"` + VisitorWebSearchEnabled bool `json:"visitor_web_search_enabled"` + WebSearchSource string `json:"web_search_source"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// VisitorWebSearchConfig 访客端联网设置(供小窗拉取,无需登录) +type VisitorWebSearchConfig struct { + WebSearchEnabled bool `json:"web_search_enabled"` } // 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"` + 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"` + VisitorWebSearchEnabled *bool `json:"visitor_web_search_enabled"` + WebSearchSource *string `json:"web_search_source"` } diff --git a/backend/service/message_service.go b/backend/service/message_service.go index d8fb809..a83261f 100644 --- a/backend/service/message_service.go +++ b/backend/service/message_service.go @@ -61,14 +61,13 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag SenderIsAgent: input.SenderIsAgent, Content: input.Content, MessageType: "user_message", - ChatMode: conv.ChatMode, // 记录消息发送时的对话模式 + ChatMode: conv.ChatMode, IsRead: false, - // 文件相关字段(可选) - FileURL: input.FileURL, - FileType: input.FileType, - FileName: input.FileName, - FileSize: input.FileSize, - MimeType: input.MimeType, + FileURL: input.FileURL, + FileType: input.FileType, + FileName: input.FileName, + FileSize: input.FileSize, + MimeType: input.MimeType, } if err := s.messages.Create(message); err != nil { @@ -100,12 +99,9 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID) } - // 3. 触发 AI 回复的两种情况: - // a) 访客对话 + AI 模式 + 访客发送的消息 - // b) 内部对话(知识库测试)+ 客服发送的消息 - needAIReply := s.aiService != nil && ( - (conv.ChatMode == "ai" && !input.SenderIsAgent) || - (conv.ConversationType == "internal" && input.SenderIsAgent)) + // 3. 触发 AI 回复(文本/识图或生图,具体由 AI 配置的 model_type 决定) + needAIReply := s.aiService != nil && conv.ChatMode == "ai" && ( + (!input.SenderIsAgent) || (conv.ConversationType == "internal" && input.SenderIsAgent)) if needAIReply { go func() { // 用于查找 AI 配置的用户 ID:访客对话用 AgentID,内部对话用发送者(客服)ID @@ -117,22 +113,70 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag userID = input.SenderID } - aiResponse, err := s.aiService.GenerateAIResponse(message.ConversationID, input.Content, userID) + opts := &GenerateAIResponseInput{ + UseKnowledgeBase: input.UseKnowledgeBase, + UseLLM: input.UseLLM, + UseWebSearch: input.UseWebSearch, + NeedWebSearch: input.NeedWebSearch, + } + if opts.UseKnowledgeBase == nil { + t := true + opts.UseKnowledgeBase = &t + } + if opts.UseLLM == nil { + t := true + opts.UseLLM = &t + } + if opts.UseWebSearch == nil { + f := false + opts.UseWebSearch = &f + } + // 多模态识图:当前条消息带图片时传给 AI + if input.FileURL != nil && input.FileType != nil && *input.FileType == "image" { + mime := "" + if input.MimeType != nil { + mime = *input.MimeType + } + opts.Attachment = &MessageAttachment{ + FileURL: *input.FileURL, + FileType: "image", + MimeType: mime, + } + } + aiResult, err := s.aiService.GenerateAIResponseWithOptions(message.ConversationID, input.Content, userID, opts) + aiResponse := "" + sourcesUsed := "" + var aiMessageFileURL *string + aiGenFailed := false if err != nil { log.Printf("❌ AI 生成回复失败: %v", err) - // 使用友好的错误消息 aiResponse = "AI客服好像出了点差错,请联系人工客服解决" + aiGenFailed = true + } else { + aiResponse = aiResult.Content + sourcesUsed = aiResult.SourcesUsed + aiMessageFileURL = aiResult.GeneratedFileURL + aiGenFailed = aiResult.GenerationFailed } - // 创建 AI 回复消息 + // 生图时前端依赖 file_type === "image" 才渲染图片,必须设置 + var aiMessageFileType *string + if aiMessageFileURL != nil { + t := "image" + aiMessageFileType = &t + } aiMessage := &models.Message{ - ConversationID: message.ConversationID, - SenderID: 0, // AI 消息的 SenderID 为 0 - SenderIsAgent: true, // AI 回复视为客服消息 - Content: aiResponse, - MessageType: "user_message", - ChatMode: "ai", // AI 回复消息的模式为 "ai" - IsRead: false, + ConversationID: message.ConversationID, + SenderID: 0, + SenderIsAgent: true, + Content: aiResponse, + MessageType: "user_message", + ChatMode: conv.ChatMode, + IsRead: false, + SourcesUsed: sourcesUsed, + FileURL: aiMessageFileURL, + FileType: aiMessageFileType, + IsAIGenerationFailed: aiGenFailed, } if err := s.messages.Create(aiMessage); err != nil { diff --git a/backend/service/prompt_config_service.go b/backend/service/prompt_config_service.go new file mode 100644 index 0000000..38df91a --- /dev/null +++ b/backend/service/prompt_config_service.go @@ -0,0 +1,238 @@ +package service + +import ( + "errors" + "time" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/repository" +) + +// PromptConfigService 系统提示词配置服务(供「提示词」页与 AIService 使用) +type PromptConfigService struct { + repo *repository.PromptConfigRepository + userRepo *repository.UserRepository +} + +// NewPromptConfigService 创建服务实例 +func NewPromptConfigService(repo *repository.PromptConfigRepository, userRepo *repository.UserRepository) *PromptConfigService { + return &PromptConfigService{repo: repo, userRepo: userRepo} +} + +// GetPrompt 按 key 获取配置内容,未配置返回空字符串 +func (s *PromptConfigService) GetPrompt(key string) (string, error) { + c, err := s.repo.Get(key) + if err != nil { + return "", err + } + if c == nil { + return "", nil + } + return c.Content, nil +} + +// GetPromptOrDefault 获取配置内容,若为空则返回 defaultContent(供 AIService 使用) +func (s *PromptConfigService) GetPromptOrDefault(key, defaultContent string) (string, error) { + content, err := s.GetPrompt(key) + if err != nil { + return "", err + } + if content != "" { + return content, nil + } + return defaultContent, nil +} + +// PromptItem 返回给前端的单项(含展示名) +type PromptItem struct { + Key string `json:"key"` + Name string `json:"name"` + Content string `json:"content"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// GetPromptName 返回 key 对应的中文展示名 +func GetPromptName(key string) string { + switch key { + case models.PromptKeyRAG: + return "RAG 基础提示词(仅知识库)" + case models.PromptKeyRAGWithWebOptional: + return "RAG + 联网可选提示词" + case models.PromptKeyNoKB: + return "无知识库时的提示词" + case models.PromptKeyWebSearchResult: + return "联网结果拼接提示词" + case models.PromptKeyNoSourceReply: + return "无任何来源时的回复语" + case models.PromptKeyAIFailReply: + return "AI 调用失败时的回复语" + default: + return key + } +} + +// GetAllForAPI 获取所有已定义的 prompt 项(若 DB 无则返回默认内容,用于前端展示与编辑) +func (s *PromptConfigService) GetAllForAPI() ([]PromptItem, error) { + keys := []string{ + models.PromptKeyRAG, + models.PromptKeyRAGWithWebOptional, + models.PromptKeyNoKB, + models.PromptKeyWebSearchResult, + models.PromptKeyNoSourceReply, + models.PromptKeyAIFailReply, + } + list, err := s.repo.GetAll() + if err != nil { + return nil, err + } + byKey := make(map[string]*models.PromptConfig) + for i := range list { + byKey[list[i].Key] = &list[i] + } + out := make([]PromptItem, 0, len(keys)) + for _, k := range keys { + item := PromptItem{Key: k, Name: GetPromptName(k)} + if c := byKey[k]; c != nil { + item.Content = c.Content + item.UpdatedAt = c.UpdatedAt + } + if item.Content == "" { + item.Content = getDefaultPromptContent(k) + } + out = append(out, item) + } + return out, nil +} + +func getDefaultPromptContent(key string) string { + switch key { + case models.PromptKeyRAG: + return defaultRAGPrompt + case models.PromptKeyRAGWithWebOptional: + return defaultRAGPromptWithWebOptional + case models.PromptKeyNoKB: + return defaultNoKBPrompt + case models.PromptKeyWebSearchResult: + return defaultWebSearchResultPrompt + case models.PromptKeyNoSourceReply: + return defaultNoSourceReply + case models.PromptKeyAIFailReply: + return defaultAIFailReply + default: + return "" + } +} + +// 默认模板(占位符:{{rag_context}}、{{user_message}}) +const ( + defaultRAGPrompt = `你是一个智能客服助手,请基于以下知识库内容回答用户的问题。 + +知识库内容: +{{rag_context}} + +用户问题: +{{user_message}} + +请根据知识库内容回答用户的问题。如果知识库中没有相关信息,请礼貌地告知用户,并建议联系人工客服。 + +回答要求: +1. 基于知识库内容,提供准确、有用的回答 +2. 如果知识库中有相关信息,请直接引用并解释 +3. 如果知识库中没有相关信息,请诚实告知 +4. 保持友好、专业的语气 +5. 回答要简洁明了,避免冗长` + + defaultRAGPromptWithWebOptional = `你是一个智能客服助手。请优先基于以下知识库内容回答用户的问题。 + +知识库内容: +{{rag_context}} + +用户问题: +{{user_message}} + +回答要求: +1. 若知识库内容与问题明确相关,请基于知识库给出准确、简洁的回答。 +2. 若知识库内容与问题无关或仅弱相关,可先基于你自身的知识回答,不必拘泥于知识库。 +3. 若你自身知识仍不足以回答(例如需要最新资讯、实时数据),你可决定是否使用联网搜索获取信息后再回答。 +4. 保持友好、专业,回答简洁明了。` + + defaultNoKBPrompt = `你是一个智能客服助手。当前未使用知识库,请仅基于你的知识回答用户问题。 + +用户问题: +{{user_message}} + +请简洁、友好地回答。若无法回答,可建议用户联系人工客服。` + + defaultWebSearchResultPrompt = `你是一个智能客服助手。请结合以下联网搜索结果回答用户问题。 + +联网搜索结果: +{{web_context}} + +用户问题: +{{user_message}} + +请基于以上内容给出简洁、准确的回答。` + + defaultNoSourceReply = "当前知识库暂无与此问题相关的内容,您可以尝试联系人工客服。" + + defaultAIFailReply = "AI客服好像出了点差错,请联系人工客服解决" +) + +// Update 更新指定 key 的提示词内容(仅管理员) +func (s *PromptConfigService) Update(userID uint, key, content string) error { + user, err := s.userRepo.GetByID(userID) + if err != nil || user == nil { + return errors.New("用户不存在") + } + if user.Role != "admin" { + return errors.New("仅管理员可修改提示词配置") + } + allowedKeys := map[string]bool{ + models.PromptKeyRAG: true, + models.PromptKeyRAGWithWebOptional: true, + models.PromptKeyNoKB: true, + models.PromptKeyWebSearchResult: true, + models.PromptKeyNoSourceReply: true, + models.PromptKeyAIFailReply: true, + } + if !allowedKeys[key] { + return errors.New("不支持的提示词 key") + } + c, _ := s.repo.Get(key) + if c == nil { + c = &models.PromptConfig{Key: key} + } + c.Content = content + c.UpdatedAt = time.Now() + return s.repo.Save(c) +} + +// GetRAGPromptTemplate 供 AIService 使用:返回 RAG 基础提示词模板(配置或默认),占位符 {{rag_context}}、{{user_message}} +func (s *PromptConfigService) GetRAGPromptTemplate() (string, error) { + return s.GetPromptOrDefault(models.PromptKeyRAG, defaultRAGPrompt) +} + +// GetRAGPromptWithWebOptionalTemplate 供 AIService 使用:返回 RAG+联网可选提示词模板 +func (s *PromptConfigService) GetRAGPromptWithWebOptionalTemplate() (string, error) { + return s.GetPromptOrDefault(models.PromptKeyRAGWithWebOptional, defaultRAGPromptWithWebOptional) +} + +// GetNoKBPromptTemplate 供 AIService 使用:无知识库时的提示词,占位符 {{user_message}} +func (s *PromptConfigService) GetNoKBPromptTemplate() (string, error) { + return s.GetPromptOrDefault(models.PromptKeyNoKB, defaultNoKBPrompt) +} + +// GetWebSearchResultPromptTemplate 供 AIService 使用:联网结果拼接提示词,占位符 {{web_context}}、{{user_message}} +func (s *PromptConfigService) GetWebSearchResultPromptTemplate() (string, error) { + return s.GetPromptOrDefault(models.PromptKeyWebSearchResult, defaultWebSearchResultPrompt) +} + +// GetNoSourceReply 供 AIService 使用:无任何来源时直接返回给用户的一句话(无占位符) +func (s *PromptConfigService) GetNoSourceReply() (string, error) { + return s.GetPromptOrDefault(models.PromptKeyNoSourceReply, defaultNoSourceReply) +} + +// GetAIFailReply 供 AIService 使用:AI 调用失败时返回给用户的一句话(无占位符) +func (s *PromptConfigService) GetAIFailReply() (string, error) { + return s.GetPromptOrDefault(models.PromptKeyAIFailReply, defaultAIFailReply) +} diff --git a/backend/service/rag/health.go b/backend/service/rag/health.go index 1afc2eb..3843adf 100644 --- a/backend/service/rag/health.go +++ b/backend/service/rag/health.go @@ -33,14 +33,16 @@ func (h *HealthChecker) Check(ctx context.Context) error { 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 + // 检查向量存储服务(简单搜索测试);未启用 Milvus 时跳过 + if h.vectorStoreService != nil && h.vectorStoreService.IsAvailable() { + testVector := make([]float32, svc.GetDimension()) + for i := range testVector { + testVector[i] = 0.1 + } + _, err = h.vectorStoreService.SearchVectors(ctx, testVector, 1, nil) + if err != nil { + return err + } } return nil diff --git a/backend/service/rag/vector_store.go b/backend/service/rag/vector_store.go index ec3ebdc..4d04bc5 100644 --- a/backend/service/rag/vector_store.go +++ b/backend/service/rag/vector_store.go @@ -2,36 +2,54 @@ package rag import ( "context" + "errors" "fmt" "strconv" "github.com/2930134478/AI-CS/backend/infra" ) +// ErrVectorStoreUnavailable 向量库未启用或未连接(写入/索引前会返回该错误)。 +var ErrVectorStoreUnavailable = errors.New("向量数据库未启用或未连接") + // VectorStoreService 向量存储服务(业务层) type VectorStoreService struct { vectorStore *infra.VectorStore } -// NewVectorStoreService 创建向量存储服务实例 +// NewVectorStoreService 创建向量存储服务实例(vectorStore 可为 nil,表示无向量库降级模式)。 func NewVectorStoreService(vectorStore *infra.VectorStore) *VectorStoreService { return &VectorStoreService{ vectorStore: vectorStore, } } +// IsAvailable 当前是否已连接可用的 Milvus 向量存储。 +func (s *VectorStoreService) IsAvailable() bool { + return s != nil && s.vectorStore != nil +} + // UpsertVector 插入或更新单个向量 func (s *VectorStoreService) UpsertVector(ctx context.Context, documentID string, knowledgeBaseID string, content string, vector []float32) error { + if s.vectorStore == nil { + return ErrVectorStoreUnavailable + } 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 { + if s.vectorStore == nil { + return ErrVectorStoreUnavailable + } 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) { + if s.vectorStore == nil { + return []SearchResult{}, nil + } results, err := s.vectorStore.SearchVectors(ctx, queryVector, topK, knowledgeBaseID) if err != nil { return nil, fmt.Errorf("向量检索失败: %w", err) @@ -53,11 +71,17 @@ func (s *VectorStoreService) SearchVectors(ctx context.Context, queryVector []fl // DeleteVector 删除向量 func (s *VectorStoreService) DeleteVector(ctx context.Context, documentID string) error { + if s.vectorStore == nil { + return nil + } return s.vectorStore.DeleteVector(ctx, documentID) } // DeleteVectors 批量删除向量 func (s *VectorStoreService) DeleteVectors(ctx context.Context, documentIDs []string) error { + if s.vectorStore == nil { + return nil + } return s.vectorStore.DeleteVectors(ctx, documentIDs) } diff --git a/backend/service/system_log_service.go b/backend/service/system_log_service.go new file mode 100644 index 0000000..de89a21 --- /dev/null +++ b/backend/service/system_log_service.go @@ -0,0 +1,164 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/2930134478/AI-CS/backend/models" + "github.com/2930134478/AI-CS/backend/repository" +) + +type CreateSystemLogInput struct { + Level string + Category string + Event string + Source string + TraceID string + ConversationID *uint + UserID *uint + VisitorID *uint + Message string + Meta map[string]interface{} + Timestamp *time.Time +} + +type QuerySystemLogsInput struct { + From string + To string + Level string + Category string + Event string + Source string + ConversationID *uint + Keyword string + Page int + PageSize int +} + +type QuerySystemLogsResult struct { + Items []models.SystemLog `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// SystemLogService 结构化日志服务(查询 + 写入)。 +type SystemLogService struct { + repo *repository.SystemLogRepository +} + +func NewSystemLogService(repo *repository.SystemLogRepository) *SystemLogService { + return &SystemLogService{repo: repo} +} + +func (s *SystemLogService) Create(input CreateSystemLogInput) error { + level := strings.ToLower(strings.TrimSpace(input.Level)) + if level == "" { + level = "info" + } + category := strings.ToLower(strings.TrimSpace(input.Category)) + if category == "" { + category = "system" + } + source := strings.ToLower(strings.TrimSpace(input.Source)) + if source == "" { + source = "backend" + } + event := strings.TrimSpace(input.Event) + if event == "" { + event = "general" + } + message := strings.TrimSpace(input.Message) + if message == "" { + return fmt.Errorf("message 不能为空") + } + + metaJSON := "" + if input.Meta != nil { + if b, err := json.Marshal(input.Meta); err == nil { + metaJSON = string(b) + } + } + ts := time.Now() + if input.Timestamp != nil { + ts = *input.Timestamp + } + + item := &models.SystemLog{ + Timestamp: ts, + Level: level, + Category: category, + Event: event, + Source: source, + TraceID: strings.TrimSpace(input.TraceID), + ConversationID: input.ConversationID, + UserID: input.UserID, + VisitorID: input.VisitorID, + Message: message, + MetaJSON: metaJSON, + } + return s.repo.Create(item) +} + +func (s *SystemLogService) Query(input QuerySystemLogsInput) (*QuerySystemLogsResult, error) { + page := input.Page + if page <= 0 { + page = 1 + } + pageSize := input.PageSize + if pageSize <= 0 { + pageSize = 50 + } + if pageSize > 200 { + pageSize = 200 + } + + db := s.repo.DB().Model(&models.SystemLog{}) + if input.From != "" { + if t, err := time.Parse("2006-01-02", input.From); err == nil { + db = db.Where("timestamp >= ?", t) + } + } + if input.To != "" { + if t, err := time.Parse("2006-01-02", input.To); err == nil { + db = db.Where("timestamp < ?", t.AddDate(0, 0, 1)) + } + } + if v := strings.TrimSpace(input.Level); v != "" { + db = db.Where("level = ?", strings.ToLower(v)) + } + if v := strings.TrimSpace(input.Category); v != "" { + db = db.Where("category = ?", strings.ToLower(v)) + } + if v := strings.TrimSpace(input.Event); v != "" { + db = db.Where("event = ?", v) + } + if v := strings.TrimSpace(input.Source); v != "" { + db = db.Where("source = ?", strings.ToLower(v)) + } + if input.ConversationID != nil { + db = db.Where("conversation_id = ?", *input.ConversationID) + } + if v := strings.TrimSpace(input.Keyword); v != "" { + like := "%" + v + "%" + db = db.Where("(message LIKE ? OR meta_json LIKE ? OR trace_id LIKE ?)", like, like, like) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + items := make([]models.SystemLog, 0, pageSize) + if err := db.Order("timestamp DESC").Offset((page-1)*pageSize).Limit(pageSize).Find(&items).Error; err != nil { + return nil, err + } + return &QuerySystemLogsResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + diff --git a/backend/service/types.go b/backend/service/types.go index 3e28885..eae1057 100644 --- a/backend/service/types.go +++ b/backend/service/types.go @@ -90,6 +90,11 @@ type CreateMessageInput struct { FileName *string // 原始文件名 FileSize *int64 // 文件大小(字节) MimeType *string // MIME类型 + // 回复数据源开关(仅 AI 模式有效):不传或 nil 时使用默认(知识库+大模型开,联网关) + UseKnowledgeBase *bool // 是否使用知识库检索,默认 true + UseLLM *bool // 无知识库匹配时是否用大模型回复,默认 true + UseWebSearch *bool // 是否允许联网搜索(需本回合 NeedWebSearch 或策略触发),默认 false + NeedWebSearch bool // 本回合是否请求联网搜索(如用户点击「联网搜索」),默认 false } // CreateAgentInput 创建客服或管理员账号需要的参数。 @@ -263,3 +268,29 @@ type UpdateKnowledgeBaseInput struct { Description *string // 知识库描述(可选) RAGEnabled *bool // 是否参与 RAG(可选) } + +// MessageAttachment 当前用户消息的附件(用于多模态:识图等) +type MessageAttachment struct { + FileURL string // 文件 URL(创建消息时返回的 file_url) + FileType string // image / document + MimeType string // 如 image/jpeg +} + +// GenerateAIResponseInput 生成 AI 回复时的选项(数据源开关等)。 +type GenerateAIResponseInput struct { + UseKnowledgeBase *bool // 是否使用知识库,默认 true + UseLLM *bool // 无知识库时是否用大模型回复,默认 true + UseWebSearch *bool // 是否允许联网,默认 false + NeedWebSearch bool // 本回合是否请求联网(如用户点击按钮),默认 false + Attachment *MessageAttachment // 当前条消息的附件(如图片),用于多模态识图 +} + +// GenerateAIResponseResult 生成 AI 回复的结果(内容 + 使用的数据源标记)。 +type GenerateAIResponseResult struct { + Content string // 合成的一条回复 + SourcesUsed string // 逗号分隔,如 "knowledge_base" / "knowledge_base,llm" / "llm,web",供前端展示 + // 生图时返回生成图片的 URL,写入 AI 消息的 file_url + GeneratedFileURL *string + // GenerationFailed 为 true 表示大模型调用失败,内容为兜底话术(仍返回 err==nil 时由 message 层写入 is_ai_generation_failed) + GenerationFailed bool +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a1d2152..60c2988 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -48,7 +48,7 @@ services: volumes: - milvus_data:/var/lib/milvus ports: - - "${MILVUS_PORT:-19530}:19530" + - "${MILVUS_PORT}:19530" depends_on: etcd: condition: service_healthy @@ -69,17 +69,17 @@ services: image: mysql:8.0 container_name: ai-cs-mysql environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword} - MYSQL_DATABASE: ${MYSQL_DATABASE:-ai_cs} - MYSQL_USER: ${MYSQL_USER:-ai_cs_user} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-ai_cs_password} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} ports: - - "${MYSQL_PORT:-3306}:3306" + - "${MYSQL_PORT}:3306" volumes: - mysql_data:/var/lib/mysql command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}"] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 @@ -90,26 +90,36 @@ services: # 后端服务(使用预构建镜像) backend: - image: ${BACKEND_IMAGE:-537yaha/ai-cs-backend:latest} + image: ${BACKEND_IMAGE} + env_file: + - ./.env container_name: ai-cs-backend environment: - DB_HOST: mysql - DB_PORT: 3306 - DB_USER: ${MYSQL_USER:-ai_cs_user} - DB_PASSWORD: ${MYSQL_PASSWORD:-ai_cs_password} - DB_NAME: ${MYSQL_DATABASE:-ai_cs} - ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} - SERVER_HOST: 0.0.0.0 - SERVER_PORT: 8080 - GIN_MODE: ${GIN_MODE:-release} - ENCRYPTION_KEY: ${ENCRYPTION_KEY:-default-key} - MILVUS_HOST: milvus-standalone - MILVUS_PORT: 19530 + DB_HOST: ${DB_HOST} + DB_PORT: ${DB_PORT} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + ADMIN_USERNAME: ${ADMIN_USERNAME} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + SERVER_HOST: ${SERVER_HOST} + SERVER_PORT: ${SERVER_PORT} + GIN_MODE: ${GIN_MODE} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + SERPER_MCP_URL: ${SERPER_MCP_URL} + SERPER_API_KEY: ${SERPER_API_KEY} + MILVUS_HOST: ${MILVUS_HOST} + MILVUS_PORT: ${MILVUS_PORT} + MILVUS_USERNAME: ${MILVUS_USERNAME} + MILVUS_PASSWORD: ${MILVUS_PASSWORD} + MILVUS_DISABLED: ${MILVUS_DISABLED} + VECTOR_STORE_DISABLED: ${VECTOR_STORE_DISABLED} + MILVUS_REQUIRED: ${MILVUS_REQUIRED} ports: - - "${BACKEND_PORT:-18080}:8080" + - "${BACKEND_PORT}:${SERVER_PORT}" volumes: - ./backend/uploads:/app/uploads + - ./.env:/app/.env:ro depends_on: mysql: condition: service_healthy @@ -131,12 +141,15 @@ services: # 前端服务(使用预构建镜像) frontend: - image: ${FRONTEND_IMAGE:-537yaha/ai-cs-frontend:latest} + image: ${FRONTEND_IMAGE} container_name: ai-cs-frontend environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8080} + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} + NEXT_PUBLIC_BACKEND_HOST: ${NEXT_PUBLIC_BACKEND_HOST} + NEXT_PUBLIC_BACKEND_PORT: ${NEXT_PUBLIC_BACKEND_PORT} + NEXT_PUBLIC_MATOMO_CONTAINER_URL: ${NEXT_PUBLIC_MATOMO_CONTAINER_URL} ports: - - "${FRONTEND_PORT:-3000}:3000" + - "${FRONTEND_PORT}:3000" depends_on: - backend networks: diff --git a/docker-compose.yml b/docker-compose.yml index 022986b..67dd9c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,17 +6,17 @@ services: image: mysql:8.0 container_name: ai-cs-mysql environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword} - MYSQL_DATABASE: ${MYSQL_DATABASE:-ai_cs} - MYSQL_USER: ${MYSQL_USER:-ai_cs_user} - MYSQL_PASSWORD: ${MYSQL_PASSWORD:-ai_cs_password} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} ports: - - "${MYSQL_PORT:-3306}:3306" + - "${MYSQL_PORT}:3306" volumes: - mysql_data:/var/lib/mysql command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}"] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 @@ -30,23 +30,35 @@ services: build: context: ./backend dockerfile: Dockerfile + env_file: + - ./.env container_name: ai-cs-backend environment: - DB_HOST: mysql - DB_PORT: 3306 - DB_USER: ${MYSQL_USER:-ai_cs_user} - DB_PASSWORD: ${MYSQL_PASSWORD:-ai_cs_password} - DB_NAME: ${MYSQL_DATABASE:-ai_cs} - ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} - SERVER_HOST: 0.0.0.0 - SERVER_PORT: 8080 - GIN_MODE: ${GIN_MODE:-release} - ENCRYPTION_KEY: ${ENCRYPTION_KEY:-default-key} + DB_HOST: ${DB_HOST} + DB_PORT: ${DB_PORT} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + ADMIN_USERNAME: ${ADMIN_USERNAME} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + SERVER_HOST: ${SERVER_HOST} + SERVER_PORT: ${SERVER_PORT} + GIN_MODE: ${GIN_MODE} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + SERPER_MCP_URL: ${SERPER_MCP_URL} + SERPER_API_KEY: ${SERPER_API_KEY} + MILVUS_HOST: ${MILVUS_HOST} + MILVUS_PORT: ${MILVUS_PORT} + MILVUS_USERNAME: ${MILVUS_USERNAME} + MILVUS_PASSWORD: ${MILVUS_PASSWORD} + MILVUS_DISABLED: ${MILVUS_DISABLED} + VECTOR_STORE_DISABLED: ${VECTOR_STORE_DISABLED} + MILVUS_REQUIRED: ${MILVUS_REQUIRED} ports: - - "${BACKEND_PORT:-18080}:8080" + - "${BACKEND_PORT}:${SERVER_PORT}" volumes: - ./backend/uploads:/app/uploads + - ./.env:/app/.env:ro depends_on: mysql: condition: service_healthy @@ -60,11 +72,13 @@ services: context: ./frontend dockerfile: Dockerfile args: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8080} - NEXT_PUBLIC_BACKEND_PORT: ${BACKEND_PORT:-18080} + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} + NEXT_PUBLIC_BACKEND_HOST: ${NEXT_PUBLIC_BACKEND_HOST} + NEXT_PUBLIC_BACKEND_PORT: ${NEXT_PUBLIC_BACKEND_PORT} + NEXT_PUBLIC_MATOMO_CONTAINER_URL: ${NEXT_PUBLIC_MATOMO_CONTAINER_URL} container_name: ai-cs-frontend ports: - - "${FRONTEND_PORT:-3000}:3000" + - "${FRONTEND_PORT}:3000" depends_on: - backend networks: diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 0bb505d..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# ============================================ -# 鍓嶇鐜鍙橀噺绀轰緥锛堝鍒朵负 .env 鎴?.env.local 鍚庢寜闇€濉啓锛?# ============================================ - -# 娴忚鍣ㄨ闂悗绔?API 鐨勫湴鍧€锛堢敓浜?瀹瑰櫒閮ㄧ讲鏃跺繀濉級 -NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 - -# 寮€鍙戠幆澧冧唬鐞嗙敤锛堜粎鏈湴 npm run dev 鏃剁敓鏁堬紝榛樿鍗冲彲锛?NEXT_PUBLIC_BACKEND_HOST=localhost -NEXT_PUBLIC_BACKEND_PORT=8080 - -# Matomo 缁熻锛堝彲閫夛紝鐣欑┖鍒欎笉鍔犺浇锛?# NEXT_PUBLIC_MATOMO_CONTAINER_URL= diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 42ee51d..fb8cf4b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -14,13 +14,9 @@ RUN npm ci # 复制源代码 COPY . . -# 构建参数(Next.js 环境变量必须在构建时传入) -ARG NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL - -# 后端端口配置(用于图片加载等) -ARG NEXT_PUBLIC_BACKEND_PORT=18080 -ENV NEXT_PUBLIC_BACKEND_PORT=$NEXT_PUBLIC_BACKEND_PORT +# 说明: +# - 当前前端请求统一走同域 `/api/*`(由反向代理转发到后端),避免在镜像构建时固化域名/端口 +# - 因此不再需要 NEXT_PUBLIC_API_BASE_URL / NEXT_PUBLIC_BACKEND_PORT 这类 build-args # 构建 Next.js 应用 RUN npm run build diff --git a/frontend/app/agent/analytics/page.tsx b/frontend/app/agent/analytics/page.tsx new file mode 100644 index 0000000..74a072b --- /dev/null +++ b/frontend/app/agent/analytics/page.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + fetchAnalyticsSummary, + type AnalyticsDailyRow, + type AnalyticsSummaryResponse, +} from "@/features/agent/services/analyticsApi"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/hooks/useToast"; + +function formatPercent(n: number) { + if (Number.isNaN(n)) return "—"; + return `${n.toFixed(2)}%`; +} + +function StatCard({ + title, + value, + sub, +}: { + title: string; + value: string | number; + sub?: string; +}) { + return ( +
+
{title}
+
{value}
+ {sub ?
{sub}
: null} +
+ ); +} + +function DailyBars({ + daily, + field, + label, + color, +}: { + daily: AnalyticsDailyRow[]; + field: keyof Pick< + AnalyticsDailyRow, + "widget_opens" | "sessions" | "messages" | "ai_replies" + >; + label: string; + color: string; +}) { + const max = useMemo(() => { + let m = 1; + for (const row of daily) { + const v = Number(row[field]) || 0; + if (v > m) m = v; + } + return m; + }, [daily, field]); + + if (daily.length === 0) { + return

暂无数据

; + } + + return ( +
+
{label}
+
+ {daily.map((row) => { + const v = Number(row[field]) || 0; + const h = Math.round((v / max) * 100); + return ( +
+
0 ? 8 : 0)}%`, + backgroundColor: color, + minHeight: v > 0 ? 4 : 0, + }} + /> + + {row.date.slice(5)} + +
+ ); + })} +
+
+ ); +} + +export default function AnalyticsPage(_props: { embedded?: boolean }) { + const [from, setFrom] = useState(() => { + const d = new Date(); + d.setDate(d.getDate() - 6); + return d.toISOString().slice(0, 10); + }); + const [to, setTo] = useState(() => new Date().toISOString().slice(0, 10)); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetchAnalyticsSummary(from, to); + setData(res); + } catch (e) { + toast.error((e as Error).message); + setData(null); + } finally { + setLoading(false); + } + }, [from, to]); + + useEffect(() => { + void load(); + }, [load]); + + const t = data?.totals; + + return ( +
+
+
+

数据报表

+

+ 访客小窗与 AI 客服统计(按上海时区自然日,不含「知识库测试」内部会话) +

+
+
+ + + +
+
+ + {data && ( +

{data.note}

+ )} + + {t && ( + <> +
+ + + + + + + + + + + + +
+ +
+ + + + +
+ + )} + + {!loading && !t && ( +

暂无数据或加载失败

+ )} +
+ ); +} diff --git a/frontend/app/agent/conversations/page.tsx b/frontend/app/agent/conversations/page.tsx index bce1e4e..a83e6f6 100644 --- a/frontend/app/agent/conversations/page.tsx +++ b/frontend/app/agent/conversations/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { API_BASE_URL } from "@/lib/config"; +import { apiUrl } from "@/lib/config"; import { Button } from "@/components/ui/button"; // 对话类型定义 @@ -40,7 +40,7 @@ export default function ConversationsPage() { // 拉取对话列表 const fetchConversations = async () => { try { - const res = await fetch(`${API_BASE_URL}/conversations`); + const res = await fetch(apiUrl("/conversations")); if (res.ok) { const data = await res.json(); if (Array.isArray(data)) { @@ -67,7 +67,7 @@ export default function ConversationsPage() { // 退出登录 const handleLogout = async () => { try { - await fetch(`${API_BASE_URL}/logout`, { + await fetch(apiUrl("/logout"), { method: "POST", }); } catch (error) { diff --git a/frontend/app/agent/login/page.tsx b/frontend/app/agent/login/page.tsx index 4becc67..96ea31a 100644 --- a/frontend/app/agent/login/page.tsx +++ b/frontend/app/agent/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, type FormEvent } from "react"; import { useRouter } from "next/navigation"; -import { API_BASE_URL } from "@/lib/config"; +import { apiUrl } from "@/lib/config"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -25,7 +25,7 @@ export default function AgentLoginPage() { setError(""); try { - const res = await fetch(`${API_BASE_URL}/login`, { + const res = await fetch(apiUrl("/login"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), diff --git a/frontend/app/agent/logs/page.tsx b/frontend/app/agent/logs/page.tsx new file mode 100644 index 0000000..4c37d0d --- /dev/null +++ b/frontend/app/agent/logs/page.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { fetchSystemLogs, type QuerySystemLogsResult } from "@/features/agent/services/systemLogApi"; +import { toast } from "@/hooks/useToast"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Copy } from "lucide-react"; + +function tryFormatJSON(raw?: string | null): string { + if (!raw) return ""; + try { + const parsed = JSON.parse(raw); + return JSON.stringify(parsed, null, 2); + } catch { + return raw; + } +} + +function levelColor(level: string): string { + if (level === "error") return "text-red-600"; + if (level === "warn") return "text-amber-600"; + return "text-emerald-600"; +} + +export default function LogsPage({ embedded = false }: { embedded?: boolean }) { + const [from, setFrom] = useState(() => { + const d = new Date(); + d.setDate(d.getDate() - 6); + return d.toISOString().slice(0, 10); + }); + const [to, setTo] = useState(() => new Date().toISOString().slice(0, 10)); + const [level, setLevel] = useState(""); + const [category, setCategory] = useState(""); + const [source, setSource] = useState(""); + const [event, setEvent] = useState(""); + const [keyword, setKeyword] = useState(""); + const [conversationId, setConversationId] = useState(""); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const pageSize = 50; + const [selected, setSelected] = useState<(QuerySystemLogsResult["items"][number]) | null>(null); + + const selectedMeta = useMemo(() => tryFormatJSON(selected?.meta_json), [selected]); + + const load = useCallback(async () => { + setLoading(true); + try { + const conv = conversationId.trim() ? Number(conversationId) : undefined; + const res = await fetchSystemLogs({ + from, + to, + level: level || undefined, + category: category || undefined, + source: source || undefined, + event: event || undefined, + keyword: keyword || undefined, + conversationId: conv, + page, + pageSize, + }); + setData(res); + } catch (e) { + toast.error((e as Error).message || "加载日志失败"); + setData(null); + } finally { + setLoading(false); + } + }, [from, to, level, category, source, event, keyword, conversationId, page]); + + useEffect(() => { + void load(); + }, [load]); + + const totalPages = useMemo(() => { + if (!data) return 1; + return Math.max(1, Math.ceil(data.total / data.page_size)); + }, [data]); + + return ( +
+
+

日志中心

+

按分类查看 AI / RAG / 系统 / 前端日志,用于排障定位。

+
+ +
+ setFrom(e.target.value)} className="rounded-md border px-2 py-1 text-sm" /> + + setTo(e.target.value)} className="rounded-md border px-2 py-1 text-sm" /> + + + + setEvent(e.target.value)} + className="rounded-md border px-2 py-1 text-sm min-w-[180px]" + /> + setConversationId(e.target.value)} + className="rounded-md border px-2 py-1 text-sm w-24" + /> + setKeyword(e.target.value)} + className="rounded-md border px-2 py-1 text-sm min-w-[220px]" + /> + +
+ +
+
+ 共 {data?.total ?? 0} 条,当前第 {data?.page ?? page}/{totalPages} 页 +
+
+ + + + + + + + + + + + + + {(data?.items ?? []).map((item) => ( + setSelected(item)} + > + + + + + + + + + ))} + {(data?.items ?? []).length === 0 && !loading && ( + + + + )} + +
时间级别分类事件会话来源消息
{new Date(item.timestamp).toLocaleString()}{item.level}{item.category}{item.event}{item.conversation_id ?? "-"}{item.source}{item.message}
暂无日志
+
+
+ + +
+
+ + { + if (!open) setSelected(null); + }} + > + + + + 日志详情 + {selected ? ( + + {selected.level} + + ) : null} + + + + {selected ? ( +
+
+
+
时间
+
{new Date(selected.timestamp).toLocaleString()}
+
+
+
source / event
+
+ {selected.source} / {selected.event} +
+
+
+
category
+
{selected.category}
+
+
+
trace_id
+
{selected.trace_id || "-"}
+
+
+
conversation_id
+
{selected.conversation_id ?? "-"}
+
+
+
user_id / visitor_id
+
+ {selected.user_id ?? "-"} / {selected.visitor_id ?? "-"} +
+
+
+ +
+
+
message
+ +
+
{selected.message}
+
+ +
+
meta_json
+
+                  {selectedMeta || "(无 meta_json)"}
+                
+
+
+ ) : null} +
+
+
+ ); +} + diff --git a/frontend/app/agent/prompts/page.tsx b/frontend/app/agent/prompts/page.tsx new file mode 100644 index 0000000..556103d --- /dev/null +++ b/frontend/app/agent/prompts/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { ResponsiveLayout } from "@/components/layout"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { fetchPrompts, updatePrompt, type PromptItem } from "@/features/agent/services/promptsApi"; +import { toast } from "@/hooks/useToast"; + +function getPlaceholderHint(key: string): string { + switch (key) { + case "rag_prompt": + case "rag_prompt_with_web_optional": + return "占位符:{{rag_context}} 为知识库检索内容,{{user_message}} 为用户问题。"; + case "no_kb_prompt": + return "占位符:{{user_message}} 为用户问题。"; + case "web_search_result_prompt": + return "占位符:{{web_context}} 为联网搜索结果,{{user_message}} 为用户问题。(当前流程未使用此模板)"; + case "no_source_reply": + case "ai_fail_reply": + return "无占位符,内容将作为完整回复语直接展示给用户。"; + default: + return "请勿删除占位符,保存后由系统替换为实际内容。"; + } +} + +/** 各提示词的使用场景说明(展示在卡片中) */ +function getUsageScenario(key: string): string { + switch (key) { + case "rag_prompt": + return "有知识库检索结果,且本回合未勾选「联网搜索」时,用此模板拼成 prompt 发给模型。"; + case "rag_prompt_with_web_optional": + return "有知识库检索结果且本回合勾选「联网搜索」时,用此模板并传入联网工具,由模型决定是否调用联网。"; + case "no_kb_prompt": + return "没有知识库检索结果且本回合未走联网时,用此模板让模型仅凭自身知识回答。"; + case "web_search_result_prompt": + return "预留:若将来有「先联网搜再拼成一段 prompt」的流程,会使用此模板。当前未使用。"; + case "no_source_reply": + return "既未命中知识库、也未使用大模型或联网时(如用户关闭了所有数据源),直接向用户展示这句话。"; + case "ai_fail_reply": + return "调用 AI 接口失败(超时、报错等)时,向用户展示这句话。"; + default: + return ""; + } +} + +function getTextareaMinHeight(key: string): string { + return key === "no_source_reply" || key === "ai_fail_reply" ? "min-h-[80px]" : "min-h-[200px]"; +} + +export default function PromptsPage({ embedded = false }: { embedded?: boolean }) { + const router = useRouter(); + const [userId, setUserId] = useState(null); + const [prompts, setPrompts] = useState([]); + const [loading, setLoading] = useState(true); + const [savingKey, setSavingKey] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + const storedUserId = localStorage.getItem("agent_user_id"); + if (!storedUserId) { + router.push("/"); + return; + } + setUserId(Number.parseInt(storedUserId, 10)); + }, [router]); + + const loadPrompts = async () => { + if (!userId) return; + try { + setLoading(true); + setError(""); + const data = await fetchPrompts(userId); + setPrompts(data); + } catch (e) { + console.error("加载提示词失败:", e); + setError((e as Error).message || "加载提示词失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (userId) loadPrompts(); + }, [userId]); + + const handleSave = async (key: string, content: string) => { + if (!userId) return; + setSavingKey(key); + try { + await updatePrompt(userId, key, content); + toast.success("保存成功,将立即生效。"); + await loadPrompts(); + } catch (e) { + toast.error((e as Error).message || "保存失败"); + } finally { + setSavingKey(null); + } + }; + + const handleContentChange = (key: string, content: string) => { + setPrompts((prev) => + prev.map((p) => (p.key === key ? { ...p, content } : p)) + ); + }; + + if (!userId) return null; + + const headerContent = ( +
+
+
+

提示词

+
+ 配置系统中使用的提示词模板,用于 RAG、联网等场景。仅管理员可修改。占位符说明见下方各卡片。 +
+
+ {!embedded && ( + + )} +
+
+ ); + + const mainContent = ( +
+
+ {error && ( +
+ {error} +
+ )} + {loading ? ( +
加载中...
+ ) : ( + prompts.map((item) => ( + + + {item.name} + {getUsageScenario(item.key) && ( +

+ 使用场景: + {getUsageScenario(item.key)} +

+ )} +

{getPlaceholderHint(item.key)}

+
+ +