Browse Source

ChatSDK完善客服 SDK 的页面样式与交互功能。两轮共落地 13 项改进(快捷问题、暗色模式、停止生成、消息反馈、a11y等)

增加开发、生产配置文件
增加部署文档DEPLOY.md
dev-mcp
wanghanlin 4 weeks ago
parent
commit
476e34f704
  1. 4
      CLAUDE.md
  2. 753
      DEPLOY.md
  3. 7
      client/README.md
  4. 1168
      client/dist/chatbot-sdk.js
  5. 2
      client/dist/chatbot-sdk.js.map
  6. 2
      client/dist/chatbot-sdk.min.js
  7. 2
      client/dist/chatbot-sdk.min.js.map
  8. 33
      client/src/api.ts
  9. 362
      client/src/chat.ts
  10. 6
      client/src/config.ts
  11. 246
      client/src/dom.ts
  12. 34
      client/src/i18n.ts
  13. 187
      client/src/index.ts
  14. 350
      client/src/styles.ts
  15. 18
      client/src/types.ts
  16. 46
      pom.xml
  17. 36
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  18. 6
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  19. 7
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  20. 31
      src/main/resources/application-dev.yml
  21. 41
      src/main/resources/application-prod.yml
  22. 59
      src/main/resources/application.yml
  23. 68
      src/main/resources/static/components/ModelConfigManager.js
  24. 1168
      src/main/resources/static/sdk/chatbot-sdk.js
  25. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  26. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  27. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map

4
CLAUDE.md

@ -71,10 +71,10 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
### 模型配置管理 ### 模型配置管理
- **ai_model_config 表**: 存储大模型配置,支持多套配置按 App 类型(CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE)独立管理。**所有模型参数(名称、温度、最大Token、API Key、Base URL 等)全部由此表管理,不再依赖 application.yml** - **ai_model_config 表**: 存储大模型配置,支持多套配置按 App 类型(CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE)独立管理。**所有模型参数(名称、温度、最大Token、API Key、Base URL 等)全部由此表管理,不再依赖 application.yml**
- **激活互斥**: 同一 App 类型只能有一个 `is_active=true` 的配置,激活操作由 Service 层 `@Transactional` 保证 - **激活互斥**: 同一 App 类型只能有一个 `is_active=true` 的配置,激活操作由 Service 层 `@Transactional` 保证
- **启动 seed**: 首次启动时使用硬编码默认值写入 DB(`qwen-turbo` / `text-embedding-v2`),用户可在前端修改
- **启动 seed**: 首次启动时使用硬编码默认值写入 DB(`qwen-turbo` / `text-embedding-v2`),用户可在前端修改。**DashScope 自动配置已禁用**(`spring.ai.dashscope.enabled=false`),api-key 可选——yml 与 DB 均未配置 api-key 不影响启动,实际调用 AI 功能前在前端「AI 大模型配置管理」页面配置即可
- **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性 - **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性
- **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位 - **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位
- **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)
- **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)。**OpenAI 兼容路径使用自定义 `completionsPath`**(与 EmbeddingModelFactory 的 embeddingsPath 对应),baseUrl 已含版本段的厂商(moonshot `/v1`、volcengine `/api/v3`、zhipu `/api/paas/v4`)设为 `/chat/completions`,其余使用默认 `/v1/chat/completions`
- **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效 - **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建 - **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建
- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()` - **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`

753
DEPLOY.md

