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

This commit is contained in:
537yaha
2026-03-25 18:50:58 +08:00
parent 1693f8b8fb
commit 0fc7147821
99 changed files with 5963 additions and 1734 deletions
+87 -14
View File
@@ -1,24 +1,97 @@
# ============================================
# Docker Compose 鐢熶骇缂栨帓鐢紙澶嶅埗涓?.env 鍚庡~鍐欙級
# 鐢ㄤ簬锛歞ocker compose -f docker-compose.prod.yml up -d
# ============================================
# AI-CS 统一配置模板(唯一真源)
# 使用方法:
# 1) 复制为项目根目录 .env
# 2) 按注释填写必填项
# 3) Docker 与本地启动都读取这同一份配置
# ============================================
# MySQL 鏁版嵁搴?MYSQL_ROOT_PASSWORD=
MYSQL_DATABASE=ai_cs
MYSQL_USER=ai_cs_user
MYSQL_PASSWORD=
# =========================
# 运行画像(建议配置)
# =========================
# 可选:docker / local
# - dockerDB_HOST/MILVUS_HOST 通常填写服务名(mysql、milvus-standalone
# - localDB_HOST/MILVUS_HOST 通常填写 localhost 或远程地址
APP_PROFILE=docker
# =========================
# 服务监听(必填)
# =========================
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# 建议:生产 release,开发 debug
GIN_MODE=release
# =========================
# 数据库(必填)
# =========================
DB_HOST=mysql
DB_PORT=3306
DB_USER=ai_cs_user
DB_PASSWORD=please_change_me
DB_NAME=ai_cs
# MySQL 容器 root 密码(Docker 部署必填)
MYSQL_ROOT_PASSWORD=please_change_root_password
# MySQL 对外端口(非必须)
MYSQL_PORT=3306
# 榛樿绠$悊鍛橈紙棣栨鍚姩鏃跺垱寤猴紝璇峰姟蹇呬慨鏀瑰瘑鐮侊級
# =========================
# 管理员与安全(必填)
# =========================
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
ADMIN_PASSWORD=please_change_admin_password
# 64 位十六进制密钥(必填)
# 生成示例:openssl rand -hex 32
ENCRYPTION_KEY=please_generate_64_hex_chars
# 鍚庣鍔犲瘑瀵嗛挜锛堝瓨搴?API Key 绛夊姞瀵嗙敤锛屽缓璁細openssl rand -hex 32锛?ENCRYPTION_KEY=
# 绔彛锛堝彲閫夛紝榛樿宸插啓鍦?compose 涓級
# =========================
# 端口映射(非必须)
# =========================
# 后端映射到宿主机端口(host:container = BACKEND_PORT:SERVER_PORT
BACKEND_PORT=18080
# 前端映射到宿主机端口
FRONTEND_PORT=3000
# 鍓嶇璁块棶鍚庣 API 鐨勫湴鍧€锛堟祻瑙堝櫒鐩磋繛鐢紝涓?BACKEND_PORT 瀵瑰簲锛?NEXT_PUBLIC_API_BASE_URL=http://localhost:18080
# =========================
# 向量库 Milvus(按需配置)
# 仅在使用知识库/RAG时需要
# =========================
MILVUS_HOST=milvus-standalone
MILVUS_PORT=19530
MILVUS_USERNAME=
MILVUS_PASSWORD=
# 鍙€?GIN_MODE=release
# true 表示禁用向量库(不连接 Milvus)
MILVUS_DISABLED=false
# 与 MILVUS_DISABLED 含义相同,保留为兼容开关
VECTOR_STORE_DISABLED=false
# true 表示强依赖 Milvus(连接失败则启动失败)
MILVUS_REQUIRED=false
# =========================
# 联网搜索(按需配置)
# 使用联网搜索功能时至少配置一种
# =========================
# 方式一:Serper MCP 服务地址
SERPER_MCP_URL=
# 方式二:Serper API Key
SERPER_API_KEY=
# =========================
# 前端公开变量(按需配置)
# 说明:NEXT_PUBLIC_* 会暴露到浏览器端
# =========================
# 非必填:站点对外的绝对地址(https://你的域名),用于 SEOcanonical、OG、sitemap)。未填时前端默认使用演示站域名。
NEXT_PUBLIC_SITE_URL=
NEXT_PUBLIC_API_BASE_URL=http://localhost:18080
NEXT_PUBLIC_BACKEND_HOST=localhost
NEXT_PUBLIC_BACKEND_PORT=8080
NEXT_PUBLIC_MATOMO_CONTAINER_URL=
# =========================
# 预构建镜像(docker-compose.prod.yml 必填)
# =========================
BACKEND_IMAGE=537yaha/ai-cs-backend:latest
FRONTEND_IMAGE=537yaha/ai-cs-frontend:latest
+1 -3
View File
@@ -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/端口
+121 -362
View File
@@ -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 DesktopWindows/Mac)或 Docker + Docker ComposeLinux
#### 部署步骤
1. **克隆项目并进入目录**
#### 1)准备配置
```bash
git clone https://github.com/2930134478/AI-CS.git
cd AI-CS
```
2. **配置环境变量**
```bash
# 复制环境变量模板
cp .env.example .env
# 编辑 .env 文件,至少修改以下配置:
# - MYSQL_ROOT_PASSWORD: MySQL root 密码
# - ADMIN_PASSWORD: 管理员密码(首次登录使用)
# - ENCRYPTION_KEY: 加密密钥(生成 64 位十六进制字符串)
```
生成加密密钥
至少要改(必填)
- **数据库**`MYSQL_ROOT_PASSWORD``DB_PASSWORD`
- **管理员**`ADMIN_PASSWORD`
- **安全密钥**`ENCRYPTION_KEY`64 位 hex
生成 `ENCRYPTION_KEY`
```bash
# Linux/Mac
@@ -65,383 +59,148 @@ openssl rand -hex 32
-join ((48..57) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_})
```
3. **一键启动**
#### 2)启动
```bash
# 使用预构建镜像启动(自动从 Docker Hub 拉取镜像)
docker-compose -f docker-compose.prod.yml up -d
```
就这么简单!🎉
#### 3)访问
4. **访问应用**
- **官网首页**`http://localhost:3000`
- **访客聊天**`http://localhost:3000/chat`
- **客服登录**`http://localhost:3000/agent/login`
- 用户名:`admin`(或 `.env``ADMIN_USERNAME`
- 密码:`.env``ADMIN_PASSWORD`
- **前端首页**: http://localhost:3000
- **访客聊天**: http://localhost:3000/chat
- **客服登录**: http://localhost:3000/agent/login
- 用户名:`admin`(或 `.env` 中配置的 `ADMIN_USERNAME`
- 密码:`.env` 中配置的 `ADMIN_PASSWORD`
#### 端口修改(重要说明)
#### 端口配置
- 默认端口:前端 `3000`,后端对外 `18080`
- 修改:在 `.env` 里改 `FRONTEND_PORT` / `BACKEND_PORT`
**默认端口**:后端 `18080`,前端 `3000`
> 说明:预构建镜像在某些静态资源/图片路径场景可能与端口强绑定(历史兼容原因)。如果你需要彻底自定义端口并确保所有资源路径一致,建议用下面的“方式 B 本地构建”。
**修改端口**:在 `.env` 文件中设置 `BACKEND_PORT``FRONTEND_PORT`
⚠️ **注意**:预构建镜像的图片加载已硬编码为 `18080` 端口,如需修改端口,请使用方式二(本地构建)重新构建镜像。
#### 常用命令
```bash
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f
# 查看服务状态
docker-compose -f docker-compose.prod.yml ps
# 停止服务
docker-compose -f docker-compose.prod.yml stop
# 停止并删除容器(保留数据)
docker-compose -f docker-compose.prod.yml down
# 完全重置(删除所有数据)
docker-compose -f docker-compose.prod.yml down -v
```
---
### 方式二:Docker 本地构建部署
> 适合需要自定义构建或网络无法访问 Docker Hub 的情况。
#### 前置要求
- Docker DesktopWindows/Mac)或 Docker + Docker ComposeLinux
- Git
#### 部署步骤
1. **克隆项目**
### 方式 B:Docker 本地构建部署(可自定义)
```bash
git clone https://github.com/2930134478/AI-CS.git
cd AI-CS
cp .env.example .env
docker-compose up -d --build
```
2. **配置环境变量**
### 方式 C:传统部署(本地开发/手动安装)
环境要求:
- Go 1.24+
- Node.js 20.9.0+
- MySQL 8.0+
```bash
# 复制环境变量模板
git clone https://github.com/2930134478/AI-CS.git
cd AI-CS
cp .env.example .env
# 编辑 .env 文件,至少修改以下配置:
# - MYSQL_ROOT_PASSWORD: MySQL root 密码
# - ADMIN_PASSWORD: 管理员密码(首次登录使用)
# - ENCRYPTION_KEY: 加密密钥(生成 64 位十六进制字符串)
```
生成加密密钥:
```bash
# Linux/Mac
openssl rand -hex 32
# Windows PowerShell
-join ((48..57) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_})
```
3. **构建并启动服务**
```bash
# 构建并启动所有服务(首次构建需要一些时间)
docker-compose up -d --build
# 查看日志
docker-compose logs -f
# 查看服务状态
docker-compose ps
```
4. **访问应用**
- **前端首页**: http://localhost:3000
- **访客聊天**: http://localhost:3000/chat
- **客服登录**: http://localhost:3000/agent/login
- 用户名:`admin`(或 `.env` 中配置的 `ADMIN_USERNAME`
- 密码:`.env` 中配置的 `ADMIN_PASSWORD`
#### 端口配置
**默认端口**:后端 `18080`,前端 `3000`
**修改端口**:在 `.env` 文件中设置 `BACKEND_PORT``FRONTEND_PORT`,然后重新构建:
```bash
docker-compose up -d --build
```
#### 常用命令
```bash
# 停止服务
docker-compose stop
# 停止并删除容器(保留数据)
docker-compose down
# 完全重置(删除所有数据)
docker-compose down -v
# 查看日志
docker-compose logs -f backend
docker-compose logs -f frontend
```
---
### 方式三:传统部署(手动安装)
#### 环境要求
- Go 1.24 或更高版本
- Node.js 20.9.0+ 和 npm/yarnNext.js 16 要求)
- MySQL 8.0 或更高版本
#### 1. 克隆项目
```bash
git clone https://github.com/2930134478/AI-CS.git
cd AI-CS
```
#### 2. 配置后端
```bash
# 1) 后端
cd backend
# 创建 .env 文件
cat > .env << EOF
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=ai_cs
# 管理员账号配置(必填)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your_admin_password
# 服务器配置
SERVER_HOST=0.0.0.0
SERVER_PORT=18080 # 默认端口 18080,可修改
GIN_MODE=debug
# 加密密钥(用于加密 AI API Keys,可选)
ENCRYPTION_KEY=$(openssl rand -hex 32)
# Milvus 向量数据库配置(知识库功能需要)
MILVUS_HOST=localhost
MILVUS_PORT=19530
# 嵌入服务配置(知识库功能需要)
# 方式一:使用 OpenAI 嵌入服务
EMBEDDING_TYPE=api
EMBEDDING_API_URL=https://api.openai.com/v1
EMBEDDING_API_KEY=your_openai_api_key
EMBEDDING_MODEL=text-embedding-3-small
# 方式二:使用本地 BGE 嵌入服务
# EMBEDDING_TYPE=local
# BGE_API_URL=http://localhost:8080
# BGE_MODEL_NAME=bge-small-zh-v1.5
EOF
# 安装依赖
go mod tidy
# 启动服务(默认端口 8080
go run main.go
```
> ⚠️ **重要**`ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建默认管理员账号。
#### 3. 配置前端
```bash
cd frontend
# 安装依赖
# 2) 前端(新开终端)
cd ../frontend
npm install
# 启动开发服务器(默认端口 3000)
npm run dev
```
**端口配置**
- 后端端口:修改 `backend/.env` 中的 `SERVER_PORT`(默认 `8080`
- 前端端口:启动时通过 `PORT` 环境变量修改,如 `PORT=4000 npm run dev`
- 图片加载端口:创建 `frontend/.env.local`,设置 `NEXT_PUBLIC_BACKEND_PORT=你的后端端口`
## 配置字典(根目录 `/.env`
#### 4. 访问应用
> 下面表格以 `/.env.example` 为准,帮助你快速判断“必填/可选/什么时候需要填”。
- **官网首页**: http://localhost:3000
- **访客聊天**:
- 直接访问:http://localhost:3000/chat
- 或点击首页右下角的客服插件按钮
- **客服登录**: http://localhost:3000/agent/login
| 变量 | 用途 | 是否必填 | 默认值(示例) | 示例 |
|---|---|---|---|---|
| `APP_PROFILE` | 部署画像(`docker/local` | 否 | `docker` | `local` |
| `SERVER_HOST` | 后端监听地址 | 是 | `0.0.0.0` | `127.0.0.1` |
| `SERVER_PORT` | 后端容器内端口 | 是 | `8080` | `8080` |
| `GIN_MODE` | 后端模式 | 建议 | `release` | `debug` |
| `DB_HOST` | 后端数据库地址 | 是 | `mysql` | `localhost` |
| `DB_PORT` | 后端数据库端口 | 是 | `3306` | `3306` |
| `DB_USER` | 数据库用户名 | 是 | `ai_cs_user` | `root` |
| `DB_PASSWORD` | 数据库密码 | 是 | 无 | `StrongPwd` |
| `DB_NAME` | 数据库名 | 是 | `ai_cs` | `ai_cs` |
| `MYSQL_ROOT_PASSWORD` | MySQL root 密码(compose | 是(Docker | 无 | `RootPwd` |
| `MYSQL_PORT` | MySQL 对外端口(compose | 否 | `3306` | `13306` |
| `ADMIN_USERNAME` | 默认管理员用户名 | 否 | `admin` | `admin` |
| `ADMIN_PASSWORD` | 默认管理员密码 | 是 | 无 | `AdminPwd` |
| `ENCRYPTION_KEY` | 后端加密密钥(64位 hex) | 是 | 无 | `openssl rand -hex 32` |
| `BACKEND_PORT` | 后端映射到宿主机端口 | 否 | `18080` | `28080` |
| `FRONTEND_PORT` | 前端映射到宿主机端口 | 否 | `3000` | `13000` |
| `MILVUS_HOST` | 向量库地址 | 可选(启用 RAG) | `milvus-standalone` | `localhost` |
| `MILVUS_PORT` | 向量库端口 | 可选(启用 RAG) | `19530` | `19530` |
| `MILVUS_USERNAME` | Milvus 用户名 | 可选 | 空 | `user` |
| `MILVUS_PASSWORD` | Milvus 密码 | 可选 | 空 | `pass` |
| `MILVUS_DISABLED` | 禁用向量库(不连接) | 否 | `false` | `true` |
| `VECTOR_STORE_DISABLED` | 同上(兼容开关) | 否 | `false` | `true` |
| `MILVUS_REQUIRED` | 强依赖向量库(失败即退出) | 否 | `false` | `true` |
| `SERPER_MCP_URL` | 联网搜索 MCP 地址 | 可选(启用联网) | 空 | `http://host:3000/sse` |
| `SERPER_API_KEY` | 联网搜索 API Key | 可选(启用联网) | 空 | `xxxxx` |
| `NEXT_PUBLIC_SITE_URL` | 站点对外绝对地址(用于 SEO) | 否 | 空(默认 demo 域名) | `https://www.example.com` |
| `NEXT_PUBLIC_API_BASE_URL` | 前端公开 API 地址 | 建议 | `http://localhost:18080` | `https://api.example.com` |
| `NEXT_PUBLIC_BACKEND_HOST` | 前端 dev 代理目标 host | 否 | `localhost` | `127.0.0.1` |
| `NEXT_PUBLIC_BACKEND_PORT` | 前端 dev 代理目标 port | 否 | `8080` | `18080` |
| `NEXT_PUBLIC_MATOMO_CONTAINER_URL` | Matomo 脚本地址 | 可选 | 空 | `https://.../container.js` |
| `BACKEND_IMAGE` | 预构建后端镜像(prod compose | 是(prod | `537yaha/ai-cs-backend:latest` | `your/backend:tag` |
| `FRONTEND_IMAGE` | 预构建前端镜像(prod compose | 是(prod | `537yaha/ai-cs-frontend:latest` | `your/frontend:tag` |
#### 5. 默认管理员账号
## 启用/关闭知识库(RAG)的推荐做法
⚠️ **重要说明**
- **你暂时不想用知识库**:把 `.env``MILVUS_DISABLED=true`(或 `VECTOR_STORE_DISABLED=true`
- 应用仍可启动,AI 对话与人工客服不受影响
- **你必须依赖知识库**(生产强约束):把 `.env``MILVUS_REQUIRED=true`
- 此时如果 Milvus 不可用,会落库一条错误日志后退出,避免“半残服务上线”
系统会在首次启动时**自动创建**管理员账号(如果不存在),但**必须先在 `backend/.env` 文件中配置 `ADMIN_PASSWORD` 环境变量**。
## 集成访客小窗到你的网站(iframe)
**配置步骤**
1.`backend/.env` 文件中设置:
```env
ADMIN_USERNAME=admin # 可选,默认为 admin
ADMIN_PASSWORD=your_password # ⚠️ 必填,首次登录后请立即修改密码
```
2. 启动后端服务,系统会自动创建管理员账号
3. 使用配置的用户名和密码登录
**安全提示**
- 生产环境请使用强密码
- 首次登录后请立即修改密码
- `ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建管理员账号
### 后端环境变量
在 `backend/.env` 中配置:
```env
# 数据库配置
DB_HOST=localhost # 数据库主机
DB_PORT=3306 # 数据库端口
DB_USER=root # 数据库用户名
DB_PASSWORD=your_password # 数据库密码
DB_NAME=ai_cs # 数据库名称
# 管理员账号配置
ADMIN_USERNAME=admin # 管理员用户名(可选,默认为 admin)
ADMIN_PASSWORD=your_admin_password # ⚠️ 管理员密码(必填)
# 服务器配置
SERVER_HOST=0.0.0.0 # 服务器监听地址
SERVER_PORT=8080 # 服务器端口
GIN_MODE=debug # 运行模式(debug/release
# 加密密钥(用于加密 AI API Keys
ENCRYPTION_KEY=your_32_byte_key # 使用 openssl rand -hex 32 生成
```
**重要提示**
- `ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建默认管理员账号
- 生产环境请使用强密码并设置 `GIN_MODE=release`
## 🔌 集成客服插件到你的网站
#### 步骤 1:在 HTML 中添加代码
在你的网站 HTML 的 `</body>` 标签之前添加:
把下面代码放到你网站的 `</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 的域名)。
- **提示音听不到**:浏览器通常需要“用户一次交互”才能解锁音频;请先点一下页面任意按钮/再打开喇叭开关测试
- **向量库连不上导致启动失败**:检查 `.env``MILVUS_REQUIRED` 是否误开;不需要知识库时建议 `MILVUS_DISABLED=true`
- **搜不到站点/分享卡片不正确**:设置 `NEXT_PUBLIC_SITE_URL=https://你的域名`,用于 canonical / OG / sitemap 生成
**示例**
```html
<!-- 如果你的 AI-CS 部署在 https://cs.example.com -->
<iframe src="https://cs.example.com/chat" ...></iframe>
```
## 贡献
### 响应式设计
欢迎提交 Issue 和 Pull Request。
插件会自动适配不同设备:
- **移动端**:小窗宽度自适应,最大高度优化
- **平板端**:中等尺寸窗口
- **桌面端**:完整尺寸窗口
### 自定义样式
如果需要自定义样式,可以通过 CSS 覆盖:
```css
/* 自定义浮动按钮 */
#ai-cs-toggle-btn {
background-color: #your-color !important;
width: 60px !important;
height: 60px !important;
}
/* 自定义聊天窗口 */
#ai-cs-chat-iframe {
border-radius: 16px !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
}
```
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 许可证
## 许可证
[MIT](LICENSE) © 2025 2930134478
## 🙏 致谢
感谢所有为这个项目做出贡献的开发者!
---
**最后更新**: 2025-01-12
**最后更新**2026-03-25
-13
View File
@@ -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
-28
View File
@@ -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 涓庡湴鍧€銆?# --------------------------------------------
@@ -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})
}
@@ -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
View File
@@ -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
View File
@@ -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)
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,105 @@
// Package mcp 提供 MCPModel 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
"已跳过 MilvusMILVUS_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 调用 SerperSERPER_MCP_URL),否则使用 Serper HTTP APISERPER_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("✅ 联网搜索已通过 MCPSerper)接入")
}
}
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"`
}
@@ -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
}
@@ -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
}
@@ -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
}
+94 -73
View File
@@ -14,100 +14,121 @@ type ControllerSet struct {
Profile *controller.ProfileController
AIConfig *controller.AIConfigController
EmbeddingConfig *controller.EmbeddingConfigController
PromptConfig *controller.PromptConfigController
FAQ *controller.FAQController
Document *controller.DocumentController
KnowledgeBase *controller.KnowledgeBaseController
Import *controller.ImportController
Visitor *controller.VisitorController
Health *controller.HealthController
Analytics *controller.AnalyticsController
SystemLog *controller.SystemLogController
}
// RegisterRoutes 注册 HTTP 路由及对应的处理函数。
func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.HandlerFunc) {
// Auth
r.POST("/login", controllers.Auth.Login)
r.POST("/logout", controllers.Auth.Logout)
register := func(routes gin.IRoutes) {
// Auth
routes.POST("/login", controllers.Auth.Login)
routes.POST("/logout", controllers.Auth.Logout)
// Conversation
r.POST("/conversation/init", controllers.Conversation.InitConversation)
r.POST("/conversations/internal", controllers.Conversation.InitInternalConversation) // 创建内部对话(知识库测试)
r.GET("/conversations", controllers.Conversation.ListConversations)
r.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
r.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
r.GET("/conversations/search", controllers.Conversation.SearchConversations)
r.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
// Conversation
routes.POST("/conversation/init", controllers.Conversation.InitConversation)
routes.POST("/conversations/internal", controllers.Conversation.InitInternalConversation) // 创建内部对话(知识库测试)
routes.GET("/conversations", controllers.Conversation.ListConversations)
routes.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
routes.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
routes.GET("/conversations/search", controllers.Conversation.SearchConversations)
routes.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
// Message
r.POST("/messages", controllers.Message.CreateMessage)
r.POST("/messages/upload", controllers.Message.UploadFile) // 文件上传接口(支持客服和访客上传)
r.GET("/messages", controllers.Message.ListMessages)
r.PUT("/messages/read", controllers.Message.MarkMessagesRead)
// Message
routes.POST("/messages", controllers.Message.CreateMessage)
routes.POST("/messages/upload", controllers.Message.UploadFile) // 文件上传接口(支持客服和访客上传)
routes.GET("/messages", controllers.Message.ListMessages)
routes.PUT("/messages/read", controllers.Message.MarkMessagesRead)
// Admin(用户管理)
r.GET("/admin/users", controllers.Admin.ListUsers) // 获取所有用户列表
r.GET("/admin/users/:id", controllers.Admin.GetUser) // 获取用户详情
r.POST("/admin/users", controllers.Admin.CreateUser) // 创建新用户
r.PUT("/admin/users/:id", controllers.Admin.UpdateUser) // 更新用户信息
r.DELETE("/admin/users/:id", controllers.Admin.DeleteUser) // 删除用户
r.PUT("/admin/users/:id/password", controllers.Admin.UpdateUserPassword) // 更新用户密码
// 兼容旧接口
r.POST("/admin/agents", controllers.Admin.CreateAgent) // 创建客服(兼容旧接口)
// Admin(用户管理)
routes.GET("/admin/users", controllers.Admin.ListUsers) // 获取所有用户列表
routes.GET("/admin/users/:id", controllers.Admin.GetUser) // 获取用户详情
routes.POST("/admin/users", controllers.Admin.CreateUser) // 创建新用户
routes.PUT("/admin/users/:id", controllers.Admin.UpdateUser) // 更新用户信息
routes.DELETE("/admin/users/:id", controllers.Admin.DeleteUser) // 删除用户
routes.PUT("/admin/users/:id/password", controllers.Admin.UpdateUserPassword) // 更新用户密码
// 兼容旧接口
routes.POST("/admin/agents", controllers.Admin.CreateAgent) // 创建客服(兼容旧接口)
// Profile(个人资料)
r.GET("/agent/profile/:user_id", controllers.Profile.GetProfile)
r.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile)
r.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar)
// Profile(个人资料)
routes.GET("/agent/profile/:user_id", controllers.Profile.GetProfile)
routes.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile)
routes.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar)
// AI ConfigAI 配置)
r.POST("/agent/ai-config/:user_id", controllers.AIConfig.CreateAIConfig)
r.GET("/agent/ai-config/:user_id", controllers.AIConfig.ListAIConfigs)
r.GET("/agent/ai-config/:user_id/:id", controllers.AIConfig.GetAIConfig)
r.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig)
r.DELETE("/agent/ai-config/:user_id/:id", controllers.AIConfig.DeleteAIConfig)
// AI ConfigAI 配置)
routes.POST("/agent/ai-config/:user_id", controllers.AIConfig.CreateAIConfig)
routes.GET("/agent/ai-config/:user_id", controllers.AIConfig.ListAIConfigs)
routes.GET("/agent/ai-config/:user_id/:id", controllers.AIConfig.GetAIConfig)
routes.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig)
routes.DELETE("/agent/ai-config/:user_id/:id", controllers.AIConfig.DeleteAIConfig)
// Embedding Config(知识库向量模型配置,平台级)
r.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get)
r.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update)
// Embedding Config(知识库向量模型配置,平台级)
routes.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get)
routes.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update)
// FAQ(事件管理/常见问题
r.GET("/faqs", controllers.FAQ.ListFAQs) // 获取 FAQ 列表(支持关键词搜索)
r.GET("/faqs/:id", controllers.FAQ.GetFAQ) // 获取 FAQ 详情
r.POST("/faqs", controllers.FAQ.CreateFAQ) // 创建 FAQ
r.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ) // 更新 FAQ
r.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ) // 删除 FAQ
// Prompt Config(提示词配置,平台级,仅管理员可更新
routes.GET("/agent/prompts", controllers.PromptConfig.Get)
routes.PUT("/agent/prompts", controllers.PromptConfig.Update)
// Document(文档管理
r.GET("/documents", controllers.Document.ListDocuments) // 获取文档列表(支持分页、搜索、状态过滤
r.GET("/documents/:id", controllers.Document.GetDocument) // 获取文档详情
r.POST("/documents", controllers.Document.CreateDocument) // 创建文档
r.PUT("/documents/:id", controllers.Document.UpdateDocument) // 更新文档
r.DELETE("/documents/:id", controllers.Document.DeleteDocument) // 删除文档
r.GET("/documents/search", controllers.Document.SearchDocuments) // 向量检索搜索文档
r.GET("/documents/hybrid-search", controllers.Document.HybridSearchDocuments) // 混合检索搜索文档
r.PUT("/documents/:id/status", controllers.Document.UpdateDocumentStatus) // 更新文档状态
r.POST("/documents/:id/publish", controllers.Document.PublishDocument) // 发布文档
r.POST("/documents/:id/unpublish", controllers.Document.UnpublishDocument) // 取消发布文档
// FAQ(事件管理/常见问题
routes.GET("/faqs", controllers.FAQ.ListFAQs) // 获取 FAQ 列表(支持关键词搜索
routes.GET("/faqs/:id", controllers.FAQ.GetFAQ) // 获取 FAQ 详情
routes.POST("/faqs", controllers.FAQ.CreateFAQ) // 创建 FAQ
routes.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ) // 更新 FAQ
routes.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ) // 删除 FAQ
// KnowledgeBase(知识库管理)
r.GET("/knowledge-bases", controllers.KnowledgeBase.ListKnowledgeBases) // 获取知识库列表
r.GET("/knowledge-bases/:id", controllers.KnowledgeBase.GetKnowledgeBase) // 获取知识库详情
r.POST("/knowledge-bases", controllers.KnowledgeBase.CreateKnowledgeBase) // 创建知识库
r.PUT("/knowledge-bases/:id", controllers.KnowledgeBase.UpdateKnowledgeBase) // 更新知识库
r.PATCH("/knowledge-bases/:id/rag-enabled", controllers.KnowledgeBase.UpdateKnowledgeBaseRAGEnabled) // 知识库是否参与 RAG
r.DELETE("/knowledge-bases/:id", controllers.KnowledgeBase.DeleteKnowledgeBase) // 删除知识库
r.GET("/knowledge-bases/:id/documents", controllers.KnowledgeBase.ListDocumentsByKnowledgeBase) // 获取知识库的文档列表
// Document(文档管理)
routes.GET("/documents", controllers.Document.ListDocuments) // 获取文档列表(支持分页、搜索、状态过滤)
routes.GET("/documents/:id", controllers.Document.GetDocument) // 获取文档详情
routes.POST("/documents", controllers.Document.CreateDocument) // 创建文档
routes.PUT("/documents/:id", controllers.Document.UpdateDocument) // 更新文档
routes.DELETE("/documents/:id", controllers.Document.DeleteDocument) // 删除文档
routes.GET("/documents/search", controllers.Document.SearchDocuments) // 向量检索搜索文档
routes.GET("/documents/hybrid-search", controllers.Document.HybridSearchDocuments) // 混合检索搜索文档
routes.PUT("/documents/:id/status", controllers.Document.UpdateDocumentStatus) // 更新文档状态
routes.POST("/documents/:id/publish", controllers.Document.PublishDocument) // 发布文档
routes.POST("/documents/:id/unpublish", controllers.Document.UnpublishDocument) // 取消发布文档
// Import(文档导入
r.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传)
r.POST("/import/urls", controllers.Import.ImportFromURLs) // 批量导入文档(URL 爬取)
// KnowledgeBase(知识库管理
routes.GET("/knowledge-bases", controllers.KnowledgeBase.ListKnowledgeBases) // 获取知识库列表
routes.GET("/knowledge-bases/:id", controllers.KnowledgeBase.GetKnowledgeBase) // 获取知识库详情
routes.POST("/knowledge-bases", controllers.KnowledgeBase.CreateKnowledgeBase) // 创建知识库
routes.PUT("/knowledge-bases/:id", controllers.KnowledgeBase.UpdateKnowledgeBase) // 更新知识库
routes.PATCH("/knowledge-bases/:id/rag-enabled", controllers.KnowledgeBase.UpdateKnowledgeBaseRAGEnabled) // 知识库是否参与 RAG
routes.DELETE("/knowledge-bases/:id", controllers.KnowledgeBase.DeleteKnowledgeBase) // 删除知识库
routes.GET("/knowledge-bases/:id/documents", controllers.KnowledgeBase.ListDocumentsByKnowledgeBase) // 获取知识库的文档列表
// Visitor(访客相关
r.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表
// Import(文档导入
routes.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传)
routes.POST("/import/urls", controllers.Import.ImportFromURLs) // 批量导入文档(URL 爬取)
// Health(健康检查
r.GET("/health", controllers.Health.HealthCheck) // 健康检查
r.GET("/health/metrics", controllers.Health.Metrics) // 性能指标
// Visitor(访客相关
routes.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表
routes.GET("/visitor/widget-config", controllers.Visitor.GetWidgetConfig) // 访客小窗配置(联网设置等,无需登录)
routes.POST("/visitor/analytics/widget-open", controllers.Analytics.PostWidgetOpen) // 访客打开小窗埋点
// WebSocket
r.GET("/ws", wsHandler)
// Analytics(数据分析报表,需客服 X-User-Id)
routes.GET("/agent/analytics/summary", controllers.Analytics.GetSummary)
routes.GET("/agent/logs/api", controllers.SystemLog.GetLogs) // 日志查询(避免与前端 /agent/logs 页面路径冲突)
routes.POST("/agent/logs/frontend", controllers.SystemLog.ReportFrontendLog) // 前端日志上报
// Health(健康检查)
routes.GET("/health", controllers.Health.HealthCheck) // 健康检查
routes.GET("/health/metrics", controllers.Health.Metrics) // 性能指标
// WebSocket
routes.GET("/ws", wsHandler)
}
// 兼容旧路径(无前缀)
register(r)
// 新路径:/api 前缀,便于反向代理“同域 API”
register(r.Group("/api"))
}
+412 -46
View File
@@ -2,11 +2,14 @@ package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
@@ -14,10 +17,11 @@ import (
// 不同的 AI 服务提供商需要实现这个接口
type AIProvider interface {
// GenerateResponse 生成 AI 回复
// conversationHistory: 对话历史(用于上下文)
// userMessage: 用户当前消息
// 返回: AI 回复内容
GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error)
// imageBase64、imageMimeType 非空时表示当前用户消息带一张图(多模态识图),将与本条文本一起作为 user 消息发送
GenerateResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error)
// GenerateResponseWithTools 带工具调用的生成;messages 与 tools 为 OpenAI 格式。返回 content、tool_calls、error。
// 若某实现不支持,可返回 ( "", nil, err ) 或仅返回 content。
GenerateResponseWithTools(messages []map[string]interface{}, tools []map[string]interface{}) (content string, toolCalls []ToolCall, err error)
}
// AdapterConfig 适配器配置(用于适配不同服务商的 API 格式差异)
@@ -36,6 +40,13 @@ type MessageHistory struct {
Content string `json:"content"` // 消息内容
}
// ToolCall 模型返回的工具调用(OpenAI 格式)
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"` // JSON 字符串
}
// AIConfig 用于 AI 调用的配置信息
type AIConfig struct {
APIURL string
@@ -50,8 +61,8 @@ type AIConfig struct {
// 通过适配器配置来适配不同服务商的细微差异
// 这样 90% 的服务商都可以用同一个 Provider,无需单独实现
type UniversalAIProvider struct {
config AIConfig
client *http.Client
config AIConfig
client *http.Client
adapter *AdapterConfig
}
@@ -61,8 +72,8 @@ func NewUniversalAIProvider(config AIConfig) *UniversalAIProvider {
adapter := config.AdapterConfig
if adapter == nil {
adapter = &AdapterConfig{
AuthHeader: "Bearer", // 默认使用 Bearer Token
ResponsePath: "choices[0].message.content", // 默认 OpenAI 格式
AuthHeader: "Bearer", // 默认使用 Bearer Token
ResponsePath: "choices[0].message.content", // 默认 OpenAI 格式
}
} else {
// 设置默认值
@@ -75,57 +86,74 @@ func NewUniversalAIProvider(config AIConfig) *UniversalAIProvider {
}
return &UniversalAIProvider{
config: config,
config: config,
client: &http.Client{
Timeout: 30 * time.Second, // 30 秒超时
Timeout: 60 * time.Second, // 60 秒超时
},
adapter: adapter,
}
}
// isResponsesAPI 判断是否为 OpenAI Responses API/v1/responses),请求/响应格式与 Chat Completions 不同。
func isResponsesAPI(apiURL string) bool {
return strings.Contains(apiURL, "/v1/responses")
}
// GenerateResponse 生成 AI 回复(支持 OpenAI 兼容格式,通过适配器适配不同服务商)。
func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
// 根据模型类型选择不同的处理逻辑
func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error) {
switch p.config.ModelType {
case "text":
return p.generateTextResponse(conversationHistory, userMessage)
return p.generateTextResponse(conversationHistory, userMessage, imageBase64, imageMimeType)
case "image":
// 图片生成(未来扩展)
return "", fmt.Errorf("图片模型暂未支持")
return "", fmt.Errorf("图片模型请使用生图接口")
case "audio":
// 语音识别/合成(未来扩展)
return "", fmt.Errorf("语音模型暂未支持")
case "video":
// 视频生成(未来扩展)
return "", fmt.Errorf("视频模型暂未支持")
default:
return "", fmt.Errorf("不支持的模型类型: %s", p.config.ModelType)
}
}
// generateTextResponse 生成文本回复(通用实现,支持所有 OpenAI 兼容格式)。
func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
// 构建消息列表(包含历史对话和当前消息)
messages := make([]map[string]string, 0)
// 添加历史对话
for _, history := range conversationHistory {
messages = append(messages, map[string]string{
"role": history.Role,
"content": history.Content,
})
// buildUserContent 构建当前用户消息的 content:纯文本或 text+image(多模态)
func buildUserContent(userMessage string, imageBase64 string, imageMimeType string) interface{} {
if imageBase64 == "" {
return userMessage
}
// OpenAI 多模态:content 为数组,text + image_urldata URL
dataURL := "data:" + imageMimeType + ";base64," + imageBase64
if imageMimeType == "" {
dataURL = "data:image/jpeg;base64," + imageBase64
}
parts := []map[string]interface{}{
{"type": "text", "text": userMessage},
{"type": "image_url", "image_url": map[string]string{"url": dataURL}},
}
return parts
}
// 添加当前用户消息
messages = append(messages, map[string]string{
"role": "user",
"content": userMessage,
})
// generateTextResponse 生成文本回复(支持多模态:当前用户消息可带图)。
func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error) {
// 使用 interface{} 以支持最后一条 user 消息的 content 为数组(多模态)
messages := make([]map[string]interface{}, 0)
for _, history := range conversationHistory {
messages = append(messages, map[string]interface{}{"role": history.Role, "content": history.Content})
}
lastContent := buildUserContent(userMessage, imageBase64, imageMimeType)
messages = append(messages, map[string]interface{}{"role": "user", "content": lastContent})
// 构建请求体(OpenAI 兼容格式)
requestBody := map[string]interface{}{
"model": p.config.Model,
"messages": messages,
var requestBody map[string]interface{}
if isResponsesAPI(p.config.APIURL) {
requestBody = map[string]interface{}{
"model": p.config.Model,
"input": messages,
"stream": false,
}
} else {
requestBody = map[string]interface{}{
"model": p.config.Model,
"messages": messages,
}
}
jsonData, err := json.Marshal(requestBody)
@@ -141,7 +169,7 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message
// 设置请求头
req.Header.Set("Content-Type", "application/json")
// 根据适配器配置设置认证头
authValue := p.config.APIKey
if p.adapter.AuthHeader == "Bearer" {
@@ -154,9 +182,11 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message
req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
}
// 发送请求
// 发送请求(若发生重定向,req.URL 会被 Client 更新为最终 URL;失败日志便于与配置里的 api_url 对照)
resp, err := p.client.Do(req)
if err != nil {
log.Printf("⚠️ AI generateTextResponse 请求失败: config.api_url=%s 实际 req.URL=%s err=%v",
p.config.APIURL, req.URL.String(), err)
return "", fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
@@ -198,12 +228,194 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message
return content, nil
}
// GenerateResponseWithTools 带工具调用的生成(OpenAI 兼容:tools + tool_calls)。
// messages 为 OpenAI 格式消息数组(可含 role, content, tool_calls, tool_call_id 等)。
// tools 为工具定义数组(如 [{"type":"function","function":{...}}] 或 [{"type":"web_search"}])。
// 返回 content、tool_calls(若有)、error。
func (p *UniversalAIProvider) GenerateResponseWithTools(messages []map[string]interface{}, tools []map[string]interface{}) (content string, toolCalls []ToolCall, err error) {
if p.config.ModelType != "text" {
return "", nil, fmt.Errorf("带工具调用仅支持 text 模型")
}
var requestBody map[string]interface{}
if isResponsesAPI(p.config.APIURL) {
requestBody = map[string]interface{}{
"model": p.config.Model,
"input": messages,
"stream": false,
"tool_choice": "auto",
}
if len(tools) > 0 {
requestBody["tools"] = tools
}
} else {
requestBody = map[string]interface{}{
"model": p.config.Model,
"messages": messages,
}
if len(tools) > 0 {
requestBody["tools"] = tools
}
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return "", nil, fmt.Errorf("序列化请求失败: %v", err)
}
req, err := http.NewRequest("POST", p.config.APIURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", nil, fmt.Errorf("创建请求失败: %v", err)
}
req.Header.Set("Content-Type", "application/json")
authValue := p.config.APIKey
if p.adapter.AuthHeader == "Bearer" {
authValue = "Bearer " + p.config.APIKey
req.Header.Set("Authorization", authValue)
} else if p.adapter.AuthHeader == "X-API-Key" {
req.Header.Set("X-API-Key", p.config.APIKey)
} else {
req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
}
resp, err := p.client.Do(req)
if err != nil {
log.Printf("⚠️ AI GenerateResponseWithTools 请求失败: config.api_url=%s 实际 req.URL=%s err=%v",
p.config.APIURL, req.URL.String(), err)
return "", nil, fmt.Errorf("请求失败: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("API 返回错误: %s (状态码: %d)", string(body), resp.StatusCode)
}
var responseData map[string]interface{}
if err := json.Unmarshal(body, &responseData); err != nil {
return "", nil, fmt.Errorf("解析响应失败: %v", err)
}
if errorMsg, ok := responseData["error"].(map[string]interface{}); ok {
if msg, ok := errorMsg["message"].(string); ok {
return "", nil, fmt.Errorf("API 错误: %s", msg)
}
}
content, toolCalls = p.extractContentAndToolCalls(responseData)
return content, toolCalls, nil
}
// extractContentAndToolCalls 从响应中提取 content 与 tool_calls
// 支持 Chat Completionschoices[0].message)与 Responses APIoutput[]
func (p *UniversalAIProvider) extractContentAndToolCalls(data map[string]interface{}) (content string, toolCalls []ToolCall) {
// Responses APIoutput 数组内 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.contentOpenAI 格式)
// Responses APIoutput 数组内 messagecontent 中 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.contentOpenAI 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 APIHTTP/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 BananaGemini 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 BananaGoogle 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")
}
+614 -61
View File
@@ -1,13 +1,18 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/2930134478/AI-CS/backend/infra"
"github.com/2930134478/AI-CS/backend/infra/search"
"github.com/2930134478/AI-CS/backend/models"
"github.com/2930134478/AI-CS/backend/repository"
"github.com/2930134478/AI-CS/backend/service/rag"
@@ -17,102 +22,181 @@ import (
// AIService AI 服务(负责调用 AI 生成回复)
type AIService struct {
aiConfigRepo *repository.AIConfigRepository
messageRepo *repository.MessageRepository
conversationRepo *repository.ConversationRepository
retrievalService *rag.RetrievalService // RAG 检索服务
providerFactory *AIProviderFactory
aiConfigRepo *repository.AIConfigRepository
messageRepo *repository.MessageRepository
conversationRepo *repository.ConversationRepository
retrievalService *rag.RetrievalService
providerFactory *AIProviderFactory
webSearchProvider search.WebSearchProvider // 可选,自建联网时用
embeddingConfigSvc *EmbeddingConfigService // 读取联网方式:厂商内置 / 自建
promptConfigSvc *PromptConfigService // 可选,提示词配置(为空则用代码内默认)
storageService infra.StorageService // 可选,用于多模态识图时读取消息附件
systemLogSvc *SystemLogService // 可选,结构化日志服务
}
// NewAIService 创建 AI 服务实例。
// NewAIService 创建 AI 服务实例。webSearchProvider、storageService 可为 nil。
func NewAIService(
aiConfigRepo *repository.AIConfigRepository,
messageRepo *repository.MessageRepository,
conversationRepo *repository.ConversationRepository,
retrievalService *rag.RetrievalService, // 添加 RAG 检索服务
retrievalService *rag.RetrievalService,
webSearchProvider search.WebSearchProvider,
embeddingConfigSvc *EmbeddingConfigService,
promptConfigSvc *PromptConfigService,
storageService infra.StorageService,
systemLogSvc *SystemLogService,
) *AIService {
return &AIService{
aiConfigRepo: aiConfigRepo,
messageRepo: messageRepo,
conversationRepo: conversationRepo,
retrievalService: retrievalService,
providerFactory: NewAIProviderFactory(),
aiConfigRepo: aiConfigRepo,
messageRepo: messageRepo,
conversationRepo: conversationRepo,
retrievalService: retrievalService,
providerFactory: NewAIProviderFactory(),
webSearchProvider: webSearchProvider,
embeddingConfigSvc: embeddingConfigSvc,
promptConfigSvc: promptConfigSvc,
storageService: storageService,
systemLogSvc: systemLogSvc,
}
}
// GenerateAIResponse 为对话生成 AI 回复。
// conversationID: 对话ID
// userMessage: 用户消息
// userID: 用户ID(用于回退查找 AI 配置)
// 返回: AI 回复内容,如果失败返回错误
// GenerateAIResponse 为对话生成 AI 回复(兼容旧调用,使用默认数据源选项)
// 返回: AI 回复内容,若失败返回错误。
func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, userID uint) (string, error) {
// 1. 获取对话信息,优先使用对话绑定的 AI 配置
conversation, err := s.conversationRepo.GetByID(conversationID)
res, err := s.GenerateAIResponseWithOptions(conversationID, userMessage, userID, nil)
if err != nil {
return "", fmt.Errorf("获取对话失败: %v", err)
return "", err
}
return res.Content, nil
}
// GenerateAIResponseWithOptions 根据数据源开关生成一条合成回复,并返回使用的来源标记。
// opts 为 nil 时使用默认:知识库+大模型开,联网关。
func (s *AIService) GenerateAIResponseWithOptions(conversationID uint, userMessage string, userID uint, opts *GenerateAIResponseInput) (*GenerateAIResponseResult, error) {
useKB := true
useLLM := true
useWeb := false
needWeb := false
if opts != nil {
if opts.UseKnowledgeBase != nil {
useKB = *opts.UseKnowledgeBase
}
if opts.UseLLM != nil {
useLLM = *opts.UseLLM
}
if opts.UseWebSearch != nil {
useWeb = *opts.UseWebSearch
}
needWeb = opts.NeedWebSearch
}
conversation, err := s.conversationRepo.GetByID(conversationID)
if err != nil {
return nil, fmt.Errorf("获取对话失败: %v", err)
}
// 以下 config 为「AI 配置」:对话/联网均使用此接口;与「知识库向量配置」(embedding,如 nekoai)无关。
var config *models.AIConfig
if conversation.AIConfigID != nil {
// 使用对话绑定的配置(多厂商支持)
config, err = s.aiConfigRepo.GetByID(*conversation.AIConfigID)
if err != nil {
return "", fmt.Errorf("获取 AI 配置失败: %v", err)
return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
}
// 验证配置是否启用
if !config.IsActive {
return "", errors.New("该模型配置已禁用")
return nil, errors.New("该模型配置已禁用")
}
} else {
// 回退:使用用户默认配置(向后兼容)
config, err = s.aiConfigRepo.GetActiveByUserID(userID, "text")
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
return nil, errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
}
return "", fmt.Errorf("获取 AI 配置失败: %v", err)
return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
}
}
// 2. 解密 API Key
apiKey, err := utils.DecryptAPIKey(config.APIKey)
if err != nil {
return "", fmt.Errorf("解密 API Key 失败: %v", err)
return nil, fmt.Errorf("解密 API Key 失败: %v", err)
}
// 若当前 AI 配置为生图模型(model_type=image),则直接走生图逻辑,
// 不参与 RAG/联网与文本对话流程。前端仍显示在「AI 客服」渠道下。
if config.ModelType == "image" {
log.Printf("[生图] 对话ID=%d 使用 model_type=image 配置 id=%d,走 GenerateImageReply", conversationID, config.ID)
return s.GenerateImageReply(conversationID, userMessage, userID)
}
// 调试:确认本条对话实际使用的 AI 配置(便于排查联网/厂商内置是否走对接口)
if needWeb || useWeb {
convAIConfigID := "nil"
if conversation.AIConfigID != nil {
convAIConfigID = fmt.Sprintf("%d", *conversation.AIConfigID)
}
apiURLMask := config.APIURL
if len(apiURLMask) > 50 {
apiURLMask = apiURLMask[:50] + "..."
}
log.Printf("[联网] 对话ID=%d 使用的AI配置: conversation.ai_config_id=%s, config.id=%d, provider=%s, api_url=%s",
conversationID, convAIConfigID, config.ID, config.Provider, apiURLMask)
}
// 3. 获取对话历史(用于上下文)
history, err := s.buildConversationHistory(conversationID)
if err != nil {
log.Printf("⚠️ 获取对话历史失败: %v", err)
// 即使获取历史失败,也继续处理(使用空历史)
history = []MessageHistory{}
}
// 4. RAG 检索:从知识库中检索相关文档
ragContext := ""
if s.retrievalService != nil {
// 多模态识图:当前条带图时读取文件并转 base64 供 provider 使用
var imageBase64, imageMimeType string
if opts != nil && opts.Attachment != nil && opts.Attachment.FileType == "image" && opts.Attachment.FileURL != "" && s.storageService != nil {
data, err := s.storageService.ReadMessageFile(opts.Attachment.FileURL)
if err != nil {
log.Printf("⚠️ 读取消息图片失败: %v", err)
} else {
imageBase64 = base64.StdEncoding.EncodeToString(data)
imageMimeType = opts.Attachment.MimeType
if imageMimeType == "" {
imageMimeType = "image/jpeg"
}
}
}
var ragContext string
ragStartedAt := time.Now()
if useKB && s.retrievalService != nil {
ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation)
if err != nil {
log.Printf("⚠️ RAG 检索失败: %v,继续使用无知识库上下文", err)
// RAG 检索失败不影响主流程,继续处理
log.Printf("⚠️ RAG 检索失败: %v", err)
}
if s.systemLogSvc != nil {
hit := strings.TrimSpace(ragContext) != ""
convID := conversationID
uID := userID
_ = s.systemLogSvc.Create(CreateSystemLogInput{
Level: "info",
Category: "rag",
Event: "rag_context_result",
Source: "backend",
ConversationID: &convID,
UserID: &uID,
Message: "RAG 检索完成",
Meta: map[string]interface{}{
"hit": hit,
"context_len": len(ragContext),
"elapsed_ms": time.Since(ragStartedAt).Milliseconds(),
"use_kb": useKB,
"need_web": needWeb,
"use_web": useWeb,
},
})
}
}
// 5. 构建增强的用户消息(包含 RAG 上下文)
enhancedUserMessage := userMessage
if ragContext != "" {
enhancedUserMessage = s.buildRAGPrompt(userMessage, ragContext)
}
// 6. 解析适配器配置(如果有)
var adapterConfig *AdapterConfig
if config.AdapterConfig != "" {
if err := json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig); err != nil {
log.Printf("⚠️ 解析适配器配置失败: %v,使用默认配置", err)
}
_ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig)
}
// 7. 创建 AI 提供商
aiConfig := AIConfig{
APIURL: config.APIURL,
APIKey: apiKey,
@@ -121,21 +205,330 @@ func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string,
Provider: config.Provider,
AdapterConfig: adapterConfig,
}
provider, err := s.providerFactory.CreateProvider(aiConfig)
if err != nil {
return "", fmt.Errorf("创建 AI 提供商失败: %v", err)
return nil, fmt.Errorf("创建 AI 提供商失败: %v", err)
}
// 8. 调用 AI 生成回复(使用增强的消息)
response, err := provider.GenerateResponse(history, enhancedUserMessage)
var sources []string
enhancedMessage := userMessage
// 1) 有知识库匹配:以知识库为主生成;若本回合允许联网,则用增强 prompt + 联网工具,由模型在无关/不足时用自身知识或联网
if ragContext != "" {
sources = append(sources, "knowledge_base")
if needWeb && useWeb {
webSource := "custom"
if s.embeddingConfigSvc != nil {
webSource, _ = s.embeddingConfigSvc.GetWebSearchSource()
}
enhancedMessage = s.buildRAGPromptWithWebOptional(userMessage, ragContext)
content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, enhancedMessage, webSource, imageBase64, imageMimeType)
if err != nil {
log.Printf("⚠️ RAG+联网(function calling)失败: %v,回退到仅 RAG", err)
if s.systemLogSvc != nil {
_ = s.systemLogSvc.Create(CreateSystemLogInput{
Level: "warn",
Category: "ai",
Event: "rag_web_fallback",
Source: "backend",
ConversationID: &conversationID,
UserID: &userID,
Message: "RAG+联网失败,回退到仅RAG",
Meta: map[string]interface{}{
"error": err.Error(),
"web_source": webSource,
"ai_config": config.ID,
},
})
}
if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) {
log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。")
}
enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
} else if content != "" {
sources = append(sources, "llm")
if usedWeb {
sources = append(sources, "web")
}
if s.systemLogSvc != nil {
convID := conversationID
uID := userID
_ = s.systemLogSvc.Create(CreateSystemLogInput{
Level: "info",
Category: "ai",
Event: "ai_web_success",
Source: "backend",
ConversationID: &convID,
UserID: &uID,
Message: "RAG+联网生成成功",
Meta: map[string]interface{}{
"sources": strings.Join(sources, ","),
},
})
}
return &GenerateAIResponseResult{
Content: content,
SourcesUsed: strings.Join(sources, ","),
}, nil
} else {
enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
}
} else {
enhancedMessage = s.buildRAGPrompt(userMessage, ragContext)
}
} else {
// 2) 无知识库匹配:本回合允许联网时走「模型决定搜」function calling;否则仅用大模型知识
if needWeb && useWeb {
webSource := "custom"
if s.embeddingConfigSvc != nil {
webSource, _ = s.embeddingConfigSvc.GetWebSearchSource()
}
content, usedWeb, err := s.generateWithWebTools(context.Background(), provider, history, userMessage, webSource, imageBase64, imageMimeType)
if err != nil {
log.Printf("⚠️ 联网(function calling)失败: %v,回退到仅大模型", err)
if s.systemLogSvc != nil {
_ = s.systemLogSvc.Create(CreateSystemLogInput{
Level: "warn",
Category: "ai",
Event: "web_fallback_to_llm",
Source: "backend",
ConversationID: &conversationID,
UserID: &userID,
Message: "联网失败,回退到仅大模型",
Meta: map[string]interface{}{
"error": err.Error(),
"web_source": webSource,
"ai_config": config.ID,
},
})
}
if webSource == "vendor" && (strings.Contains(err.Error(), "web_search") || strings.Contains(err.Error(), "Supported values")) {
log.Printf("💡 提示:当前对话使用的 AI 配置接口不支持 type \"web_search\"。若需联网,请改用支持该能力的模型(如 Poixe),或在设置中将联网方式改为「自建」并配置 SERPER_API_KEY。")
}
} else if content != "" {
sources = append(sources, "llm")
if usedWeb {
sources = append(sources, "web")
}
if s.systemLogSvc != nil {
convID := conversationID
uID := userID
_ = s.systemLogSvc.Create(CreateSystemLogInput{
Level: "info",
Category: "ai",
Event: "ai_web_success",
Source: "backend",
ConversationID: &convID,
UserID: &uID,
Message: "联网生成成功",
Meta: map[string]interface{}{
"sources": strings.Join(sources, ","),
},
})
}
return &GenerateAIResponseResult{
Content: content,
SourcesUsed: strings.Join(sources, ","),
}, nil
}
}
if useLLM && len(sources) == 0 {
enhancedMessage = s.buildNoKBPrompt(userMessage)
sources = append(sources, "llm")
} else if useLLM && len(sources) > 0 {
sources = append(sources, "llm")
}
}
// 无任何来源时(例如 useKB 且无匹配,useLLM 关):使用可配置回复语
if len(sources) == 0 {
reply := s.getNoSourceReply()
return &GenerateAIResponseResult{
Content: reply,
SourcesUsed: "",
}, nil
}
response, err := provider.GenerateResponse(history, enhancedMessage, imageBase64, imageMimeType)
if err != nil {
// AI 调用失败,返回友好的错误消息
log.Printf("❌ AI 调用失败: %v", err)
return "AI客服好像出了点差错,请联系人工客服解决", nil
if s.systemLogSvc != nil {
_ = s.systemLogSvc.Create(CreateSystemLogInput{
Level: "error",
Category: "ai",
Event: "ai_generate_failed",
Source: "backend",
ConversationID: &conversationID,
UserID: &userID,
Message: "AI 调用失败,返回兜底回复",
Meta: map[string]interface{}{
"error": err.Error(),
"ai_config": config.ID,
},
})
}
return &GenerateAIResponseResult{
Content: s.getAIFailReply(),
SourcesUsed: strings.Join(sources, ","),
GenerationFailed: true,
}, nil
}
if s.systemLogSvc != nil {
convID := conversationID
uID := userID
event := "ai_llm_success"
if strings.Contains(strings.Join(sources, ","), "knowledge_base") {
event = "ai_rag_success"
}
_ = s.systemLogSvc.Create(CreateSystemLogInput{
Level: "info",
Category: "ai",
Event: event,
Source: "backend",
ConversationID: &convID,
UserID: &uID,
Message: "AI 生成成功",
Meta: map[string]interface{}{
"sources": strings.Join(sources, ","),
},
})
}
return response, nil
return &GenerateAIResponseResult{
Content: response,
SourcesUsed: strings.Join(sources, ","),
}, nil
}
// GenerateImageReply 生图渠道专用:根据用户描述生成图片并保存到存储,返回说明文案与图片 URL。
func (s *AIService) GenerateImageReply(conversationID uint, prompt string, userID uint) (*GenerateAIResponseResult, error) {
conversation, err := s.conversationRepo.GetByID(conversationID)
if err != nil {
return nil, fmt.Errorf("获取对话失败: %v", err)
}
if conversation.AIConfigID == nil {
return nil, errors.New("生图渠道需要选择生图模型,请先在渠道中选择「生图绘画」并选择模型")
}
config, err := s.aiConfigRepo.GetByID(*conversation.AIConfigID)
if err != nil {
return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
}
if !config.IsActive {
return nil, errors.New("该生图模型已禁用")
}
if config.ModelType != "image" {
return nil, fmt.Errorf("当前选择的不是生图模型,model_type=%s", config.ModelType)
}
apiKey, err := utils.DecryptAPIKey(config.APIKey)
if err != nil {
return nil, fmt.Errorf("解密 API Key 失败: %v", err)
}
var adapterConfig *AdapterConfig
if config.AdapterConfig != "" {
_ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig)
}
aiConfig := AIConfig{
APIURL: config.APIURL,
APIKey: apiKey,
Model: config.Model,
ModelType: config.ModelType,
Provider: config.Provider,
AdapterConfig: adapterConfig,
}
provider, err := s.providerFactory.CreateProvider(aiConfig)
if err != nil {
return nil, err
}
imgProvider, ok := provider.(ImageGenerationProvider)
if !ok {
return nil, errors.New("当前提供商不支持生图")
}
imageData, mimeType, err := imgProvider.GenerateImage(prompt)
if err != nil {
return nil, err
}
if s.storageService == nil {
return nil, errors.New("存储服务未配置,无法保存生成图片")
}
ext := ".png"
if strings.Contains(mimeType, "jpeg") || strings.Contains(mimeType, "jpg") {
ext = ".jpg"
}
fileURL, err := s.storageService.SaveMessageFile(conversationID, bytes.NewReader(imageData), "generated"+ext)
if err != nil {
return nil, fmt.Errorf("保存生成图片失败: %v", err)
}
content := "已根据您的描述生成图片。"
return &GenerateAIResponseResult{
Content: content,
SourcesUsed: "",
GeneratedFileURL: &fileURL,
}, nil
}
func (s *AIService) buildNoKBPrompt(userMessage string) string {
if s.promptConfigSvc != nil {
tpl, err := s.promptConfigSvc.GetNoKBPromptTemplate()
if err == nil && tpl != "" {
return replaceUserMessageOnly(tpl, userMessage)
}
}
return fmt.Sprintf(`你是一个智能客服助手当前未使用知识库请仅基于你的知识回答用户问题
用户问题%s
请简洁友好地回答若无法回答可建议用户联系人工客服`, userMessage)
}
func (s *AIService) buildWebSearchPrompt(userMessage string, webContext string) string {
if s.promptConfigSvc != nil {
tpl, err := s.promptConfigSvc.GetWebSearchResultPromptTemplate()
if err == nil && tpl != "" {
return replaceWebSearchPlaceholders(tpl, webContext, userMessage)
}
}
return fmt.Sprintf(`你是一个智能客服助手请结合以下联网搜索结果回答用户问题
联网搜索结果
%s
用户问题%s
请基于以上内容给出简洁准确的回答`, webContext, userMessage)
}
// replaceUserMessageOnly 仅替换 {{user_message}}
func replaceUserMessageOnly(template, userMessage string) string {
return strings.ReplaceAll(template, "{{user_message}}", userMessage)
}
// replaceWebSearchPlaceholders 替换 {{web_context}}、{{user_message}}
func replaceWebSearchPlaceholders(template, webContext, userMessage string) string {
template = strings.ReplaceAll(template, "{{web_context}}", webContext)
template = strings.ReplaceAll(template, "{{user_message}}", userMessage)
return template
}
// getNoSourceReply 无任何来源时返回给用户的一句话(可配置)
func (s *AIService) getNoSourceReply() string {
if s.promptConfigSvc != nil {
reply, err := s.promptConfigSvc.GetNoSourceReply()
if err == nil && strings.TrimSpace(reply) != "" {
return strings.TrimSpace(reply)
}
}
return "当前知识库暂无与此问题相关的内容,您可以尝试联系人工客服。"
}
// getAIFailReply AI 调用失败时返回给用户的一句话(可配置)
func (s *AIService) getAIFailReply() string {
if s.promptConfigSvc != nil {
reply, err := s.promptConfigSvc.GetAIFailReply()
if err == nil && strings.TrimSpace(reply) != "" {
return strings.TrimSpace(reply)
}
}
return "AI客服好像出了点差错,请联系人工客服解决"
}
// buildConversationHistory 构建对话历史(用于 AI 上下文)。
@@ -212,11 +605,20 @@ func (s *AIService) retrieveRAGContext(ctx context.Context, query string, conver
// buildRAGPrompt 构建包含 RAG 上下文的 Prompt
// userMessage: 用户原始消息
// ragContext: RAG 检索到的文档内容
// 返回: 增强后的用户消息(包含知识库上下文)
// 返回: 增强后的用户消息(包含知识库上下文)。若已配置提示词服务则使用可配置模板(占位符 {{rag_context}}、{{user_message}}),否则使用代码内默认。
func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string {
// 构建 RAG Prompt 模板
// 参考 PandaWiki 的 Prompt 格式
prompt := fmt.Sprintf(`你是一个智能客服助手请基于以下知识库内容回答用户的问题
if s.promptConfigSvc != nil {
tpl, err := s.promptConfigSvc.GetRAGPromptTemplate()
if err == nil && tpl != "" {
return replacePromptPlaceholders(tpl, ragContext, userMessage)
}
}
return s.buildRAGPromptFallback(userMessage, ragContext)
}
// buildRAGPromptFallback 代码内默认 RAG 提示词(与 prompt_config_service 默认一致,用于 promptConfigSvc 为空或出错时)
func (s *AIService) buildRAGPromptFallback(userMessage string, ragContext string) string {
return fmt.Sprintf(`你是一个智能客服助手请基于以下知识库内容回答用户的问题
知识库内容
%s
@@ -231,6 +633,157 @@ func (s *AIService) buildRAGPrompt(userMessage string, ragContext string) string
3. 如果知识库中没有相关信息请诚实告知
4. 保持友好专业的语气
5. 回答要简洁明了避免冗长`, ragContext, userMessage)
return prompt
}
// replacePromptPlaceholders 将模板中的 {{rag_context}}、{{user_message}} 替换为实际值
func replacePromptPlaceholders(template, ragContext, userMessage string) string {
template = strings.ReplaceAll(template, "{{rag_context}}", ragContext)
template = strings.ReplaceAll(template, "{{user_message}}", userMessage)
return template
}
// buildRAGPromptWithWebOptional 构建 RAG prompt,并允许在知识库无关或不足时用自身知识或联网。
// 与 buildRAGPrompt 区别:明确说明可先基于知识库,若无关/弱相关可基于自身知识,若仍不足可由模型决定是否联网(需配合传入 web_search 工具使用)。
func (s *AIService) buildRAGPromptWithWebOptional(userMessage string, ragContext string) string {
if s.promptConfigSvc != nil {
tpl, err := s.promptConfigSvc.GetRAGPromptWithWebOptionalTemplate()
if err == nil && tpl != "" {
return replacePromptPlaceholders(tpl, ragContext, userMessage)
}
}
return s.buildRAGPromptWithWebOptionalFallback(userMessage, ragContext)
}
// buildRAGPromptWithWebOptionalFallback 代码内默认(RAG+联网可选)
func (s *AIService) buildRAGPromptWithWebOptionalFallback(userMessage string, ragContext string) string {
return fmt.Sprintf(`你是一个智能客服助手请优先基于以下知识库内容回答用户的问题
知识库内容
%s
用户问题%s
回答要求
1. 若知识库内容与问题明确相关请基于知识库给出准确简洁的回答
2. 若知识库内容与问题无关或仅弱相关可先基于你自身的知识回答不必拘泥于知识库
3. 若你自身知识仍不足以回答例如需要最新资讯实时数据你可决定是否使用联网搜索获取信息后再回答
4. 保持友好专业回答简洁明了`, ragContext, userMessage)
}
const maxWebToolRounds = 5
// webSearchToolDefinition 返回 type: "function" 的 web_search 工具定义,仅用于「自建」联网(Serper 执行)。
func (s *AIService) webSearchToolDefinition() []map[string]interface{} {
return []map[string]interface{}{
{
"type": "function",
"function": map[string]interface{}{
"name": "web_search",
"description": "Search the web for current information. Use when you need up-to-date or external information to answer the user.",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]string{"type": "string", "description": "Search query"},
},
"required": []string{"query"},
},
},
},
}
}
// generateWithWebTools 使用 function calling 做联网(模型决定是否搜)。webSource: vendor / custom。
// 联网请求始终发往当前对话的「AI 配置」对话接口(与知识库向量配置/embedding 无关)。
// - vendor(模式一:厂商内置):在 tools 里传 type "web_search",由厂商在自家 API 内封装并执行搜索,无需自建。
// - custom(模式二:自建):在 tools 里传 type "function" 的自定义函数(如 web_search),由本服务调用 Serper 等执行并回填。
func (s *AIService) generateWithWebTools(ctx context.Context, provider AIProvider, history []MessageHistory, userMessage string, webSource string, imageBase64 string, imageMimeType string) (content string, usedWeb bool, err error) {
messages := s.historyToOpenAIMessages(history, userMessage, imageBase64, imageMimeType)
var tools []map[string]interface{}
useFunctionFormat := false
switch webSource {
case "vendor":
// 模式一:厂商内置,仅传 web_search,由厂商执行
tools = []map[string]interface{}{
{"type": "web_search"},
}
case "custom":
if s.webSearchProvider == nil {
return "", false, nil
}
useFunctionFormat = true
tools = s.webSearchToolDefinition()
default:
tools = nil
}
if len(tools) == 0 {
return "", false, nil
}
rounds := 0
for rounds < maxWebToolRounds {
rounds++
respContent, toolCalls, callErr := provider.GenerateResponseWithTools(messages, tools)
if callErr != nil {
return "", usedWeb, callErr
}
if len(toolCalls) == 0 {
return respContent, usedWeb, nil
}
if useFunctionFormat {
usedWeb = true
}
// 追加 assistant 消息(含 tool_calls
assistantMsg := map[string]interface{}{"role": "assistant", "content": respContent}
tcList := make([]map[string]interface{}, 0, len(toolCalls))
for _, tc := range toolCalls {
tcList = append(tcList, map[string]interface{}{
"id": tc.ID,
"type": "function",
"function": map[string]interface{}{"name": tc.Name, "arguments": tc.Arguments},
})
}
assistantMsg["tool_calls"] = tcList
messages = append(messages, assistantMsg)
for _, tc := range toolCalls {
toolResult := ""
if useFunctionFormat && tc.Name == "web_search" && s.webSearchProvider != nil {
var args struct {
Query string `json:"query"`
}
_ = json.Unmarshal([]byte(tc.Arguments), &args)
query := args.Query
if query == "" {
query = userMessage
}
toolResult, _ = s.webSearchProvider.Search(ctx, query)
}
messages = append(messages, map[string]interface{}{
"role": "tool",
"tool_call_id": tc.ID,
"content": toolResult,
})
}
}
return "", usedWeb, fmt.Errorf("联网工具调用超过 %d 轮", maxWebToolRounds)
}
func (s *AIService) historyToOpenAIMessages(history []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(history)+1)
for _, h := range history {
out = append(out, map[string]interface{}{"role": h.Role, "content": h.Content})
}
var lastContent interface{} = userMessage
if imageBase64 != "" {
dataURL := "data:" + imageMimeType + ";base64," + imageBase64
if imageMimeType == "" {
dataURL = "data:image/jpeg;base64," + imageBase64
}
lastContent = []map[string]interface{}{
{"type": "text", "text": userMessage},
{"type": "image_url", "image_url": map[string]string{"url": dataURL}},
}
}
out = append(out, map[string]interface{}{"role": "user", "content": lastContent})
return out
}
+357
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
-10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
SerperMCP 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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
OpenAIDeepSeek 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
View File
@@ -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
View File
@@ -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 },
];
}
@@ -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
View File
@@ -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>
);
@@ -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
View File
@@ -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
View File
@@ -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>
@@ -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
View File
@@ -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>
@@ -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
View File
@@ -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>
@@ -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>
);
}
@@ -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>
);
}
@@ -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
View File
@@ -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,
]
);
@@ -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",
}
@@ -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
View File
@@ -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",
});
}
@@ -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
View File
@@ -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}`;
}
@@ -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_searchcustom=自建 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
View File
@@ -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",
});
@@ -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),
@@ -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
View File
@@ -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({
@@ -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,
});
@@ -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 || "更新提示词失败");
}
}
@@ -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
View File
@@ -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
View File
@@ -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 {
@@ -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);
}
}
@@ -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("初始化对话失败");
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -4,6 +4,9 @@
*/
export const websiteConfig = {
/** 在线演示站点(「立即体验 / 打开 Demo」等入口) */
demoUrl: "https://demo.cscorp.top",
// GitHub 仓库地址
github: {
repo: "https://github.com/2930134478/AI-CS",
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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" });
}