mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
提示词工程+UI更新+日志+可视化
This commit is contained in:
+87
-14
@@ -1,24 +1,97 @@
|
||||
# ============================================
|
||||
# Docker Compose 鐢熶骇缂栨帓鐢紙澶嶅埗涓?.env 鍚庡~鍐欙級
|
||||
# 鐢ㄤ簬锛歞ocker compose -f docker-compose.prod.yml up -d
|
||||
# ============================================
|
||||
# AI-CS 统一配置模板(唯一真源)
|
||||
# 使用方法:
|
||||
# 1) 复制为项目根目录 .env
|
||||
# 2) 按注释填写必填项
|
||||
# 3) Docker 与本地启动都读取这同一份配置
|
||||
# ============================================
|
||||
|
||||
# MySQL 鏁版嵁搴?MYSQL_ROOT_PASSWORD=
|
||||
MYSQL_DATABASE=ai_cs
|
||||
MYSQL_USER=ai_cs_user
|
||||
MYSQL_PASSWORD=
|
||||
# =========================
|
||||
# 运行画像(建议配置)
|
||||
# =========================
|
||||
# 可选:docker / local
|
||||
# - docker:DB_HOST/MILVUS_HOST 通常填写服务名(mysql、milvus-standalone)
|
||||
# - local:DB_HOST/MILVUS_HOST 通常填写 localhost 或远程地址
|
||||
APP_PROFILE=docker
|
||||
|
||||
# =========================
|
||||
# 服务监听(必填)
|
||||
# =========================
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8080
|
||||
# 建议:生产 release,开发 debug
|
||||
GIN_MODE=release
|
||||
|
||||
# =========================
|
||||
# 数据库(必填)
|
||||
# =========================
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_USER=ai_cs_user
|
||||
DB_PASSWORD=please_change_me
|
||||
DB_NAME=ai_cs
|
||||
|
||||
# MySQL 容器 root 密码(Docker 部署必填)
|
||||
MYSQL_ROOT_PASSWORD=please_change_root_password
|
||||
# MySQL 对外端口(非必须)
|
||||
MYSQL_PORT=3306
|
||||
|
||||
# 榛樿绠$悊鍛橈紙棣栨鍚姩鏃跺垱寤猴紝璇峰姟蹇呬慨鏀瑰瘑鐮侊級
|
||||
# =========================
|
||||
# 管理员与安全(必填)
|
||||
# =========================
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=
|
||||
ADMIN_PASSWORD=please_change_admin_password
|
||||
# 64 位十六进制密钥(必填)
|
||||
# 生成示例:openssl rand -hex 32
|
||||
ENCRYPTION_KEY=please_generate_64_hex_chars
|
||||
|
||||
# 鍚庣鍔犲瘑瀵嗛挜锛堝瓨搴?API Key 绛夊姞瀵嗙敤锛屽缓璁細openssl rand -hex 32锛?ENCRYPTION_KEY=
|
||||
|
||||
# 绔彛锛堝彲閫夛紝榛樿宸插啓鍦?compose 涓級
|
||||
# =========================
|
||||
# 端口映射(非必须)
|
||||
# =========================
|
||||
# 后端映射到宿主机端口(host:container = BACKEND_PORT:SERVER_PORT)
|
||||
BACKEND_PORT=18080
|
||||
# 前端映射到宿主机端口
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# 鍓嶇璁块棶鍚庣 API 鐨勫湴鍧€锛堟祻瑙堝櫒鐩磋繛鐢紝涓?BACKEND_PORT 瀵瑰簲锛?NEXT_PUBLIC_API_BASE_URL=http://localhost:18080
|
||||
# =========================
|
||||
# 向量库 Milvus(按需配置)
|
||||
# 仅在使用知识库/RAG时需要
|
||||
# =========================
|
||||
MILVUS_HOST=milvus-standalone
|
||||
MILVUS_PORT=19530
|
||||
MILVUS_USERNAME=
|
||||
MILVUS_PASSWORD=
|
||||
|
||||
# 鍙€?GIN_MODE=release
|
||||
# true 表示禁用向量库(不连接 Milvus)
|
||||
MILVUS_DISABLED=false
|
||||
# 与 MILVUS_DISABLED 含义相同,保留为兼容开关
|
||||
VECTOR_STORE_DISABLED=false
|
||||
# true 表示强依赖 Milvus(连接失败则启动失败)
|
||||
MILVUS_REQUIRED=false
|
||||
|
||||
# =========================
|
||||
# 联网搜索(按需配置)
|
||||
# 使用联网搜索功能时至少配置一种
|
||||
# =========================
|
||||
# 方式一:Serper MCP 服务地址
|
||||
SERPER_MCP_URL=
|
||||
# 方式二:Serper API Key
|
||||
SERPER_API_KEY=
|
||||
|
||||
# =========================
|
||||
# 前端公开变量(按需配置)
|
||||
# 说明:NEXT_PUBLIC_* 会暴露到浏览器端
|
||||
# =========================
|
||||
# 非必填:站点对外的绝对地址(https://你的域名),用于 SEO(canonical、OG、sitemap)。未填时前端默认使用演示站域名。
|
||||
NEXT_PUBLIC_SITE_URL=
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:18080
|
||||
NEXT_PUBLIC_BACKEND_HOST=localhost
|
||||
NEXT_PUBLIC_BACKEND_PORT=8080
|
||||
NEXT_PUBLIC_MATOMO_CONTAINER_URL=
|
||||
|
||||
# =========================
|
||||
# 预构建镜像(docker-compose.prod.yml 必填)
|
||||
# =========================
|
||||
BACKEND_IMAGE=537yaha/ai-cs-backend:latest
|
||||
FRONTEND_IMAGE=537yaha/ai-cs-frontend:latest
|
||||
|
||||
@@ -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/端口
|
||||
@@ -1,61 +1,55 @@
|
||||
# AI-CS 智能客服系统
|
||||
|
||||
> 一个融合 AI 技术与人工客服的现代化智能客服解决方案
|
||||
> 开源的 AI 客服系统:**AI + 人工一体**、可私有化部署、可配置、可观测。
|
||||
> 适合把“官网右下角客服小窗”与“客服工作台”一起落地的团队。
|
||||
|
||||
## 🌐 在线演示
|
||||
## 在线演示
|
||||
|
||||
**Demo 站点**: https://demo.cscorp.top
|
||||
- **官网首页(产品介绍 + SEO)**:`https://demo.cscorp.top`
|
||||
- **访客聊天页**:`https://demo.cscorp.top/chat`(也可从首页右下角按钮进入)
|
||||
- **客服登录**:`https://demo.cscorp.top/agent/login`
|
||||
|
||||
- **官网首页**: https://demo.cscorp.top
|
||||
- **访客聊天**: 点击首页右下角客服插件按钮
|
||||
- **客服登录**: https://demo.cscorp.top/agent/login
|
||||
## 你能用它做什么(功能回顾)
|
||||
|
||||
## ✨ 核心特性
|
||||
- **访客侧(嵌入小窗)**
|
||||
- 右下角聊天小窗,可嵌入任意网站(iframe 方式)
|
||||
- 支持 AI 模式 / 人工模式切换、消息提示音、文件上传
|
||||
- 可选“本回合联网搜索”开关(是否对访客展示可在后台控制)
|
||||
- **客服侧(工作台)**
|
||||
- 会话列表、实时消息(WebSocket)、未读角标提示
|
||||
- 多模型管理(文本/绘画等)与对话配置
|
||||
- **提示词配置**(Prompt 管理)
|
||||
- **知识库管理 + RAG**(向量检索,可按需启用;向量库不可用时可不影响启动)
|
||||
- **日志中心**:结构化日志落库,支持按级别/分类/事件/trace_id/关键字筛选排障
|
||||
- **数据报表**:按日/区间查看访客打开小窗、会话与消息、AI 回复与失败率、知识库命中率、转人工等指标
|
||||
- **官网与 SEO(面向获客)**
|
||||
- 蓝白主题官网首页,分段渐变与滚动进场动效
|
||||
- `metadata` / Open Graph / JSON-LD / `sitemap.xml` / `robots.txt`,便于搜索引擎收录与社交分享
|
||||
- **可选联网搜索(Web Search)**
|
||||
- 支持 **Serper**:MCP 接入(`SERPER_MCP_URL`)或直连 API(`SERPER_API_KEY`)
|
||||
- 也支持“厂商内置 web search”(由模型自己决定是否搜)的 function calling 流程(按模型能力与供应商而定)
|
||||
|
||||
- 🤖 **AI 客服支持**:支持多厂商 AI 模型,可配置 API 和模型选择
|
||||
- 🔍 **RAG 智能检索**:基于向量数据库的知识库检索,AI 对话自动使用知识库内容
|
||||
- 📚 **知识库管理**:完整的文档管理、知识库组织、批量导入功能
|
||||
- 👥 **人工客服**:实时在线状态显示,支持多客服协作
|
||||
- 💬 **实时通信**:基于 WebSocket 的双向实时消息推送
|
||||
- 📁 **文件传输**:支持图片、文档上传和预览
|
||||
- 📖 **FAQ 管理**:知识库管理,支持向量检索和关键词搜索
|
||||
- 👤 **用户管理**:完整的用户权限管理系统
|
||||
- 🎨 **现代化 UI**:基于 Shadcn UI 的响应式设计
|
||||
- 🔌 **访客小窗插件**:可嵌入任何网站的客服小窗组件
|
||||
- 🌐 **产品官网**:内置产品展示页面
|
||||
## 快速开始(只维护根目录 `/.env`)
|
||||
|
||||
## 🚀 快速开始
|
||||
> 统一配置真源:**只维护项目根目录 `/.env`**。Docker 与本地启动都读这一份。
|
||||
> 初始化:复制 `/.env.example` 为 `/.env` 并填写必填项。
|
||||
|
||||
### 方式一:预构建镜像一键部署(推荐,最简单)⭐
|
||||
### 方式 A:预构建镜像部署(推荐,最省事)
|
||||
|
||||
> **最简单快捷的方式**,直接使用预构建的 Docker 镜像,无需构建,一行命令启动
|
||||
|
||||
#### 前置要求
|
||||
|
||||
- Docker Desktop(Windows/Mac)或 Docker + Docker Compose(Linux)
|
||||
|
||||
#### 部署步骤
|
||||
|
||||
1. **克隆项目并进入目录**
|
||||
#### 1)准备配置
|
||||
|
||||
```bash
|
||||
git clone https://github.com/2930134478/AI-CS.git
|
||||
cd AI-CS
|
||||
```
|
||||
|
||||
2. **配置环境变量**
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,至少修改以下配置:
|
||||
# - MYSQL_ROOT_PASSWORD: MySQL root 密码
|
||||
# - ADMIN_PASSWORD: 管理员密码(首次登录使用)
|
||||
# - ENCRYPTION_KEY: 加密密钥(生成 64 位十六进制字符串)
|
||||
```
|
||||
|
||||
生成加密密钥:
|
||||
至少要改(必填):
|
||||
- **数据库**:`MYSQL_ROOT_PASSWORD`、`DB_PASSWORD`
|
||||
- **管理员**:`ADMIN_PASSWORD`
|
||||
- **安全密钥**:`ENCRYPTION_KEY`(64 位 hex)
|
||||
|
||||
生成 `ENCRYPTION_KEY`:
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
@@ -65,383 +59,148 @@ openssl rand -hex 32
|
||||
-join ((48..57) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_})
|
||||
```
|
||||
|
||||
3. **一键启动**
|
||||
#### 2)启动
|
||||
|
||||
```bash
|
||||
# 使用预构建镜像启动(自动从 Docker Hub 拉取镜像)
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
就这么简单!🎉
|
||||
#### 3)访问
|
||||
|
||||
4. **访问应用**
|
||||
- **官网首页**:`http://localhost:3000`
|
||||
- **访客聊天**:`http://localhost:3000/chat`
|
||||
- **客服登录**:`http://localhost:3000/agent/login`
|
||||
- 用户名:`admin`(或 `.env` 中 `ADMIN_USERNAME`)
|
||||
- 密码:`.env` 中 `ADMIN_PASSWORD`
|
||||
|
||||
- **前端首页**: http://localhost:3000
|
||||
- **访客聊天**: http://localhost:3000/chat
|
||||
- **客服登录**: http://localhost:3000/agent/login
|
||||
- 用户名:`admin`(或 `.env` 中配置的 `ADMIN_USERNAME`)
|
||||
- 密码:`.env` 中配置的 `ADMIN_PASSWORD`
|
||||
#### 端口修改(重要说明)
|
||||
|
||||
#### 端口配置
|
||||
- 默认端口:前端 `3000`,后端对外 `18080`
|
||||
- 修改:在 `.env` 里改 `FRONTEND_PORT` / `BACKEND_PORT`
|
||||
|
||||
**默认端口**:后端 `18080`,前端 `3000`
|
||||
> 说明:预构建镜像在某些静态资源/图片路径场景可能与端口强绑定(历史兼容原因)。如果你需要彻底自定义端口并确保所有资源路径一致,建议用下面的“方式 B 本地构建”。
|
||||
|
||||
**修改端口**:在 `.env` 文件中设置 `BACKEND_PORT` 和 `FRONTEND_PORT`
|
||||
|
||||
⚠️ **注意**:预构建镜像的图片加载已硬编码为 `18080` 端口,如需修改端口,请使用方式二(本地构建)重新构建镜像。
|
||||
|
||||
#### 常用命令
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# 停止服务
|
||||
docker-compose -f docker-compose.prod.yml stop
|
||||
|
||||
# 停止并删除容器(保留数据)
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 完全重置(删除所有数据)
|
||||
docker-compose -f docker-compose.prod.yml down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式二:Docker 本地构建部署
|
||||
|
||||
> 适合需要自定义构建或网络无法访问 Docker Hub 的情况。
|
||||
|
||||
#### 前置要求
|
||||
|
||||
- Docker Desktop(Windows/Mac)或 Docker + Docker Compose(Linux)
|
||||
- Git
|
||||
|
||||
#### 部署步骤
|
||||
|
||||
1. **克隆项目**
|
||||
### 方式 B:Docker 本地构建部署(可自定义)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/2930134478/AI-CS.git
|
||||
cd AI-CS
|
||||
cp .env.example .env
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
2. **配置环境变量**
|
||||
### 方式 C:传统部署(本地开发/手动安装)
|
||||
|
||||
环境要求:
|
||||
- Go 1.24+
|
||||
- Node.js 20.9.0+
|
||||
- MySQL 8.0+
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
git clone https://github.com/2930134478/AI-CS.git
|
||||
cd AI-CS
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,至少修改以下配置:
|
||||
# - MYSQL_ROOT_PASSWORD: MySQL root 密码
|
||||
# - ADMIN_PASSWORD: 管理员密码(首次登录使用)
|
||||
# - ENCRYPTION_KEY: 加密密钥(生成 64 位十六进制字符串)
|
||||
```
|
||||
|
||||
生成加密密钥:
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
openssl rand -hex 32
|
||||
|
||||
# Windows PowerShell
|
||||
-join ((48..57) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_})
|
||||
```
|
||||
|
||||
3. **构建并启动服务**
|
||||
|
||||
```bash
|
||||
# 构建并启动所有服务(首次构建需要一些时间)
|
||||
docker-compose up -d --build
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
4. **访问应用**
|
||||
|
||||
- **前端首页**: http://localhost:3000
|
||||
- **访客聊天**: http://localhost:3000/chat
|
||||
- **客服登录**: http://localhost:3000/agent/login
|
||||
- 用户名:`admin`(或 `.env` 中配置的 `ADMIN_USERNAME`)
|
||||
- 密码:`.env` 中配置的 `ADMIN_PASSWORD`
|
||||
|
||||
#### 端口配置
|
||||
|
||||
**默认端口**:后端 `18080`,前端 `3000`
|
||||
|
||||
**修改端口**:在 `.env` 文件中设置 `BACKEND_PORT` 和 `FRONTEND_PORT`,然后重新构建:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
#### 常用命令
|
||||
|
||||
```bash
|
||||
# 停止服务
|
||||
docker-compose stop
|
||||
|
||||
# 停止并删除容器(保留数据)
|
||||
docker-compose down
|
||||
|
||||
# 完全重置(删除所有数据)
|
||||
docker-compose down -v
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式三:传统部署(手动安装)
|
||||
|
||||
#### 环境要求
|
||||
|
||||
- Go 1.24 或更高版本
|
||||
- Node.js 20.9.0+ 和 npm/yarn(Next.js 16 要求)
|
||||
- MySQL 8.0 或更高版本
|
||||
|
||||
#### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/2930134478/AI-CS.git
|
||||
cd AI-CS
|
||||
```
|
||||
|
||||
#### 2. 配置后端
|
||||
|
||||
```bash
|
||||
# 1) 后端
|
||||
cd backend
|
||||
|
||||
# 创建 .env 文件
|
||||
cat > .env << EOF
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=ai_cs
|
||||
|
||||
# 管理员账号配置(必填)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
|
||||
# 服务器配置
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=18080 # 默认端口 18080,可修改
|
||||
GIN_MODE=debug
|
||||
|
||||
# 加密密钥(用于加密 AI API Keys,可选)
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
|
||||
# Milvus 向量数据库配置(知识库功能需要)
|
||||
MILVUS_HOST=localhost
|
||||
MILVUS_PORT=19530
|
||||
|
||||
# 嵌入服务配置(知识库功能需要)
|
||||
# 方式一:使用 OpenAI 嵌入服务
|
||||
EMBEDDING_TYPE=api
|
||||
EMBEDDING_API_URL=https://api.openai.com/v1
|
||||
EMBEDDING_API_KEY=your_openai_api_key
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# 方式二:使用本地 BGE 嵌入服务
|
||||
# EMBEDDING_TYPE=local
|
||||
# BGE_API_URL=http://localhost:8080
|
||||
# BGE_MODEL_NAME=bge-small-zh-v1.5
|
||||
EOF
|
||||
|
||||
# 安装依赖
|
||||
go mod tidy
|
||||
|
||||
# 启动服务(默认端口 8080)
|
||||
go run main.go
|
||||
```
|
||||
|
||||
> ⚠️ **重要**:`ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建默认管理员账号。
|
||||
|
||||
#### 3. 配置前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
# 2) 前端(新开终端)
|
||||
cd ../frontend
|
||||
npm install
|
||||
|
||||
# 启动开发服务器(默认端口 3000)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**端口配置**:
|
||||
- 后端端口:修改 `backend/.env` 中的 `SERVER_PORT`(默认 `8080`)
|
||||
- 前端端口:启动时通过 `PORT` 环境变量修改,如 `PORT=4000 npm run dev`
|
||||
- 图片加载端口:创建 `frontend/.env.local`,设置 `NEXT_PUBLIC_BACKEND_PORT=你的后端端口`
|
||||
## 配置字典(根目录 `/.env`)
|
||||
|
||||
#### 4. 访问应用
|
||||
> 下面表格以 `/.env.example` 为准,帮助你快速判断“必填/可选/什么时候需要填”。
|
||||
|
||||
- **官网首页**: http://localhost:3000
|
||||
- **访客聊天**:
|
||||
- 直接访问:http://localhost:3000/chat
|
||||
- 或点击首页右下角的客服插件按钮
|
||||
- **客服登录**: http://localhost:3000/agent/login
|
||||
| 变量 | 用途 | 是否必填 | 默认值(示例) | 示例 |
|
||||
|---|---|---|---|---|
|
||||
| `APP_PROFILE` | 部署画像(`docker/local`) | 否 | `docker` | `local` |
|
||||
| `SERVER_HOST` | 后端监听地址 | 是 | `0.0.0.0` | `127.0.0.1` |
|
||||
| `SERVER_PORT` | 后端容器内端口 | 是 | `8080` | `8080` |
|
||||
| `GIN_MODE` | 后端模式 | 建议 | `release` | `debug` |
|
||||
| `DB_HOST` | 后端数据库地址 | 是 | `mysql` | `localhost` |
|
||||
| `DB_PORT` | 后端数据库端口 | 是 | `3306` | `3306` |
|
||||
| `DB_USER` | 数据库用户名 | 是 | `ai_cs_user` | `root` |
|
||||
| `DB_PASSWORD` | 数据库密码 | 是 | 无 | `StrongPwd` |
|
||||
| `DB_NAME` | 数据库名 | 是 | `ai_cs` | `ai_cs` |
|
||||
| `MYSQL_ROOT_PASSWORD` | MySQL root 密码(compose) | 是(Docker) | 无 | `RootPwd` |
|
||||
| `MYSQL_PORT` | MySQL 对外端口(compose) | 否 | `3306` | `13306` |
|
||||
| `ADMIN_USERNAME` | 默认管理员用户名 | 否 | `admin` | `admin` |
|
||||
| `ADMIN_PASSWORD` | 默认管理员密码 | 是 | 无 | `AdminPwd` |
|
||||
| `ENCRYPTION_KEY` | 后端加密密钥(64位 hex) | 是 | 无 | `openssl rand -hex 32` |
|
||||
| `BACKEND_PORT` | 后端映射到宿主机端口 | 否 | `18080` | `28080` |
|
||||
| `FRONTEND_PORT` | 前端映射到宿主机端口 | 否 | `3000` | `13000` |
|
||||
| `MILVUS_HOST` | 向量库地址 | 可选(启用 RAG) | `milvus-standalone` | `localhost` |
|
||||
| `MILVUS_PORT` | 向量库端口 | 可选(启用 RAG) | `19530` | `19530` |
|
||||
| `MILVUS_USERNAME` | Milvus 用户名 | 可选 | 空 | `user` |
|
||||
| `MILVUS_PASSWORD` | Milvus 密码 | 可选 | 空 | `pass` |
|
||||
| `MILVUS_DISABLED` | 禁用向量库(不连接) | 否 | `false` | `true` |
|
||||
| `VECTOR_STORE_DISABLED` | 同上(兼容开关) | 否 | `false` | `true` |
|
||||
| `MILVUS_REQUIRED` | 强依赖向量库(失败即退出) | 否 | `false` | `true` |
|
||||
| `SERPER_MCP_URL` | 联网搜索 MCP 地址 | 可选(启用联网) | 空 | `http://host:3000/sse` |
|
||||
| `SERPER_API_KEY` | 联网搜索 API Key | 可选(启用联网) | 空 | `xxxxx` |
|
||||
| `NEXT_PUBLIC_SITE_URL` | 站点对外绝对地址(用于 SEO) | 否 | 空(默认 demo 域名) | `https://www.example.com` |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | 前端公开 API 地址 | 建议 | `http://localhost:18080` | `https://api.example.com` |
|
||||
| `NEXT_PUBLIC_BACKEND_HOST` | 前端 dev 代理目标 host | 否 | `localhost` | `127.0.0.1` |
|
||||
| `NEXT_PUBLIC_BACKEND_PORT` | 前端 dev 代理目标 port | 否 | `8080` | `18080` |
|
||||
| `NEXT_PUBLIC_MATOMO_CONTAINER_URL` | Matomo 脚本地址 | 可选 | 空 | `https://.../container.js` |
|
||||
| `BACKEND_IMAGE` | 预构建后端镜像(prod compose) | 是(prod) | `537yaha/ai-cs-backend:latest` | `your/backend:tag` |
|
||||
| `FRONTEND_IMAGE` | 预构建前端镜像(prod compose) | 是(prod) | `537yaha/ai-cs-frontend:latest` | `your/frontend:tag` |
|
||||
|
||||
#### 5. 默认管理员账号
|
||||
## 启用/关闭知识库(RAG)的推荐做法
|
||||
|
||||
⚠️ **重要说明**:
|
||||
- **你暂时不想用知识库**:把 `.env` 里 `MILVUS_DISABLED=true`(或 `VECTOR_STORE_DISABLED=true`)
|
||||
- 应用仍可启动,AI 对话与人工客服不受影响
|
||||
- **你必须依赖知识库**(生产强约束):把 `.env` 里 `MILVUS_REQUIRED=true`
|
||||
- 此时如果 Milvus 不可用,会落库一条错误日志后退出,避免“半残服务上线”
|
||||
|
||||
系统会在首次启动时**自动创建**管理员账号(如果不存在),但**必须先在 `backend/.env` 文件中配置 `ADMIN_PASSWORD` 环境变量**。
|
||||
## 集成访客小窗到你的网站(iframe)
|
||||
|
||||
**配置步骤**:
|
||||
|
||||
1. 在 `backend/.env` 文件中设置:
|
||||
```env
|
||||
ADMIN_USERNAME=admin # 可选,默认为 admin
|
||||
ADMIN_PASSWORD=your_password # ⚠️ 必填,首次登录后请立即修改密码
|
||||
```
|
||||
|
||||
2. 启动后端服务,系统会自动创建管理员账号
|
||||
|
||||
3. 使用配置的用户名和密码登录
|
||||
|
||||
**安全提示**:
|
||||
- 生产环境请使用强密码
|
||||
- 首次登录后请立即修改密码
|
||||
- `ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建管理员账号
|
||||
|
||||
### 后端环境变量
|
||||
|
||||
在 `backend/.env` 中配置:
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DB_HOST=localhost # 数据库主机
|
||||
DB_PORT=3306 # 数据库端口
|
||||
DB_USER=root # 数据库用户名
|
||||
DB_PASSWORD=your_password # 数据库密码
|
||||
DB_NAME=ai_cs # 数据库名称
|
||||
|
||||
# 管理员账号配置
|
||||
ADMIN_USERNAME=admin # 管理员用户名(可选,默认为 admin)
|
||||
ADMIN_PASSWORD=your_admin_password # ⚠️ 管理员密码(必填)
|
||||
|
||||
# 服务器配置
|
||||
SERVER_HOST=0.0.0.0 # 服务器监听地址
|
||||
SERVER_PORT=8080 # 服务器端口
|
||||
GIN_MODE=debug # 运行模式(debug/release)
|
||||
|
||||
# 加密密钥(用于加密 AI API Keys)
|
||||
ENCRYPTION_KEY=your_32_byte_key # 使用 openssl rand -hex 32 生成
|
||||
```
|
||||
|
||||
**重要提示**:
|
||||
- `ADMIN_PASSWORD` 是必填项,如果不设置,系统不会创建默认管理员账号
|
||||
- 生产环境请使用强密码并设置 `GIN_MODE=release`
|
||||
|
||||
## 🔌 集成客服插件到你的网站
|
||||
|
||||
#### 步骤 1:在 HTML 中添加代码
|
||||
|
||||
在你的网站 HTML 的 `</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);"
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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})
|
||||
}
|
||||
@@ -44,6 +44,8 @@ func (e *EmbeddingConfigController) Update(c *gin.Context) {
|
||||
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": "请求参数错误"})
|
||||
@@ -55,6 +57,8 @@ func (e *EmbeddingConfigController) Update(c *gin.Context) {
|
||||
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()})
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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 处理发送消息的请求。
|
||||
@@ -67,6 +71,10 @@ func (mc *MessageController) CreateMessage(c *gin.Context) {
|
||||
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": "保存成功"})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
// VisitorController 负责处理访客相关的 HTTP 请求。
|
||||
type VisitorController struct {
|
||||
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,
|
||||
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
@@ -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
@@ -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=
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package mcp 提供 MCP(Model Context Protocol)客户端,用于通过 MCP 协议调用远程工具(如 Serper 搜索)。
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// ErrNotConfigured 表示未配置 MCP 服务 URL 或未成功连接。
|
||||
var ErrNotConfigured = errors.New("mcp: server URL not configured or not connected")
|
||||
|
||||
// Client 封装对单个 MCP 服务端的连接,支持 CallTool。
|
||||
type Client struct {
|
||||
serverURL string
|
||||
impl *mcp.Implementation
|
||||
transport *mcp.StreamableClientTransport
|
||||
client *mcp.Client
|
||||
session *mcp.ClientSession
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewClient 创建一个 MCP 客户端。serverURL 为 MCP 服务 HTTP/SSE 地址(如 http://localhost:3000/sse)。
|
||||
// 若 serverURL 为空,后续 Connect/CallTool 将返回 ErrNotConfigured。
|
||||
func NewClient(serverURL string) *Client {
|
||||
url := strings.TrimSpace(serverURL)
|
||||
return &Client{
|
||||
serverURL: url,
|
||||
impl: &mcp.Implementation{Name: "ai-cs-backend", Version: "v1.0.0"},
|
||||
}
|
||||
}
|
||||
|
||||
// Connect 连接到 MCP 服务端。应在调用 CallTool 前成功调用一次。
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.serverURL == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
if c.session != nil {
|
||||
return nil // 已连接
|
||||
}
|
||||
c.transport = &mcp.StreamableClientTransport{Endpoint: c.serverURL}
|
||||
c.client = mcp.NewClient(c.impl, nil)
|
||||
session, err := c.client.Connect(ctx, c.transport, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp connect: %w", err)
|
||||
}
|
||||
c.session = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// CallTool 调用远程工具。name 为工具名(如 google_search),args 为参数(如 map[string]any{"query": "..."})。
|
||||
// 返回工具结果中的文本内容拼接;若未连接或工具返回错误则返回错误。
|
||||
func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (string, error) {
|
||||
c.mu.Lock()
|
||||
session := c.session
|
||||
c.mu.Unlock()
|
||||
if c.serverURL == "" {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
if session == nil {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
params := &mcp.CallToolParams{Name: name, Arguments: args}
|
||||
result, err := session.CallTool(ctx, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mcp call tool %s: %w", name, err)
|
||||
}
|
||||
if result.IsError {
|
||||
// 工具侧错误,从 Content 中取错误信息
|
||||
text := extractTextContent(result.Content)
|
||||
if text != "" {
|
||||
return "", fmt.Errorf("mcp tool error: %s", text)
|
||||
}
|
||||
return "", fmt.Errorf("mcp tool error (no content)")
|
||||
}
|
||||
return extractTextContent(result.Content), nil
|
||||
}
|
||||
|
||||
// Close 关闭与 MCP 服务端的会话。
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.session == nil {
|
||||
return nil
|
||||
}
|
||||
err := c.session.Close()
|
||||
c.session = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func extractTextContent(content []mcp.Content) string {
|
||||
var b strings.Builder
|
||||
for _, c := range content {
|
||||
if tc, ok := c.(*mcp.TextContent); ok {
|
||||
b.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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中提取文件路径
|
||||
|
||||
+186
-23
@@ -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,23 +71,57 @@ 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 envPath != "" {
|
||||
if err := godotenv.Load(envPath); err != nil {
|
||||
log.Printf("❌ 加载 .env 文件失败: %v", err)
|
||||
log.Println("⚠️ 提示:如果看到 'unexpected character' 错误,可能是文件编码问题(UTF-8 BOM)")
|
||||
@@ -93,6 +130,7 @@ func main() {
|
||||
} else {
|
||||
log.Println("✅ .env 文件加载成功")
|
||||
}
|
||||
}
|
||||
|
||||
db, err := infra.NewDB()
|
||||
if err != nil {
|
||||
@@ -100,7 +138,7 @@ func main() {
|
||||
}
|
||||
|
||||
//根据结构体定义自动创建更新表
|
||||
if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}, &models.PromptConfig{}, &models.WidgetOpenEvent{}, &models.SystemLog{}); err != nil {
|
||||
log.Fatalf("自动创建表失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -112,14 +150,20 @@ func main() {
|
||||
kbRepo := repository.NewKnowledgeBaseRepository(db)
|
||||
docRepo := repository.NewDocumentRepository(db)
|
||||
embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db)
|
||||
promptConfigRepo := repository.NewPromptConfigRepository(db)
|
||||
systemLogRepo := repository.NewSystemLogRepository(db)
|
||||
systemLogService := service.NewSystemLogService(systemLogRepo)
|
||||
|
||||
// 初始化默认管理员账号(如果不存在)
|
||||
initDefaultAdmin(userRepo)
|
||||
|
||||
//gin路由初始化
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
//使用日志中间件
|
||||
// trace_id + 结构化 HTTP 日志 + 控制台日志
|
||||
r.Use(middleware.TraceID())
|
||||
r.Use(middleware.StructuredHTTPLogger(systemLogService))
|
||||
r.Use(middleware.Logger())
|
||||
|
||||
//跨域配置
|
||||
@@ -133,21 +177,82 @@ func main() {
|
||||
publicPath := "/uploads"
|
||||
storageService := infra.NewLocalStorageService(uploadDir, publicPath)
|
||||
|
||||
// 初始化 Milvus 客户端(向量数据库)
|
||||
milvusClient, err := infra.NewMilvusClient()
|
||||
if err != nil {
|
||||
log.Fatalf("连接 Milvus 失败: %v", err)
|
||||
// 初始化 Milvus(向量数据库):默认连接失败时降级为「无向量库」启动;MILVUS_REQUIRED=true 时失败则退出
|
||||
milvusDisabled := infra.IsMilvusDisabled()
|
||||
milvusRequired := infra.IsMilvusRequired()
|
||||
var milvusClient milvus.Client
|
||||
defer func() {
|
||||
if milvusClient != nil {
|
||||
if err := milvusClient.Close(); err != nil {
|
||||
log.Printf("关闭 Milvus 客户端: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
var vectorStore *infra.VectorStore
|
||||
milvusCfg := infra.GetMilvusConfig()
|
||||
milvusMeta := map[string]interface{}{
|
||||
"milvus_host": milvusCfg.Host,
|
||||
"milvus_port": milvusCfg.Port,
|
||||
"milvus_required": milvusRequired,
|
||||
"milvus_disabled": milvusDisabled,
|
||||
}
|
||||
defer milvusClient.Close()
|
||||
|
||||
// 检查 Milvus 健康状态
|
||||
if err := infra.HealthCheck(milvusClient); err != nil {
|
||||
log.Fatalf("Milvus 健康检查失败: %v", err)
|
||||
if milvusDisabled {
|
||||
log.Println("ℹ️ 已设置 MILVUS_DISABLED / VECTOR_STORE_DISABLED,跳过 Milvus;知识库 RAG 与向量化不可用,直至启用并重启。")
|
||||
logVectorStartup(systemLogService, "info", "milvus_disabled",
|
||||
"已跳过 Milvus(MILVUS_DISABLED/VECTOR_STORE_DISABLED);知识库 RAG 与向量化不可用,启用后需重启",
|
||||
milvusMeta)
|
||||
} else {
|
||||
c, err := infra.NewMilvusClient()
|
||||
if err != nil {
|
||||
if milvusRequired {
|
||||
m := map[string]interface{}{}
|
||||
for k, v := range milvusMeta {
|
||||
m[k] = v
|
||||
}
|
||||
m["error"] = err.Error()
|
||||
fatalVectorStartup(systemLogService, "milvus_required_connect_failed",
|
||||
"连接 Milvus 失败(已设置 MILVUS_REQUIRED)", m)
|
||||
}
|
||||
log.Printf("⚠️ 连接 Milvus 失败,将以「无向量库」模式启动: %v", err)
|
||||
m := map[string]interface{}{}
|
||||
for k, v := range milvusMeta {
|
||||
m[k] = v
|
||||
}
|
||||
m["error"] = err.Error()
|
||||
logVectorStartup(systemLogService, "warn", "milvus_connect_failed",
|
||||
"连接 Milvus 失败,已降级为无向量库模式启动", m)
|
||||
} else {
|
||||
milvusClient = c
|
||||
if err := infra.HealthCheck(milvusClient); err != nil {
|
||||
_ = milvusClient.Close()
|
||||
milvusClient = nil
|
||||
if milvusRequired {
|
||||
m := map[string]interface{}{}
|
||||
for k, v := range milvusMeta {
|
||||
m[k] = v
|
||||
}
|
||||
m["error"] = err.Error()
|
||||
fatalVectorStartup(systemLogService, "milvus_required_health_check_failed",
|
||||
"Milvus 健康检查失败(已设置 MILVUS_REQUIRED)", m)
|
||||
}
|
||||
log.Printf("⚠️ Milvus 健康检查失败,将以「无向量库」模式启动: %v", err)
|
||||
m := map[string]interface{}{}
|
||||
for k, v := range milvusMeta {
|
||||
m[k] = v
|
||||
}
|
||||
m["error"] = err.Error()
|
||||
logVectorStartup(systemLogService, "warn", "milvus_health_check_failed",
|
||||
"Milvus 健康检查失败,已降级为无向量库模式启动", m)
|
||||
} else {
|
||||
log.Println("✅ Milvus 连接成功")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 嵌入服务按需从 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 milvusClient != nil {
|
||||
vs, err := infra.NewVectorStore(milvusClient, "documents", dimension, getEmbedding)
|
||||
if err != nil {
|
||||
log.Fatalf("创建向量存储失败: %v", err)
|
||||
_ = milvusClient.Close()
|
||||
milvusClient = nil
|
||||
if milvusRequired {
|
||||
m := map[string]interface{}{}
|
||||
for k, v := range milvusMeta {
|
||||
m[k] = v
|
||||
}
|
||||
m["error"] = err.Error()
|
||||
fatalVectorStartup(systemLogService, "milvus_required_vector_store_init_failed",
|
||||
"创建向量存储失败(已设置 MILVUS_REQUIRED)", m)
|
||||
}
|
||||
log.Printf("⚠️ 创建向量存储失败,将以「无向量库」模式启动: %v", err)
|
||||
m := map[string]interface{}{}
|
||||
for k, v := range milvusMeta {
|
||||
m[k] = v
|
||||
}
|
||||
m["error"] = err.Error()
|
||||
logVectorStartup(systemLogService, "warn", "milvus_vector_store_init_failed",
|
||||
"创建向量存储(集合)失败,已降级为无向量库模式启动", m)
|
||||
} else {
|
||||
vectorStore = vs
|
||||
}
|
||||
}
|
||||
if vectorStore != nil {
|
||||
okMeta := map[string]interface{}{}
|
||||
for k, v := range milvusMeta {
|
||||
okMeta[k] = v
|
||||
}
|
||||
okMeta["collection"] = "documents"
|
||||
logVectorStartup(systemLogService, "info", "milvus_ready",
|
||||
"Milvus 已连接且向量集合可用", okMeta)
|
||||
}
|
||||
vectorStoreService := rag.NewVectorStoreService(vectorStore)
|
||||
|
||||
@@ -184,12 +320,30 @@ func main() {
|
||||
retrievalService.EnableCache(5 * time.Minute)
|
||||
healthChecker := rag.NewHealthChecker(embeddingProvider, vectorStoreService)
|
||||
|
||||
// 联网搜索(可选):优先通过 MCP 调用 Serper(SERPER_MCP_URL),否则使用 Serper HTTP API(SERPER_API_KEY)
|
||||
var webSearchProvider infra_search.WebSearchProvider
|
||||
if mcpURL := os.Getenv("SERPER_MCP_URL"); mcpURL != "" {
|
||||
mcpClient := mcp.NewClient(mcpURL)
|
||||
if err := mcpClient.Connect(initCtx); err != nil {
|
||||
log.Printf("⚠️ Serper MCP 连接失败(SERPER_MCP_URL=%s): %v,联网搜索将不可用", mcpURL, err)
|
||||
} else {
|
||||
webSearchProvider = mcp.NewSerperWebSearchProvider(mcpClient)
|
||||
log.Println("✅ 联网搜索已通过 MCP(Serper)接入")
|
||||
}
|
||||
}
|
||||
if webSearchProvider == nil {
|
||||
if apiKey := os.Getenv("SERPER_API_KEY"); apiKey != "" {
|
||||
webSearchProvider = infra_search.NewSerperProvider(apiKey)
|
||||
log.Println("✅ 联网搜索已通过 Serper HTTP API 接入")
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化服务层
|
||||
authService := service.NewAuthService(userRepo)
|
||||
conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo)
|
||||
conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo, systemLogService)
|
||||
profileService := service.NewProfileService(userRepo, storageService)
|
||||
aiConfigService := service.NewAIConfigService(aiConfigRepo, userRepo)
|
||||
aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService) // 添加 RAG 检索服务
|
||||
aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService, webSearchProvider, embeddingConfigService, promptConfigService, storageService, systemLogService)
|
||||
userService := service.NewUserService(userRepo) // 用户管理服务
|
||||
faqService := service.NewFAQService(faqRepo, retrievalService, documentEmbeddingService) // FAQ 管理服务
|
||||
documentService := service.NewDocumentService(docRepo, kbRepo, documentEmbeddingService, retrievalService) // 文档管理服务
|
||||
@@ -314,11 +468,17 @@ 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{
|
||||
@@ -329,12 +489,15 @@ func main() {
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,4 +23,3 @@ type AIConfig struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
// 访客端是否显示「本回合联网搜索」选项(由配置页控制)
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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 调用失败时返回给用户的一句话
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+79
-58
@@ -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) {
|
||||
register := func(routes gin.IRoutes) {
|
||||
// Auth
|
||||
r.POST("/login", controllers.Auth.Login)
|
||||
r.POST("/logout", controllers.Auth.Logout)
|
||||
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) // 获取开放的模型列表(供访客选择)
|
||||
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)
|
||||
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) // 更新用户密码
|
||||
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) // 更新用户密码
|
||||
// 兼容旧接口
|
||||
r.POST("/admin/agents", controllers.Admin.CreateAgent) // 创建客服(兼容旧接口)
|
||||
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)
|
||||
routes.GET("/agent/profile/:user_id", controllers.Profile.GetProfile)
|
||||
routes.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile)
|
||||
routes.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar)
|
||||
|
||||
// AI Config(AI 配置)
|
||||
r.POST("/agent/ai-config/:user_id", controllers.AIConfig.CreateAIConfig)
|
||||
r.GET("/agent/ai-config/:user_id", controllers.AIConfig.ListAIConfigs)
|
||||
r.GET("/agent/ai-config/:user_id/:id", controllers.AIConfig.GetAIConfig)
|
||||
r.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig)
|
||||
r.DELETE("/agent/ai-config/:user_id/:id", controllers.AIConfig.DeleteAIConfig)
|
||||
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)
|
||||
routes.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get)
|
||||
routes.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update)
|
||||
|
||||
// Prompt Config(提示词配置,平台级,仅管理员可更新)
|
||||
routes.GET("/agent/prompts", controllers.PromptConfig.Get)
|
||||
routes.PUT("/agent/prompts", controllers.PromptConfig.Update)
|
||||
|
||||
// FAQ(事件管理/常见问题)
|
||||
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
|
||||
routes.GET("/faqs", controllers.FAQ.ListFAQs) // 获取 FAQ 列表(支持关键词搜索)
|
||||
routes.GET("/faqs/:id", controllers.FAQ.GetFAQ) // 获取 FAQ 详情
|
||||
routes.POST("/faqs", controllers.FAQ.CreateFAQ) // 创建 FAQ
|
||||
routes.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ) // 更新 FAQ
|
||||
routes.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ) // 删除 FAQ
|
||||
|
||||
// Document(文档管理)
|
||||
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) // 取消发布文档
|
||||
routes.GET("/documents", controllers.Document.ListDocuments) // 获取文档列表(支持分页、搜索、状态过滤)
|
||||
routes.GET("/documents/:id", controllers.Document.GetDocument) // 获取文档详情
|
||||
routes.POST("/documents", controllers.Document.CreateDocument) // 创建文档
|
||||
routes.PUT("/documents/:id", controllers.Document.UpdateDocument) // 更新文档
|
||||
routes.DELETE("/documents/:id", controllers.Document.DeleteDocument) // 删除文档
|
||||
routes.GET("/documents/search", controllers.Document.SearchDocuments) // 向量检索搜索文档
|
||||
routes.GET("/documents/hybrid-search", controllers.Document.HybridSearchDocuments) // 混合检索搜索文档
|
||||
routes.PUT("/documents/:id/status", controllers.Document.UpdateDocumentStatus) // 更新文档状态
|
||||
routes.POST("/documents/:id/publish", controllers.Document.PublishDocument) // 发布文档
|
||||
routes.POST("/documents/:id/unpublish", controllers.Document.UnpublishDocument) // 取消发布文档
|
||||
|
||||
// KnowledgeBase(知识库管理)
|
||||
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) // 获取知识库的文档列表
|
||||
routes.GET("/knowledge-bases", controllers.KnowledgeBase.ListKnowledgeBases) // 获取知识库列表
|
||||
routes.GET("/knowledge-bases/:id", controllers.KnowledgeBase.GetKnowledgeBase) // 获取知识库详情
|
||||
routes.POST("/knowledge-bases", controllers.KnowledgeBase.CreateKnowledgeBase) // 创建知识库
|
||||
routes.PUT("/knowledge-bases/:id", controllers.KnowledgeBase.UpdateKnowledgeBase) // 更新知识库
|
||||
routes.PATCH("/knowledge-bases/:id/rag-enabled", controllers.KnowledgeBase.UpdateKnowledgeBaseRAGEnabled) // 知识库是否参与 RAG
|
||||
routes.DELETE("/knowledge-bases/:id", controllers.KnowledgeBase.DeleteKnowledgeBase) // 删除知识库
|
||||
routes.GET("/knowledge-bases/:id/documents", controllers.KnowledgeBase.ListDocumentsByKnowledgeBase) // 获取知识库的文档列表
|
||||
|
||||
// Import(文档导入)
|
||||
r.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传)
|
||||
r.POST("/import/urls", controllers.Import.ImportFromURLs) // 批量导入文档(URL 爬取)
|
||||
routes.POST("/import/documents", controllers.Import.ImportDocuments) // 批量导入文档(文件上传)
|
||||
routes.POST("/import/urls", controllers.Import.ImportFromURLs) // 批量导入文档(URL 爬取)
|
||||
|
||||
// Visitor(访客相关)
|
||||
r.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表
|
||||
routes.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表
|
||||
routes.GET("/visitor/widget-config", controllers.Visitor.GetWidgetConfig) // 访客小窗配置(联网设置等,无需登录)
|
||||
routes.POST("/visitor/analytics/widget-open", controllers.Analytics.PostWidgetOpen) // 访客打开小窗埋点
|
||||
|
||||
// Analytics(数据分析报表,需客服 X-User-Id)
|
||||
routes.GET("/agent/analytics/summary", controllers.Analytics.GetSummary)
|
||||
routes.GET("/agent/logs/api", controllers.SystemLog.GetLogs) // 日志查询(避免与前端 /agent/logs 页面路径冲突)
|
||||
routes.POST("/agent/logs/frontend", controllers.SystemLog.ReportFrontendLog) // 前端日志上报
|
||||
|
||||
// Health(健康检查)
|
||||
r.GET("/health", controllers.Health.HealthCheck) // 健康检查
|
||||
r.GET("/health/metrics", controllers.Health.Metrics) // 性能指标
|
||||
routes.GET("/health", controllers.Health.HealthCheck) // 健康检查
|
||||
routes.GET("/health/metrics", controllers.Health.Metrics) // 性能指标
|
||||
|
||||
// WebSocket
|
||||
r.GET("/ws", wsHandler)
|
||||
routes.GET("/ws", wsHandler)
|
||||
}
|
||||
|
||||
// 兼容旧路径(无前缀)
|
||||
register(r)
|
||||
// 新路径:/api 前缀,便于反向代理“同域 API”
|
||||
register(r.Group("/api"))
|
||||
}
|
||||
|
||||
+404
-38
@@ -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
|
||||
@@ -77,56 +88,73 @@ func NewUniversalAIProvider(config AIConfig) *UniversalAIProvider {
|
||||
return &UniversalAIProvider{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second, // 30 秒超时
|
||||
Timeout: 60 * time.Second, // 60 秒超时
|
||||
},
|
||||
adapter: adapter,
|
||||
}
|
||||
}
|
||||
|
||||
// isResponsesAPI 判断是否为 OpenAI Responses API(/v1/responses),请求/响应格式与 Chat Completions 不同。
|
||||
func isResponsesAPI(apiURL string) bool {
|
||||
return strings.Contains(apiURL, "/v1/responses")
|
||||
}
|
||||
|
||||
// GenerateResponse 生成 AI 回复(支持 OpenAI 兼容格式,通过适配器适配不同服务商)。
|
||||
func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
|
||||
// 根据模型类型选择不同的处理逻辑
|
||||
func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error) {
|
||||
switch p.config.ModelType {
|
||||
case "text":
|
||||
return p.generateTextResponse(conversationHistory, userMessage)
|
||||
return p.generateTextResponse(conversationHistory, userMessage, imageBase64, imageMimeType)
|
||||
case "image":
|
||||
// 图片生成(未来扩展)
|
||||
return "", fmt.Errorf("图片模型暂未支持")
|
||||
return "", fmt.Errorf("图片模型请使用生图接口")
|
||||
case "audio":
|
||||
// 语音识别/合成(未来扩展)
|
||||
return "", fmt.Errorf("语音模型暂未支持")
|
||||
case "video":
|
||||
// 视频生成(未来扩展)
|
||||
return "", fmt.Errorf("视频模型暂未支持")
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的模型类型: %s", p.config.ModelType)
|
||||
}
|
||||
}
|
||||
|
||||
// generateTextResponse 生成文本回复(通用实现,支持所有 OpenAI 兼容格式)。
|
||||
func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
|
||||
// 构建消息列表(包含历史对话和当前消息)
|
||||
messages := make([]map[string]string, 0)
|
||||
|
||||
// 添加历史对话
|
||||
for _, history := range conversationHistory {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": history.Role,
|
||||
"content": history.Content,
|
||||
})
|
||||
// buildUserContent 构建当前用户消息的 content:纯文本或 text+image(多模态)
|
||||
func buildUserContent(userMessage string, imageBase64 string, imageMimeType string) interface{} {
|
||||
if imageBase64 == "" {
|
||||
return userMessage
|
||||
}
|
||||
// OpenAI 多模态:content 为数组,text + image_url(data URL)
|
||||
dataURL := "data:" + imageMimeType + ";base64," + imageBase64
|
||||
if imageMimeType == "" {
|
||||
dataURL = "data:image/jpeg;base64," + imageBase64
|
||||
}
|
||||
parts := []map[string]interface{}{
|
||||
{"type": "text", "text": userMessage},
|
||||
{"type": "image_url", "image_url": map[string]string{"url": dataURL}},
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// 添加当前用户消息
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "user",
|
||||
"content": userMessage,
|
||||
})
|
||||
// generateTextResponse 生成文本回复(支持多模态:当前用户消息可带图)。
|
||||
func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string, imageBase64 string, imageMimeType string) (string, error) {
|
||||
// 使用 interface{} 以支持最后一条 user 消息的 content 为数组(多模态)
|
||||
messages := make([]map[string]interface{}, 0)
|
||||
for _, history := range conversationHistory {
|
||||
messages = append(messages, map[string]interface{}{"role": history.Role, "content": history.Content})
|
||||
}
|
||||
lastContent := buildUserContent(userMessage, imageBase64, imageMimeType)
|
||||
messages = append(messages, map[string]interface{}{"role": "user", "content": lastContent})
|
||||
|
||||
// 构建请求体(OpenAI 兼容格式)
|
||||
requestBody := map[string]interface{}{
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -154,9 +182,11 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message
|
||||
req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
// 发送请求(若发生重定向,req.URL 会被 Client 更新为最终 URL;失败日志便于与配置里的 api_url 对照)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ AI generateTextResponse 请求失败: config.api_url=%s 实际 req.URL=%s err=%v",
|
||||
p.config.APIURL, req.URL.String(), err)
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -198,12 +228,194 @@ func (p *UniversalAIProvider) generateTextResponse(conversationHistory []Message
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// GenerateResponseWithTools 带工具调用的生成(OpenAI 兼容:tools + tool_calls)。
|
||||
// messages 为 OpenAI 格式消息数组(可含 role, content, tool_calls, tool_call_id 等)。
|
||||
// tools 为工具定义数组(如 [{"type":"function","function":{...}}] 或 [{"type":"web_search"}])。
|
||||
// 返回 content、tool_calls(若有)、error。
|
||||
func (p *UniversalAIProvider) GenerateResponseWithTools(messages []map[string]interface{}, tools []map[string]interface{}) (content string, toolCalls []ToolCall, err error) {
|
||||
if p.config.ModelType != "text" {
|
||||
return "", nil, fmt.Errorf("带工具调用仅支持 text 模型")
|
||||
}
|
||||
var requestBody map[string]interface{}
|
||||
if isResponsesAPI(p.config.APIURL) {
|
||||
requestBody = map[string]interface{}{
|
||||
"model": p.config.Model,
|
||||
"input": messages,
|
||||
"stream": false,
|
||||
"tool_choice": "auto",
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
requestBody["tools"] = tools
|
||||
}
|
||||
} else {
|
||||
requestBody = map[string]interface{}{
|
||||
"model": p.config.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
requestBody["tools"] = tools
|
||||
}
|
||||
}
|
||||
jsonData, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("序列化请求失败: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest("POST", p.config.APIURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
authValue := p.config.APIKey
|
||||
if p.adapter.AuthHeader == "Bearer" {
|
||||
authValue = "Bearer " + p.config.APIKey
|
||||
req.Header.Set("Authorization", authValue)
|
||||
} else if p.adapter.AuthHeader == "X-API-Key" {
|
||||
req.Header.Set("X-API-Key", p.config.APIKey)
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ AI GenerateResponseWithTools 请求失败: config.api_url=%s 实际 req.URL=%s err=%v",
|
||||
p.config.APIURL, req.URL.String(), err)
|
||||
return "", nil, fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", nil, fmt.Errorf("API 返回错误: %s (状态码: %d)", string(body), resp.StatusCode)
|
||||
}
|
||||
var responseData map[string]interface{}
|
||||
if err := json.Unmarshal(body, &responseData); err != nil {
|
||||
return "", nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
if errorMsg, ok := responseData["error"].(map[string]interface{}); ok {
|
||||
if msg, ok := errorMsg["message"].(string); ok {
|
||||
return "", nil, fmt.Errorf("API 错误: %s", msg)
|
||||
}
|
||||
}
|
||||
content, toolCalls = p.extractContentAndToolCalls(responseData)
|
||||
return content, toolCalls, nil
|
||||
}
|
||||
|
||||
// extractContentAndToolCalls 从响应中提取 content 与 tool_calls
|
||||
// 支持 Chat Completions(choices[0].message)与 Responses API(output[])
|
||||
func (p *UniversalAIProvider) extractContentAndToolCalls(data map[string]interface{}) (content string, toolCalls []ToolCall) {
|
||||
// Responses API:output 数组内 message 的 content 含 output_text 与 tool_use
|
||||
if output, ok := data["output"].([]interface{}); ok {
|
||||
var textParts []string
|
||||
for _, item := range output {
|
||||
obj, _ := item.(map[string]interface{})
|
||||
if obj == nil || getStr(obj, "type") != "message" {
|
||||
continue
|
||||
}
|
||||
contentParts, _ := obj["content"].([]interface{})
|
||||
for _, part := range contentParts {
|
||||
pm, _ := part.(map[string]interface{})
|
||||
if pm == nil {
|
||||
continue
|
||||
}
|
||||
switch getStr(pm, "type") {
|
||||
case "output_text":
|
||||
if t, ok := pm["text"].(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
case "tool_use":
|
||||
args := getStr(pm, "input")
|
||||
if args == "" {
|
||||
args = "{}"
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: getStr(pm, "id"),
|
||||
Name: getStr(pm, "name"),
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(textParts) > 0 {
|
||||
content = strings.Join(textParts, "")
|
||||
}
|
||||
return content, toolCalls
|
||||
}
|
||||
|
||||
// Chat Completions 格式
|
||||
choices, _ := data["choices"].([]interface{})
|
||||
if len(choices) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
choice, _ := choices[0].(map[string]interface{})
|
||||
message, _ := choice["message"].(map[string]interface{})
|
||||
if message != nil {
|
||||
if c, ok := message["content"].(string); ok {
|
||||
content = c
|
||||
}
|
||||
tcList, _ := message["tool_calls"].([]interface{})
|
||||
for _, t := range tcList {
|
||||
tm, _ := t.(map[string]interface{})
|
||||
fn, _ := tm["function"].(map[string]interface{})
|
||||
args := ""
|
||||
if fn != nil {
|
||||
if a, ok := fn["arguments"].(string); ok {
|
||||
args = a
|
||||
}
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: getStr(tm, "id"),
|
||||
Name: getStr(fn, "name"),
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
return content, toolCalls
|
||||
}
|
||||
|
||||
func getStr(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// extractResponseContent 根据响应路径提取内容(支持灵活的路径配置)。
|
||||
// 例如:"choices[0].message.content" 或 "data.text" 或 "result.content"
|
||||
// 例如:"choices[0].message.content"(Chat Completions)或 Responses API 的 output[].content[].text
|
||||
func (p *UniversalAIProvider) extractResponseContent(data map[string]interface{}, path string) (string, error) {
|
||||
// 默认路径:choices[0].message.content(OpenAI 格式)
|
||||
// Responses API:output 数组内 message 的 content 中 output_text 的 text
|
||||
if output, ok := data["output"].([]interface{}); ok && len(output) > 0 {
|
||||
for _, item := range output {
|
||||
obj, _ := item.(map[string]interface{})
|
||||
if obj == nil {
|
||||
continue
|
||||
}
|
||||
if getStr(obj, "type") != "message" {
|
||||
continue
|
||||
}
|
||||
contentParts, _ := obj["content"].([]interface{})
|
||||
var textParts []string
|
||||
for _, part := range contentParts {
|
||||
pm, _ := part.(map[string]interface{})
|
||||
if pm == nil {
|
||||
continue
|
||||
}
|
||||
if getStr(pm, "type") == "output_text" {
|
||||
if t, ok := pm["text"].(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(textParts) > 0 {
|
||||
return strings.Join(textParts, ""), nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("Responses API 的 output 中未找到 message 文本")
|
||||
}
|
||||
|
||||
// 默认路径:choices[0].message.content(OpenAI Chat Completions 格式)
|
||||
if path == "" || path == "choices[0].message.content" {
|
||||
// 尝试 OpenAI 格式
|
||||
if choices, ok := data["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
@@ -251,12 +463,166 @@ func NewAIProviderFactory() *AIProviderFactory {
|
||||
return &AIProviderFactory{}
|
||||
}
|
||||
|
||||
// ImageGenerationProvider 生图接口(用于 chat_mode=image 渠道)
|
||||
type ImageGenerationProvider interface {
|
||||
// GenerateImage 根据文本描述生成图片,返回图片二进制与 MIME 类型
|
||||
GenerateImage(prompt string) (imageData []byte, mimeType string, err error)
|
||||
}
|
||||
|
||||
// CreateProvider 根据配置创建对应的 AI 提供商。
|
||||
// 设计理念:
|
||||
// 所有主流 AI 服务商都使用 REST API(HTTP/HTTPS),统一使用 UniversalAIProvider 处理
|
||||
// 通过 AdapterConfig 适配不同服务商的细微差异(认证头、响应路径等)
|
||||
func (f *AIProviderFactory) CreateProvider(config AIConfig) (AIProvider, error) {
|
||||
// 所有服务商都使用 REST API,统一处理
|
||||
return NewUniversalAIProvider(config), nil
|
||||
}
|
||||
|
||||
// isPoixeGeminiImageAPI 判断是否为 Poixe 的 Google Gemini Content 生图接口(需 x-goog-api-key 且请求/响应格式不同)
|
||||
func isPoixeGeminiImageAPI(apiURL string) bool {
|
||||
lower := strings.ToLower(apiURL)
|
||||
return (strings.Contains(lower, "poixe.com") && strings.Contains(lower, "generatecontent")) ||
|
||||
(strings.Contains(lower, "poixe.com") && strings.Contains(lower, "v1beta"))
|
||||
}
|
||||
|
||||
// GenerateImage 实现生图(model_type=image 的配置使用)。支持 OpenAI Images 与 Poixe Nano Banana(Gemini Content)两种协议。
|
||||
func (p *UniversalAIProvider) GenerateImage(prompt string) (imageData []byte, mimeType string, err error) {
|
||||
if p.config.ModelType != "image" {
|
||||
return nil, "", fmt.Errorf("当前配置不是生图模型,model_type=%s", p.config.ModelType)
|
||||
}
|
||||
useGemini := isPoixeGeminiImageAPI(p.config.APIURL)
|
||||
var jsonData []byte
|
||||
if useGemini {
|
||||
// Poixe Nano Banana:Google Gemini Content 协议,见 https://docs.poixe.com/cn/docs/models-pricing/nano-banana
|
||||
body := map[string]interface{}{
|
||||
"contents": []map[string]interface{}{
|
||||
{"parts": []map[string]interface{}{{"text": prompt}}},
|
||||
},
|
||||
"generationConfig": map[string]interface{}{
|
||||
"responseModalities": []string{"Text", "Image"},
|
||||
"imageConfig": map[string]interface{}{
|
||||
"aspectRatio": "1:1",
|
||||
"imageSize": "1K",
|
||||
},
|
||||
},
|
||||
}
|
||||
jsonData, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else {
|
||||
// OpenAI 兼容:/v1/images/generations
|
||||
body := map[string]interface{}{
|
||||
"model": p.config.Model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
}
|
||||
if strings.Contains(strings.ToLower(p.config.APIURL), "images") {
|
||||
body["response_format"] = "b64_json"
|
||||
body["size"] = "1024x1024"
|
||||
}
|
||||
jsonData, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequest("POST", p.config.APIURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Poixe Gemini Content 要求 x-goog-api-key;适配器可显式指定,或按 URL 自动识别
|
||||
useGoogKey := (p.adapter != nil && strings.ToLower(p.adapter.AuthHeader) == "x-goog-api-key") || useGemini
|
||||
if useGoogKey {
|
||||
req.Header.Set("x-goog-api-key", p.config.APIKey)
|
||||
} else if p.adapter != nil && p.adapter.AuthHeader == "X-API-Key" {
|
||||
req.Header.Set("X-API-Key", p.config.APIKey)
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("生图 API 错误: %s (状态码: %d)", string(data), resp.StatusCode)
|
||||
}
|
||||
if useGemini {
|
||||
return p.parseGeminiImageResponse(data)
|
||||
}
|
||||
return p.parseOpenAIImageResponse(data)
|
||||
}
|
||||
|
||||
// parseGeminiImageResponse 解析 Poixe/Gemini Content 生图响应:candidates[0].content.parts 中的 inlineData
|
||||
func (p *UniversalAIProvider) parseGeminiImageResponse(data []byte) (imageData []byte, mimeType string, err error) {
|
||||
var parsed struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
InlineData *struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
} `json:"inlineData"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return nil, "", fmt.Errorf("解析 Gemini 生图响应失败: %w", err)
|
||||
}
|
||||
if len(parsed.Candidates) == 0 {
|
||||
return nil, "", errors.New("Gemini 生图未返回 candidates")
|
||||
}
|
||||
for _, part := range parsed.Candidates[0].Content.Parts {
|
||||
if part.InlineData != nil && part.InlineData.Data != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(part.InlineData.Data)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("base64 解码失败: %w", err)
|
||||
}
|
||||
mt := part.InlineData.MimeType
|
||||
if mt == "" {
|
||||
mt = "image/png"
|
||||
}
|
||||
return decoded, mt, nil
|
||||
}
|
||||
}
|
||||
return nil, "", errors.New("Gemini 生图响应中无 inlineData")
|
||||
}
|
||||
|
||||
// parseOpenAIImageResponse 解析 OpenAI 风格生图响应:data[0].b64_json 或 data[0].url
|
||||
func (p *UniversalAIProvider) parseOpenAIImageResponse(data []byte) (imageData []byte, mimeType string, err error) {
|
||||
var parsed struct {
|
||||
Data []struct {
|
||||
B64JSON *string `json:"b64_json"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return nil, "", fmt.Errorf("解析生图响应失败: %w", err)
|
||||
}
|
||||
if len(parsed.Data) == 0 {
|
||||
return nil, "", errors.New("生图 API 未返回图片")
|
||||
}
|
||||
first := parsed.Data[0]
|
||||
if first.B64JSON != nil && *first.B64JSON != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(*first.B64JSON)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("base64 解码失败: %w", err)
|
||||
}
|
||||
return decoded, "image/png", nil
|
||||
}
|
||||
if first.URL != "" {
|
||||
getResp, err := http.Get(first.URL)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("下载生成图片失败: %w", err)
|
||||
}
|
||||
defer getResp.Body.Close()
|
||||
decoded, err := io.ReadAll(getResp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return decoded, "image/png", nil
|
||||
}
|
||||
return nil, "", errors.New("生图响应中无 b64_json 或 url")
|
||||
}
|
||||
|
||||
+605
-52
@@ -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"
|
||||
@@ -20,16 +25,26 @@ type AIService struct {
|
||||
aiConfigRepo *repository.AIConfigRepository
|
||||
messageRepo *repository.MessageRepository
|
||||
conversationRepo *repository.ConversationRepository
|
||||
retrievalService *rag.RetrievalService // RAG 检索服务
|
||||
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,
|
||||
@@ -37,82 +52,151 @@ func NewAIService(
|
||||
conversationRepo: conversationRepo,
|
||||
retrievalService: retrievalService,
|
||||
providerFactory: NewAIProviderFactory(),
|
||||
webSearchProvider: webSearchProvider,
|
||||
embeddingConfigSvc: embeddingConfigSvc,
|
||||
promptConfigSvc: promptConfigSvc,
|
||||
storageService: storageService,
|
||||
systemLogSvc: systemLogSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAIResponse 为对话生成 AI 回复。
|
||||
// conversationID: 对话ID
|
||||
// userMessage: 用户消息
|
||||
// userID: 用户ID(用于回退查找 AI 配置)
|
||||
// 返回: AI 回复内容,如果失败返回错误
|
||||
// GenerateAIResponse 为对话生成 AI 回复(兼容旧调用,使用默认数据源选项)。
|
||||
// 返回: AI 回复内容,若失败返回错误。
|
||||
func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, userID uint) (string, error) {
|
||||
// 1. 获取对话信息,优先使用对话绑定的 AI 配置
|
||||
res, err := s.GenerateAIResponseWithOptions(conversationID, userMessage, userID, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res.Content, nil
|
||||
}
|
||||
|
||||
// GenerateAIResponseWithOptions 根据数据源开关生成一条合成回复,并返回使用的来源标记。
|
||||
// opts 为 nil 时使用默认:知识库+大模型开,联网关。
|
||||
func (s *AIService) GenerateAIResponseWithOptions(conversationID uint, userMessage string, userID uint, opts *GenerateAIResponseInput) (*GenerateAIResponseResult, error) {
|
||||
useKB := true
|
||||
useLLM := true
|
||||
useWeb := false
|
||||
needWeb := false
|
||||
if opts != nil {
|
||||
if opts.UseKnowledgeBase != nil {
|
||||
useKB = *opts.UseKnowledgeBase
|
||||
}
|
||||
if opts.UseLLM != nil {
|
||||
useLLM = *opts.UseLLM
|
||||
}
|
||||
if opts.UseWebSearch != nil {
|
||||
useWeb = *opts.UseWebSearch
|
||||
}
|
||||
needWeb = opts.NeedWebSearch
|
||||
}
|
||||
|
||||
conversation, err := s.conversationRepo.GetByID(conversationID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取对话失败: %v", err)
|
||||
return nil, fmt.Errorf("获取对话失败: %v", err)
|
||||
}
|
||||
|
||||
// 以下 config 为「AI 配置」:对话/联网均使用此接口;与「知识库向量配置」(embedding,如 nekoai)无关。
|
||||
var config *models.AIConfig
|
||||
if conversation.AIConfigID != nil {
|
||||
// 使用对话绑定的配置(多厂商支持)
|
||||
config, err = s.aiConfigRepo.GetByID(*conversation.AIConfigID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 AI 配置失败: %v", err)
|
||||
return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
|
||||
}
|
||||
// 验证配置是否启用
|
||||
if !config.IsActive {
|
||||
return "", errors.New("该模型配置已禁用")
|
||||
return nil, errors.New("该模型配置已禁用")
|
||||
}
|
||||
} else {
|
||||
// 回退:使用用户默认配置(向后兼容)
|
||||
config, err = s.aiConfigRepo.GetActiveByUserID(userID, "text")
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
|
||||
return nil, errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
|
||||
}
|
||||
return "", fmt.Errorf("获取 AI 配置失败: %v", err)
|
||||
return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 解密 API Key
|
||||
apiKey, err := utils.DecryptAPIKey(config.APIKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解密 API Key 失败: %v", err)
|
||||
return nil, fmt.Errorf("解密 API Key 失败: %v", err)
|
||||
}
|
||||
|
||||
// 若当前 AI 配置为生图模型(model_type=image),则直接走生图逻辑,
|
||||
// 不参与 RAG/联网与文本对话流程。前端仍显示在「AI 客服」渠道下。
|
||||
if config.ModelType == "image" {
|
||||
log.Printf("[生图] 对话ID=%d 使用 model_type=image 配置 id=%d,走 GenerateImageReply", conversationID, config.ID)
|
||||
return s.GenerateImageReply(conversationID, userMessage, userID)
|
||||
}
|
||||
|
||||
// 调试:确认本条对话实际使用的 AI 配置(便于排查联网/厂商内置是否走对接口)
|
||||
if needWeb || useWeb {
|
||||
convAIConfigID := "nil"
|
||||
if conversation.AIConfigID != nil {
|
||||
convAIConfigID = fmt.Sprintf("%d", *conversation.AIConfigID)
|
||||
}
|
||||
apiURLMask := config.APIURL
|
||||
if len(apiURLMask) > 50 {
|
||||
apiURLMask = apiURLMask[:50] + "..."
|
||||
}
|
||||
log.Printf("[联网] 对话ID=%d 使用的AI配置: conversation.ai_config_id=%s, config.id=%d, provider=%s, api_url=%s",
|
||||
conversationID, convAIConfigID, config.ID, config.Provider, apiURLMask)
|
||||
}
|
||||
|
||||
// 3. 获取对话历史(用于上下文)
|
||||
history, err := s.buildConversationHistory(conversationID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 获取对话历史失败: %v", err)
|
||||
// 即使获取历史失败,也继续处理(使用空历史)
|
||||
history = []MessageHistory{}
|
||||
}
|
||||
|
||||
// 4. RAG 检索:从知识库中检索相关文档
|
||||
ragContext := ""
|
||||
if s.retrievalService != nil {
|
||||
// 多模态识图:当前条带图时读取文件并转 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 {
|
||||
// AI 调用失败,返回友好的错误消息
|
||||
log.Printf("❌ AI 调用失败: %v", err)
|
||||
return "AI客服好像出了点差错,请联系人工客服解决", 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")
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
// 无任何来源时(例如 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 {
|
||||
log.Printf("❌ AI 调用失败: %v", err)
|
||||
if s.systemLogSvc != nil {
|
||||
_ = s.systemLogSvc.Create(CreateSystemLogInput{
|
||||
Level: "error",
|
||||
Category: "ai",
|
||||
Event: "ai_generate_failed",
|
||||
Source: "backend",
|
||||
ConversationID: &conversationID,
|
||||
UserID: &userID,
|
||||
Message: "AI 调用失败,返回兜底回复",
|
||||
Meta: map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"ai_config": config.ID,
|
||||
},
|
||||
})
|
||||
}
|
||||
return &GenerateAIResponseResult{
|
||||
Content: s.getAIFailReply(),
|
||||
SourcesUsed: strings.Join(sources, ","),
|
||||
GenerationFailed: true,
|
||||
}, nil
|
||||
}
|
||||
if s.systemLogSvc != nil {
|
||||
convID := conversationID
|
||||
uID := userID
|
||||
event := "ai_llm_success"
|
||||
if strings.Contains(strings.Join(sources, ","), "knowledge_base") {
|
||||
event = "ai_rag_success"
|
||||
}
|
||||
_ = s.systemLogSvc.Create(CreateSystemLogInput{
|
||||
Level: "info",
|
||||
Category: "ai",
|
||||
Event: event,
|
||||
Source: "backend",
|
||||
ConversationID: &convID,
|
||||
UserID: &uID,
|
||||
Message: "AI 生成成功",
|
||||
Meta: map[string]interface{}{
|
||||
"sources": strings.Join(sources, ","),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &GenerateAIResponseResult{
|
||||
Content: response,
|
||||
SourcesUsed: strings.Join(sources, ","),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateImageReply 生图渠道专用:根据用户描述生成图片并保存到存储,返回说明文案与图片 URL。
|
||||
func (s *AIService) GenerateImageReply(conversationID uint, prompt string, userID uint) (*GenerateAIResponseResult, error) {
|
||||
conversation, err := s.conversationRepo.GetByID(conversationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取对话失败: %v", err)
|
||||
}
|
||||
if conversation.AIConfigID == nil {
|
||||
return nil, errors.New("生图渠道需要选择生图模型,请先在渠道中选择「生图绘画」并选择模型")
|
||||
}
|
||||
config, err := s.aiConfigRepo.GetByID(*conversation.AIConfigID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 AI 配置失败: %v", err)
|
||||
}
|
||||
if !config.IsActive {
|
||||
return nil, errors.New("该生图模型已禁用")
|
||||
}
|
||||
if config.ModelType != "image" {
|
||||
return nil, fmt.Errorf("当前选择的不是生图模型,model_type=%s", config.ModelType)
|
||||
}
|
||||
apiKey, err := utils.DecryptAPIKey(config.APIKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解密 API Key 失败: %v", err)
|
||||
}
|
||||
var adapterConfig *AdapterConfig
|
||||
if config.AdapterConfig != "" {
|
||||
_ = json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig)
|
||||
}
|
||||
aiConfig := AIConfig{
|
||||
APIURL: config.APIURL,
|
||||
APIKey: apiKey,
|
||||
Model: config.Model,
|
||||
ModelType: config.ModelType,
|
||||
Provider: config.Provider,
|
||||
AdapterConfig: adapterConfig,
|
||||
}
|
||||
provider, err := s.providerFactory.CreateProvider(aiConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgProvider, ok := provider.(ImageGenerationProvider)
|
||||
if !ok {
|
||||
return nil, errors.New("当前提供商不支持生图")
|
||||
}
|
||||
imageData, mimeType, err := imgProvider.GenerateImage(prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.storageService == nil {
|
||||
return nil, errors.New("存储服务未配置,无法保存生成图片")
|
||||
}
|
||||
ext := ".png"
|
||||
if strings.Contains(mimeType, "jpeg") || strings.Contains(mimeType, "jpg") {
|
||||
ext = ".jpg"
|
||||
}
|
||||
fileURL, err := s.storageService.SaveMessageFile(conversationID, bytes.NewReader(imageData), "generated"+ext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存生成图片失败: %v", err)
|
||||
}
|
||||
content := "已根据您的描述生成图片。"
|
||||
return &GenerateAIResponseResult{
|
||||
Content: content,
|
||||
SourcesUsed: "",
|
||||
GeneratedFileURL: &fileURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AIService) buildNoKBPrompt(userMessage string) string {
|
||||
if s.promptConfigSvc != nil {
|
||||
tpl, err := s.promptConfigSvc.GetNoKBPromptTemplate()
|
||||
if err == nil && tpl != "" {
|
||||
return replaceUserMessageOnly(tpl, userMessage)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`你是一个智能客服助手。当前未使用知识库,请仅基于你的知识回答用户问题。
|
||||
|
||||
用户问题:%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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,6 +34,8 @@ func (s *EmbeddingConfigService) GetForAPI() (*EmbeddingConfigResult, error) {
|
||||
APIKeyMasked: "",
|
||||
Model: "text-embedding-3-small",
|
||||
CustomerCanUseKB: true,
|
||||
VisitorWebSearchEnabled: false,
|
||||
WebSearchSource: "custom",
|
||||
}, nil
|
||||
}
|
||||
masked := ""
|
||||
@@ -47,10 +49,19 @@ func (s *EmbeddingConfigService) GetForAPI() (*EmbeddingConfigResult, error) {
|
||||
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
|
||||
@@ -148,9 +189,16 @@ type EmbeddingConfigResult struct {
|
||||
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"`
|
||||
@@ -158,4 +206,6 @@ type UpdateEmbeddingConfigInput struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -61,9 +61,8 @@ 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,
|
||||
@@ -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 回复视为客服消息
|
||||
SenderID: 0,
|
||||
SenderIsAgent: true,
|
||||
Content: aiResponse,
|
||||
MessageType: "user_message",
|
||||
ChatMode: "ai", // AI 回复消息的模式为 "ai"
|
||||
ChatMode: conv.ChatMode,
|
||||
IsRead: false,
|
||||
SourcesUsed: sourcesUsed,
|
||||
FileURL: aiMessageFileURL,
|
||||
FileType: aiMessageFileType,
|
||||
IsAIGenerationFailed: aiGenFailed,
|
||||
}
|
||||
|
||||
if err := s.messages.Create(aiMessage); err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -33,7 +33,8 @@ func (h *HealthChecker) Check(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查向量存储服务(简单搜索测试)
|
||||
// 检查向量存储服务(简单搜索测试);未启用 Milvus 时跳过
|
||||
if h.vectorStoreService != nil && h.vectorStoreService.IsAvailable() {
|
||||
testVector := make([]float32, svc.GetDimension())
|
||||
for i := range testVector {
|
||||
testVector[i] = 0.1
|
||||
@@ -42,6 +43,7 @@ func (h *HealthChecker) Check(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type UpdateEmbeddingConfigRequest,
|
||||
} from "@/features/agent/services/embeddingConfigApi";
|
||||
import { useProfile } from "@/features/agent/hooks/useProfile";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import { apiUrl } from "@/lib/config";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
@@ -55,6 +55,8 @@ export default function SettingsPage(props: any = {}) {
|
||||
api_key: "",
|
||||
model: "text-embedding-3-small",
|
||||
customer_can_use_kb: true,
|
||||
visitor_web_search_enabled: false,
|
||||
web_search_source: "custom" as "vendor" | "custom",
|
||||
});
|
||||
const [embeddingLoading, setEmbeddingLoading] = useState(false);
|
||||
const [embeddingSubmitting, setEmbeddingSubmitting] = useState(false);
|
||||
@@ -114,6 +116,8 @@ export default function SettingsPage(props: any = {}) {
|
||||
api_key: "",
|
||||
model: data.model || "text-embedding-3-small",
|
||||
customer_can_use_kb: data.customer_can_use_kb ?? true,
|
||||
visitor_web_search_enabled: data.visitor_web_search_enabled ?? false,
|
||||
web_search_source: data.web_search_source === "vendor" ? "vendor" : "custom",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("加载知识库向量配置失败:", e);
|
||||
@@ -141,6 +145,8 @@ export default function SettingsPage(props: any = {}) {
|
||||
api_url: embeddingForm.api_url || undefined,
|
||||
model: embeddingForm.model || undefined,
|
||||
customer_can_use_kb: embeddingForm.customer_can_use_kb,
|
||||
visitor_web_search_enabled: embeddingForm.visitor_web_search_enabled,
|
||||
web_search_source: embeddingForm.web_search_source,
|
||||
};
|
||||
if (embeddingForm.api_key) {
|
||||
data.api_key = embeddingForm.api_key;
|
||||
@@ -240,7 +246,7 @@ export default function SettingsPage(props: any = {}) {
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/logout`, { method: "POST" });
|
||||
await fetch(apiUrl("/logout"), { method: "POST" });
|
||||
} catch (error) {
|
||||
console.error("退出登录失败:", error);
|
||||
} finally {
|
||||
@@ -416,6 +422,66 @@ export default function SettingsPage(props: any = {}) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 联网搜索设置(与知识库向量模型独立;实际仍写入同一配置,仅 UI 分离) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>联网搜索设置</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
控制对话中的联网搜索方式与访客端是否显示联网选项。与上方「知识库向量模型」无关,仅影响 AI 对话时的联网行为。
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{embeddingLoading ? (
|
||||
<div className="text-center py-6 text-muted-foreground">加载中...</div>
|
||||
) : (
|
||||
<form onSubmit={handleSaveEmbeddingConfig} className="space-y-4">
|
||||
{embeddingError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{embeddingError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="block text-sm font-medium mb-1">联网方式</Label>
|
||||
<select
|
||||
value={embeddingForm.web_search_source}
|
||||
onChange={(e) =>
|
||||
setEmbeddingForm({
|
||||
...embeddingForm,
|
||||
web_search_source: e.target.value as "vendor" | "custom",
|
||||
})
|
||||
}
|
||||
className="w-full max-w-xs px-3 py-2 border border-input rounded-md text-sm bg-background"
|
||||
>
|
||||
<option value="custom">自建(Serper)</option>
|
||||
<option value="vendor">厂商内置</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
自建:由后端通过 Serper(MCP 或 HTTP)执行;厂商内置:使用当前对话所用 AI 厂商自带的 web search,不占用 Serper。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="visitor_web_search_enabled_standalone"
|
||||
checked={embeddingForm.visitor_web_search_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setEmbeddingForm({
|
||||
...embeddingForm,
|
||||
visitor_web_search_enabled: checked === true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="visitor_web_search_enabled_standalone" className="text-sm cursor-pointer">
|
||||
访客小窗显示「本回合联网搜索」选项
|
||||
</Label>
|
||||
</div>
|
||||
<Button type="submit" disabled={embeddingSubmitting}>
|
||||
{embeddingSubmitting ? "保存中..." : "保存联网设置"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 配置表单 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
+7
-612
@@ -1,621 +1,16 @@
|
||||
"use client";
|
||||
import { HomePageClient } from "@/components/marketing/HomePageClient";
|
||||
import { buildHomeJsonLd } from "@/lib/seo/home-json-ld";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Users,
|
||||
Zap,
|
||||
Shield,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Star,
|
||||
HelpCircle,
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import Link from "next/link";
|
||||
import { ScreenshotDisplay } from "@/components/ScreenshotDisplay";
|
||||
import { ChatWidget } from "@/components/visitor/ChatWidget";
|
||||
import { FloatingButton } from "@/components/visitor/FloatingButton";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FadeIn, FadeInStagger, FadeInItem } from "@/components/ui/fade-in";
|
||||
import { stats, testimonials, partnerLogos } from "@/lib/stats-config";
|
||||
|
||||
/**
|
||||
* AI-CS 智能客服系统 - 产品官网首页
|
||||
*
|
||||
* 包含:
|
||||
* - Hero 区域(主标题、副标题、CTA按钮)
|
||||
* - 核心功能介绍
|
||||
* - 产品特性
|
||||
* - 案例研究/客户评价
|
||||
* - 底部 CTA
|
||||
*/
|
||||
export default function HomePage() {
|
||||
const [visitorId, setVisitorId] = useState<number | null>(null);
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
|
||||
// 初始化访客 ID(使用 localStorage 保持连续性)
|
||||
useEffect(() => {
|
||||
let stored = window.localStorage.getItem("visitor_id");
|
||||
if (!stored) {
|
||||
stored = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
|
||||
window.localStorage.setItem("visitor_id", stored);
|
||||
}
|
||||
const parsed = Number.parseInt(stored, 10);
|
||||
setVisitorId(Number.isNaN(parsed) ? null : parsed);
|
||||
}, []);
|
||||
|
||||
const handleToggleChat = () => {
|
||||
setIsChatOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleOpenChat = () => {
|
||||
// 如果访客ID还没初始化,等待一下
|
||||
if (visitorId === null) {
|
||||
// 等待访客ID初始化后再打开
|
||||
setTimeout(() => {
|
||||
setIsChatOpen(true);
|
||||
}, 500);
|
||||
} else {
|
||||
// 直接打开聊天窗口
|
||||
setIsChatOpen(true);
|
||||
}
|
||||
};
|
||||
const jsonLd = buildHomeJsonLd();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
{/* Hero - Chatbase 风格:简洁、留白、一句主标题 + 副标题 + CTA */}
|
||||
<section className="container mx-auto px-4 pt-20 pb-28 md:pt-28 md:pb-36 lg:pt-36 lg:pb-44 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-muted/30 to-transparent pointer-events-none" />
|
||||
<div className="max-w-3xl mx-auto text-center relative z-10">
|
||||
<p className="text-sm font-medium text-muted-foreground tracking-wide uppercase mb-4">
|
||||
AI 智能客服
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-foreground tracking-tight mb-6 leading-[1.15]">
|
||||
让客户服务更简单、更高效
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-muted-foreground mb-10 max-w-xl mx-auto leading-relaxed">
|
||||
7×24 小时智能应答,AI 与人工无缝切换,释放团队时间专注更有价值的事
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto text-base px-8 py-6 rounded-xl shadow-sm hover:shadow transition-shadow"
|
||||
onClick={handleOpenChat}
|
||||
>
|
||||
免费试用
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto text-base px-8 py-6 rounded-xl border border-border hover:bg-muted/50"
|
||||
>
|
||||
<Link href="/agent/login">客服登录</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">无需信用卡,立即可用</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 信任数据 - 轻量展示 */}
|
||||
<section className="py-12 md:py-16 border-t border-border/50">
|
||||
<FadeIn>
|
||||
<div className="container mx-auto px-4">
|
||||
<p className="text-xs font-medium text-muted-foreground text-center mb-8 tracking-wide">
|
||||
深受企业信赖
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-4xl mx-auto">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-2xl md:text-3xl font-semibold text-foreground">{stat.value}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</section>
|
||||
|
||||
{/* Logo 墙 - 有数据时展示 */}
|
||||
{partnerLogos.length > 0 && (
|
||||
<section className="py-12 md:py-16 border-t border-border/50">
|
||||
<FadeIn>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-wrap items-center justify-center gap-10 md:gap-14 opacity-50">
|
||||
{partnerLogos.map((partner, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm text-muted-foreground font-medium hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{partner.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 核心功能 */}
|
||||
<section id="features" className="container mx-auto px-4 py-20 md:py-28">
|
||||
<FadeIn>
|
||||
<div className="text-center mb-14 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">
|
||||
功能特性
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base max-w-xl mx-auto">
|
||||
专业、及时的服务体验,让每个客户都感受到高效与可靠
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 max-w-6xl mx-auto px-4">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Bot className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>7×24 小时智能应答</CardTitle>
|
||||
<CardDescription>
|
||||
让 AI 帮你处理常见问题,释放团队时间,专注于更有价值的工作
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
支持多种 AI 模型,选择最适合你的
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
智能理解客户意图,准确回答
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
记住对话上下文,体验更自然
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Users className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>无缝切换 AI 与人工</CardTitle>
|
||||
<CardDescription>
|
||||
复杂问题一键转人工,让客户随时得到最合适的帮助
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
消息实时同步,无缝衔接
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
清晰显示在线状态,客户一目了然
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
多客服协作,高效处理复杂问题
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<MessageSquare className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>消息即时到达</CardTitle>
|
||||
<CardDescription>
|
||||
毫秒级响应,让客户感受到专业、及时的服务体验
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
双向实时通信,就像面对面交流
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
消息已读状态,沟通更透明
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
支持文件图片,沟通更便捷
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Zap className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>极速响应,稳定可靠</CardTitle>
|
||||
<CardDescription>
|
||||
毫秒级响应速度,轻松应对高并发,让服务永不中断
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
毫秒级响应,客户无需等待
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
高并发支持,业务增长无压力
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
自动重连,服务永不中断
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<Shield className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>企业级安全保障</CardTitle>
|
||||
<CardDescription>
|
||||
数据加密存储,权限精细管理,让您的业务数据安全无忧
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
API 密钥加密,安全可靠
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
精细权限管理,灵活可控
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
自动数据备份,万无一失
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<BarChart3 className="w-6 h-6 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<CardTitle>数据驱动决策</CardTitle>
|
||||
<CardDescription>
|
||||
全面的数据分析,帮助您了解客户需求,优化服务质量
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
会话统计分析,洞察客户需求
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
客服工作量统计,合理分配资源
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||
响应时间分析,持续优化服务
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
</FadeInStagger>
|
||||
</section>
|
||||
|
||||
{/* 界面展示 */}
|
||||
<FadeIn>
|
||||
<section id="screenshots" className="container mx-auto px-4 py-20 md:py-28 border-t border-border/50">
|
||||
<div className="text-center mb-14 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">界面展示</h2>
|
||||
<p className="text-muted-foreground text-base max-w-xl mx-auto">精心设计的界面,让管理更轻松</p>
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Tabs defaultValue="dashboard" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 md:grid-cols-5 mb-8">
|
||||
<TabsTrigger value="dashboard">工作台</TabsTrigger>
|
||||
<TabsTrigger value="visitor">访客端</TabsTrigger>
|
||||
<TabsTrigger value="ai-config">AI配置</TabsTrigger>
|
||||
<TabsTrigger value="users">用户管理</TabsTrigger>
|
||||
<TabsTrigger value="faq">FAQ管理</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="dashboard" className="mt-0">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScreenshotDisplay
|
||||
imageName="dashboard.png"
|
||||
placeholderIcon={LayoutDashboard}
|
||||
placeholderText="工作台界面"
|
||||
alt="AI-CS 工作台界面"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="visitor" className="mt-0">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScreenshotDisplay
|
||||
imageName="visitor.png"
|
||||
placeholderIcon={Globe}
|
||||
placeholderText="访客端界面"
|
||||
alt="AI-CS 访客端界面"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="ai-config" className="mt-0">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScreenshotDisplay
|
||||
imageName="ai-config.png"
|
||||
placeholderIcon={Bot}
|
||||
placeholderText="AI配置界面"
|
||||
alt="AI-CS AI配置界面"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="users" className="mt-0">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScreenshotDisplay
|
||||
imageName="users.png"
|
||||
placeholderIcon={Users}
|
||||
placeholderText="用户管理界面"
|
||||
alt="AI-CS 用户管理界面"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="faq" className="mt-0">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<ScreenshotDisplay
|
||||
imageName="faq.png"
|
||||
placeholderIcon={FileText}
|
||||
placeholderText="FAQ管理界面"
|
||||
alt="AI-CS FAQ管理界面"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
</FadeIn>
|
||||
|
||||
{/* 客户评价 */}
|
||||
<section id="testimonials" className="container mx-auto px-4 py-12 md:py-16 lg:py-24">
|
||||
<FadeIn>
|
||||
<div className="text-center mb-8 md:mb-12 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-3 md:mb-4">客户评价</h2>
|
||||
<p className="text-muted-foreground text-base sm:text-lg max-w-2xl mx-auto">
|
||||
已有众多企业选择我们,让客户服务变得更简单
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 max-w-6xl mx-auto px-4">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<FadeInItem key={index}>
|
||||
<Card className="border-2 hover:border-primary/50 hover:scale-[1.02] hover:shadow-xl shadow-lg transition-all duration-300 h-full flex flex-col">
|
||||
<CardContent className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className="w-4 h-4 fill-yellow-400 text-yellow-400"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 flex-1 leading-relaxed">
|
||||
"{testimonial.content}"
|
||||
</p>
|
||||
<div className="flex items-center gap-3 pt-4 border-t">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{testimonial.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{testimonial.company}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
))}
|
||||
</FadeInStagger>
|
||||
</section>
|
||||
|
||||
{/* 常见问题 */}
|
||||
<section id="faq" className="container mx-auto px-4 py-20 md:py-28 border-t border-border/50">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<FadeIn>
|
||||
<div className="text-center mb-14 px-4">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">常见问题</h2>
|
||||
<p className="text-muted-foreground text-base max-w-xl mx-auto">快速了解 AI-CS,解答您的疑问</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<FadeInStagger className="grid grid-cols-1 md:grid-cols-2 gap-4 px-4">
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">AI-CS 支持哪些 AI 模型?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
支持 OpenAI、DeepSeek、百智云等主流 AI 平台,您可以选择最适合的模型,也可以自定义 API 配置,灵活便捷。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">如何实现 AI 和人工客服的切换?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
访客只需一键即可切换,复杂问题转人工,简单问题 AI 处理,系统会自动无缝衔接,让客户体验更流畅。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">系统支持文件上传吗?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
完全支持!访客和客服都可以上传图片和文件,支持图片预览和文件下载,让沟通更直观、更高效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">如何集成到现有网站?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
只需在网站中嵌入一段代码即可,支持自定义样式和位置,几分钟就能完成集成,无需复杂配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">系统支持多客服协作吗?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
完全支持!多个客服可以同时在线,支持会话智能分配、一键转接和团队协作,让服务更高效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
|
||||
<FadeInItem>
|
||||
<Card className="border border-border bg-card hover:border-primary/30 hover:shadow-md shadow-sm transition-all duration-200 group">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<HelpCircle className="w-5 h-5 text-primary group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">数据安全如何保障?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
企业级安全保障,API 密钥加密存储,精细权限管理,所有数据都经过安全加密处理,让您放心使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FadeInItem>
|
||||
</FadeInStagger>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 底部 CTA - Chatbase 风格 */}
|
||||
<section className="border-t border-border/50 py-20 md:py-28">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl sm:text-3xl font-semibold text-foreground tracking-tight mb-3">
|
||||
让客户体验成为你的竞争力
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base mb-8 max-w-lg mx-auto">
|
||||
用 AI-CS 提供更专业、更高效的客户服务,拉开与竞品的差距
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-xl px-8 py-6 shadow-sm hover:shadow transition-shadow"
|
||||
onClick={handleOpenChat}
|
||||
>
|
||||
免费试用
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
<p className="mt-4 text-sm text-muted-foreground">无需信用卡,立即可用</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 页脚 */}
|
||||
<Footer />
|
||||
|
||||
{/* 客服插件 */}
|
||||
{visitorId !== null && (
|
||||
<>
|
||||
{/* 浮动按钮 */}
|
||||
<FloatingButton onClick={handleToggleChat} isOpen={isChatOpen} />
|
||||
{/* 聊天小窗 */}
|
||||
{isChatOpen && (
|
||||
<ChatWidget
|
||||
visitorId={visitorId}
|
||||
isOpen={isChatOpen}
|
||||
onToggle={handleToggleChat}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
<HomePageClient />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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_search,custom=自建 Serper */
|
||||
web_search_source?: "vendor" | "custom";
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 访客小窗配置(联网设置,供访客端拉取)
|
||||
export interface VisitorWidgetConfig {
|
||||
web_search_enabled: boolean;
|
||||
}
|
||||
|
||||
// 更新入参(api_key 可选,不传则保留原密钥)
|
||||
export interface UpdateEmbeddingConfigRequest {
|
||||
embedding_type?: string;
|
||||
@@ -18,11 +26,14 @@ export interface UpdateEmbeddingConfigRequest {
|
||||
api_key?: string;
|
||||
model?: string;
|
||||
customer_can_use_kb?: boolean;
|
||||
visitor_web_search_enabled?: boolean;
|
||||
/** 联网方式:vendor=厂商内置,custom=自建(Serper) */
|
||||
web_search_source?: "vendor" | "custom";
|
||||
}
|
||||
|
||||
/** 获取当前知识库向量配置(需传 user_id 以通过代理) */
|
||||
export async function fetchEmbeddingConfig(userId: number): Promise<EmbeddingConfig> {
|
||||
const res = await fetch(`${API_BASE_URL}/agent/embedding-config?user_id=${userId}`, {
|
||||
const res = await fetch(`${apiUrl("/agent/embedding-config")}?user_id=${userId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -36,7 +47,7 @@ export async function updateEmbeddingConfig(
|
||||
userId: number,
|
||||
data: UpdateEmbeddingConfigRequest
|
||||
): Promise<EmbeddingConfig> {
|
||||
const res = await fetch(`${API_BASE_URL}/agent/embedding-config`, {
|
||||
const res = await fetch(apiUrl("/agent/embedding-config"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: userId, ...data }),
|
||||
@@ -47,3 +58,10 @@ export async function updateEmbeddingConfig(
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 获取访客小窗配置(联网设置等,无需登录,供访客端调用) */
|
||||
export async function fetchVisitorWidgetConfig(): Promise<VisitorWidgetConfig> {
|
||||
const res = await fetch(apiUrl("/visitor/widget-config"), { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("获取小窗配置失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 配置",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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(/\/$/, "");
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
export const websiteConfig = {
|
||||
/** 在线演示站点(「立即体验 / 打开 Demo」等入口) */
|
||||
demoUrl: "https://demo.cscorp.top",
|
||||
|
||||
// GitHub 仓库地址
|
||||
github: {
|
||||
repo: "https://github.com/2930134478/AI-CS",
|
||||
|
||||
@@ -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
@@ -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*`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user