@ -0,0 +1,753 @@
# AI 智能客服系统 Linux 部署详细教程
> 项目:AI 智能客服系统(Spring AI Alibaba + 通义千问 + PGVector)
> 适用服务器:Linux(CentOS 7/8、Ubuntu 20.04/22.04、Debian 等通用发行版)
> 部署方式:Docker 部署 PostgreSQL + PGVector 数据库,JDK 17 直接运行 Spring Boot Fat Jar
> 生成日期:2026-06-27
---
## 目录
1. [部署架构与端口规划](#1-部署架构与端口规划)
2. [服务器环境准备](#2-服务器环境准备)
3. [安装 Docker 与 Docker Compose](#3-安装-docker-与-docker-compose)
4. [Docker 部署 PostgreSQL + PGVector 数据库](#4-docker-部署-postgresql--pgvector-数据库)
5. [数据库初始化验证](#5-数据库初始化验证)
6. [后端项目打包](#6-后端项目打包)
7. [上传与生产配置](#7-上传与生产配置)
8. [启动后端服务](#8-启动后端服务)
9. [配置 AI 大模型(必做)](#9-配置-ai-大模型必做)
10. [开机自启:systemd 服务](#10-开机自启systemd-服务)
11. [Nginx 反向代理(可选)](#11-nginx-反向代理可选)
12. [日常运维与排错](#12-日常运维与排错)
13. [附:一键部署脚本](#13-附一键部署脚本)
---
## 1. 部署架构与端口规划
```
[浏览器/客户端]
│ :80 / :443(可选 Nginx)
[ Linux 服务器 ]
├── Docker 容器: postgres-pgvector :5432 ← 数据库
└── JDK 17 进程: yu-ai-agent.jar :9090 ← 后端服务(含前端静态页面)
```
| 组件 | 端口 | 说明 |
|------|------|------|
| Spring Boot 后端 | 9090 | API + 前端管理页面(同源) |
| PostgreSQL | 5432 | 仅容器内 / 本机访问,不对公网开放 |
**关键说明(务必先读)**
- 本项目**前端页面已打包进 jar**(位于 `src/main/resources/static/`),部署时**无需单独部署前端**,访问 `http://服务器IP:9090/index.html` 即可。
- 数据库表(`knowledge_category`、`knowledge_document`、`ai_model_config`、`chat_message` 等)由 `DatabaseInitConfig` 在应用启动时**自动创建**,向量表 `vector_store``PgVectorStore` 自动创建(`initializeSchema=true`),**无需手动执行 SQL**。
- `application.yml``spring.ai.dashscope.enabled: false`,DashScope 自动配置已关闭;**即便没有 API Key 也能正常启动服务**,AI 功能在「AI 大模型配置管理」页面填入 Key 后热切换生效。
- 模型名称、温度、最大 Token、API Key 等参数**全部在前端页面 / DB `ai_model_config` 表中管理**,yml 仅保留可选的种子 `api-key`
---
## 2. 服务器环境准备
### 2.1 系统要求
| 项目 | 要求 |
|------|------|
| OS | Linux x86_64(CentOS 7+ / Ubuntu 20.04+) |
| CPU/内存 | 建议 2 核 4G 起步(知识库向量化较耗内存) |
| 磁盘 | 20G+(文档、向量数据) |
| 网络 | 服务器需能访问 `dashscope.aliyuncs.com`(通义千问)等 AI 提供商 API |
### 2.2 基础工具安装
```bash
# Ubuntu / Debian
sudo apt update && sudo apt install -y wget curl vim tar unzip net-tools
# CentOS / RHEL
sudo yum install -y wget curl vim tar unzip net-tools
```
### 2.3 防火墙放行端口
```bash
# Ubuntu (ufw)
sudo ufw allow 9090/tcp
sudo ufw reload
# CentOS (firewalld)
sudo firewall-cmd --permanent --add-port=9090/tcp
sudo firewall-cmd --reload
```
> 若部署在云服务器(阿里云/腾讯云等),还需在**安全组**中放行 9090 端口。
---
## 3. 安装 Docker 与 Docker Compose
### 3.1 一键安装 Docker
```bash
# 使用阿里云镜像加速安装(国内推荐)
curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun
# 启动并设置开机自启
sudo systemctl start docker
sudo systemctl enable docker
# 验证
docker --version
docker compose version
```
### 3.2 配置 Docker 镜像加速(国内必做)
```bash
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<'EOF'
{
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://dockerproxy.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker
```
---
## 4. Docker 部署 PostgreSQL + PGVector 数据库
本项目原开发机使用 PostgreSQL + PGVector 扩展,部署时保持一致。`pgvector/pgvector` 镜像内置了 vector 扩展,开箱即用。
### 4.1 创建数据目录与 compose 文件
```bash
# 创建部署目录
sudo mkdir -p /opt/support-bot/{db,data,app,logs}
cd /opt/support-bot
# 创建 compose 文件
sudo tee /opt/support-bot/docker-compose.yml <<'EOF'
version: "3.8"
services:
postgres:
image: pgvector/pgvector:pg16
container_name: support-bot-postgres
restart: always
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: supportbot123
POSTGRES_DB: support_bot
TZ: Asia/Shanghai
volumes:
- /opt/support-bot/data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d support_bot"]
interval: 10s
timeout: 5s
retries: 5
EOF
```
> **参数说明**
> - `pgvector/pgvector:pg16`:内置 PGVector 扩展的 PostgreSQL 16 镜像(与开发机 PG 12+ 兼容,建议用 16)。
> - `POSTGRES_DB: support_bot`:自动创建本项目所需数据库。
> - `POSTGRES_PASSWORD`:请修改为强密码,并同步修改后端配置。
> - 数据持久化到宿主机 `/opt/support-bot/data`,容器重建不丢数据。
### 4.2 启动数据库
```bash
cd /opt/support-bot
sudo docker compose up -d
# 查看状态(应为 healthy)
sudo docker compose ps
sudo docker logs -f support-bot-postgres
```
### 4.3 在数据库中启用 vector 扩展
进入容器执行(PGVector 扩展需在目标库中显式启用):
```bash
sudo docker exec -it support-bot-postgres psql -U postgres -d support_bot -c "CREATE EXTENSION IF NOT EXISTS vector;"
# 验证扩展已安装
sudo docker exec -it support-bot-postgres psql -U postgres -d support_bot -c "\dx"
```
输出中应看到 `vector` 扩展。
> 说明:应用启动时 `PgVectorStore` 会自动创建 `vector_store` 表,但 vector 扩展本身需提前 `CREATE EXTENSION` 启用(上述命令已完成)。
---
## 5. 数据库初始化验证
确认数据库与扩展就绪:
```bash
# 列出数据库
sudo docker exec -it support-bot-postgres psql -U postgres -l
# 查看 vector 扩展
sudo docker exec -it support-bot-postgres psql -U postgres -d support_bot -c "SELECT extname FROM pg_extension;"
```
此时业务表(`knowledge_document` 等)尚不存在 —— 它们会在后端首次启动时由 `DatabaseInitConfig` 自动创建。**无需手动建表。**
---
## 6. 后端项目打包
### 6.1 方式 A:在本机(Windows)打包后上传(推荐)
项目根目录已含 `mvnw`,无需本机安装 Maven。
```bash
# 在项目根目录 D:\IdeaProjects\chat-bot 执行
./mvnw clean package -DskipTests
```
打包成功后,jar 位于:
```
D:\IdeaProjects\chat-bot\target\yu-ai-agent-0.0.1-SNAPSHOT.jar
```
> `application.yml` 已被 `.gitignore` 排除,但 jar 中会包含本地 yml。打包前请确认 yml 中数据库地址已改为生产地址(见第 7 节),或使用外部配置覆盖(推荐)。
### 6.2 方式 B:在 Linux 服务器上打包
```bash
# 安装 JDK 17 与 Maven
# Ubuntu
sudo apt install -y openjdk-17-jdk maven
# CentOS
sudo yum install -y java-17-openjdk-devel maven
# 验证
java -version
mvn -version
# 上传源码或 git clone 后,在项目根目录执行
mvn clean package -DskipTests
# jar 同样生成在 target/ 目录
```
### 6.3 上传 jar 到服务器
```bash
# 在本机 Windows 执行(scp 或使用 WinSCP / FinalShell 等工具)
scp target/yu-ai-agent-0.0.1-SNAPSHOT.jar user@服务器IP:/opt/support-bot/app/
```
---
## 7. 上传与生产配置
### 7.1 安装 JDK 17(运行 jar 必需)
```bash
# Ubuntu / Debian
sudo apt install -y openjdk-17-jre-headless
# CentOS / RHEL
sudo yum install -y java-17-openjdk-headless
# 验证
java -version
```
### 7.2 准备外部配置文件(推荐,避免改 jar)
在服务器上创建生产配置,覆盖 jar 内的默认 yml。Spring Boot 启动时同目录下的 `application.yml` 会自动覆盖 jar 内配置。
```bash
sudo tee /opt/support-bot/app/application.yml <<'EOF'
# ==================== 服务端口 ====================
server:
port: 9090
spring:
ai:
dashscope:
# 关闭 DashScope 自动配置(本项目所有模型由 Factory 按 DB 活跃配置手动构建)
enabled: false
# 仅用于首次启动写入种子默认值;可留空,真正生效的 Key 在前端页面配置
api-key:
# ==================== 数据源配置(PostgreSQL + PGVector) ====================
datasource:
driver-class-name: org.postgresql.Driver
# 指向本机 Docker 部署的 PostgreSQL
url: jdbc:postgresql://127.0.0.1:5432/support_bot
username: postgres
password: supportbot123
hikari:
maximum-pool-size: 10
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 20000
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
# ==================== MyBatis Plus 配置 ====================
mybatis-plus:
type-aliases-package: com.wok.supportbot.entity
mapper-locations: classpath*:mapper/**/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
global-config:
db-config:
logic-delete-field: isDelete
logic-delete-value: true
logic-not-delete-value: false
id-type: assign_id
# ==================== 知识库文档处理配置 ====================
knowledge:
chunk:
chunk-size: 200
overlap: 100
min-chunk-size-chars: 10
max-num-chunks: 5000
keep-separator: true
vector:
# 向量维度,需与 Embedding 模型输出维度一致
# 千问 text-embedding-v2: 1536 | 豆包 doubao-embedding-text-240515: 2048
dimension: 1536
role:
strict-isolation: false
# ==================== Knife4j API 文档配置 ====================
springdoc:
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs
group-configs:
- group: default
paths-to-match: /**
packages-to-scan: com.wok.supportbot.controller
knife4j:
enable: true
setting:
language: zh_cn
swagger-model-name: 实体类列表
logging:
level:
root: INFO
com.wok.supportbot: DEBUG
org.springframework.ai: INFO
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: /opt/support-bot/logs/support-bot.log
EOF
```
> **关键修改点**
> - `datasource.url` 改为 `127.0.0.1:5432`(指向本机 Docker 容器)。
> - `datasource.password` 与 docker-compose 中保持一致。
> - `logging.file.name` 输出日志到宿主机目录。
> - 生产环境建议将 `com.wok.supportbot` 日志级别从 `DEBUG` 调为 `INFO`
### 7.3 目录结构最终如下
```
/opt/support-bot/
├── docker-compose.yml # 数据库编排
├── data/ # PG 数据持久化
├── logs/ # 应用日志
└── app/
├── yu-ai-agent-0.0.1-SNAPSHOT.jar
└── application.yml # 外部配置(覆盖 jar 内默认值)
```
---
## 8. 启动后端服务
### 8.1 手动前台启动(首次验证用)
```bash
cd /opt/support-bot/app
java -jar yu-ai-agent-0.0.1-SNAPSHOT.jar
```
观察启动日志,应看到:
```
数据库初始化完成
... Tomcat started on port 9090 ...
... Started SupportBotApplication in x.xxx seconds ...
```
启动成功后业务表会自动创建,可用以下命令核对:
```bash
sudo docker exec -it support-bot-postgres psql -U postgres -d support_bot -c "\dt"
```
应能看到 `chat_message`、`knowledge_category`、`knowledge_document`、`ai_model_config`、`vector_store`、`customer_service_role`、`customer_account`、`conversation_session` 等表。
### 8.2 后台启动(生产用)
```bash
cd /opt/support-bot/app
nohup java -jar yu-ai-agent-0.0.1-SNAPSHOT.jar \
--spring.config.additional-location=optional:file:./ \
> /opt/support-bot/logs/startup.out 2>&1 &
# 查看进程
ps -ef | grep yu-ai-agent
# 查看日志
tail -f /opt/support-bot/logs/startup.out
```
### 8.3 访问验证
| 地址 | 说明 |
|------|------|
| `http://服务器IP:9090/index.html` | 前端管理页面 |
| `http://服务器IP:9090/doc.html` | Knife4j 接口文档 |
能打开管理页面即部署成功。AI 对话功能需先配置模型(下一步)。
---
## 9. 配置 AI 大模型(必做)
由于 `application.yml``api-key` 留空,首次启动时 `ai_model_config` 表会写入**空 Key 的默认配置**。调用 AI 功能前必须在前端填入真实 Key。
### 9.1 进入配置页面
打开 `http://服务器IP:9090/index.html` → 进入「**AI 大模型配置管理**」页面。
### 9.2 配置各应用类型
为以下 4 类应用分别填入 API Key 并激活(同一 App 类型仅一个激活):
| App 类型 | 用途 | 默认模型 |
|----------|------|----------|
| CHAT | 主对话 | qwen-turbo |
| PRODUCT_EXTRACT | 结构化数据提取 | qwen-turbo |
| EMBEDDING | 知识库向量化 | text-embedding-v2 |
| RAG_REWRITE | RAG 查询重写 | qwen-turbo |
### 9.3 通义千问(DashScope)配置示例
- **提供商**:dashscope
- **API Key**:在 [DashScope 控制台](https://dashscope.console.aliyun.com/) 获取,格式 `sk-xxxx`
- **Base URL**:留空(DashScope 内置)
- **EMBEDDING 类型**需在「向量维度」填 `1536`(与 `knowledge.vector.dimension` 一致)
> 配置后**热切换生效,无需重启**。若切换非 1536 维的 Embedding 模型,需修改 `application.yml``knowledge.vector.dimension` 并重建 `vector_store` 表(见运维章节)。
### 9.4 其他提供商(可选)
支持 OpenAI 兼容提供商:DeepSeek / 豆包 / Kimi / 智谱 / OpenAI。在配置页选择对应 provider,填入 `base_url``api_key` 即可。
---
## 10. 开机自启:systemd 服务
### 10.1 创建服务文件
```bash
sudo tee /etc/systemd/system/support-bot.service <<'EOF'
[Unit]
Description=AI Support Bot Spring Boot Service
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/support-bot/app
ExecStart=/usr/bin/java -jar /opt/support-bot/app/yu-ai-agent-0.0.1-SNAPSHOT.jar --spring.config.additional-location=optional:file:./
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
StandardOutput=append:/opt/support-bot/logs/support-bot.log
StandardError=append:/opt/support-bot/logs/support-bot.log
[Install]
WantedBy=multi-user.target
EOF
```
### 10.2 启用服务
```bash
# 重载 systemd
sudo systemctl daemon-reload
# 设置开机自启
sudo systemctl enable support-bot
# 启动 / 停止 / 重启 / 状态
sudo systemctl start support-bot
sudo systemctl status support-bot
sudo systemctl restart support-bot
sudo systemctl stop support-bot
# 查看实时日志
sudo journalctl -u support-bot -f
```
> 数据库容器已设置 `restart: always`,服务器重启后会自动拉起数据库,再由 systemd 拉起后端服务(`Requires=docker.service` 保证顺序)。
---
## 11. Nginx 反向代理(可选)
如需用 80 端口访问或加 HTTPS,可安装 Nginx 反向代理到 9090。
```bash
# Ubuntu
sudo apt install -y nginx
sudo tee /etc/nginx/conf.d/support-bot.conf <<'EOF'
server {
listen 80;
server_name your-domain.com; # 改为域名或 IP
client_max_body_size 50m; # 与后端上传限制一致
location / {
proxy_pass http://127.0.0.1:9090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE 流式响应必需
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_http_version 1.1;
}
}
EOF
sudo nginx -t && sudo systemctl reload nginx
```
> SSE 流式接口(AI 对话)必须设置 `proxy_buffering off`,否则流式输出会被缓冲导致卡顿。
---
## 12. 日常运维与排错
### 12.1 常用命令速查
```bash
# —— 数据库 ——
sudo docker compose -f /opt/support-bot/docker-compose.yml ps # 状态
sudo docker compose -f /opt/support-bot/docker-compose.yml restart # 重启
sudo docker exec -it support-bot-postgres psql -U postgres -d support_bot # 进入 psql
# —— 应用 ——
sudo systemctl status support-bot # 服务状态
sudo systemctl restart support-bot # 重启
sudo journalctl -u support-bot -f # 实时日志
tail -f /opt/support-bot/logs/support-bot.log
# —— 防火墙 ——
sudo firewall-cmd --list-ports # CentOS 查看开放端口
sudo ufw status # Ubuntu 查看状态
```
### 12.2 数据库备份与恢复
```bash
# 备份(含数据)
sudo docker exec support-bot-postgres \
pg_dump -U postgres support_bot > /opt/support-bot/backup_$(date +%F).sql
# 恢复
sudo docker exec -i support-bot-postgres \
psql -U postgres -d support_bot < /opt/support-bot/backup_2026-06-27.sql
```
建议加入 crontab 定时备份:
```bash
# 每天凌晨 3 点备份,保留 30 天
echo "0 3 * * * docker exec support-bot-postgres pg_dump -U postgres support_bot > /opt/support-bot/backup_\$(date +\%F).sql && find /opt/support-bot -name 'backup_*.sql' -mtime +30 -delete" | sudo tee /etc/cron.d/support-bot-backup
```
### 12.3 切换 Embedding 模型 / 重建向量表
若更换非 1536 维的 Embedding 模型:
```bash
# 1. 修改 application.yml 中 knowledge.vector.dimension
# 2. 删除并重建 vector_store 表
sudo docker exec -it support-bot-postgres psql -U postgres -d support_bot -c "DROP TABLE IF EXISTS vector_store CASCADE;"
# 3. 重启服务(启动时自动按新维度重建表)
sudo systemctl restart support-bot
# 4. 重新上传知识库文档(重新向量化)
```
### 12.4 常见问题
| 现象 | 原因与解决 |
|------|------------|
| 启动报 `Connection refused 127.0.0.1:5432` | 数据库容器未启动,执行 `docker compose up -d` |
| 启动报 `extension "vector" does not exist` | 未启用 PGVector 扩展,执行第 4.3 节 `CREATE EXTENSION vector` |
| 启动成功但 AI 对话报错 | `ai_model_config` 中 API Key 为空,去前端「AI 大模型配置管理」填入并激活 |
| 上传文档失败 / 向量化报错 | EMBEDDING 配置缺失或维度不匹配;核对维度与 `knowledge.vector.dimension` 一致 |
| SSE 对话卡顿不输出 | 经 Nginx 时未关 `proxy_buffering`,见第 11 节 |
| 前端页面 404 | 访问路径应为 `/index.html`(不带也行,但确认端口 9090 已放行) |
| 雪花 ID 前端精度丢失 | 已由 `@JsonSerialize(ToStringSerializer.class)` 处理,无需额外操作 |
### 12.5 升级应用
```bash
# 1. 上传新 jar 覆盖旧 jar
# 2. 重启服务
sudo systemctl restart support-bot
```
---
## 13. 附:一键部署脚本
将以下脚本保存为 `/opt/support-bot/deploy.sh`,可一键完成数据库部署 + 应用启动。
```bash
#!/bin/bash
# AI 智能客服系统 一键部署脚本
set -e
APP_DIR=/opt/support-bot
JAR_NAME=yu-ai-agent-0.0.1-SNAPSHOT.jar
DB_PASS=${DB_PASS:-supportbot123}
echo "===== 1. 创建目录 ====="
sudo mkdir -p $APP_DIR/{db,data,app,logs}
echo "===== 2. 生成 docker-compose.yml ====="
sudo tee $APP_DIR/docker-compose.yml <<EOF
version: "3.8"
services:
postgres:
image: pgvector/pgvector:pg16
container_name: support-bot-postgres
restart: always
ports: ["5432:5432"]
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: $DB_PASS
POSTGRES_DB: support_bot
TZ: Asia/Shanghai
volumes:
- $APP_DIR/data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d support_bot"]
interval: 10s
timeout: 5s
retries: 5
EOF
echo "===== 3. 启动数据库 ====="
sudo docker compose -f $APP_DIR/docker-compose.yml up -d
sleep 10
echo "===== 4. 启用 vector 扩展 ====="
sudo docker exec support-bot-postgres psql -U postgres -d support_bot -c "CREATE EXTENSION IF NOT EXISTS vector;"
echo "===== 5. 检查 jar 包 ====="
if [ ! -f $APP_DIR/app/$JAR_NAME ]; then
echo "❌ 未找到 $APP_DIR/app/$JAR_NAME,请先上传 jar 包!"
exit 1
fi
echo "===== 6. 创建 systemd 服务 ====="
sudo tee /etc/systemd/system/support-bot.service <<EOF
[Unit]
Description=AI Support Bot Spring Boot Service
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=$APP_DIR/app
ExecStart=/usr/bin/java -jar $APP_DIR/app/$JAR_NAME --spring.config.additional-location=optional:file:./
SuccessExitStatus=143
Restart=on-failure
RestartSec=10
StandardOutput=append:$APP_DIR/logs/support-bot.log
StandardError=append:$APP_DIR/logs/support-bot.log
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable support-bot
sudo systemctl restart support-bot
echo "===== 部署完成 ====="
echo "前端页面: http://$(hostname -I | awk '{print $1}'):9090/index.html"
echo "接口文档: http://$(hostname -I | awk '{print $1}'):9090/doc.html"
echo "日志: tail -f $APP_DIR/logs/support-bot.log"
echo "请前往「AI 大模型配置管理」页面填入 API Key 后再使用 AI 功能。"
EOF
```
使用方式:
```bash
chmod +x /opt/support-bot/deploy.sh
# 上传 jar 到 /opt/support-bot/app/ 后执行
sudo DB_PASS=your_strong_password /opt/support-bot/deploy.sh
```
---
## 部署 Checklist
- [ ] Docker 与 Docker Compose 已安装,镜像加速已配置
- [ ] PostgreSQL + PGVector 容器启动且 `vector` 扩展已启用
- [ ] JDK 17 已安装
- [ ] jar 包已上传至 `/opt/support-bot/app/`
- [ ] 外部 `application.yml` 已配置(数据库地址、密码、日志路径)
- [ ] 服务启动成功,业务表自动创建
- [ ] 防火墙 / 安全组放行 9090 端口
- [ ] 前端「AI 大模型配置管理」中 4 类应用已填入 API Key 并激活
- [ ] 已上传知识库文档并验证 RAG 对话
- [ ] systemd 服务已设置开机自启
- [ ] 数据库定时备份已配置
---
*本教程基于项目当前代码状态(Spring Boot 3.4.4 / Java 17 / Spring AI 1.0.1 / PGVector)编写。*

7
client/README.md

@ -44,6 +44,9 @@
position: 'right-bottom', position: 'right-bottom',
primaryColor: '#2563EB', primaryColor: '#2563EB',
showClear: true, showClear: true,
quickReplies: ['如何重置密码?', '报销流程是什么?', '查询我的年假余额'],
theme: 'light', // 主题:'light' | 'dark'
showTeaser: true, // 是否显示首访提示气泡
// 行为 // 行为
streaming: true, streaming: true,
@ -82,6 +85,10 @@ SDK 产物位于 `client/dist/` 目录:
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 | | `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 |
| `launcherIcon` | `string` | ❌ | SVG 图标 | P0 | 悬浮按钮图标 | | `launcherIcon` | `string` | ❌ | SVG 图标 | P0 | 悬浮按钮图标 |
| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 | | `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 |
| `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 |
| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` |
| `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) |
| `teaserText` | `string` | ❌ | i18n 默认 | P1 | 提示气泡文字,留空使用语言包默认值 |
| `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 | | `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 |
| `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` | | `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` |
| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 | | `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 |

1168
client/dist/chatbot-sdk.js
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

33
client/src/api.ts

@ -121,15 +121,25 @@ function buildRagSourcesUrl(message: string, categoryId?: number): string {
// ==================== HTTP 基础封装 ==================== // ==================== HTTP 基础封装 ====================
/** 带超时的 fetch 封装 */
/** 带超时的 fetch 封装,支持外部 AbortSignal(用于流式主动中断) */
async function safeFetch( async function safeFetch(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
timeout: number = REQUEST_TIMEOUT
timeout: number = REQUEST_TIMEOUT,
externalSignal?: AbortSignal
): Promise<Response> { ): Promise<Response> {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout); const timer = setTimeout(() => controller.abort(), timeout);
// 外部信号触发时同步中断内部请求
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
}
try { try {
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
@ -139,6 +149,10 @@ async function safeFetch(
}); });
return response; return response;
} catch (err: unknown) { } catch (err: unknown) {
// 用户主动中断(停止生成),不算错误
if (externalSignal?.aborted) {
throw new CskError('aborted', 'aborted');
}
if (err instanceof DOMException && err.name === 'AbortError') { if (err instanceof DOMException && err.name === 'AbortError') {
throw new CskError(t('error_timeout'), 'timeout'); throw new CskError(t('error_timeout'), 'timeout');
} }
@ -213,7 +227,8 @@ export async function chatSSERequest(
onDone: () => void, onDone: () => void,
onError: (error: CskError) => void, onError: (error: CskError) => void,
categoryId?: number, categoryId?: number,
useRag?: boolean
useRag?: boolean,
signal?: AbortSignal
): Promise<void> { ): Promise<void> {
const url = useRag const url = useRag
? buildChatRAGSSEUrl(message, categoryId) ? buildChatRAGSSEUrl(message, categoryId)
@ -223,7 +238,7 @@ export async function chatSSERequest(
logger.lifecycleSend(currentConfig!.integrateId, message.length); logger.lifecycleSend(currentConfig!.integrateId, message.length);
try { try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2);
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal);
if (!response.ok) { if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status); const errorMsg = getHttpErrorMessage(response.status);
@ -277,7 +292,10 @@ export async function chatSSERequest(
} }
} }
} catch (readErr: unknown) { } catch (readErr: unknown) {
if (totalText.length > 0) {
// 用户主动中断:视为正常结束,保留已生成内容
if (signal?.aborted) {
logger.info(`流式被用户中断,保留已生成内容 length=${totalText.length}`);
} else if (totalText.length > 0) {
onChunk('\n\n' + t('stream_unstable')); onChunk('\n\n' + t('stream_unstable'));
} else { } else {
throw readErr; throw readErr;
@ -289,6 +307,11 @@ export async function chatSSERequest(
logger.lifecycleStreamDone(currentConfig!.integrateId, totalText.length); logger.lifecycleStreamDone(currentConfig!.integrateId, totalText.length);
onDone(); onDone();
} catch (err) { } catch (err) {
// 用户主动中断不触发 onError,走 onDone
if (signal?.aborted || (err instanceof CskError && err.type === 'aborted')) {
onDone();
return;
}
if (err instanceof CskError) { if (err instanceof CskError) {
onError(err); onError(err);
} else { } else {

362
client/src/chat.ts

@ -29,6 +29,9 @@ import {
scrollToBottom, scrollToBottom,
renderSources, renderSources,
renderHistoryList, renderHistoryList,
appendStreamingCaret,
finalizeAIBubble,
updateFeedbackUI,
HistoryItemData, HistoryItemData,
} from './dom'; } from './dom';
import { saveMessages, loadMessages, clearMessages } from './storage'; import { saveMessages, loadMessages, clearMessages } from './storage';
@ -47,10 +50,23 @@ let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null; let categorySelect: HTMLSelectElement | null = null;
let historyPanel: HTMLElement | null = null; let historyPanel: HTMLElement | null = null;
let welcomeEl: HTMLElement | null = null; let welcomeEl: HTMLElement | null = null;
let newMsgBtn: HTMLElement | null = null;
let searchInput: HTMLInputElement | null = null;
let ariaLiveEl: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null; let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null; let hideLoadingFn: (() => void) | null = null;
let isSending = false; let isSending = false;
/** 当前流式请求的中断控制器(用于"停止生成") */
let abortController: AbortController | null = null;
/** 用户是否停留在底部附近(用于智能滚动 + 新消息提示) */
let isNearBottom = true;
/** 缓存的历史会话列表(供搜索过滤用) */
let historyItems: HistoryItemData[] = [];
/** 当前历史搜索关键字 */
let historySearchText = '';
/** 当前选中的知识库分类 ID */ /** 当前选中的知识库分类 ID */
let currentCategoryId: number | undefined; let currentCategoryId: number | undefined;
@ -70,6 +86,9 @@ export function initChat(
categorySelect: HTMLSelectElement | null; categorySelect: HTMLSelectElement | null;
historyPanel: HTMLElement; historyPanel: HTMLElement;
welcomeEl: HTMLElement; welcomeEl: HTMLElement;
newMsgBtn: HTMLElement;
searchInput: HTMLInputElement | null;
ariaLiveEl: HTMLElement;
showLoading: () => HTMLElement; showLoading: () => HTMLElement;
hideLoading: () => void; hideLoading: () => void;
} }
@ -83,6 +102,9 @@ export function initChat(
categorySelect = dom.categorySelect; categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel; historyPanel = dom.historyPanel;
welcomeEl = dom.welcomeEl; welcomeEl = dom.welcomeEl;
newMsgBtn = dom.newMsgBtn;
searchInput = dom.searchInput;
ariaLiveEl = dom.ariaLiveEl;
showLoadingFn = dom.showLoading; showLoadingFn = dom.showLoading;
hideLoadingFn = dom.hideLoading; hideLoadingFn = dom.hideLoading;
@ -93,6 +115,12 @@ export function initChat(
// 绑定发送事件 // 绑定发送事件
bindSendEvents(); bindSendEvents();
// 绑定滚动监听(智能滚动 + 新消息提示)
bindScrollEvents();
// 绑定历史搜索过滤
bindHistorySearch();
// 加载知识库分类下拉框 // 加载知识库分类下拉框
if (cfg.showCategorySwitch && categorySelect) { if (cfg.showCategorySwitch && categorySelect) {
loadCategories(); loadCategories();
@ -158,10 +186,18 @@ async function loadHistoryFromBackend(): Promise<void> {
function bindSendEvents(): void { function bindSendEvents(): void {
if (!inputEl || !sendBtn) return; if (!inputEl || !sendBtn) return;
sendBtn.addEventListener('click', () => handleSend());
// 发送按钮:发送态点击发送,停止态点击中断流式
sendBtn.addEventListener('click', () => {
if (isSending && abortController) {
abortController.abort();
return;
}
handleSend();
});
inputEl.addEventListener('keydown', (e: KeyboardEvent) => { inputEl.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
// IME 组合输入期间(中文/日文输入法选词回车)不触发发送
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
@ -185,7 +221,149 @@ function bindSendEvents(): void {
} }
} }
/** 文本框自动撑高 */
/** 绑定滚动监听:判断是否在底部,控制新消息提示按钮 */
function bindScrollEvents(): void {
if (!messagesContainer) return;
messagesContainer.addEventListener('scroll', () => {
if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
// 距底部 80px 内视为"在底部"
isNearBottom = scrollHeight - scrollTop - clientHeight < 80;
if (isNearBottom) hideNewMsgBtn();
});
// 新消息提示按钮点击 → 回到底部
if (newMsgBtn) {
newMsgBtn.addEventListener('click', () => {
if (messagesContainer) scrollToBottom(messagesContainer);
isNearBottom = true;
hideNewMsgBtn();
});
}
}
/** 显示新消息提示按钮 */
function showNewMsgBtn(): void {
if (newMsgBtn) newMsgBtn.classList.remove('csk-newmsg--hidden');
}
/** 隐藏新消息提示按钮 */
function hideNewMsgBtn(): void {
if (newMsgBtn) newMsgBtn.classList.add('csk-newmsg--hidden');
}
/** 智能滚动到底部:用户在底部时自动滚,上滑时显示新消息提示 */
function smartScrollToBottom(): void {
if (!messagesContainer) return;
if (isNearBottom) {
scrollToBottom(messagesContainer);
} else {
showNewMsgBtn();
}
}
/** 切换发送按钮模式:发送 / 停止生成 */
function setSendButtonMode(mode: 'send' | 'stop'): void {
if (!sendBtn) return;
if (mode === 'stop') {
sendBtn.classList.add('csk-send-btn--stop');
sendBtn.removeAttribute('disabled');
sendBtn.setAttribute('title', t('stop'));
sendBtn.setAttribute('aria-label', t('stop'));
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
} else {
sendBtn.classList.remove('csk-send-btn--stop');
sendBtn.setAttribute('title', t('send'));
sendBtn.setAttribute('aria-label', t('send'));
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
updateSendBtnState();
}
}
// ==================== 历史会话搜索过滤 ====================
/** 绑定历史搜索输入事件 */
function bindHistorySearch(): void {
if (!searchInput) return;
searchInput.addEventListener('input', () => {
historySearchText = searchInput!.value.trim().toLowerCase();
renderFilteredHistory();
});
}
/** 按搜索关键字过滤并重新渲染历史列表 */
function renderFilteredHistory(): void {
const listEl = historyPanel?.querySelector('#csk-history-list') as HTMLElement | null;
if (!listEl || !config) return;
const filtered = historySearchText
? historyItems.filter(item => {
const preview = (item.lastMessagePreview || item.chatId || item.id || '').toLowerCase();
return preview.includes(historySearchText);
})
: historyItems;
renderHistoryList(
listEl,
filtered,
(conversationId: string) => { switchToConversation(conversationId); },
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
async (id: string) => {
if (!confirm(t('history_delete_confirm'))) return;
const ok = await deleteConversation(id);
if (ok) {
if (id === getChatId()) {
messages = [];
if (messagesContainer) {
messagesContainer.querySelectorAll('.csk-msg, .csk-loading').forEach(el => el.remove());
}
if (clearBtn) clearBtn.style.display = 'none';
updateEmptyState();
}
// 从缓存中移除并重新渲染
historyItems = historyItems.filter(it => (it.chatId || it.id) !== id);
renderFilteredHistory();
}
},
getChatId()
);
}
// ==================== a11y 消息播报 ====================
/** 通过 aria-live 区域播报新消息(供屏幕阅读器使用) */
function announceMessage(text: string): void {
if (!ariaLiveEl) return;
// 截取前 120 字符,避免过长播报
const snippet = text.length > 120 ? text.substring(0, 120) + '...' : text;
ariaLiveEl.textContent = t('new_msg_announce') + ':' + snippet;
}
// ==================== 消息反馈(👍 / 👎) ====================
/**
* AI /
* messages + localStorage
* TODO POST /conversation/message/feedback
*/
export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
if (!messagesContainer) return;
const msg = messages.find(m => m.id === msgId && m.role === 'ai');
if (!msg) return;
// 切换逻辑:同值取消,异值切换
const newValue = msg.feedback === value ? undefined : value;
msg.feedback = newValue;
// 更新 DOM 状态
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
if (wrapper) updateFeedbackUI(wrapper, newValue);
// 持久化(localStorage)
if (config) saveMessages(config.integrateId, messages);
logger.info(`消息反馈 msgId=${msgId} value=${newValue || 'cleared'}`);
}
function autoResizeInput(): void { function autoResizeInput(): void {
if (!inputEl) return; if (!inputEl) return;
inputEl.style.height = 'auto'; inputEl.style.height = 'auto';
@ -222,53 +400,71 @@ async function handleSend(): Promise<void> {
updateSendBtnState(); updateSendBtnState();
inputEl.style.height = 'auto'; inputEl.style.height = 'auto';
isSending = true;
updateSendBtnState();
// 确保 chatId 已初始化
if (!config.chatId) {
await initChatId();
}
// 1. 渲染用户气泡 // 1. 渲染用户气泡
const userTimestamp = now(); const userTimestamp = now();
if (messagesContainer) renderUserBubble(messagesContainer, text, userTimestamp);
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp }; const userMsg: ChatMessage = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp };
if (messagesContainer) renderUserBubble(messagesContainer, text, userTimestamp);
messages.push(userMsg); messages.push(userMsg);
updateEmptyState(); updateEmptyState();
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex'; if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
if (messagesContainer) scrollToBottom(messagesContainer);
if (messagesContainer) smartScrollToBottom();
// 2. 显示 loading
if (showLoadingFn) showLoadingFn();
if (messagesContainer) scrollToBottom(messagesContainer);
// 2. 生成 AI 回复
await produceAIReply(text);
}
/**
* AI + +
*
*/
async function produceAIReply(userText: string): Promise<void> {
if (!config || !messagesContainer) return;
isSending = true;
setSendButtonMode('stop');
// 确保 chatId 已初始化
if (!config.chatId) {
await initChatId();
}
// 3. 发送请求
try {
let aiContent: string;
const aiTimestamp = now(); const aiTimestamp = now();
const shouldUseRag = useRag && (currentCategoryId !== undefined || config.categoryId !== undefined); const shouldUseRag = useRag && (currentCategoryId !== undefined || config.categoryId !== undefined);
// 显示 loading
if (showLoadingFn) showLoadingFn();
if (messagesContainer) smartScrollToBottom();
const aiMsgId = uuid();
let aiContent = '';
try {
if (config.streaming) { if (config.streaming) {
aiContent = await sendStreamMessage(text, aiTimestamp, shouldUseRag);
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId);
} else { } else {
aiContent = await chatRequest(text);
}
aiContent = await chatRequest(userText);
if (hideLoadingFn) hideLoadingFn(); if (hideLoadingFn) hideLoadingFn();
if (!config.streaming && messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp, renderMarkdown);
if (messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp, renderMarkdown, aiMsgId);
}
} }
const aiMsg: ChatMessage = { id: uuid(), role: 'ai', content: aiContent, timestamp: aiTimestamp };
const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp };
messages.push(aiMsg); messages.push(aiMsg);
saveMessages(config.integrateId, messages); saveMessages(config.integrateId, messages);
if (messagesContainer) scrollToBottom(messagesContainer);
if (messagesContainer) smartScrollToBottom();
// a11y 播报新 AI 消息
announceMessage(aiContent);
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
if (messagesContainer) {
messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true }));
}
// RAG 引用来源 // RAG 引用来源
if (shouldUseRag) fetchAndRenderSources(text, aiMsg);
if (shouldUseRag) fetchAndRenderSources(userText, aiMsg);
} catch (err) { } catch (err) {
if (hideLoadingFn) hideLoadingFn(); if (hideLoadingFn) hideLoadingFn();
@ -286,14 +482,21 @@ async function handleSend(): Promise<void> {
logger.error(`发送失败 integrateId=${config.integrateId}`, err); logger.error(`发送失败 integrateId=${config.integrateId}`, err);
} finally { } finally {
isSending = false; isSending = false;
abortController = null;
setSendButtonMode('send');
updateSendBtnState(); updateSendBtnState();
} }
} }
/** 流式发送消息 */ /** 流式发送消息 */
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean): Promise<string> {
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string): Promise<string> {
// 创建中断控制器,供"停止生成"使用
abortController = new AbortController();
const signal = abortController.signal;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let bubbleEl: HTMLElement | null = null; let bubbleEl: HTMLElement | null = null;
let wrapperEl: HTMLElement | null = null;
let accumulated = ''; let accumulated = '';
let streamStarted = false; let streamStarted = false;
@ -303,35 +506,92 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
accumulated += chunk; accumulated += chunk;
if (!streamStarted && messagesContainer) { if (!streamStarted && messagesContainer) {
if (hideLoadingFn) hideLoadingFn(); if (hideLoadingFn) hideLoadingFn();
const { bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp);
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp, aiMsgId);
wrapperEl = wrapper;
bubbleEl = bubble; bubbleEl = bubble;
streamStarted = true; streamStarted = true;
} }
if (bubbleEl) bubbleEl.textContent = accumulated;
if (messagesContainer) scrollToBottom(messagesContainer);
if (bubbleEl) {
bubbleEl.textContent = accumulated;
appendStreamingCaret(bubbleEl);
}
if (messagesContainer) smartScrollToBottom();
}, },
() => { () => {
// 流结束
if (wrapperEl && bubbleEl) {
// 无流内容降级为同步请求
if (!streamStarted && accumulated === '') { if (!streamStarted && accumulated === '') {
chatRequest(text).then(resolve).catch(reject); chatRequest(text).then(resolve).catch(reject);
return; return;
} }
if (bubbleEl && accumulated) bubbleEl.innerHTML = renderMarkdown(accumulated);
if (accumulated) bubbleEl.innerHTML = renderMarkdown(accumulated);
finalizeAIBubble(wrapperEl, bubbleEl);
}
resolve(accumulated); resolve(accumulated);
}, },
(error: CskError) => { (error: CskError) => {
if (accumulated.length > 0) { if (accumulated.length > 0) {
if (bubbleEl) bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));
if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));
if (wrapperEl) finalizeAIBubble(wrapperEl, bubbleEl);
}
resolve(accumulated); resolve(accumulated);
} else { } else {
reject(error); reject(error);
} }
}, },
currentCategoryId, currentCategoryId,
shouldUseRag
shouldUseRag,
signal
); );
}); });
} }
/**
*
*/
export function sendQuickReply(text: string): Promise<void> {
if (!inputEl || isSending) return Promise.resolve();
inputEl.value = text;
updateSendBtnState();
return handleSend();
}
/**
* AI ID
* AI AI
*/
export async function retryFromMessage(msgId: string): Promise<void> {
if (!config || !messagesContainer || isSending) return;
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai');
if (aiIndex < 0) return;
// 找到上一条用户消息文本
let userText = '';
for (let i = aiIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') { userText = messages[i].content; break; }
}
if (!userText) return;
// 移除该 AI 消息及其后所有消息(本地 + DOM)
const removed = messages.splice(aiIndex);
const removedIds = new Set(removed.map(m => m.id));
const nodes = messagesContainer.querySelectorAll('.csk-msg');
nodes.forEach(node => {
const id = (node as HTMLElement).dataset.cskMsgId;
// 无 id 的(如错误气泡、被中断的)按顺序兜底:此处仅按 id 精确移除
if (id && removedIds.has(id)) node.remove();
});
saveMessages(config.integrateId, messages);
updateEmptyState();
// 重新生成(不重复渲染用户气泡)
await produceAIReply(userText);
}
/** 获取并渲染 RAG 引用来源 */ /** 获取并渲染 RAG 引用来源 */
async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promise<void> { async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promise<void> {
try { try {
@ -394,11 +654,13 @@ function renderHistory(): void {
if (msg.role === 'user') { if (msg.role === 'user') {
renderUserBubble(messagesContainer, msg.content, msg.timestamp); renderUserBubble(messagesContainer, msg.content, msg.timestamp);
} else { } else {
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, renderMarkdown);
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, renderMarkdown, msg.id);
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources); if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources);
if (msg.feedback) updateFeedbackUI(wrapper, msg.feedback);
} }
} }
isNearBottom = true;
scrollToBottom(messagesContainer); scrollToBottom(messagesContainer);
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex'; if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
updateEmptyState(); updateEmptyState();
@ -459,43 +721,41 @@ export async function loadHistoryConversations(): Promise<void> {
try { try {
const result = await fetchConversationList(1, 50, config.userId, config.integrateId); const result = await fetchConversationList(1, 50, config.userId, config.integrateId);
const items: HistoryItemData[] = result.list.map(c => ({
historyItems = result.list.map(c => ({
id: c.conversationId || '', id: c.conversationId || '',
chatId: c.conversationId || '', // conversationId 就是 chatId
chatId: c.conversationId || '',
messageCount: c.messageCount, messageCount: c.messageCount,
lastMessageTime: c.lastMessageTime, lastMessageTime: c.lastMessageTime,
lastMessagePreview: c.lastMessagePreview, lastMessagePreview: c.lastMessagePreview,
createdAt: c.firstMessageTime || c.createdAt, createdAt: c.firstMessageTime || c.createdAt,
})); }));
// 重置搜索(新数据到达时清空过滤)
historySearchText = '';
if (searchInput) searchInput.value = '';
renderHistoryList( renderHistoryList(
listEl, listEl,
items,
// onSelect: 切换到选中的会话
(conversationId: string) => {
switchToConversation(conversationId);
},
// onExport
historyItems,
(conversationId: string) => { switchToConversation(conversationId); },
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); }, (id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
// onDelete
async (id: string) => { async (id: string) => {
if (!confirm(t('history_delete_confirm'))) return; if (!confirm(t('history_delete_confirm'))) return;
const ok = await deleteConversation(id); const ok = await deleteConversation(id);
if (ok) { if (ok) {
// 如果删的是当前会话,清空聊天窗口
if (id === getChatId()) { if (id === getChatId()) {
messages = []; messages = [];
if (messagesContainer) { if (messagesContainer) {
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
messagesContainer.querySelectorAll('.csk-msg, .csk-loading').forEach(el => el.remove());
} }
if (clearBtn) clearBtn.style.display = 'none'; if (clearBtn) clearBtn.style.display = 'none';
updateEmptyState(); updateEmptyState();
} }
loadHistoryConversations();
// 从缓存中移除并重新渲染(不再重新请求后端)
historyItems = historyItems.filter(it => (it.chatId || it.id) !== id);
renderFilteredHistory();
} }
}, },
// 当前活跃 chatId,用于高亮
getChatId() getChatId()
); );
} catch (err) { } catch (err) {

6
client/src/config.ts

@ -57,6 +57,12 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
launcherIcon: raw.launcherIcon || DEFAULT_LAUNCHER_ICON, launcherIcon: raw.launcherIcon || DEFAULT_LAUNCHER_ICON,
showClear: raw.showClear ?? true, showClear: raw.showClear ?? true,
showAdminPanel: raw.showAdminPanel ?? false, showAdminPanel: raw.showAdminPanel ?? false,
quickReplies: Array.isArray(raw.quickReplies)
? raw.quickReplies.map(s => String(s).trim()).filter(Boolean)
: [],
theme: raw.theme === 'dark' ? 'dark' : 'light',
showTeaser: raw.showTeaser ?? true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',
streaming: raw.streaming ?? true, streaming: raw.streaming ?? true,
locale: raw.locale || 'zh-CN', locale: raw.locale || 'zh-CN',
debug: raw.debug ?? true, debug: raw.debug ?? true,

246
client/src/dom.ts

@ -34,6 +34,11 @@ export function createLauncher(config: ResolvedConfig, onClick: () => void): HTM
// 图标内容 // 图标内容
launcher.innerHTML = config.launcherIcon; launcher.innerHTML = config.launcherIcon;
// 未读徽章(初始隐藏,有未读消息时由 index.ts 显示)
const badge = document.createElement('div');
badge.className = 'csk-launcher__badge csk-launcher__badge--hidden';
launcher.appendChild(badge);
// 点击事件(300ms 防抖) // 点击事件(300ms 防抖)
const debouncedClick = debounce(onClick, 300); const debouncedClick = debounce(onClick, 300);
launcher.addEventListener('click', debouncedClick); launcher.addEventListener('click', debouncedClick);
@ -61,13 +66,20 @@ export function createChatWindow(config: ResolvedConfig): {
categorySelect: HTMLSelectElement | null; categorySelect: HTMLSelectElement | null;
historyPanel: HTMLElement; historyPanel: HTMLElement;
welcomeEl: HTMLElement; welcomeEl: HTMLElement;
newMsgBtn: HTMLElement;
teaserEl: HTMLElement;
searchInput: HTMLInputElement | null;
ariaLiveEl: HTMLElement;
showLoading: () => HTMLElement; showLoading: () => HTMLElement;
hideLoading: () => void; hideLoading: () => void;
} { } {
// 最外层容器 // 最外层容器
const windowEl = document.createElement('div'); const windowEl = document.createElement('div');
windowEl.id = 'csk-window'; windowEl.id = 'csk-window';
windowEl.className = `csk-root csk-window csk-window--${config.position === 'left-bottom' ? 'left' : 'right'} csk-window--hidden`;
windowEl.className = `csk-root csk-window csk-window--${config.position === 'left-bottom' ? 'left' : 'right'} csk-window--hidden${config.theme === 'dark' ? ' csk-dark' : ''}`;
windowEl.setAttribute('role', 'dialog');
windowEl.setAttribute('aria-label', config.title);
windowEl.setAttribute('aria-modal', 'false');
// === 头部 === // === 头部 ===
const header = document.createElement('div'); const header = document.createElement('div');
@ -142,8 +154,37 @@ export function createChatWindow(config: ResolvedConfig): {
<div class="csk-welcome__title">${t('welcome_title')}</div> <div class="csk-welcome__title">${t('welcome_title')}</div>
<div class="csk-welcome__desc">${t('welcome_desc')}</div> <div class="csk-welcome__desc">${t('welcome_desc')}</div>
`; `;
// 快捷问题芯片(点击即自动发送)
if (config.quickReplies.length > 0) {
const chips = document.createElement('div');
chips.className = 'csk-quick-replies';
for (const reply of config.quickReplies) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-quick-reply';
chip.textContent = reply;
chip.addEventListener('click', () => {
windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } }));
});
chips.appendChild(chip);
}
welcomeEl.appendChild(chips);
}
messagesContainer.appendChild(welcomeEl); messagesContainer.appendChild(welcomeEl);
// === 新消息提示按钮(用户上滑时新消息到达显示) ===
// 挂到 windowEl 而非 messagesContainer,绝对定位悬浮于输入区上方,避免随内容滚动
const newMsgBtn = document.createElement('button');
newMsgBtn.type = 'button';
newMsgBtn.className = 'csk-newmsg csk-newmsg--hidden';
newMsgBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<span>${t('new_message')}</span>
`;
windowEl.appendChild(newMsgBtn);
// === 会话管理面板(P2,默认隐藏) === // === 会话管理面板(P2,默认隐藏) ===
const historyPanel = document.createElement('div'); const historyPanel = document.createElement('div');
historyPanel.className = 'csk-history-panel csk-history-panel--hidden'; historyPanel.className = 'csk-history-panel csk-history-panel--hidden';
@ -155,6 +196,9 @@ export function createChatWindow(config: ResolvedConfig): {
${t('close')} ${t('close')}
</button> </button>
</div> </div>
<div class="csk-history-panel__search-wrap">
<input type="text" class="csk-history-panel__search" id="csk-history-search" placeholder="${t('history_search')}" />
</div>
<div class="csk-history-panel__list" id="csk-history-list"></div> <div class="csk-history-panel__list" id="csk-history-list"></div>
`; `;
messagesContainer.appendChild(historyPanel); messagesContainer.appendChild(historyPanel);
@ -280,6 +324,25 @@ export function createChatWindow(config: ResolvedConfig): {
} }
} }
// === a11y: aria-live 播报区域(视觉隐藏,供屏幕阅读器监听新消息) ===
const ariaLiveEl = document.createElement('div');
ariaLiveEl.className = 'csk-sr-only';
ariaLiveEl.setAttribute('aria-live', 'polite');
ariaLiveEl.setAttribute('aria-atomic', 'true');
windowEl.appendChild(ariaLiveEl);
// === 历史搜索输入框引用 ===
const searchInput = historyPanel.querySelector('#csk-history-search') as HTMLInputElement | null;
// === Launcher 提示气泡(首访引导,初始隐藏,由 index.ts 定时控制) ===
const teaserEl = document.createElement('div');
teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`;
teaserEl.setAttribute('role', 'status');
teaserEl.innerHTML = `
<button class="csk-teaser__close" aria-label="${t('close')}" type="button">&times;</button>
<span>${config.teaserText || t('teaser_text')}</span>
`;
return { return {
window: windowEl, window: windowEl,
messagesContainer, messagesContainer,
@ -289,6 +352,10 @@ export function createChatWindow(config: ResolvedConfig): {
categorySelect, categorySelect,
historyPanel, historyPanel,
welcomeEl, welcomeEl,
newMsgBtn,
teaserEl,
searchInput,
ariaLiveEl,
showLoading, showLoading,
hideLoading, hideLoading,
}; };
@ -379,9 +446,10 @@ export function renderUserBubble(container: HTMLElement, text: string, timestamp
} }
/** 渲染 AI 消息气泡(支持 Markdown) */ /** 渲染 AI 消息气泡(支持 Markdown) */
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string): HTMLElement {
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string, msgId?: string): HTMLElement {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai'; wrapper.className = 'csk-msg csk-msg--ai';
if (msgId) wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div'); const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai'; avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
@ -404,18 +472,24 @@ export function renderAIBubble(container: HTMLElement, text: string, timestamp:
time.textContent = formatTime(timestamp); time.textContent = formatTime(timestamp);
content.appendChild(bubble); content.appendChild(bubble);
// AI 操作条(复制 / 重试),插入到气泡与时间戳之间
content.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(time); content.appendChild(time);
wrapper.appendChild(avatar); wrapper.appendChild(avatar);
wrapper.appendChild(content); wrapper.appendChild(content);
container.appendChild(wrapper); container.appendChild(wrapper);
// 代码块复制按钮
enhanceCodeBlocks(bubble);
return wrapper; return wrapper;
} }
/** 创建空的 AI 气泡(流式追加用) */ /** 创建空的 AI 气泡(流式追加用) */
export function createEmptyAIBubble(container: HTMLElement, timestamp: number): { wrapper: HTMLElement; bubble: HTMLElement } {
export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
wrapper.className = 'csk-msg csk-msg--ai csk-msg--streaming';
if (msgId) wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div'); const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai'; avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
@ -433,6 +507,7 @@ export function createEmptyAIBubble(container: HTMLElement, timestamp: number):
time.textContent = formatTime(timestamp); time.textContent = formatTime(timestamp);
content.appendChild(bubble); content.appendChild(bubble);
content.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(time); content.appendChild(time);
wrapper.appendChild(avatar); wrapper.appendChild(avatar);
wrapper.appendChild(content); wrapper.appendChild(content);
@ -441,6 +516,169 @@ export function createEmptyAIBubble(container: HTMLElement, timestamp: number):
return { wrapper, bubble }; return { wrapper, bubble };
} }
// ==================== AI 消息操作条 + 代码块复制 ====================
/** 复制图标 */
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
/** 重试图标 */
const RETRY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`;
/** 构建 AI 消息操作条(复制 / 重试 / 反馈) */
function buildAIActions(bubble: HTMLElement, wrapper: HTMLElement): HTMLElement {
const bar = document.createElement('div');
bar.className = 'csk-msg__actions';
// 复制按钮
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'csk-action-btn';
copyBtn.setAttribute('title', t('copy'));
copyBtn.setAttribute('aria-label', t('copy'));
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const text = bubble.textContent || '';
await copyToClipboard(text, copyBtn);
});
// 重试按钮
const retryBtn = document.createElement('button');
retryBtn.type = 'button';
retryBtn.className = 'csk-action-btn';
retryBtn.setAttribute('title', t('retry'));
retryBtn.setAttribute('aria-label', t('retry'));
retryBtn.innerHTML = RETRY_ICON;
retryBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:retry', {
detail: { msgId: wrapper.dataset.cskMsgId || '' }
}));
}
});
// 反馈按钮(👍 / 👎)
const THUMB_UP = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`;
const THUMB_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>`;
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'csk-feedback-btn';
upBtn.setAttribute('title', t('feedback_up'));
upBtn.setAttribute('aria-label', t('feedback_up'));
upBtn.innerHTML = THUMB_UP;
upBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'up' }
}));
}
});
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'csk-feedback-btn';
downBtn.setAttribute('title', t('feedback_down'));
downBtn.setAttribute('aria-label', t('feedback_down'));
downBtn.innerHTML = THUMB_DOWN;
downBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'down' }
}));
}
});
bar.appendChild(copyBtn);
bar.appendChild(retryBtn);
bar.appendChild(upBtn);
bar.appendChild(downBtn);
return bar;
}
/**
* chat.ts
* @param wrapper wrapper
* @param value 'up' | 'down' | undefined
*/
export function updateFeedbackUI(wrapper: HTMLElement, value: 'up' | 'down' | undefined): void {
const btns = wrapper.querySelectorAll('.csk-feedback-btn');
if (btns.length < 2) return;
btns.forEach((btn) => btn.classList.remove('csk-feedback-btn--active', 'down'));
if (value === 'up' && btns[0]) btns[0].classList.add('csk-feedback-btn--active');
if (value === 'down' && btns[1]) btns[1].classList.add('csk-feedback-btn--active', 'down');
}
/** 复制文本到剪贴板,带"已复制"反馈 */
async function copyToClipboard(text: string, btn: HTMLElement): Promise<void> {
const original = btn.innerHTML;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// 降级方案:execCommand
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
btn.classList.add('csk-action-btn--done');
setTimeout(() => {
btn.innerHTML = original;
btn.classList.remove('csk-action-btn--done');
}, 1500);
} catch {
// 复制失败静默处理
}
}
/** 为气泡内所有代码块添加复制按钮 */
export function enhanceCodeBlocks(bubble: HTMLElement): void {
const blocks = bubble.querySelectorAll('.csk-md-code-block');
blocks.forEach((pre) => {
if (pre.querySelector('.csk-md-code-copy')) return;
const codeEl = pre.querySelector('code');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-md-code-copy';
btn.setAttribute('title', t('copy'));
btn.setAttribute('aria-label', t('copy'));
btn.innerHTML = COPY_ICON;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await copyToClipboard(codeEl?.textContent || '', btn);
});
(pre as HTMLElement).appendChild(btn);
});
}
/** 流式打字光标:追加到气泡末尾(每次 chunk 后调用) */
export function appendStreamingCaret(bubble: HTMLElement): void {
if (bubble.querySelector('.csk-caret')) return;
const caret = document.createElement('span');
caret.className = 'csk-caret';
caret.setAttribute('aria-hidden', 'true');
bubble.appendChild(caret);
}
/** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */
export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void {
wrapper.classList.remove('csk-msg--streaming');
const caret = bubble.querySelector('.csk-caret');
if (caret) caret.remove();
enhanceCodeBlocks(bubble);
}
// ==================== P1: RAG 引用来源渲染 ==================== // ==================== P1: RAG 引用来源渲染 ====================
/** 渲染 RAG 引用来源卡片 */ /** 渲染 RAG 引用来源卡片 */

34
client/src/i18n.ts

@ -18,6 +18,14 @@ const dictionaries: Record<string, Record<string, string>> = {
// 输入区 // 输入区
placeholder: '输入您的问题...', placeholder: '输入您的问题...',
send: '发送', send: '发送',
stop: '停止生成',
new_message: '新消息',
// 消息操作
copy: '复制',
copied: '已复制',
retry: '重新生成',
retrying: '重新生成中...',
// 消息 // 消息
loading: '正在思考...', loading: '正在思考...',
@ -43,6 +51,15 @@ const dictionaries: Record<string, Record<string, string>> = {
history_delete_confirm: '确定删除该会话?', history_delete_confirm: '确定删除该会话?',
history_export: '导出', history_export: '导出',
history_delete: '删除', history_delete: '删除',
history_search: '搜索会话...',
// 消息反馈
feedback_up: '有帮助',
feedback_down: '没帮助',
// 提示气泡
teaser_text: '有什么可以帮你的吗?',
new_msg_announce: '收到新消息',
// 错误提示 // 错误提示
error_network: '网络连接失败,请检查网络', error_network: '网络连接失败,请检查网络',
@ -73,6 +90,14 @@ const dictionaries: Record<string, Record<string, string>> = {
// Input // Input
placeholder: 'Type your question...', placeholder: 'Type your question...',
send: 'Send', send: 'Send',
stop: 'Stop generating',
new_message: 'New message',
// Message actions
copy: 'Copy',
copied: 'Copied',
retry: 'Regenerate',
retrying: 'Regenerating...',
// Messages // Messages
loading: 'Thinking...', loading: 'Thinking...',
@ -98,6 +123,15 @@ const dictionaries: Record<string, Record<string, string>> = {
history_delete_confirm: 'Delete this conversation?', history_delete_confirm: 'Delete this conversation?',
history_export: 'Export', history_export: 'Export',
history_delete: 'Delete', history_delete: 'Delete',
history_search: 'Search conversations...',
// Feedback
feedback_up: 'Helpful',
feedback_down: 'Not helpful',
// Teaser
teaser_text: 'How can I help you?',
new_msg_announce: 'New message received',
// Errors // Errors
error_network: 'Network connection failed', error_network: 'Network connection failed',

187
client/src/index.ts

@ -13,7 +13,7 @@ import { setDebug, logger } from './logger';
import { setApiConfig } from './api'; import { setApiConfig } from './api';
import { injectStyles, removeStyles } from './styles'; import { injectStyles, removeStyles } from './styles';
import { createLauncher, createChatWindow, enableDrag } from './dom'; import { createLauncher, createChatWindow, enableDrag } from './dom';
import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConversations } from './chat';
import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback } from './chat';
import { clearMessages } from './storage'; import { clearMessages } from './storage';
import { setLocale } from './i18n'; import { setLocale } from './i18n';
@ -33,6 +33,13 @@ let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null; let hideLoadingFn: (() => void) | null = null;
let dragCleanup: (() => void) | null = null; let dragCleanup: (() => void) | null = null;
/** 提示气泡元素 */
let teaserEl: HTMLElement | null = null;
/** 提示气泡定时器 */
let teaserTimer: ReturnType<typeof setTimeout> | null = null;
/** 未读徽章元素(挂在 launcher 内部) */
let badgeEl: HTMLElement | null = null;
// ==================== 公开 API ==================== // ==================== 公开 API ====================
/** 初始化 SDK */ /** 初始化 SDK */
@ -63,6 +70,9 @@ function init(rawConfig: SDKConfig): void {
launcherEl = createLauncher(config, toggle); launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl); document.body.appendChild(launcherEl);
// 获取未读徽章引用(由 createLauncher 创建)
badgeEl = launcherEl.querySelector('.csk-launcher__badge');
// 7. 创建聊天弹窗 // 7. 创建聊天弹窗
const dom = createChatWindow(config); const dom = createChatWindow(config);
windowEl = dom.window; windowEl = dom.window;
@ -76,13 +86,18 @@ function init(rawConfig: SDKConfig): void {
hideLoadingFn = dom.hideLoading; hideLoadingFn = dom.hideLoading;
document.body.appendChild(windowEl); document.body.appendChild(windowEl);
// 8. 启用拖拽
// 8. 安置提示气泡(挂在 launcher 同层,由配置控制显示)
teaserEl = dom.teaserEl;
document.body.appendChild(teaserEl);
positionTeaser();
// 9. 启用拖拽
const headerEl = windowEl.querySelector('.csk-header') as HTMLElement; const headerEl = windowEl.querySelector('.csk-header') as HTMLElement;
if (headerEl) { if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl); dragCleanup = enableDrag(headerEl, windowEl);
} }
// 9. 初始化对话模块
// 10. 初始化对话模块
initChat(config, { initChat(config, {
messagesContainer, messagesContainer,
inputEl, inputEl,
@ -91,38 +106,193 @@ function init(rawConfig: SDKConfig): void {
categorySelect, categorySelect,
historyPanel, historyPanel,
welcomeEl: dom.welcomeEl, welcomeEl: dom.welcomeEl,
newMsgBtn: dom.newMsgBtn,
searchInput: dom.searchInput,
ariaLiveEl: dom.ariaLiveEl,
showLoading: showLoadingFn, showLoading: showLoadingFn,
hideLoading: hideLoadingFn, hideLoading: hideLoadingFn,
}); });
// 10. 监听知识库分类切换事件
// 11. 监听知识库分类切换事件
windowEl.addEventListener('csk:categoryChange', ((e: CustomEvent) => { windowEl.addEventListener('csk:categoryChange', ((e: CustomEvent) => {
setCategory(e.detail.categoryId); setCategory(e.detail.categoryId);
}) as EventListener); }) as EventListener);
// 11. 监听会话管理面板加载事件
// 12. 监听会话管理面板加载事件
windowEl.addEventListener('csk:loadHistory', () => { windowEl.addEventListener('csk:loadHistory', () => {
loadHistoryConversations(); loadHistoryConversations();
}); });
// 13. 监听快捷问题芯片点击
windowEl.addEventListener('csk:quickReply', ((e: CustomEvent) => {
sendQuickReply(e.detail.text);
}) as EventListener);
// 14. 监听 AI 消息重试事件
windowEl.addEventListener('csk:retry', ((e: CustomEvent) => {
retryFromMessage(e.detail.msgId);
}) as EventListener);
// 15. 监听消息反馈事件(👍 / 👎)
windowEl.addEventListener('csk:feedback', ((e: CustomEvent) => {
handleFeedback(e.detail.msgId, e.detail.value);
}) as EventListener);
// 16. 新消息到达通知(用于未读徽章)
windowEl.addEventListener('csk:newMessage', () => {
notifyNewMessage();
});
// 17. ESC 键关闭弹窗
document.addEventListener('keydown', onKeyDown);
// 17. 窗口 resize 时重新定位 teaser
window.addEventListener('resize', onResize);
isInitialized = true; isInitialized = true;
logger.lifecycleInit(config.integrateId, config.requestDomain); logger.lifecycleInit(config.integrateId, config.requestDomain);
// 12. 异步初始化 chatId 和对话历史(不阻塞 UI)
// 18. 首访提示气泡(延迟显示,弹窗未打开时生效)
if (config.showTeaser) {
teaserTimer = setTimeout(() => {
// 弹窗关闭时才显示
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showTeaser();
}
}, 1500);
}
// 19. 异步初始化 chatId 和对话历史(不阻塞 UI)
initChatHistory().catch(err => { initChatHistory().catch(err => {
logger.warn('chatId 初始化失败,将在发送消息时重试', err); logger.warn('chatId 初始化失败,将在发送消息时重试', err);
}); });
} }
// ==================== 提示气泡控制 ====================
/** 根据 launcher 位置定位 teaser 气泡 */
function positionTeaser(): void {
if (!teaserEl || !launcherEl) return;
const rect = launcherEl.getBoundingClientRect();
const isLeft = config?.position === 'left-bottom';
if (isLeft) {
teaserEl.style.left = `${rect.left}px`;
} else {
teaserEl.style.right = `${window.innerWidth - rect.right}px`;
}
teaserEl.style.bottom = `${window.innerHeight - rect.top + 8}px`;
}
function showTeaser(): void {
if (!teaserEl) return;
positionTeaser();
teaserEl.classList.remove('csk-teaser--hidden');
// 点击气泡本身 → 打开弹窗
teaserEl.addEventListener('click', onTeaserClick);
// 关闭按钮(阻止冒泡,不打开弹窗)
const closeBtn = teaserEl.querySelector('.csk-teaser__close');
if (closeBtn) {
closeBtn.addEventListener('click', onTeaserClose);
}
}
function hideTeaser(): void {
if (!teaserEl) return;
teaserEl.classList.add('csk-teaser--hidden');
teaserEl.removeEventListener('click', onTeaserClick);
const closeBtn = teaserEl.querySelector('.csk-teaser__close');
if (closeBtn) closeBtn.removeEventListener('click', onTeaserClose);
}
function onTeaserClick(e: Event): void {
// 关闭按钮点击不打开弹窗
if ((e.target as HTMLElement).closest('.csk-teaser__close')) return;
hideTeaser();
open();
}
function onTeaserClose(e: Event): void {
e.stopPropagation();
hideTeaser();
}
function onResize(): void {
if (teaserEl && !teaserEl.classList.contains('csk-teaser--hidden')) {
positionTeaser();
}
}
// ==================== 未读徽章 ====================
function showBadge(): void {
if (badgeEl) badgeEl.classList.remove('csk-launcher__badge--hidden');
}
function hideBadge(): void {
if (badgeEl) badgeEl.classList.add('csk-launcher__badge--hidden');
}
/** 新消息到达时弹窗关闭,则显示徽章 */
function notifyNewMessage(): void {
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showBadge();
}
}
// ==================== a11y 焦点陷阱 ====================
/** 弹窗内可聚焦元素选择器 */
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function trapFocus(e: KeyboardEvent): void {
if (!windowEl || windowEl.classList.contains('csk-window--hidden')) return;
if (e.key !== 'Tab') return;
const focusables = Array.from(windowEl.querySelectorAll(FOCUSABLE)) as HTMLElement[];
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
// Shift+Tab:在第一个元素上按 → 跳到最后一个
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab:在最后一个元素上按 → 跳到第一个
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// ==================== 全局键盘事件 ====================
function onKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape' && windowEl && !windowEl.classList.contains('csk-window--hidden')) {
close();
}
trapFocus(e);
}
/** 销毁 SDK 实例 */ /** 销毁 SDK 实例 */
function destroy(): void { function destroy(): void {
if (!isInitialized) return; if (!isInitialized) return;
if (teaserTimer) { clearTimeout(teaserTimer); teaserTimer = null; }
hideTeaser();
if (teaserEl && teaserEl.parentNode) { teaserEl.parentNode.removeChild(teaserEl); teaserEl = null; }
if (launcherEl && launcherEl.parentNode) { launcherEl.parentNode.removeChild(launcherEl); launcherEl = null; } if (launcherEl && launcherEl.parentNode) { launcherEl.parentNode.removeChild(launcherEl); launcherEl = null; }
if (windowEl && windowEl.parentNode) { windowEl.parentNode.removeChild(windowEl); windowEl = null; } if (windowEl && windowEl.parentNode) { windowEl.parentNode.removeChild(windowEl); windowEl = null; }
if (dragCleanup) { dragCleanup(); dragCleanup = null; } if (dragCleanup) { dragCleanup(); dragCleanup = null; }
removeStyles(); removeStyles();
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', onResize);
const oldIntegrateId = config?.integrateId; const oldIntegrateId = config?.integrateId;
config = null; config = null;
@ -135,6 +305,7 @@ function destroy(): void {
historyPanel = null; historyPanel = null;
showLoadingFn = null; showLoadingFn = null;
hideLoadingFn = null; hideLoadingFn = null;
badgeEl = null;
logger.lifecycleDestroy(oldIntegrateId || ''); logger.lifecycleDestroy(oldIntegrateId || '');
} }
@ -142,6 +313,9 @@ function destroy(): void {
function open(): void { function open(): void {
if (!windowEl) return; if (!windowEl) return;
windowEl.classList.remove('csk-window--hidden'); windowEl.classList.remove('csk-window--hidden');
hideTeaser();
hideBadge();
setTimeout(() => { if (inputEl) inputEl.focus(); }, 80);
} }
function close(): void { function close(): void {
@ -153,7 +327,6 @@ function toggle(): void {
if (!windowEl) return; if (!windowEl) return;
if (windowEl.classList.contains('csk-window--hidden')) { if (windowEl.classList.contains('csk-window--hidden')) {
open(); open();
setTimeout(() => { if (inputEl) inputEl.focus(); }, 100);
} else { } else {
close(); close();
} }

350
client/src/styles.ts

@ -457,6 +457,7 @@ function getStyles(config: ResolvedConfig): string {
color: #1F2937; color: #1F2937;
} }
.csk-input::placeholder { color: #9CA3AF; } .csk-input::placeholder { color: #9CA3AF; }
.csk-root .csk-input:focus-visible { outline: none; }
.csk-send-btn { .csk-send-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@ -828,6 +829,355 @@ function getStyles(config: ResolvedConfig): string {
} }
.csk-history-panel__loadmore:hover { background: #F3F4F6; } .csk-history-panel__loadmore:hover { background: #F3F4F6; }
/* ========== 快捷问题芯片 ========== */
.csk-quick-replies {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 18px;
max-width: 320px;
}
.csk-quick-reply {
border: 1px solid var(--csk-border);
background: #fff;
color: #374151;
font-size: 12.5px;
padding: 7px 13px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
line-height: 1.3;
transition: all 0.18s ease;
max-width: 100%;
}
.csk-quick-reply:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.06);
transform: translateY(-1px);
}
.csk-quick-reply:active { transform: scale(0.96); }
/* ========== AI 消息操作条 ========== */
.csk-msg__actions {
display: flex;
gap: 2px;
margin-top: 5px;
padding: 0 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.csk-msg--ai:hover .csk-msg__actions { opacity: 1; }
.csk-msg--streaming .csk-msg__actions { display: none; }
.csk-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: #9CA3AF;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.csk-action-btn:hover {
background: #F3F4F6;
color: #374151;
}
.csk-action-btn--done { color: #10B981; }
.csk-action-btn:active { transform: scale(0.9); }
/* ========== 流式打字光标 ========== */
.csk-caret {
display: inline-block;
width: 7px;
height: 15px;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--csk-primary);
border-radius: 1px;
animation: csk-caret-blink 1s step-end infinite;
}
@keyframes csk-caret-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* ========== 代码块复制按钮 ========== */
.csk-md-code-block { position: relative; }
.csk-md-code-copy {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: rgba(255, 255, 255, 0.12);
color: #CBD5E1;
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
}
.csk-md-code-block:hover .csk-md-code-copy { opacity: 1; }
.csk-md-code-copy:hover { background: rgba(255, 255, 255, 0.22); color: #fff; }
.csk-md-code-copy:active { transform: scale(0.9); }
/* ========== 新消息提示按钮 ========== */
.csk-newmsg {
position: absolute;
bottom: 82px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
border: none;
background: var(--csk-primary);
color: #fff;
font-size: 12px;
font-family: inherit;
border-radius: 999px;
cursor: pointer;
box-shadow: 0 4px 14px rgba(var(--csk-primary-rgb), 0.38);
transition: transform 0.2s ease, box-shadow 0.2s ease;
animation: csk-msg-in 0.25s ease;
white-space: nowrap;
}
.csk-newmsg:hover { transform: translateX(-50%) translateY(-1px); box-shadow: 0 6px 18px rgba(var(--csk-primary-rgb), 0.48); }
.csk-newmsg:active { transform: translateX(-50%) scale(0.95); }
.csk-newmsg--hidden { display: none; }
/* ========== 发送按钮停止态 ========== */
.csk-send-btn--stop {
background: #EF4444 !important;
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35) !important;
}
.csk-send-btn--stop:hover:not(:disabled) {
box-shadow: 0 5px 14px rgba(239, 68, 68, 0.45) !important;
}
/* ========== 暗色模式 ========== */
.csk-root.csk-dark {
--csk-bg-ai: #1E1E2E;
--csk-text-ai: #E2E8F0;
--csk-border: #3B3B52;
--csk-bg-app: #141421;
color: #E2E8F0;
}
.csk-dark .csk-window {
background: #1A1A2E;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
}
.csk-dark .csk-header::after { background: linear-gradient(to bottom, rgba(0,0,0,0.12), transparent); }
.csk-dark .csk-msg__avatar--ai {
background: var(--csk-bg-ai);
color: var(--csk-primary);
border-color: var(--csk-border);
}
.csk-dark .csk-input-area { background: #1A1A2E; border-top-color: var(--csk-border); }
.csk-dark .csk-input-wrap { background: var(--csk-bg-app); border-color: var(--csk-border); }
.csk-dark .csk-input-wrap--focus { background: #1A1A2E; border-color: var(--csk-primary); }
.csk-dark .csk-input { color: #E2E8F0; }
.csk-dark .csk-loading__avatar { background: var(--csk-bg-ai); border-color: var(--csk-border); }
.csk-dark .csk-loading__bubble { background: var(--csk-bg-ai); border-color: var(--csk-border); }
.csk-dark .csk-loading__dot { background: #6B7280; }
.csk-dark .csk-category-bar { background: #1A1A2E; border-top-color: var(--csk-border); }
.csk-dark .csk-category-select { background-color: var(--csk-bg-app); border-color: var(--csk-border); color: #E2E8F0; }
.csk-dark .csk-sources { background: #1E1E2E; border-color: var(--csk-border); }
.csk-dark .csk-sources__header { background: #25253A; }
.csk-dark .csk-sources__header:hover { background: #2D2D48; }
.csk-dark .csk-source-item:hover { background: var(--csk-bg-ai); }
.csk-dark .csk-source-item__name { color: #E2E8F0; }
.csk-dark .csk-source-item__meta { color: #6B7280; }
.csk-dark .csk-history-panel { background: #1A1A2E; }
.csk-dark .csk-history-panel__header { background: var(--csk-bg-app); border-bottom-color: var(--csk-border); }
.csk-dark .csk-history-panel__back { background: var(--csk-bg-app); border-color: var(--csk-border); color: #E2E8F0; }
.csk-dark .csk-history-panel__back:hover { background: #2D2D48; }
.csk-dark .csk-history-item:hover { background: #25253A; }
.csk-dark .csk-history-item__id { color: #E2E8F0; }
.csk-dark .csk-history-item__meta { color: #6B7280; }
.csk-dark .csk-history-item--active { background: rgba(var(--csk-primary-rgb), 0.15); }
.csk-dark .csk-history-item--active:hover { background: rgba(var(--csk-primary-rgb), 0.22); }
.csk-dark .csk-action-btn:hover { background: #2D2D48; color: #E2E8F0; }
.csk-dark .csk-clear-btn { background: var(--csk-bg-app); border-color: var(--csk-border); color: #9CA3AF; }
.csk-dark .csk-clear-btn:hover { background: #3B1A1A; border-color: #7F1D1D; color: #FCA5A5; }
.csk-dark .csk-md-code-block { background: #0F0F1A; }
.csk-dark .csk-md-blockquote { background: rgba(var(--csk-primary-rgb), 0.08); }
.csk-dark .csk-md-link { color: var(--csk-primary-light); }
.csk-dark .csk-msg--ai .csk-msg__bubble { background: var(--csk-bg-ai); color: var(--csk-text-ai); border-color: var(--csk-border); }
.csk-dark .csk-welcome__title { color: #E2E8F0; }
.csk-dark .csk-welcome__desc { color: #6B7280; }
.csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); }
.csk-dark .csk-teaser::after { border-top-color: #2D2D48; }
.csk-dark .csk-teaser__close { color: #6B7280; }
.csk-dark .csk-teaser__close:hover { color: #9CA3AF; }
.csk-dark .csk-feedback-btn { background: rgba(var(--csk-primary-rgb), 0.1); color: #9CA3AF; }
.csk-dark .csk-feedback-btn:hover { background: rgba(var(--csk-primary-rgb), 0.2); color: var(--csk-primary-light); }
.csk-dark .csk-feedback-btn--active { background: rgba(var(--csk-primary-rgb), 0.28); color: var(--csk-primary-light); }
.csk-dark .csk-history-panel__search-wrap { background: var(--csk-bg-app); border-color: var(--csk-border); }
.csk-dark .csk-history-panel__search-wrap:focus-within { border-color: var(--csk-primary); }
.csk-dark .csk-history-panel__search { color: #E2E8F0; }
.csk-dark .csk-launcher__badge { box-shadow: 0 0 0 2px #1A1A2E; }
.csk-dark .csk-welcome__avatar { box-shadow: 0 6px 18px rgba(var(--csk-primary-rgb), 0.25); }
.csk-dark .csk-header__avatar { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.1); }
/* ========== Launcher 未读徽章 ========== */
.csk-launcher__badge {
position: absolute;
top: -3px;
right: -3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #EF4444;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #fff;
animation: csk-badge-in 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
}
.csk-launcher__badge--hidden { display: none; }
@keyframes csk-badge-in {
from { transform: scale(0); }
to { transform: scale(1); }
}
/* ========== Launcher 提示气泡 ========== */
.csk-teaser {
position: fixed;
bottom: 90px;
z-index: 9997;
max-width: 240px;
padding: 10px 14px;
background: #fff;
color: #1F2937;
font-size: 13px;
line-height: 1.5;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.15);
animation: csk-teaser-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
cursor: pointer;
user-select: none;
}
.csk-teaser--right { right: 24px; }
.csk-teaser--left { left: 24px; }
.csk-teaser::after {
content: '';
position: absolute;
bottom: -6px;
right: 28px;
width: 12px;
height: 12px;
background: inherit;
border-radius: 2px;
transform: rotate(45deg);
box-shadow: 2px 2px 4px rgba(15, 23, 42, 0.08);
}
.csk-teaser--left::after { right: auto; left: 28px; }
.csk-teaser__close {
position: absolute;
top: 4px;
right: 6px;
background: none;
border: none;
font-size: 16px;
color: #9CA3AF;
cursor: pointer;
padding: 2px 4px;
line-height: 1;
border-radius: 4px;
}
.csk-teaser__close:hover { color: #374151; }
.csk-teaser--hidden { display: none; }
@keyframes csk-teaser-in {
from { opacity: 0; transform: translateY(8px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ========== 消息反馈按钮(👍 / 👎) ========== */
.csk-feedback-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: rgba(var(--csk-primary-rgb), 0.08);
color: #9CA3AF;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.csk-feedback-btn:hover { background: rgba(var(--csk-primary-rgb), 0.18); color: var(--csk-primary); }
.csk-feedback-btn--active {
background: rgba(var(--csk-primary-rgb), 0.28);
color: var(--csk-primary);
}
.csk-feedback-btn--active.down { background: #FEF2F2; color: #EF4444; }
.csk-feedback-btn:active { transform: scale(0.9); }
.csk-msg--streaming .csk-feedback-btn { display: none; }
/* ========== 历史会话搜索框 ========== */
.csk-history-panel__search-wrap {
padding: 8px 10px;
border-bottom: 1px solid var(--csk-border);
flex-shrink: 0;
}
.csk-history-panel__search-wrap:focus-within {
border-bottom-color: var(--csk-primary);
}
.csk-history-panel__search {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--csk-border);
border-radius: 8px;
font-size: 12.5px;
font-family: inherit;
color: #1F2937;
background: var(--csk-bg-app);
outline: none;
transition: border-color 0.2s;
}
.csk-history-panel__search:focus { border-color: var(--csk-primary); }
.csk-history-panel__search::placeholder { color: #9CA3AF; }
/* ========== a11y 焦点指示 ========== */
.csk-root :focus-visible {
outline: 2px solid var(--csk-primary);
outline-offset: 2px;
}
.csk-root button:focus-visible {
outline: 2px solid var(--csk-primary);
outline-offset: 2px;
border-radius: inherit;
}
/* 弹窗打开时隐藏 launcher 上的焦点环 */
.csk-launcher:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(var(--csk-primary-rgb), 0.4); }
/* ========== a11y aria-live 播报区(视觉隐藏) ========== */
.csk-sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0,0,0,0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* ========== 移动端适配 ========== */ /* ========== 移动端适配 ========== */
@media (max-width: 480px) { @media (max-width: 480px) {
.csk-window { .csk-window {

18
client/src/types.ts

@ -40,6 +40,14 @@ export interface SDKConfig {
showClear?: boolean; showClear?: boolean;
/** 是否显示管理面板,默认 false */ /** 是否显示管理面板,默认 false */
showAdminPanel?: boolean; showAdminPanel?: boolean;
/** 欢迎态快捷问题列表,点击即自动发送,默认空数组 */
quickReplies?: string[];
/** 主题模式,默认 'light' */
theme?: 'light' | 'dark';
/** 是否显示首访提示气泡(延迟 1.5s 弹出),默认 true */
showTeaser?: boolean;
/** 提示气泡文字,留空则使用 i18n 默认值 */
teaserText?: string;
// === 行为配置 === // === 行为配置 ===
/** 是否启用流式输出,默认 true */ /** 是否启用流式输出,默认 true */
@ -76,6 +84,14 @@ export interface ResolvedConfig {
showClear: boolean; showClear: boolean;
/** 显示管理面板 */ /** 显示管理面板 */
showAdminPanel: boolean; showAdminPanel: boolean;
/** 欢迎态快捷问题列表 */
quickReplies: string[];
/** 主题模式 */
theme: 'light' | 'dark';
/** 是否显示首访提示气泡 */
showTeaser: boolean;
/** 提示气泡文字 */
teaserText: string;
/** 流式输出 */ /** 流式输出 */
streaming: boolean; streaming: boolean;
/** 界面语言 */ /** 界面语言 */
@ -98,6 +114,8 @@ export interface ChatMessage {
timestamp: number; timestamp: number;
/** 可选:RAG 引用来源 */ /** 可选:RAG 引用来源 */
sources?: RagSource[]; sources?: RagSource[];
/** 可选:用户反馈 'up' | 'down',预留后端对接位 */
feedback?: 'up' | 'down';
} }
/** RAG 引用来源 */ /** RAG 引用来源 */

46
pom.xml

@ -17,6 +17,42 @@
<java.version>17</java.version> <java.version>17</java.version>
<spring-ai-alibaba.version>1.0.0.4</spring-ai-alibaba.version> <spring-ai-alibaba.version>1.0.0.4</spring-ai-alibaba.version>
</properties> </properties>
<!-- ==================== Maven 多环境 Profile ==================== -->
<!-- 通过 -P 参数选择目标环境:mvn clean package -P test / -P prod;不传则默认 dev -->
<!-- 每个 profile 通过 profiles.active 属性标识当前环境,打包时会替换 application.yml 中的 @profiles.active@ 占位符 -->
<profiles>
<profile>
<!-- 本地开发环境(默认激活) -->
<id>dev</id>
<properties>
<profiles.active>dev</profiles.active>
</properties>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<!-- 生产环境 -->
<id>prod</id>
<properties>
<profiles.active>prod</profiles.active>
</properties>
</profile>
<!-- 禁用 Spring Boot 父 POM 自带的 GraalVM 原生镜像 profile(IDEA 面板不再显示) -->
<profile>
<id>native</id>
<activation>
<property><name>skipNativeProfile</name></property>
</activation>
</profile>
<profile>
<id>nativeTest</id>
<activation>
<property><name>skipNativeTestProfile</name></property>
</activation>
</profile>
</profiles>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<!-- Spring AI BOM:统一管理所有 Spring AI 依赖版本 --> <!-- Spring AI BOM:统一管理所有 Spring AI 依赖版本 -->
@ -126,6 +162,16 @@
</dependencies> </dependencies>
<build> <build>
<!-- 打包产物按环境区分文件名,如 supportbot-dev.jar / supportbot-test.jar / supportbot-prod.jar -->
<finalName>supportbot-${profiles.active}</finalName>
<!-- 开启资源过滤:使 application.yml 中的 @profiles.active@ 占位符在打包时被替换为实际环境 -->
<!-- Spring Boot 父 POM 默认使用 @...@ 作为过滤占位符,不会误伤 yml 中的 ${...} -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>

36
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -51,6 +51,18 @@ public class ChatModelFactory {
"openai", "https://api.openai.com" "openai", "https://api.openai.com"
); );
/**
* 各提供商的 Chat 完成路径默认 /v1/chat/completions
* baseUrl 已含版本路径段的厂商moonshot/volcengine/zhipu需设为 /chat/completions
* 否则与默认 /v1/chat/completions 拼接后会多出一层路径 /api/v3/v1/chat/completions
* 此映射与 EmbeddingModelFactory.DEFAULT_EMBEDDINGS_PATHS 一一对应
*/
private static final Map<String, String> DEFAULT_COMPLETIONS_PATHS = Map.of(
"moonshot", "/chat/completions",
"volcengine", "/chat/completions",
"zhipu", "/chat/completions"
);
/** /**
* 按应用类型获取活跃的 ChatModel * 按应用类型获取活跃的 ChatModel
* 如果该类型无活跃配置抛出异常不应在缺少配置时静默回退 * 如果该类型无活跃配置抛出异常不应在缺少配置时静默回退
@ -108,13 +120,18 @@ public class ChatModelFactory {
// OpenAI 兼容提供商 // OpenAI 兼容提供商
String baseUrl = resolveBaseUrl(config); String baseUrl = resolveBaseUrl(config);
log.info("创建 OpenAI 兼容 ChatModel: provider={}, baseUrl={}, model={}",
config.getProvider(), baseUrl, config.getModelName());
String completionsPath = resolveCompletionsPath(config);
log.info("创建 OpenAI 兼容 ChatModel: provider={}, baseUrl={}, completionsPath={}, model={}",
config.getProvider(), baseUrl, completionsPath, config.getModelName());
var api = OpenAiApi.builder()
var apiBuilder = OpenAiApi.builder()
.apiKey(config.getApiKey()) .apiKey(config.getApiKey())
.baseUrl(baseUrl)
.build();
.baseUrl(baseUrl);
// 按提供商设置 Chat 完成路径默认 /v1/chat/completionsbaseUrl 已含版本段的厂商用 /chat/completions
if (completionsPath != null) {
apiBuilder.completionsPath(completionsPath);
}
var api = apiBuilder.build();
var optionsBuilder = OpenAiChatOptions.builder() var optionsBuilder = OpenAiChatOptions.builder()
.model(config.getModelName()); .model(config.getModelName());
@ -146,6 +163,15 @@ public class ChatModelFactory {
"未知提供商 [" + config.getProvider() + "],请在配置中填写 API 基础地址 (baseUrl)"); "未知提供商 [" + config.getProvider() + "],请在配置中填写 API 基础地址 (baseUrl)");
} }
/**
* 解析 Chat 完成路径baseUrl 已含版本路径段的厂商moonshot/volcengine/zhipu
* 需要设为 /chat/completions其余使用 OpenAI 默认路径 /v1/chat/completions
* 返回 null 表示使用默认值不额外设置
*/
private String resolveCompletionsPath(AiModelConfig config) {
return DEFAULT_COMPLETIONS_PATHS.get(config.getProvider());
}
/** /**
* 清除 ChatModel 缓存配置变更时调用 * 清除 ChatModel 缓存配置变更时调用
*/ */

6
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -391,6 +391,12 @@ public class DatabaseInitConfig {
return; return;
} }
// yml 未配置 api-key 时种子值留空不影响启动用户需在前端AI 大模型配置管理页面填入 api_key 后才能调用 AI 功能
if (dashscopeApiKey == null || dashscopeApiKey.isBlank()) {
log.warn("未检测到 spring.ai.dashscope.api-key,已写入空 api_key 的默认配置;" +
"请在前端「AI 大模型配置管理」页面为各应用类型填入 API Key 后再使用 AI 功能");
}
insertDefaultModelConfig("Chat Default", "CHAT", "dashscope", insertDefaultModelConfig("Chat Default", "CHAT", "dashscope",
dashscopeApiKey, "qwen-turbo", 0.7, 2000, dashscopeApiKey, "qwen-turbo", 0.7, 2000,
true, 100, "Default chat model config"); true, 100, "Default chat model config");

7
src/main/java/com/wok/supportbot/service/AiModelConfigService.java

@ -189,7 +189,12 @@ public class AiModelConfigService {
// JSONB 字段 MyBatis Plus typeHandler 可能不触发 JdbcTemplate 显式写入保证可靠 // JSONB 字段 MyBatis Plus typeHandler 可能不触发 JdbcTemplate 显式写入保证可靠
persistExtraConfig(id, config.getExtraConfig()); persistExtraConfig(id, config.getExtraConfig());
log.info("更新 AI 模型配置: id={}", id); log.info("更新 AI 模型配置: id={}", id);
return aiModelConfigMapper.selectById(id);
AiModelConfig updated = aiModelConfigMapper.selectById(id);
// 返回前脱敏避免明文 api_key 暴露给前端与列表/详情接口保持一致
if (updated != null) {
updated.setApiKey(maskApiKey(updated.getApiKey()));
}
return updated;
} }
// ==================== 激活配置 ==================== // ==================== 激活配置 ====================

31
src/main/resources/application-dev.yml

@ -0,0 +1,31 @@
# ================================================================
# 本地开发环境配置(默认激活)
# ================================================================
# 本地 IDE 运行时自动加载此文件,无需额外参数。
# Maven 打包:mvn clean package(不传 -P,默认 dev)
# ================================================================
server:
port: 9090
spring:
datasource:
url: jdbc:postgresql://192.168.1.18:5432/support_bot
username: postgres
password: supportbot123
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
# ==================== Knife4j 开关 ====================
knife4j:
enable: true
# ==================== 日志级别 ====================
logging:
level:
root: INFO
com.wok.supportbot: DEBUG
org.springframework.ai: INFO

41
src/main/resources/application-prod.yml

@ -0,0 +1,41 @@
# ================================================================
# 生产环境配置
# ================================================================
# Maven 打包:mvn clean package -P prod
# 生成产物:target/supportbot-prod.jar
#
# ⚠️ 请将下方「PROD_HOST / PROD_PORT / PROD_DB / PROD_USER / PROD_PASSWORD」
# 替换为真实的生产环境数据库连接信息后再打包。
# 建议通过 JVM 启动参数或环境变量覆盖敏感信息,例如:
# java -jar supportbot-prod.jar \
# --spring.datasource.url=jdbc:postgresql://实际地址:5432/support_bot \
# --spring.datasource.username=实际账号 \
# --spring.datasource.password=实际密码
# ================================================================
server:
port: 9090
spring:
datasource:
url: jdbc:postgresql://192.168.1.220:5432/support_bot
username: admin
password: cdw147258369
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
# ==================== Knife4j 开关 ====================
# 生产环境建议关闭 API 文档入口
knife4j:
enable: false
# ==================== 日志级别 ====================
# 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO
logging:
level:
root: WARN
com.wok.supportbot: INFO
org.springframework.ai: WARN

59
src/main/resources/application.yml

@ -1,38 +1,38 @@
# ================================================================ # ================================================================
# Support Bot - AI智能客服系统 配置文件
# Support Bot - AI智能客服系统 通用配置
# ================================================================
# 本文件仅保留所有环境共享的通用配置。
# 环境差异化的配置(端口、数据库连接、日志级别、API 文档开关等)
# 已拆分到 application-dev.yml / application-test.yml / application-prod.yml。
# 打包时通过 Maven Profile 选择目标环境:mvn clean package -P test / -P prod
# ================================================================ # ================================================================
# ==================== 服务端口 ====================
server:
port: 9090
spring:
# ==================== Profile 激活(由 Maven 替换) ====================
# 打包时 Maven 会将 @profiles.active@ 替换为 dev/test/prod
# 本地 IDE 直接运行时可在此处手动指定,或通过 --spring.profiles.active=dev 传入
profiles:
active: @profiles.active@
# ==================== Spring AI Alibaba DashScope 配置 ==================== # ==================== Spring AI Alibaba DashScope 配置 ====================
# 注意: 需要替换为你的阿里云 DashScope API Key
# 获取地址: https://dashscope.console.aliyun.com/
# 模型名称、温度等参数已迁移到前端「AI 大模型配置管理」页面,在 ai_model_config 表中管理
spring:
# 模型名称、温度、API Key 等参数已迁移到前端「AI 大模型配置管理」页面管理
ai: ai:
dashscope: dashscope:
api-key: sk-ws-H.RPMIMYH.G2gK.MEQCIFQ2aUocl1x5Q8sod1UgcBy0DzC5aJda5J-14thyXERBAiBYwX1k-7lWbEWYPnnDmJ9UV11uxLa13czU4hMMWJzi3A
# 关闭 DashScope 自动配置:所有 ChatModel/EmbeddingModel 由
# ChatModelFactory / EmbeddingModelFactory 按 DB(ai_model_config 表) 手动构建
enabled: false
# ==================== 数据源配置(PostgreSQL + PGVector) ====================
# ==================== 连接池通用配置 ====================
# 具体的数据源 url/username/password 在各环境 yml 中配置
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://192.168.1.18:5432/support_bot
username: postgres
password: supportbot123
hikari: hikari:
maximum-pool-size: 10 maximum-pool-size: 10
minimum-idle: 5 minimum-idle: 5
idle-timeout: 300000 idle-timeout: 300000
connection-timeout: 20000 connection-timeout: 20000
# ==================== SQL 初始化(首次启动可开启,后续建议关闭) ====================
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
# ==================== 文件上传大小限制 ====================
servlet: servlet:
multipart: multipart:
max-file-size: 50MB max-file-size: 50MB
@ -40,22 +40,16 @@ spring:
# ==================== MyBatis Plus 配置 ==================== # ==================== MyBatis Plus 配置 ====================
mybatis-plus: mybatis-plus:
# 实体类扫描包路径
type-aliases-package: com.wok.supportbot.entity type-aliases-package: com.wok.supportbot.entity
# Mapper XML 文件位置
mapper-locations: classpath*:mapper/**/*.xml mapper-locations: classpath*:mapper/**/*.xml
configuration: configuration:
# 日志输出
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 下划线转驼峰
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
global-config: global-config:
db-config: db-config:
# 逻辑删除字段
logic-delete-field: isDelete logic-delete-field: isDelete
logic-delete-value: true logic-delete-value: true
logic-not-delete-value: false logic-not-delete-value: false
# 主键类型
id-type: assign_id id-type: assign_id
# ==================== 知识库文档处理配置 ==================== # ==================== 知识库文档处理配置 ====================
@ -67,15 +61,15 @@ knowledge:
max-num-chunks: 5000 max-num-chunks: 5000
keep-separator: true keep-separator: true
vector: vector:
# 向量维度,需与 Embedding 模型输出维度一致
# 向量维度,需与 Embedding 模型输出维度一致
# 千问 text-embedding-v2: 1536 | 豆包 doubao-embedding-text-240515: 2048 | OpenAI text-embedding-3-small: 1536 # 千问 text-embedding-v2: 1536 | 豆包 doubao-embedding-text-240515: 2048 | OpenAI text-embedding-3-small: 1536
# 修改此值后需重建 vector_store 表(PG 不支持直接修改 vector 列维度)
dimension: 1536 dimension: 1536
role: role:
# 严格隔离:true=角色未绑定知识库分类时禁止检索任何内容;false(默认)=可检索全部知识库
# 严格隔离:true=角色未绑定知识库分类时禁止检索任何内容;false=可检索全部知识库
strict-isolation: false strict-isolation: false
# ==================== Knife4j API 文档配置 ====================
# ==================== Knife4j API 文档通用配置 ====================
# knife4j.enable 开关在各环境 yml 中配置(生产环境建议关闭)
springdoc: springdoc:
swagger-ui: swagger-ui:
path: /swagger-ui.html path: /swagger-ui.html
@ -89,15 +83,12 @@ springdoc:
packages-to-scan: com.wok.supportbot.controller packages-to-scan: com.wok.supportbot.controller
knife4j: knife4j:
enable: true
setting: setting:
language: zh_cn language: zh_cn
swagger-model-name: 实体类列表 swagger-model-name: 实体类列表
# ==================== 日志输出格式(通用) ====================
# 具体的 logging.level 在各环境 yml 中配置
logging: logging:
level:
root: INFO
com.wok.supportbot: DEBUG
org.springframework.ai: INFO
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

68
src/main/resources/static/components/ModelConfigManager.js

@ -27,47 +27,62 @@ const PROVIDER_OPTIONS = [
] ]
// 提供商默认配置(切换时自动填充) // 提供商默认配置(切换时自动填充)
// chatModels / embeddingModels 按 app_type 分别展示,models 作为向后兼容兜底
const PROVIDER_DEFAULTS = { const PROVIDER_DEFAULTS = {
dashscope: { dashscope: {
baseUrl: '', baseUrl: '',
models: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-long', 'text-embedding-v2'],
defaultModel: 'qwen-turbo',
chatModels: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-long'],
embeddingModels: ['text-embedding-v2'],
defaultChatModel: 'qwen-turbo',
defaultEmbeddingModel: 'text-embedding-v2',
tip: '通义千问使用 DashScope 自动配置,Base URL 留空即可' tip: '通义千问使用 DashScope 自动配置,Base URL 留空即可'
}, },
deepseek: { deepseek: {
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
models: ['deepseek-chat', 'deepseek-reasoner'],
defaultModel: 'deepseek-chat',
chatModels: ['deepseek-chat', 'deepseek-reasoner'],
embeddingModels: [],
defaultChatModel: 'deepseek-chat',
defaultEmbeddingModel: '',
tip: '' tip: ''
}, },
volcengine: { volcengine: {
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
models: ['doubao-embedding-text-240515', 'doubao-embedding-large', 'doubao-embedding-vision-251215'],
defaultModel: 'doubao-embedding-text-240515',
tip: '豆包文本向量化推荐 doubao-embedding-text-240515(2048维)或 doubao-embedding-large(4096维);多模态推荐 doubao-embedding-vision-251215。注意维度需与前端配置的向量维度一致'
chatModels: ['doubao-1-5-pro-32k', 'doubao-1-5-lite-32k', 'doubao-pro-32k', 'doubao-1-5-thinking-pro', 'doubao-seed-1.6'],
embeddingModels: ['doubao-embedding-text-240515', 'doubao-embedding-large', 'doubao-embedding-vision-251215'],
defaultChatModel: 'doubao-1-5-pro-32k',
defaultEmbeddingModel: 'doubao-embedding-text-240515',
tip: '豆包对话推荐 doubao-1-5-pro-32k;向量化推荐 doubao-embedding-text-240515(2048维)。模型名称可在火山引擎 ARK 控制台获取'
}, },
moonshot: { moonshot: {
baseUrl: 'https://api.moonshot.cn/v1', baseUrl: 'https://api.moonshot.cn/v1',
models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
defaultModel: 'moonshot-v1-8k',
chatModels: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'],
embeddingModels: [],
defaultChatModel: 'moonshot-v1-8k',
defaultEmbeddingModel: '',
tip: '' tip: ''
}, },
zhipu: { zhipu: {
baseUrl: 'https://open.bigmodel.cn/api/paas/v4', baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
models: ['glm-4-plus', 'glm-4-flash', 'glm-4-long', 'glm-4'],
defaultModel: 'glm-4-flash',
chatModels: ['glm-4-plus', 'glm-4-flash', 'glm-4-long', 'glm-4'],
embeddingModels: [],
defaultChatModel: 'glm-4-flash',
defaultEmbeddingModel: '',
tip: '' tip: ''
}, },
openai: { openai: {
baseUrl: 'https://api.openai.com', baseUrl: 'https://api.openai.com',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'],
defaultModel: 'gpt-4o-mini',
chatModels: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'],
embeddingModels: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'],
defaultChatModel: 'gpt-4o-mini',
defaultEmbeddingModel: 'text-embedding-3-small',
tip: '' tip: ''
}, },
other: { other: {
baseUrl: '', baseUrl: '',
models: [],
defaultModel: '',
chatModels: [],
embeddingModels: [],
defaultChatModel: '',
defaultEmbeddingModel: '',
tip: '使用 OpenAI 兼容 API 的其他提供商,请填写 Base URL 和模型名称' tip: '使用 OpenAI 兼容 API 的其他提供商,请填写 Base URL 和模型名称'
} }
} }
@ -301,27 +316,38 @@ export default {
} }
} }
// 提供商切换时自动填充
// 提供商切换时自动填充(按 app_type 区分对话/向量模型)
function onProviderChange() { function onProviderChange() {
const provider = editModal.value.form.provider const provider = editModal.value.form.provider
const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.other const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.other
const isEmbedding = editModal.value.form.app_type === 'EMBEDDING'
// 自动填充 Base URL // 自动填充 Base URL
editModal.value.form.base_url = defaults.baseUrl editModal.value.form.base_url = defaults.baseUrl
// 自动填充模型名称(仅新建模式且模型名为空时)
// 按 app_type 选择模型列表和默认模型
const models = isEmbedding ? (defaults.embeddingModels || []) : (defaults.chatModels || [])
const defaultModel = isEmbedding ? (defaults.defaultEmbeddingModel || '') : (defaults.defaultChatModel || '')
// 仅新建模式且模型名为空时自动填充默认模型
if (editModal.value.mode === 'add' && !editModal.value.form.model_name) { if (editModal.value.mode === 'add' && !editModal.value.form.model_name) {
editModal.value.form.model_name = defaults.defaultModel
editModal.value.form.model_name = defaultModel
} }
// 更新模型列表和提示
currentProviderModels.value = defaults.models || []
currentProviderDefaultModel.value = defaults.defaultModel || ''
currentProviderModels.value = models
currentProviderDefaultModel.value = defaultModel
providerTip.value = defaults.tip || '' providerTip.value = defaults.tip || ''
providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址' providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址'
customModelName.value = '' customModelName.value = ''
} }
// 监听 app_type 变化,刷新模型列表
watch(() => editModal.value.form.app_type, () => {
if (editModal.value.visible) {
onProviderChange()
}
})
// 监听自定义模型名输入 // 监听自定义模型名输入
watch(customModelName, (val) => { watch(customModelName, (val) => {
if (editModal.value.form.model_name === '__custom__' && val) { if (editModal.value.form.model_name === '__custom__' && val) {

1168
src/main/resources/static/sdk/chatbot-sdk.js
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

Loading…
Cancel
Save