Quellcode durchsuchen

提示词工程+UI更新+日志+可视化

537yaha vor 3 Monaten
Ursprung
Commit
0fc7147821
99 geänderte Dateien mit 5975 neuen und 1746 gelöschten Zeilen
  1. 87 14
      .env.example
  2. 1 3
      .github/workflows/docker-build.yml
  3. 123 364
      README.md
  4. 0 13
      backend/.env
  5. 0 28
      backend/.env.example
  6. 60 0
      backend/controller/analytics_controller.go
  7. 15 11
      backend/controller/embedding_config_controller.go
  8. 10 0
      backend/controller/helper.go
  9. 18 10
      backend/controller/message_controller.go
  10. 54 0
      backend/controller/prompt_config_controller.go
  11. 108 0
      backend/controller/system_log_controller.go
  12. 16 3
      backend/controller/visitor_controller.go
  13. 7 2
      backend/go.mod
  14. 19 4
      backend/go.sum
  15. 105 0
      backend/infra/mcp/client.go
  16. 28 0
      backend/infra/mcp/serper.go
  17. 18 0
      backend/infra/milvus.go
  18. 10 0
      backend/infra/search/provider.go
  19. 85 0
      backend/infra/search/serper.go
  20. 29 0
      backend/infra/storage.go
  21. 208 45
      backend/main.go
  22. 73 1
      backend/middleware/middleware.go
  23. 11 12
      backend/models/ai_config.go
  24. 14 0
      backend/models/analytics.go
  25. 6 2
      backend/models/embedding_config.go
  26. 21 0
      backend/models/prompt_config.go
  27. 21 0
      backend/models/system_log.go
  28. 4 0
      backend/models/user.go
  29. 42 0
      backend/repository/prompt_config_repository.go
  30. 24 0
      backend/repository/system_log_repository.go
  31. 19 0
      backend/repository/widget_open_repository.go
  32. 107 86
      backend/router/router.go
  33. 412 46
      backend/service/ai_provider.go
  34. 611 58
      backend/service/ai_service.go
  35. 357 0
      backend/service/analytics_service.go
  36. 53 0
      backend/service/conversation_service.go
  37. 74 24
      backend/service/embedding_config_service.go
  38. 67 23
      backend/service/message_service.go
  39. 238 0
      backend/service/prompt_config_service.go
  40. 10 8
      backend/service/rag/health.go
  41. 25 1
      backend/service/rag/vector_store.go
  42. 164 0
      backend/service/system_log_service.go
  43. 31 0
      backend/service/types.go
  44. 38 25
      docker-compose.prod.yml
  45. 35 21
      docker-compose.yml
  46. 0 10
      frontend/.env.example
  47. 3 7
      frontend/Dockerfile
  48. 214 0
      frontend/app/agent/analytics/page.tsx
  49. 3 3
      frontend/app/agent/conversations/page.tsx
  50. 2 2
      frontend/app/agent/login/page.tsx
  51. 288 0
      frontend/app/agent/logs/page.tsx
  52. 190 0
      frontend/app/agent/prompts/page.tsx
  53. 68 2
      frontend/app/agent/settings/page.tsx
  54. 46 0
      frontend/app/api/agent/prompts/route.ts
  55. 2 1
      frontend/app/layout.tsx
  56. 10 615
      frontend/app/page.tsx
  57. 15 0
      frontend/app/robots.ts
  58. 12 0
      frontend/app/sitemap.ts
  59. 55 1
      frontend/components/dashboard/DashboardShell.tsx
  60. 45 13
      frontend/components/dashboard/MessageList.tsx
  61. 20 5
      frontend/components/dashboard/NavigationSidebar.tsx
  62. 3 3
      frontend/components/layout/Footer.tsx
  63. 11 11
      frontend/components/layout/Header.tsx
  64. 410 0
      frontend/components/marketing/HomePageClient.tsx
  65. 29 13
      frontend/components/ui/fade-in.tsx
  66. 73 77
      frontend/components/visitor/ChatModeSelector.tsx
  67. 216 70
      frontend/components/visitor/ChatWidget.tsx
  68. 7 6
      frontend/components/visitor/OnlineAgentsList.tsx
  69. 215 0
      frontend/components/visitor/VisitorMessageInput.tsx
  70. 7 0
      frontend/features/agent/hooks/useConversations.ts
  71. 11 3
      frontend/features/agent/hooks/useMessages.ts
  72. 7 7
      frontend/features/agent/services/aiConfigApi.ts
  73. 54 0
      frontend/features/agent/services/analyticsApi.ts
  74. 2 2
      frontend/features/agent/services/authApi.ts
  75. 8 8
      frontend/features/agent/services/conversationApi.ts
  76. 10 10
      frontend/features/agent/services/documentApi.ts
  77. 21 3
      frontend/features/agent/services/embeddingConfigApi.ts
  78. 6 6
      frontend/features/agent/services/faqApi.ts
  79. 3 3
      frontend/features/agent/services/importApi.ts
  80. 8 8
      frontend/features/agent/services/knowledgeBaseApi.ts
  81. 43 8
      frontend/features/agent/services/messageApi.ts
  82. 4 4
      frontend/features/agent/services/profileApi.ts
  83. 47 0
      frontend/features/agent/services/promptsApi.ts
  84. 95 0
      frontend/features/agent/services/systemLogApi.ts
  85. 7 7
      frontend/features/agent/services/userApi.ts
  86. 3 1
      frontend/features/agent/types.ts
  87. 14 0
      frontend/features/visitor/services/analyticsApi.ts
  88. 11 2
      frontend/features/visitor/services/conversationApi.ts
  89. 2 2
      frontend/features/visitor/services/visitorApi.ts
  90. 8 5
      frontend/hooks/useSoundNotification.ts
  91. 8 2
      frontend/lib/config.ts
  92. 39 0
      frontend/lib/constants/agent-pages.tsx
  93. 34 0
      frontend/lib/seo/home-json-ld.ts
  94. 6 0
      frontend/lib/site.ts
  95. 3 0
      frontend/lib/website-config.ts
  96. 19 0
      frontend/next.config.mjs
  97. 27 5
      frontend/next.config.ts
  98. 2 0
      frontend/utils/avatar.ts
  99. 71 17
      frontend/utils/sound.ts

+ 87 - 14
.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 与本地启动都读取这同一份配置
+# ============================================
+
+# =========================
+# 运行画像(建议配置)
+# =========================
+# 可选: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
 
-# MySQL 鏁版嵁搴?MYSQL_ROOT_PASSWORD=
-MYSQL_DATABASE=ai_cs
-MYSQL_USER=ai_cs_user
-MYSQL_PASSWORD=
+# =========================
+# 数据库(必填)
+# =========================
+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=
-
-# 鍚庣鍔犲瘑瀵嗛挜锛堝瓨搴?API Key 绛夊姞瀵嗙敤锛屽缓璁細openssl rand -hex 32锛?ENCRYPTION_KEY=
+ADMIN_PASSWORD=please_change_admin_password
+# 64 位十六进制密钥(必填)
+# 生成示例:openssl rand -hex 32
+ENCRYPTION_KEY=please_generate_64_hex_chars
 
-# 绔彛锛堝彲閫夛紝榛樿宸插啓鍦?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=
+
+# 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=
 
-# 鍙€?GIN_MODE=release
+# =========================
+# 预构建镜像(docker-compose.prod.yml 必填)
+# =========================
+BACKEND_IMAGE=537yaha/ai-cs-backend:latest
+FRONTEND_IMAGE=537yaha/ai-cs-frontend:latest

+ 1 - 3
.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
+          # 前端统一走同域 /api(由反向代理转发到后端),无需在构建时注入 localhost/端口

+ 123 - 364
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
 ```
 
-就这么简单!🎉
-
-4. **访问应用**
-
-- **前端首页**: http://localhost:3000
-- **访客聊天**: http://localhost:3000/chat
-- **客服登录**: http://localhost:3000/agent/login
-  - 用户名:`admin`(或 `.env` 中配置的 `ADMIN_USERNAME`)
-  - 密码:`.env` 中配置的 `ADMIN_PASSWORD`
+#### 3)访问
 
-#### 端口配置
+- **官网首页**:`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`
+- 默认端口:前端 `3000`,后端对外 `18080`
+- 修改:在 `.env` 里改 `FRONTEND_PORT` / `BACKEND_PORT`
 
-⚠️ **注意**:预构建镜像的图片加载已硬编码为 `18080` 端口,如需修改端口,请使用方式二(本地构建)重新构建镜像
+> 说明:预构建镜像在某些静态资源/图片路径场景可能与端口强绑定(历史兼容原因)。如果你需要彻底自定义端口并确保所有资源路径一致,建议用下面的“方式 B 本地构建”
 
-#### 常用命令
-
-```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
-```
-
-2. **配置环境变量**
-
-```bash
-# 复制环境变量模板
 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
-```
+### 方式 C:传统部署(本地开发/手动安装)
 
-#### 常用命令
-
-```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. 克隆项目
+环境要求:
+- Go 1.24+
+- Node.js 20.9.0+
+- MySQL 8.0+
 
 ```bash
 git clone https://github.com/2930134478/AI-CS.git
 cd AI-CS
-```
-
-#### 2. 配置后端
+cp .env.example .env
 
-```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=你的后端端口`
-
-#### 4. 访问应用
-
-- **官网首页**: http://localhost:3000
-- **访客聊天**: 
-  - 直接访问:http://localhost:3000/chat
-  - 或点击首页右下角的客服插件按钮
-- **客服登录**: http://localhost:3000/agent/login
-
-#### 5. 默认管理员账号
-
-⚠️ **重要说明**:
-
-系统会在首次启动时**自动创建**管理员账号(如果不存在),但**必须先在 `backend/.env` 文件中配置 `ADMIN_PASSWORD` 环境变量**。
-
-**配置步骤**:
-
-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 的 `</body>` 标签之前添加:
+## 配置字典(根目录 `/.env`)
+
+> 下面表格以 `/.env.example` 为准,帮助你快速判断“必填/可选/什么时候需要填”。
+
+| 变量 | 用途 | 是否必填 | 默认值(示例) | 示例 |
+|---|---|---|---|---|
+| `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` |
+
+## 启用/关闭知识库(RAG)的推荐做法
+
+- **你暂时不想用知识库**:把 `.env` 里 `MILVUS_DISABLED=true`(或 `VECTOR_STORE_DISABLED=true`)
+  - 应用仍可启动,AI 对话与人工客服不受影响
+- **你必须依赖知识库**(生产强约束):把 `.env` 里 `MILVUS_REQUIRED=true`
+  - 此时如果 Milvus 不可用,会落库一条错误日志后退出,避免“半残服务上线”
+
+## 集成访客小窗到你的网站(iframe)
+
+把下面代码放到你网站的 `</body>` 前,核心是把 `src` 指向你自己的部署域名的 `/chat`:
 
 ```html
-<!-- 浮动按钮和聊天窗口 -->
 <div id="ai-cs-widget" style="position: fixed; bottom: 20px; right: 20px; z-index: 9999;">
-  <!-- 浮动按钮 -->
-  <button 
-    id="ai-cs-toggle-btn" 
-    style="width: 56px; height: 56px; border-radius: 50%; background: #3b82f6; color: white; border: none; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);"
+  <button
+    id="ai-cs-toggle-btn"
+    style="width:56px;height:56px;border-radius:50%;background:#3b82f6;color:#fff;border:none;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.15);"
     onclick="toggleChat()"
   >
-    <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
-    </svg>
+    Chat
   </button>
-  
-  <!-- 聊天窗口 iframe -->
-  <iframe 
+
+  <iframe
     id="ai-cs-chat-iframe"
-    src="https://demo.cscorp.top/chat" 
-    style="display: none; position: fixed; bottom: 80px; right: 20px; width: 400px; height: 600px; max-width: calc(100vw - 40px); max-height: calc(100vh - 100px); border: none; border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);"
-    allow="microphone"
+    src="https://你的域名/chat"
+    style="display:none;position:fixed;bottom:80px;right:20px;width:400px;height:600px;max-width:calc(100vw - 40px);max-height:calc(100vh - 100px);border:none;border-radius:12px;box-shadow:0 20px 25px -5px rgba(0,0,0,.1);"
   ></iframe>
 </div>
 
 <script>
   function toggleChat() {
-    const iframe = document.getElementById('ai-cs-chat-iframe');
-    const btn = document.getElementById('ai-cs-toggle-btn');
-    const isVisible = iframe.style.display !== 'none';
-    
-    iframe.style.display = isVisible ? 'none' : 'block';
-    
-    // 切换按钮图标
-    if (isVisible) {
-      btn.innerHTML = '<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>';
-    } else {
-      btn.innerHTML = '<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>';
-    }
+    const iframe = document.getElementById("ai-cs-chat-iframe");
+    iframe.style.display = iframe.style.display !== "none" ? "none" : "block";
   }
 </script>
 ```
 
-#### 步骤 2:修改域名
-
-将代码中的 `https://demo.cscorp.top` 替换为你的实际域名(部署 AI-CS 的域名)。
-
-**示例**:
-```html
-<!-- 如果你的 AI-CS 部署在 https://cs.example.com -->
-<iframe src="https://cs.example.com/chat" ...></iframe>
-```
-
-### 响应式设计
-
-插件会自动适配不同设备:
-
-- **移动端**:小窗宽度自适应,最大高度优化
-- **平板端**:中等尺寸窗口
-- **桌面端**:完整尺寸窗口
-
+## 常见问题与排障(先看这里)
 
-### 自定义样式
+- **提示音听不到**:浏览器通常需要“用户一次交互”才能解锁音频;请先点一下页面任意按钮/再打开喇叭开关测试
+- **向量库连不上导致启动失败**:检查 `.env` 的 `MILVUS_REQUIRED` 是否误开;不需要知识库时建议 `MILVUS_DISABLED=true`
+- **搜不到站点/分享卡片不正确**:设置 `NEXT_PUBLIC_SITE_URL=https://你的域名`,用于 canonical / OG / sitemap 生成
 
-如果需要自定义样式,可以通过 CSS 覆盖:
+## 贡献
 
-```css
-/* 自定义浮动按钮 */
-#ai-cs-toggle-btn {
-  background-color: #your-color !important;
-  width: 60px !important;
-  height: 60px !important;
-}
+欢迎提交 Issue 和 Pull Request。
 
-/* 自定义聊天窗口 */
-#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

+ 0 - 13
backend/.env

@@ -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                                                                  

+ 0 - 28
backend/.env.example

@@ -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 涓庡湴鍧€銆?# --------------------------------------------

+ 60 - 0
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})
+}

+ 15 - 11
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()})

+ 10 - 0
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 ""
+}

+ 18 - 10
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)

+ 54 - 0
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": "保存成功"})
+}

+ 108 - 0
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})
+}
+

+ 16 - 3
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)
+}
+

+ 7 - 2
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

+ 19 - 4
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=

+ 105 - 0
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()
+}

+ 28 - 0
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})
+}

+ 18 - 0
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()

+ 10 - 0
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)
+}

+ 85 - 0
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()
+}

+ 29 - 0
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中提取文件路径

+ 208 - 45
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),
 	)

+ 73 - 1
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,
 	})
 }

+ 11 - 12
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"`
 }
-

+ 14 - 0
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"
+}

+ 6 - 2
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"`
 }

+ 21 - 0
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 调用失败时返回给用户的一句话
+)

+ 21 - 0
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"`
+}
+

+ 4 - 0
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"`
 }

+ 42 - 0
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
+}

+ 24 - 0
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
+}
+

+ 19 - 0
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
+}

+ 107 - 86
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)
-
-	// 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) // 获取开放的模型列表(供访客选择)
-
-	// 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)
-
-	// 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)                // 创建客服(兼容旧接口)
-
-	// 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)
-
-	// 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)
-
-	// Embedding Config(知识库向量模型配置,平台级)
-	r.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get)
-	r.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update)
-
-	// FAQ(事件管理/常见问题)
-	r.GET("/faqs", controllers.FAQ.ListFAQs)           // 获取 FAQ 列表(支持关键词搜索)
-	r.GET("/faqs/:id", controllers.FAQ.GetFAQ)         // 获取 FAQ 详情
-	r.POST("/faqs", controllers.FAQ.CreateFAQ)         // 创建 FAQ
-	r.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ)      // 更新 FAQ
-	r.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ)   // 删除 FAQ
-
-	// Document(文档管理)
-	r.GET("/documents", controllers.Document.ListDocuments)                    // 获取文档列表(支持分页、搜索、状态过滤)
-	r.GET("/documents/:id", controllers.Document.GetDocument)                  // 获取文档详情
-	r.POST("/documents", controllers.Document.CreateDocument)                  // 创建文档
-	r.PUT("/documents/:id", controllers.Document.UpdateDocument)               // 更新文档
-	r.DELETE("/documents/:id", controllers.Document.DeleteDocument)            // 删除文档
-	r.GET("/documents/search", controllers.Document.SearchDocuments)           // 向量检索搜索文档
-	r.GET("/documents/hybrid-search", controllers.Document.HybridSearchDocuments) // 混合检索搜索文档
-	r.PUT("/documents/:id/status", controllers.Document.UpdateDocumentStatus)  // 更新文档状态
-	r.POST("/documents/:id/publish", controllers.Document.PublishDocument)     // 发布文档
-	r.POST("/documents/:id/unpublish", controllers.Document.UnpublishDocument) // 取消发布文档
-
-	// KnowledgeBase(知识库管理)
-	r.GET("/knowledge-bases", controllers.KnowledgeBase.ListKnowledgeBases)              // 获取知识库列表
-	r.GET("/knowledge-bases/:id", controllers.KnowledgeBase.GetKnowledgeBase)            // 获取知识库详情
-	r.POST("/knowledge-bases", controllers.KnowledgeBase.CreateKnowledgeBase)            // 创建知识库
-	r.PUT("/knowledge-bases/:id", controllers.KnowledgeBase.UpdateKnowledgeBase)         // 更新知识库
-	r.PATCH("/knowledge-bases/:id/rag-enabled", controllers.KnowledgeBase.UpdateKnowledgeBaseRAGEnabled) // 知识库是否参与 RAG
-	r.DELETE("/knowledge-bases/:id", controllers.KnowledgeBase.DeleteKnowledgeBase)      // 删除知识库
-	r.GET("/knowledge-bases/:id/documents", controllers.KnowledgeBase.ListDocumentsByKnowledgeBase) // 获取知识库的文档列表
-
-	// Import(文档导入)
-	r.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传)
-	r.POST("/import/urls", controllers.Import.ImportFromURLs)       // 批量导入文档(URL 爬取)
-
-	// Visitor(访客相关)
-	r.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表
-
-	// Health(健康检查)
-	r.GET("/health", controllers.Health.HealthCheck)       // 健康检查
-	r.GET("/health/metrics", controllers.Health.Metrics)   // 性能指标
-
-	// WebSocket
-	r.GET("/ws", wsHandler)
+	register := func(routes gin.IRoutes) {
+		// Auth
+		routes.POST("/login", controllers.Auth.Login)
+		routes.POST("/logout", controllers.Auth.Logout)
+
+		// 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
+		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(用户管理)
+		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(个人资料)
+		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 配置)
+		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(知识库向量模型配置,平台级)
+		routes.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get)
+		routes.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update)
+
+		// Prompt Config(提示词配置,平台级,仅管理员可更新)
+		routes.GET("/agent/prompts", controllers.PromptConfig.Get)
+		routes.PUT("/agent/prompts", controllers.PromptConfig.Update)
+
+		// 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
+
+		// 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)    // 取消发布文档
+
+		// 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)       // 获取知识库的文档列表
+
+		// Import(文档导入)
+		routes.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传)
+		routes.POST("/import/urls", controllers.Import.ImportFromURLs)       // 批量导入文档(URL 爬取)
+
+		// 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) // 访客打开小窗埋点
+
+		// 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"))
 }

+ 412 - 46
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)
+// 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
+}
 
-	// 添加历史对话
+// 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]string{
-			"role":    history.Role,
-			"content": history.Content,
-		})
+		messages = append(messages, map[string]interface{}{"role": history.Role, "content": history.Content})
 	}
-
-	// 添加当前用户消息
-	messages = append(messages, map[string]string{
-		"role":    "user",
-		"content": userMessage,
-	})
-
-	// 构建请求体(OpenAI 兼容格式)
-	requestBody := map[string]interface{}{
-		"model":    p.config.Model,
-		"messages": messages,
+	lastContent := buildUserContent(userMessage, imageBase64, imageMimeType)
+	messages = append(messages, map[string]interface{}{"role": "user", "content": lastContent})
+
+	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")
+}

+ 611 - 58
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 配置
+	res, err := s.GenerateAIResponseWithOptions(conversationID, userMessage, userID, nil)
+	if err != nil {
+		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 "", fmt.Errorf("获取对话失败: %v", err)
+		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 {
-		ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation)
+	// 多模态识图:当前条带图时读取文件并转 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("⚠️ RAG 检索失败: %v,继续使用无知识库上下文", err)
-			// RAG 检索失败不影响主流程,继续处理
+			log.Printf("⚠️ 读取消息图片失败: %v", err)
+		} else {
+			imageBase64 = base64.StdEncoding.EncodeToString(data)
+			imageMimeType = opts.Attachment.MimeType
+			if imageMimeType == "" {
+				imageMimeType = "image/jpeg"
+			}
 		}
 	}
 
-	// 5. 构建增强的用户消息(包含 RAG 上下文)
-	enhancedUserMessage := userMessage
-	if ragContext != "" {
-		enhancedUserMessage = s.buildRAGPrompt(userMessage, ragContext)
+	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)
+		}
+		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,
+				},
+			})
+		}
 	}
 
-	// 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)
+	}
+
+	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")
+		}
 	}
 
-	// 8. 调用 AI 生成回复(使用增强的消息)
-	response, err := provider.GenerateResponse(history, enhancedUserMessage)
+	// 无任何来源时(例如 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 &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(`你是一个智能客服助手。当前未使用知识库,请仅基于你的知识回答用户问题。
 
-	return response, nil
+用户问题:%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)
+}
+
+// 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"},
+				},
+			},
+		},
+	}
+}
 
-	return prompt
+// 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
 }

+ 357 - 0
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
+}

+ 53 - 0
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 {

+ 74 - 24
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"`
 }

+ 67 - 23
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 {

+ 238 - 0
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)
+}

+ 10 - 8
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

+ 25 - 1
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)
 }
 

+ 164 - 0
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
+}
+

+ 31 - 0
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
+}

+ 38 - 25
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:

+ 35 - 21
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:

+ 0 - 10
frontend/.env.example

@@ -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=

+ 3 - 7
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

