dev-mcp #1

Merged
wanghanlin merged 8 commits from dev-mcp into master 1 week ago
  1. 19
      CLAUDE.md
  2. 472
      SDK-INTEGRATION.md
  3. 2
      client/README.md
  4. 963
      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. 101
      client/src/api.ts
  9. 107
      client/src/chat.ts
  10. 126
      client/src/config.ts
  11. 179
      client/src/dom.ts
  12. 6
      client/src/i18n.ts
  13. 55
      client/src/index.ts
  14. 453
      client/src/styles.ts
  15. 21
      client/src/types.ts
  16. 9
      client/src/utils.ts
  17. BIN
      io/modelcontextprotocol/client/McpSyncClient.class
  18. 6
      pom.xml
  19. 223
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  20. 16
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  21. 203
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  22. 80
      src/main/java/com/wok/supportbot/config/FileStorageConfig.java
  23. 660
      src/main/java/com/wok/supportbot/config/McpClientManager.java
  24. 24
      src/main/java/com/wok/supportbot/config/McpRequestInterceptor.java
  25. 24
      src/main/java/com/wok/supportbot/config/McpWebMvcConfig.java
  26. 17
      src/main/java/com/wok/supportbot/controller/AiController.java
  27. 59
      src/main/java/com/wok/supportbot/controller/ApiKeyController.java
  28. 127
      src/main/java/com/wok/supportbot/controller/AuthController.java
  29. 36
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  30. 36
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  31. 348
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  32. 118
      src/main/java/com/wok/supportbot/converter/MessageConverter.java
  33. 12
      src/main/java/com/wok/supportbot/dao/McpServerConfigMapper.java
  34. 35
      src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java
  35. 37
      src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java
  36. 41
      src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java
  37. 4
      src/main/java/com/wok/supportbot/entity/ApiKey.java
  38. 6
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  39. 104
      src/main/java/com/wok/supportbot/entity/McpServerConfig.java
  40. 265
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  41. 111
      src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java
  42. 73
      src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java
  43. 2
      src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java
  44. 30
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  45. 4
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  46. 124
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  47. 149
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  48. 5
      src/main/java/com/wok/supportbot/security/SecurityConfig.java
  49. 82
      src/main/java/com/wok/supportbot/service/ApiKeyService.java
  50. 143
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
  51. 124
      src/main/java/com/wok/supportbot/service/DocumentService.java
  52. 3
      src/main/java/com/wok/supportbot/service/FaqService.java
  53. 320
      src/main/java/com/wok/supportbot/service/McpServerConfigService.java
  54. 5
      src/main/resources/application-dev.yml
  55. 9
      src/main/resources/application-prod.yml
  56. 9
      src/main/resources/application.yml
  57. 94
      src/main/resources/init-database.sql
  58. 129
      src/main/resources/static/components/ApiKeyManager.js
  59. 41
      src/main/resources/static/components/ChatPanel.js
  60. 9
      src/main/resources/static/components/DocList.js
  61. 524
      src/main/resources/static/components/McpServerManager.js
  62. 29
      src/main/resources/static/components/RoleManager.js
  63. 9
      src/main/resources/static/css/main.css
  64. 134
      src/main/resources/static/js/api.js
  65. 33
      src/main/resources/static/js/app.js
  66. 5
      src/main/resources/static/js/store.js
  67. 61
      src/main/resources/static/js/utils.js
  68. 963
      src/main/resources/static/sdk/chatbot-sdk.js
  69. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  70. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  71. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map
  72. 221
      src/main/resources/static/sdk/test.html

19
CLAUDE.md

@ -174,6 +174,25 @@ catch (e) { toast('操作失败', 'error') }
- 消息反馈: `/feedback/*`(`MessageFeedbackController`) - 消息反馈: `/feedback/*`(`MessageFeedbackController`)
- 敏感词管理: `/sensitive-word/*`(`SensitiveWordController`) - 敏感词管理: `/sensitive-word/*`(`SensitiveWordController`)
- FAQ 管理: `/faq/*`(`FaqController`) - FAQ 管理: `/faq/*`(`FaqController`)
- SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取)
- API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色)
### Filter 优先级
| 优先级 | Filter | 路径 | 说明 |
|--------|--------|------|------|
| `HIGHEST + 1` | `SdkAuthFilter` | `/ai/**` | SDK JWT 鉴权 |
| `HIGHEST + 2` | `ApiKeyAuthFilter` | `/open-api/**` | API Key 鉴权 |
| SecurityFilterChain 内 | `JwtAuthFilter` | 管理接口 | 管理后台 JWT |
### SDK 鉴权架构
采用两段式鉴权:客户端后端用 API Key 换取短期 JWT Token → SDK 携带 Token 请求 `/ai/**` → SdkAuthFilter 校验放行。SDK JWT 密钥独立于管理后台 JWT(`jwt.sdk-secret`)。
- API Key 支持绑定客服角色列表(`role_ids` JSONB 字段),Token 换取时优先返回绑定的角色
- 未绑定角色的 API Key 返回所有启用角色(向后兼容)
- JWT Token 中 `sub` 为 apiKeyId,`rids` 为允许的角色 ID 列表
- 第三方系统接入指南详见 `SDK-INTEGRATION.md`
## P0 阶段新增功能 ## P0 阶段新增功能

472
SDK-INTEGRATION.md

@ -0,0 +1,472 @@
# Chat SDK 第三方系统集成指南
> 本文档说明第三方系统如何接入 AI 智能客服的 Chat SDK,完成从创建 API Key 到嵌入对话组件的全流程。
---
## 目录
1. [集成架构总览](#1-集成架构总览)
2. [第一步:创建 API Key](#2-第一步创建-api-key)
3. [第二步:绑定客服角色(可选)](#3-第二步绑定客服角色可选)
4. [第三步:后端换取 SDK Token](#4-第三步后端换取-sdk-token)
5. [第四步:前端嵌入 Chat SDK](#5-第四步前端嵌入-chat-sdk)
- [5.5 角色切换](#55-角色切换)
6. [API 接口参考](#6-api-接口参考)
7. [SDK 配置参数参考](#7-sdk-配置参数参考)
8. [错误码与排查](#8-错误码与排查)
9. [安全建议](#9-安全建议)
---
## 1. 集成架构总览
```
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 第三方前端 │ │ 第三方后端 │ │ AI 客服后端 │
│ (嵌入 SDK) │ │ (你的服务) │ │ (本系统) │
│ │ │ │ │ │
│ Chat SDK ──────┼─────┼──────────────────┼─────┼─► /ai/** 对话 │
│ ↑ token │ │ │ │ │
│ │ │ ① 携带 API Key │ │ ② 返回 JWT Token│
│ │ │ POST /open-api/ │─────┼─► /auth/token │
│ │ │ auth/token │ │ │
└─────────────────┘ └──────────────────┘ └──────────────────┘
```
**两段式鉴权流程:**
1. **管理后台**:管理员创建 API Key,可选绑定客服角色
2. **第三方后端**:用 API Key 换取短期 JWT Token + 可用角色列表
3. **第三方前端**:Chat SDK 携带 JWT Token 请求 `/ai/**` 接口进行对话
---
## 2. 第一步:创建 API Key
在管理后台「系统设置 → API Key 管理」页面创建。
### 管理后台操作
1. 登录管理后台 `http://localhost:9090/index.html`
2. 进入「系统设置 → API Key 管理」
3. 点击「+ 新建 API Key」
4. 填写名称、描述、频率限制等参数
5. **(可选)** 在创建弹窗中勾选需要绑定的客服角色
6. 创建后**立即复制保存 Key**,关闭后无法再次查看
### API Key 格式
```
sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx(sk_ 前缀 + 32 位 UUID)
```
---
## 3. 第二步:绑定客服角色(可选)
客服角色决定 AI 的人设、知识库范围和 MCP 工具权限。
### 角色绑定规则
| role_ids 状态 | Token 换取行为 |
|---|---|
| 空数组 `[]`(默认) | 返回**所有**启用的客服角色(向后兼容) |
| 非空 `[1, 2, 3]` | 仅返回绑定的角色 |
### 通过管理后台绑定
在 API Key 列表中点击「绑定角色」按钮,勾选需要的角色后保存。
### 通过 API 绑定
```bash
PUT /api-key/{keyId}/roles
Content-Type: application/json
Authorization: Bearer {管理后台 JWT Token}
{
"roleIds": [1, 2, 3]
}
```
响应:
```json
{
"success": true,
"message": "角色绑定更新成功"
}
```
---
## 4. 第三步:后端换取 SDK Token
**⚠️ 重要:Token 换取必须在第三方后端完成,不可在前端暴露 API Key。**
### 请求
```bash
POST /open-api/auth/token
Content-Type: application/json
X-API-Key: sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
{
"ttl": 7200
}
```
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `X-API-Key` | Header | 是 | 创建的 API Key |
| `ttl` | Body | 否 | Token 有效期(秒),默认 7200(2 小时),范围 [300, 86400] |
### 成功响应
```json
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiJ9...",
"expiresIn": 7200,
"roles": [
{ "id": "1", "key": "general", "name": "通用客服" },
{ "id": "2", "key": "finance", "name": "财务顾问" }
]
}
```
| 字段 | 说明 |
|---|---|
| `token` | SDK JWT Token,传给前端使用 |
| `expiresIn` | 过期时间(秒) |
| `roles` | 可用客服角色列表,前端用于展示角色选择器 |
### 错误响应
| HTTP 状态码 | 说明 |
|---|---|
| 401 | API Key 无效或已禁用 |
| 403 | 无可用客服角色 |
---
## 5. 第四步:前端嵌入 Chat SDK
### 使用测试面板快速验证
访问 `http://localhost:9090/sdk/test.html` 打开测试面板:
1. 在「🔐 SDK Token 鉴权」区域输入 API Key
2. 点击「获取 Token」按钮自动换取(或手动粘贴已有的 Token)
3. Token 获取成功后,integrateId 会自动填充为第一个可用角色
4. 点击「🚀 初始化 SDK」即可测试对话功能
测试面板支持所有 P0/P1/P2 测试用例的自动化运行,包括 API 连通性验证。
### 5.1 引入 SDK
```html
<script src="https://your-domain.com/sdk/chatbot-sdk.js"></script>
```
### 5.2 初始化
```javascript
const chatbot = ChatbotSDK.init({
// ========== 必填参数 ==========
integrateId: '1', // 客服角色 ID(从 Token 换取接口的 roles 中选择)
requestDomain: 'https://your-domain.com', // AI 客服后端域名
// ========== 鉴权参数(推荐) ==========
token: 'eyJhbGciOiJIUzI1NiJ9...', // 后端换取的 SDK Token
roles: [ // 可用角色列表(传多个时 SDK header 显示角色切换下拉框)
{ id: '1', key: 'general', name: '通用客服' },
{ id: '2', key: 'finance', name: '财务顾问' }
],
// ========== 可选参数 ==========
userId: 'user_12345', // 用户标识(用于会话隔离)
title: 'AI 智能助手', // 对话窗口标题
theme: 'light', // 主题:light / dark
primaryColor: '#4F46E5', // 主题色(十六进制)
streaming: true, // 启用流式回复(默认 true)
enableRag: true, // 启用 RAG 知识库检索(默认 true)
quickReplies: ['如何退款?', '联系人工客服'], // 快捷问题
position: 'right-bottom', // 悬浮按钮位置:right-bottom / left-bottom
width: 380, // 窗口宽度(px)
height: 520, // 窗口高度(px)
debug: true, // 控制台调试日志
// ========== 回调函数 ==========
onReady: function() { console.log('SDK 就绪'); },
onMessage: function(msg) { console.log('收到消息', msg); },
onError: function(err) { console.error('SDK 错误', err); }
});
```
### 5.3 SDK 方法
```javascript
// 销毁实例(移除 DOM 和事件监听)
chatbot.destroy();
```
### 5.4 不使用 Token 的兼容模式
如果暂时不接入后端 Token 换取,可直接使用兼容模式(**不推荐用于生产环境**):
```javascript
ChatbotSDK.init({
integrateId: '1', // 直接指定角色 ID
requestDomain: 'https://your-domain.com'
// 不传 token 和 roles
});
```
兼容模式下,`integrateId` 直接作为 `roleId` 传递给后端,无需 Token 换取步骤。由于不传 `roles`,SDK 不会显示角色选择器,用户只能使用初始指定的单一角色。
### 5.5 角色切换
`roles` 数组长度 > 1 时,SDK 会在聊天窗口头部自动显示角色选择下拉框,用户可随时切换当前使用的客服角色。
**触发条件:**
- `ChatbotSDK.init()` 时传入的 `roles` 数组包含 2 个及以上角色
**切换行为:**
1. 自动保存当前对话到 localStorage(与旧角色关联)
2. 清空当前消息列表
3. 生成新的 `chatId`(与新角色关联)
4. 尝试从 localStorage 恢复新角色的缓存消息
5. 异步从后端加载新角色的历史会话
**示例:**
```javascript
// 1. 后端换取 Token,获取可用角色列表
const data = await fetch('/api/chatbot/token').then(r => r.json());
// 2. 初始化 SDK,传入角色列表
ChatbotSDK.init({
integrateId: data.roles[0].id, // 默认使用第一个角色
requestDomain: 'https://your-domain.com',
token: data.token,
roles: data.roles, // 传入多个角色 → header 显示角色选择器
userId: 'current_user_id'
});
```
**安全说明:**
- 角色切换由 SDK 前端发起,后端 `SdkAuthFilter` 会校验 JWT Token 中的 `rids` claim,确保用户只能切换到被授权的角色
- 切换角色不需要重新换取 Token,JWT 中已包含允许的角色 ID 列表
---
## 6. API 接口参考
### 6.1 Token 换取
```
POST /open-api/auth/token
```
详见 [第三步:后端换取 SDK Token](#4-第三步后端换取-sdk-token)。
### 6.2 对话接口(SDK 内部调用,无需手动对接)
| 接口 | 方法 | 说明 |
|---|---|---|
| `/ai/assistant_app/chat/sync` | GET | 同步对话(返回完整文本) |
| `/ai/assistant_app/chat/sse` | GET | SSE 流式对话 |
| `/ai/assistant_app/chat/rag/sse` | GET | RAG 增强流式对话 |
| `/ai/assistant_app/rag/sources` | GET | 获取 RAG 引用来源 |
| `/ai/sdk/conversation/list` | GET | 会话列表 |
| `/ai/sdk/conversation/{id}/messages` | GET | 会话消息 |
| `/ai/sdk/conversation/{id}` | DELETE | 删除会话 |
| `/ai/sdk/conversation/{id}/export` | GET | 导出会话 |
| `/category/tree` | GET | 知识库分类树 |
| `/feedback` | POST | 消息反馈 |
**通用请求参数(对话接口):**
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `message` | Query | 是 | 用户消息 |
| `chatId` | Query | 是 | 会话 ID(SDK 自动管理) |
| `roleId` | Query | 否 | 客服角色 ID |
| `accountId` | Query | 否 | 用户标识 |
| `categoryId` | Query | 否 | 知识库分类 ID |
| `rewriteStrategy` | Query | 否 | RAG 查询重写策略:REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY |
**认证方式:** 所有 `/ai/**` 请求自动在 Header 中携带 `Authorization: Bearer {token}`
### 6.3 管理接口(需要管理后台 JWT)
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api-key/list` | GET | API Key 列表(admin) |
| `/api-key` | POST | 创建 API Key(admin) |
| `/api-key/{id}/revoke` | PUT | 吊销 API Key(admin) |
| `/api-key/{id}/enable` | PUT | 启用 API Key(admin) |
| `/api-key/{id}` | DELETE | 删除 API Key(admin) |
| `/api-key/{id}/roles` | PUT | 更新角色绑定(admin) |
| `/role/list` | GET | 客服角色列表(admin) |
| `/role/all` | GET | 全部角色含停用(admin) |
---
## 7. SDK 配置参数参考
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `integrateId` | String/Number | **必填** | 客服角色 ID |
| `requestDomain` | String | **必填** | 后端域名 |
| `token` | String | - | SDK JWT Token(推荐) |
| `roles` | Array | - | 角色列表 `[{id, key, name}]`,长度 > 1 时 header 显示切换下拉框 |
| `userId` | String | - | 用户标识(会话隔离) |
| `categoryId` | String | - | 默认知识库分类 ID |
| `showCategorySwitch` | Boolean | false | 显示分类切换器 |
| `title` | String | 'AI 智能助手' | 窗口标题 |
| `width` | Number | 380 | 窗口宽度(px) |
| `height` | Number | 520 | 窗口高度(px,最小 300) |
| `position` | String | 'right-bottom' | 悬浮按钮位置 |
| `primaryColor` | String | '#4F46E5' | 主题色 |
| `launcherTheme` | String | - | 按钮主题:dream-purple / mint-tech / coral-peach / sky-blue |
| `launcherIcon` | String | (内置) | 自定义悬浮按钮 SVG |
| `theme` | String | 'light' | 界面主题:light / dark |
| `streaming` | Boolean | true | 流式回复 |
| `enableRag` | Boolean | true | RAG 知识库检索 |
| `rewriteStrategy` | String | 'REWRITE' | 查询重写策略 |
| `quickReplies` | String[] | [] | 快捷问题列表 |
| `showClear` | Boolean | true | 显示清空按钮 |
| `showAdminPanel` | Boolean | false | 显示管理入口 |
| `showTeaser` | Boolean | true | 显示提示气泡 |
| `teaserText` | String | '' | 提示气泡文本 |
| `sound` | Boolean | false | 消息提示音 |
| `notification` | Boolean | false | 浏览器通知 |
| `locale` | String | 'zh-CN' | 语言:zh-CN / en |
| `debug` | Boolean | true | 调试日志 |
| `onReady` | Function | - | SDK 就绪回调 |
| `onMessage` | Function | - | 消息回调 |
| `onError` | Function | - | 错误回调 |
---
## 8. 错误码与排查
### Token 换取阶段
| 错误 | 原因 | 解决方案 |
|---|---|---|
| 401 `API Key 鉴权失败` | X-API-Key 无效或缺失 | 检查 Header 是否正确携带 |
| 401 `API Key 已被禁用` | Key 被吊销 | 在管理后台重新启用或创建新 Key |
| 401 `API Key 已过期` | Key 超过有效期 | 创建新的 API Key |
| 403 `无可用客服角色` | 未绑定角色且系统无启用角色 | 在管理后台创建并启用客服角色 |
### SDK 对话阶段
| HTTP 状态码 | SDK 提示 | 原因 |
|---|---|---|
| 401 | 鉴权失败 | Token 过期或无效,需重新换取 |
| 403 | 无访问权限 | roleId 不在 Token 允许范围内 |
| 429 | 请求过于频繁 | 超过 API Key 频率限制 |
| 500 | 服务器异常 | 后端错误,查看服务端日志 |
| 502/503 | 服务暂不可用 | 后端未启动或正在部署 |
### 常见问题
**Q: Token 过期后怎么办?**
A: Token 默认 2 小时过期。建议在第三方后端实现 Token 缓存和自动刷新逻辑:检测到 401 时重新调用 `/open-api/auth/token` 换取新 Token。
**Q: 如何让用户只能使用特定角色?**
A: 在 API Key 上绑定角色(`PUT /api-key/{id}/roles`),SDK 初始化时只传入绑定的角色 ID。
**Q: 多个第三方系统可以用同一个 API Key 吗?**
A: 可以,但建议每个系统使用独立的 API Key,便于独立管控频率限制和角色权限。
---
## 9. 安全建议
1. **API Key 仅在后端使用**:永远不要将 API Key 暴露到前端代码中
2. **Token 短有效期**:生产环境建议 TTL 设为 1-2 小时,配合自动刷新
3. **角色最小权限**:只绑定必要的客服角色,避免授予过多权限
4. **频率限制**:根据业务量设置合理的 rateLimit(默认 60 次/分钟)
5. **HTTPS**:生产环境必须使用 HTTPS,防止 Token 被中间人截获
6. **定期轮换 Key**:定期吊销旧 Key 并创建新 Key
---
## 附录:完整对接示例(Node.js)
```javascript
// ==================== 第三方后端示例 ====================
const express = require('express');
const app = express();
const AI_DOMAIN = 'https://your-ai-domain.com';
const API_KEY = 'sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
// Token 缓存
let cachedToken = null;
let tokenExpireAt = 0;
// 换取 Token(带缓存)
async function getSdkToken() {
if (cachedToken && Date.now() < tokenExpireAt) {
return cachedToken;
}
const res = await fetch(`${AI_DOMAIN}/open-api/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY
},
body: JSON.stringify({ ttl: 7200 })
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
cachedToken = data.token;
tokenExpireAt = Date.now() + (data.expiresIn - 300) * 1000; // 提前 5 分钟刷新
return { token: data.token, roles: data.roles };
}
// 给前端提供 Token
app.get('/api/chatbot/token', async (req, res) => {
try {
const data = await getSdkToken();
res.json({ success: true, ...data });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
app.listen(3000);
```
```html
<!-- ==================== 第三方前端示例 ==================== -->
<script src="https://your-ai-domain.com/sdk/chatbot-sdk.js"></script>
<script>
// 从自己后端获取 Token
fetch('/api/chatbot/token')
.then(r => r.json())
.then(data => {
if (data.success) {
ChatbotSDK.init({
integrateId: data.roles[0].id, // 默认使用第一个角色
requestDomain: 'https://your-ai-domain.com',
token: data.token,
roles: data.roles, // 传入多个角色时自动显示角色选择器
userId: 'current_user_id',
title: '在线客服',
theme: 'light',
quickReplies: ['如何退款?', '查看订单', '联系人工']
});
}
});
</script>
```

2
client/README.md

@ -85,7 +85,7 @@ SDK 产物位于 `client/dist/` 目录:
| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) | | `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) |
| `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 | | `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 |
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 | | `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 |
| `launcherIcon` | `string` | ❌ | SVG 图标 | P0 | 悬浮按钮图标 |
| `launcherIcon` | `string` | ❌ | 极光粒子图标 | P0 | 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 |
| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 | | `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 |
| `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 | | `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 |
| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` | | `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` |

963
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

101
client/src/api.ts

@ -15,11 +15,30 @@ const REQUEST_TIMEOUT = 30000;
let currentConfig: ResolvedConfig | null = null; let currentConfig: ResolvedConfig | null = null;
/** 运行时动态设置的角色 ID(通过角色选择器切换),优先于 currentConfig.integrateId */
let activeIntegrateId: string | null = null;
/** 设置当前配置 */ /** 设置当前配置 */
export function setApiConfig(config: ResolvedConfig): void { export function setApiConfig(config: ResolvedConfig): void {
currentConfig = config; currentConfig = config;
} }
/** 清空当前配置(销毁 SDK 时调用) */
export function clearApiConfig(): void {
currentConfig = null;
activeIntegrateId = null;
}
/** 设置当前活跃角色 ID(角色切换时调用),影响后续所有 API 调用的 roleId 参数 */
export function setActiveRoleId(roleId: string | null): void {
activeIntegrateId = roleId;
}
/** 获取当前有效的 integrateId(优先使用运行时设置,回退到配置默认值) */
export function getActiveIntegrateId(): string {
return activeIntegrateId ?? currentConfig?.integrateId ?? '';
}
/** 更新当前 chatId(对话 ID) */ /** 更新当前 chatId(对话 ID) */
export function updateChatId(chatId: string): void { export function updateChatId(chatId: string): void {
if (currentConfig) { if (currentConfig) {
@ -33,7 +52,7 @@ export function getChatId(): string {
} }
/** 构建完整请求 URL,自动防御双斜杠 */ /** 构建完整请求 URL,自动防御双斜杠 */
export function buildUrl(path: string): string {
function buildUrl(path: string): string {
if (!currentConfig) { if (!currentConfig) {
throw new Error('API 配置未初始化'); throw new Error('API 配置未初始化');
} }
@ -65,7 +84,7 @@ function buildChatUrl(message: string): string {
params.set('chatId', currentConfig!.chatId); params.set('chatId', currentConfig!.chatId);
// integrateId 映射为 roleId // integrateId 映射为 roleId
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
// userId 映射为 accountId // userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
@ -80,7 +99,7 @@ function buildChatSSEUrl(message: string, categoryId?: number): string {
params.set('message', message); params.set('message', message);
params.set('chatId', currentConfig!.chatId); params.set('chatId', currentConfig!.chatId);
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
@ -96,7 +115,7 @@ function buildChatRAGSSEUrl(message: string, categoryId?: number): string {
params.set('chatId', currentConfig!.chatId); params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
@ -112,7 +131,7 @@ function buildRagSourcesUrl(message: string, categoryId?: number): string {
params.set('chatId', currentConfig!.chatId); params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE'); params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
@ -141,12 +160,31 @@ async function safeFetch(
} }
try { try {
// 注入 SDK Token 认证头(仅对 /ai/ 路径注入,避免将 SDK Token 发送到管理接口)
const headers: Record<string, string> = {};
if (options.headers) {
if (options.headers instanceof Headers) {
options.headers.forEach((value, key) => { headers[key] = value; });
} else if (Array.isArray(options.headers)) {
options.headers.forEach(([key, value]) => { headers[key] = value; });
} else {
Object.assign(headers, options.headers as Record<string, string>);
}
}
if (currentConfig?.token && url.includes('/ai/')) {
headers['Authorization'] = `Bearer ${currentConfig.token}`;
}
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers,
signal: controller.signal, signal: controller.signal,
mode: 'cors', mode: 'cors',
credentials: 'include', credentials: 'include',
}); });
if (response.status === 401 && url.includes('/ai/')) {
logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token');
}
return response; return response;
} catch (err: unknown) { } catch (err: unknown) {
// 用户主动中断(停止生成),不算错误 // 用户主动中断(停止生成),不算错误
@ -196,22 +234,22 @@ function getHttpErrorMessage(status: number): string {
*/ */
export async function chatRequest(message: string): Promise<string> { export async function chatRequest(message: string): Promise<string> {
const url = buildChatUrl(message); const url = buildChatUrl(message);
logger.lifecycleSend(currentConfig!.integrateId, message.length);
logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
const response = await safeFetch(url); const response = await safeFetch(url);
if (!response.ok) { if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status); const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
throw new CskError(errorMsg, `http_${response.status}`); throw new CskError(errorMsg, `http_${response.status}`);
} }
const text = await response.text(); const text = await response.text();
logger.lifecycleReply(currentConfig!.integrateId, text.length);
logger.lifecycleReply(getActiveIntegrateId(), text.length);
return text; return text;
} catch (err) { } catch (err) {
if (err instanceof CskError) throw err; if (err instanceof CskError) throw err;
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
throw new CskError(t('error_unknown'), 'unknown'); throw new CskError(t('error_unknown'), 'unknown');
} }
} }
@ -235,14 +273,14 @@ export async function chatSSERequest(
: buildChatSSEUrl(message, categoryId); : buildChatSSEUrl(message, categoryId);
let totalText = ''; let totalText = '';
logger.lifecycleSend(currentConfig!.integrateId, message.length);
logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal); 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);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
onError(new CskError(errorMsg, `http_${response.status}`)); onError(new CskError(errorMsg, `http_${response.status}`));
return; return;
} }
@ -304,7 +342,7 @@ export async function chatSSERequest(
reader.releaseLock(); reader.releaseLock();
} }
logger.lifecycleStreamDone(currentConfig!.integrateId, totalText.length);
logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length);
onDone(); onDone();
} catch (err) { } catch (err) {
// 用户主动中断不触发 onError,走 onDone // 用户主动中断不触发 onError,走 onDone
@ -315,7 +353,7 @@ export async function chatSSERequest(
if (err instanceof CskError) { if (err instanceof CskError) {
onError(err); onError(err);
} else { } else {
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
onError(new CskError(t('error_network'), 'network')); onError(new CskError(t('error_network'), 'network'));
} }
} }
@ -417,6 +455,7 @@ interface ConversationItem {
chatId?: string; chatId?: string;
accountId?: string; accountId?: string;
roleId?: number; roleId?: number;
roleName?: string;
messageCount?: number; messageCount?: number;
lastMessageTime?: string; lastMessageTime?: string;
firstMessageTime?: string; firstMessageTime?: string;
@ -470,7 +509,7 @@ export async function fetchConversationMessages(conversationId: string): Promise
}> { }> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId); if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
const url = buildUrl(`/ai/sdk/conversation/${conversationId}/messages?${params.toString()}`); const url = buildUrl(`/ai/sdk/conversation/${conversationId}/messages?${params.toString()}`);
try { try {
const response = await safeFetch(url); const response = await safeFetch(url);
@ -493,7 +532,7 @@ export async function fetchConversationMessages(conversationId: string): Promise
export async function deleteConversation(conversationId: string): Promise<boolean> { export async function deleteConversation(conversationId: string): Promise<boolean> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId); if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
const url = buildUrl(`/ai/sdk/conversation/${conversationId}?${params.toString()}`); const url = buildUrl(`/ai/sdk/conversation/${conversationId}?${params.toString()}`);
try { try {
const response = await safeFetch(url, { method: 'DELETE' }); const response = await safeFetch(url, { method: 'DELETE' });
@ -514,7 +553,7 @@ export async function deleteConversation(conversationId: string): Promise<boolea
export function getConversationExportUrl(conversationId: string): string { export function getConversationExportUrl(conversationId: string): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId); if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`); return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`);
} }
@ -523,33 +562,33 @@ export function getConversationExportUrl(conversationId: string): string {
/** /**
* chatId * chatId
* *
*
* 1. localStorage chatId integrateId + userId
* 2. /ai/sdk/conversation/list?accountId=X&roleId=Y
* 3. 使 conversationId chatId
* 4. chatIdsdk_timestamp_random
* @param forceRefresh true localStorage
*/ */
export async function initChatId(): Promise<string> {
export async function initChatId(forceRefresh: boolean = false): Promise<string> {
if (!currentConfig) return ''; if (!currentConfig) return '';
// 1. 先尝试从 localStorage 恢复
const cachedChatId = loadCachedChatId(currentConfig.integrateId, currentConfig.userId);
if (cachedChatId) {
currentConfig.chatId = cachedChatId;
logger.info(`从缓存恢复 chatId=${cachedChatId}`);
return cachedChatId;
const activeId = getActiveIntegrateId();
// 1. 先从 localStorage 恢复(非强制刷新时)
if (!forceRefresh) {
const cachedChatId = loadCachedChatId(activeId, currentConfig.userId);
if (cachedChatId) {
currentConfig.chatId = cachedChatId;
logger.info(`从缓存恢复 chatId=${cachedChatId}`);
return cachedChatId;
}
} }
// 2. 查询后端会话列表 // 2. 查询后端会话列表
try { try {
const result = await fetchConversationList(1, 5, currentConfig.userId, currentConfig.integrateId);
const result = await fetchConversationList(1, 5, currentConfig.userId, activeId);
if (result.list.length > 0) { if (result.list.length > 0) {
// 使用最新会话的 conversationId 作为 chatId // 使用最新会话的 conversationId 作为 chatId
const latestConv = result.list[0]; const latestConv = result.list[0];
const chatId = latestConv.conversationId || latestConv.chatId || ''; const chatId = latestConv.conversationId || latestConv.chatId || '';
if (chatId) { if (chatId) {
currentConfig.chatId = chatId; currentConfig.chatId = chatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, chatId);
saveCachedChatId(activeId, currentConfig.userId, chatId);
logger.info(`从后端恢复会话 chatId=${chatId} messageCount=${latestConv.messageCount}`); logger.info(`从后端恢复会话 chatId=${chatId} messageCount=${latestConv.messageCount}`);
return chatId; return chatId;
} }
@ -561,7 +600,7 @@ export async function initChatId(): Promise<string> {
// 3. 生成新的 chatId // 3. 生成新的 chatId
const newChatId = generateChatId(); const newChatId = generateChatId();
currentConfig.chatId = newChatId; currentConfig.chatId = newChatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, newChatId);
saveCachedChatId(activeId, currentConfig.userId, newChatId);
logger.info(`生成新 chatId=${newChatId}`); logger.info(`生成新 chatId=${newChatId}`);
return newChatId; return newChatId;
} }

107
client/src/chat.ts

@ -21,6 +21,8 @@ import {
getChatId, getChatId,
saveCachedChatId, saveCachedChatId,
submitFeedbackApi, submitFeedbackApi,
setActiveRoleId,
getActiveIntegrateId,
CskError, CskError,
} from './api'; } from './api';
import { import {
@ -49,6 +51,7 @@ let inputWrap: HTMLElement | null = null;
let sendBtn: HTMLElement | null = null; let sendBtn: HTMLElement | null = null;
let clearBtn: HTMLElement | null = null; let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null; let categorySelect: HTMLSelectElement | null = null;
let roleSelect: 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 newMsgBtn: HTMLElement | null = null;
@ -85,6 +88,7 @@ export function initChat(
sendBtn: HTMLElement; sendBtn: HTMLElement;
clearBtn: HTMLElement | null; clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null; categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
historyPanel: HTMLElement; historyPanel: HTMLElement;
welcomeEl: HTMLElement; welcomeEl: HTMLElement;
newMsgBtn: HTMLElement; newMsgBtn: HTMLElement;
@ -101,6 +105,7 @@ export function initChat(
sendBtn = dom.sendBtn; sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn; clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect; categorySelect = dom.categorySelect;
roleSelect = dom.roleSelect;
historyPanel = dom.historyPanel; historyPanel = dom.historyPanel;
welcomeEl = dom.welcomeEl; welcomeEl = dom.welcomeEl;
newMsgBtn = dom.newMsgBtn; newMsgBtn = dom.newMsgBtn;
@ -175,8 +180,8 @@ async function loadHistoryFromBackend(): Promise<void> {
renderHistory(); renderHistory();
logger.info(`从后端加载 ${messages.length} 条历史消息`); logger.info(`从后端加载 ${messages.length} 条历史消息`);
// 同步到 localStorage
saveMessages(config.integrateId, messages);
// 同步到 localStorage(使用当前活跃角色 ID)
saveMessages(getActiveIntegrateId(), messages);
} }
} catch (err) { } catch (err) {
logger.warn('从后端加载历史消息失败', err); logger.warn('从后端加载历史消息失败', err);
@ -721,6 +726,78 @@ export function setCategory(categoryId: number | undefined): void {
logger.lifecycleCategoryChange(categoryId ?? '全部'); logger.lifecycleCategoryChange(categoryId ?? '全部');
} }
// ==================== 角色切换 ====================
/**
*
* - + chatId
* - API roleId
* -
*/
export async function switchRole(newRoleId: string): Promise<void> {
if (!config || !messagesContainer) return;
const oldRoleId = getActiveIntegrateId();
// 1. 判断是否与当前角色相同(防重复切换)
if (newRoleId === oldRoleId) return;
// 2. 保存当前消息到 localStorage(与旧 integrateId 关联)
if (messages.length > 0) {
saveMessages(oldRoleId, messages);
}
// 3. 中断正在进行的流式请求
if (abortController) {
abortController.abort();
abortController = null;
}
isSending = false;
setSendButtonMode('send');
// 4. 清空消息数组和 DOM
messages = [];
const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgNodes.forEach(el => el.remove());
// 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前
clearMessages(oldRoleId);
saveCachedChatId(oldRoleId, config.userId, undefined);
clearMessages(newRoleId);
saveCachedChatId(newRoleId, config.userId, undefined);
// 6. 更新 API 层的 integrateId(必须在 initChatId 之前)
setActiveRoleId(newRoleId);
// 7. 更新角色下拉框高亮项
if (roleSelect) {
roleSelect.value = newRoleId;
}
// 8. 强制查询后端会话列表(跳过 localStorage 缓存)
const foundChatId = await initChatId(true);
logger.info(`角色切换 initChatId 完成 chatId=${foundChatId} roleId=${newRoleId}`);
// 9. 从后端加载新角色的对话历史
if (foundChatId) {
try {
await loadHistoryFromBackend();
} catch (err) {
logger.warn(`切换角色后加载后端历史失败 roleId=${newRoleId}`, err);
}
}
// 10. 隐藏清空按钮 + 更新欢迎态
if (clearBtn && messages.length === 0) clearBtn.style.display = 'none';
updateEmptyState();
// 11. 重置滚动状态
isNearBottom = true;
if (messagesContainer) scrollToBottom(messagesContainer);
logger.info(`角色切换完成 ${oldRoleId} -> ${newRoleId}`);
}
// ==================== 会话管理面板 ==================== // ==================== 会话管理面板 ====================
/** 加载会话列表并渲染 */ /** 加载会话列表并渲染 */
@ -733,10 +810,12 @@ export async function loadHistoryConversations(): Promise<void> {
listEl.innerHTML = `<div class="csk-history-panel__loading">加载中...</div>`; listEl.innerHTML = `<div class="csk-history-panel__loading">加载中...</div>`;
try { try {
const result = await fetchConversationList(1, 50, config.userId, config.integrateId);
const result = await fetchConversationList(1, 50, config.userId, getActiveIntegrateId());
historyItems = result.list.map(c => ({ historyItems = result.list.map(c => ({
id: c.conversationId || '', id: c.conversationId || '',
chatId: c.conversationId || '', chatId: c.conversationId || '',
roleId: c.roleId,
roleName: c.roleName,
messageCount: c.messageCount, messageCount: c.messageCount,
lastMessageTime: c.lastMessageTime, lastMessageTime: c.lastMessageTime,
lastMessagePreview: c.lastMessagePreview, lastMessagePreview: c.lastMessagePreview,
@ -779,16 +858,36 @@ export async function loadHistoryConversations(): Promise<void> {
/** /**
* *
*
* @param conversationId ID chatId * @param conversationId ID chatId
*/ */
export async function switchToConversation(conversationId: string): Promise<void> { export async function switchToConversation(conversationId: string): Promise<void> {
if (!config || !messagesContainer) return; if (!config || !messagesContainer) return;
// 找到该会话对应的历史条目,获取其所属角色
const historyItem = historyItems.find(
it => (it.chatId || it.id) === conversationId
);
// 如果会话所属角色与当前活跃角色不同,先静默切换角色
if (historyItem && historyItem.roleId !== undefined) {
const convRoleId = String(historyItem.roleId);
const currentRoleId = getActiveIntegrateId();
if (convRoleId && convRoleId !== currentRoleId) {
logger.info(`会话角色不匹配,自动切换角色 ${currentRoleId} -> ${convRoleId}`);
setActiveRoleId(convRoleId);
// 更新角色下拉框高亮项
if (roleSelect) {
roleSelect.value = convRoleId;
}
}
}
logger.info(`切换到会话 conversationId=${conversationId}`); logger.info(`切换到会话 conversationId=${conversationId}`);
// 1. 更新 chatId // 1. 更新 chatId
updateChatId(conversationId); updateChatId(conversationId);
saveCachedChatId(config.integrateId, config.userId, conversationId);
saveCachedChatId(getActiveIntegrateId(), config.userId, conversationId);
// 2. 关闭历史面板 // 2. 关闭历史面板
if (historyPanel) { if (historyPanel) {

126
client/src/config.ts

@ -9,103 +9,8 @@
import { SDKConfig, ResolvedConfig } from './types'; import { SDKConfig, ResolvedConfig } from './types';
import { logger } from './logger'; import { logger } from './logger';
/** 默认悬浮按钮 SVG 图标(赛博机器人:对话气泡 + 机器人面孔 + 天线能量灯 + 眨眼动效) */
const DEFAULT_LAUNCHER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="csk-ico-grad" x1="6" y1="8" x2="26" y2="28" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#fff" stop-opacity="1"/>
<stop offset="100%" stop-color="#fff" stop-opacity="0.82"/>
</linearGradient>
</defs>
<rect x="5" y="8" width="22" height="16" rx="4" fill="url(#csk-ico-grad)"/>
<path d="M9 24L6 28.5l6-4.5z" fill="url(#csk-ico-grad)"/>
<line x1="16" y1="8" x2="16" y2="4" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="16" cy="3" r="1.5" fill="#fff" class="csk-ico-antenna"/>
<rect x="9.5" y="13" width="4" height="3.5" rx="1.2" fill="rgba(99,102,241,0.55)" class="csk-ico-eye"/>
<rect x="18.5" y="13" width="4" height="3.5" rx="1.2" fill="rgba(99,102,241,0.55)" class="csk-ico-eye"/>
<circle cx="11" cy="14.2" r="0.7" fill="#fff" opacity="0.9"/>
<circle cx="20" cy="14.2" r="0.7" fill="#fff" opacity="0.9"/>
<path d="M12.5 20 Q16 22.5 19.5 20" stroke="rgba(99,102,241,0.4)" stroke-width="1.2" fill="none" stroke-linecap="round"/>
</svg>`;
// ==================== 马卡龙色系玻璃质感图标 ====================
/**
* SVG
* + + +
*/
function buildGlassIcon(eyeColor: string, smileColor: string, bubbleColor: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 40 40" fill="none">
<defs>
<!-- -->
<linearGradient id="csk-glass-hi" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#fff" stop-opacity="0.72"/>
<stop offset="100%" stop-color="#fff" stop-opacity="0.18"/>
</linearGradient>
<!-- -->
<linearGradient id="csk-bot-body" x1="8" y1="10" x2="32" y2="34" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#fff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#fff" stop-opacity="0.78"/>
</linearGradient>
</defs>
<!-- + -->
<rect x="7" y="10" width="26" height="18" rx="5" fill="url(#csk-bot-body)" stroke="rgba(255,255,255,0.6)" stroke-width="0.8"/>
<path d="M12 28L8 33.5l7-5.5z" fill="url(#csk-bot-body)" stroke="rgba(255,255,255,0.5)" stroke-width="0.5"/>
<!-- -->
<ellipse cx="16" cy="14" rx="9" ry="4.5" fill="url(#csk-glass-hi)" opacity="0.55"/>
<!-- 线 + -->
<line x1="20" y1="10" x2="20" y2="5" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity="0.85"/>
<circle cx="20" cy="4" r="2" fill="#fff" opacity="0.9" class="csk-ico-antenna"/>
<!-- -->
<rect x="12" y="16" width="4.5" height="4" rx="1.5" fill="${eyeColor}" class="csk-ico-eye"/>
<rect x="23.5" y="16" width="4.5" height="4" rx="1.5" fill="${eyeColor}" class="csk-ico-eye"/>
<!-- -->
<circle cx="13.5" cy="17" r="0.9" fill="#fff" opacity="0.95"/>
<circle cx="25" cy="17" r="0.9" fill="#fff" opacity="0.95"/>
<!-- -->
<path d="M16 23 Q20 25.8 24 23" stroke="${smileColor}" stroke-width="1.4" fill="none" stroke-linecap="round"/>
<!-- 3 -->
<circle cx="31" cy="8" r="2.2" fill="${bubbleColor}" class="csk-ico-bubble csk-ico-bubble--1"/>
<circle cx="35" cy="5" r="1.6" fill="${bubbleColor}" class="csk-ico-bubble csk-ico-bubble--2" opacity="0.7"/>
<circle cx="28" cy="4.5" r="1.2" fill="${bubbleColor}" class="csk-ico-bubble csk-ico-bubble--3" opacity="0.5"/>
</svg>`;
}
/** 梦幻紫粉主题图标 */
const GLASS_ICON_DREAM_PURPLE = buildGlassIcon(
'rgba(139,92,246,0.6)', // 眼睛:紫色
'rgba(139,92,246,0.45)', // 微笑
'rgba(240,171,252,0.85)' // 气泡:粉紫
);
/** 薄荷青绿主题图标 */
const GLASS_ICON_MINT_TECH = buildGlassIcon(
'rgba(16,185,129,0.6)', // 眼睛:翠绿
'rgba(16,185,129,0.45)', // 微笑
'rgba(110,231,183,0.85)' // 气泡:薄荷
);
/** 珊瑚蜜桃主题图标 */
const GLASS_ICON_CORAL_PEACH = buildGlassIcon(
'rgba(244,63,94,0.55)', // 眼睛:珊瑚红
'rgba(244,63,94,0.4)', // 微笑
'rgba(253,186,116,0.85)' // 气泡:蜜桃
);
/** 天空蓝紫主题图标 */
const GLASS_ICON_SKY_BLUE = buildGlassIcon(
'rgba(56,189,248,0.6)', // 眼睛:天蓝
'rgba(56,189,248,0.45)', // 微笑
'rgba(167,139,250,0.85)' // 气泡:淡紫
);
/** 主题 → 默认主色映射(当用户未指定 primaryColor 时自动使用) */
const THEME_PRIMARY_COLORS: Record<string, string> = {
'dream-purple': '#A78BFA',
'mint-tech': '#34D399',
'coral-peach': '#FB7185',
'sky-blue': '#7DD3FC',
};
/** 默认悬浮按钮图标 — E5 梦幻拖尾·全粒子:极光旋转渐变底 + 20 颗粒子系统 */
const DEFAULT_LAUNCHER_ICON = `<span class="csk-pt csk-pt-burst" style="--dx:-16px;--dy:-12px"></span><span class="csk-pt csk-pt-burst" style="--dx:16px;--dy:-10px"></span><span class="csk-pt csk-pt-burst" style="--dx:-14px;--dy:14px"></span><span class="csk-pt csk-pt-burst" style="--dx:13px;--dy:13px"></span><span class="csk-pt csk-pt-burst" style="--dx:0px;--dy:-18px"></span><span class="csk-pt csk-pt-burst" style="--dx:18px;--dy:3px"></span><span class="csk-pt csk-pt-burst" style="--dx:-18px;--dy:-3px"></span><span class="csk-pt csk-pt-burst" style="--dx:0px;--dy:16px"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-pulse"></span><span class="csk-pt csk-pt-pulse"></span><span class="csk-pt csk-pt-elliptic"></span><span class="csk-pt csk-pt-elliptic"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-sheen"></span><span class="csk-dual"></span>`;
/** /**
* *
@ -135,26 +40,9 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
// integrateId 统一转为字符串(后端 roleId 为 Long,但 query param 传字符串也可接收) // integrateId 统一转为字符串(后端 roleId 为 Long,但 query param 传字符串也可接收)
const integrateIdStr = String(raw.integrateId).trim(); const integrateIdStr = String(raw.integrateId).trim();
// 解析 launcherTheme:根据主题自动选择图标和默认主色
const launcherTheme = raw.launcherTheme || undefined;
let resolvedLauncherIcon = raw.launcherIcon || DEFAULT_LAUNCHER_ICON;
let resolvedPrimaryColor = raw.primaryColor || '#4F46E5';
if (launcherTheme && !raw.launcherIcon) {
// 有主题且未自定义图标 → 使用主题专属玻璃质感图标
const themeIconsMap: Record<string, string> = {
'dream-purple': GLASS_ICON_DREAM_PURPLE,
'mint-tech': GLASS_ICON_MINT_TECH,
'coral-peach': GLASS_ICON_CORAL_PEACH,
'sky-blue': GLASS_ICON_SKY_BLUE,
};
resolvedLauncherIcon = themeIconsMap[launcherTheme] || DEFAULT_LAUNCHER_ICON;
// 若未自定义主色 → 使用主题默认色
if (!raw.primaryColor) {
resolvedPrimaryColor = THEME_PRIMARY_COLORS[launcherTheme] || '#4F46E5';
}
}
// 解析图标:用户传入优先,否则使用默认极光粒子图标
const resolvedLauncherIcon = raw.launcherIcon || DEFAULT_LAUNCHER_ICON;
const resolvedPrimaryColor = raw.primaryColor || '#4F46E5';
// 填充默认值 // 填充默认值
const config: ResolvedConfig = { const config: ResolvedConfig = {
@ -168,7 +56,6 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
height: Math.max(300, raw.height ?? 520), height: Math.max(300, raw.height ?? 520),
position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom', position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom',
primaryColor: resolvedPrimaryColor, primaryColor: resolvedPrimaryColor,
launcherTheme: launcherTheme,
launcherIcon: resolvedLauncherIcon, launcherIcon: resolvedLauncherIcon,
showClear: raw.showClear ?? true, showClear: raw.showClear ?? true,
showAdminPanel: raw.showAdminPanel ?? false, showAdminPanel: raw.showAdminPanel ?? false,
@ -178,6 +65,7 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
theme: raw.theme === 'dark' ? 'dark' : 'light', theme: raw.theme === 'dark' ? 'dark' : 'light',
showTeaser: raw.showTeaser ?? true, showTeaser: raw.showTeaser ?? true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '', teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',
resizable: raw.resizable ?? true,
streaming: raw.streaming ?? true, streaming: raw.streaming ?? true,
enableRag: raw.enableRag ?? true, enableRag: raw.enableRag ?? true,
rewriteStrategy: raw.rewriteStrategy || 'REWRITE', rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
@ -188,6 +76,8 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
onError: typeof raw.onError === 'function' ? raw.onError : undefined, onError: typeof raw.onError === 'function' ? raw.onError : undefined,
onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined, onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined,
onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined, onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined,
token: raw.token,
roles: raw.roles,
chatId: '', // 初始为空,由 chatId 初始化流程填充 chatId: '', // 初始为空,由 chatId 初始化流程填充
}; };

179
client/src/dom.ts

@ -8,7 +8,7 @@
* - * -
*/ */
import { ResolvedConfig, RagSource } from './types'; import { ResolvedConfig, RagSource } from './types';
import { debounce } from './utils';
import { debounce, formatTime } from './utils';
import { t } from './i18n'; import { t } from './i18n';
// ==================== 图标常量 ==================== // ==================== 图标常量 ====================
@ -25,9 +25,7 @@ const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" f
export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement { export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement {
const launcher = document.createElement('div'); const launcher = document.createElement('div');
launcher.id = 'csk-launcher'; launcher.id = 'csk-launcher';
// 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式
const glassClass = config.launcherTheme ? ' csk-launcher--glass' : '';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`;
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
launcher.setAttribute('title', config.title); launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title); launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button'); launcher.setAttribute('role', 'button');
@ -66,10 +64,12 @@ export function createChatWindow(config: ResolvedConfig): {
sendBtn: HTMLElement; sendBtn: HTMLElement;
clearBtn: HTMLElement | null; clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null; categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
historyPanel: HTMLElement; historyPanel: HTMLElement;
welcomeEl: HTMLElement; welcomeEl: HTMLElement;
newMsgBtn: HTMLElement; newMsgBtn: HTMLElement;
teaserEl: HTMLElement; teaserEl: HTMLElement;
resizeHandle: HTMLElement;
searchInput: HTMLInputElement | null; searchInput: HTMLInputElement | null;
ariaLiveEl: HTMLElement; ariaLiveEl: HTMLElement;
showLoading: () => HTMLElement; showLoading: () => HTMLElement;
@ -110,6 +110,42 @@ export function createChatWindow(config: ResolvedConfig): {
headerLeft.appendChild(headerAvatar); headerLeft.appendChild(headerAvatar);
headerLeft.appendChild(headerInfo); headerLeft.appendChild(headerInfo);
// === 角色选择器(仅当有多个角色时显示) ===
let roleSelect: HTMLSelectElement | null = null;
const roles = config.roles;
if (roles && roles.length > 1) {
roleSelect = document.createElement('select');
roleSelect.className = 'csk-role-select';
roleSelect.setAttribute('aria-label', t('role_selector_label'));
for (const role of roles) {
const option = document.createElement('option');
option.value = String(role.id);
option.textContent = role.name || String(role.id);
if (String(role.id) === String(config.integrateId)) {
option.selected = true;
}
roleSelect.appendChild(option);
}
// onChange 触发自定义事件
roleSelect.addEventListener('change', () => {
const selectedValue = roleSelect!.value;
windowEl.dispatchEvent(new CustomEvent('csk:roleChange', {
detail: { roleId: selectedValue }
}));
});
// 包装后插入到 headerLeft 之后、actions 之前
const roleWrap = document.createElement('div');
roleWrap.className = 'csk-role-select-wrap';
roleWrap.appendChild(roleSelect);
header.appendChild(headerLeft);
header.appendChild(roleWrap);
} else {
header.appendChild(headerLeft);
}
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'csk-header__actions'; actions.className = 'csk-header__actions';
@ -140,7 +176,6 @@ export function createChatWindow(config: ResolvedConfig): {
actions.appendChild(historyBtn); actions.appendChild(historyBtn);
actions.appendChild(minimizeBtn); actions.appendChild(minimizeBtn);
actions.appendChild(closeBtn); actions.appendChild(closeBtn);
header.appendChild(headerLeft);
header.appendChild(actions); header.appendChild(actions);
// === 消息区 === // === 消息区 ===
@ -345,6 +380,14 @@ export function createChatWindow(config: ResolvedConfig): {
<span>${config.teaserText || t('teaser_text')}</span> <span>${config.teaserText || t('teaser_text')}</span>
`; `;
// === 缩放拖拽手柄(右下角) ===
const resizeHandle = document.createElement('div');
resizeHandle.className = 'csk-resize-handle';
resizeHandle.setAttribute('aria-label', t('resize'));
// 纯 CSS 渲染三条斜线,无需 SVG
resizeHandle.innerHTML = '<span class="csk-resize-grip"></span>';
windowEl.appendChild(resizeHandle);
return { return {
window: windowEl, window: windowEl,
messagesContainer, messagesContainer,
@ -356,8 +399,10 @@ export function createChatWindow(config: ResolvedConfig): {
welcomeEl, welcomeEl,
newMsgBtn, newMsgBtn,
teaserEl, teaserEl,
resizeHandle,
searchInput, searchInput,
ariaLiveEl, ariaLiveEl,
roleSelect,
showLoading, showLoading,
hideLoading, hideLoading,
}; };
@ -586,6 +631,115 @@ export function enableLauncherDrag(
}; };
} }
// ==================== 窗口缩放支持 ====================
/** 窗口最小尺寸 */
const MIN_WIDTH = 300;
const MIN_HEIGHT = 300;
/**
*
* @param handleEl
* @param windowEl
* @param onResizeEnd
* @returns
*/
export function enableResize(
handleEl: HTMLElement,
windowEl: HTMLElement,
onResizeEnd?: (size: { width: number; height: number }) => void,
): () => void {
let resizing = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
const onPointerDown = (clientX: number, clientY: number): void => {
resizing = true;
startX = clientX;
startY = clientY;
const rect = windowEl.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
windowEl.classList.add('csk-window--resizing');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
};
const onPointerMove = (clientX: number, clientY: number, e?: Event): void => {
if (!resizing) return;
if (e) e.preventDefault();
const dx = clientX - startX;
const dy = clientY - startY;
const newWidth = Math.max(MIN_WIDTH, Math.min(startWidth + dx, window.innerWidth - 24));
const newHeight = Math.max(MIN_HEIGHT, Math.min(startHeight + dy, window.innerHeight - 24));
windowEl.style.width = `${newWidth}px`;
windowEl.style.height = `${newHeight}px`;
// 同步更新 CSS 变量(供内部布局使用)
windowEl.style.setProperty('--csk-window-width', `${newWidth}px`);
windowEl.style.setProperty('--csk-window-height', `${newHeight}px`);
// 拖拽缩放后清除 bottom/right 定位,避免与固定定位冲突
windowEl.style.bottom = '';
windowEl.style.right = '';
};
const onPointerUp = (): void => {
if (!resizing) return;
resizing = false;
windowEl.classList.remove('csk-window--resizing');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
const rect = windowEl.getBoundingClientRect();
if (onResizeEnd) {
onResizeEnd({ width: rect.width, height: rect.height });
}
};
// 鼠标事件(DOM 事件监听器包装)
function onMouseMove(e: MouseEvent): void { onPointerMove(e.clientX, e.clientY, e); }
function onMouseUp(): void { onPointerUp(); }
// 鼠标事件
function onMouseDown(e: MouseEvent): void {
e.preventDefault();
e.stopPropagation();
onPointerDown(e.clientX, e.clientY);
}
// 触摸事件
function onTouchStart(e: TouchEvent): void {
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 1) onPointerDown(e.touches[0].clientX, e.touches[0].clientY);
}
function onTouchMove(e: TouchEvent): void {
if (e.touches.length === 1) onPointerMove(e.touches[0].clientX, e.touches[0].clientY, e);
}
function onTouchEnd(): void { onPointerUp(); }
handleEl.addEventListener('mousedown', onMouseDown);
handleEl.addEventListener('touchstart', onTouchStart, { passive: false });
return () => {
handleEl.removeEventListener('mousedown', onMouseDown);
handleEl.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
};
}
// ==================== 消息渲染 ==================== // ==================== 消息渲染 ====================
/** 渲染用户消息气泡 */ /** 渲染用户消息气泡 */
@ -815,7 +969,7 @@ async function copyToClipboard(text: string, btn: HTMLElement): Promise<void> {
} }
/** 为气泡内所有代码块添加复制按钮 */ /** 为气泡内所有代码块添加复制按钮 */
export function enhanceCodeBlocks(bubble: HTMLElement): void {
function enhanceCodeBlocks(bubble: HTMLElement): void {
const blocks = bubble.querySelectorAll('.csk-md-code-block'); const blocks = bubble.querySelectorAll('.csk-md-code-block');
blocks.forEach((pre) => { blocks.forEach((pre) => {
if (pre.querySelector('.csk-md-code-copy')) return; if (pre.querySelector('.csk-md-code-copy')) return;
@ -934,6 +1088,10 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
export interface HistoryItemData { export interface HistoryItemData {
id: string; id: string;
chatId?: string; chatId?: string;
/** 会话所属角色 ID */
roleId?: number;
/** 会话所属角色名称 */
roleName?: string;
messageCount?: number; messageCount?: number;
lastMessageTime?: string; lastMessageTime?: string;
lastMessagePreview?: string; lastMessagePreview?: string;
@ -1041,6 +1199,7 @@ export function renderHistoryList(
const metaEl = document.createElement('div'); const metaEl = document.createElement('div');
metaEl.className = 'csk-history-item__meta'; metaEl.className = 'csk-history-item__meta';
const metaParts: string[] = []; const metaParts: string[] = [];
if (item.roleName) metaParts.push(item.roleName);
if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`); if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`);
if (item.lastMessageTime) metaParts.push(item.lastMessageTime); if (item.lastMessageTime) metaParts.push(item.lastMessageTime);
else if (item.createdAt) metaParts.push(item.createdAt); else if (item.createdAt) metaParts.push(item.createdAt);
@ -1092,11 +1251,3 @@ export function renderHistoryList(
export function scrollToBottom(container: HTMLElement): void { export function scrollToBottom(container: HTMLElement): void {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
/** 格式化时间戳 */
function formatTime(timestamp: number): string {
const d = new Date(timestamp);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}

6
client/src/i18n.ts

@ -10,6 +10,7 @@ const dictionaries: Record<string, Record<string, string>> = {
minimize: '最小化', minimize: '最小化',
close: '关闭', close: '关闭',
status_online: '在线', status_online: '在线',
role_selector_label: '服务角色',
// 欢迎空状态 // 欢迎空状态
welcome_title: '你好,我是 AI 智能助手', welcome_title: '你好,我是 AI 智能助手',
@ -64,6 +65,7 @@ const dictionaries: Record<string, Record<string, string>> = {
// 提示气泡 // 提示气泡
teaser_text: '有什么可以帮你的吗?', teaser_text: '有什么可以帮你的吗?',
new_msg_announce: '收到新消息', new_msg_announce: '收到新消息',
resize: '拖拽缩放窗口',
// 错误提示 // 错误提示
error_network: '网络连接失败,请检查网络', error_network: '网络连接失败,请检查网络',
@ -86,6 +88,7 @@ const dictionaries: Record<string, Record<string, string>> = {
minimize: 'Minimize', minimize: 'Minimize',
close: 'Close', close: 'Close',
status_online: 'Online', status_online: 'Online',
role_selector_label: 'Service',
// Welcome // Welcome
welcome_title: 'Hi, I am your AI assistant', welcome_title: 'Hi, I am your AI assistant',
@ -140,6 +143,7 @@ const dictionaries: Record<string, Record<string, string>> = {
// Teaser // Teaser
teaser_text: 'How can I help you?', teaser_text: 'How can I help you?',
new_msg_announce: 'New message received', new_msg_announce: 'New message received',
resize: 'Resize window',
// Errors // Errors
error_network: 'Network connection failed', error_network: 'Network connection failed',
@ -199,6 +203,6 @@ export function t(key: string, params?: Record<string, string | number>): string
/** /**
* *
*/ */
export function getLocale(): string {
function getLocale(): string {
return currentLocale; return currentLocale;
} }

55
client/src/index.ts

@ -10,10 +10,10 @@
import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types'; import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types';
import { parseConfig } from './config'; import { parseConfig } from './config';
import { setDebug, logger, setErrorCallback } from './logger'; import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig } from './api';
import { setApiConfig, clearApiConfig } from './api';
import { injectStyles, removeStyles } from './styles'; import { injectStyles, removeStyles } from './styles';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag } from './dom';
import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback } from './chat';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom';
import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat';
import { clearMessages } from './storage'; import { clearMessages } from './storage';
import { setLocale } from './i18n'; import { setLocale } from './i18n';
@ -46,6 +46,7 @@ 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 launcherDragCleanup: (() => void) | null = null; let launcherDragCleanup: (() => void) | null = null;
let resizeCleanup: (() => void) | null = null;
/** Launcher 位置记忆状态(拖拽后更新) */ /** Launcher 位置记忆状态(拖拽后更新) */
let launcherPos: { side: 'left' | 'right'; bottom: number } | null = null; let launcherPos: { side: 'left' | 'right'; bottom: number } | null = null;
@ -146,6 +147,16 @@ function init(rawConfig: SDKConfig): void {
// 9.1 恢复上次拖拽位置 // 9.1 恢复上次拖拽位置
restoreWindowPosition(config.integrateId, windowEl); restoreWindowPosition(config.integrateId, windowEl);
// 9.2 启用缩放(resizable 配置控制)
if (config.resizable) {
const resizeHandle = dom.resizeHandle;
resizeCleanup = enableResize(resizeHandle, windowEl, (size) => {
saveWindowSize(config!.integrateId, size);
});
// 恢复上次缩放的尺寸
restoreWindowSize(config.integrateId, windowEl);
}
// 10. 初始化对话模块 // 10. 初始化对话模块
initChat(config, { initChat(config, {
messagesContainer, messagesContainer,
@ -158,6 +169,7 @@ function init(rawConfig: SDKConfig): void {
newMsgBtn: dom.newMsgBtn, newMsgBtn: dom.newMsgBtn,
searchInput: dom.searchInput, searchInput: dom.searchInput,
ariaLiveEl: dom.ariaLiveEl, ariaLiveEl: dom.ariaLiveEl,
roleSelect: dom.roleSelect,
showLoading: showLoadingFn, showLoading: showLoadingFn,
hideLoading: hideLoadingFn, hideLoading: hideLoadingFn,
}); });
@ -196,6 +208,11 @@ function init(rawConfig: SDKConfig): void {
} }
}) as EventListener); }) as EventListener);
// 16.5 监听角色切换事件
windowEl.addEventListener('csk:roleChange', ((e: CustomEvent) => {
if (e.detail.roleId) switchRole(String(e.detail.roleId));
}) as EventListener);
// 17. ESC 键关闭弹窗 // 17. ESC 键关闭弹窗
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
@ -358,6 +375,7 @@ function destroy(): void {
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; }
if (launcherDragCleanup) { launcherDragCleanup(); launcherDragCleanup = null; } if (launcherDragCleanup) { launcherDragCleanup(); launcherDragCleanup = null; }
if (resizeCleanup) { resizeCleanup(); resizeCleanup = null; }
launcherPos = null; launcherPos = null;
removeStyles(); removeStyles();
@ -378,6 +396,7 @@ function destroy(): void {
hideLoadingFn = null; hideLoadingFn = null;
badgeEl = null; badgeEl = null;
audioCtx = null; audioCtx = null;
clearApiConfig();
logger.lifecycleDestroy(oldIntegrateId || ''); logger.lifecycleDestroy(oldIntegrateId || '');
} }
@ -410,6 +429,36 @@ function clearHistory(): void {
else if (confirm('确定清空所有对话记录?')) { clearMessages(config.integrateId); } else if (confirm('确定清空所有对话记录?')) { clearMessages(config.integrateId); }
} }
// ==================== 窗口尺寸记忆 ====================
/** localStorage key:窗口尺寸 */
function sizeKey(integrateId: string): string {
return `csk_size_${integrateId}`;
}
/** 保存窗口缩放的尺寸到 localStorage */
function saveWindowSize(integrateId: string, size: { width: number; height: number }): void {
try {
localStorage.setItem(sizeKey(integrateId), JSON.stringify(size));
} catch { /* 忽略 */ }
}
/** 恢复窗口缩放尺寸 */
function restoreWindowSize(integrateId: string, winEl: HTMLElement): void {
try {
const raw = localStorage.getItem(sizeKey(integrateId));
if (!raw) return;
const size = JSON.parse(raw) as { width: number; height: number };
if (typeof size.width !== 'number' || typeof size.height !== 'number') return;
const w = Math.max(300, Math.min(size.width, window.innerWidth - 24));
const h = Math.max(300, Math.min(size.height, window.innerHeight - 24));
winEl.style.width = `${w}px`;
winEl.style.height = `${h}px`;
winEl.style.setProperty('--csk-window-width', `${w}px`);
winEl.style.setProperty('--csk-window-height', `${h}px`);
} catch { /* 忽略 */ }
}
// ==================== 窗口位置记忆 ==================== // ==================== 窗口位置记忆 ====================
/** localStorage key:窗口位置 */ /** localStorage key:窗口位置 */

453
client/src/styles.ts

@ -21,16 +21,6 @@ function cssVars(config: ResolvedConfig): string {
const lighter = adjustColor(config.primaryColor, 18); const lighter = adjustColor(config.primaryColor, 18);
const rgb = hexToRgb(config.primaryColor); const rgb = hexToRgb(config.primaryColor);
// 马卡龙主题渐变色映射
const themeGradients: Record<string, { grad1: string; grad2: string; glow: string }> = {
'dream-purple': { grad1: '#A78BFA', grad2: '#F0ABFC', glow: '167, 139, 250' },
'mint-tech': { grad1: '#6EE7B7', grad2: '#34D399', glow: '52, 211, 153' },
'coral-peach': { grad1: '#FB7185', grad2: '#FDBA74', glow: '251, 113, 133' },
'sky-blue': { grad1: '#7DD3FC', grad2: '#A78BFA', glow: '125, 211, 252' },
};
const themeColors = config.launcherTheme ? themeGradients[config.launcherTheme] : null;
return ` return `
--csk-primary: ${config.primaryColor}; --csk-primary: ${config.primaryColor};
--csk-primary-hover: ${darker}; --csk-primary-hover: ${darker};
@ -47,11 +37,6 @@ function cssVars(config: ResolvedConfig): string {
--csk-shadow-bubble: 0 1px 2px rgba(15, 23, 42, 0.06); --csk-shadow-bubble: 0 1px 2px rgba(15, 23, 42, 0.06);
--csk-border: #ECEEF2; --csk-border: #ECEEF2;
--csk-bg-app: #F6F7F9; --csk-bg-app: #F6F7F9;
${themeColors ? `
--csk-launcher-grad-1: ${themeColors.grad1};
--csk-launcher-grad-2: ${themeColors.grad2};
--csk-launcher-glow: ${themeColors.glow};
` : ''}
`; `;
} }
@ -92,7 +77,8 @@ function getStyles(config: ResolvedConfig): string {
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
background: var(--csk-bg-user);
/* E5 梦幻拖尾 — 四段极光渐变慢旋 */
background: conic-gradient(from 0deg, #ec4899 0deg, #8b5cf6 90deg, #3b82f6 180deg, #06b6d4 270deg, #ec4899 360deg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -106,61 +92,27 @@ function getStyles(config: ResolvedConfig): string {
box-shadow: box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.06),
0 4px 16px rgba(0, 0, 0, 0.10), 0 4px 16px rgba(0, 0, 0, 0.10),
0 6px 24px rgba(var(--csk-primary-rgb), 0.28),
0 0 48px rgba(var(--csk-primary-rgb), 0.12);
0 6px 24px rgba(236, 72, 153, 0.28),
0 0 48px rgba(236, 72, 153, 0.12);
transition: transition:
transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease, box-shadow 0.25s ease,
border-color 0.25s ease; border-color 0.25s ease;
animation: csk-launcher-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
animation:
csk-aurora-spin 5s linear infinite,
csk-launcher-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
} }
.csk-launcher--right { right: 24px; } .csk-launcher--right { right: 24px; }
.csk-launcher--left { left: 24px; } .csk-launcher--left { left: 24px; }
/* 高光扫过动效 */
.csk-launcher::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 40%;
height: 200%;
border-radius: 50%;
background: linear-gradient(
105deg,
transparent 30%,
rgba(255, 255, 255, 0.28) 48%,
rgba(255, 255, 255, 0.1) 52%,
transparent 70%
);
transform: rotate(25deg);
animation: csk-shimmer 4s ease-in-out infinite;
pointer-events: none;
}
/* 呼吸色晕 — 配合接地阴影,强化白底上的立体存在感 */
.csk-launcher::after {
content: '';
position: absolute;
inset: -18px;
border-radius: 50%;
background: radial-gradient(circle, rgba(var(--csk-primary-rgb), 0.18) 0%, rgba(var(--csk-primary-rgb), 0.05) 45%, transparent 68%);
animation: csk-breathe 3.5s ease-in-out infinite;
pointer-events: none;
}
.csk-launcher:hover { .csk-launcher:hover {
transform: translateY(-3px) scale(1.08); transform: translateY(-3px) scale(1.08);
border-color: rgba(255, 255, 255, 0.75); border-color: rgba(255, 255, 255, 0.75);
box-shadow: box-shadow:
0 2px 6px rgba(0, 0, 0, 0.10), 0 2px 6px rgba(0, 0, 0, 0.10),
0 8px 24px rgba(0, 0, 0, 0.14), 0 8px 24px rgba(0, 0, 0, 0.14),
0 10px 36px rgba(var(--csk-primary-rgb), 0.32),
0 0 56px rgba(var(--csk-primary-rgb), 0.14);
}
.csk-launcher:hover::before { animation-play-state: paused; }
.csk-launcher:hover::after {
animation: none;
inset: -22px;
background: radial-gradient(circle, rgba(var(--csk-primary-rgb), 0.22) 0%, rgba(var(--csk-primary-rgb), 0.06) 45%, transparent 68%);
0 10px 36px rgba(236, 72, 153, 0.32),
0 0 56px rgba(236, 72, 153, 0.14);
} }
.csk-launcher:active { transform: scale(0.93); } .csk-launcher:active { transform: scale(0.93); }
.csk-launcher svg { .csk-launcher svg {
@ -176,107 +128,149 @@ function getStyles(config: ResolvedConfig): string {
60% { transform: scale(1.06) translateY(-2px); } 60% { transform: scale(1.06) translateY(-2px); }
100% { opacity: 1; transform: scale(1) translateY(0); } 100% { opacity: 1; transform: scale(1) translateY(0); }
} }
@keyframes csk-shimmer {
0%, 100% { left: -60%; opacity: 0; }
20% { opacity: 1; }
60% { opacity: 1; }
80% { left: 120%; opacity: 0; }
/* ========== E5 梦幻拖尾·全粒子动画系统 ========== */
/* 极光旋转 */
@keyframes csk-aurora-spin {
to { transform: rotate(360deg); }
} }
@keyframes csk-breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.35; }
/* 粒子通用 */
.csk-pt {
position: absolute;
border-radius: 50%;
background: #fff;
z-index: 4;
top: 50%;
left: 50%;
pointer-events: none;
}
/* ── 爆散粒子(8颗,不同方向/距离/大小/延迟)── */
.csk-pt-burst { animation: csk-burst 2.8s ease-in-out infinite; }
.csk-pt-burst:nth-child(1) { width:5px; height:5px; animation-delay:0s; }
.csk-pt-burst:nth-child(2) { width:4px; height:4px; animation-delay:0.35s; }
.csk-pt-burst:nth-child(3) { width:5px; height:5px; animation-delay:0.7s; }
.csk-pt-burst:nth-child(4) { width:3px; height:3px; animation-delay:1.05s; }
.csk-pt-burst:nth-child(5) { width:5px; height:5px; animation-delay:1.4s; }
.csk-pt-burst:nth-child(6) { width:4px; height:4px; animation-delay:1.75s; }
.csk-pt-burst:nth-child(7) { width:3px; height:3px; animation-delay:2.1s; }
.csk-pt-burst:nth-child(8) { width:5px; height:5px; animation-delay:2.45s; }
@keyframes csk-burst {
0% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
12% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
32% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1.5); opacity: 1; }
48% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(0.5); opacity: .55; }
62% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
100% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
} }
/* SVG 图标内部动效:眨眼 */
.csk-launcher .csk-ico-eye {
animation: csk-eye-blink 4s ease-in-out infinite;
/* ── 浮游粒子(持续轨道运动,永不停歇)── */
.csk-pt-float {
animation: csk-float-orbit 3s linear infinite;
box-shadow: 0 0 4px 1px rgba(255,255,255,.5);
}
.csk-pt-float:nth-child(9) { width:3px; height:3px; animation-duration:2.4s; animation-delay:0s; }
.csk-pt-float:nth-child(10) { width:4px; height:4px; animation-duration:3.2s; animation-delay:-0.6s; }
.csk-pt-float:nth-child(11) { width:3px; height:3px; animation-duration:2.8s; animation-delay:-1.2s; }
.csk-pt-float:nth-child(12) { width:4px; height:4px; animation-duration:3.6s; animation-delay:-1.8s; }
@keyframes csk-float-orbit {
0% { transform: translate(-50%,-50%) rotate(0deg) translateX(20px) rotate(0deg); }
100% { transform: translate(-50%,-50%) rotate(360deg) translateX(20px) rotate(-360deg); }
} }
@keyframes csk-eye-blink {
0%, 42%, 48%, 100% { opacity: 1; }
45% { opacity: 0.1; }
/* ── 心跳粒子(中心脉冲 + 呼吸缩放)── */
.csk-pt-pulse {
animation: csk-heart-pulse 1.4s ease-in-out infinite;
} }
/* SVG 图标内部动效:天线能量闪烁 */
.csk-launcher .csk-ico-antenna {
animation: csk-antenna-glow 2s ease-in-out infinite;
.csk-pt-pulse:nth-child(13) {
width: 8px; height: 8px;
box-shadow: 0 0 14px 4px rgba(255,255,255,.8), 0 0 28px 8px rgba(255,255,255,.3);
animation-delay: 0s;
} }
@keyframes csk-antenna-glow {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
.csk-pt-pulse:nth-child(14) {
width: 4px; height: 4px;
box-shadow: 0 0 8px 2px rgba(255,255,255,.6);
animation-delay: 0.7s;
} }
/* ========== 磨砂玻璃质感悬浮按钮 ========== */
.csk-launcher--glass {
background: linear-gradient(
135deg,
var(--csk-launcher-grad-1, var(--csk-primary)) 0%,
var(--csk-launcher-grad-2, var(--csk-primary-light)) 100%
) !important;
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border: 1.5px solid rgba(255, 255, 255, 0.55) !important;
/* 多层阴影:接地 + 彩色光晕 + 内阴影立体感 */
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.04),
0 6px 20px rgba(0, 0, 0, 0.08),
0 8px 32px rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.30),
inset 0 1px 1px rgba(255, 255, 255, 0.35),
inset 0 -1px 1px rgba(0, 0, 0, 0.04) !important;
}
.csk-launcher--glass:hover {
border-color: rgba(255, 255, 255, 0.75) !important;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.06),
0 10px 28px rgba(0, 0, 0, 0.10),
0 12px 40px rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.38),
inset 0 1px 2px rgba(255, 255, 255, 0.45),
inset 0 -1px 1px rgba(0, 0, 0, 0.04) !important;
@keyframes csk-heart-pulse {
0%, 100% { transform: translate(-50%,-50%) scale(0.4); opacity: .3; }
25% { transform: translate(-50%,-50%) scale(1.6); opacity: 1; }
50% { transform: translate(-50%,-50%) scale(0.4); opacity: .3; }
75% { transform: translate(-50%,-50%) scale(0.9); opacity: .6; }
}
/* ── 甩尾粒子(椭圆轨道 + 变速,近大远小)── */
.csk-pt-elliptic {
animation: csk-elliptic-orbit 2.6s ease-in-out infinite;
box-shadow: 0 0 5px 1px rgba(255,255,255,.6);
} }
.csk-pt-elliptic:nth-child(15) { width:4px; height:4px; animation-delay:0s; }
.csk-pt-elliptic:nth-child(16) { width:4px; height:4px; animation-delay:-1.3s; }
@keyframes csk-elliptic-orbit {
0% { transform: translate(-50%,-50%) rotate(0deg) translateX(18px) rotate(0deg) scaleY(1); }
25% { transform: translate(-50%,-50%) rotate(90deg) translateX(18px) rotate(-90deg) scaleY(0.45); }
50% { transform: translate(-50%,-50%) rotate(180deg) translateX(18px) rotate(-180deg) scaleY(1); }
75% { transform: translate(-50%,-50%) rotate(270deg) translateX(18px) rotate(-270deg) scaleY(0.45); }
100% { transform: translate(-50%,-50%) rotate(360deg) translateX(18px) rotate(-360deg) scaleY(1); }
}
/* ── 环绕流光(外层慢速光环拖尾)── */
.csk-pt-ring {
width: 3px; height: 3px;
background: rgba(255,255,255,.9);
box-shadow: 0 0 6px 2px rgba(255,255,255,.7);
animation: csk-ring-orbit 5s linear infinite;
}
.csk-pt-ring:nth-child(17) { animation-delay: 0s; }
.csk-pt-ring:nth-child(18) { animation-delay: -1.25s; }
.csk-pt-ring:nth-child(19) { animation-delay: -2.5s; }
.csk-pt-ring:nth-child(20) { animation-delay: -3.75s; }
/* 玻璃按钮的呼吸色晕 — 更鲜艳、更醒目 */
.csk-launcher--glass::after {
background: radial-gradient(
circle,
rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.28) 0%,
rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.10) 40%,
transparent 65%
) !important;
animation: csk-breathe-glow 3.5s ease-in-out infinite;
}
@keyframes csk-breathe-glow {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.22); opacity: 0.25; }
@keyframes csk-ring-orbit {
0% { transform: translate(-50%,-50%) rotate(0deg) translateX(23px) rotate(0deg) scale(1); opacity: .8; }
50% { transform: translate(-50%,-50%) rotate(180deg) translateX(23px) rotate(-180deg) scale(1.8); opacity: 1; }
100% { transform: translate(-50%,-50%) rotate(360deg) translateX(23px) rotate(-360deg) scale(1); opacity: .8; }
} }
/* 玻璃按钮的高光扫过 — 更柔和的玻璃质感 */
.csk-launcher--glass::before {
background: linear-gradient(
115deg,
transparent 25%,
rgba(255, 255, 255, 0.35) 45%,
rgba(255, 255, 255, 0.15) 55%,
transparent 75%
) !important;
width: 50%;
animation: csk-shimmer-glass 5s ease-in-out infinite;
}
@keyframes csk-shimmer-glass {
0%, 100% { left: -70%; opacity: 0; }
15% { opacity: 1; }
55% { opacity: 1; }
70% { left: 130%; opacity: 0; }
/* ── 极光扫光叠加层 ── */
.csk-sheen {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 3;
overflow: hidden;
}
.csk-sheen::after {
content: '';
position: absolute;
top: -60%; left: -60%;
width: 35%; height: 220%;
background: rgba(255,255,255,.15);
transform: rotate(22deg);
animation: csk-shimmer-sweep 2.8s ease-in-out infinite;
}
@keyframes csk-shimmer-sweep {
0% { left: -60%; }
40% { left: 50%; }
60% { left: 50%; }
100% { left: 130%; }
} }
/* SVG 内部动效:跳动气泡(3 个小圆点交错弹跳) */
.csk-launcher .csk-ico-bubble {
animation: csk-bubble-jump 1.4s ease-in-out infinite;
}
.csk-launcher .csk-ico-bubble--1 { animation-delay: 0s; }
.csk-launcher .csk-ico-bubble--2 { animation-delay: 0.18s; }
.csk-launcher .csk-ico-bubble--3 { animation-delay: 0.36s; }
@keyframes csk-bubble-jump {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-3.5px); }
60% { transform: translateY(-1px); }
/* ── 双层极光圈(反向旋转,增加层次)── */
.csk-dual {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 2;
background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,.08) 35%, transparent 65%);
animation: csk-aurora-spin 2s linear infinite reverse;
} }
/* ========== 聊天弹窗 ========== */ /* ========== 聊天弹窗 ========== */
@ -411,6 +405,43 @@ function getStyles(config: ResolvedConfig): string {
.csk-header__btn:active, .csk-header__btn:active,
.csk-history-btn:active { transform: scale(0.92); } .csk-history-btn:active { transform: scale(0.92); }
/* ========== 角色选择器 ========== */
.csk-role-select-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
margin: 0 4px;
}
.csk-role-select {
appearance: none;
-webkit-appearance: none;
padding: 4px 24px 4px 10px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.15) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='white' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 6px center;
color: #fff;
font-size: 12px;
font-family: inherit;
cursor: pointer;
outline: none;
transition: background 0.15s, border-color 0.15s;
max-width: 120px;
text-overflow: ellipsis;
white-space: nowrap;
}
.csk-role-select:hover {
background-color: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.55);
}
.csk-role-select:focus {
border-color: rgba(255, 255, 255, 0.7);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
.csk-role-select option {
color: #1F2937;
background: #fff;
}
/* ========== 消息区 ========== */ /* ========== 消息区 ========== */
.csk-messages { .csk-messages {
flex: 1; flex: 1;
@ -798,6 +829,7 @@ function getStyles(config: ResolvedConfig): string {
.csk-msg--ai .csk-msg__bubble .csk-md-h4 { font-size: 14px; } .csk-msg--ai .csk-msg__bubble .csk-md-h4 { font-size: 14px; }
.csk-md-code-block { .csk-md-code-block {
position: relative;
background: #1E293B; background: #1E293B;
color: #E2E8F0; color: #E2E8F0;
padding: 12px 14px; padding: 12px 14px;
@ -984,19 +1016,6 @@ function getStyles(config: ResolvedConfig): string {
color: #9CA3AF; color: #9CA3AF;
font-size: 13px; font-size: 13px;
} }
.csk-history-panel__loadmore {
display: block;
width: 100%;
padding: 10px;
border: none;
background: #F9FAFB;
color: #6B7280;
font-size: 12px;
cursor: pointer;
text-align: center;
transition: background 0.15s;
}
.csk-history-panel__loadmore:hover { background: #F3F4F6; }
/* ========== 快捷问题芯片 ========== */ /* ========== 快捷问题芯片 ========== */
.csk-quick-replies { .csk-quick-replies {
@ -1076,7 +1095,6 @@ function getStyles(config: ResolvedConfig): string {
} }
/* ========== 代码块复制按钮 ========== */ /* ========== 代码块复制按钮 ========== */
.csk-md-code-block { position: relative; }
.csk-md-code-copy { .csk-md-code-copy {
position: absolute; position: absolute;
top: 8px; top: 8px;
@ -1134,6 +1152,94 @@ function getStyles(config: ResolvedConfig): string {
box-shadow: 0 5px 14px rgba(239, 68, 68, 0.45) !important; box-shadow: 0 5px 14px rgba(239, 68, 68, 0.45) !important;
} }
/* ========== 窗口缩放拖拽手柄 ========== */
.csk-resize-handle {
position: absolute;
right: 4px;
bottom: 4px;
width: 20px;
height: 20px;
display: flex;
align-items: flex-end;
justify-content: flex-end;
cursor: nwse-resize;
z-index: 5;
user-select: none;
-webkit-user-select: none;
/* 圆形半透明背景,与整体圆润风格一致 */
border-radius: 50%;
background: transparent;
transition: background 0.2s ease;
}
.csk-resize-handle:hover {
background: rgba(var(--csk-primary-rgb), 0.08);
}
/* 三斜线抓手 — 纯 CSS 绘制,用两条间距微调的线,视觉上是三条 */
.csk-resize-grip {
display: block;
width: 10px;
height: 10px;
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
#C8CCD2 38%, #C8CCD2 42%,
transparent 42%, transparent 54%,
#C8CCD2 54%, #C8CCD2 58%,
transparent 58%, transparent 70%,
#C8CCD2 70%, #C8CCD2 74%,
transparent 74%
);
transition: background 0.2s ease;
border-radius: 2px;
}
/* hover 时线条变为主色 */
.csk-resize-handle:hover .csk-resize-grip {
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
var(--csk-primary) 38%, var(--csk-primary) 42%,
transparent 42%, transparent 54%,
var(--csk-primary) 54%, var(--csk-primary) 58%,
transparent 58%, transparent 70%,
var(--csk-primary) 70%, var(--csk-primary) 74%,
transparent 74%
);
}
/* 暗色模式 */
.csk-dark .csk-resize-grip {
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
#5B6070 38%, #5B6070 42%,
transparent 42%, transparent 54%,
#5B6070 54%, #5B6070 58%,
transparent 58%, transparent 70%,
#5B6070 70%, #5B6070 74%,
transparent 74%
);
}
.csk-dark .csk-resize-handle:hover .csk-resize-grip {
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
var(--csk-primary-light) 38%, var(--csk-primary-light) 42%,
transparent 42%, transparent 54%,
var(--csk-primary-light) 54%, var(--csk-primary-light) 58%,
transparent 58%, transparent 70%,
var(--csk-primary-light) 70%, var(--csk-primary-light) 74%,
transparent 74%
);
}
/* 缩放拖拽中:禁用过渡动画 + 全局光标 */
.csk-window--resizing {
transition: none !important;
user-select: none;
}
.csk-window--resizing * { cursor: nwse-resize !important; }
/* ========== 暗色模式 ========== */ /* ========== 暗色模式 ========== */
.csk-root.csk-dark { .csk-root.csk-dark {
--csk-bg-ai: #1E1E2E; --csk-bg-ai: #1E1E2E;
@ -1200,6 +1306,10 @@ function getStyles(config: ResolvedConfig): string {
.csk-dark .csk-launcher__badge { box-shadow: 0 0 0 2px #1A1A2E; } .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-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); } .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); }
.csk-dark .csk-role-select option {
background: #1A1A2E;
color: #E2E8F0;
}
/* Launcher 拖拽态:微放大 + 阴影加深 */ /* Launcher 拖拽态:微放大 + 阴影加深 */
.csk-launcher--dragging { .csk-launcher--dragging {
@ -1208,11 +1318,9 @@ function getStyles(config: ResolvedConfig): string {
box-shadow: box-shadow:
0 4px 12px rgba(0, 0, 0, 0.14), 0 4px 12px rgba(0, 0, 0, 0.14),
0 12px 32px rgba(0, 0, 0, 0.18), 0 12px 32px rgba(0, 0, 0, 0.18),
0 14px 40px rgba(var(--csk-primary-rgb), 0.36),
0 0 56px rgba(var(--csk-primary-rgb), 0.18) !important;
0 14px 40px rgba(236, 72, 153, 0.36),
0 0 56px rgba(236, 72, 153, 0.18) !important;
} }
.csk-launcher--dragging::before,
.csk-launcher--dragging::after { animation-play-state: paused !important; }
/* 拖拽中隐藏未读徽章 */ /* 拖拽中隐藏未读徽章 */
.csk-launcher--dragging .csk-launcher__badge { display: none !important; } .csk-launcher--dragging .csk-launcher__badge { display: none !important; }
/* 吸附过渡动画(释放时的缓动) */ /* 吸附过渡动画(释放时的缓动) */
@ -1401,6 +1509,7 @@ function getStyles(config: ResolvedConfig): string {
left: 0 !important; left: 0 !important;
border-radius: 0; border-radius: 0;
} }
.csk-resize-handle { display: none !important; }
.csk-window--hidden { transform: translateY(100%); } .csk-window--hidden { transform: translateY(100%); }
/* 移动端默认位置,仅在未被 JS 拖拽覆盖时生效 */ /* 移动端默认位置,仅在未被 JS 拖拽覆盖时生效 */
.csk-launcher:not([style*="bottom"]) { bottom: 20px; } .csk-launcher:not([style*="bottom"]) { bottom: 20px; }
@ -1425,26 +1534,6 @@ export function injectStyles(config: ResolvedConfig): void {
document.head.appendChild(styleElement); document.head.appendChild(styleElement);
} }
/**
* CSS 使
*/
export function updateTheme(config: ResolvedConfig): void {
const root = document.querySelector('.csk-root') as HTMLElement;
if (!root) {
return;
}
const vars = cssVars(config);
vars.split(';').forEach((pair) => {
const idx = pair.indexOf(':');
if (idx < 0) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key && value) {
root.style.setProperty(key, value);
}
});
}
/** /**
* *
*/ */

21
client/src/types.ts

@ -19,6 +19,12 @@ export interface SDKConfig {
/** 宿主用户标识 → 后端 accountId 参数(客户账号 ID),账号可绑定角色 */ /** 宿主用户标识 → 后端 accountId 参数(客户账号 ID),账号可绑定角色 */
userId?: string; userId?: string;
// === 鉴权配置 ===
/** SDK JWT Token(从 /open-api/auth/token 换取),用于访问 /ai/** 接口 */
token?: string;
/** 可用客服角色列表(与 token 配套使用,决定用户可选择哪些角色) */
roles?: Array<{ id: string | number; key?: string; name?: string }>;
// === 知识库 === // === 知识库 ===
/** 默认知识库分类 ID */ /** 默认知识库分类 ID */
categoryId?: number; categoryId?: number;
@ -36,9 +42,7 @@ export interface SDKConfig {
position?: 'left-bottom' | 'right-bottom'; position?: 'left-bottom' | 'right-bottom';
/** 主色调,默认 "#4F46E5" */ /** 主色调,默认 "#4F46E5" */
primaryColor?: string; primaryColor?: string;
/** 悬浮按钮主题风格(马卡龙色系磨砂玻璃),不传则使用 primaryColor 渐变 */
launcherTheme?: 'dream-purple' | 'mint-tech' | 'coral-peach' | 'sky-blue';
/** 悬浮按钮图标(可传 URL 或 SVG 字符串) */
/** 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 */
launcherIcon?: string; launcherIcon?: string;
/** 是否显示清空对话按钮,默认 true */ /** 是否显示清空对话按钮,默认 true */
showClear?: boolean; showClear?: boolean;
@ -52,6 +56,8 @@ export interface SDKConfig {
showTeaser?: boolean; showTeaser?: boolean;
/** 提示气泡文字,留空则使用 i18n 默认值 */ /** 提示气泡文字,留空则使用 i18n 默认值 */
teaserText?: string; teaserText?: string;
/** 是否允许用户拖拽缩放窗口(右下角手柄),默认 true。移动端(≤480px)强制全屏时忽略 */
resizable?: boolean;
// === 行为配置 === // === 行为配置 ===
/** 是否启用流式输出,默认 true */ /** 是否启用流式输出,默认 true */
@ -92,6 +98,10 @@ export interface ResolvedConfig {
requestDomain: string; requestDomain: string;
/** 客户账号 ID → 后端 accountId */ /** 客户账号 ID → 后端 accountId */
userId?: string; userId?: string;
/** SDK JWT Token → 访问 /ai/** 接口的认证头 */
token?: string;
/** 可用客服角色列表 */
roles?: Array<{ id: string | number; key?: string; name?: string }>;
/** 知识库分类 ID */ /** 知识库分类 ID */
categoryId?: number; categoryId?: number;
/** 是否显示知识库切换 */ /** 是否显示知识库切换 */
@ -106,8 +116,6 @@ export interface ResolvedConfig {
position: 'left-bottom' | 'right-bottom'; position: 'left-bottom' | 'right-bottom';
/** 主色调 */ /** 主色调 */
primaryColor: string; primaryColor: string;
/** 悬浮按钮主题风格 */
launcherTheme?: 'dream-purple' | 'mint-tech' | 'coral-peach' | 'sky-blue';
/** 悬浮按钮图标 */ /** 悬浮按钮图标 */
launcherIcon: string; launcherIcon: string;
/** 显示清空按钮 */ /** 显示清空按钮 */
@ -122,6 +130,8 @@ export interface ResolvedConfig {
showTeaser: boolean; showTeaser: boolean;
/** 提示气泡文字 */ /** 提示气泡文字 */
teaserText: string; teaserText: string;
/** 是否允许用户拖拽缩放窗口 */
resizable: boolean;
/** 流式输出 */ /** 流式输出 */
streaming: boolean; streaming: boolean;
/** 是否启用 RAG 知识库检索 */ /** 是否启用 RAG 知识库检索 */
@ -193,6 +203,7 @@ export interface ConversationSummary {
chatId: string; chatId: string;
accountId?: string; accountId?: string;
roleId?: number; roleId?: number;
roleName?: string;
messageCount?: number; messageCount?: number;
lastMessageTime?: number; lastMessageTime?: number;
createdAt?: number; createdAt?: number;

9
client/src/utils.ts

@ -2,15 +2,6 @@
* *
*/ */
/** 生成简短 UUID(取 crypto.randomUUID 前 8 位) */
export function shortUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID().substring(0, 8);
}
// fallback
return Math.random().toString(36).substring(2, 10);
}
/** 生成完整 UUID */ /** 生成完整 UUID */
export function uuid(): string { export function uuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) { if (typeof crypto !== 'undefined' && crypto.randomUUID) {

BIN
io/modelcontextprotocol/client/McpSyncClient.class

6
pom.xml

@ -92,6 +92,12 @@
<artifactId>spring-ai-openai</artifactId> <artifactId>spring-ai-openai</artifactId>
</dependency> </dependency>
<!-- Spring AI MCP Client(Model Context Protocol 客户端) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!--Hutool 工具库--> <!--Hutool 工具库-->
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>

223
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -10,6 +10,8 @@ import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
import com.wok.supportbot.service.FaqMatchEngine; import com.wok.supportbot.service.FaqMatchEngine;
import com.wok.supportbot.mcp.McpToolCallback;
import com.wok.supportbot.mcp.McpToolCallbackAdapter;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
@ -23,10 +25,12 @@ import org.springframework.ai.document.Document;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor; import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter; import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever; import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@ -62,6 +66,13 @@ public class AssistantApp {
@Resource @Resource
private FaqMatchEngine faqMatchEngine; private FaqMatchEngine faqMatchEngine;
@Resource
private McpToolCallbackAdapter mcpToolCallbackAdapter;
/** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */
@Value("${chat.mcp.enabled:true}")
private boolean enableMcpTools;
private final ChatModelFactory chatModelFactory; private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory; private final DatabaseChatMemory chatMemory;
@ -81,13 +92,51 @@ public class AssistantApp {
} }
private ChatClient getChatClient(String appType) { private ChatClient getChatClient(String appType) {
return chatClientCache.computeIfAbsent(appType, type -> {
ChatModel chatModel = chatModelFactory.getChatModel(type);
return getChatClient(appType, enableMcpTools ? java.util.List.of("*") : null);
}
/**
* 获取 ChatClient支持按角色过滤 MCP 工具
* 缓存 key 包含工具权限信息不同角色权限缓存不同的 ChatClient 实例
*
* @param appType 应用类型CHAT / RAG_REWRITE
* @param allowedMcpTools 允许的 MCP 工具名列表null 或空 = 允许所有默认行为["*"] = 全部
*/
private ChatClient getChatClient(String appType, List<String> allowedMcpTools) {
// 空列表降级为 ["*"]允许所有工具与无参数版本行为一致
final List<String> effectiveTools;
if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) {
effectiveTools = java.util.List.of("*");
} else {
effectiveTools = allowedMcpTools;
}
String cacheKey = appType + ":" + (effectiveTools == null ? "none" : String.join(",", effectiveTools));
return chatClientCache.computeIfAbsent(cacheKey, type -> {
ChatModel chatModel = chatModelFactory.getChatModel(appType);
var builder = ChatClient.builder(chatModel); var builder = ChatClient.builder(chatModel);
// 仅在系统提示词非空时设置避免 Spring AI Assert.hasText 校验抛异常 // 仅在系统提示词非空时设置避免 Spring AI Assert.hasText 校验抛异常
if (StringUtils.hasText(SYSTEM_PROMPT)) { if (StringUtils.hasText(SYSTEM_PROMPT)) {
builder.defaultSystem(SYSTEM_PROMPT); builder.defaultSystem(SYSTEM_PROMPT);
} }
// 注册 MCP 工具按角色权限过滤
if (enableMcpTools && effectiveTools != null && !effectiveTools.isEmpty()) {
ToolCallback[] mcpTools = mcpToolCallbackAdapter.getFilteredToolCallbacks(effectiveTools);
if (mcpTools.length > 0) {
builder.defaultToolCallbacks(mcpTools);
log.info("✅ 已注册 {} 个 MCP 工具到 ChatClient [{}]", mcpTools.length, cacheKey);
for (ToolCallback tc : mcpTools) {
log.info(" 🔧 工具: {} — {}", tc.getToolDefinition().name(), tc.getToolDefinition().description());
}
} else {
log.warn("⚠️ MCP 工具已启用但无可注册的工具 [{}],请检查:", cacheKey);
log.warn(" 1. 是否在「MCP 服务管理」页面添加了 MCP Server 配置?");
log.warn(" 2. 配置是否已启用(is_active=true)?");
log.warn(" 3. MCP Server 是否连接成功(点击「测试连接」验证)?");
log.warn(" 4. MCP Server 是否暴露了工具(listTools 返回非空)?");
}
} else {
log.info("ℹ️ MCP 工具未启用 [enableMcpTools={}, effectiveTools={}]", enableMcpTools, effectiveTools);
}
return builder return builder
.defaultAdvisors( .defaultAdvisors(
contentSafetyAdvisor, contentSafetyAdvisor,
@ -123,7 +172,20 @@ public class AssistantApp {
* @return AI 回答 * @return AI 回答
*/ */
public String doChat(String message, String chatId, String systemPrompt) { public String doChat(String message, String chatId, String systemPrompt) {
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT")
return doChat(message, chatId, systemPrompt, null);
}
/**
* AI 基础对话支持多轮对话记忆 + 角色系统提示词 + MCP 工具权限
*
* @param message 用户消息
* @param chatId 会话ID
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return AI 回答
*/
public String doChat(String message, String chatId, String systemPrompt, List<String> allowedMcpTools) {
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt() .prompt()
.user(message) .user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId)); .advisors(s -> s.param(CONVERSATION_ID, chatId));
@ -175,6 +237,64 @@ public class AssistantApp {
return spec.stream().content(); return spec.stream().content();
} }
/**
* AI 基础对话多轮记忆 + 角色系统提示词 + MCP 工具权限SSE 流式传输
*
* @param message 用户消息
* @param chatId 会话ID
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return 流式回答
*/
public Flux<String> doChatByStream(String message, String chatId, String systemPrompt, List<String> allowedMcpTools) {
// 重置 MCP 工具调用状态事件收集器 + 轮次计数器
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt()
.user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
return appendMcpToolEvents(spec.stream().content());
}
/**
* SSE 文本流末尾追加 MCP 工具调用事件
* 前端通过解析 "event: tool_call_start" / "event: tool_call_result" 行来展示工具调用过程
* 事件格式遵循 SSE 标准 readSSEStreamWithEvents 的解析逻辑匹配
*/
private Flux<String> appendMcpToolEvents(Flux<String> contentFlux) {
return contentFlux.concatMap(chunk -> Flux.just(chunk))
.concatWith(Flux.defer(() -> {
java.util.List<McpToolCallback.ToolCallEvent> events = McpToolCallback.drainEvents();
if (events.isEmpty()) {
return Flux.empty();
}
log.info("本次对话触发了 {} 个 MCP 工具调用", events.size());
List<String> sseLines = new ArrayList<>();
for (McpToolCallback.ToolCallEvent event : events) {
sseLines.add("event:tool_call_result");
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}",
escapeSse(event.tool()),
escapeSse(event.result()),
event.latencyMs());
sseLines.add("data:" + json);
sseLines.add(""); // SSE 事件分隔空行
}
return Flux.fromIterable(sseLines);
}));
}
/** 转义 SSE 数据中的特殊字符 */
private static String escapeSse(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r");
}
// ==================== FAQ 优先匹配 ==================== // ==================== FAQ 优先匹配 ====================
/** /**
@ -278,6 +398,44 @@ public class AssistantApp {
return chatResponse.getResult().getOutput().getText(); return chatResponse.getResult().getOutput().getText();
} }
/**
* RAG 知识库对话支持查询重写策略 + MCP 工具权限
*
* @param message 用户消息
* @param chatId 会话ID
* @param strategy 查询重写策略
* @param categoryIds 知识库分类过滤
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return AI 回答
*/
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds,
String systemPrompt, List<String> allowedMcpTools) {
// FAQ 优先匹配
Optional<String> faqAnswer = tryFaqMatch(message);
if (faqAnswer.isPresent()) {
log.info("FAQ 命中,直接返回标准答案: chatId={}", chatId);
return faqAnswer.get();
}
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt, allowedMcpTools);
}
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt()
.user(rewrittenMessage)
.advisors(s -> s.param(CONVERSATION_ID, chatId))
.advisors(buildRetrievalAdvisor(4, categoryIds));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
ChatResponse chatResponse = spec.call().chatResponse();
return chatResponse.getResult().getOutput().getText();
}
/** /**
* 根据策略对查询做预检索改写MULTI_QUERY 不走此方法 * 根据策略对查询做预检索改写MULTI_QUERY 不走此方法
*/ */
@ -344,6 +502,49 @@ public class AssistantApp {
return spec.stream().content(); return spec.stream().content();
} }
/**
* RAG 知识库进行对话支持查询重写策略 + MCP 工具权限SSE 流式传输
*
* @param message 用户消息
* @param chatId 会话ID
* @param strategy 查询重写策略
* @param categoryIds 知识库分类过滤
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return 流式回答
*/
public Flux<String> doChatWithRagStrategyByStream(String message, String chatId, String strategy,
List<Long> categoryIds, String systemPrompt,
List<String> allowedMcpTools) {
// 重置 MCP 工具调用状态
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// FAQ 优先匹配命中则直接以流式形式返回标准答案
Optional<String> faqAnswer = tryFaqMatch(message);
if (faqAnswer.isPresent()) {
log.info("FAQ 命中(流式),直接返回标准答案: chatId={}", chatId);
return Flux.just(faqAnswer.get());
}
// 对于 MULTI_QUERY 策略需要先手动检索合并再流式生成
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt, allowedMcpTools);
}
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt()
.user(rewrittenMessage)
.advisors(s -> s.param(CONVERSATION_ID, chatId))
.advisors(buildRetrievalAdvisor(4, categoryIds));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
return appendMcpToolEvents(spec.stream().content());
}
/** /**
* 使用多路查询扩展的 RAG 对话 * 使用多路查询扩展的 RAG 对话
* 将原始查询扩展为多个语义不同的查询分别检索后按文档ID去重合并 * 将原始查询扩展为多个语义不同的查询分别检索后按文档ID去重合并
@ -356,8 +557,13 @@ public class AssistantApp {
* @return AI 回答 * @return AI 回答
*/ */
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds, String systemPrompt) { private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt, null);
}
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds,
String systemPrompt, List<String> allowedMcpTools) {
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt); String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
ChatResponse chatResponse = getChatClient("CHAT")
ChatResponse chatResponse = getChatClient("CHAT", allowedMcpTools)
.prompt() .prompt()
.system(ragSystem) .system(ragSystem)
.user(message) .user(message)
@ -368,14 +574,19 @@ public class AssistantApp {
} }
private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds, String systemPrompt) { private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt, null);
}
private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds,
String systemPrompt, List<String> allowedMcpTools) {
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt); String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
return getChatClient("CHAT")
return appendMcpToolEvents(getChatClient("CHAT", allowedMcpTools)
.prompt() .prompt()
.system(ragSystem) .system(ragSystem)
.user(message) .user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId)) .advisors(s -> s.param(CONVERSATION_ID, chatId))
.stream() .stream()
.content();
.content());
} }
/** /**

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

@ -354,6 +354,22 @@ public class ChatModelFactory {
// ==================== F6: Fallback 故障自动切换 ==================== // ==================== F6: Fallback 故障自动切换 ====================
/**
* 判断指定提供商是否支持 Tool Calling
* DashScope: 支持通义千问系列
* DeepSeek: 支持
* OpenAI: 支持
* 其他: 默认支持 API 端点自行返回错误
*
* @param provider 提供商标识 dashscopedeepseekopenai
* @return 是否支持 Tool Calling
*/
public boolean supportsToolCalling(String provider) {
if (provider == null) return true;
// 当前所有已支持的提供商都支持 tool calling
return true;
}
/** 共享熔断器:连续 3 次失败触发,5 分钟自动恢复 */ /** 共享熔断器:连续 3 次失败触发,5 分钟自动恢复 */
private final SimpleCircuitBreaker circuitBreaker = private final SimpleCircuitBreaker circuitBreaker =
new SimpleCircuitBreaker(3, 5 * 60 * 1000L, ""); new SimpleCircuitBreaker(3, 5 * 60 * 1000L, "");

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

@ -8,6 +8,8 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
/** /**
* 数据库初始化配置 * 数据库初始化配置
* 应用启动时检查并创建必要的表迁移列同步种子数据 * 应用启动时检查并创建必要的表迁移列同步种子数据
@ -58,6 +60,7 @@ public class DatabaseInitConfig {
safeInit("迁移 knowledge_document.content_hash 列", this::addContentHashColumn); safeInit("迁移 knowledge_document.content_hash 列", this::addContentHashColumn);
safeInit("迁移 knowledge_document.enabled 列", this::addDocumentEnabledColumn); safeInit("迁移 knowledge_document.enabled 列", this::addDocumentEnabledColumn);
safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn); safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn);
safeInit("迁移 knowledge_document.file_path 列", this::addDocumentFilePathColumn);
safeInit("创建客服角色表 customer_service_role", () -> { safeInit("创建客服角色表 customer_service_role", () -> {
if (!checkTableExists("customer_service_role")) { if (!checkTableExists("customer_service_role")) {
@ -86,6 +89,8 @@ public class DatabaseInitConfig {
}); });
safeInit("迁移 conversation_session.external_account_id 列", this::addConversationSessionExternalAccountIdColumn); safeInit("迁移 conversation_session.external_account_id 列", this::addConversationSessionExternalAccountIdColumn);
safeInit("迁移 customer_service_role.allowed_mcp_tools 列", this::addRoleAllowedMcpToolsColumn);
safeInit("同步默认客服角色", this::syncDefaultCustomerServiceRoles); safeInit("同步默认客服角色", this::syncDefaultCustomerServiceRoles);
safeInit("同步默认客服账号", this::syncDefaultCustomerAccounts); safeInit("同步默认客服账号", this::syncDefaultCustomerAccounts);
@ -182,12 +187,26 @@ public class DatabaseInitConfig {
} }
}); });
// ==================== MCP Server 配置 ====================
safeInit("创建 MCP Server 配置表 mcp_server_config", () -> {
if (!checkTableExists("mcp_server_config")) {
createMcpServerConfigTable();
}
});
// P0-001: 混合检索 - vector_store 添加全文检索列 // P0-001: 混合检索 - vector_store 添加全文检索列
safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch); safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch);
// 迁移 vector_store.create_time Spring AI 自动建表时不包含此列 // 迁移 vector_store.create_time Spring AI 自动建表时不包含此列
safeInit("迁移 vector_store.create_time 列", this::addVectorStoreCreateTimeColumn); safeInit("迁移 vector_store.create_time 列", this::addVectorStoreCreateTimeColumn);
// ==================== 清理租户相关数据 ====================
safeInit("清理租户表和 tenant_id 列", this::cleanupTenantData);
// ==================== API Key 角色绑定 ====================
safeInit("api_key 新增 role_ids 列", this::addApiKeyRoleIdsColumn);
// 为所有表添加注释幂等可重复执行 // 为所有表添加注释幂等可重复执行
safeInit("应用数据库表注释", this::applyTableComments); safeInit("应用数据库表注释", this::applyTableComments);
@ -220,7 +239,8 @@ public class DatabaseInitConfig {
"knowledge_faq", "faq_embedding", "knowledge_faq", "faq_embedding",
"sys_user", "sys_role", "sys_permission", "sys_user_role", "sys_user", "sys_role", "sys_permission", "sys_user_role",
"rag_hit_log", "dashboard_snapshot", "rag_hit_log", "dashboard_snapshot",
"api_key", "webhook_config"
"api_key", "webhook_config",
"mcp_server_config"
}; };
java.util.List<String> missingTables = new java.util.ArrayList<>(); java.util.List<String> missingTables = new java.util.ArrayList<>();
@ -296,6 +316,7 @@ public class DatabaseInitConfig {
source_name VARCHAR(500), source_name VARCHAR(500),
file_type VARCHAR(20) NOT NULL, file_type VARCHAR(20) NOT NULL,
file_size BIGINT DEFAULT 0 NOT NULL, file_size BIGINT DEFAULT 0 NOT NULL,
file_path VARCHAR(500),
content TEXT, content TEXT,
category_id BIGINT DEFAULT 0 NOT NULL, category_id BIGINT DEFAULT 0 NOT NULL,
tags JSONB DEFAULT '{}' NOT NULL, tags JSONB DEFAULT '{}' NOT NULL,
@ -328,7 +349,6 @@ public class DatabaseInitConfig {
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
prompt TEXT, prompt TEXT,
sort_order INTEGER DEFAULT 0 NOT NULL,
enabled BOOLEAN DEFAULT TRUE NOT NULL, enabled BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
@ -336,7 +356,7 @@ public class DatabaseInitConfig {
) )
"""; """;
jdbcTemplate.execute(sql); jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_service_role_enabled ON customer_service_role (enabled, sort_order)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_service_role_enabled ON customer_service_role (enabled)");
} }
private void createCustomerServiceRoleCategoryTable() { private void createCustomerServiceRoleCategoryTable() {
@ -430,22 +450,40 @@ public class DatabaseInitConfig {
log.warn("add conversation_session.external_account_id failed: {}", e.getMessage()); log.warn("add conversation_session.external_account_id failed: {}", e.getMessage());
} }
} }
/**
* customer_service_role 表添加 allowed_mcp_tools 角色 MCP 工具权限控制
* 幂等已有列则跳过
*/
private void addRoleAllowedMcpToolsColumn() {
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'customer_service_role' AND column_name = 'allowed_mcp_tools'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
log.info("添加 customer_service_role.allowed_mcp_tools 列");
jdbcTemplate.execute("ALTER TABLE customer_service_role ADD COLUMN allowed_mcp_tools JSONB DEFAULT '[]'");
}
} catch (Exception e) {
log.error("添加 customer_service_role.allowed_mcp_tools 列失败", e);
}
}
private void syncDefaultCustomerServiceRoles() { private void syncDefaultCustomerServiceRoles() {
upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明", upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明",
""" """
职责回答用户关于业务办理服务流程常见问题问题受理和进度说明的问题 职责回答用户关于业务办理服务流程常见问题问题受理和进度说明的问题
要求优先依据知识库回答资料不足时说明无法确认并引导用户补充必要信息涉及财务行政制度细节时不要猜测应提示转对应角色处理 要求优先依据知识库回答资料不足时说明无法确认并引导用户补充必要信息涉及财务行政制度细节时不要猜测应提示转对应角色处理
""", 10);
""");
upsertDefaultRole("finance", "财务", "付款、退款、发票、对账、报销、结算与费用规则", upsertDefaultRole("finance", "财务", "付款、退款、发票、对账、报销、结算与费用规则",
""" """
职责回答付款退款发票对账报销结算费用规则相关问题 职责回答付款退款发票对账报销结算费用规则相关问题
要求金额账户票据时间节点必须严谨知识库没有依据时不得编造政策需要用户提供单号金额日期发票抬头等关键信息 要求金额账户票据时间节点必须严谨知识库没有依据时不得编造政策需要用户提供单号金额日期发票抬头等关键信息
""", 20);
""");
upsertDefaultRole("administration", "行政", "办公制度、行政流程、资产、会议、考勤、用章、采购与后勤", upsertDefaultRole("administration", "行政", "办公制度、行政流程、资产、会议、考勤、用章、采购与后勤",
""" """
职责回答办公制度行政流程资产会议考勤入职用章采购后勤等问题 职责回答办公制度行政流程资产会议考勤入职用章采购后勤等问题
要求优先依据公司制度和流程文件回答涉及审批权限特殊例外或未覆盖场景时提示按制度提交申请或联系行政负责人 要求优先依据公司制度和流程文件回答涉及审批权限特殊例外或未覆盖场景时提示按制度提交申请或联系行政负责人
""", 30);
""");
retireObsoleteDefaultRoles(); retireObsoleteDefaultRoles();
} }
@ -456,19 +494,13 @@ public class DatabaseInitConfig {
upsertDefaultAccount("administration", "行政账号", "默认行政账号", "administration"); upsertDefaultAccount("administration", "行政账号", "默认行政账号", "administration");
} }
private void upsertDefaultRole(String roleKey, String name, String description, String prompt, int sortOrder) {
private void upsertDefaultRole(String roleKey, String name, String description, String prompt) {
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO customer_service_role (role_key, name, description, prompt, sort_order)
VALUES (?, ?, ?, ?, ?)
INSERT INTO customer_service_role (role_key, name, description, prompt)
VALUES (?, ?, ?, ?)
ON CONFLICT (role_key) ON CONFLICT (role_key)
DO UPDATE SET name = EXCLUDED.name,
description = EXCLUDED.description,
prompt = EXCLUDED.prompt,
sort_order = EXCLUDED.sort_order,
enabled = true,
is_delete = false,
update_time = CURRENT_TIMESTAMP
""", roleKey, name, description, prompt, sortOrder);
DO NOTHING
""", roleKey, name, description, prompt);
} }
private void upsertDefaultAccount(String accountKey, String name, String description, String roleKey) { private void upsertDefaultAccount(String accountKey, String name, String description, String roleKey) {
@ -586,6 +618,22 @@ public class DatabaseInitConfig {
} }
} }
/**
* knowledge_document 表添加 file_path 存储原始文件路径
*/
private void addDocumentFilePathColumn() {
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'file_path'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
log.info("添加 knowledge_document.file_path 列");
jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN file_path VARCHAR(500)");
}
} catch (Exception e) {
log.error("添加 knowledge_document.file_path 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN file_path VARCHAR(500)", e);
}
}
// ==================== P0-004: 内容安全过滤 ==================== // ==================== P0-004: 内容安全过滤 ====================
private void createSensitiveWordTable() { private void createSensitiveWordTable() {
@ -1017,6 +1065,27 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_webhook_enabled ON webhook_config (enabled) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_webhook_enabled ON webhook_config (enabled) WHERE is_delete = false");
} }
private void createMcpServerConfigTable() {
String sql = """
CREATE TABLE IF NOT EXISTS mcp_server_config (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
transport_type VARCHAR(20) NOT NULL DEFAULT 'sse',
server_url VARCHAR(500),
command VARCHAR(500),
args VARCHAR(1000),
env_vars JSONB DEFAULT '{}' NOT NULL,
description VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
)
""";
jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_mcp_server_config_active ON mcp_server_config (is_active) WHERE is_delete = FALSE");
}
/** /**
* 为所有自动创建的表添加注释COMMENT ON * 为所有自动创建的表添加注释COMMENT ON
* 所有语句均为幂等操作可安全重复执行 * 所有语句均为幂等操作可安全重复执行
@ -1027,7 +1096,7 @@ public class DatabaseInitConfig {
executeComment("TABLE chat_message", "聊天消息表(存储用户与 AI 助手的对话历史)"); executeComment("TABLE chat_message", "聊天消息表(存储用户与 AI 助手的对话历史)");
executeComment("COLUMN chat_message.id", "主键(雪花算法生成)"); executeComment("COLUMN chat_message.id", "主键(雪花算法生成)");
executeComment("COLUMN chat_message.conversation_id", "会话 ID(标识同一次对话)"); executeComment("COLUMN chat_message.conversation_id", "会话 ID(标识同一次对话)");
executeComment("COLUMN chat_message.message_type", "消息类型: USER(用户消息) / ASSISTANT(AI回复) / SYSTEM(系统消息)");
executeComment("COLUMN chat_message.message_type", "消息类型: USER(用户消息) / ASSISTANT(AI回复,可能含toolCalls) / SYSTEM(系统消息) / TOOL(MCP工具调用响应)");
executeComment("COLUMN chat_message.content", "消息内容(实际对话文本)"); executeComment("COLUMN chat_message.content", "消息内容(实际对话文本)");
executeComment("COLUMN chat_message.metadata", "元数据(JSON 格式,存储额外信息)"); executeComment("COLUMN chat_message.metadata", "元数据(JSON 格式,存储额外信息)");
executeComment("COLUMN chat_message.create_time", "创建时间"); executeComment("COLUMN chat_message.create_time", "创建时间");
@ -1061,6 +1130,7 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.content_hash", "内容 SHA-256 哈希值(用于文档去重)"); executeComment("COLUMN knowledge_document.content_hash", "内容 SHA-256 哈希值(用于文档去重)");
executeComment("COLUMN knowledge_document.enabled", "是否启用: TRUE=参与RAG检索, FALSE=禁用(不参与检索但保留数据)"); executeComment("COLUMN knowledge_document.enabled", "是否启用: TRUE=参与RAG检索, FALSE=禁用(不参与检索但保留数据)");
executeComment("COLUMN knowledge_document.extra_config", "分块参数配置(JSONB),存储 per-doc 的 chunkSize/overlap 等"); executeComment("COLUMN knowledge_document.extra_config", "分块参数配置(JSONB),存储 per-doc 的 chunkSize/overlap 等");
executeComment("COLUMN knowledge_document.file_path", "原始文件存储路径(相对于存储根目录的相对路径)");
executeComment("COLUMN knowledge_document.create_time", "创建时间"); executeComment("COLUMN knowledge_document.create_time", "创建时间");
executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.update_time", "更新时间");
executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
@ -1072,11 +1142,11 @@ public class DatabaseInitConfig {
executeComment("COLUMN customer_service_role.name", "角色名称"); executeComment("COLUMN customer_service_role.name", "角色名称");
executeComment("COLUMN customer_service_role.description", "角色描述"); executeComment("COLUMN customer_service_role.description", "角色描述");
executeComment("COLUMN customer_service_role.prompt", "系统提示词(角色人设与行为规范)"); executeComment("COLUMN customer_service_role.prompt", "系统提示词(角色人设与行为规范)");
executeComment("COLUMN customer_service_role.sort_order", "排序权重");
executeComment("COLUMN customer_service_role.enabled", "是否启用"); executeComment("COLUMN customer_service_role.enabled", "是否启用");
executeComment("COLUMN customer_service_role.create_time", "创建时间"); executeComment("COLUMN customer_service_role.create_time", "创建时间");
executeComment("COLUMN customer_service_role.update_time", "更新时间"); executeComment("COLUMN customer_service_role.update_time", "更新时间");
executeComment("COLUMN customer_service_role.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); executeComment("COLUMN customer_service_role.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
executeComment("COLUMN customer_service_role.allowed_mcp_tools", "允许使用的 MCP 工具列表(JSONB 数组,空=不允许,[\"*\"]=全部允许)");
// ===== customer_service_role_category ===== // ===== customer_service_role_category =====
executeComment("TABLE customer_service_role_category", "客服角色知识库关联表(角色与知识库分类的多对多关系)"); executeComment("TABLE customer_service_role_category", "客服角色知识库关联表(角色与知识库分类的多对多关系)");
@ -1208,6 +1278,21 @@ public class DatabaseInitConfig {
// ===== webhook_config ===== // ===== webhook_config =====
executeComment("TABLE webhook_config", "Webhook 配置表(事件推送订阅)"); executeComment("TABLE webhook_config", "Webhook 配置表(事件推送订阅)");
// ===== mcp_server_config =====
executeComment("TABLE mcp_server_config", "MCP Server 配置表(管理外部 MCP Server 连接配置)");
executeComment("COLUMN mcp_server_config.id", "主键(雪花算法生成)");
executeComment("COLUMN mcp_server_config.name", "配置名称");
executeComment("COLUMN mcp_server_config.transport_type", "传输类型: stdio(标准输入输出) / sse(Server-Sent Events)");
executeComment("COLUMN mcp_server_config.server_url", "SSE 模式的 MCP Server URL");
executeComment("COLUMN mcp_server_config.command", "stdio 模式的启动命令");
executeComment("COLUMN mcp_server_config.args", "stdio 模式的命令参数(多个用逗号分隔)");
executeComment("COLUMN mcp_server_config.env_vars", "环境变量(JSONB 键值对)");
executeComment("COLUMN mcp_server_config.description", "描述说明");
executeComment("COLUMN mcp_server_config.is_active", "是否启用");
executeComment("COLUMN mcp_server_config.create_time", "创建时间");
executeComment("COLUMN mcp_server_config.update_time", "更新时间");
executeComment("COLUMN mcp_server_config.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
// ===== ai_model_config ===== // ===== ai_model_config =====
executeComment("TABLE ai_model_config", "AI 大模型配置表(管理多套模型配置,按应用类型绑定)"); executeComment("TABLE ai_model_config", "AI 大模型配置表(管理多套模型配置,按应用类型绑定)");
executeComment("COLUMN ai_model_config.id", "主键(雪花算法生成)"); executeComment("COLUMN ai_model_config.id", "主键(雪花算法生成)");
@ -1230,6 +1315,9 @@ public class DatabaseInitConfig {
// ===== chat_message.user_id ===== // ===== chat_message.user_id =====
executeComment("COLUMN chat_message.user_id", "所属系统用户ID(数据隔离,SDK调用时为null)"); executeComment("COLUMN chat_message.user_id", "所属系统用户ID(数据隔离,SDK调用时为null)");
// ===== api_key.role_ids =====
executeComment("COLUMN api_key.role_ids", "绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)");
log.info("数据库表注释已应用"); log.info("数据库表注释已应用");
} catch (Exception e) { } catch (Exception e) {
log.warn("应用数据库表注释时出错", e); log.warn("应用数据库表注释时出错", e);
@ -1243,4 +1331,81 @@ public class DatabaseInitConfig {
jdbcTemplate.execute(String.format("COMMENT ON %s IS '%s'", target, comment.replace("'", "''"))); jdbcTemplate.execute(String.format("COMMENT ON %s IS '%s'", target, comment.replace("'", "''")));
} }
// ==================== 清理租户相关数据 ====================
/**
* 清理租户相关数据删除 tenant 移除各表的 tenant_id 列和索引
*/
private void cleanupTenantData() {
// 1. 删除 tenant
if (checkTableExists("tenant")) {
jdbcTemplate.execute("DROP TABLE IF EXISTS tenant CASCADE");
log.info("已删除 tenant 表");
}
// 2. 清理 api_key.tenant_id
if (checkTableExists("api_key")) {
try {
jdbcTemplate.execute("DROP INDEX IF EXISTS idx_api_key_tenant_id");
jdbcTemplate.execute("ALTER TABLE api_key DROP COLUMN IF EXISTS tenant_id");
} catch (Exception e) {
log.debug("清理 api_key.tenant_id 跳过: {}", e.getMessage());
}
}
// 3. 清理 customer_service_role.tenant_id
if (checkTableExists("customer_service_role")) {
try {
jdbcTemplate.execute("DROP INDEX IF EXISTS idx_csr_tenant_id");
jdbcTemplate.execute("ALTER TABLE customer_service_role DROP COLUMN IF EXISTS tenant_id");
} catch (Exception e) {
log.debug("清理 customer_service_role.tenant_id 跳过: {}", e.getMessage());
}
}
// 4. 清理 knowledge_category.tenant_id
if (checkTableExists("knowledge_category")) {
try {
jdbcTemplate.execute("DROP INDEX IF EXISTS idx_kc_tenant_id");
jdbcTemplate.execute("ALTER TABLE knowledge_category DROP COLUMN IF EXISTS tenant_id");
} catch (Exception e) {
log.debug("清理 knowledge_category.tenant_id 跳过: {}", e.getMessage());
}
}
// 5. 清理 knowledge_faq.tenant_id
if (checkTableExists("knowledge_faq")) {
try {
jdbcTemplate.execute("DROP INDEX IF EXISTS idx_faq_tenant_id");
jdbcTemplate.execute("ALTER TABLE knowledge_faq DROP COLUMN IF EXISTS tenant_id");
} catch (Exception e) {
log.debug("清理 knowledge_faq.tenant_id 跳过: {}", e.getMessage());
}
}
log.info("租户相关数据清理完成");
}
// ==================== API Key 角色绑定 ====================
/**
* api_key 表添加 role_ids JSONB 数组存储绑定的客服角色 ID
*/
private void addApiKeyRoleIdsColumn() {
if (!checkTableExists("api_key")) {
log.debug("api_key 表尚未创建,跳过 role_ids 迁移");
return;
}
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'api_key' AND column_name = 'role_ids'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
log.info("添加 api_key.role_ids 列");
jdbcTemplate.execute("ALTER TABLE api_key ADD COLUMN role_ids JSONB DEFAULT '[]' NOT NULL");
}
} catch (Exception e) {
log.error("添加 api_key.role_ids 列失败", e);
}
}
} }

80
src/main/java/com/wok/supportbot/config/FileStorageConfig.java

@ -0,0 +1,80 @@
package com.wok.supportbot.config;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 文件存储配置
* 管理上传文件的本地存储路径
*/
@Component
@Slf4j
public class FileStorageConfig {
@Value("${knowledge.storage.local-path:./uploads}")
private String localPath;
private Path storageRootPath;
@PostConstruct
public void init() {
storageRootPath = Paths.get(localPath).toAbsolutePath().normalize();
File rootDir = storageRootPath.toFile();
if (!rootDir.exists()) {
boolean created = rootDir.mkdirs();
if (created) {
log.info("创建文件存储根目录: {}", storageRootPath);
} else {
log.warn("创建文件存储根目录失败: {}", storageRootPath);
}
}
log.info("文件存储路径: {}", storageRootPath);
}
/**
* 获取存储根路径
*/
public Path getStorageRootPath() {
return storageRootPath;
}
/**
* 获取指定文件的完整路径
*
* @param relativePath 相对路径 "2024/01/xxx.docx"
*/
public Path getFilePath(String relativePath) {
return storageRootPath.resolve(relativePath).normalize();
}
/**
* 生成按年月组织的相对路径
*
* @param originalFilename 原始文件名
* @return 相对路径 "2024/01/uuid.docx"
*/
public String generateRelativePath(String originalFilename) {
String extension = getExtension(originalFilename);
String uniqueName = java.util.UUID.randomUUID().toString() + extension;
// 按年月分目录
java.time.LocalDate now = java.time.LocalDate.now();
return String.format("%d/%02d/%s", now.getYear(), now.getMonthValue(), uniqueName);
}
/**
* 获取文件扩展名含点号
*/
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return "";
}
return filename.substring(filename.lastIndexOf(".")).toLowerCase();
}
}

660
src/main/java/com/wok/supportbot/config/McpClientManager.java

@ -0,0 +1,660 @@
package com.wok.supportbot.config;
import com.wok.supportbot.entity.McpServerConfig;
import com.wok.supportbot.service.McpServerConfigService;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* MCP 客户端生命周期管理器
* 负责 MCP Client 的创建缓存刷新和销毁
* <p>
* 支持两种传输模式
* - SSEServer-Sent Events通过 HttpClientSseClientTransport 连接远程 MCP Server
* - stdio标准输入输出通过 StdioClientTransport 启动本地 MCP Server 进程
*/
@Component
@Slf4j
public class McpClientManager {
@Autowired
private McpServerConfigService mcpServerConfigService;
/**
* 客户端缓存key = 配置ID 的字符串形式value = MCP Client 实例
* volatile 保证 refreshAll() 双缓冲切换时的可见性
*/
private volatile ConcurrentHashMap<String, McpSyncClient> clientCache = new ConcurrentHashMap<>();
/**
* 不可用配置集合记录已知不存在或未启用的配置ID避免重复查询 DB
* 配置被启用或新建时需同步移除此集合中的对应条目
*/
private final Set<String> unavailableConfigs = ConcurrentHashMap.newKeySet();
/**
* 健康状态缓存key = 配置ID 的字符串形式value = 健康检查结果
*/
private final ConcurrentHashMap<String, HealthStatus> healthCache = new ConcurrentHashMap<>();
/**
* MCP Server 健康状态记录
*/
public record HealthStatus(
/** 状态:ONLINE / OFFLINE / UNKNOWN */
String status,
/** 响应延迟(毫秒) */
long latencyMs,
/** 最后检查时间 */
Instant lastCheckTime,
/** 错误信息(健康时为 null) */
String errorMessage
) {
public static HealthStatus online(long latencyMs) {
return new HealthStatus("ONLINE", latencyMs, Instant.now(), null);
}
public static HealthStatus offline(long latencyMs, String errorMessage) {
return new HealthStatus("OFFLINE", latencyMs, Instant.now(), errorMessage);
}
public static HealthStatus unknown(String errorMessage) {
return new HealthStatus("UNKNOWN", 0, Instant.now(), errorMessage);
}
}
/**
* MCP 客户端请求超时时间支持 Duration 格式 30sPT30S60s
*/
@Value("${mcp.client.request-timeout:30s}")
private String requestTimeoutStr;
/**
* MCP 客户端初始化超时时间支持 Duration 格式 15sPT15S30s
*/
@Value("${mcp.client.init-timeout:15s}")
private String initTimeoutStr;
/**
* 应用启动时自动加载所有已启用的 MCP Server 配置并建立连接
* 确保第一次对话请求时 clientCache 已就绪MCP 工具可以被注册到 ChatClient
*/
@PostConstruct
public void init() {
log.info("McpClientManager 初始化,开始加载已启用的 MCP Server 配置...");
refreshAll();
}
/**
* 刷新所有 MCP 客户端连接双缓冲策略
* 先在新 Map 中构建所有客户端再原子切换引用最后关闭旧客户端
* 避免清空与重建之间的请求全部失败
*/
public void refreshAll() {
log.info("开始刷新所有 MCP 客户端连接...");
// 获取所有启用的配置一次查询代替两次按类型过滤
List<McpServerConfig> allActiveConfigs = mcpServerConfigService.listAllActiveConfigs();
// 第一步在全新 Map 中构建所有客户端不影响现有缓存
ConcurrentHashMap<String, McpSyncClient> newCache = new ConcurrentHashMap<>();
for (McpServerConfig config : allActiveConfigs) {
try {
McpSyncClient client = createClientDirectly(config);
if (client != null) {
newCache.put(config.getId().toString(), client);
}
} catch (Exception e) {
log.error("创建客户端失败: id={}, name={}, transportType={}, error={}",
config.getId(), config.getName(), config.getTransportType(), e.getMessage());
}
}
// 第二步原子切换引用volatile 保证其他线程立即可见
ConcurrentHashMap<String, McpSyncClient> oldCache = clientCache;
clientCache = newCache;
unavailableConfigs.clear();
// 第三步关闭旧缓存中的客户端不影响新请求
for (Map.Entry<String, McpSyncClient> entry : oldCache.entrySet()) {
try {
McpSyncClient client = entry.getValue();
if (client != null) {
client.close();
log.debug("已关闭旧 MCP 客户端: configId={}", entry.getKey());
}
} catch (Exception e) {
log.error("关闭旧 MCP 客户端失败: configId={}, error={}", entry.getKey(), e.getMessage());
}
}
log.info("MCP 客户端刷新完成,当前缓存数量: {}", clientCache.size());
}
/**
* 获取指定配置的客户端懒加载
* 使用 computeIfAbsent 保证同一配置只创建一次客户端避免并发竞态
* 如果缓存中不存在且不在不可用集合中 DB 读取配置后原子创建并缓存
*
* @param configId 配置ID
* @return MCP Client 实例不存在或未启用返回 null
*/
public McpSyncClient getClient(Long configId) {
String key = configId.toString();
// 快速路径缓存命中
McpSyncClient cached = clientCache.get(key);
if (cached != null) {
return cached;
}
// 快速路径已知不可用的配置直接跳过
if (unavailableConfigs.contains(key)) {
return null;
}
// 缓存未命中使用 computeIfAbsent 保证同一 key 只创建一次客户端
// ConcurrentHashMap.computeIfAbsent 对同一 key 加锁避免并发线程重复创建
// 注意mapping function 不能返回 null会抛 NPE因此不可用的情况记录到 unavailableConfigs
McpServerConfig config = mcpServerConfigService.getConfigById(configId);
if (config == null) {
log.warn("MCP 配置不存在: id={}", configId);
unavailableConfigs.add(key);
return null;
}
if (!Boolean.TRUE.equals(config.getIsActive())) {
log.warn("MCP 配置未启用,跳过创建客户端: id={}, name={}", configId, config.getName());
unavailableConfigs.add(key);
return null;
}
// 配置存在且启用通过 computeIfAbsent 原子创建防止并发重复创建
return clientCache.computeIfAbsent(key, k -> createClientDirectly(config));
}
/**
* 获取所有已启用的 MCP 工具描述
* 遍历所有已缓存的客户端调用 listTools() 获取真实的工具列表
*
* @return 工具描述列表每个元素包含 config_idnametransport_typedescriptiontools
*/
public List<Map<String, Object>> listAvailableTools() {
List<Map<String, Object>> tools = new ArrayList<>();
log.info("🔍 listAvailableTools: clientCache 大小 = {}", clientCache.size());
if (clientCache.isEmpty()) {
log.warn("⚠️ clientCache 为空!MCP Server 可能未配置或连接失败。");
log.warn(" 请检查:1) 是否添加了 MCP Server 配置 2) 配置是否已启用 3) 启动日志是否有连接错误");
return tools;
}
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) {
Long configId;
try {
configId = Long.parseLong(entry.getKey());
} catch (NumberFormatException e) {
log.warn("缓存 key 格式异常: {}", entry.getKey());
continue;
}
McpServerConfig config = mcpServerConfigService.getConfigById(configId);
if (config == null || !Boolean.TRUE.equals(config.getIsActive())) {
continue;
}
Map<String, Object> toolInfo = new LinkedHashMap<>();
toolInfo.put("config_id", config.getId().toString());
toolInfo.put("name", config.getName());
toolInfo.put("transport_type", config.getTransportType());
toolInfo.put("description", config.getDescription());
// MCP Client 获取真实的工具列表
List<Map<String, Object>> toolList = new ArrayList<>();
try {
McpSyncClient client = entry.getValue();
McpSchema.ListToolsResult listToolsResult = client.listTools();
if (listToolsResult != null && listToolsResult.tools() != null) {
log.info(" 📦 MCP Server [{}] 暴露了 {} 个工具", config.getName(), listToolsResult.tools().size());
for (McpSchema.Tool tool : listToolsResult.tools()) {
Map<String, Object> toolMeta = new LinkedHashMap<>();
toolMeta.put("name", tool.name());
toolMeta.put("description", tool.description());
// JsonSchema 对象序列化为 JSON 字符串 McpToolCallback 构建 ToolDefinition 使用
if (tool.inputSchema() != null) {
toolMeta.put("inputSchema", ModelOptionsUtils.toJsonString(tool.inputSchema()));
} else {
toolMeta.put("inputSchema", "{}");
}
toolList.add(toolMeta);
}
}
log.info(" 📦 MCP Server [{}] 获取到 {} 个工具", config.getName(), toolList.size());
if (toolList.isEmpty()) {
log.warn(" ⚠️ MCP Server [{}] 未暴露任何工具,AI 模型将无法调用", config.getName());
}
} catch (Exception e) {
log.error("获取 MCP 工具列表失败: serverId={}, name={}, error={}",
configId, config.getName(), e.getMessage());
}
toolInfo.put("tools", toolList);
tools.add(toolInfo);
}
return tools;
}
/**
* 定时健康检查 5 分钟执行一次
* 遍历所有缓存的客户端尝试 listTools() 探测连通性记录状态到 healthCache
*/
@Scheduled(fixedDelay = 300000)
public void healthCheck() {
log.info("开始 MCP Server 定时健康检查,当前缓存数量: {}", clientCache.size());
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) {
String key = entry.getKey();
checkSingleClient(key, entry.getValue());
}
log.info("MCP Server 定时健康检查完成");
}
/**
* 手动触发单个配置的健康检查
*
* @param configId 配置ID
* @return 该配置的最新健康状态
*/
public HealthStatus checkHealthForConfig(Long configId) {
String key = configId.toString();
McpSyncClient client = clientCache.get(key);
if (client == null) {
HealthStatus status = HealthStatus.unknown("客户端未缓存或配置不存在");
healthCache.put(key, status);
return status;
}
return checkSingleClient(key, client);
}
/**
* 对单个客户端执行健康探测
* 通过调用 listTools() 判断服务端是否存活
*
* @param key 缓存 key配置ID 字符串
* @param client MCP 客户端实例
* @return 健康状态
*/
private HealthStatus checkSingleClient(String key, McpSyncClient client) {
long startTime = System.currentTimeMillis();
try {
McpSchema.ListToolsResult result = client.listTools();
long latencyMs = System.currentTimeMillis() - startTime;
int toolsCount = (result != null && result.tools() != null) ? result.tools().size() : 0;
HealthStatus status = HealthStatus.online(latencyMs);
healthCache.put(key, status);
log.debug("MCP Server 健康检查通过: configId={}, latencyMs={}, toolsCount={}", key, latencyMs, toolsCount);
return status;
} catch (Exception e) {
long latencyMs = System.currentTimeMillis() - startTime;
HealthStatus status = HealthStatus.offline(latencyMs, e.getMessage());
healthCache.put(key, status);
log.warn("MCP Server 健康检查失败: configId={}, latencyMs={}, error={}", key, latencyMs, e.getMessage());
return status;
}
}
/**
* 获取指定配置的健康状态
*
* @param configId 配置ID
* @return 健康状态未检查过返回 UNKNOWN
*/
public HealthStatus getHealthStatus(Long configId) {
HealthStatus status = healthCache.get(configId.toString());
if (status == null) {
return HealthStatus.unknown("尚未执行健康检查");
}
return status;
}
/**
* 获取所有已缓存配置的健康状态
*
* @return Mapkey = 配置ID 字符串value = 健康状态
*/
public Map<String, HealthStatus> getAllHealthStatus() {
Map<String, HealthStatus> result = new LinkedHashMap<>();
// 遍历所有缓存的客户端未在 healthCache 中的标记为 UNKNOWN
for (String key : clientCache.keySet()) {
HealthStatus status = healthCache.get(key);
if (status == null) {
status = HealthStatus.unknown("尚未执行健康检查");
}
result.put(key, status);
}
return result;
}
/**
* 关闭所有 MCP 客户端连接
* 在应用关闭或手动刷新时调用
*/
@PreDestroy
public void shutdown() {
log.info("关闭所有 MCP 客户端连接,当前数量: {}", clientCache.size());
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) {
try {
McpSyncClient client = entry.getValue();
if (client != null) {
client.close();
log.debug("已关闭 MCP 客户端: configId={}", entry.getKey());
}
} catch (Exception e) {
log.error("关闭 MCP 客户端失败: configId={}, error={}", entry.getKey(), e.getMessage());
}
}
clientCache.clear();
unavailableConfigs.clear();
healthCache.clear();
log.info("所有 MCP 客户端连接已关闭");
}
/**
* 根据配置创建 MCP 客户端不负责缓存由调用方决定是否缓存
* <p>
* SSE 模式使用 HttpClientSseClientTransport 连接远程 MCP Server
* stdio 模式使用 StdioClientTransport 启动本地 MCP Server 进程
*
* @param config MCP Server 配置
* @return 创建并初始化后的客户端实例失败返回 null
*/
private McpSyncClient createClientDirectly(McpServerConfig config) {
try {
McpSyncClient client;
if ("sse".equals(config.getTransportType())) {
// 创建 SSE 传输层连接远程 MCP Server
// HttpClientSseClientTransport 会自动将 /sse 作为 SSE 端点附加到 baseUri
String serverUrl = config.getServerUrl().trim();
// 如果 URL /sse 结尾去掉后缀作为 baseUritransport 层会自动附加
String baseUri = serverUrl.replaceAll("/sse/?$", "");
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(baseUri).build();
client = buildSyncClient(transport);
log.info("正在初始化 SSE MCP 客户端: id={}, url={}", config.getId(), baseUri);
} else if ("stdio".equals(config.getTransportType())) {
// 创建 stdio 传输层启动本地 MCP Server 进程
String command = config.getCommand().trim();
// 解析参数列表逗号分隔
List<String> argsList = new ArrayList<>();
if (config.getArgs() != null && !config.getArgs().trim().isEmpty()) {
argsList = Arrays.asList(config.getArgs().split("\\s*,\\s*"));
}
// 构建 ServerParameters
ServerParameters.Builder paramsBuilder = ServerParameters.builder(command);
if (!argsList.isEmpty()) {
paramsBuilder.args(argsList);
}
// 环境变量Map<String, Object> -> Map<String, String>
if (config.getEnvVars() != null && !config.getEnvVars().isEmpty()) {
Map<String, String> envStr = new HashMap<>();
config.getEnvVars().forEach((k, v) -> envStr.put(k, v != null ? v.toString() : ""));
paramsBuilder.env(envStr);
}
StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build());
client = buildSyncClient(transport);
log.info("正在初始化 stdio MCP 客户端: id={}, command={}", config.getId(), command);
} else {
log.error("不支持的传输类型: {}", config.getTransportType());
return null;
}
// 初始化连接握手协议
client.initialize();
log.info("MCP 客户端初始化成功: id={}, name={}, transportType={}",
config.getId(), config.getName(), config.getTransportType());
return client;
} catch (Exception e) {
log.error("创建 MCP 客户端失败: id={}, name={}, transportType={}, error={}",
config.getId(), config.getName(), config.getTransportType(), e.getMessage(), e);
return null;
}
}
/**
* 构建 McpSyncClient 实例
* 统一设置客户端信息和超时参数超时时间从配置文件读取
*
* @param transport 传输层实例SSE stdio
* @return 构建的 McpSyncClient
*/
private McpSyncClient buildSyncClient(io.modelcontextprotocol.spec.McpClientTransport transport) {
Duration requestTimeout = parseDuration(requestTimeoutStr, Duration.ofSeconds(30));
Duration initTimeout = parseDuration(initTimeoutStr, Duration.ofSeconds(15));
return io.modelcontextprotocol.client.McpClient.sync(transport)
.clientInfo(new McpSchema.Implementation("supportbot-mcp-client", "1.0.0"))
.requestTimeout(requestTimeout)
.initializationTimeout(initTimeout)
.build();
}
/**
* 解析 Duration 字符串兼容 ISO-8601 格式PT30S和简化格式30s60s
* 解析失败时返回默认值
*
* @param durationStr Duration 字符串
* @param defaultValue 解析失败时的默认值
* @return 解析后的 Duration
*/
private Duration parseDuration(String durationStr, Duration defaultValue) {
if (durationStr == null || durationStr.isBlank()) {
return defaultValue;
}
try {
// 先尝试 ISO-8601 格式 PT30SPT1M30S
return Duration.parse(durationStr.trim());
} catch (Exception e) {
// 尝试简化格式纯数字秒 "30"或带 "s" 后缀 "30s"
String trimmed = durationStr.trim().toLowerCase();
String numStr = trimmed.replaceAll("[^0-9.]+$", "");
if (!numStr.isEmpty()) {
try {
long seconds = Long.parseLong(numStr);
return Duration.ofSeconds(seconds);
} catch (NumberFormatException ignored) {
// 继续尝试小数
}
}
log.warn("无法解析 Duration 配置 '{}',使用默认值: {}", durationStr, defaultValue);
return defaultValue;
}
}
// ==================== 增量缓存管理 Controller CRUD 操作使用 ====================
/**
* 新增配置时创建客户端并加入缓存
* 仅对新建的单个配置创建连接不影响其他已有连接
*
* @param configId 新建配置的ID
*/
public void addClient(Long configId) {
String key = configId.toString();
// 从不可用集合中移除新建的配置可能之前被标记为不可用
unavailableConfigs.remove(key);
McpServerConfig config = mcpServerConfigService.getConfigById(configId);
if (config == null || !Boolean.TRUE.equals(config.getIsActive())) {
return;
}
McpSyncClient client = createClientDirectly(config);
if (client != null) {
clientCache.put(key, client);
log.info("增量添加 MCP 客户端: id={}, name={}", configId, config.getName());
}
}
/**
* 更新配置时关闭旧客户端重建新客户端
* 仅对更新的单个配置重建连接不影响其他已有连接
*
* @param configId 更新的配置ID
*/
public void rebuildClient(Long configId) {
String key = configId.toString();
// 先移除旧客户端
removeClient(configId);
McpServerConfig config = mcpServerConfigService.getConfigById(configId);
if (config == null) {
unavailableConfigs.add(key);
return;
}
if (!Boolean.TRUE.equals(config.getIsActive())) {
unavailableConfigs.add(key);
return;
}
unavailableConfigs.remove(key);
McpSyncClient client = createClientDirectly(config);
if (client != null) {
clientCache.put(key, client);
log.info("增量重建 MCP 客户端: id={}, name={}", configId, config.getName());
}
}
/**
* 禁用配置时关闭并移除客户端标记为不可用
*
* @param configId 禁用的配置ID
*/
public void disableClient(Long configId) {
removeClient(configId);
unavailableConfigs.add(configId.toString());
}
/**
* 移除指定配置的客户端缓存
* 配置删除或禁用时调用
*
* @param configId 配置ID
*/
public void removeClient(Long configId) {
String key = configId.toString();
McpSyncClient removed = clientCache.remove(key);
if (removed != null) {
try {
removed.close();
} catch (Exception e) {
log.error("关闭被移除的 MCP 客户端失败: configId={}, error={}", configId, e.getMessage());
}
log.info("已移除并关闭 MCP 客户端缓存: configId={}", configId);
}
}
/**
* 测试指定配置的 MCP Server 连接
* 创建临时客户端 -> initialize -> listTools -> close不影响正常缓存
*
* @param configId 配置ID
* @return 测试结果 Mapsuccess / latencyMs / toolsCount / errorMessage
*/
public Map<String, Object> testConnection(Long configId) {
McpServerConfig config = mcpServerConfigService.getConfigById(configId);
if (config == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("latencyMs", 0);
result.put("toolsCount", 0);
result.put("errorMessage", "配置不存在: id=" + configId);
return result;
}
McpSyncClient tempClient = null;
long startTime = System.currentTimeMillis();
try {
// 创建临时客户端不加入缓存复用 createClientDirectly 的传输层构建逻辑
tempClient = createClientDirectly(config);
if (tempClient == null) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("latencyMs", System.currentTimeMillis() - startTime);
result.put("toolsCount", 0);
result.put("errorMessage", "客户端创建失败,请检查配置");
return result;
}
// 获取工具列表
McpSchema.ListToolsResult listToolsResult = tempClient.listTools();
int toolsCount = (listToolsResult != null && listToolsResult.tools() != null)
? listToolsResult.tools().size() : 0;
long latencyMs = System.currentTimeMillis() - startTime;
log.info("MCP 连接测试成功: id={}, name={}, latencyMs={}, toolsCount={}",
configId, config.getName(), latencyMs, toolsCount);
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
result.put("latencyMs", latencyMs);
result.put("toolsCount", toolsCount);
result.put("errorMessage", null);
return result;
} catch (Exception e) {
long latencyMs = System.currentTimeMillis() - startTime;
log.warn("MCP 连接测试失败: id={}, name={}, latencyMs={}, error={}",
configId, config.getName(), latencyMs, e.getMessage());
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("latencyMs", latencyMs);
result.put("toolsCount", 0);
result.put("errorMessage", e.getMessage());
return result;
} finally {
// 确保关闭临时客户端
if (tempClient != null) {
try {
tempClient.close();
} catch (Exception e) {
log.debug("关闭临时测试客户端失败: configId={}, error={}", configId, e.getMessage());
}
}
}
}
/**
* 获取当前缓存的客户端数量
*
* @return 缓存数量
*/
public int getCacheSize() {
return clientCache.size();
}
}

24
src/main/java/com/wok/supportbot/config/McpRequestInterceptor.java

@ -0,0 +1,24 @@
package com.wok.supportbot.config;
import com.wok.supportbot.mcp.McpToolCallback;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* MCP 请求拦截器
* 在每个 HTTP 请求开始时重置 McpToolCallback ThreadLocal 调用轮次计数器
* 防止 Tomcat 线程池复用导致计数器累积避免内存泄漏和限流失效
*/
@Component
@Slf4j
public class McpRequestInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
McpToolCallback.resetCallRounds();
return true;
}
}

24
src/main/java/com/wok/supportbot/config/McpWebMvcConfig.java

@ -0,0 +1,24 @@
package com.wok.supportbot.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* MCP 拦截器注册配置
* McpRequestInterceptor 注册到所有请求路径
* 确保每次请求开始时重置 MCP 工具调用轮次计数器
*/
@Configuration
public class McpWebMvcConfig implements WebMvcConfigurer {
@Autowired
private McpRequestInterceptor mcpRequestInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(mcpRequestInterceptor)
.addPathPatterns("/**");
}
}

17
src/main/java/com/wok/supportbot/controller/AiController.java

@ -61,7 +61,8 @@ public class AiController {
AccountRoleContext context = resolveAccountRole(accountId, roleId); AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context); bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt));
String result = assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools());
return result;
} }
/** /**
@ -77,7 +78,7 @@ public class AiController {
AccountRoleContext context = resolveAccountRole(accountId, roleId); AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context); bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt));
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools());
} }
/** /**
@ -93,7 +94,7 @@ public class AiController {
AccountRoleContext context = resolveAccountRole(accountId, roleId); AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context); bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt))
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools())
.map(chunk -> ServerSentEvent.<String>builder() .map(chunk -> ServerSentEvent.<String>builder()
.data(chunk) .data(chunk)
.build()); .build());
@ -115,7 +116,7 @@ public class AiController {
// 创建一个超时时间较长的 SseEmitter // 创建一个超时时间较长的 SseEmitter
SseEmitter sseEmitter = new SseEmitter(180000L); // 3 分钟超时 SseEmitter sseEmitter = new SseEmitter(180000L); // 3 分钟超时
// 获取 Flux 响应式数据流并且直接通过订阅推送给 SseEmitter // 获取 Flux 响应式数据流并且直接通过订阅推送给 SseEmitter
assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt))
assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools())
.subscribe(chunk -> { .subscribe(chunk -> {
try { try {
sseEmitter.send(chunk); sseEmitter.send(chunk);
@ -147,12 +148,12 @@ public class AiController {
String sys = resolveSystemPrompt(scope, systemPrompt); String sys = resolveSystemPrompt(scope, systemPrompt);
// 严格隔离未授权任何知识库的角色退回普通对话绝不检索 KB // 严格隔离未授权任何知识库的角色退回普通对话绝不检索 KB
if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) { if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return assistantApp.doChat(message, chatId, sys);
return assistantApp.doChat(message, chatId, sys, scope.allowedMcpTools());
} }
try { try {
return assistantApp.doChatWithRagStrategy( return assistantApp.doChatWithRagStrategy(
message, chatId, normalizeStrategy(rewriteStrategy), message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
resolveCategoryIds(scope, categoryId, categoryIds), sys, scope.allowedMcpTools());
} catch (Exception e) { } catch (Exception e) {
log.error("RAG 对话失败 [strategy={}, chatId={}]: {}", rewriteStrategy, chatId, e.getMessage(), e); log.error("RAG 对话失败 [strategy={}, chatId={}]: {}", rewriteStrategy, chatId, e.getMessage(), e);
return "抱歉,知识库检索出现异常,请稍后重试。"; return "抱歉,知识库检索出现异常,请稍后重试。";
@ -179,11 +180,11 @@ public class AiController {
String sys = resolveSystemPrompt(scope, systemPrompt); String sys = resolveSystemPrompt(scope, systemPrompt);
// 严格隔离未授权任何知识库的角色退回普通流式对话绝不检索 KB // 严格隔离未授权任何知识库的角色退回普通流式对话绝不检索 KB
if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) { if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return assistantApp.doChatByStream(message, chatId, sys);
return assistantApp.doChatByStream(message, chatId, sys, scope.allowedMcpTools());
} }
return assistantApp.doChatWithRagStrategyByStream( return assistantApp.doChatWithRagStrategyByStream(
message, chatId, normalizeStrategy(rewriteStrategy), message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
resolveCategoryIds(scope, categoryId, categoryIds), sys, scope.allowedMcpTools());
} }
/** /**

59
src/main/java/com/wok/supportbot/controller/ApiKeyController.java

@ -13,11 +13,13 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Date; import java.util.Date;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
/** /**
* API Key 管理接口 * API Key 管理接口
* 提供 API Key 的创建吊销启用删除分页查询等操作
* 提供 API Key 的创建吊销启用删除分页查询角色绑定等操作
*/ */
@Slf4j @Slf4j
@RestController @RestController
@ -61,7 +63,7 @@ public class ApiKeyController {
/** /**
* 创建 API Key * 创建 API Key
* *
* @param body 请求体包含 name, description, rateLimit, maxCalls, expireTime
* @param body 请求体包含 name, description, rateLimit, maxCalls, expireTime, roleIds(可选)
*/ */
@PostMapping @PostMapping
@PreAuthorize("hasRole('admin')") @PreAuthorize("hasRole('admin')")
@ -102,6 +104,13 @@ public class ApiKeyController {
Long userId = (currentUser != null) ? currentUser.getId() : null; Long userId = (currentUser != null) ? currentUser.getId() : null;
ApiKey apiKey = apiKeyService.generateKey(userId, name, description, rateLimit, maxCalls, expireTime); ApiKey apiKey = apiKeyService.generateKey(userId, name, description, rateLimit, maxCalls, expireTime);
// 绑定角色可选
if (body.containsKey("roleIds")) {
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
apiKeyService.updateKeyRoles(apiKey.getId(), roleIds);
}
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "创建成功", "message", "创建成功",
@ -198,4 +207,50 @@ public class ApiKeyController {
)); ));
} }
} }
/**
* 更新 API Key 绑定的客服角色
* 空数组表示不限制返回所有启用角色
*/
@PutMapping("/{id}/roles")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateRoles(
@PathVariable Long id,
@RequestBody Map<String, Object> body) {
try {
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
apiKeyService.updateKeyRoles(id, roleIds);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "角色绑定更新成功"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
log.error("更新 API Key 角色绑定失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "更新失败:" + e.getMessage()
));
}
}
/**
* 解析请求体中的 roleIds 列表
*/
@SuppressWarnings("unchecked")
private List<Long> parseRoleIds(Object raw) {
if (!(raw instanceof List<?> list)) {
return List.of();
}
return list.stream()
.filter(Objects::nonNull)
.map(item -> item instanceof Number n ? n.longValue() : Long.parseLong(item.toString()))
.filter(id -> id > 0)
.distinct()
.toList();
}
} }

127
src/main/java/com/wok/supportbot/controller/AuthController.java

@ -0,0 +1,127 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.entity.ApiKey;
import com.wok.supportbot.security.SdkJwtTokenProvider;
import com.wok.supportbot.service.ApiKeyService;
import com.wok.supportbot.service.CustomerServiceRoleService;
import com.wok.supportbot.service.CustomerServiceRoleService.RoleBrief;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* SDK 认证控制器
* 提供 Token 换取接口客户后端用 API Key 换取 SDK JWT Token + 角色列表
* <p>
* 路径在 /open-api/ 请求首先由 ApiKeyAuthFilter 校验 X-API-Key
* 校验通过后 ApiKey 信息存入 request.attribute("apiKey")本控制器直接使用
* <p>
* 角色来源: 优先使用 API Key 绑定的角色列表role_ids
* 未绑定时返回所有启用角色向后兼容
*/
@Slf4j
@RestController("sdkAuthController")
@RequestMapping("/open-api/auth")
public class AuthController {
private final CustomerServiceRoleService roleService;
private final SdkJwtTokenProvider sdkJwtTokenProvider;
private final ApiKeyService apiKeyService;
public AuthController(CustomerServiceRoleService roleService,
SdkJwtTokenProvider sdkJwtTokenProvider,
ApiKeyService apiKeyService) {
this.roleService = roleService;
this.sdkJwtTokenProvider = sdkJwtTokenProvider;
this.apiKeyService = apiKeyService;
}
/**
* Token 换取接口
* <p>
* 前置条件: ApiKeyAuthFilter 已校验 X-API-Key存入 request.attribute("apiKey")
* <p>
* 返回: SDK JWT Token过期时间可用角色列表
*
* @param request HTTP 请求含已鉴权的 API Key 信息
* @param body 请求体可选支持自定义 ttl 秒数
* @return {token, expiresIn, roles[]}
*/
@PostMapping("/token")
public ResponseEntity<?> getToken(HttpServletRequest request,
@RequestBody(required = false) TokenRequest body) {
// 1. 获取已鉴权的 API Key ApiKeyAuthFilter 注入
ApiKey apiKey = (ApiKey) request.getAttribute("apiKey");
if (apiKey == null) {
return ResponseEntity.status(401).body(Map.of(
"success", false,
"message", "API Key 鉴权失败"
));
}
// 2. 读取 API Key 绑定的角色列表
List<Long> boundRoleIds = apiKeyService.parseRoleIds(apiKey);
// 3. 查询角色有绑定则用绑定列表否则返回所有启用角色向后兼容
List<RoleBrief> roles;
if (!boundRoleIds.isEmpty()) {
roles = roleService.listRolesByIds(boundRoleIds);
} else {
roles = roleService.listEnabledRolesBrief();
}
if (roles.isEmpty()) {
return ResponseEntity.status(403).body(Map.of(
"success", false,
"message", "无可用客服角色"
));
}
List<Long> roleIds = roles.stream()
.map(RoleBrief::id)
.collect(Collectors.toList());
// 4. 计算过期时间默认 2 小时钳制到 [5min, 24h]
long ttlMs = 7200000L;
if (body != null && body.ttl() != null) {
ttlMs = Math.max(body.ttl() * 1000L, 300000L);
ttlMs = Math.min(ttlMs, 86400000L);
}
// 5. 签发 SDK JWTsubject = apiKeyId
String token = sdkJwtTokenProvider.generateToken(
apiKey.getId().toString(),
SdkJwtTokenProvider.maskApiKey(apiKey.getKeyValue()),
roleIds,
ttlMs
);
// 6. 返回 Token + 角色列表
log.info("SDK Token 签发成功: apiKeyId={}, roleCount={}", apiKey.getId(), roles.size());
return ResponseEntity.ok(Map.of(
"success", true,
"token", token,
"expiresIn", ttlMs / 1000,
"roles", roles.stream().map(r -> Map.of(
"id", r.id().toString(),
"key", r.roleKey(),
"name", r.name()
)).collect(Collectors.toList())
));
}
/**
* 请求体 DTO
*
* @param ttl 期望的 Token 有效期会被钳制到 [300, 86400]
*/
public record TokenRequest(Long ttl) {
}
}

36
src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java

@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@ -27,7 +28,7 @@ public class CustomerServiceRoleController {
public ResponseEntity<Map<String, Object>> listRoles() { public ResponseEntity<Map<String, Object>> listRoles() {
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"data", customerServiceRoleService.listRoles()
"data", customerServiceRoleService.listRoles(false)
)); ));
} }
@ -49,7 +50,7 @@ public class CustomerServiceRoleController {
try { try {
customerServiceRoleService.createRole( customerServiceRoleService.createRole(
str(body.get("roleKey")), str(body.get("name")), str(body.get("description")), str(body.get("roleKey")), str(body.get("name")), str(body.get("description")),
str(body.get("prompt")), intOrNull(body.get("sortOrder")), boolOrNull(body.get("enabled")));
str(body.get("prompt")), boolOrNull(body.get("enabled")));
return ResponseEntity.ok(Map.of("success", true, "message", "角色创建成功")); return ResponseEntity.ok(Map.of("success", true, "message", "角色创建成功"));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "创建角色失败:" + e.getMessage())); return ResponseEntity.status(500).body(Map.of("success", false, "message", "创建角色失败:" + e.getMessage()));
@ -62,7 +63,7 @@ public class CustomerServiceRoleController {
try { try {
customerServiceRoleService.updateRole(roleId, customerServiceRoleService.updateRole(roleId,
str(body.get("name")), str(body.get("description")), str(body.get("prompt")), str(body.get("name")), str(body.get("description")), str(body.get("prompt")),
intOrNull(body.get("sortOrder")), boolOrNull(body.get("enabled")));
boolOrNull(body.get("enabled")));
return ResponseEntity.ok(Map.of("success", true, "message", "角色更新成功")); return ResponseEntity.ok(Map.of("success", true, "message", "角色更新成功"));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "更新角色失败:" + e.getMessage())); return ResponseEntity.status(500).body(Map.of("success", false, "message", "更新角色失败:" + e.getMessage()));
@ -93,6 +94,35 @@ public class CustomerServiceRoleController {
)); ));
} }
/**
* 更新角色的 MCP 工具权限
* body: { "allowedMcpTools": ["*"] } { "allowedMcpTools": ["mcp_get_weather", "mcp_query_db"] }
*/
@SuppressWarnings("unchecked")
@PutMapping("/role/{id}/mcp-tools")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateRoleMcpTools(
@PathVariable("id") Long roleId,
@RequestBody Map<String, Object> body) {
try {
Object raw = body.get("allowedMcpTools");
List<String> allowedMcpTools;
if (raw instanceof List<?> list) {
allowedMcpTools = list.stream()
.filter(Objects::nonNull)
.map(Objects::toString)
.distinct()
.toList();
} else {
allowedMcpTools = List.of();
}
customerServiceRoleService.updateRoleMcpTools(roleId, allowedMcpTools);
return ResponseEntity.ok(Map.of("success", true, "message", "MCP 工具权限更新成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "更新失败:" + e.getMessage()));
}
}
private List<Long> parseCategoryIds(Object rawCategoryIds) { private List<Long> parseCategoryIds(Object rawCategoryIds) {
if (!(rawCategoryIds instanceof List<?> list)) { if (!(rawCategoryIds instanceof List<?> list)) {
return List.of(); return List.of();

36
src/main/java/com/wok/supportbot/controller/DocumentController.java

@ -250,6 +250,42 @@ public class DocumentController {
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/**
* 下载文档的原始文件
*
* @param id 文档ID
*/
@GetMapping("/document/download/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<?> downloadDocument(@PathVariable("id") Long id) {
try {
var entry = documentService.getDocumentFile(id);
java.io.File file = entry.getKey();
String originalFilename = entry.getValue();
// 使用 RFC 5987 编码中文文件名兼容所有浏览器
String encodedFilename = java.net.URLEncoder.encode(
originalFilename, java.nio.charset.StandardCharsets.UTF_8)
.replace("+", "%20");
// 设置响应头
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
headers.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
headers.set(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename);
headers.setContentLength(file.length());
return ResponseEntity.ok()
.headers(headers)
.body(new org.springframework.core.io.FileSystemResource(file));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "下载失败:" + e.getMessage()
));
}
}
/** /**
* 查询文档列表分页 + 过滤 + 关键词搜索 + 标签筛选 * 查询文档列表分页 + 过滤 + 关键词搜索 + 标签筛选
* *

348
src/main/java/com/wok/supportbot/controller/McpServerConfigController.java

@ -0,0 +1,348 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.config.McpClientManager;
import com.wok.supportbot.entity.McpServerConfig;
import com.wok.supportbot.service.McpServerConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* MCP Server 配置管理控制器
* 提供 MCP Server 配置的增删改查启用/禁用连接测试缓存刷新等 API
*/
@RestController
@Slf4j
public class McpServerConfigController {
@Autowired
private McpServerConfigService mcpServerConfigService;
@Autowired
private McpClientManager mcpClientManager;
// ==================== 分页列表 ====================
/**
* 获取 MCP Server 配置列表分页
*
* @param page 页码默认1
* @param size 每页大小默认10
* @return 分页配置列表
*/
@GetMapping("/mcp-server/list")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listConfigs(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
try {
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size);
Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
data.put("total", result.get("total"));
data.put("page", result.get("page"));
data.put("size", result.get("size"));
data.put("pages", result.get("pages"));
return ResponseEntity.ok(data);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
// ==================== 单条详情 ====================
/**
* 获取单条 MCP Server 配置详情
*
* @param id 配置ID
* @return 配置详情
*/
@GetMapping("/mcp-server/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> getConfigDetail(@PathVariable("id") Long id) {
try {
Map<String, Object> config = mcpServerConfigService.getConfigDetail(id);
if (config == null) {
return ResponseEntity.status(404).body(Map.of(
"success", false,
"message", "配置不存在"
));
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", config
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "查询失败:" + e.getMessage()
));
}
}
// ==================== 新建配置 ====================
/**
* 新建 MCP Server 配置
*
* @param config 配置对象
* @return 创建结果
*/
@PostMapping("/mcp-server")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> createConfig(@RequestBody McpServerConfig config) {
try {
Map<String, Object> created = mcpServerConfigService.createConfig(config);
// 增量操作仅为新建配置创建客户端不影响其他已有连接
Object idObj = created.get("id");
if (idObj != null) {
try {
mcpClientManager.addClient(Long.parseLong(idObj.toString()));
} catch (Exception e) {
log.warn("新建配置后创建客户端失败(不影响配置保存): {}", e.getMessage());
}
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", created,
"message", "配置创建成功"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "创建失败:" + e.getMessage()
));
}
}
// ==================== 更新配置 ====================
/**
* 更新 MCP Server 配置
*
* @param id 配置ID
* @param config 更新内容
* @return 更新结果
*/
@PutMapping("/mcp-server/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateConfig(
@PathVariable("id") Long id,
@RequestBody McpServerConfig config) {
try {
Map<String, Object> updated = mcpServerConfigService.updateConfig(id, config);
// 增量操作仅重建更新的配置客户端不影响其他已有连接
mcpClientManager.rebuildClient(id);
return ResponseEntity.ok(Map.of(
"success", true,
"data", updated,
"message", "配置更新成功"
));
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "更新失败:" + e.getMessage()
));
}
}
// ==================== 删除配置 ====================
/**
* 删除 MCP Server 配置逻辑删除
*
* @param id 配置ID
* @return 删除结果
*/
@DeleteMapping("/mcp-server/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> deleteConfig(@PathVariable("id") Long id) {
try {
mcpServerConfigService.deleteConfig(id);
// 增量操作仅移除被删除的配置客户端不影响其他已有连接
mcpClientManager.removeClient(id);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "配置删除成功"
));
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "删除失败:" + e.getMessage()
));
}
}
// ==================== 启用/禁用 ====================
/**
* 切换配置启用/禁用状态
*
* @param id 配置ID
* @param body 请求体{active: true/false}
* @return 操作结果
*/
@PutMapping("/mcp-server/{id}/toggle")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> toggleActive(
@PathVariable("id") Long id,
@RequestBody Map<String, Boolean> body) {
try {
Boolean active = body.get("active");
if (active == null) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "active 参数不能为空"
));
}
mcpServerConfigService.toggleActive(id, active);
// 增量操作启用时重建客户端禁用时移除客户端
if (active) {
mcpClientManager.rebuildClient(id);
} else {
mcpClientManager.disableClient(id);
}
return ResponseEntity.ok(Map.of(
"success", true,
"message", active ? "配置已启用" : "配置已禁用"
));
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "操作失败:" + e.getMessage()
));
}
}
// ==================== 测试连接 ====================
/**
* 测试 MCP Server 连接
* 创建临时客户端进行握手 + 工具列表查询不影响正常缓存
*
* @param id 配置ID
* @return 测试结果success / latencyMs / toolsCount / errorMessage
*/
@PostMapping("/mcp-server/{id}/test")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> testConnection(@PathVariable("id") Long id) {
try {
Map<String, Object> result = mcpClientManager.testConnection(id);
return ResponseEntity.ok(result);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "测试失败:" + e.getMessage()
));
}
}
// ==================== 刷新连接 ====================
/**
* 刷新所有 MCP 客户端连接
* 清空现有缓存并重新建立所有已启用配置的客户端连接
*
* @return 刷新结果
*/
@PostMapping("/mcp-server/refresh")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> refreshAll() {
try {
mcpClientManager.refreshAll();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "MCP 客户端连接已刷新"
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "刷新失败:" + e.getMessage()
));
}
}
// ==================== 健康检查 ====================
/**
* 获取所有 MCP Server 的健康状态
* 返回各配置的在线/离线状态延迟最后检查时间等信息
*
* @return 所有 MCP Server 的健康状态
*/
@GetMapping("/mcp-server/health")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> getAllHealthStatus() {
try {
Map<String, McpClientManager.HealthStatus> healthMap = mcpClientManager.getAllHealthStatus();
Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true);
data.put("data", healthMap);
return ResponseEntity.ok(data);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取健康状态失败:" + e.getMessage()
));
}
}
/**
* 手动触发单个 MCP Server 的健康检查
* 对指定配置的客户端执行 listTools 探测返回最新健康状态
*
* @param id 配置ID
* @return 该配置的健康状态
*/
@PostMapping("/mcp-server/{id}/health-check")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> checkSingleHealth(@PathVariable("id") Long id) {
try {
McpClientManager.HealthStatus status = mcpClientManager.checkHealthForConfig(id);
return ResponseEntity.ok(Map.of(
"success", true,
"data", Map.of(
"configId", id.toString(),
"status", status.status(),
"latencyMs", status.latencyMs(),
"lastCheckTime", status.lastCheckTime().toString(),
"errorMessage", status.errorMessage() != null ? status.errorMessage() : ""
)
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "健康检查失败:" + e.getMessage()
));
}
}
}

118
src/main/java/com/wok/supportbot/converter/MessageConverter.java

@ -3,42 +3,136 @@ package com.wok.supportbot.converter;
import com.wok.supportbot.entity.ChatMessage; import com.wok.supportbot.entity.ChatMessage;
import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.messages.*;
import java.util.List;
import java.util.Map;
import java.util.*;
/** /**
* @Classname MessageConverter
* @Description
* @Version 1.0.0
* @Date 2025/06/28 13:30
* @Author lyx
* 消息转换器Message <-> ChatMessage
* 支持 USER / ASSISTANT / SYSTEM / TOOL 四种消息类型
* 其中 ASSISTANT toolCalls TOOL responses 通过 metadata JSONB 字段持久化
*/ */
public class MessageConverter { public class MessageConverter {
/** metadata 中存储 toolCalls 的内部键名 */
private static final String META_KEY_TOOL_CALLS = "_toolCalls";
/** metadata 中存储 toolResponses 的内部键名 */
private static final String META_KEY_TOOL_RESPONSES = "_toolResponses";
/** /**
* Message 转换为 ChatMessage
* Spring AI Message 转换为数据库实体 ChatMessage
* 对于 ASSISTANT 消息如果包含 toolCalls 则序列化到 metadata
* 对于 TOOL 消息 responses 序列化到 metadata
*/ */
public static ChatMessage toChatMessage(Message message, String conversationId) { public static ChatMessage toChatMessage(Message message, String conversationId) {
Map<String, Object> metadata = message.getMetadata() != null
? new HashMap<>(message.getMetadata())
: new HashMap<>();
// 处理 AssistantMessage toolCalls
if (message instanceof AssistantMessage assistant && assistant.hasToolCalls()) {
List<Map<String, String>> toolCallsJson = new ArrayList<>();
for (AssistantMessage.ToolCall tc : assistant.getToolCalls()) {
Map<String, String> tcMap = new LinkedHashMap<>();
tcMap.put("id", tc.id());
tcMap.put("type", tc.type());
tcMap.put("name", tc.name());
tcMap.put("arguments", tc.arguments());
toolCallsJson.add(tcMap);
}
metadata.put(META_KEY_TOOL_CALLS, toolCallsJson);
}
// 处理 ToolResponseMessage responses
if (message instanceof ToolResponseMessage trm) {
List<Map<String, String>> responsesJson = new ArrayList<>();
for (ToolResponseMessage.ToolResponse tr : trm.getResponses()) {
Map<String, String> trMap = new LinkedHashMap<>();
trMap.put("id", tr.id());
trMap.put("name", tr.name());
trMap.put("responseData", tr.responseData());
responsesJson.add(trMap);
}
metadata.put(META_KEY_TOOL_RESPONSES, responsesJson);
}
return ChatMessage.builder() return ChatMessage.builder()
.conversationId(conversationId) .conversationId(conversationId)
.messageType(message.getMessageType()) .messageType(message.getMessageType())
.content(message.getText()) .content(message.getText())
.metadata(message.getMetadata())
.metadata(metadata)
.build(); .build();
} }
/** /**
* ChatMessage 转换为 Message
* 将数据库实体 ChatMessage 转换为 Spring AI Message
* 根据 messageType 分发到具体子类反序列化 toolCalls / responses
*/ */
@SuppressWarnings("unchecked")
public static Message toMessage(ChatMessage chatMessage) { public static Message toMessage(ChatMessage chatMessage) {
MessageType messageType = chatMessage.getMessageType(); MessageType messageType = chatMessage.getMessageType();
String text = chatMessage.getContent(); String text = chatMessage.getContent();
Map<String, Object> metadata = chatMessage.getMetadata(); Map<String, Object> metadata = chatMessage.getMetadata();
// 构建不含内部键的干净 metadata Message 对象使用
Map<String, Object> cleanMetadata = metadata != null
? new HashMap<>(metadata)
: new HashMap<>();
return switch (messageType) { return switch (messageType) {
case USER -> new UserMessage(text); case USER -> new UserMessage(text);
case ASSISTANT -> new AssistantMessage(text, metadata);
case ASSISTANT -> {
List<AssistantMessage.ToolCall> toolCalls = extractToolCalls(cleanMetadata);
yield new AssistantMessage(text, cleanMetadata, toolCalls);
}
case SYSTEM -> new SystemMessage(text); case SYSTEM -> new SystemMessage(text);
case TOOL -> new ToolResponseMessage(List.of(), metadata);
case TOOL -> {
List<ToolResponseMessage.ToolResponse> responses = extractToolResponses(cleanMetadata);
yield new ToolResponseMessage(responses, cleanMetadata);
}
}; };
} }
/**
* metadata 中提取并移除 _toolCalls 反序列化为 ToolCall 列表
*/
@SuppressWarnings("unchecked")
private static List<AssistantMessage.ToolCall> extractToolCalls(Map<String, Object> metadata) {
Object raw = metadata.remove(META_KEY_TOOL_CALLS);
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return List.of();
}
List<AssistantMessage.ToolCall> result = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map) {
Map<String, Object> map = (Map<String, Object>) item;
String id = String.valueOf(map.getOrDefault("id", ""));
String type = String.valueOf(map.getOrDefault("type", "function"));
String name = String.valueOf(map.getOrDefault("name", ""));
String arguments = String.valueOf(map.getOrDefault("arguments", "{}"));
result.add(new AssistantMessage.ToolCall(id, type, name, arguments));
}
}
return result;
}
/**
* metadata 中提取并移除 _toolResponses 反序列化为 ToolResponse 列表
*/
@SuppressWarnings("unchecked")
private static List<ToolResponseMessage.ToolResponse> extractToolResponses(Map<String, Object> metadata) {
Object raw = metadata.remove(META_KEY_TOOL_RESPONSES);
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return List.of();
}
List<ToolResponseMessage.ToolResponse> result = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map) {
Map<String, Object> map = (Map<String, Object>) item;
String id = String.valueOf(map.getOrDefault("id", ""));
String name = String.valueOf(map.getOrDefault("name", ""));
String responseData = String.valueOf(map.getOrDefault("responseData", ""));
result.add(new ToolResponseMessage.ToolResponse(id, name, responseData));
}
}
return result;
}
} }

12
src/main/java/com/wok/supportbot/dao/McpServerConfigMapper.java

@ -0,0 +1,12 @@
package com.wok.supportbot.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wok.supportbot.entity.McpServerConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* MCP Server 配置 Mapper - 继承 MyBatis Plus BaseMapper自带 CRUD
*/
@Mapper
public interface McpServerConfigMapper extends BaseMapper<McpServerConfig> {
}

35
src/main/java/com/wok/supportbot/document/extract/JsonDocumentLoader.java

@ -16,6 +16,37 @@ import java.util.List;
@Slf4j @Slf4j
public class JsonDocumentLoader { public class JsonDocumentLoader {
// ==================== 从已保存的 File 加载 ====================
/**
* 基本读取方式从文件
*/
public List<Document> loadBasicJsonFromFile(File file) {
Resource resource = new FileSystemResource(file);
JsonReader reader = new JsonReader(resource);
return reader.get();
}
/**
* 指定字段读取方式从文件
*/
public List<Document> loadJsonByFieldsFromFile(File file, String... fields) {
Resource resource = new FileSystemResource(file);
JsonReader reader = new JsonReader(resource, fields);
return reader.get();
}
/**
* 使用 JSON Pointer 提取数组路径内容从文件
*/
public List<Document> loadJsonByPointerFromFile(File file, String pointer) {
Resource resource = new FileSystemResource(file);
JsonReader reader = new JsonReader(resource);
return reader.get(pointer);
}
// ==================== MultipartFile 加载兼容旧接口 ====================
/** /**
* 基本读取方式 * 基本读取方式
*/ */
@ -26,7 +57,7 @@ public class JsonDocumentLoader {
} }
/** /**
* 指定字段读取方式例如 descriptionfeatures 字段
* 指定字段读取方式
*/ */
public List<Document> loadJsonByFields(MultipartFile file, String... fields) { public List<Document> loadJsonByFields(MultipartFile file, String... fields) {
Resource resource = toResource(file); Resource resource = toResource(file);
@ -35,7 +66,7 @@ public class JsonDocumentLoader {
} }
/** /**
* 使用 JSON Pointer 提取数组路径内容 /items
* 使用 JSON Pointer 提取数组路径内容
*/ */
public List<Document> loadJsonByPointer(MultipartFile file, String pointer) { public List<Document> loadJsonByPointer(MultipartFile file, String pointer) {
Resource resource = toResource(file); Resource resource = toResource(file);

37
src/main/java/com/wok/supportbot/document/extract/MarkdownDocumentLoader.java

@ -20,14 +20,38 @@ import java.util.List;
@Slf4j @Slf4j
public class MarkdownDocumentLoader { public class MarkdownDocumentLoader {
public List<Document> loadMarkdownFromFile(MultipartFile file) {
/**
* 从已保存的文件加载 Markdown
*/
public List<Document> loadMarkdownFromFile(File file) {
try {
Resource resource = new FileSystemResource(file);
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
.withHorizontalRuleCreateDocument(true)
.withIncludeCodeBlock(false)
.withIncludeBlockquote(false)
.withAdditionalMetadata("filename", file.getName())
.build();
MarkdownDocumentReader reader = new MarkdownDocumentReader(resource, config);
return reader.get();
} catch (Exception e) {
log.error("Markdown 文件解析失败", e);
throw new RuntimeException("Markdown 文件解析失败", e);
}
}
/**
* MultipartFile 加载 Markdown兼容旧接口
*/
public List<Document> loadMarkdownFromMultipartFile(MultipartFile file) {
try { try {
// MultipartFile 保存为临时文件
File temp = File.createTempFile("upload-", file.getOriginalFilename()); File temp = File.createTempFile("upload-", file.getOriginalFilename());
file.transferTo(temp); file.transferTo(temp);
Resource resource = new FileSystemResource(temp); Resource resource = new FileSystemResource(temp);
// 配置文档解析
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder() MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
.withHorizontalRuleCreateDocument(true) .withHorizontalRuleCreateDocument(true)
.withIncludeCodeBlock(false) .withIncludeCodeBlock(false)
@ -35,9 +59,12 @@ public class MarkdownDocumentLoader {
.withAdditionalMetadata("filename", file.getOriginalFilename()) .withAdditionalMetadata("filename", file.getOriginalFilename())
.build(); .build();
// 读取文档内容
MarkdownDocumentReader reader = new MarkdownDocumentReader(resource, config); MarkdownDocumentReader reader = new MarkdownDocumentReader(resource, config);
return reader.get();
List<Document> docs = reader.get();
// 清理临时文件
temp.delete();
return docs;
} catch (IOException e) { } catch (IOException e) {
log.error("Markdown 文件解析失败", e); log.error("Markdown 文件解析失败", e);

41
src/main/java/com/wok/supportbot/document/extract/TikaDocumentReader.java

@ -19,15 +19,34 @@ import java.util.UUID;
@Slf4j @Slf4j
public class TikaDocumentReader { public class TikaDocumentReader {
public List<Document> read(MultipartFile file) {
/**
* 从已保存的文件解析内容
*/
public List<Document> readFromFile(File file) {
try { try {
// MultipartFile Resource
File tempFile = File.createTempFile("upload-", file.getOriginalFilename());
file.transferTo(tempFile);
Resource resource = new FileSystemResource(tempFile);
Tika tika = new Tika();
String text = tika.parseToString(new java.io.FileInputStream(file));
Document doc = Document.builder()
.id(UUID.randomUUID().toString())
.text(text)
.build();
return Collections.singletonList(doc);
} catch (IOException | TikaException e) {
log.error("Tika 文件解析失败", e);
throw new RuntimeException("Tika 文件解析失败", e);
}
}
/**
* MultipartFile 解析内容兼容旧接口
*/
public List<Document> read(MultipartFile file) {
try {
Tika tika = new Tika(); Tika tika = new Tika();
String text = tika.parseToString(resource.getInputStream());
String text = tika.parseToString(file.getInputStream());
Document doc = Document.builder() Document doc = Document.builder()
.id(UUID.randomUUID().toString()) .id(UUID.randomUUID().toString())
@ -41,4 +60,14 @@ public class TikaDocumentReader {
throw new RuntimeException("Tika 文件解析失败", e); throw new RuntimeException("Tika 文件解析失败", e);
} }
} }
/**
* 获取文件扩展名含点号 ".pptx"无扩展名时返回 ".tmp"
*/
private String getExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return ".tmp";
}
return filename.substring(filename.lastIndexOf("."));
}
} }

4
src/main/java/com/wok/supportbot/entity/ApiKey.java

@ -60,6 +60,10 @@ public class ApiKey implements Serializable {
@TableField("current_calls") @TableField("current_calls")
private Long currentCalls; private Long currentCalls;
/** 绑定的客服角色 ID 列表(JSON 字符串,如 "[1,2,3]";空数组 "[]" 表示不限制) */
@TableField("role_ids")
private String roleIds;
/** 过期时间(null 表示永不过期) */ /** 过期时间(null 表示永不过期) */
@TableField("expire_time") @TableField("expire_time")
private Date expireTime; private Date expireTime;

6
src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java

@ -56,6 +56,12 @@ public class KnowledgeDocument implements Serializable {
@TableField("file_size") @TableField("file_size")
private Long fileSize; private Long fileSize;
/**
* 原始文件存储路径相对路径
*/
@TableField("file_path")
private String filePath;
/** /**
* 原文内容(截断预览) * 原文内容(截断预览)
*/ */

104
src/main/java/com/wok/supportbot/entity/McpServerConfig.java

@ -0,0 +1,104 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.wok.supportbot.handler.PostgresJsonTypeHandler;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.Map;
/**
* MCP Server 配置表 - 管理外部 MCP Server 连接支持 stdio sse 两种传输模式
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "mcp_server_config", autoResultMap = true)
public class McpServerConfig implements Serializable {
@Serial
@TableField(exist = false)
private static final long serialVersionUID = 1L;
/**
* 主键ID雪花算法
*/
@TableId(value = "id", type = IdType.ASSIGN_ID)
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
/**
* 配置名称"天气查询服务"
*/
@TableField("name")
private String name;
/**
* 传输类型: stdio标准输入输出/ sseServer-Sent Events
*/
@TableField("transport_type")
private String transportType;
/**
* SSE 模式的 MCP Server URL
*/
@TableField("server_url")
private String serverUrl;
/**
* stdio 模式的启动命令
*/
@TableField("command")
private String command;
/**
* stdio 模式的命令参数多个用逗号分隔
*/
@TableField("args")
private String args;
/**
* 环境变量JSONB键值对形式
*/
@TableField(value = "env_vars", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> envVars;
/**
* 描述说明
*/
@TableField("description")
private String description;
/**
* 是否启用
*/
@TableField("is_active")
private Boolean isActive;
/**
* 创建时间
*/
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
/**
* 更新时间
*/
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/**
* 删除标志 - false:未删除, true:已删除(逻辑删除)
*/
@TableField("is_delete")
@TableLogic
private boolean isDelete;
}

265
src/main/java/com/wok/supportbot/mcp/McpToolCallback.java

@ -0,0 +1,265 @@
package com.wok.supportbot.mcp;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.config.McpClientManager;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* MCP Tool -> Spring AI ToolCallback 适配器
* 将单个 MCP Server 暴露的工具适配为 Spring AI 可调用的 ToolCallback
* <p>
* 工作流程
* 1. ChatClient 在对话时通过 getToolDefinition() 获取工具元数据名称描述参数 Schema
* 2. AI 模型决定调用该工具时Spring AI 框架自动调用 call() 方法
* 3. call() 内部通过 McpClientManager 获取对应的 MCP Client转发调用到远程 MCP Server
* 4. 工具调用事件通过 ThreadLocal 收集器记录 SSE 流式输出使用
*/
@Slf4j
public class McpToolCallback implements ToolCallback {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// ==================== 工具调用事件收集 ====================
/**
* 工具调用事件记录
*/
public record ToolCallEvent(String tool, String input, String result, long latencyMs, boolean error) {}
/**
* 线程级事件收集器在同一请求线程中收集所有工具调用事件
* SSE 流式输出完成后从这里取出事件发送给前端
*/
private static final ThreadLocal<List<ToolCallEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new);
/**
* 获取当前线程收集的所有工具调用事件并清空收集器
*/
public static List<ToolCallEvent> drainEvents() {
List<ToolCallEvent> events = EVENTS.get();
List<ToolCallEvent> copy = new ArrayList<>(events);
events.clear();
return copy;
}
/**
* 重置事件收集器每个新请求开始时调用
*/
public static void resetEvents() {
EVENTS.remove();
}
/**
* MCP Server 配置 ID用于路由调用到正确的 MCP Client
*/
private final Long mcpServerConfigId;
/**
* MCP Client Manager用于获取实际的 MCP Client 实例
*/
private final McpClientManager mcpClientManager;
/**
* 工具名称已加 mcp_ 前缀避免与内置工具冲突
*/
private final String toolName;
/**
* MCP Server 上的原始工具名称无前缀用于发送给 MCP Server 调用
*/
private final String originalToolName;
/**
* 工具描述 AI 模型理解工具用途
*/
private final String toolDescription;
/**
* 工具输入参数的 JSON Schema AI 模型生成合法的调用参数
*/
private final String inputSchema;
/**
* 单次对话请求中最大允许的调用轮次防止无限循环调用
*/
private final int maxCallRounds;
/**
* 线程级调用轮次计数器每个请求线程独立计数
*/
private static final ThreadLocal<Integer> CALL_ROUNDS = ThreadLocal.withInitial(() -> 0);
/**
* 构造 MCP 工具适配器含最大调用轮次限制
*
* @param mcpServerConfigId MCP Server 配置 ID
* @param toolName 工具名称 mcp_ 前缀用于 Spring AI 注册
* @param originalToolName MCP Server 上的原始工具名称无前缀用于实际调用
* @param toolDescription 工具描述
* @param inputSchema 输入参数的 JSON Schema
* @param mcpClientManager MCP 客户端管理器
* @param maxCallRounds 单次请求中最大允许的调用轮次
*/
public McpToolCallback(Long mcpServerConfigId, String toolName, String originalToolName,
String toolDescription, String inputSchema, McpClientManager mcpClientManager,
int maxCallRounds) {
this.mcpServerConfigId = mcpServerConfigId;
this.toolName = toolName;
this.originalToolName = originalToolName;
this.toolDescription = toolDescription;
this.inputSchema = inputSchema;
this.mcpClientManager = mcpClientManager;
this.maxCallRounds = maxCallRounds;
}
/**
* 构造 MCP 工具适配器使用默认最大调用轮次 5
*/
public McpToolCallback(Long mcpServerConfigId, String toolName, String originalToolName,
String toolDescription, String inputSchema, McpClientManager mcpClientManager) {
this(mcpServerConfigId, toolName, originalToolName, toolDescription, inputSchema, mcpClientManager, 5);
}
/**
* 兼容旧构造方式toolName 直接用于调用不加前缀区分
*/
public McpToolCallback(Long mcpServerConfigId, String toolName, String toolDescription,
String inputSchema, McpClientManager mcpClientManager) {
this(mcpServerConfigId, toolName, toolName, toolDescription, inputSchema, mcpClientManager, 5);
}
/**
* 返回工具定义 Spring AI 框架注册到 ChatClient
* AI 模型通过此定义了解工具的名称用途和参数格式
*/
@Override
public ToolDefinition getToolDefinition() {
return DefaultToolDefinition.builder()
.name(toolName)
.description(toolDescription)
.inputSchema(inputSchema)
.build();
}
/**
* 执行工具调用
* AI 模型决定调用此工具时Spring AI 框架自动调用此方法
* 内部通过 McpClientManager 获取对应的 MCP Client 实例
* 将调用转发到远程 MCP Server 并返回结果
* <p>
* 包含调用轮次限制同一请求线程中所有 MCP 工具的累计调用次数
* 超过 maxCallRounds 返回错误 JSON 防止无限循环
*
* @param toolInput JSON 格式的工具输入参数
* @return 工具执行结果JSON 字符串形式
*/
@Override
public String call(String toolInput) {
// 检查调用轮次是否超限
int currentRound = CALL_ROUNDS.get();
if (currentRound >= maxCallRounds) {
log.warn("MCP 工具调用轮次超限: tool={}, currentRound={}, maxRounds={}",
originalToolName, currentRound, maxCallRounds);
return "{\"error\": \"工具调用轮次已达上限 (" + maxCallRounds + " 次),已终止调用以防止无限循环。请优化提示词减少工具调用次数。\"}";
}
CALL_ROUNDS.set(currentRound + 1);
log.info("MCP 工具调用: serverId={}, tool={}, input={}, round={}/{}",
mcpServerConfigId, originalToolName, toolInput, currentRound + 1, maxCallRounds);
long startTime = System.currentTimeMillis();
try {
// 获取 MCP Client 实例
McpSyncClient client = mcpClientManager.getClient(mcpServerConfigId);
if (client == null) {
log.error("MCP 客户端不可用: serverId={}, tool={}", mcpServerConfigId, originalToolName);
return "{\"error\": \"MCP Server (id=" + mcpServerConfigId + ") 客户端未就绪,请检查配置\"}";
}
// 解析工具输入参数JSON 字符串 -> Map
Map<String, Object> arguments = OBJECT_MAPPER.readValue(toolInput, new TypeReference<>() {});
// 构建 MCP 调用请求并执行
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(originalToolName, arguments);
McpSchema.CallToolResult result = client.callTool(request);
long latency = System.currentTimeMillis() - startTime;
log.info("MCP 工具调用完成: tool={}, latency={}ms, isError={}",
originalToolName, latency, result.isError());
// 收集工具调用事件 SSE 流式输出使用
String resultStr = result.content() != null ? String.valueOf(result.content()) : "";
boolean isError = result.isError() != null && result.isError();
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, resultStr, latency, isError));
// 检查是否为错误结果
if (result.isError() != null && result.isError()) {
log.error("MCP 工具返回错误: tool={}, content={}", originalToolName, result.content());
return "{\"error\": \"工具执行返回错误: " +
escapeJson(String.valueOf(result.content())) + "\"}";
}
// MCP Content 列表序列化为 JSON 字符串返回给 AI 模型
// 与官方 SyncMcpToolCallback 保持一致使用 ModelOptionsUtils 序列化
String resultJson = ModelOptionsUtils.toJsonString(result.content());
log.debug("MCP 工具调用结果: tool={}, result={}", originalToolName, resultJson);
return resultJson;
} catch (Exception e) {
long latency = System.currentTimeMillis() - startTime;
log.error("MCP 工具调用失败: tool={}, latency={}ms, error={}", originalToolName, latency, e.getMessage());
// 记录失败事件
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true));
return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}";
}
}
/**
* 转义字符串中的特殊字符避免破坏 JSON 格式
*/
private String escapeJson(String text) {
if (text == null) return "未知错误";
return text.replace("\\", "\\\\").replace("\"", "'").replace("\n", "\\n").replace("\r", "\\r");
}
// ==================== Getter 方法 ====================
public Long getMcpServerConfigId() {
return mcpServerConfigId;
}
public String getToolName() {
return toolName;
}
public String getOriginalToolName() {
return originalToolName;
}
public String getToolDescription() {
return toolDescription;
}
public String getInputSchema() {
return inputSchema;
}
/**
* 重置当前线程的调用轮次计数器
* 应在每次新的对话请求开始时调用确保轮次计数从零开始
*/
public static void resetCallRounds() {
CALL_ROUNDS.remove();
}
}

111
src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java

@ -0,0 +1,111 @@
package com.wok.supportbot.mcp;
import com.wok.supportbot.config.McpClientManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* MCP 工具批量适配器
* McpClientManager 获取所有已启用的 MCP 工具描述
* 批量转换为 Spring AI ToolCallback 列表 ChatClient 注册
* <p>
* 使用方式
* <pre>
* // 注入后在 ChatClient 构建时注册所有 MCP 工具
* ChatClient.builder(chatModel)
* .defaultTools(mcpToolCallbackAdapter.getAllToolCallbacksAsArray())
* .defaultAdvisors(...)
* .build();
* </pre>
*/
@Component
@Slf4j
public class McpToolCallbackAdapter {
private final McpClientManager mcpClientManager;
/**
* 单次对话请求中最大允许的 MCP 工具调用轮次防止无限循环调用
*/
@Value("${mcp.tools.max-call-rounds:5}")
private int maxCallRounds;
public McpToolCallbackAdapter(McpClientManager mcpClientManager) {
this.mcpClientManager = mcpClientManager;
}
/**
* 获取所有 MCP 工具的 ToolCallback 列表
* 遍历所有已启用的 MCP Server将其暴露的工具转换为 ToolCallback
*
* @return ToolCallback 列表可能为空但不会为 null
*/
public List<ToolCallback> getAllToolCallbacks() {
List<ToolCallback> callbacks = new ArrayList<>();
List<Map<String, Object>> tools = mcpClientManager.listAvailableTools();
for (Map<String, Object> toolInfo : tools) {
String configId = (String) toolInfo.get("config_id");
String serverName = (String) toolInfo.get("name");
@SuppressWarnings("unchecked")
List<Map<String, Object>> toolList = (List<Map<String, Object>>) toolInfo.get("tools");
if (toolList == null || toolList.isEmpty()) {
log.debug("MCP Server [{}] 暂无可用工具", serverName);
continue;
}
for (Map<String, Object> tool : toolList) {
String toolName = (String) tool.get("name");
String description = (String) tool.get("description");
String inputSchema = (String) tool.get("inputSchema");
// 工具名加 mcp_ 前缀避免与内置工具冲突
String prefixedName = "mcp_" + toolName;
McpToolCallback callback = new McpToolCallback(
Long.parseLong(configId), prefixedName, toolName, description, inputSchema,
mcpClientManager, maxCallRounds);
callbacks.add(callback);
log.debug("注册 MCP 工具: server={}, tool={}", serverName, prefixedName);
}
}
log.info("MCP 工具适配完成,共注册 {} 个工具", callbacks.size());
return callbacks;
}
/**
* 获取 MCP 工具的 ToolCallback 数组 ChatClient.defaultTools() 使用
* ChatClient.defaultTools() 接受 ToolCallback[] 参数此方法提供便捷转换
*
* @return ToolCallback 数组
*/
public ToolCallback[] getAllToolCallbacksAsArray() {
return getAllToolCallbacks().toArray(new ToolCallback[0]);
}
/**
* 获取指定工具名列表的 ToolCallback 数组按角色权限过滤 MCP 工具
*
* @param allowedTools 允许的工具名列表["*"] 表示全部允许其他为具体工具名列表
* @return 过滤后的 ToolCallback 数组可能为空数组
*/
public ToolCallback[] getFilteredToolCallbacks(List<String> allowedTools) {
if (allowedTools == null || allowedTools.isEmpty()) {
return new ToolCallback[0];
}
List<ToolCallback> all = getAllToolCallbacks();
if (allowedTools.contains("*")) {
return all.toArray(new ToolCallback[0]);
}
return all.stream()
.filter(cb -> allowedTools.contains(cb.getToolDefinition().name()))
.toArray(ToolCallback[]::new);
}
}

73
src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java

@ -0,0 +1,73 @@
package com.wok.supportbot.mcp;
import org.springframework.http.codec.ServerSentEvent;
/**
* SSE 事件构建器
* 用于在 MCP 工具调用流程中构建标准 SSE 事件
* 让前端能区分文本内容与工具调用状态
*
* 事件类型
* - message: 普通文本内容
* - tool_call_start: 工具调用开始
* - tool_call_result: 工具调用结果返回
* - error: 错误信息
*/
public class SseEventBuilder {
/**
* 构建普通文本消息事件
*/
public static ServerSentEvent<String> messageEvent(String data) {
return ServerSentEvent.<String>builder()
.event("message")
.data(data)
.build();
}
/**
* 构建工具调用开始事件
*/
public static ServerSentEvent<String> toolCallStartEvent(String toolName, String input) {
String json = String.format("{\"tool\":\"%s\",\"input\":\"%s\"}",
escapeJson(toolName), escapeJson(input));
return ServerSentEvent.<String>builder()
.event("tool_call_start")
.data(json)
.build();
}
/**
* 构建工具调用结果事件
*/
public static ServerSentEvent<String> toolCallResultEvent(String toolName, String result, long latencyMs) {
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}",
escapeJson(toolName), escapeJson(result), latencyMs);
return ServerSentEvent.<String>builder()
.event("tool_call_result")
.data(json)
.build();
}
/**
* 构建错误事件
*/
public static ServerSentEvent<String> errorEvent(String message) {
return ServerSentEvent.<String>builder()
.event("error")
.data("{\"message\":\"" + escapeJson(message) + "\"}")
.build();
}
/**
* JSON 字符串转义
*/
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}

2
src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java

@ -25,7 +25,7 @@ import java.util.Map;
*/ */
@Slf4j @Slf4j
@Component @Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
public class ApiKeyAuthFilter extends OncePerRequestFilter { public class ApiKeyAuthFilter extends OncePerRequestFilter {
@Autowired @Autowired

30
src/main/java/com/wok/supportbot/security/JwtAuthFilter.java

@ -33,20 +33,30 @@ public class JwtAuthFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String token = extractToken(request); String token = extractToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsernameFromToken(token);
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
if (StringUtils.hasText(token)) {
boolean valid = jwtTokenProvider.validateToken(token);
if (valid) {
String username = jwtTokenProvider.getUsernameFromToken(token);
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
// 构建 Spring Security 权限列表ROLE_ 前缀
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
// 构建 Spring Security 权限列表ROLE_ 前缀
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("JWT 认证成功: path={}, user={}, roles={}", path, username, roles);
} else {
log.warn("JWT Token 验证失败: path={}, tokenPrefix={}",
path, token.length() > 10 ? token.substring(0, 10) + "..." : token);
}
} else {
log.debug("JWT 未找到 Token: path={}", path);
} }
filterChain.doFilter(request, response); filterChain.doFilter(request, response);

4
src/main/java/com/wok/supportbot/security/JwtTokenProvider.java

@ -1,6 +1,7 @@
package com.wok.supportbot.security; package com.wok.supportbot.security;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -70,6 +71,9 @@ public class JwtTokenProvider {
try { try {
parseClaims(token); parseClaims(token);
return true; return true;
} catch (ExpiredJwtException e) {
// Token 已过期不打印堆栈
return false;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }

124
src/main/java/com/wok/supportbot/security/SdkAuthFilter.java

@ -0,0 +1,124 @@
package com.wok.supportbot.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
/**
* SDK 鉴权过滤器
* 拦截 /ai/** 请求校验 SDK JWT Token
* <p>
* 优先级高于 ApiKeyAuthFilter SecurityFilterChain 之前执行
* 校验通过后提取 allowedRoleIds 用于角色鉴权
* 校验失败直接返回 401/403请求不会到达 AiController
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class SdkAuthFilter extends OncePerRequestFilter {
@Autowired
private SdkJwtTokenProvider sdkJwtTokenProvider;
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
// 放行 CORS 预检请求OPTIONS 请求不需要 Token 鉴权
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 仅拦截 /ai/ 路径排除静态资源和 swagger
return !path.startsWith("/ai/");
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
// 提取 Authorization: Bearer {token}
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
writeError(response, 401, "缺少认证令牌");
return;
}
String token = authHeader.substring(7).trim();
if (token.isEmpty()) {
writeError(response, 401, "认证令牌为空");
return;
}
// JWT 签名验证 + 过期检查
Claims claims;
try {
claims = sdkJwtTokenProvider.parseToken(token);
} catch (ExpiredJwtException e) {
writeError(response, 401, "令牌已过期");
return;
} catch (JwtException e) {
log.warn("SDK Token 验证失败: {}", e.getMessage());
writeError(response, 401, "无效的认证令牌");
return;
}
// 提取 allowedRoleIds
Set<Long> allowedRoleIds = sdkJwtTokenProvider.getAllowedRoleIds(claims);
// roleId allowedRoleIds 校验仅当请求携带 roleId 参数时
String roleIdParam = request.getParameter("roleId");
if (roleIdParam != null && !roleIdParam.isEmpty()) {
try {
Long roleId = Long.parseLong(roleIdParam);
if (!allowedRoleIds.isEmpty() && !allowedRoleIds.contains(roleId)) {
writeError(response, 403, "无权使用该客服角色");
return;
}
} catch (NumberFormatException e) {
writeError(response, 400, "无效的角色ID");
return;
}
}
chain.doFilter(request, response);
} finally {
// 预留清理点
}
}
/**
* 输出 JSON 格式的错误响应 ApiKeyAuthFilter 保持一致的格式
* 同时添加 CORS 避免浏览器将鉴权失败误报为跨域错误
*/
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
// 添加 CORS 使浏览器能正确读取错误响应
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials", "true");
Map<String, Object> body = Map.of(
"success", false,
"message", message,
"code", status
);
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}

149
src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java

@ -0,0 +1,149 @@
package com.wok.supportbot.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* SDK 专用 JWT 令牌提供者
* 独立于管理后台 JwtTokenProvider使用独立密钥签发和验证 SDK Token
* <p>
* Token 中包含:
* - sub: apiKeyIdAPI Key 标识
* - ak: 脱敏后的 API Key
* - rids: 允许使用的客服角色 ID 列表
*/
@Slf4j
@Component
public class SdkJwtTokenProvider {
private final SecretKey key;
private final long defaultExpiration;
/** 24 小时上限 */
private static final long MAX_EXPIRATION = 86400000L;
/** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L;
/** 默认密钥标识(禁止使用) */
private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change";
public SdkJwtTokenProvider(
@Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret,
@Value("${jwt.sdk-expiration:7200000}") long defaultExpiration) {
if (DEFAULT_SECRET.equals(sdkSecret)) {
log.warn("⚠️ SDK JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.sdk-secret 为强随机字符串");
}
this.key = Keys.hmacShaKeyFor(sdkSecret.getBytes(StandardCharsets.UTF_8));
this.defaultExpiration = defaultExpiration;
}
/**
* 签发 SDK JWT Token
*
* @param apiKeyId API Key 标识
* @param maskedApiKey 脱敏后的 API Key
* @param roleIds 允许使用的客服角色 ID 列表
* @param expirationMs 过期时间毫秒会被钳制到 [5min, 24h]
* @return JWT Token 字符串
*/
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds, long expirationMs) {
long clampedExpiration = clampExpiration(expirationMs);
Date now = new Date();
return Jwts.builder()
.subject(apiKeyId)
.claim("ak", maskedApiKey)
.claim("rids", roleIds)
.issuedAt(now)
.expiration(new Date(now.getTime() + clampedExpiration))
.signWith(key)
.compact();
}
/**
* 使用默认过期时间签发 SDK JWT Token
*/
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds) {
return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration);
}
/**
* 验证并解析 Token返回 Claims
*
* @param token JWT Token 字符串
* @return Claims 对象
* @throws ExpiredJwtException Token 已过期
* @throws JwtException Token 签名无效或格式错误
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 验证 Token 是否有效签名正确且未过期
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
/**
* Claims 中提取 apiKeyIdsubject
*/
public String getApiKeyId(Claims claims) {
return claims.getSubject();
}
/**
* Claims 中提取允许的角色 ID 集合
* JWT rids List&lt;Integer&gt;需转换为 Set&lt;Long&gt;
*/
@SuppressWarnings("unchecked")
public Set<Long> getAllowedRoleIds(Claims claims) {
List<?> rids = claims.get("rids", List.class);
if (rids == null) {
return Set.of();
}
return rids.stream()
.map(r -> ((Number) r).longValue())
.collect(Collectors.toSet());
}
/**
* 脱敏 API Key: sk_ab****xyz0
* 保留前 4 位和后 4 中间用 **** 替代
*/
public static String maskApiKey(String keyValue) {
if (keyValue == null || keyValue.length() < 12) {
return "****";
}
return keyValue.substring(0, 4) + "****" + keyValue.substring(keyValue.length() - 4);
}
/**
* 将过期时间钳制到 [5min, 24h] 范围内
*/
private long clampExpiration(long expirationMs) {
return Math.max(MIN_EXPIRATION, Math.min(expirationMs, MAX_EXPIRATION));
}
}

5
src/main/java/com/wok/supportbot/security/SecurityConfig.java

@ -43,7 +43,10 @@ public class SecurityConfig {
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// ========== 公开接口无需认证 ========== // ========== 公开接口无需认证 ==========
// SDK 对话接口方案A不认证SDK 直接调用
// SDK 对话接口
// SdkAuthFilter 作为 Servlet Filter 已在 SecurityFilterChain 之前执行
// 校验通过时已放行校验失败时已返回 401不会到达此处
// 因此 /ai/** 保持 permitAll()实际安全性由 SdkAuthFilter 保证
.requestMatchers("/ai/**").permitAll() .requestMatchers("/ai/**").permitAll()
// 开放 API ApiKeyAuthFilter 独立鉴权 // 开放 API ApiKeyAuthFilter 独立鉴权
.requestMatchers("/open-api/**").permitAll() .requestMatchers("/open-api/**").permitAll()

82
src/main/java/com/wok/supportbot/service/ApiKeyService.java

@ -1,6 +1,8 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.ApiKeyMapper; import com.wok.supportbot.dao.ApiKeyMapper;
import com.wok.supportbot.entity.ApiKey; import com.wok.supportbot.entity.ApiKey;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -8,10 +10,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
/** /**
* API Key 管理服务 * API Key 管理服务
* 提供 API Key 的生成查询吊销启用删除验证等功能
* 提供 API Key 的生成查询吊销启用删除验证角色绑定等功能
*/ */
@Slf4j @Slf4j
@Service @Service
@ -20,6 +23,11 @@ public class ApiKeyService {
@Autowired @Autowired
private ApiKeyMapper apiKeyMapper; private ApiKeyMapper apiKeyMapper;
@Autowired
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
/** /**
* 生成新的 API Key * 生成新的 API Key
* *
@ -54,6 +62,7 @@ public class ApiKeyService {
/** /**
* 分页查询 API Key 列表 * 分页查询 API Key 列表
* 分两步查询MyBatis Plus 读基础字段 + JdbcTemplate JSONB role_ids
* *
* @param page 页码从1开始 * @param page 页码从1开始
* @param size 每页条数 * @param size 每页条数
@ -68,6 +77,19 @@ public class ApiKeyService {
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<ApiKey> records = apiKeyMapper.selectList(listWrapper); List<ApiKey> records = apiKeyMapper.selectList(listWrapper);
// 补充读取 JSONB role_ids 字段MyBatis Plus 无法正确读取 JSONB
if (!records.isEmpty()) {
Map<Long, String> roleIdsMap = new HashMap<>();
String ids = records.stream().map(k -> k.getId().toString()).collect(Collectors.joining(","));
String sql = "SELECT id, COALESCE(role_ids::text, '[]') AS role_ids FROM api_key WHERE id IN (" + ids + ")";
jdbcTemplate.query(sql, (rs) -> {
roleIdsMap.put(rs.getLong("id"), rs.getString("role_ids"));
});
for (ApiKey key : records) {
key.setRoleIds(roleIdsMap.getOrDefault(key.getId(), "[]"));
}
}
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("records", records); result.put("records", records);
result.put("total", total); result.put("total", total);
@ -147,24 +169,60 @@ public class ApiKeyService {
} }
/** /**
* 自增调用次数
* 自增调用次数SQL 原子操作避免并发竞态
* *
* @param id Key ID * @param id Key ID
*/ */
public void incrementCallCount(Long id) { public void incrementCallCount(Long id) {
// 使用 SQL 原子操作自增避免并发问题
try { try {
String sql = "UPDATE api_key SET current_calls = current_calls + 1 WHERE id = ?";
// 通过 MyBatis Plus 无法直接做原子自增使用 updateById 兜底
ApiKey existing = apiKeyMapper.selectById(id);
if (existing != null) {
existing.setCurrentCalls(
(existing.getCurrentCalls() != null ? existing.getCurrentCalls() : 0L) + 1
);
apiKeyMapper.updateById(existing);
}
jdbcTemplate.update(
"UPDATE api_key SET current_calls = current_calls + 1 WHERE id = ? AND is_delete = false",
id);
} catch (Exception e) { } catch (Exception e) {
log.warn("自增 API Key 调用次数失败: id={}", id, e); log.warn("自增 API Key 调用次数失败: id={}", id, e);
} }
} }
/**
* 更新 API Key 绑定的客服角色列表
* 使用 JdbcTemplate 显式 ::jsonb 转换避免 MyBatis-Plus 类型不匹配
*
* @param id API Key ID
* @param roleIds 角色 ID 列表/null 表示不限制
*/
public void updateKeyRoles(Long id, List<Long> roleIds) {
ApiKey existing = apiKeyMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("API Key 不存在,ID:" + id);
}
String json;
try {
json = objectMapper.writeValueAsString(roleIds != null ? roleIds : List.of());
} catch (Exception e) {
json = "[]";
}
int rows = jdbcTemplate.update(
"UPDATE api_key SET role_ids = ?::jsonb, update_time = CURRENT_TIMESTAMP WHERE id = ? AND is_delete = false",
json, id);
log.info("API Key 角色绑定已更新: id={}, roleIds={}", id, json);
}
/**
* 解析 API Key 绑定的角色 ID 列表
*
* @param apiKey API Key 实体
* @return 角色 ID 列表空数组表示不限制
*/
public List<Long> parseRoleIds(ApiKey apiKey) {
if (apiKey == null || apiKey.getRoleIds() == null
|| apiKey.getRoleIds().isBlank() || "[]".equals(apiKey.getRoleIds().trim())) {
return List.of();
}
try {
return objectMapper.readValue(apiKey.getRoleIds(), new TypeReference<List<Long>>() {});
} catch (Exception e) {
log.warn("解析 role_ids 失败: {}", apiKey.getRoleIds(), e);
return List.of();
}
}
} }

143
src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java

@ -1,5 +1,8 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -10,13 +13,18 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors;
@Service @Service
@Slf4j
public class CustomerServiceRoleService { public class CustomerServiceRoleService {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
@Autowired
private ObjectMapper objectMapper;
public List<Map<String, Object>> listRoles() { public List<Map<String, Object>> listRoles() {
return listRoles(false); return listRoles(false);
} }
@ -25,12 +33,16 @@ public class CustomerServiceRoleService {
* @param includeDisabled true=包含已停用角色管理页用false=仅启用角色对话页用 * @param includeDisabled true=包含已停用角色管理页用false=仅启用角色对话页用
*/ */
public List<Map<String, Object>> listRoles(boolean includeDisabled) { public List<Map<String, Object>> listRoles(boolean includeDisabled) {
String roleSql = "SELECT id::text AS id, role_key, name, description, prompt, sort_order, enabled "
+ "FROM customer_service_role WHERE is_delete = false "
+ (includeDisabled ? "" : "AND enabled = true ")
+ "ORDER BY sort_order ASC, id ASC";
List<Map<String, Object>> roles = jdbcTemplate.queryForList(roleSql);
StringBuilder roleSql = new StringBuilder(
"SELECT id::text AS id, role_key, name, description, prompt, enabled, allowed_mcp_tools "
+ "FROM customer_service_role WHERE is_delete = false ");
if (!includeDisabled) {
roleSql.append("AND enabled = true ");
}
roleSql.append("ORDER BY id ASC");
List<Map<String, Object>> roles = jdbcTemplate.queryForList(roleSql.toString());
// 查询角色-分类关联
String categorySql = """ String categorySql = """
SELECT rc.role_id::text AS role_id, rc.category_id::text AS category_id, c.name AS category_name SELECT rc.role_id::text AS role_id, rc.category_id::text AS category_id, c.name AS category_name
FROM customer_service_role_category rc FROM customer_service_role_category rc
@ -87,7 +99,7 @@ public class CustomerServiceRoleService {
* 新增角色role_key 缺省时自动生成 * 新增角色role_key 缺省时自动生成
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void createRole(String roleKey, String name, String description, String prompt, Integer sortOrder, Boolean enabled) {
public void createRole(String roleKey, String name, String description, String prompt, Boolean enabled) {
if (name == null || name.trim().isEmpty()) { if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("角色名称不能为空"); throw new IllegalArgumentException("角色名称不能为空");
} }
@ -95,11 +107,10 @@ public class CustomerServiceRoleService {
? "role_" + System.currentTimeMillis() ? "role_" + System.currentTimeMillis()
: roleKey.trim(); : roleKey.trim();
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO customer_service_role (role_key, name, description, prompt, sort_order, enabled)
VALUES (?, ?, ?, ?, ?, ?)
""",
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES (?, ?, ?, ?, ?)
""",
key, name.trim(), description, prompt, key, name.trim(), description, prompt,
sortOrder != null ? sortOrder : 0,
enabled != null ? enabled : true); enabled != null ? enabled : true);
} }
@ -107,7 +118,7 @@ public class CustomerServiceRoleService {
* 编辑角色基本信息不含知识库分类分类走 {@link #updateRoleCategories} * 编辑角色基本信息不含知识库分类分类走 {@link #updateRoleCategories}
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void updateRole(Long roleId, String name, String description, String prompt, Integer sortOrder, Boolean enabled) {
public void updateRole(Long roleId, String name, String description, String prompt, Boolean enabled) {
if (roleId == null || roleId <= 0) { if (roleId == null || roleId <= 0) {
throw new IllegalArgumentException("角色ID无效"); throw new IllegalArgumentException("角色ID无效");
} }
@ -116,11 +127,10 @@ public class CustomerServiceRoleService {
} }
jdbcTemplate.update(""" jdbcTemplate.update("""
UPDATE customer_service_role UPDATE customer_service_role
SET name = ?, description = ?, prompt = ?, sort_order = ?, enabled = ?, update_time = CURRENT_TIMESTAMP
SET name = ?, description = ?, prompt = ?, enabled = ?, update_time = CURRENT_TIMESTAMP
WHERE id = ? AND is_delete = false WHERE id = ? AND is_delete = false
""", """,
name.trim(), description, prompt, name.trim(), description, prompt,
sortOrder != null ? sortOrder : 0,
enabled != null ? enabled : true, enabled != null ? enabled : true,
roleId); roleId);
} }
@ -137,6 +147,28 @@ public class CustomerServiceRoleService {
jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId); jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId);
} }
/**
* 更新角色的 MCP 工具权限
*
* @param roleId 角色ID
* @param allowedMcpTools 允许的 MCP 工具名列表["*"]=全部允许/null=不允许任何工具
*/
@Transactional(rollbackFor = Exception.class)
public void updateRoleMcpTools(Long roleId, List<String> allowedMcpTools) {
if (roleId == null || roleId <= 0) {
throw new IllegalArgumentException("角色ID无效");
}
String json;
try {
json = objectMapper.writeValueAsString(allowedMcpTools != null ? allowedMcpTools : List.of());
} catch (Exception e) {
json = "[]";
}
jdbcTemplate.update(
"UPDATE customer_service_role SET allowed_mcp_tools = ?::jsonb, update_time = CURRENT_TIMESTAMP WHERE id = ? AND is_delete = false",
json, roleId);
}
/** /**
* 服务端解析角色的知识库范围与人设 * 服务端解析角色的知识库范围与人设
* 用于对话时强制约束角色只能检索其绑定分类下的内容由后端决定客户端无法越权跨域 * 用于对话时强制约束角色只能检索其绑定分类下的内容由后端决定客户端无法越权跨域
@ -149,7 +181,7 @@ public class CustomerServiceRoleService {
return RoleScope.empty(); return RoleScope.empty();
} }
List<Map<String, Object>> rows = jdbcTemplate.queryForList( List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT name, prompt FROM customer_service_role WHERE id = ? AND is_delete = false AND enabled = true",
"SELECT name, prompt, allowed_mcp_tools FROM customer_service_role WHERE id = ? AND is_delete = false AND enabled = true",
roleId); roleId);
if (rows.isEmpty()) { if (rows.isEmpty()) {
return RoleScope.empty(); return RoleScope.empty();
@ -159,22 +191,97 @@ public class CustomerServiceRoleService {
List<Long> categoryIds = jdbcTemplate.queryForList( List<Long> categoryIds = jdbcTemplate.queryForList(
"SELECT category_id FROM customer_service_role_category WHERE role_id = ? AND is_delete = false", "SELECT category_id FROM customer_service_role_category WHERE role_id = ? AND is_delete = false",
Long.class, roleId); Long.class, roleId);
return new RoleScope(true, name, prompt, categoryIds);
// 解析 allowed_mcp_tools JSONB 字段
List<String> allowedMcpTools = parseAllowedMcpTools(rows.get(0).get("allowed_mcp_tools"));
return new RoleScope(true, name, prompt, categoryIds, allowedMcpTools);
}
/**
* 解析 allowed_mcp_tools JSONB 字段为 List<String>
* 兼容 PostgreSQL JSONBJava ListString 等多种输入类型
*/
private List<String> parseAllowedMcpTools(Object raw) {
if (raw == null) {
return List.of();
}
try {
if (raw instanceof List<?> list) {
return list.stream().map(Objects::toString).toList();
}
String json = raw.toString().trim();
if (json.isEmpty() || "[]".equals(json) || "null".equals(json)) {
return List.of();
}
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.warn("解析 allowed_mcp_tools 失败: {}", raw, e);
return List.of();
}
}
/**
* 精简角色信息Token 换取接口用
*/
public record RoleBrief(Long id, String roleKey, String name) {}
/**
* 查询所有启用的客服角色精简字段
*
* @return 精简角色信息列表
*/
public List<RoleBrief> listEnabledRolesBrief() {
String sql = "SELECT id, role_key, name FROM customer_service_role " +
"WHERE enabled = true AND is_delete = false ORDER BY id";
return jdbcTemplate.query(sql, (rs, i) -> new RoleBrief(
rs.getLong("id"),
rs.getString("role_key"),
rs.getString("name")
));
} }
/** /**
* 角色范围是否命中角色角色人设可检索的知识库分类
* 根据 ID 列表查询启用的客服角色精简字段用于 API Key 角色绑定的 Token 换取
*
* @param roleIds 角色 ID 列表
* @return 精简角色信息列表
*/
public List<RoleBrief> listRolesByIds(List<Long> roleIds) {
if (roleIds == null || roleIds.isEmpty()) {
return List.of();
}
String placeholders = roleIds.stream().map(id -> "?").collect(Collectors.joining(","));
String sql = "SELECT id, role_key, name FROM customer_service_role " +
"WHERE enabled = true AND is_delete = false AND id IN (" + placeholders + ") ORDER BY id";
return jdbcTemplate.query(sql, (rs, i) -> new RoleBrief(
rs.getLong("id"),
rs.getString("role_key"),
rs.getString("name")
), roleIds.toArray());
}
/**
* 角色范围是否命中角色角色人设可检索的知识库分类MCP 工具权限
*/ */
public record RoleScope(boolean present, String name, String prompt, List<Long> categoryIds) {
public record RoleScope(boolean present, String name, String prompt, List<Long> categoryIds, List<String> allowedMcpTools) {
public static RoleScope empty() { public static RoleScope empty() {
return new RoleScope(false, "", "", List.of());
return new RoleScope(false, "", "", List.of(), List.of());
} }
public boolean hasRole() { public boolean hasRole() {
return present; return present;
} }
/** 是否允许使用 MCP 工具(非空即允许) */
public boolean isMcpToolsAllowed() {
return allowedMcpTools != null && !allowedMcpTools.isEmpty();
}
/** 是否允许使用所有 MCP 工具(["*"] 表示全部允许) */
public boolean isAllMcpToolsAllowed() {
return allowedMcpTools != null && allowedMcpTools.contains("*");
}
/** /**
* 组合角色系统提示词当前客服角色 + 人设都为空时返回 null退回基础提示词 * 组合角色系统提示词当前客服角色 + 人设都为空时返回 null退回基础提示词
*/ */

124
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -73,6 +73,9 @@ public class DocumentService {
@Autowired @Autowired
private DocumentProcessingService documentProcessingService; private DocumentProcessingService documentProcessingService;
@Autowired
private com.wok.supportbot.config.FileStorageConfig fileStorageConfig;
// ==================== 文档上传 ==================== // ==================== 文档上传 ====================
/** /**
@ -90,13 +93,15 @@ public class DocumentService {
* @param tags 标签列表 * @param tags 标签列表
* @param chunkSize 分块大小可选覆盖全局配置 * @param chunkSize 分块大小可选覆盖全局配置
* @param overlap 重叠大小可选覆盖全局配置 * @param overlap 重叠大小可选覆盖全局配置
* @param filePath 原始文件存储路径可选null表示无原始文件
* @return 创建完成的文档记录status=PROCESSING * @return 创建完成的文档记录status=PROCESSING
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName, public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName,
String fileType, Long fileSize, String content, String fileType, Long fileSize, String content,
Long categoryId, List<String> tags, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
Integer chunkSize, Integer overlap,
String filePath) {
// 0. 内容去重检查 // 0. 内容去重检查
String contentHash = computeContentHash(content); String contentHash = computeContentHash(content);
if (contentHash != null) { if (contentHash != null) {
@ -117,6 +122,7 @@ public class DocumentService {
.sourceName(sourceName) .sourceName(sourceName)
.fileType(fileType) .fileType(fileType)
.fileSize(fileSize != null ? fileSize : 0L) .fileSize(fileSize != null ? fileSize : 0L)
.filePath(filePath)
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content) .content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content)
.categoryId(categoryId != null ? categoryId : 0L) .categoryId(categoryId != null ? categoryId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null) .tags(tags != null ? Map.of("tags", tags) : null)
@ -138,12 +144,19 @@ public class DocumentService {
} }
/** /**
* 解析文件并上传
* 解析文件并上传同时保存原始文件到本地
*/ */
public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List<String> tags, public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
List<Document> documents = tikaDocumentReader.read(file);
// 1. 先保存原始文件到本地磁盘必须在解析之前因为 transferTo 只能调用一次
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析避免 MultipartFile 的临时文件问题
List<Document> documents = tikaDocumentReader.readFromFile(savedFile);
String fileType = getFileExtension(file.getOriginalFilename()); String fileType = getFileExtension(file.getOriginalFilename());
// 3. 创建文档记录并保存文件路径
return uploadDocument(documents, return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(), title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(), file.getOriginalFilename(),
@ -151,17 +164,45 @@ public class DocumentService {
file.getSize(), file.getSize(),
documents.get(0).getText(), documents.get(0).getText(),
categoryId, categoryId,
tags, chunkSize, overlap);
tags, chunkSize, overlap,
relativePath);
}
/**
* 保存上传的文件到本地磁盘
*
* @param file 上传的文件
* @return 相对路径
*/
private String saveFileToLocal(MultipartFile file) {
try {
String relativePath = fileStorageConfig.generateRelativePath(file.getOriginalFilename());
java.nio.file.Path targetPath = fileStorageConfig.getFilePath(relativePath);
// 确保父目录存在
java.io.File parentDir = targetPath.getParent().toFile();
if (!parentDir.exists()) {
parentDir.mkdirs();
}
// 使用 transferTo 保存文件这是唯一一次调用
file.transferTo(targetPath.toFile());
log.info("文件已保存到本地: {}", targetPath);
return relativePath;
} catch (java.io.IOException e) {
log.error("保存文件到本地失败", e);
throw new RuntimeException("保存文件失败: " + e.getMessage(), e);
}
} }
/** /**
* 解析字符串并上传
* 解析字符串并上传无原始文件
*/ */
public KnowledgeDocument uploadString(String content, String title, Long categoryId, List<String> tags, public KnowledgeDocument uploadString(String content, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
List<Document> documents = simpleStringDocumentReader.read(content); List<Document> documents = simpleStringDocumentReader.read(content);
return uploadDocument(documents, title, title, "txt", return uploadDocument(documents, title, title, "txt",
(long) content.length(), content, categoryId, tags, chunkSize, overlap);
(long) content.length(), content, categoryId, tags, chunkSize, overlap, null);
} }
/** /**
@ -169,8 +210,14 @@ public class DocumentService {
*/ */
public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List<String> tags, public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(file);
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(savedFile);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(), title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(), file.getOriginalFilename(),
@ -178,7 +225,8 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags, chunkSize, overlap);
tags, chunkSize, overlap,
relativePath);
} }
/** /**
@ -186,8 +234,14 @@ public class DocumentService {
*/ */
public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List<String> tags, public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadBasicJson(file);
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> documents = jsonDocumentLoader.loadBasicJsonFromFile(savedFile);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(), title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(), file.getOriginalFilename(),
@ -195,7 +249,8 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags, chunkSize, overlap);
tags, chunkSize, overlap,
relativePath);
} }
/** /**
@ -204,8 +259,14 @@ public class DocumentService {
public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title, public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title,
Long categoryId, List<String> tags, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0]));
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> documents = jsonDocumentLoader.loadJsonByFieldsFromFile(savedFile, fields.toArray(new String[0]));
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(), title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(), file.getOriginalFilename(),
@ -213,7 +274,8 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags, chunkSize, overlap);
tags, chunkSize, overlap,
relativePath);
} }
/** /**
@ -222,8 +284,14 @@ public class DocumentService {
public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title,
Long categoryId, List<String> tags, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) { Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByPointer(file, pointer);
// 1. 先保存原始文件
String relativePath = saveFileToLocal(file);
java.io.File savedFile = fileStorageConfig.getFilePath(relativePath).toFile();
// 2. 使用保存的文件进行解析
List<Document> documents = jsonDocumentLoader.loadJsonByPointerFromFile(savedFile, pointer);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
title != null ? title : file.getOriginalFilename(), title != null ? title : file.getOriginalFilename(),
file.getOriginalFilename(), file.getOriginalFilename(),
@ -231,7 +299,33 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags, chunkSize, overlap);
tags, chunkSize, overlap,
relativePath);
}
// ==================== 文件下载 ====================
/**
* 获取文档的原始文件用于下载
*
* @param documentId 文档ID
* @return 文件对象和原始文件名
*/
public java.util.Map.Entry<java.io.File, String> getDocumentFile(Long documentId) {
KnowledgeDocument doc = documentMapper.selectById(documentId);
if (doc == null || doc.isDelete()) {
throw new RuntimeException("文档不存在");
}
if (doc.getFilePath() == null || doc.getFilePath().isEmpty()) {
throw new RuntimeException("该文档无原始文件(纯文本上传)");
}
java.io.File file = fileStorageConfig.getFilePath(doc.getFilePath()).toFile();
if (!file.exists()) {
throw new RuntimeException("文件不存在或已被删除");
}
return java.util.Map.entry(file, doc.getSourceName());
} }
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
@ -907,7 +1001,7 @@ public class DocumentService {
} }
/** /**
* 创建分类
* 创建知识库分类
*/ */
public KnowledgeCategory createCategory(String name, String description, Long parentId, Integer sortOrder) { public KnowledgeCategory createCategory(String name, String description, Long parentId, Integer sortOrder) {
KnowledgeCategory category = KnowledgeCategory.builder() KnowledgeCategory category = KnowledgeCategory.builder()

3
src/main/java/com/wok/supportbot/service/FaqService.java

@ -259,7 +259,8 @@ public class FaqService {
Map<String, Object> stats = new HashMap<>(); Map<String, Object> stats = new HashMap<>();
// FAQ // FAQ
Long totalCount = faqMapper.selectCount(new QueryWrapper<>());
QueryWrapper<KnowledgeFaq> totalWrapper = new QueryWrapper<>();
Long totalCount = faqMapper.selectCount(totalWrapper);
stats.put("totalCount", totalCount); stats.put("totalCount", totalCount);
// 启用数 // 启用数

320
src/main/java/com/wok/supportbot/service/McpServerConfigService.java

@ -0,0 +1,320 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.McpServerConfigMapper;
import com.wok.supportbot.entity.McpServerConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* MCP Server 配置管理服务
* 提供 CRUD启用/禁用JSONB 字段显式持久化等功能
*/
@Service
@Slf4j
public class McpServerConfigService {
@Autowired
private McpServerConfigMapper mcpServerConfigMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
/** 复用的 JSON 序列化器,避免每次持久化都新建实例 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// ==================== 分页列表 ====================
/**
* 分页查询 MCP Server 配置列表
*
* @param page 页码从1开始
* @param size 每页大小
* @return 分页结果records/total/page/size/pages
*/
public Map<String, Object> listConfigs(int page, int size) {
// 查询总数
LambdaQueryWrapper<McpServerConfig> countWrapper = new LambdaQueryWrapper<>();
Long total = mcpServerConfigMapper.selectCount(countWrapper);
if (total == null) total = 0L;
// 查询列表带排序和分页
LambdaQueryWrapper<McpServerConfig> listWrapper = new LambdaQueryWrapper<>();
listWrapper.orderByDesc(McpServerConfig::getCreateTime);
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size));
List<McpServerConfig> records = mcpServerConfigMapper.selectList(listWrapper);
// 格式化返回结果
List<Map<String, Object>> formattedRecords = new ArrayList<>();
for (McpServerConfig record : records) {
formattedRecords.add(formatConfig(record));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("records", formattedRecords);
result.put("total", total);
result.put("page", page);
result.put("size", size);
result.put("pages", (total + size - 1) / size);
return result;
}
// ==================== 详情 ====================
/**
* 获取单条配置详情
*
* @param id 配置ID
* @return 格式化的配置详情不存在返回 null
*/
public Map<String, Object> getConfigDetail(Long id) {
McpServerConfig config = mcpServerConfigMapper.selectById(id);
if (config == null) {
return null;
}
return formatConfig(config);
}
// ==================== 新建配置 ====================
/**
* 新建 MCP Server 配置
*
* @param config 配置对象
* @return 保存后的配置格式化
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> createConfig(McpServerConfig config) {
// 校验必填字段
validateRequired(config);
mcpServerConfigMapper.insert(config);
// JSONB 字段显式持久化避免 MyBatis Plus typeHandler 不触发的问题
persistEnvVars(config.getId(), config.getEnvVars());
log.info("新建 MCP Server 配置: name={}, transportType={}", config.getName(), config.getTransportType());
return formatConfig(mcpServerConfigMapper.selectById(config.getId()));
}
// ==================== 更新配置 ====================
/**
* 更新 MCP Server 配置
* 使用 LambdaUpdateWrapper 显式设置所有字段 null
* 避免 updateById 默认跳过 null 字段导致无法清空可选字段
*
* @param id 配置ID
* @param config 更新内容
* @return 更新后的配置格式化
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> updateConfig(Long id, McpServerConfig config) {
McpServerConfig existing = mcpServerConfigMapper.selectById(id);
if (existing == null) {
throw new RuntimeException("配置不存在: id=" + id);
}
// 使用 LambdaUpdateWrapper 显式设置所有字段null 也会被写入清空语义
LambdaUpdateWrapper<McpServerConfig> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(McpServerConfig::getId, id)
.set(McpServerConfig::getName, config.getName())
.set(McpServerConfig::getTransportType, config.getTransportType())
.set(McpServerConfig::getServerUrl, config.getServerUrl())
.set(McpServerConfig::getCommand, config.getCommand())
.set(McpServerConfig::getArgs, config.getArgs())
.set(McpServerConfig::getDescription, config.getDescription());
// isActive 仅在前端显式传入时更新null 表示不修改
if (config.getIsActive() != null) {
updateWrapper.set(McpServerConfig::getIsActive, config.getIsActive());
}
mcpServerConfigMapper.update(null, updateWrapper);
// JSONB 字段显式持久化LambdaUpdateWrapper 不支持 JSONB 类型
// null 或空 Map 均执行持久化清空语义 LambdaUpdateWrapper 行为一致
persistEnvVars(id, config.getEnvVars());
log.info("更新 MCP Server 配置: id={}", id);
return formatConfig(mcpServerConfigMapper.selectById(id));
}
// ==================== 删除配置 ====================
/**
* 逻辑删除 MCP Server 配置
*
* @param id 配置ID
*/
@Transactional(rollbackFor = Exception.class)
public void deleteConfig(Long id) {
McpServerConfig config = mcpServerConfigMapper.selectById(id);
if (config == null) {
throw new RuntimeException("配置不存在: id=" + id);
}
mcpServerConfigMapper.deleteById(id);
log.info("逻辑删除 MCP Server 配置: id={}, name={}", id, config.getName());
}
// ==================== 启用/禁用 ====================
/**
* 切换配置启用状态
*
* @param id 配置ID
* @param active 是否启用
*/
@Transactional(rollbackFor = Exception.class)
public void toggleActive(Long id, boolean active) {
McpServerConfig config = mcpServerConfigMapper.selectById(id);
if (config == null) {
throw new RuntimeException("配置不存在: id=" + id);
}
LambdaUpdateWrapper<McpServerConfig> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(McpServerConfig::getId, id)
.set(McpServerConfig::getIsActive, active);
mcpServerConfigMapper.update(null, updateWrapper);
log.info("切换 MCP Server 配置状态: id={}, name={}, active={}", id, config.getName(), active);
}
// ==================== 活跃配置查询 ====================
/**
* 获取所有启用的 SSE 类型配置
* McpClientManager 建立 SSE 连接使用
*
* @return SSE 类型的活跃配置列表
*/
public List<McpServerConfig> listActiveSseConfigs() {
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(McpServerConfig::getIsActive, true)
.eq(McpServerConfig::getTransportType, "sse");
return mcpServerConfigMapper.selectList(wrapper);
}
/**
* 获取所有启用的 stdio 类型配置
* McpClientManager 启动 stdio 进程使用
*
* @return stdio 类型的活跃配置列表
*/
public List<McpServerConfig> listActiveStdioConfigs() {
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(McpServerConfig::getIsActive, true)
.eq(McpServerConfig::getTransportType, "stdio");
return mcpServerConfigMapper.selectList(wrapper);
}
/**
* 根据 ID 获取配置含完整字段仅供内部使用
*
* @param id 配置ID
* @return 原始配置对象不存在返回 null
*/
public McpServerConfig getConfigById(Long id) {
return mcpServerConfigMapper.selectById(id);
}
/**
* 获取所有启用的配置不区分传输类型
* McpClientManager.refreshAll() 一次查询获取所有活跃配置
*
* @return 所有启用的配置列表
*/
public List<McpServerConfig> listAllActiveConfigs() {
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(McpServerConfig::getIsActive, true);
return mcpServerConfigMapper.selectList(wrapper);
}
// ==================== 工具方法 ====================
/**
* 校验必填字段
*
* @param config 配置对象
*/
private void validateRequired(McpServerConfig config) {
if (config.getName() == null || config.getName().trim().isEmpty()) {
throw new IllegalArgumentException("配置名称不能为空");
}
if (config.getTransportType() == null || config.getTransportType().trim().isEmpty()) {
throw new IllegalArgumentException("传输类型不能为空");
}
// 根据传输类型校验对应必填字段
switch (config.getTransportType()) {
case "sse":
if (config.getServerUrl() == null || config.getServerUrl().trim().isEmpty()) {
throw new IllegalArgumentException("SSE 模式下服务地址不能为空");
}
break;
case "stdio":
if (config.getCommand() == null || config.getCommand().trim().isEmpty()) {
throw new IllegalArgumentException("stdio 模式下启动命令不能为空");
}
break;
default:
throw new IllegalArgumentException("不支持的传输类型: " + config.getTransportType());
}
}
/**
* 格式化配置为前端返回格式
* 雪花 ID 转字符串时间格式化
*
* @param config 原始配置
* @return 格式化后的 Map
*/
private Map<String, Object> formatConfig(McpServerConfig config) {
Map<String, Object> formatted = new LinkedHashMap<>();
// 雪花 ID 必须转为字符串避免 JS 精度丢失
formatted.put("id", config.getId().toString());
formatted.put("name", config.getName());
formatted.put("transport_type", config.getTransportType());
formatted.put("server_url", config.getServerUrl());
formatted.put("command", config.getCommand());
formatted.put("args", config.getArgs());
formatted.put("env_vars", config.getEnvVars());
formatted.put("description", config.getDescription());
formatted.put("is_active", config.getIsActive());
formatted.put("create_time", config.getCreateTime());
formatted.put("update_time", config.getUpdateTime());
return formatted;
}
/**
* 显式持久化 envVars JSONB 字段
* MyBatis Plus insert/updateById 对带 typeHandler JSONB 字段可能不触发写入
* 使用 JdbcTemplate 显式更新保证数据可靠落库
*
* @param id 配置ID
* @param envVars 环境变量 Map
*/
private void persistEnvVars(Long id, Map<String, Object> envVars) {
if (id == null) {
return;
}
try {
String json;
if (envVars == null || envVars.isEmpty()) {
json = "{}";
} else {
json = OBJECT_MAPPER.writeValueAsString(envVars);
}
jdbcTemplate.update(
"UPDATE mcp_server_config SET env_vars = ?::jsonb WHERE id = ?",
json, id);
log.debug("持久化 envVars: id={}, envVars={}", id, json);
} catch (Exception e) {
log.error("持久化 envVars 失败: id={}, error={}", id, e.getMessage());
throw new RuntimeException("持久化环境变量失败: " + e.getMessage(), e);
}
}
}

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

@ -23,6 +23,11 @@ spring:
knife4j: knife4j:
enable: true enable: true
# ==================== 文件存储路径 ====================
knowledge:
storage:
local-path: D:/uploads
# ==================== 日志级别 ==================== # ==================== 日志级别 ====================
logging: logging:
level: level:

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

@ -32,10 +32,15 @@ spring:
knife4j: knife4j:
enable: false enable: false
# ==================== 文件存储路径 ====================
knowledge:
storage:
local-path: /www/uploads
# ==================== 日志级别 ==================== # ==================== 日志级别 ====================
# 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO # 生产环境收敛日志,仅保留 WARN 及以上,业务代码保留 INFO
logging: logging:
level: level:
root: WARN
root: INFO
com.wok.supportbot: INFO com.wok.supportbot: INFO
org.springframework.ai: WARN
org.springframework.ai: INFO

9
src/main/resources/application.yml

@ -64,6 +64,10 @@ knowledge:
faq: faq:
# FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索 # FAQ 语义匹配阈值(0-1),低于此值自动降级到 RAG 检索
semantic-threshold: 0.85 semantic-threshold: 0.85
storage:
# 上传文件本地存储根路径(Windows: D:/uploads, Linux: /data/uploads)
# 各环境可在 application-{env}.yml 中覆盖
local-path: ./uploads
# ==================== Knife4j API 文档通用配置 ==================== # ==================== Knife4j API 文档通用配置 ====================
# knife4j.enable 开关在各环境 yml 中配置(生产环境建议关闭) # knife4j.enable 开关在各环境 yml 中配置(生产环境建议关闭)
@ -102,3 +106,8 @@ jwt:
expiration: 86400000 expiration: 86400000
# 刷新令牌有效期:7天(毫秒) # 刷新令牌有效期:7天(毫秒)
refresh-expiration: 604800000 refresh-expiration: 604800000
# ==================== SDK 专用 JWT 配置 ====================
# SDK Token 签名密钥(独立于管理后台 JWT 密钥,生产环境务必覆盖)
sdk-secret: support-bot-sdk-jwt-secret-2026-please-change
# SDK Token 默认有效期:2小时(毫秒)
sdk-expiration: 7200000

94
src/main/resources/init-database.sql

@ -41,7 +41,7 @@ CREATE TABLE IF NOT EXISTS chat_message (
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE, is_delete BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT chk_message_type CHECK (message_type IN ('USER', 'ASSISTANT', 'SYSTEM'))
CONSTRAINT chk_message_type CHECK (message_type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
); );
CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id); CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id);
@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS knowledge_document (
source_name VARCHAR(500), source_name VARCHAR(500),
file_type VARCHAR(20) NOT NULL, file_type VARCHAR(20) NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0, file_size BIGINT NOT NULL DEFAULT 0,
file_path VARCHAR(500),
content TEXT, content TEXT,
category_id BIGINT NOT NULL DEFAULT 0, category_id BIGINT NOT NULL DEFAULT 0,
tags JSONB NOT NULL DEFAULT '{}', tags JSONB NOT NULL DEFAULT '{}',
@ -101,6 +102,8 @@ CREATE TABLE IF NOT EXISTS knowledge_document (
status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING', status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING',
error_message TEXT, error_message TEXT,
content_hash VARCHAR(64), content_hash VARCHAR(64),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
extra_config JSONB NOT NULL DEFAULT '{}',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE is_delete BOOLEAN NOT NULL DEFAULT FALSE
@ -110,6 +113,7 @@ CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_docu
CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status); CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC); CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash); CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_enabled ON knowledge_document (enabled);
COMMENT ON TABLE knowledge_document IS '知识文档表'; COMMENT ON TABLE knowledge_document IS '知识文档表';
COMMENT ON COLUMN knowledge_document.id IS '主键'; COMMENT ON COLUMN knowledge_document.id IS '主键';
@ -117,6 +121,7 @@ COMMENT ON COLUMN knowledge_document.title IS '文档标题';
COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名'; COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名';
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型'; COMMENT ON COLUMN knowledge_document.file_type IS '文件类型';
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)'; COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)'; COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID'; COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID';
COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)'; COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)';
@ -124,6 +129,8 @@ COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量';
COMMENT ON COLUMN knowledge_document.status IS '状态: PROCESSING / READY / FAILED'; COMMENT ON COLUMN knowledge_document.status IS '状态: PROCESSING / READY / FAILED';
COMMENT ON COLUMN knowledge_document.error_message IS '处理失败时的错误信息'; COMMENT ON COLUMN knowledge_document.error_message IS '处理失败时的错误信息';
COMMENT ON COLUMN knowledge_document.content_hash IS '内容SHA-256哈希(用于去重)'; COMMENT ON COLUMN knowledge_document.content_hash IS '内容SHA-256哈希(用于去重)';
COMMENT ON COLUMN knowledge_document.enabled IS '是否启用';
COMMENT ON COLUMN knowledge_document.extra_config IS '扩展配置(JSON)';
COMMENT ON COLUMN knowledge_document.create_time IS '创建时间'; COMMENT ON COLUMN knowledge_document.create_time IS '创建时间';
COMMENT ON COLUMN knowledge_document.update_time IS '更新时间'; COMMENT ON COLUMN knowledge_document.update_time IS '更新时间';
COMMENT ON COLUMN knowledge_document.is_delete IS '逻辑删除'; COMMENT ON COLUMN knowledge_document.is_delete IS '逻辑删除';
@ -137,14 +144,14 @@ CREATE TABLE IF NOT EXISTS customer_service_role (
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
description TEXT, description TEXT,
prompt TEXT, prompt TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
is_delete BOOLEAN NOT NULL DEFAULT FALSE,
allowed_mcp_tools JSONB NOT NULL DEFAULT '[]'
); );
CREATE INDEX IF NOT EXISTS idx_customer_service_role_enabled ON customer_service_role (enabled, sort_order);
CREATE INDEX IF NOT EXISTS idx_customer_service_role_enabled ON customer_service_role (enabled);
COMMENT ON TABLE customer_service_role IS '客服角色表'; COMMENT ON TABLE customer_service_role IS '客服角色表';
COMMENT ON COLUMN customer_service_role.id IS '主键'; COMMENT ON COLUMN customer_service_role.id IS '主键';
@ -152,23 +159,23 @@ COMMENT ON COLUMN customer_service_role.role_key IS '角色标识符(唯一
COMMENT ON COLUMN customer_service_role.name IS '角色名称'; COMMENT ON COLUMN customer_service_role.name IS '角色名称';
COMMENT ON COLUMN customer_service_role.description IS '角色描述'; COMMENT ON COLUMN customer_service_role.description IS '角色描述';
COMMENT ON COLUMN customer_service_role.prompt IS '系统提示词'; COMMENT ON COLUMN customer_service_role.prompt IS '系统提示词';
COMMENT ON COLUMN customer_service_role.sort_order IS '排序权重';
COMMENT ON COLUMN customer_service_role.enabled IS '是否启用'; COMMENT ON COLUMN customer_service_role.enabled IS '是否启用';
COMMENT ON COLUMN customer_service_role.create_time IS '创建时间'; COMMENT ON COLUMN customer_service_role.create_time IS '创建时间';
COMMENT ON COLUMN customer_service_role.update_time IS '更新时间'; COMMENT ON COLUMN customer_service_role.update_time IS '更新时间';
COMMENT ON COLUMN customer_service_role.is_delete IS '逻辑删除'; COMMENT ON COLUMN customer_service_role.is_delete IS '逻辑删除';
COMMENT ON COLUMN customer_service_role.allowed_mcp_tools IS '允许使用的 MCP 工具列表(JSONB 数组,空=不允许,["*"]=全部允许)';
-- 默认角色种子数据 -- 默认角色种子数据
INSERT INTO customer_service_role (role_key, name, description, sort_order, enabled)
VALUES ('general', '客服', '通用客服角色', 0, TRUE)
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('general', '客服', '通用客服角色', TRUE)
ON CONFLICT (role_key) DO NOTHING; ON CONFLICT (role_key) DO NOTHING;
INSERT INTO customer_service_role (role_key, name, description, sort_order, enabled)
VALUES ('finance', '财务', '财务相关客服角色', 1, TRUE)
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('finance', '财务', '财务相关客服角色', TRUE)
ON CONFLICT (role_key) DO NOTHING; ON CONFLICT (role_key) DO NOTHING;
INSERT INTO customer_service_role (role_key, name, description, sort_order, enabled)
VALUES ('administration', '行政', '行政相关客服角色', 2, TRUE)
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('administration', '行政', '行政相关客服角色', TRUE)
ON CONFLICT (role_key) DO NOTHING; ON CONFLICT (role_key) DO NOTHING;
-- ============================================================ -- ============================================================
@ -261,6 +268,40 @@ COMMENT ON COLUMN conversation_session.role_id IS '归属角色ID';
COMMENT ON COLUMN conversation_session.create_time IS '创建时间'; COMMENT ON COLUMN conversation_session.create_time IS '创建时间';
COMMENT ON COLUMN conversation_session.update_time IS '更新时间'; COMMENT ON COLUMN conversation_session.update_time IS '更新时间';
-- ============================================================
-- 表 7.5: mcp_server_config — MCP Server 配置表
-- ============================================================
CREATE TABLE IF NOT EXISTS mcp_server_config (
id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
transport_type VARCHAR(20) NOT NULL DEFAULT 'sse',
server_url VARCHAR(500),
command VARCHAR(500),
args VARCHAR(1000),
env_vars JSONB DEFAULT '{}' NOT NULL,
description VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_mcp_server_config_active ON mcp_server_config (is_active) WHERE is_delete = FALSE;
COMMENT ON TABLE mcp_server_config IS 'MCP Server 配置表(管理外部 MCP Server 连接配置)';
COMMENT ON COLUMN mcp_server_config.id IS '主键(雪花算法生成)';
COMMENT ON COLUMN mcp_server_config.name IS '配置名称';
COMMENT ON COLUMN mcp_server_config.transport_type IS '传输类型: stdio(标准输入输出) / sse(Server-Sent Events)';
COMMENT ON COLUMN mcp_server_config.server_url IS 'SSE 模式的 MCP Server URL';
COMMENT ON COLUMN mcp_server_config.command IS 'stdio 模式的启动命令';
COMMENT ON COLUMN mcp_server_config.args IS 'stdio 模式的命令参数(多个用逗号分隔)';
COMMENT ON COLUMN mcp_server_config.env_vars IS '环境变量(JSONB 键值对)';
COMMENT ON COLUMN mcp_server_config.description IS '描述说明';
COMMENT ON COLUMN mcp_server_config.is_active IS '是否启用';
COMMENT ON COLUMN mcp_server_config.create_time IS '创建时间';
COMMENT ON COLUMN mcp_server_config.update_time IS '更新时间';
COMMENT ON COLUMN mcp_server_config.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';
-- ============================================================ -- ============================================================
-- 表 8: vector_store — 向量存储表 -- 表 8: vector_store — 向量存储表
-- 注意: Spring AI PgVectorStore 配置了 initializeSchema(true), -- 注意: Spring AI PgVectorStore 配置了 initializeSchema(true),
@ -452,6 +493,7 @@ CREATE TABLE IF NOT EXISTS knowledge_faq (
answer TEXT NOT NULL, answer TEXT NOT NULL,
similar_questions TEXT NOT NULL DEFAULT '[]', similar_questions TEXT NOT NULL DEFAULT '[]',
category VARCHAR(128), category VARCHAR(128),
category_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', status VARCHAR(20) NOT NULL DEFAULT 'ENABLED',
priority INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL DEFAULT 0,
hit_count BIGINT NOT NULL DEFAULT 0, hit_count BIGINT NOT NULL DEFAULT 0,
@ -461,9 +503,10 @@ CREATE TABLE IF NOT EXISTS knowledge_faq (
is_delete BOOLEAN NOT NULL DEFAULT FALSE is_delete BOOLEAN NOT NULL DEFAULT FALSE
); );
CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = FALSE;
CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category);
CREATE INDEX IF NOT EXISTS idx_faq_priority ON knowledge_faq (priority DESC);
CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = FALSE;
CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category);
CREATE INDEX IF NOT EXISTS idx_faq_category_id ON knowledge_faq (category_id);
CREATE INDEX IF NOT EXISTS idx_faq_priority ON knowledge_faq (priority DESC);
COMMENT ON TABLE knowledge_faq IS 'FAQ 知识库表(用于意图路由后的精准问答匹配)'; COMMENT ON TABLE knowledge_faq IS 'FAQ 知识库表(用于意图路由后的精准问答匹配)';
COMMENT ON COLUMN knowledge_faq.id IS '主键(雪花算法生成)'; COMMENT ON COLUMN knowledge_faq.id IS '主键(雪花算法生成)';
@ -499,6 +542,29 @@ COMMENT ON COLUMN faq_embedding.embedding IS '向量嵌入(维度由 knowled
COMMENT ON COLUMN faq_embedding.model_name IS '使用的 Embedding 模型名称(用于检测模型变更后重新计算)'; COMMENT ON COLUMN faq_embedding.model_name IS '使用的 Embedding 模型名称(用于检测模型变更后重新计算)';
COMMENT ON COLUMN faq_embedding.create_time IS '创建时间'; COMMENT ON COLUMN faq_embedding.create_time IS '创建时间';
-- ============================================================
-- 清理租户相关数据(移除多租户架构)
-- ============================================================
DROP TABLE IF EXISTS tenant CASCADE;
ALTER TABLE api_key DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS idx_api_key_tenant_id;
ALTER TABLE customer_service_role DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS idx_csr_tenant_id;
ALTER TABLE knowledge_category DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS idx_kc_tenant_id;
ALTER TABLE knowledge_faq DROP COLUMN IF EXISTS tenant_id;
DROP INDEX IF EXISTS idx_faq_tenant_id;
-- ============================================================
-- API Key 角色绑定:新增 role_ids 列
-- ============================================================
ALTER TABLE api_key ADD COLUMN IF NOT EXISTS role_ids JSONB DEFAULT '[]' NOT NULL;
COMMENT ON COLUMN api_key.role_ids IS '绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)';
-- ============================================================ -- ============================================================
-- 完成! -- 完成!
-- 共创建 14 张表及对应索引。 -- 共创建 14 张表及对应索引。

129
src/main/resources/static/components/ApiKeyManager.js

@ -1,9 +1,9 @@
/** /**
* API Key 管理组件 * API Key 管理组件
* 支持 API Key 的生成吊销启用删除复制分页查询
* 支持 API Key 的生成吊销启用删除复制分页查询角色绑定
*/ */
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey } from '../js/api.js'
import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey, getAllRoles, updateApiKeyRoles } from '../js/api.js'
import { toast } from '../js/utils.js' import { toast } from '../js/utils.js'
export default { export default {
@ -25,14 +25,15 @@ export default {
<th style="text-align:left;padding:8px;">Key</th> <th style="text-align:left;padding:8px;">Key</th>
<th style="text-align:center;padding:8px;">频率限制</th> <th style="text-align:center;padding:8px;">频率限制</th>
<th style="text-align:center;padding:8px;">调用次数</th> <th style="text-align:center;padding:8px;">调用次数</th>
<th style="text-align:center;padding:8px;">绑定角色</th>
<th style="text-align:center;padding:8px;">过期时间</th> <th style="text-align:center;padding:8px;">过期时间</th>
<th style="text-align:center;padding:8px;">状态</th> <th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th> <th style="text-align:right;padding:8px;">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"><td colspan="7" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="keys.length === 0"><td colspan="7" style="text-align:center;padding:20px;color:#999;">暂无 API Key</td></tr>
<tr v-if="loading"><td colspan="8" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="keys.length === 0"><td colspan="8" style="text-align:center;padding:20px;color:#999;">暂无 API Key</td></tr>
<tr v-for="k in keys" :key="k.id" style="border-bottom:1px solid var(--border);"> <tr v-for="k in keys" :key="k.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;"> <td style="padding:8px;">
<div style="font-weight:500;">{{ k.name || '未命名' }}</div> <div style="font-weight:500;">{{ k.name || '未命名' }}</div>
@ -50,6 +51,10 @@ export default {
<span v-if="k.maxCalls" style="color:#999;">/ {{ k.maxCalls }}</span> <span v-if="k.maxCalls" style="color:#999;">/ {{ k.maxCalls }}</span>
<span v-else style="color:#999;">不限</span> <span v-else style="color:#999;">不限</span>
</td> </td>
<td style="text-align:center;padding:8px;font-size:12px;">
<span v-if="getBoundRoleCount(k.roleIds) > 0" style="color:var(--primary);">{{ getBoundRoleCount(k.roleIds) }} 个角色</span>
<span v-else style="color:#999;">不限全部</span>
</td>
<td style="text-align:center;padding:8px;font-size:12px;"> <td style="text-align:center;padding:8px;font-size:12px;">
{{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }} {{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }}
</td> </td>
@ -59,6 +64,7 @@ export default {
</span> </span>
</td> </td>
<td style="text-align:right;padding:8px;"> <td style="text-align:right;padding:8px;">
<button @click="openRoleDialog(k)" style="padding:3px 8px;background:none;border:1px solid var(--primary);color:var(--primary);border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">绑定角色</button>
<button v-if="k.enabled" @click="doRevoke(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">吊销</button> <button v-if="k.enabled" @click="doRevoke(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">吊销</button>
<button v-else @click="doEnable(k.id)" style="padding:3px 8px;background:none;border:1px solid #28a745;color:#28a745;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">启用</button> <button v-else @click="doEnable(k.id)" style="padding:3px 8px;background:none;border:1px solid #28a745;color:#28a745;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">启用</button>
<button @click="doDelete(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;font-size:12px;">删除</button> <button @click="doDelete(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;font-size:12px;">删除</button>
@ -100,6 +106,16 @@ export default {
<label style="display:block;margin-bottom:4px;font-size:13px;">过期时间可选</label> <label style="display:block;margin-bottom:4px;font-size:13px;">过期时间可选</label>
<input v-model="form.expireTime" type="datetime-local" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" /> <input v-model="form.expireTime" type="datetime-local" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div> </div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">绑定客服角色可选不选则不限制</label>
<div v-if="allRoles.length === 0" style="font-size:12px;color:#999;padding:4px 0;">暂无可用角色</div>
<div v-for="role in allRoles" :key="role.id" style="display:inline-flex;align-items:center;gap:4px;margin:2px 8px 2px 0;font-size:13px;">
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" :value="Number(role.id)" v-model="form.roleIds" />
{{ role.name }} <span style="color:#999;font-size:11px;">({{ role.role_key }})</span>
</label>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;"> <div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showCreateDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button> <button @click="showCreateDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doCreate" :disabled="!form.name" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">创建</button> <button @click="doCreate" :disabled="!form.name" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">创建</button>
@ -121,6 +137,37 @@ export default {
</div> </div>
</div> </div>
</div> </div>
<!-- 角色绑定弹窗 -->
<div v-if="showRoleDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:8px;">绑定客服角色 {{ editingKeyName }}</h4>
<p style="color:#666;font-size:13px;margin-bottom:16px;">勾选允许此 API Key 使用的客服角色不勾选任何角色则不限制可使用所有角色</p>
<div v-if="roleLoading" style="text-align:center;padding:20px;color:#999;">加载角色列表...</div>
<div v-else-if="allRoles.length === 0" style="text-align:center;padding:20px;color:#999;">暂无可用角色请先创建客服角色</div>
<div v-else>
<div v-for="role in allRoles" :key="role.id"
style="display:flex;align-items:center;gap:8px;padding:10px 12px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;">
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;flex:1;">
<input type="checkbox" v-model="selectedRoleIds" :value="Number(role.id)" />
<div>
<div style="font-weight:500;font-size:14px;">{{ role.name }}</div>
<div style="font-size:12px;color:#999;">{{ role.role_key }}<span v-if="role.description"> · {{ role.description }}</span></div>
</div>
</label>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;">
<span style="font-size:12px;color:#999;">已选 {{ selectedRoleIds.length }} 个角色{{ selectedRoleIds.length === 0 ? '(不限制)' : '' }}</span>
<div style="display:flex;gap:8px;">
<button @click="showRoleDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doSaveRoles" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button>
</div>
</div>
</div>
</div>
</div> </div>
`, `,
@ -133,7 +180,15 @@ export default {
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
const showKeyReveal = ref(false) const showKeyReveal = ref(false)
const revealedKey = ref('') const revealedKey = ref('')
const form = ref({ name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '' })
const form = ref({ name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] })
// 角色绑定相关状态
const allRoles = ref([])
const roleLoading = ref(false)
const showRoleDialog = ref(false)
const editingKeyId = ref(null)
const editingKeyName = ref('')
const selectedRoleIds = ref([])
async function loadList() { async function loadList() {
loading.value = true loading.value = true
@ -149,8 +204,20 @@ export default {
loading.value = false loading.value = false
} }
async function loadRoles() {
roleLoading.value = true
try {
const res = await getAllRoles()
allRoles.value = res.data || []
} catch (e) {
console.warn('加载角色列表失败', e)
}
roleLoading.value = false
}
function openCreateDialog() { function openCreateDialog() {
form.value = { name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '' }
form.value = { name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] }
loadRoles()
showCreateDialog.value = true showCreateDialog.value = true
} }
@ -159,6 +226,7 @@ export default {
if (form.value.rateLimit) data.rateLimit = form.value.rateLimit if (form.value.rateLimit) data.rateLimit = form.value.rateLimit
if (form.value.maxCalls) data.maxCalls = form.value.maxCalls if (form.value.maxCalls) data.maxCalls = form.value.maxCalls
if (form.value.expireTime) data.expireTime = form.value.expireTime if (form.value.expireTime) data.expireTime = form.value.expireTime
if (form.value.roleIds && form.value.roleIds.length > 0) data.roleIds = form.value.roleIds
try { try {
const res = await createApiKey(data) const res = await createApiKey(data)
@ -205,6 +273,51 @@ export default {
} catch (e) { toast('删除失败: ' + e.message, 'error') } } catch (e) { toast('删除失败: ' + e.message, 'error') }
} }
function openRoleDialog(key) {
editingKeyId.value = key.id
editingKeyName.value = key.name || '未命名'
try {
selectedRoleIds.value = key.roleIds ? JSON.parse(key.roleIds).map(Number) : []
} catch { selectedRoleIds.value = [] }
loadRoles()
showRoleDialog.value = true
}
function toggleRole(roleId) {
const idx = selectedRoleIds.value.indexOf(roleId)
if (idx >= 0) selectedRoleIds.value.splice(idx, 1)
else selectedRoleIds.value.push(roleId)
}
function toggleRole(roleId) {
const idx = selectedRoleIds.value.indexOf(roleId)
if (idx >= 0) selectedRoleIds.value.splice(idx, 1)
else selectedRoleIds.value.push(roleId)
}
async function doSaveRoles() {
try {
const res = await updateApiKeyRoles(editingKeyId.value, selectedRoleIds.value)
if (res.success) {
toast('角色绑定更新成功', 'success')
showRoleDialog.value = false
loadList()
} else {
toast(res.message || '更新失败', 'error')
}
} catch (e) {
toast('更新失败: ' + e.message, 'error')
}
}
function getBoundRoleCount(roleIdsStr) {
if (!roleIdsStr) return 0
try {
const arr = JSON.parse(roleIdsStr)
return Array.isArray(arr) ? arr.length : 0
} catch { return 0 }
}
function maskKey(key) { function maskKey(key) {
if (!key || key.length < 12) return key || '***' if (!key || key.length < 12) return key || '***'
return key.substring(0, 6) + '****' + key.substring(key.length - 4) return key.substring(0, 6) + '****' + key.substring(key.length - 4)
@ -242,9 +355,11 @@ export default {
return { return {
keys, loading, page, pageSize, total, keys, loading, page, pageSize, total,
showCreateDialog, showKeyReveal, revealedKey, form, showCreateDialog, showKeyReveal, revealedKey, form,
allRoles, roleLoading, showRoleDialog, editingKeyId, editingKeyName, selectedRoleIds,
loadList, openCreateDialog, doCreate, loadList, openCreateDialog, doCreate,
doRevoke, doEnable, doDelete, doRevoke, doEnable, doDelete,
maskKey, copyKey, formatDate
openRoleDialog, doSaveRoles,
getBoundRoleCount, maskKey, copyKey, formatDate
} }
} }
} }

41
src/main/resources/static/components/ChatPanel.js

@ -3,7 +3,7 @@
*/ */
import { ref, computed, nextTick, onMounted } from 'vue' import { ref, computed, nextTick, onMounted } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js' import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js'
import { toast, readSSEStream, renderMarkdown } from '../js/utils.js'
import { toast, readSSEStream, readSSEStreamWithEvents, renderMarkdown } from '../js/utils.js'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import MessageSources from './MessageSources.js' import MessageSources from './MessageSources.js'
@ -108,6 +108,16 @@ export default {
</div> </div>
</template> </template>
<template v-else> <template v-else>
<!-- MCP 工具调用展示 -->
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls">
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item">
<div class="mcp-tool-header">
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
<span class="mcp-tool-name">{{ tc.tool }}</span>
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span>
</div>
</div>
</div>
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div> <div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div>
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div> <div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<div v-else class="msg-bubble">{{ m.content }}</div> <div v-else class="msg-bubble">{{ m.content }}</div>
@ -310,7 +320,7 @@ export default {
isSending.value = true isSending.value = true
messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() }) messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() })
const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [] }
const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [], toolCalls: [] }
messages.value.push(assistantMsg) messages.value.push(assistantMsg)
await scrollToBottom() await scrollToBottom()
@ -325,10 +335,29 @@ export default {
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId) assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId)
} else if (isRagMode.value) { } else if (isRagMode.value) {
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId) const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId)
await readSSEStream(url, async (chunk) => {
assistantMsg.content += chunk
await scrollToBottom()
}, () => {})
await readSSEStreamWithEvents(url, {
onMessage: async (chunk) => {
assistantMsg.content += chunk
await scrollToBottom()
},
onToolCallStart: (data) => {
assistantMsg.toolCalls.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
},
onToolCallResult: (data) => {
const tc = assistantMsg.toolCalls.find(t => t.tool === data.tool && t.status === 'running')
if (tc) {
tc.status = 'done'
tc.result = data.result
tc.latencyMs = data.latencyMs
}
scrollToBottom()
},
onError: (data) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
},
onDone: () => {}
})
} else if (mode.value === 'sync') { } else if (mode.value === 'sync') {
assistantMsg.content = await chatSync(text, cid, roleId) assistantMsg.content = await chatSync(text, cid, roleId)
} else { } else {

9
src/main/resources/static/components/DocList.js

@ -9,7 +9,7 @@
*/ */
import { ref, computed, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments } from '../js/api.js'
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '../js/api.js'
import { toast, formatDate, formatBytes } from '../js/utils.js' import { toast, formatDate, formatBytes } from '../js/utils.js'
export default { export default {
@ -106,6 +106,7 @@ export default {
<td>{{ formatDate(d.createTime) }}</td> <td>{{ formatDate(d.createTime) }}</td>
<td> <td>
<button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button> <button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button>
<button v-if="d.filePath" class="btn btn-sm btn-outline" @click="download(d.id)" title="下载原始文件">下载</button>
<button class="btn btn-sm btn-warn" @click="reprocess(d.id)">重新处理</button> <button class="btn btn-sm btn-warn" @click="reprocess(d.id)">重新处理</button>
<button class="btn btn-sm btn-danger" @click="remove(d.id)">删除</button> <button class="btn btn-sm btn-danger" @click="remove(d.id)">删除</button>
</td> </td>
@ -265,6 +266,10 @@ export default {
store.openDetail(id) store.openDetail(id)
} }
function download(id) {
downloadDocument(id)
}
// P1-2.1: 切换单个文档启用/禁用 // P1-2.1: 切换单个文档启用/禁用
async function toggle(doc) { async function toggle(doc) {
try { try {
@ -420,7 +425,7 @@ export default {
formatSize, debouncedLoad, getDocTags, getRowStyle, formatSize, debouncedLoad, getDocTags, getRowStyle,
load, toggleSelect, toggleSelectAll, clearSelection, load, toggleSelect, toggleSelectAll, clearSelection,
viewDetail, toggle, remove, reprocess, batchRemove, batchReprocess, viewDetail, toggle, remove, reprocess, batchRemove, batchReprocess,
batchEnable, batchDisable, batchMove, formatDate
batchEnable, batchDisable, batchMove, formatDate, download
} }
} }
} }

524
src/main/resources/static/components/McpServerManager.js

@ -0,0 +1,524 @@
/**
* MCP 服务管理组件
* 支持 SSE / stdio 两种传输类型的 MCP Server 配置管理
* 功能列表展示新建/编辑/删除启用/禁用切换测试连接刷新连接分页
*/
import { ref, computed, onMounted, onUnmounted } from 'vue'
import * as api from '../js/api.js'
import { toast, formatDate } from '../js/utils.js'
// 传输类型选项
const TRANSPORT_OPTIONS = [
{ value: 'sse', label: 'SSE(HTTP 服务)' },
{ value: 'stdio', label: 'Stdio(本地进程)' }
]
export default {
template: `
<div class="card">
<h2>MCP 服务管理</h2>
<p style="font-size:13px;color:var(--sub);margin:-4px 0 16px;">
管理 MCP (Model Context Protocol) 服务端配置支持 SSE Stdio 两种传输方式
</p>
<!-- 操作栏 -->
<div class="input-row" style="flex-wrap:wrap;margin-bottom:16px;">
<button class="btn btn-primary btn-sm" @click="openAddModal">新建配置</button>
<button class="btn btn-outline btn-sm" @click="refreshConnections" :disabled="refreshLoading">
{{ refreshLoading ? '刷新中...' : '刷新连接' }}
</button>
<button class="btn btn-outline btn-sm" @click="load(currentPage)">刷新列表</button>
<span style="margin-left:auto;font-size:12px;color:var(--sub);">
{{ total }} 条配置
</span>
</div>
<!-- 列表表格 -->
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>名称</th>
<th>传输类型</th>
<th>URL / 命令</th>
<th>启用状态</th>
<th>描述</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="servers.length === 0">
<td colspan="7" style="text-align:center;color:var(--sub);">暂无 MCP 服务配置</td>
</tr>
<tr v-for="s in servers" :key="s.id">
<td><strong>{{ s.name || '-' }}</strong></td>
<td>
<span class="badge" :class="s.transport_type === 'sse' ? 'badge-get' : ''">
{{ s.transport_type === 'sse' ? 'SSE' : 'Stdio' }}
</span>
</td>
<td style="font-size:12px;color:#6b7280;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
:title="getDisplayUrl(s)">
{{ getDisplayUrl(s) || '-' }}
</td>
<td>
<label class="toggle-switch" :title="s.is_active ? '已启用' : '已禁用'">
<input type="checkbox" :checked="s.is_active" @change="toggleActive(s)">
<span class="toggle-slider"></span>
</label>
</td>
<td style="font-size:12px;color:#6b7280;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
:title="s.description">
{{ s.description || '-' }}
</td>
<td style="font-size:12px;color:#6b7280;">{{ formatDate(s.create_time) }}</td>
<td style="white-space:nowrap;">
<button class="btn btn-sm btn-outline" @click="testConnection(s)" :disabled="testLoading[s.id]" title="测试连接">
{{ testLoading[s.id] ? '测试中...' : '测试' }}
</button>
<button class="btn btn-sm btn-outline" @click="openEditModal(s)" title="编辑">编辑</button>
<button class="btn btn-sm btn-danger" @click="remove(s.id, s.name)" title="删除">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination" v-if="totalPages > 1">
<button :disabled="currentPage <= 1" @click="load(currentPage - 1)">上一页</button>
<template v-for="i in totalPages" :key="i">
<button v-if="i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)"
:class="{ active: i === currentPage }" @click="load(i)">{{ i }}</button>
<span v-else-if="i === currentPage - 3 || i === currentPage + 3" style="padding:6px;">...</span>
</template>
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button>
</div>
<!-- 测试结果提示 -->
<div v-if="testResult.visible" class="test-result-toast"
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
background: testResult.success ? '#16a34a' : '#dc2626' }">
<div style="font-weight:600;">{{ testResult.success ? '连接成功' : '连接失败' }}</div>
<div v-if="testResult.message" style="font-size:12px;margin-top:4px;opacity:0.9;">{{ testResult.message }}</div>
</div>
</div>
<!-- 新建/编辑弹窗 -->
<div class="modal-overlay" :class="{ active: showModal }">
<div class="modal-box" style="max-width:600px;">
<button class="modal-close" @click="closeModal">&times;</button>
<h2 style="margin-bottom:16px;">{{ modalMode === 'add' ? '新建 MCP 服务配置' : '编辑 MCP 服务配置' }}</h2>
<div style="display:flex;flex-direction:column;gap:16px;">
<!-- 名称 -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
服务名称 <span style="color:#dc2626;">*</span>
</label>
<input type="text" class="input" v-model.trim="form.name" placeholder="如:天气查询服务">
</div>
<!-- 传输类型 -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">
传输类型 <span style="color:#dc2626;">*</span>
</label>
<div style="display:flex;gap:8px;">
<button v-for="opt in transportOptions" :key="opt.value"
type="button"
@click="form.transportType = opt.value"
style="padding:6px 16px;border-radius:6px;font-size:13px;cursor:pointer;transition:all 0.15s;"
:style="form.transportType === opt.value
? 'background:#2563eb;color:white;border:2px solid #2563eb;font-weight:600;'
: 'background:white;color:#374151;border:2px solid #e5e7eb;font-weight:400;'">
{{ opt.label }}
</button>
</div>
</div>
<!-- SSE 模式URL -->
<div v-if="form.transportType === 'sse'">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
Server URL <span style="color:#dc2626;">*</span>
</label>
<input type="text" class="input" v-model.trim="form.url" placeholder="如:http://localhost:3001/sse">
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
MCP 服务的 SSE 端点地址
</div>
</div>
<!-- Stdio 模式Command + Args -->
<template v-if="form.transportType === 'stdio'">
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
Command <span style="color:#dc2626;">*</span>
</label>
<input type="text" class="input" v-model.trim="form.command" placeholder="如:npx, python, node">
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
启动 MCP 服务的可执行命令
</div>
</div>
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
Args命令参数每行一个
</label>
<textarea class="input" v-model="form.argsText" rows="3"
placeholder="-m&#10;mcp-server-fetch&#10;--verbose"></textarea>
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
每行一个参数留空则不传参
</div>
</div>
</template>
<!-- 环境变量 -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">
环境变量可选
</label>
<textarea class="input" v-model="form.envText" rows="2"
placeholder="KEY1=value1&#10;KEY2=value2"></textarea>
<div style="font-size:11px;color:#6b7280;margin-top:4px;">
格式KEY=value每行一组留空则不设置
</div>
</div>
<!-- 描述 -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label>
<textarea class="input" v-model.trim="form.description" rows="2" placeholder="可选,填写服务用途说明"></textarea>
</div>
</div>
<!-- 按钮 -->
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;">
<button class="btn btn-outline" @click="closeModal">取消</button>
<button class="btn btn-primary" @click="save" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
`,
setup() {
// ==================== 列表状态 ====================
const servers = ref([])
const currentPage = ref(1)
const totalPages = ref(1)
const total = ref(0)
const pageSize = 10
// ==================== 弹窗状态 ====================
const showModal = ref(false)
const modalMode = ref('add') // 'add' | 'edit'
const editId = ref(null)
const saving = ref(false)
// 表单数据
const form = ref(createEmptyForm())
function createEmptyForm() {
return {
name: '',
transportType: 'sse',
url: '',
command: '',
argsText: '',
envText: '',
description: ''
}
}
// ==================== 测试连接状态 ====================
const testLoading = ref({})
const testResult = ref({ visible: false, success: false, message: '' })
let testResultTimer = null
// ==================== 刷新连接状态 ====================
const refreshLoading = ref(false)
// ==================== 传输类型选项 ====================
const transportOptions = TRANSPORT_OPTIONS
// ==================== 数据加载 ====================
async function load(p = 1) {
currentPage.value = p
try {
const json = await api.listMcpServers(p, pageSize)
if (json.success) {
servers.value = json.data || []
total.value = json.total || 0
totalPages.value = json.pages || 1
} else {
toast(json.message || '查询失败', 'error')
}
} catch (e) {
toast('加载列表失败:' + e.message, 'error')
}
}
// ==================== 弹窗操作 ====================
function openAddModal() {
modalMode.value = 'add'
editId.value = null
form.value = createEmptyForm()
showModal.value = true
}
function openEditModal(server) {
modalMode.value = 'edit'
editId.value = server.id
// 解析 args 数组为换行文本
let argsText = ''
if (server.args && Array.isArray(server.args)) {
argsText = server.args.join('\n')
} else if (typeof server.args === 'string' && server.args) {
try {
const parsed = JSON.parse(server.args)
if (Array.isArray(parsed)) argsText = parsed.join('\n')
else argsText = server.args
} catch (e) {
argsText = server.args
}
}
// 解析环境变量对象为换行文本
let envText = ''
if (server.env_vars && typeof server.env_vars === 'object') {
envText = Object.entries(server.env_vars).map(([k, v]) => k + '=' + v).join('\n')
} else if (typeof server.env_vars === 'string' && server.env_vars) {
try {
const parsed = JSON.parse(server.env_vars)
if (typeof parsed === 'object' && parsed !== null) {
envText = Object.entries(parsed).map(([k, v]) => k + '=' + v).join('\n')
} else {
envText = server.env_vars
}
} catch (e) {
envText = server.env_vars
}
}
form.value = {
name: server.name || '',
transportType: server.transport_type || 'sse',
url: server.server_url || '',
command: server.command || '',
argsText: argsText,
envText: envText,
description: server.description || ''
}
showModal.value = true
}
function closeModal() {
showModal.value = false
}
// ==================== 保存 ====================
function buildSaveData() {
const f = form.value
const data = {
name: f.name,
transportType: f.transportType,
description: f.description
}
if (f.transportType === 'sse') {
data.serverUrl = f.url
} else {
data.command = f.command
// 解析 args:按换行拆分,过滤空行
if (f.argsText && f.argsText.trim()) {
data.args = f.argsText.split('\n').map(s => s.trim()).filter(Boolean)
} else {
data.args = []
}
}
// 解析环境变量:按换行拆分,解析 KEY=value
if (f.envText && f.envText.trim()) {
const envObj = {}
f.envText.split('\n').forEach(line => {
const trimmed = line.trim()
if (!trimmed) return
const eqIdx = trimmed.indexOf('=')
if (eqIdx > 0) {
envObj[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1)
}
})
data.envVars = envObj
}
return data
}
async function save() {
const f = form.value
// 校验
if (!f.name || !f.name.trim()) {
toast('请填写服务名称', 'error')
return
}
if (f.transportType === 'sse') {
if (!f.url || !f.url.trim()) {
toast('请填写 Server URL', 'error')
return
}
} else {
if (!f.command || !f.command.trim()) {
toast('请填写 Command', 'error')
return
}
}
saving.value = true
try {
const data = buildSaveData()
let json
if (modalMode.value === 'add') {
json = await api.createMcpServer(data)
} else {
json = await api.updateMcpServer(editId.value, data)
}
if (json.success) {
toast(modalMode.value === 'add' ? '创建成功' : '更新成功', 'success')
closeModal()
load(currentPage.value)
} else {
toast(json.message || '操作失败', 'error')
}
} catch (e) {
toast('保存失败:' + e.message, 'error')
} finally {
saving.value = false
}
}
// ==================== 删除 ====================
async function remove(id, name) {
if (!confirm('确定删除配置「' + (name || id) + '」?删除后不可恢复。')) return
try {
const json = await api.deleteMcpServer(id)
if (json.success) {
toast('删除成功', 'success')
load(currentPage.value)
} else {
toast(json.message || '删除失败', 'error')
}
} catch (e) {
toast('删除失败:' + e.message, 'error')
}
}
// ==================== 启用/禁用 ====================
async function toggleActive(server) {
const newActive = !server.is_active
try {
const json = await api.toggleMcpServer(server.id, newActive)
if (json.success) {
toast(newActive ? '已启用' : '已禁用', 'success')
// 乐观更新本地状态,避免刷新列表
server.is_active = newActive
} else {
toast(json.message || '操作失败', 'error')
}
} catch (e) {
toast('操作失败:' + e.message, 'error')
}
}
// ==================== 测试连接 ====================
async function testConnection(server) {
testLoading.value = { ...testLoading.value, [server.id]: true }
try {
const json = await api.testMcpServer(server.id)
showTestResult(json)
} catch (e) {
showTestResult({ success: false, message: e.message })
} finally {
testLoading.value = { ...testLoading.value, [server.id]: false }
}
}
function showTestResult(result) {
if (testResultTimer) clearTimeout(testResultTimer)
testResult.value = {
visible: true,
success: result.success,
message: result.message || ''
}
testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000)
}
// ==================== 刷新连接 ====================
async function refreshConnections() {
refreshLoading.value = true
try {
const json = await api.refreshMcpServers()
if (json.success) {
toast('连接已刷新', 'success')
load(currentPage.value)
} else {
toast(json.message || '刷新失败', 'error')
}
} catch (e) {
toast('刷新失败:' + e.message, 'error')
} finally {
refreshLoading.value = false
}
}
// ==================== 工具函数 ====================
function getDisplayUrl(server) {
if (server.transport_type === 'sse') {
return server.server_url || '-'
}
// stdio 模式:显示 command + args
let display = server.command || ''
if (server.args) {
// args 可能是字符串或数组
const argsStr = Array.isArray(server.args) ? server.args.join(' ') : server.args
display += ' ' + argsStr
}
return display || '-'
}
// ==================== 生命周期 ====================
onMounted(() => {
load()
})
onUnmounted(() => {
if (testResultTimer) clearTimeout(testResultTimer)
})
return {
// 列表
servers, currentPage, totalPages, total,
// 弹窗
showModal, modalMode, form, saving, transportOptions,
// 测试
testLoading, testResult,
// 刷新
refreshLoading,
// 方法
load, openAddModal, openEditModal, closeModal, save, remove,
toggleActive, testConnection, refreshConnections, getDisplayUrl, formatDate
}
}
}

29
src/main/resources/static/components/RoleManager.js

@ -19,14 +19,24 @@ export default {
<!-- 新增 / 编辑 表单 --> <!-- 新增 / 编辑 表单 -->
<div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:16px;"> <div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:16px;">
<div class="input-row"> <div class="input-row">
<input class="input" v-model="form.name" placeholder="角色名称 *">
<input class="input" v-model="form.description" placeholder="职责描述(可选)">
<input class="input input-sm" v-model.number="form.sortOrder" type="number" placeholder="排序">
<label class="toggle-line"><input type="checkbox" v-model="form.enabled"><span>启用</span></label>
<div style="flex:1;min-width:0;">
<label style="display:block;font-size:12px;color:var(--sub);margin-bottom:4px;">角色名称 <span style="color:#b91c1c;">*</span></label>
<input class="input" v-model="form.name" placeholder="请输入角色名称">
</div>
<div style="flex:1;min-width:0;">
<label style="display:block;font-size:12px;color:var(--sub);margin-bottom:4px;">职责描述</label>
<input class="input" v-model="form.description" placeholder="可选,简要说明角色职责">
</div>
<div style="display:flex;align-items:flex-end;padding-bottom:6px;">
<label class="toggle-line"><input type="checkbox" v-model="form.enabled"><span>启用</span></label>
</div>
</div>
<div style="margin-top:8px;">
<label style="display:block;font-size:12px;color:var(--sub);margin-bottom:4px;">角色人设 / 风格提示词</label>
<textarea class="textarea" v-model="form.prompt" rows="2"
placeholder="例如:先判断问题归属,再引导用户补充必要信息,并按制度或流程说明下一步。"
style="width:100%;"></textarea>
</div> </div>
<textarea class="textarea" v-model="form.prompt" rows="2"
placeholder="角色人设 / 风格提示词,例如:先判断问题归属,再引导用户补充必要信息,并按制度或流程说明下一步。"
style="margin-top:8px;width:100%;"></textarea>
<div class="input-row" style="margin-top:8px;"> <div class="input-row" style="margin-top:8px;">
<button class="btn btn-success" @click="submit">{{ editingId ? '保存修改' : '新增角色' }}</button> <button class="btn btn-success" @click="submit">{{ editingId ? '保存修改' : '新增角色' }}</button>
<button v-if="editingId" class="btn btn-outline btn-sm" @click="resetForm">取消编辑</button> <button v-if="editingId" class="btn btn-outline btn-sm" @click="resetForm">取消编辑</button>
@ -75,7 +85,7 @@ export default {
setup() { setup() {
const roles = ref([]) const roles = ref([])
const editingId = ref('') const editingId = ref('')
const form = reactive({ name: '', description: '', prompt: '', sortOrder: 0, enabled: true })
const form = reactive({ name: '', description: '', prompt: '', enabled: true })
async function reload() { async function reload() {
try { try {
@ -96,7 +106,6 @@ export default {
form.name = '' form.name = ''
form.description = '' form.description = ''
form.prompt = '' form.prompt = ''
form.sortOrder = 0
form.enabled = true form.enabled = true
} }
@ -105,7 +114,6 @@ export default {
form.name = role.name || '' form.name = role.name || ''
form.description = role.description || '' form.description = role.description || ''
form.prompt = role.prompt || '' form.prompt = role.prompt || ''
form.sortOrder = Number(role.sort_order || 0)
form.enabled = role.enabled !== false form.enabled = role.enabled !== false
} }
@ -118,7 +126,6 @@ export default {
name: form.name, name: form.name,
description: form.description, description: form.description,
prompt: form.prompt, prompt: form.prompt,
sortOrder: form.sortOrder,
enabled: form.enabled enabled: form.enabled
} }
try { try {

9
src/main/resources/static/css/main.css

@ -252,6 +252,15 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.msg-tools button { border:none; background:transparent; color:var(--muted); cursor:pointer; font-size:12px; padding:2px 7px; border-radius:6px; line-height:1.4; transition:all .15s; } .msg-tools button { border:none; background:transparent; color:var(--muted); cursor:pointer; font-size:12px; padding:2px 7px; border-radius:6px; line-height:1.4; transition:all .15s; }
.msg-tools button:hover { background:#f4f4f5; color:var(--text); } .msg-tools button:hover { background:#f4f4f5; color:var(--text); }
.msg-tools button:focus-visible { outline:none; box-shadow:var(--ring); } .msg-tools button:focus-visible { outline:none; box-shadow:var(--ring); }
/* ==================== MCP 工具调用展示 ==================== */
.mcp-tool-calls { margin-bottom:8px; border-left:3px solid var(--primary, #4f46e5); padding-left:10px; }
.mcp-tool-item { padding:4px 0; }
.mcp-tool-header { display:flex; align-items:center; gap:6px; font-size:12px; color:#6b7280; }
.mcp-tool-icon { font-size:14px; }
.mcp-tool-name { font-family:monospace; background:#f3f4f6; padding:1px 6px; border-radius:4px; font-size:11px; }
.mcp-tool-latency { color:#9ca3af; font-size:11px; }
.chat-composer { border-top:1px solid var(--border); padding:16px 24px 20px; background:#fff; } .chat-composer { border-top:1px solid var(--border); padding:16px 24px 20px; background:#fff; }
.composer-box { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; border:1px solid var(--border); border-radius:14px; padding:8px 8px 8px 14px; transition:border-color .15s; } .composer-box { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; border:1px solid var(--border); border-radius:14px; padding:8px 8px 8px 14px; transition:border-color .15s; }
.composer-box:focus-within { border-color:var(--text); } .composer-box:focus-within { border-color:var(--text); }

134
src/main/resources/static/js/api.js

@ -3,15 +3,19 @@
* 封装所有后端接口调用组件只需调用函数处理业务逻辑 * 封装所有后端接口调用组件只需调用函数处理业务逻辑
* P1: 所有请求自动附加 Bearer Token401 时触发未登录事件 * P1: 所有请求自动附加 Bearer Token401 时触发未登录事件
*/ */
import { API_BASE, authHeaders, clearTokens } from './utils.js'
import { API_BASE, authHeaders } from './utils.js'
// ==================== 通用请求 ==================== // ==================== 通用请求 ====================
/** /**
* 处理 401 未授权响应 * 处理 401 未授权响应
* 仅清除 access_token保留 refresh_token 供后续刷新使用
* auth:unauthorized 事件由 app.js 监听并尝试刷新 Token
* 刷新成功则自动恢复刷新失败时再清除 refresh_token
*/ */
function handleUnauthorized() { function handleUnauthorized() {
clearTokens()
localStorage.removeItem('sb_token')
localStorage.removeItem('sb_user')
window.dispatchEvent(new Event('auth:unauthorized')) window.dispatchEvent(new Event('auth:unauthorized'))
} }
@ -21,7 +25,10 @@ function handleUnauthorized() {
async function getJSON(path) { async function getJSON(path) {
const res = await fetch(API_BASE + path, { headers: authHeaders() }) const res = await fetch(API_BASE + path, { headers: authHeaders() })
if (res.status === 401) { handleUnauthorized(); throw new Error('401') } if (res.status === 401) { handleUnauthorized(); throw new Error('401') }
if (res.status === 403) throw new Error('403')
if (res.status === 403) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || '403')
}
return res.json() return res.json()
} }
@ -257,6 +264,58 @@ export function getDocumentChunks(id) {
return getJSON(`/document/${id}/chunks`) return getJSON(`/document/${id}/chunks`)
} }
/**
* 下载文档原始文件
* 使用 fetch 携带 Token通过 Blob 触发浏览器下载
*/
export async function downloadDocument(id) {
try {
const response = await fetch(`/document/download/${id}`, {
headers: authHeaders()
})
if (response.status === 401) {
handleUnauthorized()
throw new Error('未登录或登录已过期')
}
if (!response.ok) {
const error = await response.json().catch(() => ({ message: '下载失败' }))
throw new Error(error.message || `HTTP ${response.status}`)
}
// 从 Content-Disposition 获取文件名
const disposition = response.headers.get('Content-Disposition')
let filename = 'download'
if (disposition) {
// 解析 filename*=UTF-8''xxx 或 filename="xxx"
const utf8Match = disposition.match(/filename\*=UTF-8''(.+?)(?:;|$)/i)
if (utf8Match) {
filename = decodeURIComponent(utf8Match[1])
} else {
const quotedMatch = disposition.match(/filename="(.+?)"/i)
if (quotedMatch) {
filename = decodeURIComponent(quotedMatch[1])
}
}
}
// 创建 Blob 并触发下载
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} catch (e) {
console.error('下载失败:', e)
alert('下载失败:' + e.message)
}
}
/** /**
* 删除文档 * 删除文档
*/ */
@ -1012,3 +1071,72 @@ export function deleteWebhook(id) {
export function testWebhook(id) { export function testWebhook(id) {
return postJSON(`/webhook/${id}/test`, {}) return postJSON(`/webhook/${id}/test`, {})
} }
// ==================== MCP 服务管理 ====================
/**
* MCP Server 配置列表分页
*/
export function listMcpServers(page = 1, size = 10) {
return getJSON(`/mcp-server/list?page=${page}&size=${size}`)
}
/**
* MCP Server 配置详情
*/
export function getMcpServerDetail(id) {
return getJSON(`/mcp-server/${id}`)
}
/**
* 新建 MCP Server 配置
*/
export function createMcpServer(data) {
return postJSON('/mcp-server', data)
}
/**
* 更新 MCP Server 配置
*/
export function updateMcpServer(id, data) {
return putJSONWithBody(`/mcp-server/${id}`, data)
}
/**
* 删除 MCP Server 配置
*/
export function deleteMcpServer(id) {
return deleteJSON(`/mcp-server/${id}`)
}
/**
* 启用/禁用 MCP Server 配置
*/
export function toggleMcpServer(id, active) {
return putJSONWithBody(`/mcp-server/${id}/toggle`, { active })
}
/**
* 测试 MCP Server 连接
*/
export function testMcpServer(id) {
return postJSON(`/mcp-server/${id}/test`, {})
}
/**
* 刷新所有 MCP 客户端连接
*/
export function refreshMcpServers() {
return postJSON('/mcp-server/refresh', {})
}
// ==================== API Key 角色绑定 ====================
/**
* 更新 API Key 绑定的客服角色
* @param {string} id API Key ID
* @param {number[]} roleIds 角色 ID 列表
*/
export function updateApiKeyRoles(id, roleIds) {
return putJSONWithBody(`/api-key/${id}/roles`, { roleIds })
}

33
src/main/resources/static/js/app.js

@ -27,6 +27,7 @@ import UserManager from '../components/UserManager.js'
import DashboardPanel from '../components/DashboardPanel.js' import DashboardPanel from '../components/DashboardPanel.js'
import ApiKeyManager from '../components/ApiKeyManager.js' import ApiKeyManager from '../components/ApiKeyManager.js'
import WebhookManager from '../components/WebhookManager.js' import WebhookManager from '../components/WebhookManager.js'
import McpServerManager from '../components/McpServerManager.js'
const app = createApp({ const app = createApp({
setup() { setup() {
@ -92,20 +93,42 @@ const app = createApp({
store.currentUser = res.data store.currentUser = res.data
store.isLoggedIn = true store.isLoggedIn = true
} else { } else {
// Token 失效,尝试刷新
// 业务失败(如用户被禁用),尝试刷新
tryRefreshToken().then(ok => { tryRefreshToken().then(ok => {
if (!ok) { if (!ok) {
store.isLoggedIn = false store.isLoggedIn = false
clearTokens() clearTokens()
} else {
// 刷新成功后重新获取用户信息
getCurrentUser().then(r => {
if (r.success && r.data) {
store.currentUser = r.data
store.isLoggedIn = true
} else {
store.isLoggedIn = false
clearTokens()
}
}).catch(() => { store.isLoggedIn = false; clearTokens() })
} }
}) })
} }
}).catch(() => { }).catch(() => {
// 401 等错误,刷新尝试
// 401 等错误,尝试刷新(不要先清除 token!)
tryRefreshToken().then(ok => { tryRefreshToken().then(ok => {
if (!ok) { if (!ok) {
store.isLoggedIn = false store.isLoggedIn = false
clearTokens() clearTokens()
} else {
// 刷新成功后重新获取用户信息
getCurrentUser().then(r => {
if (r.success && r.data) {
store.currentUser = r.data
store.isLoggedIn = true
} else {
store.isLoggedIn = false
clearTokens()
}
}).catch(() => { store.isLoggedIn = false; clearTokens() })
} }
}) })
}) })
@ -279,6 +302,11 @@ const app = createApp({
<webhook-manager></webhook-manager> <webhook-manager></webhook-manager>
</div> </div>
<!-- 系统设置 - MCP 服务管理 -->
<div v-if="store.activePage === 'mcp-server'" class="page-pane" style="animation: fadeIn .3s ease;">
<mcp-server-manager></mcp-server-manager>
</div>
<!-- 文档详情弹窗 --> <!-- 文档详情弹窗 -->
<doc-detail></doc-detail> <doc-detail></doc-detail>
</main> </main>
@ -309,5 +337,6 @@ app.component('user-manager', UserManager)
app.component('dashboard-panel', DashboardPanel) app.component('dashboard-panel', DashboardPanel)
app.component('api-key-manager', ApiKeyManager) app.component('api-key-manager', ApiKeyManager)
app.component('webhook-manager', WebhookManager) app.component('webhook-manager', WebhookManager)
app.component('mcp-server-manager', McpServerManager)
app.mount('#app') app.mount('#app')

5
src/main/resources/static/js/store.js

@ -47,7 +47,8 @@ export const MENU_ITEMS = [
{ id: 'audit-log', label: '审计日志', icon: '📋' }, { id: 'audit-log', label: '审计日志', icon: '📋' },
{ id: 'user-manage', label: '用户管理', icon: '👥', roles: ['admin'] }, { id: 'user-manage', label: '用户管理', icon: '👥', roles: ['admin'] },
{ id: 'api-key', label: 'API Key 管理', icon: '🔑', roles: ['admin'] }, { id: 'api-key', label: 'API Key 管理', icon: '🔑', roles: ['admin'] },
{ id: 'webhook', label: 'Webhook 管理', icon: '🔔', roles: ['admin'] }
{ id: 'webhook', label: 'Webhook 管理', icon: '🔔', roles: ['admin'] },
{ id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', roles: ['admin'] }
] ]
} }
] ]
@ -242,7 +243,9 @@ export const store = reactive({
case 'dashboard': case 'dashboard':
case 'user-manage': case 'user-manage':
case 'api-key': case 'api-key':
break
case 'webhook': case 'webhook':
case 'mcp-server':
// 新页面无额外数据预加载 // 新页面无额外数据预加载
break break
} }

61
src/main/resources/static/js/utils.js

@ -152,6 +152,67 @@ export async function readSSEStream(url, onChunk, onDone, headers) {
if (onDone) onDone() if (onDone) onDone()
} }
/**
* 增强版 SSE 流式读取支持事件类型分发
* 解析 SSE 标准的 event: 字段将不同类型事件分发到对应回调
*
* @param {string} url 请求地址
* @param {object} handlers 回调对象
* - onMessage(chunk): 普通文本内容event: message 或无 event data
* - onToolCallStart(data): 工具调用开始event: tool_call_start
* - onToolCallResult(data): 工具调用结果event: tool_call_result
* - onError(data): 错误事件event: error
* - onDone(): 流结束
* @param {object} [headers] 额外请求头
*/
export async function readSSEStreamWithEvents(url, handlers, headers) {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
const res = await fetch(url, { headers: headers || {} })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = 'message' // 默认事件类型
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim()
} else if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (!data || data === '[DONE]') continue
switch (currentEvent) {
case 'tool_call_start':
if (onToolCallStart) onToolCallStart(JSON.parse(data))
break
case 'tool_call_result':
if (onToolCallResult) onToolCallResult(JSON.parse(data))
break
case 'error':
if (onError) onError(JSON.parse(data))
break
case 'message':
default:
if (onMessage) onMessage(data)
break
}
currentEvent = 'message' // 重置为默认
} else if (line.trim() && !line.startsWith(':')) {
// Flux<String> 模式,非 SSE 标准格式
if (onMessage) onMessage(line)
}
}
}
if (onDone) onDone()
}
// ==================== Markdown 渲染 ==================== // ==================== Markdown 渲染 ====================
/** /**

963
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

221
src/main/resources/static/sdk/test.html

@ -111,6 +111,24 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
chatId → <b>自动管理</b>(从 /conversation/list 获取或自动生成) chatId → <b>自动管理</b>(从 /conversation/list 获取或自动生成)
</div> </div>
<!-- SDK Token 鉴权 -->
<div style="background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;padding:12px;margin-bottom:14px;">
<div style="font-size:12px;font-weight:600;color:#166534;margin-bottom:8px;">🔐 SDK Token 鉴权</div>
<div class="fg" style="margin-bottom:8px">
<label>API Key(用于换取 Token)</label>
<div style="display:flex;gap:6px">
<input type="text" id="cfg-apikey" placeholder="sk_xxxx..." style="flex:1">
<button class="btn btn-primary btn-sm" onclick="fetchToken()" id="btn-fetch-token">获取 Token</button>
</div>
<div class="hint">调用 POST /open-api/auth/token 换取 SDK Token</div>
</div>
<div class="fg" style="margin-bottom:0">
<label>SDK Token(自动填充或手动粘贴)</label>
<input type="text" id="cfg-token" placeholder="eyJhbGciOi...(获取后自动填充)">
<div class="hint">Token 有效期默认 2 小时,过期需重新获取</div>
</div>
</div>
<div class="fg"> <div class="fg">
<label>integrateId → roleId(必传,客服角色 ID)</label> <label>integrateId → roleId(必传,客服角色 ID)</label>
<input type="number" id="cfg-iid" value="1" placeholder="客服角色 ID(数字)"> <input type="number" id="cfg-iid" value="1" placeholder="客服角色 ID(数字)">
@ -148,17 +166,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
<div class="fg-row"> <div class="fg-row">
<div class="fg"> <div class="fg">
<label>width (px)</label> <label>width (px)</label>
<input type="number" id="cfg-width" value="380" min="300" max="600">
<input type="number" id="cfg-width" value="380" min="300" max="1200">
</div> </div>
<div class="fg"> <div class="fg">
<label>streaming</label>
<select id="cfg-stream"><option value="1" selected>开启流式</option><option value="0">关闭(同步)</option></select>
<label>height (px)</label>
<input type="number" id="cfg-height" value="520" min="300" max="1200">
</div> </div>
</div> </div>
<div class="fg-row"> <div class="fg-row">
<div class="fg"> <div class="fg">
<label>locale</label>
<select id="cfg-locale"><option value="zh-CN" selected>中文</option><option value="en">English</option></select>
<label>resizable 可缩放</label>
<select id="cfg-resizable"><option value="1" selected>开启</option><option value="0">关闭</option></select>
</div>
<div class="fg">
<label>theme 主题模式</label>
<select id="cfg-theme-mode"><option value="light" selected>白天 light</option><option value="dark">暗夜 dark</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>streaming</label>
<select id="cfg-stream"><option value="1" selected>开启流式</option><option value="0">关闭(同步)</option></select>
</div> </div>
<div class="fg"> <div class="fg">
<label>enableRag 知识库检索</label> <label>enableRag 知识库检索</label>
@ -167,23 +195,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
</div> </div>
<div class="fg-row"> <div class="fg-row">
<div class="fg"> <div class="fg">
<label>launcherTheme 悬浮按钮主题</label>
<select id="cfg-theme">
<option value="" selected>默认(primaryColor 渐变)</option>
<option value="dream-purple">🔮 梦幻紫粉</option>
<option value="mint-tech">🌿 薄荷青绿</option>
<option value="coral-peach">🍑 珊瑚蜜桃</option>
<option value="sky-blue">🌊 天空蓝紫</option>
</select>
<div class="hint">磨砂玻璃 + 呼吸发光 + 跳动气泡</div>
<label>locale</label>
<select id="cfg-locale"><option value="zh-CN" selected>中文</option><option value="en">English</option></select>
</div> </div>
<div class="fg"> <div class="fg">
<label>debug</label> <label>debug</label>
<select id="cfg-debug"><option value="1" selected>开启日志</option><option value="0">关闭日志</option></select> <select id="cfg-debug"><option value="1" selected>开启日志</option><option value="0">关闭日志</option></select>
</div> </div>
</div> </div>
<div class="fg-row">
<div class="fg">
<label>launcherIcon 悬浮按钮图标</label>
<input id="cfg-launcher-icon" type="text" placeholder="URL 或 SVG 字符串,留空使用默认极光粒子图标" style="width:100%;box-sizing:border-box;" />
<div class="hint">留空则使用默认极光粒子动画</div>
</div>
<div class="fg">
<label>showClear 清空按钮</label>
<select id="cfg-clear"><option value="1" selected>显示</option><option value="0">隐藏</option></select>
</div>
</div>
<div class="actions"> <div class="actions">
<button class="btn btn-primary btn-full" onclick="doInit()">🚀 初始化 SDK</button>
<button class="btn btn-primary btn-full" id="btn-init" onclick="doInit()">🚀 初始化 SDK</button>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-outline btn-sm" onclick="domAction('open')">👁 打开窗口</button> <button class="btn btn-outline btn-sm" onclick="domAction('open')">👁 打开窗口</button>
@ -202,9 +234,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
1. integrateId 填写客服角色 ID(数字,对应后端 roleId)<br> 1. integrateId 填写客服角色 ID(数字,对应后端 roleId)<br>
2. userId 填写外部用户ID(对应后端 accountId)<br> 2. userId 填写外部用户ID(对应后端 accountId)<br>
3. chatId 自动管理:从 /conversation/list 获取或自动生成<br> 3. chatId 自动管理:从 /conversation/list 获取或自动生成<br>
4. 对话历史从后端加载(点击头部时钟图标查看)<br>
5. AI 回复支持 Markdown 渲染<br>
6. SDK 默认启用 RAG 检索;开启 showCategorySwitch 可选择知识库分类
4. 拖拽窗口右下角手柄可缩放窗口,尺寸自动保存<br>
5. 对话历史从后端加载(点击头部时钟图标查看)<br>
6. AI 回复支持 Markdown 渲染<br>
7. SDK 默认启用 RAG 检索;开启 showCategorySwitch 可选择知识库分类
</div> </div>
<hr class="divider"> <hr class="divider">
@ -259,7 +292,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
</div> </div>
</div> </div>
<script src="/sdk/chatbot-sdk.min.js"></script>
<script src="/sdk/chatbot-sdk.min.js?v=2"></script>
<script> <script>
(function() { (function() {
@ -296,7 +329,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
} }
function getCfg() { function getCfg() {
return {
const token = getEl('cfg-token').value.trim() || undefined;
console.log('[getCfg] token input value:', getEl('cfg-token').value, 'trimmed:', token);
const cfg = {
integrateId: getEl('cfg-iid').value, // 对应后端 roleId integrateId: getEl('cfg-iid').value, // 对应后端 roleId
requestDomain: getEl('cfg-domain').value || window.location.origin, requestDomain: getEl('cfg-domain').value || window.location.origin,
userId: getEl('cfg-uid').value || undefined, // 对应后端 accountId userId: getEl('cfg-uid').value || undefined, // 对应后端 accountId
@ -304,19 +339,106 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
primaryColor: getEl('cfg-color').value, primaryColor: getEl('cfg-color').value,
position: getEl('cfg-pos').value, position: getEl('cfg-pos').value,
width: parseInt(getEl('cfg-width').value) || 380, width: parseInt(getEl('cfg-width').value) || 380,
height: parseInt(getEl('cfg-height').value) || 520,
resizable: getEl('cfg-resizable').value === '1',
streaming: getEl('cfg-stream').value === '1', streaming: getEl('cfg-stream').value === '1',
locale: getEl('cfg-locale').value, locale: getEl('cfg-locale').value,
showCategorySwitch: getEl('cfg-cat').value === '1', showCategorySwitch: getEl('cfg-cat').value === '1',
enableRag: getEl('cfg-rag').value === '1', enableRag: getEl('cfg-rag').value === '1',
debug: getEl('cfg-debug').value === '1', debug: getEl('cfg-debug').value === '1',
showClear: true,
launcherTheme: getEl('cfg-theme').value || undefined,
theme: getEl('cfg-theme-mode').value,
showClear: getEl('cfg-clear').value === '1',
}; };
// 有 Token 时传入鉴权参数
if (token) {
cfg.token = token;
// roles 从 Token 换取接口获取(缓存在 window.__sdkRoles)
if (window.__sdkRoles) cfg.roles = window.__sdkRoles;
}
console.log('[getCfg] final cfg:', cfg);
return cfg;
} }
/**
* 根据 Token 输入框内容更新初始化按钮状态
*/
function updateInitButton() {
const btn = getEl('btn-init');
if (!btn) return;
const hasToken = !!getEl('cfg-token').value.trim();
btn.disabled = !hasToken;
btn.style.opacity = hasToken ? '1' : '0.6';
btn.title = hasToken ? '' : '请先获取 SDK Token';
}
/**
* 用 API Key 换取 SDK Token
*/
window.fetchToken = async function() {
const apiKey = getEl('cfg-apikey').value.trim();
if (!apiKey) { alert('请先输入 API Key'); return; }
const domain = getEl('cfg-domain').value || window.location.origin;
const btn = getEl('btn-fetch-token');
btn.textContent = '获取中...';
btn.disabled = true;
try {
const res = await fetch(domain + '/open-api/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey
},
body: JSON.stringify({ ttl: 7200 })
});
const data = await res.json();
if (data.success && data.token) {
getEl('cfg-token').value = data.token;
// 缓存 roles 供 SDK 使用
window.__sdkRoles = data.roles;
// 自动填充第一个角色的 ID
if (data.roles && data.roles.length > 0) {
getEl('cfg-iid').value = data.roles[0].id;
}
// 刷新接入代码
generateCode();
// 如果 SDK 已加载,自动用 Token 重新初始化
if (document.getElementById('csk-launcher')) {
ChatbotSDK.destroy();
setTimeout(() => ChatbotSDK.init(getCfg()), 100);
}
btn.textContent = '✅ 已获取';
btn.style.background = '#059669';
setTimeout(() => { btn.textContent = '获取 Token'; btn.style.background = ''; btn.disabled = false; }, 2000);
updateInitButton();
} else {
alert('换取失败: ' + (data.message || '未知错误'));
btn.textContent = '获取 Token';
btn.disabled = false;
updateInitButton();
}
} catch (e) {
alert('请求失败: ' + e.message);
btn.textContent = '获取 Token';
btn.disabled = false;
updateInitButton();
}
};
function sleep(ms){return new Promise(r=>setTimeout(r,ms));} function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function assert(c,m){if(!c)throw new Error(m);} function assert(c,m){if(!c)throw new Error(m);}
function assertExists(s,m){const el=document.getElementById(s)||document.querySelector(s);assert(!!el,m||s+' 不存在');return el;} function assertExists(s,m){const el=document.getElementById(s)||document.querySelector(s);assert(!!el,m||s+' 不存在');return el;}
/**
* 带 SDK Token 的 fetch 封装(/ai/ 路径自动注入 Authorization)
*/
function authFetch(url, options) {
const token = getEl('cfg-token').value.trim();
const opts = Object.assign({}, options);
if (token && url.includes('/ai/')) {
opts.headers = Object.assign({}, opts.headers || {}, { 'Authorization': 'Bearer ' + token });
}
return fetch(url, opts);
}
// ==================== 接入代码生成 ==================== // ==================== 接入代码生成 ====================
// 当前代码展示 Tab:'snippet'(Script 片段)| 'fullpage'(完整 HTML 页面) // 当前代码展示 Tab:'snippet'(Script 片段)| 'fullpage'(完整 HTML 页面)
let codeTab = 'snippet'; let codeTab = 'snippet';
@ -325,12 +447,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
const SDK_DEFAULTS = { const SDK_DEFAULTS = {
title: 'AI 智能助手', title: 'AI 智能助手',
width: 380, width: 380,
height: 520,
position: 'right-bottom', position: 'right-bottom',
primaryColor: '#4F46E5', primaryColor: '#4F46E5',
streaming: true, streaming: true,
locale: 'zh-CN', locale: 'zh-CN',
showCategorySwitch: false, showCategorySwitch: false,
enableRag: true, enableRag: true,
resizable: true,
theme: 'light',
showClear: true,
}; };
// 生成 Script 片段代码(纯文本) // 生成 Script 片段代码(纯文本)
@ -344,17 +470,30 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
// 必传参数 // 必传参数
params.push(' integrateId: ' + iidLit + ', // 必传:客服角色 ID(对应后端 roleId)'); params.push(' integrateId: ' + iidLit + ', // 必传:客服角色 ID(对应后端 roleId)');
params.push(' requestDomain: ' + JSON.stringify(domain) + ', // 必传:后端 API 域名'); params.push(' requestDomain: ' + JSON.stringify(domain) + ', // 必传:后端 API 域名');
// Token 鉴权(推荐)
const token = getEl('cfg-token').value.trim();
if (token) {
params.push(' token: ' + JSON.stringify(token) + ', // SDK JWT Token(有效期约2小时,生产环境请从后端动态换取)');
if (window.__sdkRoles) {
params.push(' roles: ' + JSON.stringify(window.__sdkRoles) + ', // 可用客服角色列表');
}
} else {
params.push(' token: ' + "'<从后端获取的 SDK Token>', // 推荐:SDK JWT Token(POST /open-api/auth/token 换取)");
}
// 可选参数:仅输出与默认值不同的项 // 可选参数:仅输出与默认值不同的项
if (cfg.userId) params.push(' userId: ' + JSON.stringify(cfg.userId) + ', // 可选:外部用户ID(对应后端 accountId)'); if (cfg.userId) params.push(' userId: ' + JSON.stringify(cfg.userId) + ', // 可选:外部用户ID(对应后端 accountId)');
if (cfg.title !== SDK_DEFAULTS.title) params.push(' title: ' + JSON.stringify(cfg.title) + ', // 弹窗标题'); if (cfg.title !== SDK_DEFAULTS.title) params.push(' title: ' + JSON.stringify(cfg.title) + ', // 弹窗标题');
if (cfg.primaryColor !== SDK_DEFAULTS.primaryColor) params.push(' primaryColor: ' + JSON.stringify(cfg.primaryColor) + ', // 主色调'); if (cfg.primaryColor !== SDK_DEFAULTS.primaryColor) params.push(' primaryColor: ' + JSON.stringify(cfg.primaryColor) + ', // 主色调');
if (cfg.position !== SDK_DEFAULTS.position) params.push(' position: ' + JSON.stringify(cfg.position) + ', // 悬浮按钮位置'); if (cfg.position !== SDK_DEFAULTS.position) params.push(' position: ' + JSON.stringify(cfg.position) + ', // 悬浮按钮位置');
if (cfg.width !== SDK_DEFAULTS.width) params.push(' width: ' + cfg.width + ', // 弹窗宽度(px)'); if (cfg.width !== SDK_DEFAULTS.width) params.push(' width: ' + cfg.width + ', // 弹窗宽度(px)');
if (cfg.height !== SDK_DEFAULTS.height) params.push(' height: ' + cfg.height + ', // 弹窗高度(px),最小 300');
if (cfg.resizable !== SDK_DEFAULTS.resizable) params.push(' resizable: ' + cfg.resizable + ', // 允许拖拽缩放窗口');
if (cfg.theme !== SDK_DEFAULTS.theme) params.push(' theme: ' + JSON.stringify(cfg.theme) + ', // 主题模式');
if (cfg.streaming !== SDK_DEFAULTS.streaming) params.push(' streaming: ' + cfg.streaming + ', // 是否启用流式输出'); if (cfg.streaming !== SDK_DEFAULTS.streaming) params.push(' streaming: ' + cfg.streaming + ', // 是否启用流式输出');
if (cfg.locale !== SDK_DEFAULTS.locale) params.push(' locale: ' + JSON.stringify(cfg.locale) + ', // 界面语言'); if (cfg.locale !== SDK_DEFAULTS.locale) params.push(' locale: ' + JSON.stringify(cfg.locale) + ', // 界面语言');
if (cfg.showCategorySwitch !== SDK_DEFAULTS.showCategorySwitch) params.push(' showCategorySwitch: ' + cfg.showCategorySwitch + ', // 是否显示知识库切换'); if (cfg.showCategorySwitch !== SDK_DEFAULTS.showCategorySwitch) params.push(' showCategorySwitch: ' + cfg.showCategorySwitch + ', // 是否显示知识库切换');
if (cfg.enableRag !== SDK_DEFAULTS.enableRag) params.push(' enableRag: ' + cfg.enableRag + ', // 启用 RAG 知识库检索'); if (cfg.enableRag !== SDK_DEFAULTS.enableRag) params.push(' enableRag: ' + cfg.enableRag + ', // 启用 RAG 知识库检索');
if (cfg.launcherTheme) params.push(' launcherTheme: ' + JSON.stringify(cfg.launcherTheme) + ', // 悬浮按钮主题(磨砂玻璃质感)');
if (cfg.showClear !== SDK_DEFAULTS.showClear) params.push(' showClear: ' + cfg.showClear + ', // 显示清空按钮');
// debug 强制 false(生产环境推荐),无论面板当前值 // debug 强制 false(生产环境推荐),无论面板当前值
params.push(' debug: false // 调试日志,生产环境建议关闭'); params.push(' debug: false // 调试日志,生产环境建议关闭');
const initBlock = 'ChatbotSDK.init({\n' + params.join('\n') + '\n});'; const initBlock = 'ChatbotSDK.init({\n' + params.join('\n') + '\n});';
@ -386,7 +525,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
// 语法高亮:先转义 HTML,再用单遍正则匹配 HTML 注释 / 字符串 / 行注释 / 数字 / 对象 key // 语法高亮:先转义 HTML,再用单遍正则匹配 HTML 注释 / 字符串 / 行注释 / 数字 / 对象 key
function highlightCode(code) { function highlightCode(code) {
const esc = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const re = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|showAdminPanel|launcherIcon|launcherTheme)(?=\s*:)/g;
const re = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|showAdminPanel|launcherIcon|token|roles)(?=\s*:)/g;
return esc.replace(re, (m, htmlCmt, str, cmt, num, key) => { return esc.replace(re, (m, htmlCmt, str, cmt, num, key) => {
if (htmlCmt) return '<span class="cm">' + htmlCmt + '</span>'; if (htmlCmt) return '<span class="cm">' + htmlCmt + '</span>';
if (str) return '<span class="st">' + str + '</span>'; if (str) return '<span class="st">' + str + '</span>';
@ -439,7 +578,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
generateCode(); generateCode();
}; };
window.doInit=function(){ChatbotSDK.destroy();ChatbotSDK.init(getCfg());const tag=getEl('tag-sdk');if(document.getElementById('csk-launcher')){tag.className='tag tag--pass';tag.textContent='✅ SDK 就绪';}};
window.doInit=function(){
const token = getEl('cfg-token').value.trim();
if (!token) {
alert('请先点击「获取 Token」按钮,用 API Key 换取 SDK Token 后再初始化。');
return;
}
ChatbotSDK.destroy();
ChatbotSDK.init(getCfg());
const tag=getEl('tag-sdk');
if(document.getElementById('csk-launcher')){tag.className='tag tag--pass';tag.textContent='✅ SDK 就绪';}
};
window.doDestroy=function(){ChatbotSDK.destroy();const tag=getEl('tag-sdk');tag.className='tag tag--idle';tag.textContent='⭕ SDK 未加载';}; window.doDestroy=function(){ChatbotSDK.destroy();const tag=getEl('tag-sdk');tag.className='tag tag--idle';tag.textContent='⭕ SDK 未加载';};
window.doClearHistory=function(){ChatbotSDK.clearHistory();}; window.doClearHistory=function(){ChatbotSDK.clearHistory();};
window.domAction=function(act){if(!document.getElementById('csk-launcher'))ChatbotSDK.init(getCfg());ChatbotSDK[act]();}; window.domAction=function(act){if(!document.getElementById('csk-launcher'))ChatbotSDK.init(getCfg());ChatbotSDK[act]();};
@ -468,18 +617,18 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
async function test11(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(500);ChatbotSDK.open();await sleep(300);const input=getEl('csk-input');if(!input){log('⚠ 输入框不存在','log-line--warn');m('skip','输入框不存在');return;}input.value='你好';input.dispatchEvent(new Event('input'));await sleep(100);const sendBtn=getEl('csk-send-btn');if(sendBtn)sendBtn.click();log('✅ 点击发送按钮','log-line--info');await sleep(8000);const aiMsgs=document.querySelectorAll('.csk-msg--ai .csk-msg__bubble');if(aiMsgs.length>0){const lastAi=aiMsgs[aiMsgs.length-1];log('AI 回复: '+(lastAi.textContent||'').substring(0,100),'log-line--pass');m('pass','通过(有AI回复)');}else{log('⚠ 等待 AI 回复超时','log-line--warn');m('skip','AI 未回复');}} async function test11(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(500);ChatbotSDK.open();await sleep(300);const input=getEl('csk-input');if(!input){log('⚠ 输入框不存在','log-line--warn');m('skip','输入框不存在');return;}input.value='你好';input.dispatchEvent(new Event('input'));await sleep(100);const sendBtn=getEl('csk-send-btn');if(sendBtn)sendBtn.click();log('✅ 点击发送按钮','log-line--info');await sleep(8000);const aiMsgs=document.querySelectorAll('.csk-msg--ai .csk-msg__bubble');if(aiMsgs.length>0){const lastAi=aiMsgs[aiMsgs.length-1];log('AI 回复: '+(lastAi.textContent||'').substring(0,100),'log-line--pass');m('pass','通过(有AI回复)');}else{log('⚠ 等待 AI 回复超时','log-line--warn');m('skip','AI 未回复');}}
async function test12(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent('你好')+'&chatId=verify_test&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('❌ fetch 失败','log-line--fail');throw new Error('后端未运行');}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(r.ok,'HTTP '+r.status);const text=await r.text();assert(text.length>3,'回复过短');log('AI 回复: '+text.substring(0,100),'log-line--pass');log('参数: chatId=verify_test roleId='+cfg.integrateId,'log-line--info');m('pass','通过 ('+el+'ms)');}
async function test12(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent('你好')+'&chatId=verify_test&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('❌ fetch 失败','log-line--fail');throw new Error('后端未运行');}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(r.ok,'HTTP '+r.status);const text=await r.text();assert(text.length>3,'回复过短');log('AI 回复: '+text.substring(0,100),'log-line--pass');log('参数: chatId=verify_test roleId='+cfg.integrateId,'log-line--info');m('pass','通过 ('+el+'ms)');}
async function test13(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sse?message='+encodeURIComponent('你好')+'&chatId=verify_sse&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await fetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('❌ SSE 失败','log-line--fail');throw new Error('SSE 失败');}assert(r.ok,'HTTP '+r.status);const reader=r.body.getReader();const decoder=new TextDecoder();let chunks=0,total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});chunks++;}}finally{reader.releaseLock();}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(total.length>3,'SSE 回复过短');log('✅ SSE '+chunks+' chunks, '+total.length+' chars','log-line--pass');m('pass','通过 ('+el+'ms)');}
async function test13(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sse?message='+encodeURIComponent('你好')+'&chatId=verify_sse&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('❌ SSE 失败','log-line--fail');throw new Error('SSE 失败');}assert(r.ok,'HTTP '+r.status);const reader=r.body.getReader();const decoder=new TextDecoder();let chunks=0,total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});chunks++;}}finally{reader.releaseLock();}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(total.length>3,'SSE 回复过短');log('✅ SSE '+chunks+' chunks, '+total.length+' chars','log-line--pass');m('pass','通过 ('+el+'ms)');}
// ==================== P1 ==================== // ==================== P1 ====================
async function test14(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(300);const s=document.querySelector('style[data-csk-sdk]');const css=s?s.textContent:'';assert(css.includes('csk-md-code-block'),'代码块样式');log('✅ 代码块样式','log-line--pass');assert(css.includes('csk-md-link'),'链接样式');log('✅ 链接样式','log-line--pass');m('pass','通过');} async function test14(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(300);const s=document.querySelector('style[data-csk-sdk]');const css=s?s.textContent:'';assert(css.includes('csk-md-code-block'),'代码块样式');log('✅ 代码块样式','log-line--pass');assert(css.includes('csk-md-link'),'链接样式');log('✅ 链接样式','log-line--pass');m('pass','通过');}
async function test15(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:true}));await sleep(300);const sel=document.getElementById('csk-category-select');assert(!!sel,'分类下拉框未创建');log('✅ 分类下拉框已创建','log-line--pass');ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:false}));await sleep(300);assert(!document.getElementById('csk-category-select'),'关闭后不应创建');log('✅ showCategorySwitch=false','log-line--pass');m('pass','通过');} async function test15(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:true}));await sleep(300);const sel=document.getElementById('csk-category-select');assert(!!sel,'分类下拉框未创建');log('✅ 分类下拉框已创建','log-line--pass');ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:false}));await sleep(300);assert(!document.getElementById('csk-category-select'),'关闭后不应创建');log('✅ showCategorySwitch=false','log-line--pass');m('pass','通过');}
async function test16(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/rag/sse?message='+encodeURIComponent('你好')+'&chatId=verify_rag&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;log('请求 RAG SSE...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('⚠ RAG SSE 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const reader=r.body.getReader();const decoder=new TextDecoder();let total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});}}finally{reader.releaseLock();}assert(total.length>3,'RAG 回复过短');log('✅ RAG SSE 回复 '+total.length+' chars','log-line--pass');m('pass','通过');}
async function test16(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/rag/sse?message='+encodeURIComponent('你好')+'&chatId=verify_rag&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;log('请求 RAG SSE...','log-line--info');let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('⚠ RAG SSE 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const reader=r.body.getReader();const decoder=new TextDecoder();let total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});}}finally{reader.releaseLock();}assert(total.length>3,'RAG 回复过短');log('✅ RAG SSE 回复 '+total.length+' chars','log-line--pass');m('pass','通过');}
async function test17(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/rag/sources?message='+encodeURIComponent('请假')+'&chatId=verify_sources&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const json=await r.json();log('返回 success='+json.success+' data='+(json.data?json.data.length:0)+' 条','log-line--info');assert(json.success!==undefined,'应返回 success 字段');log('✅ RAG 引用来源接口可用','log-line--pass');m('pass','通过');}
async function test17(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/rag/sources?message='+encodeURIComponent('请假')+'&chatId=verify_sources&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const json=await r.json();log('返回 success='+json.success+' data='+(json.data?json.data.length:0)+' 条','log-line--info');assert(json.success!==undefined,'应返回 success 字段');log('✅ RAG 引用来源接口可用','log-line--pass');m('pass','通过');}
async function test18(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/category/tree';apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 分类树 '+(json.data?json.data.length:0)+' 个根节点','log-line--pass');m('pass','通过');} async function test18(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/category/tree';apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 分类树 '+(json.data?json.data.length:0)+' 个根节点','log-line--pass');m('pass','通过');}
@ -493,7 +642,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
async function test22(log,m){const cfg=getCfg();if(cfg.userId){const url=cfg.requestDomain+'/conversation/list?page=1&size=10&accountId='+encodeURIComponent(cfg.userId)+'&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (accountId='+cfg.userId+', roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 按外部用户+角色过滤 total='+json.total,'log-line--pass');m('pass','通过');}else{log('⚠ 未设置 userId,跳过','log-line--warn');m('skip','未设置 userId');}} async function test22(log,m){const cfg=getCfg();if(cfg.userId){const url=cfg.requestDomain+'/conversation/list?page=1&size=10&accountId='+encodeURIComponent(cfg.userId)+'&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (accountId='+cfg.userId+', roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 按外部用户+角色过滤 total='+json.total,'log-line--pass');m('pass','通过');}else{log('⚠ 未设置 userId,跳过','log-line--warn');m('skip','未设置 userId');}}
// ========== 参数映射验证 ========== // ========== 参数映射验证 ==========
async function test23(log,m){log('验证参数映射:integrateId→roleId, userId→accountId','log-line--info');const cfg=getCfg();const msg='test_mapping';const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent(msg)+'&chatId=verify_mapping&roleId='+cfg.integrateId+(cfg.userId?'&accountId='+encodeURIComponent(cfg.userId):'');log('请求 URL: '+url.substring(0,120)+'...','log-line--info');apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ fetch 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const text=await r.text();assert(text.length>0,'应有回复');log('✅ integrateId('+cfg.integrateId+')→roleId, userId('+(cfg.userId||'未设置')+')→accountId 映射正确','log-line--pass');log('AI 回复: '+text.substring(0,80),'log-line--info');m('pass','通过');}
async function test23(log,m){log('验证参数映射:integrateId→roleId, userId→accountId','log-line--info');const cfg=getCfg();const msg='test_mapping';const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent(msg)+'&chatId=verify_mapping&roleId='+cfg.integrateId+(cfg.userId?'&accountId='+encodeURIComponent(cfg.userId):'');log('请求 URL: '+url.substring(0,120)+'...','log-line--info');apiCalls++;let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ fetch 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const text=await r.text();assert(text.length>0,'应有回复');log('✅ integrateId('+cfg.integrateId+')→roleId, userId('+(cfg.userId||'未设置')+')→accountId 映射正确','log-line--pass');log('AI 回复: '+text.substring(0,80),'log-line--info');m('pass','通过');}
window.runAll = async function() { window.runAll = async function() {
passCount=0;failCount=0;skipCount=0;apiCalls=0;apiDurations=[]; passCount=0;failCount=0;skipCount=0;apiCalls=0;apiDurations=[];
@ -535,14 +684,22 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
setEl('footer-time',new Date().toLocaleTimeString()); setEl('footer-time',new Date().toLocaleTimeString());
if(typeof window.ChatbotSDK!=='undefined'){const tag=getEl('tag-sdk');tag.className='tag tag--pass';tag.textContent='✅ SDK 已加载';} if(typeof window.ChatbotSDK!=='undefined'){const tag=getEl('tag-sdk');tag.className='tag tag--pass';tag.textContent='✅ SDK 已加载';}
// 监听配置表单变化,实时刷新接入代码 // 监听配置表单变化,实时刷新接入代码
['cfg-iid','cfg-domain','cfg-uid','cfg-title','cfg-color','cfg-pos','cfg-width','cfg-stream','cfg-locale','cfg-cat','cfg-debug','cfg-theme'].forEach(function(id){
['cfg-iid','cfg-domain','cfg-uid','cfg-title','cfg-color','cfg-pos','cfg-width','cfg-height','cfg-resizable','cfg-stream','cfg-locale','cfg-cat','cfg-rag','cfg-clear','cfg-debug','cfg-theme','cfg-theme-mode','cfg-apikey','cfg-token'].forEach(function(id){
const el=getEl(id); const el=getEl(id);
if(!el)return; if(!el)return;
el.addEventListener('input', generateCode); el.addEventListener('input', generateCode);
el.addEventListener('change', generateCode); el.addEventListener('change', generateCode);
if(id==='cfg-token'){
el.addEventListener('input', updateInitButton);
el.addEventListener('change', updateInitButton);
}
}); });
generateCode(); generateCode();
fetch(window.location.origin+'/ai/assistant_app/chat/sync?message=test&chatId=__probe__&roleId=1',{signal:AbortSignal.timeout(5000)}).then(r=>{const tag=getEl('tag-api');if(r.ok||r.status<500){tag.className='tag tag--pass';tag.textContent='✅ 后端连通';setEl('footer-info','ChatbotSDK v1.2.0 后端在线 ');}}).catch(()=>{const tag=getEl('tag-api');tag.className='tag tag--fail';tag.textContent='⚠ 后端离线';});
updateInitButton();
// 后端连通性探测(使用 Token 或直接请求)
const probeToken = getEl('cfg-token').value.trim();
const probeHeaders = probeToken ? { 'Authorization': 'Bearer ' + probeToken } : {};
fetch(window.location.origin+'/ai/assistant_app/chat/sync?message=test&chatId=__probe__&roleId=1',{signal:AbortSignal.timeout(5000),headers:probeHeaders}).then(r=>{const tag=getEl('tag-api');if(r.ok||r.status<500){tag.className='tag tag--pass';tag.textContent='✅ 后端连通';setEl('footer-info','ChatbotSDK v1.2.0 后端在线 ');}}).catch(()=>{const tag=getEl('tag-api');tag.className='tag tag--fail';tag.textContent='⚠ 后端离线';});
})(); })();
})(); })();
</script> </script>

Loading…
Cancel
Save