+ 214 - 0
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 (
+    <div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
+      <div className="text-xs font-medium text-muted-foreground">{title}</div>
+      <div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
+      {sub ? <div className="mt-1 text-xs text-muted-foreground">{sub}</div> : null}
+    </div>
+  );
+}
+
+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 <p className="text-sm text-muted-foreground">暂无数据</p>;
+  }
+
+  return (
+    <div>
+      <div className="mb-2 text-sm font-medium text-foreground">{label}</div>
+      <div className="flex h-36 items-end gap-1 border-b border-border/40 pb-1">
+        {daily.map((row) => {
+          const v = Number(row[field]) || 0;
+          const h = Math.round((v / max) * 100);
+          return (
+            <div
+              key={row.date}
+              className="flex min-w-0 flex-1 flex-col items-center justify-end gap-1"
+              title={`${row.date}: ${v}`}
+            >
+              <div
+                className="w-full max-w-[28px] rounded-t transition-all"
+                style={{
+                  height: `${Math.max(h, v > 0 ? 8 : 0)}%`,
+                  backgroundColor: color,
+                  minHeight: v > 0 ? 4 : 0,
+                }}
+              />
+              <span className="truncate text-[10px] text-muted-foreground">
+                {row.date.slice(5)}
+              </span>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+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<AnalyticsSummaryResponse | null>(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 (
+    <div
+      className="flex flex-col min-h-0 overflow-auto p-4 max-w-6xl mx-auto w-full"
+    >
+      <div className="mb-6 flex flex-wrap items-end gap-4">
+        <div>
+          <h1 className="text-xl font-semibold tracking-tight">数据报表</h1>
+          <p className="text-sm text-muted-foreground mt-1">
+            访客小窗与 AI 客服统计(按上海时区自然日,不含「知识库测试」内部会话)
+          </p>
+        </div>
+        <div className="flex flex-wrap items-center gap-2 ml-auto">
+          <label className="text-xs text-muted-foreground flex items-center gap-1">
+            从
+            <input
+              type="date"
+              value={from}
+              onChange={(e) => setFrom(e.target.value)}
+              className="rounded-md border border-input bg-background px-2 py-1 text-sm"
+            />
+          </label>
+          <label className="text-xs text-muted-foreground flex items-center gap-1">
+            到
+            <input
+              type="date"
+              value={to}
+              onChange={(e) => setTo(e.target.value)}
+              className="rounded-md border border-input bg-background px-2 py-1 text-sm"
+            />
+          </label>
+          <Button size="sm" onClick={() => void load()} disabled={loading}>
+            {loading ? "加载中…" : "查询"}
+          </Button>
+        </div>
+      </div>
+
+      {data && (
+        <p className="text-xs text-muted-foreground mb-4">{data.note}</p>
+      )}
+
+      {t && (
+        <>
+          <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mb-6">
+            <StatCard title="小窗打开次数" value={t.widget_opens} sub="需前端埋点,历史数据可能为 0" />
+            <StatCard title="新建会话数" value={t.sessions} />
+            <StatCard title="消息数" value={t.messages} />
+            <StatCard title="AI 回复次数" value={t.ai_replies} />
+            <StatCard title="AI 失败次数" value={t.ai_failed} />
+            <StatCard title="AI 失败率" value={formatPercent(t.ai_failure_rate_percent)} sub="占 AI 回复条数" />
+            <StatCard title="知识库命中次数" value={t.kb_hits} />
+            <StatCard title="知识库命中率" value={formatPercent(t.kb_hit_rate_percent)} sub="占成功 AI 回复" />
+            <StatCard title="最大 AI 对话轮数" value={t.max_ai_rounds} sub="单会话内用户+AI 一轮" />
+            <StatCard title="AI 参与会话" value={t.sessions_with_ai} sub={`占新建会话 ${formatPercent(t.ai_participation_rate_percent)}`} />
+            <StatCard title="AI→人工(会话数)" value={t.ai_to_human_sessions} sub={`占有过 AI 发言的会话 ${formatPercent(t.ai_to_human_rate_percent)}`} />
+            <StatCard title="人工→AI(会话数)" value={t.human_to_ai_sessions} sub={`占有过人工发言的会话 ${formatPercent(t.human_to_ai_rate_percent)}`} />
+          </div>
+
+          <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 rounded-xl border border-border/60 bg-card p-4">
+            <DailyBars
+              daily={data!.daily}
+              field="widget_opens"
+              label="每日小窗打开"
+              color="rgb(34 197 94)"
+            />
+            <DailyBars
+              daily={data!.daily}
+              field="sessions"
+              label="每日新建会话"
+              color="rgb(59 130 246)"
+            />
+            <DailyBars
+              daily={data!.daily}
+              field="messages"
+              label="每日消息数"
+              color="rgb(168 85 247)"
+            />
+            <DailyBars
+              daily={data!.daily}
+              field="ai_replies"
+              label="每日 AI 回复"
+              color="rgb(249 115 22)"
+            />
+          </div>
+        </>
+      )}
+
+      {!loading && !t && (
+        <p className="text-sm text-muted-foreground">暂无数据或加载失败</p>
+      )}
+    </div>
+  );
+}

+ 3 - 3
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) {

+ 2 - 2
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 }),

+ 288 - 0
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<QuerySystemLogsResult | null>(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 (
+    <div className={`flex flex-col min-h-0 overflow-auto ${embedded ? "p-4" : "p-6 max-w-6xl mx-auto w-full"}`}>
+      <div className="mb-4">
+        <h1 className="text-xl font-semibold">日志中心</h1>
+        <p className="text-sm text-muted-foreground mt-1">按分类查看 AI / RAG / 系统 / 前端日志,用于排障定位。</p>
+      </div>
+
+      <div className="rounded-xl border border-border/60 bg-card p-3 mb-4 flex flex-wrap gap-2 items-center">
+        <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
+        <span className="text-xs text-muted-foreground">到</span>
+        <input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
+        <select value={level} onChange={(e) => setLevel(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
+          <option value="">全部级别</option>
+          <option value="info">info</option>
+          <option value="warn">warn</option>
+          <option value="error">error</option>
+        </select>
+        <select value={category} onChange={(e) => setCategory(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
+          <option value="">全部分类</option>
+          <option value="ai">ai</option>
+          <option value="rag">rag</option>
+          <option value="frontend">frontend</option>
+          <option value="system">system</option>
+          <option value="business">business</option>
+          <option value="http">http</option>
+          <option value="vector">vector</option>
+        </select>
+        <select value={source} onChange={(e) => setSource(e.target.value)} className="rounded-md border px-2 py-1 text-sm">
+          <option value="">全部来源</option>
+          <option value="backend">backend</option>
+          <option value="frontend">frontend</option>
+        </select>
+        <input
+          placeholder="事件名(event)"
+          value={event}
+          onChange={(e) => setEvent(e.target.value)}
+          className="rounded-md border px-2 py-1 text-sm min-w-[180px]"
+        />
+        <input
+          placeholder="会话ID"
+          value={conversationId}
+          onChange={(e) => setConversationId(e.target.value)}
+          className="rounded-md border px-2 py-1 text-sm w-24"
+        />
+        <input
+          placeholder="关键词(message/meta)"
+          value={keyword}
+          onChange={(e) => setKeyword(e.target.value)}
+          className="rounded-md border px-2 py-1 text-sm min-w-[220px]"
+        />
+        <Button size="sm" disabled={loading} onClick={() => { setPage(1); void load(); }}>
+          {loading ? "加载中..." : "查询"}
+        </Button>
+      </div>
+
+      <div className="rounded-xl border border-border/60 bg-card overflow-hidden">
+        <div className="px-3 py-2 border-b text-xs text-muted-foreground">
+          共 {data?.total ?? 0} 条,当前第 {data?.page ?? page}/{totalPages} 页
+        </div>
+        <div className="overflow-auto">
+          <table className="w-full text-sm">
+            <thead className="bg-muted/40 text-xs text-muted-foreground">
+              <tr>
+                <th className="text-left px-3 py-2">时间</th>
+                <th className="text-left px-3 py-2">级别</th>
+                <th className="text-left px-3 py-2">分类</th>
+                <th className="text-left px-3 py-2">事件</th>
+                <th className="text-left px-3 py-2">会话</th>
+                <th className="text-left px-3 py-2">来源</th>
+                <th className="text-left px-3 py-2">消息</th>
+              </tr>
+            </thead>
+            <tbody>
+              {(data?.items ?? []).map((item) => (
+                <tr
+                  key={item.id}
+                  className="border-t cursor-pointer hover:bg-muted/30"
+                  onClick={() => setSelected(item)}
+                >
+                  <td className="px-3 py-2 whitespace-nowrap text-xs">{new Date(item.timestamp).toLocaleString()}</td>
+                  <td className={`px-3 py-2 font-medium ${levelColor(item.level)}`}>{item.level}</td>
+                  <td className="px-3 py-2">{item.category}</td>
+                  <td className="px-3 py-2">{item.event}</td>
+                  <td className="px-3 py-2">{item.conversation_id ?? "-"}</td>
+                  <td className="px-3 py-2">{item.source}</td>
+                  <td className="px-3 py-2 max-w-[560px] truncate" title={item.message}>{item.message}</td>
+                </tr>
+              ))}
+              {(data?.items ?? []).length === 0 && !loading && (
+                <tr>
+                  <td className="px-3 py-8 text-center text-muted-foreground" colSpan={7}>暂无日志</td>
+                </tr>
+              )}
+            </tbody>
+          </table>
+        </div>
+        <div className="px-3 py-2 border-t flex items-center justify-end gap-2">
+          <Button
+            variant="outline"
+            size="sm"
+            disabled={loading || page <= 1}
+            onClick={() => setPage((p) => Math.max(1, p - 1))}
+          >
+            上一页
+          </Button>
+          <Button
+            variant="outline"
+            size="sm"
+            disabled={loading || page >= totalPages}
+            onClick={() => setPage((p) => p + 1)}
+          >
+            下一页
+          </Button>
+        </div>
+      </div>
+
+      <Dialog
+        open={Boolean(selected)}
+        onOpenChange={(open) => {
+          if (!open) setSelected(null);
+        }}
+      >
+        <DialogContent className="max-w-4xl">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <span>日志详情</span>
+              {selected ? (
+                <span className={`text-xs px-2 py-0.5 rounded border ${selected.level === "error" ? "border-red-200 text-red-700" : selected.level === "warn" ? "border-amber-200 text-amber-700" : "border-emerald-200 text-emerald-700"}`}>
+                  {selected.level}
+                </span>
+              ) : null}
+            </DialogTitle>
+          </DialogHeader>
+
+          {selected ? (
+            <div className="space-y-3">
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
+                <div className="rounded-lg border p-2">
+                  <div className="text-xs text-muted-foreground">时间</div>
+                  <div className="font-medium">{new Date(selected.timestamp).toLocaleString()}</div>
+                </div>
+                <div className="rounded-lg border p-2">
+                  <div className="text-xs text-muted-foreground">source / event</div>
+                  <div className="font-medium">
+                    {selected.source} / {selected.event}
+                  </div>
+                </div>
+                <div className="rounded-lg border p-2">
+                  <div className="text-xs text-muted-foreground">category</div>
+                  <div className="font-medium">{selected.category}</div>
+                </div>
+                <div className="rounded-lg border p-2">
+                  <div className="text-xs text-muted-foreground">trace_id</div>
+                  <div className="font-medium break-all">{selected.trace_id || "-"}</div>
+                </div>
+                <div className="rounded-lg border p-2">
+                  <div className="text-xs text-muted-foreground">conversation_id</div>
+                  <div className="font-medium">{selected.conversation_id ?? "-"}</div>
+                </div>
+                <div className="rounded-lg border p-2">
+                  <div className="text-xs text-muted-foreground">user_id / visitor_id</div>
+                  <div className="font-medium">
+                    {selected.user_id ?? "-"} / {selected.visitor_id ?? "-"}
+                  </div>
+                </div>
+              </div>
+
+              <div className="rounded-lg border p-3">
+                <div className="flex items-center justify-between gap-2 mb-2">
+                  <div className="text-sm font-medium">message</div>
+                  <Button
+                    size="sm"
+                    variant="outline"
+                    onClick={async () => {
+                      try {
+                        await navigator.clipboard.writeText(selected.message);
+                        toast.success("已复制 message");
+                      } catch {
+                        toast.error("复制失败");
+                      }
+                    }}
+                  >
+                    <Copy className="h-4 w-4 mr-1" />
+                    复制
+                  </Button>
+                </div>
+                <pre className="whitespace-pre-wrap text-sm bg-muted/30 rounded p-2 max-h-48 overflow-auto">{selected.message}</pre>
+              </div>
+
+              <div className="rounded-lg border p-3">
+                <div className="text-sm font-medium mb-2">meta_json</div>
+                <pre className="whitespace-pre-wrap text-xs bg-muted/30 rounded p-2 max-h-80 overflow-auto">
+                  {selectedMeta || "(无 meta_json)"}
+                </pre>
+              </div>
+            </div>
+          ) : null}
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+}
+

+ 190 - 0
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<number | null>(null);
+  const [prompts, setPrompts] = useState<PromptItem[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [savingKey, setSavingKey] = useState<string | null>(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 = (
+    <div className="bg-card border-b p-4 shadow-sm">
+      <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
+        <div>
+          <h1 className="text-xl font-bold text-foreground">提示词</h1>
+          <div className="text-sm text-muted-foreground mt-1">
+            配置系统中使用的提示词模板,用于 RAG、联网等场景。仅管理员可修改。占位符说明见下方各卡片。
+          </div>
+        </div>
+        {!embedded && (
+          <Button
+            onClick={() => router.push("/agent/dashboard")}
+            variant="outline"
+            size="sm"
+          >
+            返回工作台
+          </Button>
+        )}
+      </div>
+    </div>
+  );
+
+  const mainContent = (
+    <div className="flex-1 overflow-auto p-4 md:p-6">
+      <div className="max-w-4xl mx-auto space-y-6">
+        {error && (
+          <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
+            {error}
+          </div>
+        )}
+        {loading ? (
+          <div className="text-center py-12 text-muted-foreground">加载中...</div>
+        ) : (
+          prompts.map((item) => (
+            <Card key={item.key}>
+              <CardHeader>
+                <CardTitle className="text-base">{item.name}</CardTitle>
+                {getUsageScenario(item.key) && (
+                  <p className="text-sm text-muted-foreground mt-1">
+                    <span className="font-medium">使用场景:</span>
+                    {getUsageScenario(item.key)}
+                  </p>
+                )}
+                <p className="text-xs text-muted-foreground mt-1">{getPlaceholderHint(item.key)}</p>
+              </CardHeader>
+              <CardContent className="space-y-3">
+                <textarea
+                  className={`w-full ${getTextareaMinHeight(item.key)} px-3 py-2 border border-input rounded-md text-sm bg-background font-mono resize-y`}
+                  value={item.content}
+                  onChange={(e) => handleContentChange(item.key, e.target.value)}
+                  placeholder={item.key === "no_source_reply" || item.key === "ai_fail_reply" ? "请输入一句完整回复语" : "请输入提示词内容,保留占位符"}
+                  spellCheck={false}
+                />
+                <Button
+                  size="sm"
+                  onClick={() => handleSave(item.key, item.content)}
+                  disabled={savingKey === item.key}
+                >
+                  {savingKey === item.key ? "保存中..." : "保存"}
+                </Button>
+              </CardContent>
+            </Card>
+          ))
+        )}
+      </div>
+    </div>
+  );
+
+  if (embedded) {
+    return (
+      <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+        {headerContent}
+        {mainContent}
+      </div>
+    );
+  }
+
+  return (
+    <ResponsiveLayout header={headerContent} main={mainContent} />
+  );
+}

+ 68 - 2
frontend/app/agent/settings/page.tsx

@@ -22,7 +22,7 @@ import {
   type UpdateEmbeddingConfigRequest,
 } from "@/features/agent/services/embeddingConfigApi";
 import { useProfile } from "@/features/agent/hooks/useProfile";
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 import { Checkbox } from "@/components/ui/checkbox";
 import { Label } from "@/components/ui/label";
 import { toast } from "@/hooks/useToast";
@@ -55,6 +55,8 @@ export default function SettingsPage(props: any = {}) {
     api_key: "",
     model: "text-embedding-3-small",
     customer_can_use_kb: true,
+    visitor_web_search_enabled: false,
+    web_search_source: "custom" as "vendor" | "custom",
   });
   const [embeddingLoading, setEmbeddingLoading] = useState(false);
   const [embeddingSubmitting, setEmbeddingSubmitting] = useState(false);
@@ -114,6 +116,8 @@ export default function SettingsPage(props: any = {}) {
         api_key: "",
         model: data.model || "text-embedding-3-small",
         customer_can_use_kb: data.customer_can_use_kb ?? true,
+        visitor_web_search_enabled: data.visitor_web_search_enabled ?? false,
+        web_search_source: data.web_search_source === "vendor" ? "vendor" : "custom",
       });
     } catch (e) {
       console.error("加载知识库向量配置失败:", e);
@@ -141,6 +145,8 @@ export default function SettingsPage(props: any = {}) {
         api_url: embeddingForm.api_url || undefined,
         model: embeddingForm.model || undefined,
         customer_can_use_kb: embeddingForm.customer_can_use_kb,
+        visitor_web_search_enabled: embeddingForm.visitor_web_search_enabled,
+        web_search_source: embeddingForm.web_search_source,
       };
       if (embeddingForm.api_key) {
         data.api_key = embeddingForm.api_key;
@@ -240,7 +246,7 @@ export default function SettingsPage(props: any = {}) {
   // 退出登录
   const handleLogout = async () => {
     try {
-      await fetch(`${API_BASE_URL}/logout`, { method: "POST" });
+      await fetch(apiUrl("/logout"), { method: "POST" });
     } catch (error) {
       console.error("退出登录失败:", error);
     } finally {
@@ -416,6 +422,66 @@ export default function SettingsPage(props: any = {}) {
             </CardContent>
           </Card>
 
+          {/* 联网搜索设置(与知识库向量模型独立;实际仍写入同一配置,仅 UI 分离) */}
+          <Card>
+            <CardHeader>
+              <CardTitle>联网搜索设置</CardTitle>
+              <p className="text-sm text-muted-foreground mt-1">
+                控制对话中的联网搜索方式与访客端是否显示联网选项。与上方「知识库向量模型」无关,仅影响 AI 对话时的联网行为。
+              </p>
+            </CardHeader>
+            <CardContent>
+              {embeddingLoading ? (
+                <div className="text-center py-6 text-muted-foreground">加载中...</div>
+              ) : (
+                <form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
+                  {embeddingError && (
+                    <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
+                      {embeddingError}
+                    </div>
+                  )}
+                  <div>
+                    <Label className="block text-sm font-medium mb-1">联网方式</Label>
+                    <select
+                      value={embeddingForm.web_search_source}
+                      onChange={(e) =>
+                        setEmbeddingForm({
+                          ...embeddingForm,
+                          web_search_source: e.target.value as "vendor" | "custom",
+                        })
+                      }
+                      className="w-full max-w-xs px-3 py-2 border border-input rounded-md text-sm bg-background"
+                    >
+                      <option value="custom">自建(Serper)</option>
+                      <option value="vendor">厂商内置</option>
+                    </select>
+                    <p className="text-xs text-muted-foreground mt-1">
+                      自建:由后端通过 Serper(MCP 或 HTTP)执行;厂商内置:使用当前对话所用 AI 厂商自带的 web search,不占用 Serper。
+                    </p>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <Checkbox
+                      id="visitor_web_search_enabled_standalone"
+                      checked={embeddingForm.visitor_web_search_enabled}
+                      onCheckedChange={(checked) =>
+                        setEmbeddingForm({
+                          ...embeddingForm,
+                          visitor_web_search_enabled: checked === true,
+                        })
+                      }
+                    />
+                    <Label htmlFor="visitor_web_search_enabled_standalone" className="text-sm cursor-pointer">
+                      访客小窗显示「本回合联网搜索」选项
+                    </Label>
+                  </div>
+                  <Button type="submit" disabled={embeddingSubmitting}>
+                    {embeddingSubmitting ? "保存中..." : "保存联网设置"}
+                  </Button>
+                </form>
+              )}
+            </CardContent>
+          </Card>
+
           {/* 配置表单 */}
           <Card>
             <CardHeader>

+ 46 - 0
frontend/app/api/agent/prompts/route.ts

@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from "next/server";
+
+const BACKEND_HOST = process.env.NEXT_PUBLIC_BACKEND_HOST || "localhost";
+const BACKEND_PORT = process.env.NEXT_PUBLIC_BACKEND_PORT || "8080";
+const BACKEND_BASE = `http://${BACKEND_HOST}:${BACKEND_PORT}`;
+
+/** 开发环境:将 /api/agent/prompts 代理到后端,避免 rewrites 在 Turbopack 下不稳定 */
+export async function GET(request: NextRequest) {
+  const { searchParams } = new URL(request.url);
+  const backendUrl = `${BACKEND_BASE}/agent/prompts?${searchParams.toString()}`;
+  try {
+    const res = await fetch(backendUrl, { cache: "no-store" });
+    const body = await res.text();
+    return new NextResponse(body, {
+      status: res.status,
+      headers: { "Content-Type": res.headers.get("content-type") || "application/json" },
+    });
+  } catch (e) {
+    return NextResponse.json(
+      { error: "无法连接后端,请确认后端已启动且端口一致(默认 8080)" },
+      { status: 502 }
+    );
+  }
+}
+
+export async function PUT(request: NextRequest) {
+  const backendUrl = `${BACKEND_BASE}/agent/prompts`;
+  try {
+    const body = await request.text();
+    const res = await fetch(backendUrl, {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body,
+    });
+    const resBody = await res.text();
+    return new NextResponse(resBody, {
+      status: res.status,
+      headers: { "Content-Type": res.headers.get("content-type") || "application/json" },
+    });
+  } catch (e) {
+    return NextResponse.json(
+      { error: "无法连接后端,请确认后端已启动且端口一致(默认 8080)" },
+      { status: 502 }
+    );
+  }
+}

+ 2 - 1
frontend/app/layout.tsx

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
 import "./globals.css";
 import MatomoTracker from "@/components/MatomoTracker";
 import { Toaster } from "@/components/ui/toaster";
+import { getSiteUrl } from "@/lib/site";
 
 const geistSans = Geist({
   variable: "--font-geist-sans",
@@ -31,7 +32,7 @@ export default function RootLayout({
   children: React.ReactNode;
 }>) {
   return (
-    <html lang="en">
+    <html lang="zh-CN">
       <head>
         <script
           dangerouslySetInnerHTML={{

+ 10 - 615
frontend/app/page.tsx

@@ -1,621 +1,16 @@
-"use client";
+import { HomePageClient } from "@/components/marketing/HomePageClient";
+import { buildHomeJsonLd } from "@/lib/seo/home-json-ld";
 
-import { useEffect, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { 
-  MessageSquare,
-  Bot, 
-  Users, 
-  Zap, 
-  Shield, 
-  BarChart3,
-  CheckCircle2,
-  ArrowRight,
-  Star,
-  HelpCircle,
-  LayoutDashboard,
-  FileText,
-  Globe
-} from "lucide-react";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import Link from "next/link";
-import { ScreenshotDisplay } from "@/components/ScreenshotDisplay";
-import { ChatWidget } from "@/components/visitor/ChatWidget";
-import { FloatingButton } from "@/components/visitor/FloatingButton";
-import { Header } from "@/components/layout/Header";
-import { Footer } from "@/components/layout/Footer";
-import { FadeIn, FadeInStagger, FadeInItem } from "@/components/ui/fade-in";
-import { stats, testimonials, partnerLogos } from "@/lib/stats-config";
-
-/**
- * AI-CS 智能客服系统 - 产品官网首页
- * 
- * 包含:
- * - Hero 区域(主标题、副标题、CTA按钮)
- * - 核心功能介绍
- * - 产品特性
- * - 案例研究/客户评价
- * - 底部 CTA
- */
 export default function HomePage() {
-  const [visitorId, setVisitorId] = useState<number | null>(null);
-  const [isChatOpen, setIsChatOpen] = useState(false);
-
-  // 初始化访客 ID(使用 localStorage 保持连续性)
-  useEffect(() => {
-    let stored = window.localStorage.getItem("visitor_id");
-    if (!stored) {
-      stored = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
-      window.localStorage.setItem("visitor_id", stored);
-    }
-    const parsed = Number.parseInt(stored, 10);
-    setVisitorId(Number.isNaN(parsed) ? null : parsed);
-  }, []);
-
-  const handleToggleChat = () => {
-    setIsChatOpen((prev) => !prev);
-  };
-
-  const handleOpenChat = () => {
-    // 如果访客ID还没初始化,等待一下
-    if (visitorId === null) {
-      // 等待访客ID初始化后再打开
-      setTimeout(() => {
-        setIsChatOpen(true);
-      }, 500);
-    } else {
-      // 直接打开聊天窗口
-      setIsChatOpen(true);
-    }
-  };
+  const jsonLd = buildHomeJsonLd();
 
   return (
-    <div className="min-h-screen bg-background">
-      <Header />
-
-      {/* Hero - Chatbase 风格:简洁、留白、一句主标题 + 副标题 + CTA */}
-      <section className="container mx-auto px-4 pt-20 pb-28 md:pt-28 md:pb-36 lg:pt-36 lg:pb-44 relative overflow-hidden">
-        <div className="absolute inset-0 bg-gradient-to-b from-muted/30 to-transparent pointer-events-none" />
-        <div className="max-w-3xl mx-auto text-center relative z-10">
-          <p className="text-sm font-medium text-muted-foreground tracking-wide uppercase mb-4">
-            AI 智能客服
-          </p>
-          <h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-foreground tracking-tight mb-6 leading-[1.15]">
-            让客户服务更简单、更高效
-          </h1>
-          <p className="text-lg sm:text-xl text-muted-foreground mb-10 max-w-xl mx-auto leading-relaxed">
-            7×24 小时智能应答,AI 与人工无缝切换,释放团队时间专注更有价值的事
-          </p>
-          <div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
-            <Button
-              size="lg"
-              className="w-full sm:w-auto text-base px-8 py-6 rounded-xl shadow-sm hover:shadow transition-shadow"
-              onClick={handleOpenChat}
-            >
-              免费试用
-              <ArrowRight className="ml-2 w-4 h-4" />
-            </Button>
-            <Button
-              asChild
-              size="lg"
-              variant="outline"
-              className="w-full sm:w-auto text-base px-8 py-6 rounded-xl border border-border hover:bg-muted/50"
-            >
-              <Link href="/agent/login">客服登录</Link>
-            </Button>
-          </div>
-          <p className="mt-4 text-sm text-muted-foreground">无需信用卡,立即可用</p>
-        </div>
-      </section>
-
-      {/* 信任数据 - 轻量展示 */}
-      <section className="py-12 md:py-16 border-t border-border/50">
-        <FadeIn>
-          <div className="container mx-auto px-4">
-            <p className="text-xs font-medium text-muted-foreground text-center mb-8 tracking-wide">
-              深受企业信赖
-            </p>
-            <div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-4xl mx-auto">
-              {stats.map((stat, index) => (
-                <div key={index} className="text-center">
-                  <div className="text-2xl md:text-3xl font-semibold text-foreground">{stat.value}</div>
-                  <div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
-                </div>
-              ))}
-            </div>
-          </div>
-        </FadeIn>
-      </section>
-
-      {/* Logo 墙 - 有数据时展示 */}
-      {partnerLogos.length > 0 && (
-        <section className="py-12 md:py-16 border-t border-border/50">
-          <FadeIn>
-            <div className="container mx-auto px-4">
-              <div className="flex flex-wrap items-center justify-center gap-10 md:gap-14 opacity-50">
-                {partnerLogos.map((partner, index) => (
-                  <div
-                    key={index}
-                    className="text-sm text-muted-foreground font-medium hover:opacity-100 transition-opacity"
-                  >
-                    {partner.name}
-                  </div>
-                ))}
-              </div>
-            </div>
-          </FadeIn>
-        </section>
-      )}
-
-      {/* 核心功能 */}
-      <section id="features" className="container mx-auto px-4 py-20 md:py-28">
-        <FadeIn>
-          <div className="text-center mb-14 px-4">
-            <h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">
-              功能特性
-            </h2>
-            <p className="text-muted-foreground text-base max-w-xl mx-auto">
-              专业、及时的服务体验,让每个客户都感受到高效与可靠
-            </p>
-          </div>
-        </FadeIn>
-        <FadeInStagger className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 max-w-6xl mx-auto px-4">
-          <FadeInItem>
-          <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-            <CardHeader>
-              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
-                <Bot className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
-              </div>
-              <CardTitle>7×24 小时智能应答</CardTitle>
-              <CardDescription>
-                让 AI 帮你处理常见问题,释放团队时间,专注于更有价值的工作
-              </CardDescription>
-            </CardHeader>
-            <CardContent>
-              <ul className="space-y-2 text-sm text-muted-foreground">
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  支持多种 AI 模型,选择最适合你的
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  智能理解客户意图,准确回答
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  记住对话上下文,体验更自然
-                </li>
-              </ul>
-            </CardContent>
-          </Card>
-          </FadeInItem>
-
-          <FadeInItem>
-          <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-            <CardHeader>
-              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
-                <Users className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
-              </div>
-              <CardTitle>无缝切换 AI 与人工</CardTitle>
-              <CardDescription>
-                复杂问题一键转人工,让客户随时得到最合适的帮助
-              </CardDescription>
-            </CardHeader>
-            <CardContent>
-              <ul className="space-y-2 text-sm text-muted-foreground">
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  消息实时同步,无缝衔接
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  清晰显示在线状态,客户一目了然
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  多客服协作,高效处理复杂问题
-                </li>
-              </ul>
-            </CardContent>
-          </Card>
-          </FadeInItem>
-
-          <FadeInItem>
-          <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-            <CardHeader>
-              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
-                <MessageSquare className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
-              </div>
-              <CardTitle>消息即时到达</CardTitle>
-              <CardDescription>
-                毫秒级响应,让客户感受到专业、及时的服务体验
-              </CardDescription>
-            </CardHeader>
-            <CardContent>
-              <ul className="space-y-2 text-sm text-muted-foreground">
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  双向实时通信,就像面对面交流
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  消息已读状态,沟通更透明
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  支持文件图片,沟通更便捷
-                </li>
-              </ul>
-            </CardContent>
-          </Card>
-          </FadeInItem>
-
-          <FadeInItem>
-          <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-            <CardHeader>
-              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
-                <Zap className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
-              </div>
-              <CardTitle>极速响应,稳定可靠</CardTitle>
-              <CardDescription>
-                毫秒级响应速度,轻松应对高并发,让服务永不中断
-              </CardDescription>
-            </CardHeader>
-            <CardContent>
-              <ul className="space-y-2 text-sm text-muted-foreground">
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  毫秒级响应,客户无需等待
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  高并发支持,业务增长无压力
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  自动重连,服务永不中断
-                </li>
-              </ul>
-            </CardContent>
-          </Card>
-          </FadeInItem>
-
-          <FadeInItem>
-          <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-            <CardHeader>
-              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
-                <Shield className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
-              </div>
-              <CardTitle>企业级安全保障</CardTitle>
-              <CardDescription>
-                数据加密存储,权限精细管理,让您的业务数据安全无忧
-              </CardDescription>
-            </CardHeader>
-            <CardContent>
-              <ul className="space-y-2 text-sm text-muted-foreground">
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  API 密钥加密,安全可靠
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  精细权限管理,灵活可控
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  自动数据备份,万无一失
-                </li>
-              </ul>
-            </CardContent>
-          </Card>
-          </FadeInItem>
-
-          <FadeInItem>
-          <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-            <CardHeader>
-              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
-                <BarChart3 className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
-              </div>
-              <CardTitle>数据驱动决策</CardTitle>
-              <CardDescription>
-                全面的数据分析,帮助您了解客户需求,优化服务质量
-              </CardDescription>
-            </CardHeader>
-            <CardContent>
-              <ul className="space-y-2 text-sm text-muted-foreground">
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  会话统计分析,洞察客户需求
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  客服工作量统计,合理分配资源
-                </li>
-                <li className="flex items-center gap-2">
-                  <CheckCircle2 className="w-4 h-4 text-primary" />
-                  响应时间分析,持续优化服务
-                </li>
-              </ul>
-            </CardContent>
-          </Card>
-          </FadeInItem>
-        </FadeInStagger>
-      </section>
-
-      {/* 界面展示 */}
-      <FadeIn>
-      <section id="screenshots" className="container mx-auto px-4 py-20 md:py-28 border-t border-border/50">
-        <div className="text-center mb-14 px-4">
-          <h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">界面展示</h2>
-          <p className="text-muted-foreground text-base max-w-xl mx-auto">精心设计的界面,让管理更轻松</p>
-        </div>
-        <div className="max-w-6xl mx-auto">
-          <Tabs defaultValue="dashboard" className="w-full">
-            <TabsList className="grid w-full grid-cols-3 md:grid-cols-5 mb-8">
-              <TabsTrigger value="dashboard">工作台</TabsTrigger>
-              <TabsTrigger value="visitor">访客端</TabsTrigger>
-              <TabsTrigger value="ai-config">AI配置</TabsTrigger>
-              <TabsTrigger value="users">用户管理</TabsTrigger>
-              <TabsTrigger value="faq">FAQ管理</TabsTrigger>
-            </TabsList>
-            <TabsContent value="dashboard" className="mt-0">
-              <div className="border rounded-lg overflow-hidden">
-                <ScreenshotDisplay
-                  imageName="dashboard.png"
-                  placeholderIcon={LayoutDashboard}
-                  placeholderText="工作台界面"
-                  alt="AI-CS 工作台界面"
-                />
-              </div>
-            </TabsContent>
-            <TabsContent value="visitor" className="mt-0">
-              <div className="border rounded-lg overflow-hidden">
-                <ScreenshotDisplay
-                  imageName="visitor.png"
-                  placeholderIcon={Globe}
-                  placeholderText="访客端界面"
-                  alt="AI-CS 访客端界面"
-                />
-              </div>
-            </TabsContent>
-            <TabsContent value="ai-config" className="mt-0">
-              <div className="border rounded-lg overflow-hidden">
-                <ScreenshotDisplay
-                  imageName="ai-config.png"
-                  placeholderIcon={Bot}
-                  placeholderText="AI配置界面"
-                  alt="AI-CS AI配置界面"
-                />
-              </div>
-            </TabsContent>
-            <TabsContent value="users" className="mt-0">
-              <div className="border rounded-lg overflow-hidden">
-                <ScreenshotDisplay
-                  imageName="users.png"
-                  placeholderIcon={Users}
-                  placeholderText="用户管理界面"
-                  alt="AI-CS 用户管理界面"
-                />
-              </div>
-            </TabsContent>
-            <TabsContent value="faq" className="mt-0">
-              <div className="border rounded-lg overflow-hidden">
-                <ScreenshotDisplay
-                  imageName="faq.png"
-                  placeholderIcon={FileText}
-                  placeholderText="FAQ管理界面"
-                  alt="AI-CS FAQ管理界面"
-                />
-              </div>
-            </TabsContent>
-          </Tabs>
-        </div>
-      </section>
-      </FadeIn>
-
-      {/* 客户评价 */}
-      <section id="testimonials" className="container mx-auto px-4 py-12 md:py-16 lg:py-24">
-        <FadeIn>
-            <div className="text-center mb-8 md:mb-12 px-4">
-              <h2 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-3 md:mb-4">客户评价</h2>
-              <p className="text-muted-foreground text-base sm:text-lg max-w-2xl mx-auto">
-                已有众多企业选择我们,让客户服务变得更简单
-              </p>
-            </div>
-        </FadeIn>
-        <FadeInStagger className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 max-w-6xl mx-auto px-4">
-          {testimonials.map((testimonial, index) => (
-            <FadeInItem key={index}>
-              <Card className="border-2 hover:border-primary/50 hover:scale-[1.02] hover:shadow-xl shadow-lg transition-all duration-300 h-full flex flex-col">
-                <CardContent className="p-6 flex flex-col flex-1">
-                  <div className="flex items-center gap-1 mb-4">
-                    {[...Array(5)].map((_, i) => (
-                      <Star
-                        key={i}
-                        className="w-4 h-4 fill-yellow-400 text-yellow-400"
-                      />
-                    ))}
-                  </div>
-                  <p className="text-muted-foreground mb-6 flex-1 leading-relaxed">
-                    "{testimonial.content}"
-                  </p>
-                  <div className="flex items-center gap-3 pt-4 border-t">
-                    <div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
-                      <Users className="w-5 h-5 text-primary" />
-                    </div>
-                    <div>
-                      <div className="font-semibold text-sm">{testimonial.name}</div>
-                      <div className="text-xs text-muted-foreground">{testimonial.company}</div>
-                    </div>
-                  </div>
-                </CardContent>
-              </Card>
-            </FadeInItem>
-          ))}
-        </FadeInStagger>
-      </section>
-
-      {/* 常见问题 */}
-      <section id="faq" className="container mx-auto px-4 py-20 md:py-28 border-t border-border/50">
-        <div className="max-w-4xl mx-auto">
-          <FadeIn>
-            <div className="text-center mb-14 px-4">
-              <h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">常见问题</h2>
-              <p className="text-muted-foreground text-base max-w-xl mx-auto">快速了解 AI-CS,解答您的疑问</p>
-            </div>
-          </FadeIn>
-          <FadeInStagger className="grid grid-cols-1 md:grid-cols-2 gap-4 px-4">
-            <FadeInItem>
-            <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-              <CardContent className="p-6">
-                <div className="flex items-start gap-4">
-                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
-                    <HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
-                  </div>
-                  <div className="flex-1">
-                    <h3 className="font-semibold text-lg mb-2">AI-CS 支持哪些 AI 模型?</h3>
-                    <p className="text-muted-foreground text-sm">
-                      支持 OpenAI、DeepSeek、百智云等主流 AI 平台,您可以选择最适合的模型,也可以自定义 API 配置,灵活便捷。
-                    </p>
-                  </div>
-                </div>
-              </CardContent>
-            </Card>
-            </FadeInItem>
-
-            <FadeInItem>
-            <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-              <CardContent className="p-6">
-                <div className="flex items-start gap-4">
-                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
-                    <HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
-                  </div>
-                  <div className="flex-1">
-                    <h3 className="font-semibold text-lg mb-2">如何实现 AI 和人工客服的切换?</h3>
-                    <p className="text-muted-foreground text-sm">
-                      访客只需一键即可切换,复杂问题转人工,简单问题 AI 处理,系统会自动无缝衔接,让客户体验更流畅。
-                    </p>
-                  </div>
-                </div>
-              </CardContent>
-            </Card>
-            </FadeInItem>
-
-            <FadeInItem>
-            <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-              <CardContent className="p-6">
-                <div className="flex items-start gap-4">
-                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
-                    <HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
-                  </div>
-                  <div className="flex-1">
-                    <h3 className="font-semibold text-lg mb-2">系统支持文件上传吗?</h3>
-                    <p className="text-muted-foreground text-sm">
-                      完全支持!访客和客服都可以上传图片和文件,支持图片预览和文件下载,让沟通更直观、更高效。
-                    </p>
-                  </div>
-                </div>
-              </CardContent>
-            </Card>
-            </FadeInItem>
-
-            <FadeInItem>
-            <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-              <CardContent className="p-6">
-                <div className="flex items-start gap-4">
-                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
-                    <HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
-                  </div>
-                  <div className="flex-1">
-                    <h3 className="font-semibold text-lg mb-2">如何集成到现有网站?</h3>
-                    <p className="text-muted-foreground text-sm">
-                      只需在网站中嵌入一段代码即可,支持自定义样式和位置,几分钟就能完成集成,无需复杂配置。
-                    </p>
-                  </div>
-                </div>
-              </CardContent>
-            </Card>
-            </FadeInItem>
-
-            <FadeInItem>
-            <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-              <CardContent className="p-6">
-                <div className="flex items-start gap-4">
-                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
-                    <HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
-                  </div>
-                  <div className="flex-1">
-                    <h3 className="font-semibold text-lg mb-2">系统支持多客服协作吗?</h3>
-                    <p className="text-muted-foreground text-sm">
-                      完全支持!多个客服可以同时在线,支持会话智能分配、一键转接和团队协作,让服务更高效。
-                    </p>
-                  </div>
-                </div>
-              </CardContent>
-            </Card>
-            </FadeInItem>
-
-            <FadeInItem>
-            <Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
-              <CardContent className="p-6">
-                <div className="flex items-start gap-4">
-                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
-                    <HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
-                  </div>
-                  <div className="flex-1">
-                    <h3 className="font-semibold text-lg mb-2">数据安全如何保障?</h3>
-                    <p className="text-muted-foreground text-sm">
-                      企业级安全保障,API 密钥加密存储,精细权限管理,所有数据都经过安全加密处理,让您放心使用。
-                    </p>
-                  </div>
-                </div>
-              </CardContent>
-            </Card>
-            </FadeInItem>
-          </FadeInStagger>
-        </div>
-      </section>
-
-      {/* 底部 CTA - Chatbase 风格 */}
-      <section className="border-t border-border/50 py-20 md:py-28">
-        <div className="container mx-auto px-4 text-center">
-          <h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">
-            让客户体验成为你的竞争力
-          </h2>
-          <p className="text-muted-foreground text-base mb-8 max-w-lg mx-auto">
-            用 AI-CS 提供更专业、更高效的客户服务,拉开与竞品的差距
-          </p>
-          <Button
-            size="lg"
-            className="rounded-xl px-8 py-6 shadow-sm hover:shadow transition-shadow"
-            onClick={handleOpenChat}
-          >
-            免费试用
-            <ArrowRight className="ml-2 w-4 h-4" />
-          </Button>
-          <p className="mt-4 text-sm text-muted-foreground">无需信用卡,立即可用</p>
-        </div>
-      </section>
-
-      {/* 页脚 */}
-      <Footer />
-
-      {/* 客服插件 */}
-      {visitorId !== null && (
-        <>
-          {/* 浮动按钮 */}
-          <FloatingButton onClick={handleToggleChat} isOpen={isChatOpen} />
-          {/* 聊天小窗 */}
-          {isChatOpen && (
-            <ChatWidget
-              visitorId={visitorId}
-              isOpen={isChatOpen}
-              onToggle={handleToggleChat}
-            />
-          )}
-        </>
-      )}
-    </div>
+    <>
+      <script
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+      />
+      <HomePageClient />
+    </>
   );
 }

+ 15 - 0
frontend/app/robots.ts

@@ -0,0 +1,15 @@
+import type { MetadataRoute } from "next";
+import { getSiteUrl } from "@/lib/site";
+
+export default function robots(): MetadataRoute.Robots {
+  const base = getSiteUrl();
+
+  return {
+    rules: {
+      userAgent: "*",
+      allow: "/",
+      disallow: ["/agent/", "/api/"],
+    },
+    sitemap: `${base}/sitemap.xml`,
+  };
+}

+ 12 - 0
frontend/app/sitemap.ts

@@ -0,0 +1,12 @@
+import type { MetadataRoute } from "next";
+import { getSiteUrl } from "@/lib/site";
+
+export default function sitemap(): MetadataRoute.Sitemap {
+  const base = getSiteUrl();
+  const now = new Date();
+
+  return [
+    { url: base, lastModified: now, changeFrequency: "weekly", priority: 1 },
+    { url: `${base}/chat`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
+  ];
+}

+ 55 - 1
frontend/components/dashboard/DashboardShell.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { usePathname, useRouter, useSearchParams } from "next/navigation";
 
 import { useAuth } from "@/features/agent/hooks/useAuth";
@@ -21,11 +21,14 @@ import { ChatHeader } from "./ChatHeader";
 import { ConversationSidebar } from "./ConversationSidebar";
 import { MessageInput } from "./MessageInput";
 import { MessageList } from "./MessageList";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
 import { NavigationSidebar, type NavigationPage } from "./NavigationSidebar";
 import { ProfileModal } from "./ProfileModal";
 import { VisitorDetailPanel } from "./VisitorDetailPanel";
 import { useSoundNotification } from "@/hooks/useSoundNotification";
 import { usePageTitle } from "@/hooks/usePageTitle";
+import { reportFrontendLog } from "@/features/agent/services/systemLogApi";
 
 export function DashboardShell() {
   const pathname = usePathname();
@@ -36,6 +39,38 @@ export function DashboardShell() {
   // 登录状态:负责从本地存储读取客服信息,并提供登出方法
   const { agent, loading: authLoading, logout } = useAuth();
 
+  // 前端全局错误上报(最小可用:window error + promise rejection)
+  useEffect(() => {
+    const onError = (ev: ErrorEvent) => {
+      void reportFrontendLog({
+        level: "error",
+        category: "frontend",
+        event: "window_error",
+        message: ev.message || "window error",
+        meta: {
+          filename: ev.filename,
+          lineno: ev.lineno,
+          colno: ev.colno,
+        },
+      });
+    };
+    const onRejection = (ev: PromiseRejectionEvent) => {
+      const reason = String(ev.reason ?? "unhandled rejection");
+      void reportFrontendLog({
+        level: "error",
+        category: "frontend",
+        event: "unhandled_rejection",
+        message: reason.slice(0, 500),
+      });
+    };
+    window.addEventListener("error", onError);
+    window.addEventListener("unhandledrejection", onRejection);
+    return () => {
+      window.removeEventListener("error", onError);
+      window.removeEventListener("unhandledrejection", onRejection);
+    };
+  }, []);
+
   // 个人资料状态
   const [profileModalOpen, setProfileModalOpen] = useState(false);
   const {
@@ -112,6 +147,8 @@ export function DashboardShell() {
     includeAIMessages,
     toggleAIMessages,
     aiThinking,
+    needWebSearch,
+    setNeedWebSearch,
   } = useMessages({
     conversationId: selectedConversationId,
     agentId: agent?.id ?? null,
@@ -227,6 +264,7 @@ export function DashboardShell() {
         onProfileClick={() => setProfileModalOpen(true)}
         onLogout={logout}
         avatarUrl={profile?.avatar_url}
+        unreadChatCount={totalUnreadCount}
       />
       <ConversationSidebar
         conversations={filteredConversations}
@@ -248,6 +286,7 @@ export function DashboardShell() {
         onProfileClick={() => setProfileModalOpen(true)}
         onLogout={logout}
         avatarUrl={profile?.avatar_url}
+        unreadChatCount={totalUnreadCount}
       />
     </div>
   );
@@ -289,6 +328,21 @@ export function DashboardShell() {
                 ) : null
               }
             />
+            {/* 知识库测试:联网选项 */}
+            {isInternalChat && (
+              <div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-2 py-2 border-t border-border/50 bg-muted/30 text-xs text-muted-foreground">
+                <div className="flex items-center gap-2">
+                  <Checkbox
+                    id="internal-need-web-search"
+                    checked={needWebSearch}
+                    onCheckedChange={(v) => setNeedWebSearch(Boolean(v))}
+                  />
+                  <Label htmlFor="internal-need-web-search" className="cursor-pointer font-normal">
+                    本回合联网搜索
+                  </Label>
+                </div>
+              </div>
+            )}
             <MessageInput
               value={messageInput}
               onChange={setMessageInput}

+ 45 - 13
frontend/components/dashboard/MessageList.tsx

@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
 import { Dialog, DialogContent } from "@/components/ui/dialog";
 import { Paperclip, Download, X } from "lucide-react";
 import { API_BASE_URL } from "@/lib/config";
+import { getAvatarUrl } from "@/utils/avatar";
 
 interface MessageListProps {
   messages: MessageItem[];
@@ -23,6 +24,8 @@ interface MessageListProps {
   bottomSlot?: React.ReactNode;
   /** 知识库测试(内部对话)模式:AI 回复(sender_id=0)显示在左侧,客服消息显示在右侧 */
   internalChatMode?: boolean;
+  /** 访客侧左侧消息头像(key 为 sender_id) */
+  leftAvatarBySenderId?: Record<number, string | null | undefined>;
 }
 
 export function MessageList({
@@ -36,6 +39,7 @@ export function MessageList({
   onMarkMessagesRead,
   bottomSlot,
   internalChatMode = false,
+  leftAvatarBySenderId,
 }: MessageListProps) {
   const containerRef = useRef<HTMLDivElement>(null);
   const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
@@ -349,8 +353,9 @@ export function MessageList({
 
   if (messages.length === 0) {
     return (
-      <div ref={containerRef} className="flex-1 min-h-0 overflow-y-auto p-4 bg-muted/30 scrollbar-auto">
+      <div ref={containerRef} className="flex-1 min-h-0 overflow-y-auto p-3 bg-muted/20 scrollbar-auto">
         <div className="text-center text-muted-foreground mt-8 text-sm">暂无消息</div>
+        {bottomSlot ? <div className="mt-4">{bottomSlot}</div> : null}
       </div>
     );
   }
@@ -382,10 +387,10 @@ export function MessageList({
 
       <div
         ref={containerRef}
-        className="h-full w-full overflow-y-auto p-4 bg-muted/30 scrollbar-auto"
+        className="h-full w-full overflow-y-auto p-3 bg-muted/20 scrollbar-auto"
         style={{ height: '100%' }}
       >
-        <div className="space-y-4">
+        <div className="space-y-3.5">
           {messages.map((message) => {
           const keyword = highlightKeyword.trim();
           const isMatching =
@@ -403,9 +408,9 @@ export function MessageList({
                 ref={(element) => {
                   messageRefs.current[message.id] = element;
                 }}
-                className={`text-center text-xs text-muted-foreground`}
+                className="text-center text-xs text-muted-foreground/90"
               >
-                <Badge variant="secondary" className="inline-block">
+                <Badge variant="secondary" className="inline-block border border-border/40 bg-background/70 text-muted-foreground">
                   {message.content}
                 </Badge>
               </div>
@@ -421,9 +426,12 @@ export function MessageList({
               : !isSenderAgent;
           const alignment = isCurrentUser ? "justify-end" : "justify-start";
           const bubbleColor = isCurrentUser
-            ? "bg-primary text-primary-foreground shadow-md"
-            : "bg-card text-card-foreground border border-border/50 shadow-sm";
-          const cornerClass = isCurrentUser ? "rounded-br-none" : "rounded-bl-none";
+            ? "bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/20"
+            : "bg-background/95 text-card-foreground border border-border/45 shadow-[0_1px_4px_rgba(15,23,42,0.06)]";
+          // 拉开双方气泡圆角差异:自己消息更利落、对方消息更柔和,便于快速分辨
+          const cornerClass = isCurrentUser
+            ? "rounded-[18px] rounded-br-md"
+            : "rounded-[18px] rounded-bl-md";
           // 计算已读回执的样式类名
           // 统一使用相同的样式:蓝色半透明(text-primary/70)
           // 因为访客端和客服端的当前用户消息都是蓝色背景(bg-primary),所以使用相同的样式
@@ -466,19 +474,31 @@ export function MessageList({
             document.body.removeChild(link);
           };
 
+          const leftAvatarUrl = !isCurrentUser ? getAvatarUrl(leftAvatarBySenderId?.[message.sender_id]) : null;
+          const showLeftAvatar = !isCurrentUser && Boolean(leftAvatarBySenderId);
+
           return (
             <div
               key={message.id}
               ref={(element) => {
                 messageRefs.current[message.id] = element;
               }}
-              className={`flex ${alignment}`}
+              className={`flex ${alignment} items-end gap-2`}
             >
-              <div className="max-w-[70%]">
+              {showLeftAvatar ? (
+                <div className="w-7 h-7 rounded-full overflow-hidden bg-slate-200 border border-slate-300 flex-shrink-0">
+                  {leftAvatarUrl ? (
+                    <img src={leftAvatarUrl} alt="客服头像" className="w-full h-full object-cover" />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center text-[10px] text-slate-600">客</div>
+                  )}
+                </div>
+              ) : null}
+              <div className="max-w-[72%]">
                 <div
-                  className={`px-4 py-2.5 rounded-2xl ${
+                  className={`px-3.5 py-2.5 rounded-2xl ${
                     cornerClass
-                  } ${bubbleColor} transition-shadow hover:shadow-md`}
+                  } ${bubbleColor} transition-shadow hover:shadow-sm`}
                 >
                   {/* 文本内容 */}
                   {message.content && (
@@ -535,7 +555,7 @@ export function MessageList({
                     </div>
                   )}
                 </div>
-                <div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground">
+                <div className="flex items-center gap-1 mt-1.5 px-0.5 text-[10px] text-muted-foreground/80">
                   {isCurrentUser && (
                     <span className={receiptClass}>
                       {message.is_read ? "✓✓" : "✓"}
@@ -543,6 +563,18 @@ export function MessageList({
                   )}
                   <span>{formatMessageTime(message.created_at)}</span>
                 </div>
+                {/* AI 回复的数据源标记(仅对方消息且存在 sources_used 时显示) */}
+                {!isCurrentUser && message.sources_used && (
+                  <div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-x-2 gap-y-0">
+                    {message.sources_used.split(",").map((s) => s.trim()).filter(Boolean).map((src) => (
+                      <span key={src}>
+                        {src === "knowledge_base" && "已使用知识库"}
+                        {src === "llm" && "已使用大模型"}
+                        {src === "web" && "已使用联网搜索"}
+                      </span>
+                    ))}
+                  </div>
+                )}
               </div>
             </div>
           );

+ 20 - 5
frontend/components/dashboard/NavigationSidebar.tsx

@@ -5,6 +5,7 @@ import { useAuth } from "@/features/agent/hooks/useAuth";
 import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
 import { Button } from "@/components/ui/button";
 import { websiteConfig } from "@/lib/website-config";
+import { Badge } from "@/components/ui/badge";
 import {
   AGENT_PAGES,
   type NavigationPage,
@@ -18,6 +19,8 @@ interface NavigationSidebarProps {
   onProfileClick?: () => void;
   onLogout?: () => void;
   avatarUrl?: string | null;
+  /** 顶部/左侧“对话”图标角标展示用:总未读消息数 */
+  unreadChatCount?: number;
 }
 
 export function NavigationSidebar({
@@ -26,6 +29,7 @@ export function NavigationSidebar({
   onProfileClick,
   onLogout,
   avatarUrl,
+  unreadChatCount = 0,
 }: NavigationSidebarProps) {
   const { agent } = useAuth();
   const [profileMenuOpen, setProfileMenuOpen] = useState(false);
@@ -66,6 +70,7 @@ export function NavigationSidebar({
       {visiblePages.map((page) => {
         const isActive = currentPage === page.id;
         const Icon = page.Icon;
+        const showUnread = page.id === "dashboard" && unreadChatCount > 0;
         return (
           <button
             key={page.id}
@@ -77,11 +82,21 @@ export function NavigationSidebar({
             title={page.title}
             onClick={() => handleNavigate(page.id as NavigationPage)}
           >
-            <Icon
-              className={`w-6 h-6 ${
-                isActive ? "text-white" : "text-gray-600"
-              }`}
-            />
+            <div className="relative w-full h-full flex items-center justify-center">
+              <Icon
+                className={`w-6 h-6 ${
+                  isActive ? "text-white" : "text-gray-600"
+                }`}
+              />
+              {showUnread && (
+                <Badge
+                  variant="destructive"
+                  className="absolute -top-1 -right-1 px-1 py-0 h-4 min-w-4 rounded-full text-[10px] leading-none flex items-center justify-center"
+                >
+                  {unreadChatCount > 99 ? "99+" : unreadChatCount}
+                </Badge>
+              )}
+            </div>
           </button>
         );
       })}

+ 3 - 3
frontend/components/layout/Footer.tsx

@@ -46,7 +46,7 @@ export function Footer() {
                   href="#features"
                   className="text-muted-foreground hover:text-foreground transition-colors"
                 >
-                  功能特性
+                  核心能力
                 </Link>
               </li>
               <li>
@@ -59,10 +59,10 @@ export function Footer() {
               </li>
               <li>
                 <Link
-                  href="#faq"
+                  href="#quick-start"
                   className="text-muted-foreground hover:text-foreground transition-colors"
                 >
-                  常见问题
+                  快速接入
                 </Link>
               </li>
               <li>

+ 11 - 11
frontend/components/layout/Header.tsx

@@ -12,38 +12,38 @@ import { websiteConfig } from "@/lib/website-config";
 export function Header() {
   return (
     <header className="sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-md">
-      <div className="container mx-auto px-4">
-        <div className="flex h-14 md:h-16 items-center justify-between">
+      <div className="container mx-auto px-6">
+        <div className="flex h-16 md:h-20 items-center justify-between">
           <Link href="/" className="flex items-center gap-2">
-            <div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
+            <div className="w-9 h-9 rounded-lg bg-primary flex items-center justify-center">
               <span className="text-primary-foreground font-semibold text-sm">AI</span>
             </div>
-            <span className="text-lg font-semibold text-foreground tracking-tight">AI-CS</span>
+            <span className="text-[19px] font-semibold text-foreground tracking-tight">AI-CS</span>
           </Link>
 
           <div className="flex items-center gap-6 md:gap-8">
             <nav className="hidden md:flex items-center gap-6">
               <Link
                 href="#features"
-                className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+                className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
               >
-                功能特性
+                核心能力
               </Link>
               <Link
                 href="#screenshots"
-                className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+                className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
               >
                 界面展示
               </Link>
               <Link
-                href="#faq"
-                className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+                href="#quick-start"
+                className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
               >
-                常见问题
+                快速接入
               </Link>
               <Link
                 href="/agent/login"
-                className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+                className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
               >
                 客服登录
               </Link>

+ 410 - 0
frontend/components/marketing/HomePageClient.tsx

@@ -0,0 +1,410 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+  Bot,
+  BookOpen,
+  Users,
+  Wand2,
+  LineChart,
+  ScrollText,
+  Globe,
+  LayoutDashboard,
+  FileText,
+  ArrowRight,
+  Github,
+  Mail,
+} from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { ScreenshotDisplay } from "@/components/ScreenshotDisplay";
+import { ChatWidget } from "@/components/visitor/ChatWidget";
+import { FloatingButton } from "@/components/visitor/FloatingButton";
+import { Header } from "@/components/layout/Header";
+import { Footer } from "@/components/layout/Footer";
+import { FadeIn, FadeInStagger, FadeInItem } from "@/components/ui/fade-in";
+import { websiteConfig } from "@/lib/website-config";
+import { stats } from "@/lib/stats-config";
+
+const capabilityCards = [
+  {
+    icon: Bot,
+    title: "多模型 AI 客服",
+    description:
+      "支持配置多家大模型与绘画等能力,访客与后台可统一管理模型与使用方式,便于替换供应商、控制成本。",
+  },
+  {
+    icon: BookOpen,
+    title: "知识库与 RAG",
+    description:
+      "文档入库、向量检索,让回答贴近你的业务资料;回复可标记是否使用知识库、模型或联网,便于核对与优化。",
+  },
+  {
+    icon: Wand2,
+    title: "提示词工程",
+    description:
+      "配置系统中使用的提示词模板,用于不同领域 RAG、联网等不同的业务场景。",
+  },
+  {
+    icon: Users,
+    title: "人工客服与实时协作",
+    description:
+      "在线状态、会话实时推送(WebSocket),支持人工接管与日常协作;访客小窗可嵌入任意站点。",
+  },
+  {
+    icon: LineChart,
+    title: "可视化报表",
+    description:
+      "按日或自定义区间查看访客小窗打开、会话与消息、AI 回复与失败率、知识库命中率等指标,快速掌握运营态势。",
+  },
+  {
+    icon: ScrollText,
+    title: "日志中心",
+    description:
+      "结构化日志按分类与事件落库,支持 trace_id 与关键字筛选,关键链路与异常可追溯,便于排障与审计。",
+  },
+];
+
+const steps = [
+  {
+    title: "克隆与配置",
+    body: "复制 .env 模板,填好数据库与管理员等必填项。",
+  },
+  {
+    title: "一键启动",
+    body: "使用 Docker Compose 拉起前后端与依赖服务(详见 README)。",
+  },
+  {
+    title: "嵌入访客端",
+    body: "在站点中挂载聊天小窗,后台完成模型与知识库配置后即可对外服务。",
+  },
+];
+
+export function HomePageClient() {
+  const [visitorId, setVisitorId] = useState<number | null>(null);
+  const [isChatOpen, setIsChatOpen] = useState(false);
+
+  useEffect(() => {
+    let stored = window.localStorage.getItem("visitor_id");
+    if (!stored) {
+      stored = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
+      window.localStorage.setItem("visitor_id", stored);
+    }
+    const parsed = Number.parseInt(stored, 10);
+    setVisitorId(Number.isNaN(parsed) ? null : parsed);
+  }, []);
+
+  const handleToggleChat = () => setIsChatOpen((prev) => !prev);
+
+  const handleOpenChat = () => {
+    if (visitorId === null) {
+      setTimeout(() => setIsChatOpen(true), 500);
+    } else {
+      setIsChatOpen(true);
+    }
+  };
+
+  return (
+    <div className="min-h-screen bg-background text-foreground">
+      <Header />
+
+      {/* Hero(回归旧版文案气质,保留新版三按钮) */}
+      <section className="relative overflow-hidden border-b border-border/40">
+        <div
+          className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_80%_at_50%_-20%,rgba(37,99,235,0.14),transparent_55%)]"
+          aria-hidden
+        />
+        <div
+          className="pointer-events-none absolute inset-0 bg-gradient-to-b from-blue-50/80 via-background to-background"
+          aria-hidden
+        />
+        <div className="container relative mx-auto px-6 pb-32 pt-20 md:pb-40 md:pt-28 lg:pt-28 xl:max-w-[1280px]">
+          <FadeIn>
+            <div className="mx-auto max-w-4xl text-center">
+              <p className="mb-4 text-sm font-medium text-muted-foreground tracking-wide uppercase">
+                AI 智能客服
+              </p>
+              <h1 className="mb-6 text-balance text-4xl font-bold tracking-tight text-foreground sm:text-5xl md:text-6xl md:leading-[1.12]">
+                让客户服务更简单、更高效
+              </h1>
+              <p className="mx-auto mb-10 max-w-3xl text-pretty text-lg sm:text-xl text-muted-foreground leading-relaxed">
+                7×24 小时智能应答,AI 与人工无缝切换,释放团队时间专注更有价值的事
+              </p>
+              <div className="flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:flex-wrap sm:items-center">
+                <Button
+                  size="lg"
+                  className="rounded-xl bg-blue-600 px-8 py-6 text-[15px] shadow-sm transition-all hover:bg-blue-500 hover:shadow-md"
+                  onClick={handleOpenChat}
+                >
+                  立即体验
+                  <ArrowRight className="ml-2 h-4 w-4" />
+                </Button>
+                <Button
+                  size="lg"
+                  variant="outline"
+                  className="rounded-xl border-border/80 px-8 py-6 text-[15px] bg-background/60 backdrop-blur-sm"
+                  asChild
+                >
+                  <Link href="/agent/login" className="inline-flex items-center justify-center gap-2">
+                    客服登录
+                  </Link>
+                </Button>
+              </div>
+              <p className="mt-4 text-sm text-muted-foreground">无需等待,可立即使用</p>
+            </div>
+          </FadeIn>
+        </div>
+      </section>
+
+      {/* 数字条(沿用旧版) */}
+      <section className="py-16 md:py-20 border-t border-border/50">
+        <FadeIn>
+          <div className="container mx-auto px-6">
+            <p className="text-xs font-medium text-muted-foreground text-center mb-8 tracking-wide">
+              深受企业信赖
+            </p>
+            <div className="grid grid-cols-2 md:grid-cols-4 gap-10 max-w-6xl mx-auto">
+              {stats.map((stat) => (
+                <div key={stat.label} className="text-center">
+                  <div className="text-3xl md:text-4xl font-semibold text-foreground">{stat.value}</div>
+                  <div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
+                </div>
+              ))}
+            </div>
+          </div>
+        </FadeIn>
+      </section>
+
+      {/* 核心能力 */}
+      <section id="features" className="relative scroll-mt-20">
+        <div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-blue-200/50 to-transparent" aria-hidden />
+        <div className="container mx-auto px-6 py-20 md:py-28">
+          <FadeIn>
+            <div className="mb-14 text-center px-4">
+              <h2 className="mb-3 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
+                核心能力
+              </h2>
+              <p className="mx-auto max-w-xl text-base text-muted-foreground">
+                从模型、知识库、提示词到人工协作、报表与日志,一套系统串起来。
+              </p>
+            </div>
+          </FadeIn>
+          <FadeInStagger className="mx-auto grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
+            {capabilityCards.map((item) => {
+              const Icon = item.icon;
+              return (
+                <FadeInItem key={item.title}>
+                  <Card className="group h-full border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm transition-all duration-300 hover:-translate-y-0.5 hover:border-blue-200/70 hover:shadow-md">
+                    <CardHeader className="pb-3">
+                      <div className="mb-4 flex h-11 w-11 items-center justify-center rounded-xl border border-blue-100/80 bg-gradient-to-br from-blue-50 to-background text-blue-700 transition-transform duration-300 group-hover:scale-[1.03]">
+                        <Icon className="h-5 w-5" />
+                      </div>
+                      <CardTitle className="text-lg font-semibold tracking-tight">
+                        {item.title}
+                      </CardTitle>
+                      <CardDescription className="text-sm leading-relaxed text-muted-foreground">
+                        {item.description}
+                      </CardDescription>
+                    </CardHeader>
+                  </Card>
+                </FadeInItem>
+              );
+            })}
+          </FadeInStagger>
+        </div>
+      </section>
+
+      {/* 界面展示 */}
+      <FadeIn>
+        <section
+          id="screenshots"
+          className="scroll-mt-20 border-t border-border/40 bg-muted/20 py-20 md:py-28"
+        >
+          <div className="container mx-auto px-6">
+            <div className="mb-14 text-center px-4">
+              <h2 className="mb-3 text-3xl font-semibold tracking-tight sm:text-4xl">
+                界面展示
+              </h2>
+              <p className="mx-auto max-w-xl text-muted-foreground">
+                精心设计的界面,让管理更轻松
+              </p>
+            </div>
+            <div className="mx-auto max-w-6xl">
+              <Tabs defaultValue="dashboard" className="w-full">
+                <TabsList className="mb-8 grid w-full grid-cols-3 md:grid-cols-5 rounded-xl bg-muted/50 p-1.5">
+                  <TabsTrigger
+                    value="dashboard"
+                    className="text-[15px] rounded-md px-3.5 py-2 transition-colors hover:bg-background/60"
+                  >
+                    工作台
+                  </TabsTrigger>
+                  <TabsTrigger
+                    value="visitor"
+                    className="text-[15px] rounded-md px-3.5 py-2 transition-colors hover:bg-background/60"
+                  >
+                    访客端
+                  </TabsTrigger>
+                  <TabsTrigger
+                    value="ai-config"
+                    className="text-[15px] rounded-md px-3.5 py-2 transition-colors hover:bg-background/60"
+                  >
+                    AI配置
+                  </TabsTrigger>
+                  <TabsTrigger
+                    value="users"
+                    className="text-[15px] rounded-md px-3.5 py-2 transition-colors hover:bg-background/60"
+                  >
+                    用户管理
+                  </TabsTrigger>
+                  <TabsTrigger
+                    value="faq"
+                    className="text-[15px] rounded-md px-3.5 py-2 transition-colors hover:bg-background/60"
+                  >
+                    FAQ管理
+                  </TabsTrigger>
+                </TabsList>
+                <TabsContent value="dashboard" className="mt-0">
+                  <div className="overflow-hidden rounded-xl border border-border/60 shadow-sm">
+                    <ScreenshotDisplay
+                      imageName="dashboard.png"
+                      placeholderIcon={LayoutDashboard}
+                      placeholderText="工作台界面"
+                      alt="AI-CS 工作台界面"
+                    />
+                  </div>
+                </TabsContent>
+                <TabsContent value="visitor" className="mt-0">
+                  <div className="overflow-hidden rounded-xl border border-border/60 shadow-sm">
+                    <ScreenshotDisplay
+                      imageName="visitor.png"
+                      placeholderIcon={Globe}
+                      placeholderText="访客端界面"
+                      alt="AI-CS 访客端界面"
+                    />
+                  </div>
+                </TabsContent>
+                <TabsContent value="ai-config" className="mt-0">
+                  <div className="overflow-hidden rounded-xl border border-border/60 shadow-sm">
+                    <ScreenshotDisplay
+                      imageName="ai-config.png"
+                      placeholderIcon={Bot}
+                      placeholderText="AI配置界面"
+                      alt="AI-CS AI配置界面"
+                    />
+                  </div>
+                </TabsContent>
+                <TabsContent value="users" className="mt-0">
+                  <div className="overflow-hidden rounded-xl border border-border/60 shadow-sm">
+                    <ScreenshotDisplay
+                      imageName="users.png"
+                      placeholderIcon={Users}
+                      placeholderText="用户管理界面"
+                      alt="AI-CS 用户管理界面"
+                    />
+                  </div>
+                </TabsContent>
+                <TabsContent value="faq" className="mt-0">
+                  <div className="overflow-hidden rounded-xl border border-border/60 shadow-sm">
+                    <ScreenshotDisplay
+                      imageName="faq.png"
+                      placeholderIcon={FileText}
+                      placeholderText="FAQ管理界面"
+                      alt="AI-CS FAQ管理界面"
+                    />
+                  </div>
+                </TabsContent>
+              </Tabs>
+            </div>
+          </div>
+        </section>
+      </FadeIn>
+
+      {/* 快速接入 */}
+      <section id="quick-start" className="relative scroll-mt-20 border-t border-border/40">
+        <div
+          className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-border to-transparent"
+          aria-hidden
+        />
+        <div className="container mx-auto px-6 py-20 md:py-28">
+          <FadeIn>
+            <div className="mb-12 text-center px-4">
+              <h2 className="mb-3 text-3xl font-semibold tracking-tight sm:text-4xl">
+                快速接入
+              </h2>
+              <p className="text-muted-foreground">三步跑通,从仓库到访客小窗。</p>
+            </div>
+          </FadeIn>
+          <FadeInStagger className="mx-auto grid max-w-4xl grid-cols-1 gap-8 md:grid-cols-3">
+            {steps.map((step, i) => (
+              <FadeInItem key={step.title}>
+                <div className="relative rounded-2xl border border-border/60 bg-card/50 p-6 text-center md:text-left transition-all duration-300 hover:border-blue-200/70 hover:shadow-md hover:-translate-y-0.5">
+                  <div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full border border-blue-200/80 bg-blue-50/80 text-sm font-semibold text-blue-800 md:mx-0">
+                    {i + 1}
+                  </div>
+                  <h3 className="mb-2 font-semibold">{step.title}</h3>
+                  <p className="text-sm leading-relaxed text-muted-foreground">{step.body}</p>
+                </div>
+              </FadeInItem>
+            ))}
+          </FadeInStagger>
+        </div>
+      </section>
+
+      {/* 收尾 CTA */}
+      <section className="relative border-t border-border/40 overflow-hidden">
+        <div
+          className="pointer-events-none absolute inset-0 bg-gradient-to-br from-blue-600/[0.07] via-transparent to-blue-400/[0.06]"
+          aria-hidden
+        />
+        <div className="container relative mx-auto px-6 py-20 text-center md:py-28">
+          <FadeIn>
+            <h2 className="mb-4 text-3xl font-semibold tracking-tight sm:text-4xl">
+              准备好把 AI-CS 接到你的产品里了吗?
+            </h2>
+            <p className="mx-auto mb-10 max-w-lg text-muted-foreground leading-relaxed">
+              从开源仓库开始,或用在线 Demo 先看交互与能力边界。
+            </p>
+            <div className="flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center">
+              <Button size="lg" className="rounded-xl bg-blue-600 px-8 shadow-sm hover:bg-blue-500" asChild>
+                <a
+                  href={websiteConfig.github.repo}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="inline-flex items-center justify-center gap-2"
+                >
+                  <Github className="h-4 w-4" />
+                  Star / Fork 仓库
+                </a>
+              </Button>
+              <Button size="lg" variant="outline" className="rounded-xl border-border/80 px-8 bg-background/80" asChild>
+                <a
+                  // 点击后直接唤起邮箱客户端(可在客户端自动带上主题/正文)
+                  href={`mailto:2930134478@qq.com?subject=${encodeURIComponent("AI-CS 建议反馈")}&body=${encodeURIComponent(
+                    "你好,我想反馈:\n\n1)问题/建议:\n2)影响范围/环境:\n3)期望结果:\n\n---\n联系方式(可选):"
+                  )}`}
+                  className="inline-flex items-center justify-center gap-2"
+                >
+                  建议反馈
+                  <Mail className="h-3.5 w-3.5" />
+                </a>
+              </Button>
+            </div>
+          </FadeIn>
+        </div>
+      </section>
+
+      <Footer />
+
+      {visitorId !== null && (
+        <>
+          <FloatingButton onClick={handleToggleChat} isOpen={isChatOpen} />
+          {isChatOpen && (
+            <ChatWidget visitorId={visitorId} isOpen={isChatOpen} onToggle={handleToggleChat} />
+          )}
+        </>
+      )}
+    </div>
+  );
+}

+ 29 - 13
frontend/components/ui/fade-in.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { motion } from "framer-motion";
+import { motion, useReducedMotion } from "framer-motion";
 import { ReactNode } from "react";
 
 interface FadeInProps {
@@ -10,14 +10,27 @@ interface FadeInProps {
 }
 
 export function FadeIn({ children, delay = 0, className = "" }: FadeInProps) {
+  const reduceMotion = useReducedMotion();
   return (
     <motion.div
-      initial={{ opacity: 0, y: 20 }}
-      whileInView={{ opacity: 1, y: 0 }}
-      viewport={{ once: true, margin: "-100px" }}
-      transition={{ duration: 0.6, delay, ease: "easeOut" }}
+      initial={
+        reduceMotion
+          ? { opacity: 1, y: 0 }
+          : { opacity: 0, y: 12, filter: "blur(2px)" }
+      }
+      whileInView={
+        reduceMotion
+          ? { opacity: 1, y: 0 }
+          : { opacity: 1, y: 0, filter: "blur(0px)" }
+      }
+      viewport={{ once: true, amount: 0.2 }}
+      transition={{
+        duration: 0.42,
+        delay,
+        ease: [0.16, 1, 0.3, 1],
+      }}
       className={className}
-      style={{ willChange: "opacity, transform" }}
+      style={{ willChange: "opacity, transform, filter" }}
     >
       {children}
     </motion.div>
@@ -30,17 +43,19 @@ interface FadeInStaggerProps {
 }
 
 export function FadeInStagger({ children, className = "" }: FadeInStaggerProps) {
+  const reduceMotion = useReducedMotion();
   return (
     <motion.div
       initial="hidden"
       whileInView="visible"
-      viewport={{ once: true, margin: "-100px" }}
+      viewport={{ once: true, amount: 0.2 }}
       variants={{
-        hidden: { opacity: 0 },
+        hidden: reduceMotion ? { opacity: 1 } : { opacity: 0 },
         visible: {
           opacity: 1,
           transition: {
-            staggerChildren: 0.1,
+            staggerChildren: reduceMotion ? 0 : 0.06,
+            delayChildren: reduceMotion ? 0 : 0.03,
           },
         },
       }}
@@ -57,15 +72,16 @@ interface FadeInItemProps {
 }
 
 export function FadeInItem({ children, className = "" }: FadeInItemProps) {
+  const reduceMotion = useReducedMotion();
   return (
     <motion.div
       variants={{
-        hidden: { opacity: 0, y: 20 },
-        visible: { opacity: 1, y: 0 },
+        hidden: reduceMotion ? { opacity: 1, y: 0 } : { opacity: 0, y: 10, filter: "blur(2px)" },
+        visible: reduceMotion ? { opacity: 1, y: 0 } : { opacity: 1, y: 0, filter: "blur(0px)" },
       }}
-      transition={{ duration: 0.5, ease: "easeOut" }}
+      transition={{ duration: 0.38, ease: [0.16, 1, 0.3, 1] }}
       className={className}
-      style={{ willChange: "opacity, transform" }}
+      style={{ willChange: "opacity, transform, filter" }}
     >
       {children}
     </motion.div>

+ 73 - 77
frontend/components/visitor/ChatModeSelector.tsx

@@ -5,26 +5,32 @@ import { Button } from "@/components/ui/button";
 import { Card } from "@/components/ui/card";
 import { fetchPublicAIModels, type AIConfig } from "@/features/agent/services/aiConfigApi";
 
+export type ChatModeType = "human" | "ai" | "image";
+
 interface ChatModeSelectorProps {
-  onSelect: (mode: "human" | "ai", aiConfigId?: number) => void;
+  onSelect: (mode: ChatModeType, aiConfigId?: number) => void;
   loading?: boolean;
 }
 
 export function ChatModeSelector({ onSelect, loading }: ChatModeSelectorProps) {
   const [aiModels, setAiModels] = useState<AIConfig[]>([]);
+  const [imageModels, setImageModels] = useState<AIConfig[]>([]);
   const [loadingModels, setLoadingModels] = useState(true);
-  const [selectedModel, setSelectedModel] = useState<number | null>(null);
+  const [selectedAIModel, setSelectedAIModel] = useState<number | null>(null);
+  const [selectedImageModel, setSelectedImageModel] = useState<number | null>(null);
 
-  // 加载开放的 AI 模型列表
+  // 加载开放的 AI(文本)与生图模型列表
   useEffect(() => {
     async function loadModels() {
       try {
-        const models = await fetchPublicAIModels("text");
-        setAiModels(models);
-        // 如果有模型,默认选择第一个
-        if (models.length > 0) {
-          setSelectedModel(models[0].id);
-        }
+        const [textModels, imgModels] = await Promise.all([
+          fetchPublicAIModels("text"),
+          fetchPublicAIModels("image"),
+        ]);
+        setAiModels(textModels);
+        setImageModels(imgModels);
+        if (textModels.length > 0) setSelectedAIModel(textModels[0].id);
+        if (imgModels.length > 0) setSelectedImageModel(imgModels[0].id);
       } catch (error) {
         console.error("加载模型列表失败:", error);
       } finally {
@@ -34,17 +40,14 @@ export function ChatModeSelector({ onSelect, loading }: ChatModeSelectorProps) {
     loadModels();
   }, []);
 
-  const handleSelectHuman = () => {
-    onSelect("human");
-  };
-
+  const handleSelectHuman = () => onSelect("human");
   const handleSelectAI = () => {
-    if (selectedModel) {
-      onSelect("ai", selectedModel);
-    } else if (aiModels.length > 0) {
-      // 如果没有选择,使用第一个模型
-      onSelect("ai", aiModels[0].id);
-    }
+    if (selectedAIModel) onSelect("ai", selectedAIModel);
+    else if (aiModels.length > 0) onSelect("ai", aiModels[0].id);
+  };
+  const handleSelectImage = () => {
+    if (selectedImageModel) onSelect("image", selectedImageModel);
+    else if (imageModels.length > 0) onSelect("image", imageModels[0].id);
   };
 
   return (
@@ -57,97 +60,90 @@ export function ChatModeSelector({ onSelect, loading }: ChatModeSelectorProps) {
           请选择您需要的服务方式
         </p>
 
-        <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
-          {/* 人工客服选项 */}
+        <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
+          {/* 人工客服 */}
           <Card className="p-6 cursor-pointer hover:shadow-lg transition-shadow border-2 hover:border-primary">
             <div className="flex flex-col items-center text-center">
               <div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mb-4">
-                <svg
-                  className="w-8 h-8 text-blue-600"
-                  fill="none"
-                  stroke="currentColor"
-                  viewBox="0 0 24 24"
-                >
-                  <path
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                    strokeWidth={2}
-                    d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
-                  />
+                <svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
                 </svg>
               </div>
               <h3 className="text-xl font-semibold mb-2">人工客服</h3>
-              <p className="text-sm text-gray-600 mb-4">
-                由专业客服人员为您提供一对一服务
-              </p>
-              <Button
-                onClick={handleSelectHuman}
-                disabled={loading}
-                className="w-full"
-              >
+              <p className="text-sm text-gray-600 mb-4">由专业客服人员为您提供一对一服务</p>
+              <Button onClick={handleSelectHuman} disabled={loading} className="w-full">
                 {loading ? "连接中..." : "选择人工客服"}
               </Button>
             </div>
           </Card>
 
-          {/* AI 客服选项 */}
+          {/* AI 客服 */}
           <Card className="p-6 cursor-pointer hover:shadow-lg transition-shadow border-2 hover:border-primary">
             <div className="flex flex-col items-center text-center">
               <div className="w-16 h-16 rounded-full bg-purple-100 flex items-center justify-center mb-4">
-                <svg
-                  className="w-8 h-8 text-purple-600"
-                  fill="none"
-                  stroke="currentColor"
-                  viewBox="0 0 24 24"
-                >
-                  <path
-                    strokeLinecap="round"
-                    strokeLinejoin="round"
-                    strokeWidth={2}
-                    d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
-                  />
+                <svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
                 </svg>
               </div>
               <h3 className="text-xl font-semibold mb-2">AI 客服</h3>
-              <p className="text-sm text-gray-600 mb-4">
-                智能 AI 助手,24 小时在线为您服务
-              </p>
+              <p className="text-sm text-gray-600 mb-4">智能 AI 助手,24 小时在线为您服务</p>
               {loadingModels ? (
-                <div className="w-full py-2 text-sm text-gray-500">
-                  加载模型中...
-                </div>
+                <div className="w-full py-2 text-sm text-gray-500">加载模型中...</div>
               ) : aiModels.length === 0 ? (
-                <div className="w-full py-2 text-sm text-red-500">
-                  暂无可用的 AI 模型
-                </div>
+                <div className="w-full py-2 text-sm text-red-500">暂无可用的 AI 模型</div>
               ) : (
                 <>
-                  {/* 模型选择下拉框 */}
                   <select
-                    value={selectedModel || ""}
-                    onChange={(e) => setSelectedModel(Number(e.target.value))}
+                    value={selectedAIModel || ""}
+                    onChange={(e) => setSelectedAIModel(Number(e.target.value))}
                     className="w-full mb-4 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
                     disabled={loading}
                   >
-                    {aiModels.map((model) => (
-                      <option key={model.id} value={model.id}>
-                        {model.provider} - {model.model}
-                        {model.description ? ` (${model.description})` : ""}
-                      </option>
+                    {aiModels.map((m) => (
+                      <option key={m.id} value={m.id}>{m.provider} - {m.model}{m.description ? ` (${m.description})` : ""}</option>
                     ))}
                   </select>
-                  <Button
-                    onClick={handleSelectAI}
-                    disabled={loading || !selectedModel}
-                    variant="default"
-                    className="w-full"
-                  >
+                  <Button onClick={handleSelectAI} disabled={loading || !selectedAIModel} variant="default" className="w-full">
                     {loading ? "连接中..." : "选择 AI 客服"}
                   </Button>
                 </>
               )}
             </div>
           </Card>
+
+          {/* 生图绘画 */}
+          <Card className="p-6 cursor-pointer hover:shadow-lg transition-shadow border-2 hover:border-primary">
+            <div className="flex flex-col items-center text-center">
+              <div className="w-16 h-16 rounded-full bg-amber-100 flex items-center justify-center mb-4">
+                <svg className="w-8 h-8 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
+                </svg>
+              </div>
+              <h3 className="text-xl font-semibold mb-2">生图绘画</h3>
+              <p className="text-sm text-gray-600 mb-4">通过对话描述,AI 生成图片</p>
+              {loadingModels ? (
+                <div className="w-full py-2 text-sm text-gray-500">加载模型中...</div>
+              ) : imageModels.length === 0 ? (
+                <div className="w-full py-2 text-sm text-red-500">暂无可用的生图模型</div>
+              ) : (
+                <>
+                  <select
+                    value={selectedImageModel || ""}
+                    onChange={(e) => setSelectedImageModel(Number(e.target.value))}
+                    className="w-full mb-4 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
+                    disabled={loading}
+                  >
+                    {imageModels.map((m) => (
+                      <option key={m.id} value={m.id}>{m.provider} - {m.model}{m.description ? ` (${m.description})` : ""}</option>
+                    ))}
+                  </select>
+                  <Button onClick={handleSelectImage} disabled={loading || !selectedImageModel} variant="default" className="w-full">
+                    {loading ? "连接中..." : "选择生图绘画"}
+                  </Button>
+                </>
+              )}
+            </div>
+          </Card>
         </div>
       </div>
     </div>

+ 216 - 70
frontend/components/visitor/ChatWidget.tsx

@@ -1,9 +1,9 @@
 "use client";
 
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { MessageList } from "@/components/dashboard/MessageList";
-import { MessageInput } from "@/components/dashboard/MessageInput";
 import { OnlineAgentsList, type OnlineAgent } from "./OnlineAgentsList";
+import { VisitorMessageInput } from "./VisitorMessageInput";
 import { Button } from "@/components/ui/button";
 import { Card } from "@/components/ui/card";
 import { websiteConfig } from "@/lib/website-config";
@@ -19,13 +19,21 @@ import {
   UploadFileResult,
 } from "@/features/agent/services/messageApi";
 import { initVisitorConversation } from "@/features/visitor/services/conversationApi";
+import { postWidgetOpen } from "@/features/visitor/services/analyticsApi";
 import { fetchOnlineAgents } from "@/features/visitor/services/visitorApi";
-import { fetchPublicAIModels } from "@/features/agent/services/aiConfigApi";
+import {
+  fetchPublicAIModels,
+  type AIConfig,
+} from "@/features/agent/services/aiConfigApi";
+import {
+  fetchVisitorWidgetConfig,
+  type VisitorWidgetConfig,
+} from "@/features/agent/services/embeddingConfigApi";
 import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
 import type { WSMessage } from "@/lib/websocket";
 import { useSoundNotification } from "@/hooks/useSoundNotification";
 import { playNotificationSound } from "@/utils/sound";
-import { Loader2 } from "lucide-react";
+import { Check, ChevronDown, Loader2 } from "lucide-react";
 
 interface ChatWidgetProps {
   visitorId: number;
@@ -68,6 +76,20 @@ function parseUserAgent(userAgent: string) {
  * 提供小窗形式的聊天界面,支持展开/收起
  */
 export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
+  const WEB_SEARCH_PREF_KEY = "visitor_widget_need_web_search";
+  // 数据分析:每次由关→开上报一次小窗打开(供后台「小窗打开次数」统计)
+  const prevIsOpenRef = useRef(false);
+  useEffect(() => {
+    if (!isOpen) {
+      prevIsOpenRef.current = false;
+      return;
+    }
+    if (!prevIsOpenRef.current && visitorId != null && visitorId > 0) {
+      void postWidgetOpen(visitorId);
+    }
+    prevIsOpenRef.current = true;
+  }, [isOpen, visitorId]);
+
   // ===== 状态管理 =====
   const [conversationId, setConversationId] = useState<number | null>(null);
   const [conversationStatus, setConversationStatus] = useState<string>("open");
@@ -80,18 +102,62 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
   const [selectedAIConfigId, setSelectedAIConfigId] = useState<
     number | undefined
   >(undefined);
-  const [aiModels, setAiModels] = useState<
-    Array<{ id: number; provider: string; model: string }>
-  >([]);
+  const [modelMenuOpen, setModelMenuOpen] = useState(false);
+  const modelMenuRef = useRef<HTMLDivElement | null>(null);
+  const [aiModels, setAiModels] = useState<AIConfig[]>([]);
   const [onlineAgents, setOnlineAgents] = useState<OnlineAgent[]>([]);
   const [loadingAgents, setLoadingAgents] = useState(false);
   /** AI 模式下发消息后等待回复时显示「正在输入」提示 */
   const [aiTyping, setAiTyping] = useState(false);
+  /** 联网搜索:本回合是否使用联网(访客可勾选) */
+  const [needWebSearch, setNeedWebSearch] = useState(false);
+  /** 访客小窗配置(由配置页控制是否显示联网设置) */
+  const [widgetConfig, setWidgetConfig] = useState<VisitorWidgetConfig | null>(null);
 
   // 声音通知开关(访客端)
   const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(true);
 
   const noopHighlight = useCallback(() => {}, []);
+  const shouldHideForVisitor = useCallback((msg: MessageItem) => {
+    if ((msg.message_type ?? "") !== "system_message") return false;
+    const content = (msg.content || "").trim().toLowerCase();
+    // 访客侧隐藏来源/落地页埋点系统消息,仅客服端查看即可
+    return (
+      content.startsWith("visitor opened the page") ||
+      content.startsWith("visitor came from")
+    );
+  }, []);
+  const isMessageInCurrentMode = useCallback(
+    (msg: MessageItem) => {
+      const mode = (msg.chat_mode || "human").toLowerCase();
+      return mode === chatMode;
+    },
+    [chatMode]
+  );
+  const selectedAIModel = useMemo(
+    () => aiModels.find((m) => m.id === selectedAIConfigId) ?? null,
+    [aiModels, selectedAIConfigId]
+  );
+  const agentAvatarMap = useMemo<Record<number, string>>(
+    () =>
+      Object.fromEntries(
+        onlineAgents
+          .filter((a) => a.id > 0 && Boolean(a.avatar_url))
+          .map((a) => [a.id, a.avatar_url])
+      ),
+    [onlineAgents]
+  );
+
+  useEffect(() => {
+    const onDocClick = (event: MouseEvent) => {
+      if (!modelMenuRef.current) return;
+      if (!modelMenuRef.current.contains(event.target as Node)) {
+        setModelMenuOpen(false);
+      }
+    };
+    document.addEventListener("mousedown", onDocClick);
+    return () => document.removeEventListener("mousedown", onDocClick);
+  }, []);
 
   // 加载在线客服列表
   const loadOnlineAgents = useCallback(async () => {
@@ -116,17 +182,43 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
     }
   }, [isOpen, loadOnlineAgents]);
 
-  // 加载开放的 AI 模型列表
+  // 当小窗打开时,拉取访客小窗配置(联网设置是否显示及来源)
+  useEffect(() => {
+    if (isOpen) {
+      fetchVisitorWidgetConfig()
+        .then(setWidgetConfig)
+        .catch(() => setWidgetConfig(null));
+    }
+  }, [isOpen]);
+
+  // 记住「联网搜索」开关状态(仅浏览器端)
+  useEffect(() => {
+    if (typeof window === "undefined") return;
+    const saved = window.localStorage.getItem(WEB_SEARCH_PREF_KEY);
+    if (saved === "true") setNeedWebSearch(true);
+    if (saved === "false") setNeedWebSearch(false);
+  }, []);
+
+  useEffect(() => {
+    if (typeof window === "undefined") return;
+    window.localStorage.setItem(WEB_SEARCH_PREF_KEY, String(needWebSearch));
+  }, [needWebSearch]);
+
+  // 加载开放的 AI 模型列表(文本 + 生图),统一作为 AI 客服下的渠道
   useEffect(() => {
     async function loadModels() {
       try {
-        const models = await fetchPublicAIModels("text");
-        setAiModels(models);
-        if (models.length > 0) {
-          setSelectedAIConfigId(models[0].id);
+        const [textModels, imgModels] = await Promise.all([
+          fetchPublicAIModels("text"),
+          fetchPublicAIModels("image"),
+        ]);
+        const all = [...textModels, ...imgModels];
+        setAiModels(all);
+        if (all.length > 0) {
+          setSelectedAIConfigId(all[0].id);
         }
       } catch (error) {
-        console.error("加载 AI 模型列表失败:", error);
+        console.error("加载 AI/生图模型列表失败:", error);
       }
     }
     loadModels();
@@ -180,17 +272,20 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
       if (visitorId === null || initializing) {
         return;
       }
-      if (mode === "ai" && !selectedAIConfigId) {
-        alert("请先选择一个 AI 模型");
-        return;
+      if (mode === "ai") {
+        if (aiModels.length === 0) {
+          alert("暂无可用的 AI 模型,请在后台「设置」-「AI 配置」中至少将一个模型设为「开放给访客」后再试。");
+          return;
+        }
+        if (!selectedAIConfigId) {
+          alert("请先选择一个 AI 模型");
+          return;
+        }
       }
-      initializeConversation(
-        visitorId,
-        mode,
-        mode === "ai" ? selectedAIConfigId : undefined
-      );
+      const configId = mode === "ai" ? selectedAIConfigId : undefined;
+      initializeConversation(visitorId, mode, configId);
     },
-    [visitorId, initializing, selectedAIConfigId, initializeConversation]
+    [visitorId, initializing, selectedAIConfigId, aiModels.length, initializeConversation]
   );
 
   // 标记客服消息已读
@@ -237,14 +332,14 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
         ...msg,
         is_read: msg.is_read ?? false,
         read_at: msg.read_at ?? null,
-      }));
+      })).filter((msg) => !shouldHideForVisitor(msg) && isMessageInCurrentMode(msg));
       setMessages(normalizedMessages);
     } catch (error) {
       console.error("拉取消息失败:", error);
     } finally {
       setLoadingMessages(false);
     }
-  }, [conversationId, chatMode]);
+  }, [conversationId, chatMode, shouldHideForVisitor, isMessageInCurrentMode]);
 
   useEffect(() => {
     if (isOpen && conversationId) {
@@ -259,6 +354,12 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
       if (!conversationId || message.conversation_id !== conversationId) {
         return;
       }
+      if (shouldHideForVisitor(message)) {
+        return;
+      }
+      if (!isMessageInCurrentMode(message)) {
+        return;
+      }
       
       // 如果是客服发送的消息(不是访客自己发送的)且开启声音,播放提示音
       if (message.sender_is_agent && soundEnabled) {
@@ -350,7 +451,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
         return newMessages;
       });
     },
-    [conversationId]
+    [conversationId, shouldHideForVisitor, isMessageInCurrentMode]
   );
 
   // 处理 WebSocket 的已读事件
@@ -468,6 +569,8 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
           fileName: fileInfo?.file_name,
           fileSize: fileInfo?.file_size,
           mimeType: fileInfo?.mime_type,
+          needWebSearch: chatMode === "ai" ? needWebSearch : undefined,
+          useWebSearch: chatMode === "ai" && needWebSearch ? true : undefined,
         });
         
         // 不在这里调用 loadMessages,完全依赖 WebSocket 来接收新消息
@@ -485,7 +588,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
         setSending(false);
       }
     },
-    [conversationId, input, sending, visitorId, chatMode]
+    [conversationId, input, sending, visitorId, chatMode, needWebSearch, widgetConfig]
   );
 
   // 如果不打开,不渲染内容
@@ -494,13 +597,13 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
   }
 
   return (
-    <Card className="fixed bottom-20 right-4 sm:bottom-24 sm:right-6 w-[calc(100vw-2rem)] max-w-[400px] h-[500px] sm:w-[400px] sm:max-w-none sm:h-[600px] md:w-[480px] md:h-[700px] flex flex-col shadow-2xl z-40 border border-border/50 overflow-hidden rounded-2xl bg-background backdrop-blur-sm ring-1 ring-black/5">
-      {/* 头部:标题和操作按钮 - 使用渐变背景 */}
-      <div className="bg-gradient-to-r from-primary to-primary/80 border-b border-primary/20 p-4 flex items-center justify-between rounded-t-2xl">
-        <div className="flex items-center gap-2">
-          <div className="w-8 h-8 rounded-lg bg-white/20 backdrop-blur-sm flex items-center justify-center">
+    <Card className="fixed bottom-20 right-4 sm:bottom-24 sm:right-6 w-[calc(100vw-1.5rem)] max-w-[420px] h-[540px] sm:w-[420px] sm:h-[620px] md:h-[680px] flex flex-col shadow-[0_24px_60px_-24px_rgba(2,6,23,0.35)] z-40 border border-slate-200 overflow-hidden rounded-2xl bg-white text-slate-900 ring-1 ring-slate-200/80">
+      {/* 头部:回归品牌蓝色系,保持轻量与一致 */}
+      <div className="bg-gradient-to-r from-[#2563eb] to-[#3b82f6] border-b border-blue-300/40 px-4 py-3.5 flex items-center justify-between rounded-t-2xl">
+        <div className="flex items-center gap-2.5 min-w-0">
+          <div className="w-8 h-8 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center ring-1 ring-white/30">
             <svg
-              className="w-5 h-5 text-white"
+              className="w-5 h-5 text-white/90"
               fill="none"
               stroke="currentColor"
               viewBox="0 0 24 24"
@@ -513,7 +616,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
               />
             </svg>
           </div>
-          <h2 className="text-lg font-bold text-white">客服聊天</h2>
+          <h2 className="text-base font-bold text-white truncate">客服聊天</h2>
         </div>
         <div className="flex items-center gap-2">
           {/* 声音开关按钮 */}
@@ -521,7 +624,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
             variant="ghost"
             size="sm"
             onClick={toggleSound}
-            className="text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
+            className="text-white/90 hover:text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
             aria-label={soundEnabled ? "关闭声音" : "开启声音"}
             title={soundEnabled ? "关闭声音提示" : "开启声音提示"}
           >
@@ -566,7 +669,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
             variant="ghost"
             size="sm"
             asChild
-            className="text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
+            className="text-white/90 hover:text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
             aria-label="GitHub"
             title="查看 GitHub 仓库"
           >
@@ -588,9 +691,9 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
       </div>
 
       {/* 模式切换和在线客服列表 */}
-      <div className="p-4 border-b bg-gradient-to-b from-muted/50 to-background">
+      <div className="px-4 py-3 border-b border-slate-200 bg-slate-50">
         {/* 模式切换按钮 */}
-        <div className="flex items-center gap-2 mb-3 justify-center">
+        <div className="flex items-center gap-2 mb-3 justify-center flex-wrap">
           <Button
             variant={chatMode === "human" ? "default" : "outline"}
             size="sm"
@@ -598,8 +701,8 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
             disabled={initializing}
             className={
               chatMode === "human"
-                ? "bg-primary text-primary-foreground shadow-md hover:shadow-lg transition-shadow"
-                : "hover:bg-muted border-border"
+                ? "bg-blue-600 text-white shadow-sm hover:bg-blue-500 transition-colors border border-blue-600"
+                : "bg-white text-slate-700 hover:text-slate-900 hover:bg-slate-100 border border-slate-300"
             }
           >
             人工客服
@@ -608,39 +711,18 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
             variant={chatMode === "ai" ? "default" : "outline"}
             size="sm"
             onClick={() => handleModeSwitch("ai")}
-            disabled={initializing || aiModels.length === 0 || !selectedAIConfigId}
+            disabled={initializing}
+            title={aiModels.length === 0 ? "暂无可用的 AI/绘画模型,请在后台设置中开放" : undefined}
             className={
               chatMode === "ai"
-                ? "bg-primary text-primary-foreground shadow-md hover:shadow-lg transition-shadow"
-                : "hover:bg-muted border-border"
+                ? "bg-blue-600 text-white shadow-sm hover:bg-blue-500 transition-colors border border-blue-600"
+                : "bg-white text-slate-700 hover:text-slate-900 hover:bg-slate-100 border border-slate-300"
             }
           >
             AI 客服
           </Button>
         </div>
-        {/* AI 模型选择下拉框(仅 AI 模式显示) */}
-        {aiModels.length > 0 && chatMode === "ai" && (
-          <div className="flex justify-center mb-3">
-            <select
-              value={selectedAIConfigId || ""}
-              onChange={(e) => {
-                const configId = Number(e.target.value);
-                setSelectedAIConfigId(configId);
-                if (visitorId) {
-                  initializeConversation(visitorId, "ai", configId);
-                }
-              }}
-              disabled={initializing}
-              className="px-3 py-1.5 text-xs rounded-md border border-border bg-background hover:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-colors"
-            >
-              {aiModels.map((model) => (
-                <option key={model.id} value={model.id}>
-                  {model.provider} - {model.model}
-                </option>
-              ))}
-            </select>
-          </div>
-        )}
+        {/* 模型选择已下沉到输入区发送按钮左侧(仅 AI 模式显示) */}
         {/* 在线客服列表(仅人工模式显示) */}
         {chatMode === "human" && (
           <OnlineAgentsList
@@ -653,9 +735,9 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
       </div>
 
       {/* 消息列表 */}
-      <div className="flex-1 overflow-hidden min-h-0 bg-gradient-to-b from-background to-muted/20">
+      <div className="flex-1 overflow-hidden min-h-0 bg-slate-50">
         <MessageList
-          key={`messages-${conversationId}`}
+          key={`messages-${conversationId}-${chatMode}`}
           messages={messages}
           loading={loadingMessages}
           highlightKeyword=""
@@ -664,10 +746,11 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
           disableAutoScroll={false}
           conversationId={conversationId}
           onMarkMessagesRead={handleMarkAgentMessagesRead}
+          leftAvatarBySenderId={chatMode === "human" ? agentAvatarMap : undefined}
           bottomSlot={
             chatMode === "ai" && aiTyping ? (
               <div className="flex justify-start mt-2">
-                <div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-card border border-border/50 shadow-sm text-sm text-muted-foreground">
+                <div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-white border border-slate-200 shadow-sm text-sm text-slate-500">
                   <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
                   <span>AI 正在思考...</span>
                 </div>
@@ -678,13 +761,76 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
       </div>
 
       {/* 消息输入框 */}
-      <div className="border-t border-border/50 bg-background rounded-b-2xl">
-        <MessageInput
+      <div className="border-t border-slate-200 bg-slate-50 rounded-b-2xl px-3 pt-2 pb-2.5">
+        <VisitorMessageInput
           value={input}
           onChange={setInput}
           onSubmit={handleSendMessage}
           sending={sending}
           conversationId={conversationId ?? undefined}
+          toolsSlot={
+            chatMode === "ai" && (widgetConfig?.web_search_enabled ?? false) ? (
+              <button
+                type="button"
+                onClick={() => setNeedWebSearch((v) => !v)}
+                className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs transition-colors ${
+                  needWebSearch
+                    ? "border-blue-300 bg-blue-50 text-blue-700"
+                    : "border-slate-300 bg-white text-slate-600 hover:bg-slate-50"
+                }`}
+                aria-pressed={needWebSearch}
+              >
+                联网搜索
+              </button>
+            ) : null
+          }
+          submitLeftSlot={
+            chatMode === "ai" && aiModels.length > 0 ? (
+              <div className="relative" ref={modelMenuRef}>
+                <button
+                  type="button"
+                  onClick={() => setModelMenuOpen((v) => !v)}
+                  disabled={initializing || sending}
+                  className="h-8 inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white px-2.5 text-xs text-slate-700 hover:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 transition-colors disabled:opacity-50"
+                  title="选择模型"
+                >
+                  {selectedAIModel?.model_type === "image" ? "绘画" : "文本"}
+                  <ChevronDown className="w-3.5 h-3.5" />
+                </button>
+                {modelMenuOpen && (
+                  <div className="absolute bottom-10 -right-10 z-20 w-[280px] rounded-lg border border-slate-200 bg-white shadow-lg p-1">
+                    {aiModels.map((model) => {
+                      const active = model.id === selectedAIConfigId;
+                      return (
+                        <button
+                          key={model.id}
+                          type="button"
+                          className={`w-full px-2.5 py-2 text-left rounded-md flex items-start justify-between gap-2 ${
+                            active ? "bg-blue-50 text-blue-700" : "text-slate-700 hover:bg-slate-50"
+                          }`}
+                          onClick={() => {
+                            setSelectedAIConfigId(model.id);
+                            setModelMenuOpen(false);
+                            if (visitorId) initializeConversation(visitorId, "ai", model.id);
+                          }}
+                        >
+                          <span className="min-w-0">
+                            <div className="text-xs font-medium leading-4">
+                              {model.model_type === "image" ? "绘画" : "文本"}
+                            </div>
+                            <div className="text-[11px] leading-4 text-slate-500 break-all">
+                              {model.provider} - {model.model}
+                            </div>
+                          </span>
+                          {active ? <Check className="w-3.5 h-3.5 ml-2 flex-shrink-0" /> : null}
+                        </button>
+                      );
+                    })}
+                  </div>
+                )}
+              </div>
+            ) : null
+          }
         />
       </div>
     </Card>

+ 7 - 6
frontend/components/visitor/OnlineAgentsList.tsx

@@ -34,17 +34,17 @@ export function OnlineAgentsList({
     <div className="space-y-3">
       <div className="text-sm font-semibold text-foreground mb-3 text-center flex items-center justify-center gap-2">
         <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
-        <span>在线客服 ({agents.length})</span>
+        <span>在线</span>
       </div>
       <div className="flex flex-wrap gap-3 justify-center">
         {agents.map((agent) => (
           <button
             key={agent.id}
             onClick={() => onAgentClick?.(agent)}
-            className="flex flex-col items-center gap-2 p-3 rounded-xl hover:bg-primary/5 hover:shadow-md transition-all cursor-pointer group border border-transparent hover:border-primary/20"
+            className="flex flex-col items-center gap-1.5 px-2 py-1 rounded-lg hover:bg-primary/5 transition-all cursor-pointer group"
             title={agent.nickname}
           >
-            <div className="relative w-12 h-12 rounded-full overflow-hidden bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/30 group-hover:border-primary/60 transition-all shadow-sm group-hover:shadow-md">
+            <div className="relative w-12 h-12 rounded-full overflow-hidden bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/30 group-hover:border-primary/60 transition-all">
               {getAvatarUrl(agent.avatar_url) ? (
                 <Image
                   src={getAvatarUrl(agent.avatar_url)!}
@@ -57,15 +57,16 @@ export function OnlineAgentsList({
                   {agent.nickname.charAt(0).toUpperCase()}
                 </div>
               )}
-              {/* 在线状态指示器 */}
-              <div className="absolute bottom-0 right-0 w-3.5 h-3.5 bg-green-500 rounded-full border-2 border-background shadow-sm" />
             </div>
-            <span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors truncate max-w-[70px]">
+            <span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors truncate max-w-[72px]">
               {agent.nickname}
             </span>
           </button>
         ))}
       </div>
+      <p className="text-sm font-medium text-muted-foreground text-center pt-1">
+        有疑问吗?联系我们!
+      </p>
     </div>
   );
 }

+ 215 - 0
frontend/components/visitor/VisitorMessageInput.tsx

@@ -0,0 +1,215 @@
+"use client";
+
+import { FormEvent, ReactNode, useCallback, useEffect, useRef, useState } from "react";
+import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
+import { Paperclip, ArrowUp, X } from "lucide-react";
+import { toast } from "@/hooks/useToast";
+
+interface VisitorMessageInputProps {
+  value: string;
+  onChange: (value: string) => void;
+  onSubmit: (fileInfo?: UploadFileResult) => Promise<void> | void;
+  sending: boolean;
+  conversationId?: number;
+  toolsSlot?: ReactNode;
+  submitLeftSlot?: ReactNode;
+}
+
+interface FilePreview {
+  file: File;
+  preview?: string;
+}
+
+export function VisitorMessageInput({
+  value,
+  onChange,
+  onSubmit,
+  sending,
+  conversationId,
+  toolsSlot,
+  submitLeftSlot,
+}: VisitorMessageInputProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const prevSendingRef = useRef<boolean>(false);
+  const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
+  const [uploading, setUploading] = useState(false);
+
+  useEffect(() => {
+    if (prevSendingRef.current && !sending && inputRef.current) {
+      setTimeout(() => inputRef.current?.focus(), 0);
+    }
+    prevSendingRef.current = sending;
+  }, [sending]);
+
+  const handleFileSelect = useCallback(async (file: File) => {
+    const MAX_FILE_SIZE = 10 * 1024 * 1024;
+    if (file.size > MAX_FILE_SIZE) {
+      toast.error("文件大小超过限制(最大10MB)");
+      return;
+    }
+
+    const ext = file.name.toLowerCase().split(".").pop();
+    const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
+    if (!ext || !allowedExts.includes(ext)) {
+      toast.error("不支持的文件类型");
+      return;
+    }
+
+    let preview: string | undefined;
+    if (file.type.startsWith("image/")) {
+      preview = URL.createObjectURL(file);
+    }
+    setFilePreview({ file, preview });
+  }, []);
+
+  const handleFileInputChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      const file = event.target.files?.[0];
+      if (file) handleFileSelect(file);
+      if (fileInputRef.current) fileInputRef.current.value = "";
+    },
+    [handleFileSelect]
+  );
+
+  const handleRemoveFile = useCallback(() => {
+    if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview);
+    setFilePreview(null);
+  }, [filePreview]);
+
+  const formatFileSize = (bytes: number): string => {
+    if (bytes < 1024) return bytes + " B";
+    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+    return (bytes / (1024 * 1024)).toFixed(1) + " MB";
+  };
+
+  const handleSubmit = async (event: FormEvent) => {
+    event.preventDefault();
+    if (sending || uploading) return;
+    if (!value.trim() && !filePreview) return;
+
+    try {
+      let fileInfo: UploadFileResult | undefined;
+      if (filePreview) {
+        setUploading(true);
+        try {
+          fileInfo = await uploadFile(filePreview.file, conversationId);
+        } catch (error) {
+          toast.error((error as Error).message || "文件上传失败");
+          setUploading(false);
+          return;
+        }
+        setUploading(false);
+      }
+
+      await onSubmit(fileInfo);
+      onChange("");
+      handleRemoveFile();
+    } catch (error) {
+      // 发送异常由上层统一处理
+    }
+  };
+
+  useEffect(() => {
+    const handlePaste = (event: ClipboardEvent) => {
+      const items = event.clipboardData?.items;
+      if (!items) return;
+      for (let i = 0; i < items.length; i++) {
+        const item = items[i];
+        if (item.type.startsWith("image/")) {
+          const file = item.getAsFile();
+          if (file) {
+            event.preventDefault();
+            void handleFileSelect(file);
+            break;
+          }
+        }
+      }
+    };
+    const input = inputRef.current;
+    if (!input) return;
+    input.addEventListener("paste", handlePaste);
+    return () => input.removeEventListener("paste", handlePaste);
+  }, [handleFileSelect]);
+
+  useEffect(() => {
+    return () => {
+      if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview);
+    };
+  }, [filePreview]);
+
+  return (
+    <form onSubmit={handleSubmit} className="rounded-2xl border border-slate-200/90 bg-white shadow-[0_8px_24px_-20px_rgba(15,23,42,0.35)] px-3 py-2">
+      {filePreview && (
+        <div className="mb-2 rounded-xl border border-slate-200 bg-slate-50 p-2 flex items-start gap-2">
+          <div className="flex-1 min-w-0">
+            {filePreview.preview ? (
+              <div className="inline-block">
+                <img src={filePreview.preview} alt="预览" className="max-w-[180px] max-h-[140px] rounded-lg object-cover border border-slate-200" />
+                <div className="mt-1 text-xs text-slate-500">
+                  {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
+                </div>
+              </div>
+            ) : (
+              <div className="text-xs text-slate-600">
+                {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
+              </div>
+            )}
+          </div>
+          <button
+            type="button"
+            onClick={handleRemoveFile}
+            className="rounded-md p-1 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
+            disabled={sending || uploading}
+          >
+            <X className="w-4 h-4" />
+          </button>
+        </div>
+      )}
+
+      <input
+        ref={inputRef}
+        type="text"
+        placeholder={filePreview ? "添加消息(可选)..." : "输入消息"}
+        value={value}
+        onChange={(event) => onChange(event.target.value)}
+        className="w-full bg-transparent text-sm text-slate-800 placeholder:text-slate-400 outline-none border-none px-1"
+        disabled={sending || uploading}
+      />
+
+      <div className="mt-2 flex items-center justify-between">
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept="image/*,.pdf,.doc,.docx,.txt"
+          onChange={handleFileInputChange}
+          className="hidden"
+        />
+        <div className="flex items-center gap-2">
+          <button
+            type="button"
+            onClick={() => fileInputRef.current?.click()}
+            disabled={sending || uploading}
+            className="inline-flex items-center justify-center rounded-full w-8 h-8 text-slate-500 hover:bg-slate-100 hover:text-slate-700 transition-colors"
+            title="上传文件"
+          >
+            <Paperclip className="w-4 h-4" />
+          </button>
+          {toolsSlot}
+        </div>
+        <div className="flex items-center gap-2">
+          {submitLeftSlot}
+          <button
+            type="submit"
+            disabled={sending || uploading || (!value.trim() && !filePreview)}
+            className="inline-flex items-center justify-center rounded-full w-8 h-8 bg-blue-400 text-white hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+            title={uploading ? "上传中" : sending ? "发送中" : "发送"}
+          >
+            <ArrowUp className="w-4 h-4" />
+          </button>
+        </div>
+      </div>
+    </form>
+  );
+}
+

+ 7 - 0
frontend/features/agent/hooks/useConversations.ts

@@ -68,6 +68,13 @@ export function useConversations(options?: UseConversationsOptions) {
   const loadConversations = useCallback(async () => {
     setLoading(true);
     try {
+      // 内部对话(知识库测试)必须带 user_id,后端否则返回 400;未登录或 agentId 未就绪时不请求
+      if (listType === "internal" && !agentId) {
+        setConversations([]);
+        setFilteredConversations([]);
+        setSelectedConversationId(null);
+        return;
+      }
       const data = await fetchConversations(agentId ?? undefined, listType === "internal" ? { type: "internal" } : undefined);
       setConversations(data);
       const filtered = listType === "internal" ? data : applyFilter(data);

+ 11 - 3
frontend/features/agent/hooks/useMessages.ts

@@ -58,6 +58,8 @@ export function useMessages({
   const [includeAIMessages, setIncludeAIMessages] = useState(forceIncludeAIMessages);
   /** 内部对话(知识库测试)下发消息后等待 AI 回复时显示「正在思考」(与访客小窗逻辑一致) */
   const [aiThinking, setAiThinking] = useState(false);
+  /** 知识库测试:联网选项 */
+  const [needWebSearch, setNeedWebSearch] = useState(false);
 
   const refreshConversationDetail = useCallback(
     async (id: number) => {
@@ -216,6 +218,8 @@ export function useMessages({
           fileName: fileInfo?.file_name,
           fileSize: fileInfo?.file_size,
           mimeType: fileInfo?.mime_type,
+          needWebSearch: forceIncludeAIMessages ? needWebSearch : undefined,
+          useWebSearch: forceIncludeAIMessages && needWebSearch ? true : undefined,
         });
       } catch (error) {
         console.error(error);
@@ -227,7 +231,7 @@ export function useMessages({
         setSending(false);
       }
     },
-    [agentId, conversationId, sending, forceIncludeAIMessages]
+    [agentId, conversationId, sending, forceIncludeAIMessages, needWebSearch]
   );
 
   const handleNewMessage = useCallback(
@@ -320,10 +324,11 @@ export function useMessages({
         return [...prev, message];
       });
 
-      // 内部对话(知识库测试):收到 AI 回复时关闭「正在思考」(与访客小窗一致:收到对方回复即关闭)
+      // 内部对话(知识库测试):仅收到 AI 机器人(sender_id=0)回复时关闭「正在思考」。
+      // 之前仅判断 chat_mode=ai,会在回推到“自己刚发出的 AI 模式消息”时被提前关闭,导致一闪而过。
       if (forceIncludeAIMessages && message.conversation_id === conversationId) {
         const msgChatMode = message.chat_mode || "human";
-        if (msgChatMode === "ai") {
+        if (msgChatMode === "ai" && message.sender_is_agent && message.sender_id === 0) {
           setAiThinking(false);
         }
       }
@@ -522,6 +527,8 @@ export function useMessages({
       toggleAIMessages,
       forceIncludeAIMessages,
       aiThinking,
+      needWebSearch,
+      setNeedWebSearch,
     }),
     [
       conversationDetail,
@@ -537,6 +544,7 @@ export function useMessages({
       toggleAIMessages,
       forceIncludeAIMessages,
       aiThinking,
+      needWebSearch,
     ]
   );
 

+ 7 - 7
frontend/features/agent/services/aiConfigApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 
 // AI 配置类型定义
 export interface AIConfig {
@@ -41,7 +41,7 @@ export interface UpdateAIConfigRequest {
 
 // 获取用户的所有 AI 配置
 export async function fetchAIConfigs(userId: number): Promise<AIConfig[]> {
-  const res = await fetch(`${API_BASE_URL}/agent/ai-config/${userId}`, {
+  const res = await fetch(apiUrl(`/agent/ai-config/${userId}`), {
     cache: "no-store",
   });
   if (!res.ok) {
@@ -55,7 +55,7 @@ export async function fetchAIConfig(
   userId: number,
   configId: number
 ): Promise<AIConfig> {
-  const res = await fetch(`${API_BASE_URL}/agent/ai-config/${userId}/${configId}`, {
+  const res = await fetch(apiUrl(`/agent/ai-config/${userId}/${configId}`), {
     cache: "no-store",
   });
   if (!res.ok) {
@@ -69,7 +69,7 @@ export async function createAIConfig(
   userId: number,
   data: CreateAIConfigRequest
 ): Promise<AIConfig> {
-  const res = await fetch(`${API_BASE_URL}/agent/ai-config/${userId}`, {
+  const res = await fetch(apiUrl(`/agent/ai-config/${userId}`), {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(data),
@@ -87,7 +87,7 @@ export async function updateAIConfig(
   configId: number,
   data: UpdateAIConfigRequest
 ): Promise<AIConfig> {
-  const res = await fetch(`${API_BASE_URL}/agent/ai-config/${userId}/${configId}`, {
+  const res = await fetch(apiUrl(`/agent/ai-config/${userId}/${configId}`), {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(data),
@@ -104,7 +104,7 @@ export async function deleteAIConfig(
   userId: number,
   configId: number
 ): Promise<void> {
-  const res = await fetch(`${API_BASE_URL}/agent/ai-config/${userId}/${configId}`, {
+  const res = await fetch(apiUrl(`/agent/ai-config/${userId}/${configId}`), {
     method: "DELETE",
   });
   if (!res.ok) {
@@ -117,7 +117,7 @@ export async function fetchPublicAIModels(
   modelType: string = "text"
 ): Promise<AIConfig[]> {
   const res = await fetch(
-    `${API_BASE_URL}/conversations/ai-models?model_type=${modelType}`,
+    `${apiUrl("/conversations/ai-models")}?model_type=${modelType}`,
     {
       cache: "no-store",
     }

+ 54 - 0
frontend/features/agent/services/analyticsApi.ts

@@ -0,0 +1,54 @@
+import { apiUrl, getAgentHeaders } from "@/lib/config";
+
+export interface AnalyticsDailyRow {
+  date: string;
+  widget_opens: number;
+  sessions: number;
+  messages: number;
+  ai_replies: number;
+}
+
+export interface AnalyticsTotals {
+  widget_opens: number;
+  sessions: number;
+  messages: number;
+  ai_replies: number;
+  ai_failed: number;
+  ai_failure_rate_percent: number;
+  kb_hits: number;
+  kb_hit_rate_percent: number;
+  max_ai_rounds: number;
+  sessions_with_ai: number;
+  ai_participation_rate_percent: number;
+  ai_to_human_sessions: number;
+  ai_to_human_rate_percent: number;
+  human_to_ai_sessions: number;
+  human_to_ai_rate_percent: number;
+  sessions_with_ai_user_msg: number;
+  sessions_with_human_user_msg: number;
+}
+
+export interface AnalyticsSummaryResponse {
+  from: string;
+  to: string;
+  totals: AnalyticsTotals;
+  daily: AnalyticsDailyRow[];
+  note: string;
+}
+
+export async function fetchAnalyticsSummary(
+  from?: string,
+  to?: string
+): Promise<AnalyticsSummaryResponse> {
+  const q = new URLSearchParams();
+  if (from) q.set("from", from);
+  if (to) q.set("to", to);
+  const qs = q.toString();
+  const url = `${apiUrl("/agent/analytics/summary")}${qs ? `?${qs}` : ""}`;
+  const res = await fetch(url, { headers: getAgentHeaders() });
+  if (!res.ok) {
+    const j = await res.json().catch(() => ({}));
+    throw new Error((j as { error?: string }).error || `请求失败 ${res.status}`);
+  }
+  return res.json();
+}

+ 2 - 2
frontend/features/agent/services/authApi.ts

@@ -1,7 +1,7 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 
 export async function logout(): Promise<void> {
-  await fetch(`${API_BASE_URL}/logout`, {
+  await fetch(apiUrl("/logout"), {
     method: "POST",
   });
 }

+ 8 - 8
frontend/features/agent/services/conversationApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 import {
   ConversationDetail,
   ConversationSummary,
@@ -13,7 +13,7 @@ export async function fetchConversations(
   const params = new URLSearchParams();
   if (userId) params.set("user_id", String(userId));
   if (opts?.type) params.set("type", opts.type);
-  const url = `${API_BASE_URL}/conversations?${params.toString()}`;
+  const url = `${apiUrl("/conversations")}?${params.toString()}`;
   const res = await fetch(url, { cache: "no-store" });
   if (!res.ok) {
     throw new Error("获取对话列表失败");
@@ -31,7 +31,7 @@ export async function fetchConversations(
 
 /** 创建一条内部对话(知识库测试),返回新对话 ID */
 export async function initInternalConversation(userId: number): Promise<{ conversation_id: number }> {
-  const res = await fetch(`${API_BASE_URL}/conversations/internal?user_id=${userId}`, {
+  const res = await fetch(`${apiUrl("/conversations/internal")}?user_id=${userId}`, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
   });
@@ -48,8 +48,8 @@ export async function searchConversations(
   userId?: number
 ): Promise<ConversationSummary[]> {
   const url = userId
-    ? `${API_BASE_URL}/conversations/search?q=${encodeURIComponent(query)}&user_id=${userId}`
-    : `${API_BASE_URL}/conversations/search?q=${encodeURIComponent(query)}`;
+    ? `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&user_id=${userId}`
+    : `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}`;
   const res = await fetch(url, {
     cache: "no-store",
   });
@@ -72,8 +72,8 @@ export async function fetchConversationDetail(
   userId?: number
 ): Promise<ConversationDetail | null> {
   const url = userId
-    ? `${API_BASE_URL}/conversations/${conversationId}?user_id=${userId}`
-    : `${API_BASE_URL}/conversations/${conversationId}`;
+    ? `${apiUrl(`/conversations/${conversationId}`)}?user_id=${userId}`
+    : apiUrl(`/conversations/${conversationId}`);
   const res = await fetch(url, { cache: "no-store" });
   if (!res.ok) {
     return null;
@@ -102,7 +102,7 @@ export async function updateConversationContact(
   payload: UpdateConversationContactPayload
 ): Promise<UpdateConversationContactResult> {
   const res = await fetch(
-    `${API_BASE_URL}/conversations/${conversationId}/contact`,
+    apiUrl(`/conversations/${conversationId}/contact`),
     {
       method: "PUT",
       headers: { "Content-Type": "application/json" },

+ 10 - 10
frontend/features/agent/services/documentApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL, getAgentHeaders } from "@/lib/config";
+import { apiUrl, getAgentHeaders } from "@/lib/config";
 
 // 文档摘要信息
 export interface Document {
@@ -50,7 +50,7 @@ export async function fetchDocuments(
   keyword?: string,
   status?: string
 ): Promise<DocumentListResult> {
-  let url = `${API_BASE_URL}/documents?page=${page}&page_size=${pageSize}`;
+  let url = `${apiUrl("/documents")}?page=${page}&page_size=${pageSize}`;
   if (knowledgeBaseId) {
     url += `&knowledge_base_id=${knowledgeBaseId}`;
   }
@@ -75,7 +75,7 @@ export async function fetchDocuments(
 
 // 获取文档详情
 export async function fetchDocument(id: number): Promise<Document> {
-  const res = await fetch(`${API_BASE_URL}/documents/${id}`, {
+  const res = await fetch(apiUrl(`/documents/${id}`), {
     cache: "no-store",
     headers: getAgentHeaders(),
   });
@@ -92,7 +92,7 @@ export async function fetchDocument(id: number): Promise<Document> {
 
 // 创建文档
 export async function createDocument(data: CreateDocumentRequest): Promise<Document> {
-  const res = await fetch(`${API_BASE_URL}/documents`, {
+  const res = await fetch(apiUrl("/documents"), {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(data),
@@ -111,7 +111,7 @@ export async function updateDocument(
   id: number,
   data: UpdateDocumentRequest
 ): Promise<Document> {
-  const res = await fetch(`${API_BASE_URL}/documents/${id}`, {
+  const res = await fetch(apiUrl(`/documents/${id}`), {
     method: "PUT",
     headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify(data),
@@ -130,7 +130,7 @@ export async function updateDocument(
 
 // 删除文档
 export async function deleteDocument(id: number): Promise<void> {
-  const res = await fetch(`${API_BASE_URL}/documents/${id}`, {
+  const res = await fetch(apiUrl(`/documents/${id}`), {
     method: "DELETE",
     headers: getAgentHeaders(),
   });
@@ -146,7 +146,7 @@ export async function deleteDocument(id: number): Promise<void> {
 
 // 更新文档状态
 export async function updateDocumentStatus(id: number, status: string): Promise<void> {
-  const res = await fetch(`${API_BASE_URL}/documents/${id}/status`, {
+  const res = await fetch(apiUrl(`/documents/${id}/status`), {
     method: "PUT",
     headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify({ status }),
@@ -160,7 +160,7 @@ export async function updateDocumentStatus(id: number, status: string): Promise<
 
 // 发布文档
 export async function publishDocument(id: number): Promise<void> {
-  const res = await fetch(`${API_BASE_URL}/documents/${id}/publish`, {
+  const res = await fetch(apiUrl(`/documents/${id}/publish`), {
     method: "POST",
     headers: getAgentHeaders(),
   });
@@ -173,7 +173,7 @@ export async function publishDocument(id: number): Promise<void> {
 
 // 取消发布文档
 export async function unpublishDocument(id: number): Promise<void> {
-  const res = await fetch(`${API_BASE_URL}/documents/${id}/unpublish`, {
+  const res = await fetch(apiUrl(`/documents/${id}/unpublish`), {
     method: "POST",
     headers: getAgentHeaders(),
   });
@@ -190,7 +190,7 @@ export async function searchDocuments(
   topK: number = 5,
   knowledgeBaseId?: number
 ): Promise<Document[]> {
-  let url = `${API_BASE_URL}/documents/search?query=${encodeURIComponent(query)}&top_k=${topK}`;
+  let url = `${apiUrl("/documents/search")}?query=${encodeURIComponent(query)}&top_k=${topK}`;
   if (knowledgeBaseId) {
     url += `&knowledge_base_id=${knowledgeBaseId}`;
   }

+ 21 - 3
frontend/features/agent/services/embeddingConfigApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 
 // 知识库向量配置(API 返回,不含明文 API Key)
 export interface EmbeddingConfig {
@@ -8,9 +8,17 @@ export interface EmbeddingConfig {
   api_key_masked?: string;
   model: string;
   customer_can_use_kb: boolean;
+  visitor_web_search_enabled?: boolean;
+  /** 联网方式:vendor=厂商内置 web_search,custom=自建 Serper */
+  web_search_source?: "vendor" | "custom";
   updated_at?: string;
 }
 
+// 访客小窗配置(联网设置,供访客端拉取)
+export interface VisitorWidgetConfig {
+  web_search_enabled: boolean;
+}
+
 // 更新入参(api_key 可选,不传则保留原密钥)
 export interface UpdateEmbeddingConfigRequest {
   embedding_type?: string;
@@ -18,11 +26,14 @@ export interface UpdateEmbeddingConfigRequest {
   api_key?: string;
   model?: string;
   customer_can_use_kb?: boolean;
+  visitor_web_search_enabled?: boolean;
+  /** 联网方式:vendor=厂商内置,custom=自建(Serper) */
+  web_search_source?: "vendor" | "custom";
 }
 
 /** 获取当前知识库向量配置(需传 user_id 以通过代理) */
 export async function fetchEmbeddingConfig(userId: number): Promise<EmbeddingConfig> {
-  const res = await fetch(`${API_BASE_URL}/agent/embedding-config?user_id=${userId}`, {
+  const res = await fetch(`${apiUrl("/agent/embedding-config")}?user_id=${userId}`, {
     cache: "no-store",
   });
   if (!res.ok) {
@@ -36,7 +47,7 @@ export async function updateEmbeddingConfig(
   userId: number,
   data: UpdateEmbeddingConfigRequest
 ): Promise<EmbeddingConfig> {
-  const res = await fetch(`${API_BASE_URL}/agent/embedding-config`, {
+  const res = await fetch(apiUrl("/agent/embedding-config"), {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({ user_id: userId, ...data }),
@@ -47,3 +58,10 @@ export async function updateEmbeddingConfig(
   }
   return res.json();
 }
+
+/** 获取访客小窗配置(联网设置等,无需登录,供访客端调用) */
+export async function fetchVisitorWidgetConfig(): Promise<VisitorWidgetConfig> {
+  const res = await fetch(apiUrl("/visitor/widget-config"), { cache: "no-store" });
+  if (!res.ok) throw new Error("获取小窗配置失败");
+  return res.json();
+}

+ 6 - 6
frontend/features/agent/services/faqApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 
 // FAQ 摘要信息
 export interface FAQSummary {
@@ -28,7 +28,7 @@ export interface UpdateFAQRequest {
 // query 格式:关键词之间用 % 分隔,例如 "openai%api%调用"
 export async function fetchFAQs(query?: string): Promise<FAQSummary[]> {
   // 使用相对路径构建 URL,支持查询参数
-  let url = `${API_BASE_URL}/faqs`;
+  let url = apiUrl("/faqs");
   if (query) {
     url += `?query=${encodeURIComponent(query)}`;
   }
@@ -47,7 +47,7 @@ export async function fetchFAQs(query?: string): Promise<FAQSummary[]> {
 
 // 获取 FAQ 详情
 export async function fetchFAQ(id: number): Promise<FAQSummary> {
-  const res = await fetch(`${API_BASE_URL}/faqs/${id}`, {
+  const res = await fetch(apiUrl(`/faqs/${id}`), {
     cache: "no-store",
   });
 
@@ -63,7 +63,7 @@ export async function fetchFAQ(id: number): Promise<FAQSummary> {
 
 // 创建 FAQ
 export async function createFAQ(data: CreateFAQRequest): Promise<FAQSummary> {
-  const res = await fetch(`${API_BASE_URL}/faqs`, {
+  const res = await fetch(apiUrl("/faqs"), {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(data),
@@ -82,7 +82,7 @@ export async function updateFAQ(
   id: number,
   data: UpdateFAQRequest
 ): Promise<FAQSummary> {
-  const res = await fetch(`${API_BASE_URL}/faqs/${id}`, {
+  const res = await fetch(apiUrl(`/faqs/${id}`), {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(data),
@@ -101,7 +101,7 @@ export async function updateFAQ(
 
 // 删除 FAQ
 export async function deleteFAQ(id: number): Promise<void> {
-  const res = await fetch(`${API_BASE_URL}/faqs/${id}`, {
+  const res = await fetch(apiUrl(`/faqs/${id}`), {
     method: "DELETE",
   });
 

+ 3 - 3
frontend/features/agent/services/importApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL, getAgentHeaders } from "@/lib/config";
+import { apiUrl, getAgentHeaders } from "@/lib/config";
 
 // 导入结果
 export interface ImportResult {
@@ -20,7 +20,7 @@ export async function importDocuments(
     formData.append("files", file);
   }
 
-  const res = await fetch(`${API_BASE_URL}/import/documents`, {
+  const res = await fetch(apiUrl("/import/documents"), {
     method: "POST",
     headers: getAgentHeaders(),
     body: formData,
@@ -56,7 +56,7 @@ export interface ImportFromUrlsRequest {
 }
 
 export async function importFromUrls(data: ImportFromUrlsRequest): Promise<ImportResult> {
-  const res = await fetch(`${API_BASE_URL}/import/urls`, {
+  const res = await fetch(apiUrl("/import/urls"), {
     method: "POST",
     headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify(data),

+ 8 - 8
frontend/features/agent/services/knowledgeBaseApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL, getAgentHeaders } from "@/lib/config";
+import { apiUrl, getAgentHeaders } from "@/lib/config";
 
 // 知识库摘要信息
 export interface KnowledgeBase {
@@ -26,7 +26,7 @@ export interface UpdateKnowledgeBaseRequest {
 
 // 获取知识库列表
 export async function fetchKnowledgeBases(): Promise<KnowledgeBase[]> {
-  const res = await fetch(`${API_BASE_URL}/knowledge-bases`, {
+  const res = await fetch(apiUrl("/knowledge-bases"), {
     cache: "no-store",
     headers: getAgentHeaders(),
   });
@@ -41,7 +41,7 @@ export async function fetchKnowledgeBases(): Promise<KnowledgeBase[]> {
 
 // 获取知识库详情
 export async function fetchKnowledgeBase(id: number): Promise<KnowledgeBase> {
-  const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}`, {
+  const res = await fetch(apiUrl(`/knowledge-bases/${id}`), {
     cache: "no-store",
     headers: getAgentHeaders(),
   });
@@ -58,7 +58,7 @@ export async function fetchKnowledgeBase(id: number): Promise<KnowledgeBase> {
 
 // 创建知识库
 export async function createKnowledgeBase(data: CreateKnowledgeBaseRequest): Promise<KnowledgeBase> {
-  const res = await fetch(`${API_BASE_URL}/knowledge-bases`, {
+  const res = await fetch(apiUrl("/knowledge-bases"), {
     method: "POST",
     headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify(data),
@@ -77,7 +77,7 @@ export async function updateKnowledgeBaseRAGEnabled(
   id: number,
   ragEnabled: boolean
 ): Promise<KnowledgeBase> {
-  const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}/rag-enabled`, {
+  const res = await fetch(apiUrl(`/knowledge-bases/${id}/rag-enabled`), {
     method: "PATCH",
     headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify({ rag_enabled: ragEnabled }),
@@ -94,7 +94,7 @@ export async function updateKnowledgeBase(
   id: number,
   data: UpdateKnowledgeBaseRequest
 ): Promise<KnowledgeBase> {
-  const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}`, {
+  const res = await fetch(apiUrl(`/knowledge-bases/${id}`), {
     method: "PUT",
     headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify(data),
@@ -113,7 +113,7 @@ export async function updateKnowledgeBase(
 
 // 删除知识库
 export async function deleteKnowledgeBase(id: number): Promise<void> {
-  const res = await fetch(`${API_BASE_URL}/knowledge-bases/${id}`, {
+  const res = await fetch(apiUrl(`/knowledge-bases/${id}`), {
     method: "DELETE",
   });
 
@@ -134,7 +134,7 @@ export async function fetchDocumentsByKnowledgeBase(
   keyword?: string,
   status?: string
 ): Promise<any> {
-  let url = `${API_BASE_URL}/documents?knowledge_base_id=${knowledgeBaseId}&page=${page}&page_size=${pageSize}`;
+  let url = `${apiUrl("/documents")}?knowledge_base_id=${knowledgeBaseId}&page=${page}&page_size=${pageSize}`;
   if (keyword) {
     url += `&keyword=${encodeURIComponent(keyword)}`;
   }

+ 43 - 8
frontend/features/agent/services/messageApi.ts

@@ -1,17 +1,21 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 import { MessageItem } from "../types";
+import { reportFrontendLog } from "./systemLogApi";
 
 interface SendMessagePayload {
   conversationId: number;
   content: string;
   senderId?: number;
   senderIsAgent?: boolean;
-  // 文件相关字段(可选)
   fileUrl?: string;
   fileType?: "image" | "document";
   fileName?: string;
   fileSize?: number;
   mimeType?: string;
+  useKnowledgeBase?: boolean;
+  useLLM?: boolean;
+  useWebSearch?: boolean;
+  needWebSearch?: boolean;
 }
 
 // 文件上传结果
@@ -28,12 +32,20 @@ export async function fetchMessages(
   includeAIMessages: boolean = false
 ): Promise<MessageItem[]> {
   const res = await fetch(
-    `${API_BASE_URL}/messages?conversation_id=${conversationId}&include_ai_messages=${includeAIMessages}`,
+    `${apiUrl("/messages")}?conversation_id=${conversationId}&include_ai_messages=${includeAIMessages}`,
     {
       cache: "no-store",
     }
   );
   if (!res.ok) {
+    void reportFrontendLog({
+      level: "warn",
+      category: "frontend",
+      event: "fetch_messages_failed",
+      message: "获取消息失败",
+      conversationId,
+      meta: { status: res.status, includeAIMessages },
+    });
     throw new Error("获取消息失败");
   }
   const data = await res.json();
@@ -54,13 +66,21 @@ export async function uploadFile(
     formData.append("conversation_id", conversationId.toString());
   }
 
-  const res = await fetch(`${API_BASE_URL}/messages/upload`, {
+  const res = await fetch(apiUrl("/messages/upload"), {
     method: "POST",
     body: formData,
   });
 
   if (!res.ok) {
     const error = await res.json().catch(() => ({}));
+    void reportFrontendLog({
+      level: "warn",
+      category: "frontend",
+      event: "upload_file_failed",
+      message: "上传文件失败",
+      conversationId,
+      meta: { status: res.status, error },
+    });
     throw new Error(error.error || "文件上传失败");
   }
 
@@ -82,15 +102,18 @@ export async function sendMessage({
   fileName,
   fileSize,
   mimeType,
+  useKnowledgeBase,
+  useLLM,
+  useWebSearch,
+  needWebSearch,
 }: SendMessagePayload): Promise<void> {
-  const payload: any = {
+  const payload: Record<string, unknown> = {
     conversation_id: conversationId,
     content,
     sender_is_agent: senderIsAgent,
     sender_id: typeof senderId === "number" ? senderId : 0,
   };
 
-  // 如果有文件,添加文件字段
   if (fileUrl) {
     payload.file_url = fileUrl;
     if (fileType) payload.file_type = fileType;
@@ -98,8 +121,12 @@ export async function sendMessage({
     if (fileSize) payload.file_size = fileSize;
     if (mimeType) payload.mime_type = mimeType;
   }
+  if (useKnowledgeBase !== undefined) payload.use_knowledge_base = useKnowledgeBase;
+  if (useLLM !== undefined) payload.use_llm = useLLM;
+  if (useWebSearch !== undefined) payload.use_web_search = useWebSearch;
+  if (needWebSearch !== undefined) payload.need_web_search = needWebSearch;
 
-  const res = await fetch(`${API_BASE_URL}/messages`, {
+  const res = await fetch(apiUrl("/messages"), {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(payload),
@@ -109,6 +136,14 @@ export async function sendMessage({
     console.error(
       `❌ 发送消息失败: 对话ID=${conversationId}, 状态=${res.status}, 错误=${JSON.stringify(error)}`
     );
+    void reportFrontendLog({
+      level: "error",
+      category: "frontend",
+      event: "send_message_failed",
+      message: "发送消息失败",
+      conversationId,
+      meta: { status: res.status, error },
+    });
     throw new Error(error.error || "发送消息失败");
   }
 }
@@ -123,7 +158,7 @@ export async function markMessagesRead(
   conversationId: number,
   readerIsAgent: boolean
 ): Promise<MarkMessagesReadResult | null> {
-  const res = await fetch(`${API_BASE_URL}/messages/read`, {
+  const res = await fetch(apiUrl("/messages/read"), {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({

+ 4 - 4
frontend/features/agent/services/profileApi.ts

@@ -1,10 +1,10 @@
 // 客服个人资料 API 服务
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 import { Profile } from "../types";
 
 // 获取个人资料
 export async function fetchProfile(userId: number): Promise<Profile | null> {
-  const res = await fetch(`${API_BASE_URL}/agent/profile/${userId}`, {
+  const res = await fetch(apiUrl(`/agent/profile/${userId}`), {
     cache: "no-store",
   });
   if (!res.ok) {
@@ -36,7 +36,7 @@ export async function updateProfile(
   userId: number,
   payload: UpdateProfilePayload
 ): Promise<Profile> {
-  const res = await fetch(`${API_BASE_URL}/agent/profile/${userId}`, {
+  const res = await fetch(apiUrl(`/agent/profile/${userId}`), {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify(payload),
@@ -67,7 +67,7 @@ export async function uploadAvatar(
   const formData = new FormData();
   formData.append("avatar", file);
 
-  const res = await fetch(`${API_BASE_URL}/agent/avatar/${userId}`, {
+  const res = await fetch(apiUrl(`/agent/avatar/${userId}`), {
     method: "POST",
     body: formData,
   });

+ 47 - 0
frontend/features/agent/services/promptsApi.ts

@@ -0,0 +1,47 @@
+import { apiUrl } from "@/lib/config";
+
+export interface PromptItem {
+  key: string;
+  name: string;
+  content: string;
+  updated_at?: string;
+}
+
+export interface PromptsResponse {
+  prompts: PromptItem[];
+}
+
+/** 获取所有提示词配置(用于「提示词」页) */
+export async function fetchPrompts(userId: number): Promise<PromptItem[]> {
+  const res = await fetch(`${apiUrl("/agent/prompts")}?user_id=${userId}`, {
+    cache: "no-store",
+  });
+  if (!res.ok) {
+    throw new Error("获取提示词配置失败");
+  }
+  const contentType = res.headers.get("content-type") ?? "";
+  if (!contentType.includes("application/json")) {
+    throw new Error(
+      "提示词接口返回非 JSON,请确认:1) 后端已启动;2) 前端代理端口与后端一致(默认 8080,若后端在 18080 请在 frontend/.env.local 设置 NEXT_PUBLIC_BACKEND_PORT=18080 并重启前端)"
+    );
+  }
+  const data: PromptsResponse = await res.json();
+  return data.prompts ?? [];
+}
+
+/** 更新单条提示词(仅管理员) */
+export async function updatePrompt(
+  userId: number,
+  key: string,
+  content: string
+): Promise<void> {
+  const res = await fetch(apiUrl("/agent/prompts"), {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ user_id: userId, key, content }),
+  });
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    throw new Error((err as { error?: string }).error || "更新提示词失败");
+  }
+}

+ 95 - 0
frontend/features/agent/services/systemLogApi.ts

@@ -0,0 +1,95 @@
+import { apiUrl, getAgentHeaders } from "@/lib/config";
+
+export interface SystemLogItem {
+  id: number;
+  timestamp: string;
+  level: string;
+  category: string;
+  event: string;
+  source: string;
+  trace_id?: string;
+  conversation_id?: number;
+  user_id?: number;
+  visitor_id?: number;
+  message: string;
+  meta_json?: string;
+  created_at: string;
+}
+
+export interface QuerySystemLogsResult {
+  items: SystemLogItem[];
+  total: number;
+  page: number;
+  page_size: number;
+}
+
+export interface QuerySystemLogsParams {
+  from?: string;
+  to?: string;
+  level?: string;
+  category?: string;
+  event?: string;
+  source?: string;
+  conversationId?: number;
+  keyword?: string;
+  page?: number;
+  pageSize?: number;
+}
+
+export async function fetchSystemLogs(params: QuerySystemLogsParams): Promise<QuerySystemLogsResult> {
+  const q = new URLSearchParams();
+  if (params.from) q.set("from", params.from);
+  if (params.to) q.set("to", params.to);
+  if (params.level) q.set("level", params.level);
+  if (params.category) q.set("category", params.category);
+  if (params.event) q.set("event", params.event);
+  if (params.source) q.set("source", params.source);
+  if (params.conversationId != null) q.set("conversation_id", String(params.conversationId));
+  if (params.keyword) q.set("keyword", params.keyword);
+  q.set("page", String(params.page ?? 1));
+  q.set("page_size", String(params.pageSize ?? 50));
+
+  const res = await fetch(`${apiUrl("/agent/logs/api")}?${q.toString()}`, {
+    headers: getAgentHeaders(),
+  });
+  if (!res.ok) {
+    const j = await res.json().catch(() => ({}));
+    throw new Error((j as { error?: string }).error || `加载日志失败(${res.status})`);
+  }
+  return res.json();
+}
+
+export async function reportFrontendLog(input: {
+  level: "info" | "warn" | "error";
+  category: string;
+  event: string;
+  message: string;
+  traceId?: string;
+  conversationId?: number;
+  visitorId?: number;
+  meta?: Record<string, unknown>;
+}): Promise<void> {
+  const payload: Record<string, unknown> = {
+    level: input.level,
+    category: input.category,
+    event: input.event,
+    message: input.message,
+    trace_id: input.traceId,
+    conversation_id: input.conversationId,
+    visitor_id: input.visitorId,
+    meta: input.meta ?? {},
+  };
+  const res = await fetch(apiUrl("/agent/logs/frontend"), {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      ...getAgentHeaders(),
+    },
+    body: JSON.stringify(payload),
+  });
+  if (!res.ok) {
+    // 日志上报失败不阻断业务
+    console.warn("frontend log report failed", res.status);
+  }
+}
+

+ 7 - 7
frontend/features/agent/services/userApi.ts

@@ -1,4 +1,4 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 
 // 用户摘要信息(列表)
 export interface UserSummary {
@@ -41,7 +41,7 @@ export async function fetchUsers(
   currentUserId: number
 ): Promise<UserSummary[]> {
   const res = await fetch(
-    `${API_BASE_URL}/admin/users?current_user_id=${currentUserId}`,
+    `${apiUrl("/admin/users")}?current_user_id=${currentUserId}`,
     {
       cache: "no-store",
     }
@@ -68,7 +68,7 @@ export async function fetchUser(
   currentUserId: number
 ): Promise<UserSummary> {
   const res = await fetch(
-    `${API_BASE_URL}/admin/users/${id}?current_user_id=${currentUserId}`,
+    `${apiUrl(`/admin/users/${id}`)}?current_user_id=${currentUserId}`,
     {
       cache: "no-store",
     }
@@ -92,7 +92,7 @@ export async function createUser(
   currentUserId: number
 ): Promise<UserSummary> {
   const res = await fetch(
-    `${API_BASE_URL}/admin/users?current_user_id=${currentUserId}`,
+    `${apiUrl("/admin/users")}?current_user_id=${currentUserId}`,
     {
       method: "POST",
       headers: { "Content-Type": "application/json" },
@@ -117,7 +117,7 @@ export async function updateUser(
   currentUserId: number
 ): Promise<UserSummary> {
   const res = await fetch(
-    `${API_BASE_URL}/admin/users/${id}?current_user_id=${currentUserId}`,
+    `${apiUrl(`/admin/users/${id}`)}?current_user_id=${currentUserId}`,
     {
       method: "PUT",
       headers: { "Content-Type": "application/json" },
@@ -144,7 +144,7 @@ export async function deleteUser(
   currentUserId: number
 ): Promise<void> {
   const res = await fetch(
-    `${API_BASE_URL}/admin/users/${id}?current_user_id=${currentUserId}`,
+    `${apiUrl(`/admin/users/${id}`)}?current_user_id=${currentUserId}`,
     {
       method: "DELETE",
     }
@@ -168,7 +168,7 @@ export async function updateUserPassword(
   currentUserId: number
 ): Promise<void> {
   const res = await fetch(
-    `${API_BASE_URL}/admin/users/${id}/password?current_user_id=${currentUserId}`,
+    `${apiUrl(`/admin/users/${id}/password`)}?current_user_id=${currentUserId}`,
     {
       method: "PUT",
       headers: { "Content-Type": "application/json" },

+ 3 - 1
frontend/features/agent/types.ts

@@ -36,10 +36,12 @@ export interface MessageItem {
   read_at?: string | null;
   // 文件相关字段(可选)
   file_url?: string | null;
-  file_type?: string | null; // "image" | "document"
+  file_type?: string | null;
   file_name?: string | null;
   file_size?: number | null;
   mime_type?: string | null;
+  /** AI 回复使用的数据源,逗号分隔,如 knowledge_base / llm / web */
+  sources_used?: string | null;
 }
 
 export interface ConversationDetail extends ConversationSummary {

+ 14 - 0
frontend/features/visitor/services/analyticsApi.ts

@@ -0,0 +1,14 @@
+import { apiUrl } from "@/lib/config";
+
+/** 访客打开客服小窗时上报(用于统计访问次数) */
+export async function postWidgetOpen(visitorId: number): Promise<void> {
+  const res = await fetch(apiUrl("/visitor/analytics/widget-open"), {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ visitor_id: visitorId }),
+  });
+  if (!res.ok) {
+    // 埋点失败不阻断用户
+    console.warn("widget-open 上报失败", res.status);
+  }
+}

+ 11 - 2
frontend/features/visitor/services/conversationApi.ts

@@ -1,4 +1,5 @@
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
+import { reportFrontendLog } from "@/features/agent/services/systemLogApi";
 
 export interface InitVisitorConversationPayload {
   visitorId: number;
@@ -20,7 +21,7 @@ export interface InitVisitorConversationResult {
 export async function initVisitorConversation(
   payload: InitVisitorConversationPayload
 ): Promise<InitVisitorConversationResult> {
-  const res = await fetch(`${API_BASE_URL}/conversation/init`, {
+  const res = await fetch(apiUrl("/conversation/init"), {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({
@@ -37,6 +38,14 @@ export async function initVisitorConversation(
   });
 
   if (!res.ok) {
+    void reportFrontendLog({
+      level: "warn",
+      category: "frontend",
+      event: "visitor_init_conversation_failed",
+      message: "访客初始化对话失败",
+      visitorId: payload.visitorId,
+      meta: { status: res.status, chatMode: payload.chatMode, aiConfigId: payload.aiConfigId },
+    });
     throw new Error("初始化对话失败");
   }
 

+ 2 - 2
frontend/features/visitor/services/visitorApi.ts

@@ -3,7 +3,7 @@
  * 提供访客相关的 API 调用
  */
 
-import { API_BASE_URL } from "@/lib/config";
+import { apiUrl } from "@/lib/config";
 
 /**
  * 在线客服信息
@@ -18,7 +18,7 @@ export interface OnlineAgent {
  * 获取在线客服列表
  */
 export async function fetchOnlineAgents(): Promise<OnlineAgent[]> {
-  const response = await fetch(`${API_BASE_URL}/visitor/online-agents`, {
+  const response = await fetch(apiUrl("/visitor/online-agents"), {
     method: "GET",
     headers: {
       "Content-Type": "application/json",

+ 8 - 5
frontend/hooks/useSoundNotification.ts

@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useRef, useState } from "react";
+import { unlockSound } from "@/utils/sound";
 
 export function useSoundNotification(initialEnabled: boolean = true) {
   const [enabled, setEnabled] = useState(initialEnabled);
@@ -7,10 +8,8 @@ export function useSoundNotification(initialEnabled: boolean = true) {
   useEffect(() => {
     if (!enabled) return;
 
-    if (!audioRef.current) {
-      audioRef.current = new Audio("/notification.mp3");
-      audioRef.current.volume = 0.5;
-    }
+    // 尝试解锁音频(多数浏览器需用户交互后才能真正响)
+    void unlockSound();
 
     return () => {
       if (audioRef.current) {
@@ -29,7 +28,11 @@ export function useSoundNotification(initialEnabled: boolean = true) {
   }, [enabled]);
 
   const toggle = useCallback(() => {
-    setEnabled((prev) => !prev);
+    setEnabled((prev) => {
+      const next = !prev;
+      if (next) void unlockSound();
+      return next;
+    });
   }, []);
 
   return { enabled, toggle, play };

+ 8 - 2
frontend/lib/config.ts

@@ -1,7 +1,13 @@
 // 统一的 API 配置
-// 使用相对路径,自动适配当前域名(无论是否绑定域名都能工作)
-// 前端和后端通过 Nginx 代理在同一域名下,所以使用相对路径即可
+// 推荐生产形态(形态2):同域反向代理,把后端挂到 /api 下。
+// 这样无需在前端产物里写死域名/端口(避免 Docker 镜像里固化 localhost)。
 export const API_BASE_URL = "";
+export const API_PREFIX = "/api";
+
+export function apiUrl(path: string): string {
+  const p = path.startsWith("/") ? path : `/${path}`;
+  return `${API_BASE_URL}${API_PREFIX}${p}`;
+}
 
 /** 知识库/文档/导入等接口需带当前用户 ID,供后端校验「是否开放知识库」开关 */
 export function getAgentHeaders(): Record<string, string> {

+ 39 - 0
frontend/lib/constants/agent-pages.tsx

@@ -10,6 +10,9 @@ import {
   ClipboardList,
   Users,
   Settings,
+  FileText,
+  BarChart3,
+  ScrollText,
 } from "lucide-react";
 
 /** 嵌入在 dashboard 内的页面组件(懒加载) */
@@ -29,6 +32,18 @@ const SettingsPage = dynamic(
   () => import("@/app/agent/settings/page").then((mod) => ({ default: mod.default })),
   { ssr: false }
 );
+const PromptsPage = dynamic(
+  () => import("@/app/agent/prompts/page").then((mod) => ({ default: mod.default })),
+  { ssr: false }
+);
+const AnalyticsPage = dynamic(
+  () => import("@/app/agent/analytics/page").then((mod) => ({ default: mod.default })),
+  { ssr: false }
+);
+const LogsPage = dynamic(
+  () => import("@/app/agent/logs/page").then((mod) => ({ default: mod.default })),
+  { ssr: false }
+);
 
 export interface AgentPageItem {
   id: string;
@@ -79,6 +94,22 @@ export const AGENT_PAGES = [
     adminOnly: false,
     component: FAQsPage,
   },
+  {
+    id: "analytics",
+    label: "数据报表",
+    title: "数据报表",
+    Icon: BarChart3,
+    adminOnly: false,
+    component: AnalyticsPage,
+  },
+  {
+    id: "logs",
+    label: "日志中心",
+    title: "日志中心",
+    Icon: ScrollText,
+    adminOnly: false,
+    component: LogsPage,
+  },
   {
     id: "users",
     label: "用户管理",
@@ -87,6 +118,14 @@ export const AGENT_PAGES = [
     adminOnly: true,
     component: UsersPage,
   },
+  {
+    id: "prompts",
+    label: "提示词",
+    title: "提示词",
+    Icon: FileText,
+    adminOnly: true,
+    component: PromptsPage,
+  },
   {
     id: "settings",
     label: "AI 配置",

+ 34 - 0
frontend/lib/seo/home-json-ld.ts

@@ -0,0 +1,34 @@
+import { websiteConfig } from "@/lib/website-config";
+import { getSiteUrl } from "@/lib/site";
+
+export function buildHomeJsonLd() {
+  const url = getSiteUrl();
+  const repo = websiteConfig.github.repo;
+
+  return {
+    "@context": "https://schema.org",
+    "@graph": [
+      {
+        "@type": "Organization",
+        name: "AI-CS",
+        url,
+        sameAs: [repo],
+      },
+      {
+        "@type": "SoftwareApplication",
+        name: "AI-CS 智能客服系统",
+        applicationCategory: "BusinessApplication",
+        operatingSystem: "Web · Docker 私有化部署",
+        description:
+          "开源 AI 客服系统,支持多模型对话、知识库向量检索(RAG)、提示词工程、人工协作与可观测运营。",
+        url,
+        codeRepository: repo,
+        offers: {
+          "@type": "Offer",
+          price: "0",
+          priceCurrency: "USD",
+        },
+      },
+    ],
+  };
+}

+ 6 - 0
frontend/lib/site.ts

@@ -0,0 +1,6 @@
+/** 官网绝对地址(用于 SEO、sitemap、OG)。未配置时使用 README 中的演示站。 */
+export function getSiteUrl(): string {
+  const raw =
+    process.env.NEXT_PUBLIC_SITE_URL?.trim() || "https://demo.cscorp.top";
+  return raw.replace(/\/$/, "");
+}

+ 3 - 0
frontend/lib/website-config.ts

@@ -4,6 +4,9 @@
  */
 
 export const websiteConfig = {
+  /** 在线演示站点(「立即体验 / 打开 Demo」等入口) */
+  demoUrl: "https://demo.cscorp.top",
+
   // GitHub 仓库地址
   github: {
     repo: "https://github.com/2930134478/AI-CS",

+ 19 - 0
frontend/next.config.mjs

@@ -3,6 +3,7 @@
  * 与 next.config.ts 逻辑一致,供 Docker 生产镜像使用,避免 next start 触发 npm install
  */
 
+// 开发时代理目标端口(统一从根目录 .env 读取 NEXT_PUBLIC_BACKEND_*)
 const backendPort = process.env.NEXT_PUBLIC_BACKEND_PORT || "8080";
 const backendHost = process.env.NEXT_PUBLIC_BACKEND_HOST || "localhost";
 
@@ -11,6 +12,11 @@ const nextConfig = {
   async rewrites() {
     if (process.env.NODE_ENV === "development") {
       return [
+        // 形态2(同域 /api)在本地开发的兜底:把 /api/* 代理到后端 /api/*
+        {
+          source: "/api/:path*",
+          destination: `http://${backendHost}:${backendPort}/api/:path*`,
+        },
         {
           source: "/agent/profile/:path*",
           destination: `http://${backendHost}:${backendPort}/agent/profile/:path*`,
@@ -27,6 +33,19 @@ const nextConfig = {
           source: "/agent/ai-config/:path*",
           destination: `http://${backendHost}:${backendPort}/agent/ai-config/:path*`,
         },
+        {
+          // 数据报表 API(后端 gin 路由在 /agent/analytics/summary)
+          source: "/agent/analytics/summary",
+          destination: `http://${backendHost}:${backendPort}/agent/analytics/summary`,
+        },
+        {
+          source: "/agent/logs/api",
+          destination: `http://${backendHost}:${backendPort}/agent/logs/api`,
+        },
+        {
+          source: "/agent/logs/frontend",
+          destination: `http://${backendHost}:${backendPort}/agent/logs/frontend`,
+        },
         {
           source: "/:path((?!_next|agent|chat|favicon.ico).*)",
           destination: `http://${backendHost}:${backendPort}/:path*`,

+ 27 - 5
frontend/next.config.ts

@@ -1,7 +1,6 @@
 import type { NextConfig } from "next";
 
-// 从环境变量读取后端端口,默认 8080(与后端 main.go 保持一致)
-// 如果设置了 NEXT_PUBLIC_BACKEND_PORT,优先使用(用于 Docker 部署等场景)
+// 开发时代理目标端口(统一从根目录 .env 读取 NEXT_PUBLIC_BACKEND_*)
 const backendPort = process.env.NEXT_PUBLIC_BACKEND_PORT || "8080";
 const backendHost = process.env.NEXT_PUBLIC_BACKEND_HOST || "localhost";
 
@@ -12,6 +11,12 @@ const nextConfig: NextConfig = {
     // 只在开发环境启用代理
     if (process.env.NODE_ENV === "development") {
       return [
+        // 形态2(同域 /api)在本地开发的兜底:把 /api/* 代理到后端 /api/*
+        // 避免 Next 把 /api 当成自己的 API 路由而导致 404
+        {
+          source: "/api/:path*",
+          destination: `http://${backendHost}:${backendPort}/api/:path*`,
+        },
         // 优先匹配后端 API 路径(这些需要代理到后端)
         {
           source: "/agent/profile/:path*",
@@ -25,14 +30,31 @@ const nextConfig: NextConfig = {
           source: "/agent/embedding-config",
           destination: `http://${backendHost}:${backendPort}/agent/embedding-config`,
         },
+        {
+          source: "/agent/prompts",
+          destination: `http://${backendHost}:${backendPort}/agent/prompts`,
+        },
         {
           source: "/agent/ai-config/:path*",
           destination: `http://${backendHost}:${backendPort}/agent/ai-config/:path*`,
         },
-        // 匹配其他 API 路径(不以 /_next、/agent、/chat 开头的路径)
-        // 例如:/login, /conversations, /messages 等
         {
-          source: "/:path((?!_next|agent|chat|favicon.ico).*)",
+          // 数据报表 API(后端 gin 路由在 /agent/analytics/summary)
+          source: "/agent/analytics/summary",
+          destination: `http://${backendHost}:${backendPort}/agent/analytics/summary`,
+        },
+        {
+          source: "/agent/logs/api",
+          destination: `http://${backendHost}:${backendPort}/agent/logs/api`,
+        },
+        {
+          source: "/agent/logs/frontend",
+          destination: `http://${backendHost}:${backendPort}/agent/logs/frontend`,
+        },
+        // 匹配其他 API 路径(不以 /_next、/agent、/api、/chat 开头的路径)
+        // /api/agent/prompts 由 app/api/agent/prompts/route.ts 代理,不在此转发
+        {
+          source: "/:path((?!_next|agent|api|chat|favicon.ico).*)",
           destination: `http://${backendHost}:${backendPort}/:path*`,
         },
       ];

+ 2 - 0
frontend/utils/avatar.ts

@@ -17,6 +17,8 @@ export function getAvatarUrl(avatarUrl: string | null | undefined): string | nul
   // 如果是相对路径,拼接 API_BASE_URL
   // 确保路径以 / 开头
   const path = avatarUrl.startsWith("/") ? avatarUrl : `/${avatarUrl}`;
+  // 形态2(同域 /api)下,后端返回的头像通常是 /uploads/... 这类相对路径
+  // 保持拼接行为不变:API_BASE_URL 为空则走同域
   return `${API_BASE_URL}${path}`;
 }
 

+ 71 - 17
frontend/utils/sound.ts

@@ -1,24 +1,78 @@
-// 声音通知工具函数
-export function playNotificationSound() {
+let audioCtx: AudioContext | null = null;
+let unlocked = false;
+
+function getAudioContext(): AudioContext | null {
+  if (typeof window === "undefined") return null;
+  if (audioCtx) return audioCtx;
+  const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+  if (!Ctx) return null;
+  audioCtx = new Ctx();
+  return audioCtx;
+}
+
+/** 尝试解锁音频播放(需用户手势触发更稳定)。 */
+export async function unlockSound() {
+  const ctx = getAudioContext();
+  if (!ctx) return;
   try {
-    const audio = new Audio("/notification.mp3");
-    audio.volume = 0.5;
-    audio.play().catch(() => {
-      // 忽略播放错误(用户可能未交互)
-    });
-  } catch (error) {
-    // 忽略错误
+    if (ctx.state === "suspended") {
+      await ctx.resume();
+    }
+    // 轻触发一次极低音量,避免某些浏览器仍阻止后续播放
+    const osc = ctx.createOscillator();
+    const gain = ctx.createGain();
+    gain.gain.value = 0.00001;
+    osc.type = "sine";
+    osc.frequency.value = 440;
+    osc.connect(gain);
+    gain.connect(ctx.destination);
+    osc.start();
+    osc.stop(ctx.currentTime + 0.01);
+    unlocked = true;
+  } catch {
+    // ignore
   }
 }
 
-export function playMessageSound() {
+function playTone(opts: { frequency: number; durationMs: number; volume: number; type?: OscillatorType; whenMs?: number }) {
+  const ctx = getAudioContext();
+  if (!ctx) return;
+  // 未解锁时也尝试播放;如果被阻止,保持静默(不抛错)
+  const startAt = ctx.currentTime + (opts.whenMs ?? 0) / 1000;
+  const endAt = startAt + opts.durationMs / 1000;
+
+  const osc = ctx.createOscillator();
+  const gain = ctx.createGain();
+
+  osc.type = opts.type ?? "sine";
+  osc.frequency.setValueAtTime(opts.frequency, startAt);
+
+  // 快速起音 + 平滑衰减(避免“哔”得刺耳)
+  gain.gain.setValueAtTime(0.00001, startAt);
+  gain.gain.linearRampToValueAtTime(opts.volume, startAt + 0.01);
+  gain.gain.exponentialRampToValueAtTime(0.00001, endAt);
+
+  osc.connect(gain);
+  gain.connect(ctx.destination);
+
   try {
-    const audio = new Audio("/message.mp3");
-    audio.volume = 0.3;
-    audio.play().catch(() => {
-      // 忽略播放错误
-    });
-  } catch (error) {
-    // 忽略错误
+    osc.start(startAt);
+    osc.stop(endAt);
+  } catch {
+    // ignore
   }
 }
+
+/** 新消息提示音(蜂鸣:两段短音)。 */
+export function playNotificationSound() {
+  // 默认音量尽量克制;用户可通过系统音量调节
+  const volume = 0.08;
+  playTone({ frequency: 880, durationMs: 70, volume, type: "sine" });
+  playTone({ frequency: 660, durationMs: 90, volume: volume * 0.9, type: "sine", whenMs: 90 });
+}
+
+/** 轻提示音(更柔和,用于非关键提示)。 */
+export function playMessageSound() {
+  const volume = 0.05;
+  playTone({ frequency: 520, durationMs: 80, volume, type: "triangle" });
+}