Browse Source

ChatSDK P1/P2 功能已全部实现并修复了三个核心 bug(integrateId→roleId 映射、chatId

自动管理、对话历史从后端加载)
master
wanghanlin 14 hours ago
parent
commit
cfc1ca8650
  1. 361
      client/README.md
  2. 1715
      client/dist/chatbot-sdk.js
  3. 2
      client/dist/chatbot-sdk.js.map
  4. 2
      client/dist/chatbot-sdk.min.js
  5. 2
      client/dist/chatbot-sdk.min.js.map
  6. 437
      client/src/api.ts
  7. 346
      client/src/chat.ts
  8. 23
      client/src/config.ts
  9. 275
      client/src/dom.ts
  10. 152
      client/src/i18n.ts
  11. 98
      client/src/index.ts
  12. 72
      client/src/logger.ts
  13. 212
      client/src/markdown.ts
  14. 362
      client/src/styles.ts
  15. 68
      client/src/types.ts
  16. 1715
      src/main/resources/static/sdk/chatbot-sdk.js
  17. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  18. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  19. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map
  20. 681
      src/main/resources/static/sdk/test.html

361
client/README.md

@ -1,6 +1,6 @@
# ChatbotSDK — 前端对接文档
> P0 核心链路已完成 | 构建时间:2026-06-25 | 版本:1.0.0
> P0 核心链路 ✅ | P1 体验增强 ✅ | P2 运营完善 ✅ | 版本:1.2.0 | 更新日期:2026-06-26
---
@ -12,7 +12,7 @@
<script src="https://your-domain.com/sdk/chatbot-sdk.min.js"></script>
<script>
ChatbotSDK.init({
integrateId: 'my-app', // 必传:集成标识
integrateId: 1, // 必传:客服角色 ID(对应后端 roleId)
requestDomain: 'https://your-domain.com', // 必传:后端地址
});
</script>
@ -20,70 +20,21 @@
引入后在页面右下角出现悬浮客服按钮,点击即可对话。
### 集成到现有项目
SDK 产物位于 `client/dist/` 目录:
| 文件 | 大小 | 说明 |
|------|------|------|
| `chatbot-sdk.js` | 47KB | 未压缩开发版(含 sourcemap) |
| `chatbot-sdk.min.js` | 22KB | 压缩生产版(gzip ~8KB) |
**部署路径:** 将 `.min.js` 文件上传到后端 `static/sdk/` 目录或任意 CDN。
---
## 二、SDKConfig 完整参数
| 参数 | 类型 | 必传 | 默认值 | 说明 |
|------|------|------|--------|------|
| `integrateId` | `string` | ✅ | — | 集成标识 → 后端 `chatId` + 数据隔离 key |
| `requestDomain` | `string` | ✅ | — | 后端 API 域名(如 `https://ai.example.com`) |
| `userId` | `string` | ❌ | — | 宿主用户标识 → 后端 `accountId` |
| `roleId` | `number` | ❌ | — | 客服角色 ID |
| `categoryId` | `number` | ❌ | — | 默认知识库分类 |
| `showCategorySwitch` | `boolean` | ❌ | `false` | 是否显示知识库下拉 |
| `title` | `string` | ❌ | `"AI 智能助手"` | 弹窗标题 |
| `width` | `number` | ❌ | `380` | 弹窗宽度(px),移动端自适应 |
| `position` | `string` | ❌ | `"right-bottom"` | 悬浮按钮位置:`left-bottom` \| `right-bottom` |
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | 主色调(适用于按钮/用户气泡/链接) |
| `launcherIcon` | `string` | ❌ | SVG 图标 | 悬浮按钮图标(URL 或 SVG 字符串) |
| `showClear` | `boolean` | ❌ | `true` | 是否显示清空对话按钮 |
| `showAdminPanel` | `boolean` | ❌ | `false` | 是否显示管理面板 |
| `streaming` | `boolean` | ❌ | `true` | 是否启用 SSE 流式输出 |
| `locale` | `string` | ❌ | `"zh-CN"` | 界面语言(预留) |
| `debug` | `boolean` | ❌ | `true` | 是否输出调试日志 |
---
## 三、公开 API
| 方法 | 说明 |
|------|------|
| `ChatbotSDK.init(config)` | 初始化 SDK |
| `ChatbotSDK.destroy()` | 销毁实例(清理 DOM + 样式 + 事件) |
| `ChatbotSDK.open()` | 打开聊天窗口 |
| `ChatbotSDK.close()` | 关闭聊天窗口 |
| `ChatbotSDK.toggle()` | 切换窗口显示/隐藏 |
| `ChatbotSDK.clearHistory()` | 清空当前会话 |
---
## 四、完整接入示例
### 完整配置接入
```html
<script src="/sdk/chatbot-sdk.min.js"></script>
<script>
ChatbotSDK.init({
// 必传
integrateId: 'hr-portal-v2',
integrateId: 'hr-portal-v2', // 客服角色 ID(对应后端 roleId)
requestDomain: 'https://ai.example.com',
// 用户身份
userId: 'zhangsan',
roleId: 1,
// 知识库
// 知识库联动(P1)
categoryId: 5,
showCategorySwitch: true,
@ -96,73 +47,222 @@ SDK 产物位于 `client/dist/` 目录:
// 行为
streaming: true,
locale: 'zh-CN',
locale: 'zh-CN', // 多语言(P2):zh-CN / en
debug: false,
});
</script>
```
### 集成到现有项目
SDK 产物位于 `client/dist/` 目录:
| 文件 | 大小 | 说明 |
|------|------|------|
| `chatbot-sdk.js` | 93KB | 未压缩开发版(含 sourcemap) |
| `chatbot-sdk.min.js` | 45KB | 压缩生产版(gzip ~15KB) |
**部署路径:** 将 `.min.js` 文件上传到后端 `static/sdk/` 目录或任意 CDN。
---
## 五、参数映射关系
## 二、SDKConfig 完整参数
SDK 入参与后端 API 参数对照:
| 参数 | 类型 | 必传 | 默认值 | 阶段 | 说明 |
|------|------|------|--------|------|------|
| `integrateId` | `string \| number` | ✅ | — | P0 | 集成标识 → 后端 `roleId`(客服角色 ID,决定 AI 人设和知识库范围) |
| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 |
| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` |
| `roleId` | `number` | ❌ | — | P0 | 客服角色 ID |
| `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 |
| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 |
| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 |
| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) |
| `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 |
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 |
| `launcherIcon` | `string` | ❌ | SVG 图标 | P0 | 悬浮按钮图标 |
| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 |
| `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 |
| `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` |
| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 |
| SDK 入参 | 后端参数 | 说明 |
|----------|----------|------|
| `integrateId` | `chatId` | 会话标识 + 数据隔离 key |
| `userId` | `accountId` | 宿主用户标识 |
| `roleId` | `roleId` | 客服角色 ID |
| `categoryId` | `categoryId` | 知识库分类过滤 |
---
## 三、公开 API
| 方法 | 阶段 | 说明 |
|------|------|------|
| `ChatbotSDK.init(config)` | P0 | 初始化 SDK |
| `ChatbotSDK.destroy()` | P0 | 销毁实例(清理 DOM + 样式 + 事件) |
| `ChatbotSDK.open()` | P0 | 打开聊天窗口 |
| `ChatbotSDK.close()` | P0 | 关闭聊天窗口 |
| `ChatbotSDK.toggle()` | P0 | 切换窗口显示/隐藏 |
| `ChatbotSDK.clearHistory()` | P0 | 清空当前会话 |
---
## 六、对接的后端接口
## 四、P1 体验增强功能
### 同步对话
```
GET /ai/assistant_app/chat/sync
?message={message}
&chatId={integrateId}
&accountId={userId}
&roleId={roleId}
### 4.1 SSE 流式打字机效果
默认开启(`streaming: true`),AI 回复逐字输出,支持:
- 流式追加到气泡,实时滚动到底部
- 流中断兜底:保留已接收内容 + 灰色提示
- 无流内容时自动降级为同步请求
### 4.2 Markdown 渲染
AI 回复支持 Markdown 格式渲染,包括:
- 代码块(深色背景 ` ``` ` 语法)
- 行内代码(`` `code` ``)
- 标题(`##` `###` 等)
- 粗体(`**text**`)、斜体(`*text*`)、删除线(`~~text~~`)
- 有序/无序列表
- 链接(`[text](url)`,仅允许 http/https 协议)
- 引用块(`>` )
- 水平线(`---`)
> **XSS 安全**:所有非代码块内容先转义 HTML,再转换 Markdown 语法为安全 HTML 标签。代码块内容同样转义。
### 4.3 知识库联动
开启 `showCategorySwitch: true` 后,输入区上方出现知识库分类下拉框:
```html
ChatbotSDK.init({
integrateId: 'my-app',
requestDomain: 'https://ai.example.com',
showCategorySwitch: true, // 显示分类下拉框
categoryId: 5, // 默认选中分类
});
```
### SSE 流式对话
- 选择分类后,后续对话自动走 RAG 增强接口(`/ai/assistant_app/chat/rag/sse`)
- 选择「全部分类」则走普通流式接口
- 分类数据从 `/category/tree` 接口动态加载,支持树形缩进显示
### 4.4 RAG 引用来源展示
使用 RAG 对话后,AI 气泡底部自动展示引用来源卡片:
```
GET /ai/assistant_app/chat/sse
?message={message}
&chatId={integrateId}
&accountId={userId}
&roleId={roleId}
┌─────────────────────────────────┐
│ 📚 3 条参考来源 ▼ │
├─────────────────────────────────┤
│ 员工手册.pdf │
│ 员工可以享受年假... │
│ 员工手册.pdf · 分块 #3 · 85% │
│ │
│ HR制度.md │
│ 年假计算规则如下... │
│ HR制度.md · 分块 #1 · 72% │
└─────────────────────────────────┘
```
### RAG 增强对话(P1 阶段启用)
- 默认折叠,只显示标题行,点击展开/折叠
- 显示文档名称、摘要、来源文件、分块编号、相关度
- 来源数据从 `/ai/assistant_app/rag/sources` 接口获取
---
## 五、P2 运营完善功能
### 5.1 多语言国际化
支持 `zh-CN`(中文)和 `en`(英文)两种语言:
```html
ChatbotSDK.init({
integrateId: 'my-app',
requestDomain: 'https://ai.example.com',
locale: 'en', // 英文界面
});
```
GET /ai/assistant_app/chat/rag/sse
?message={message}
&chatId={integrateId}
&categoryId={categoryId}
&rewriteStrategy=REWRITE
国际化覆盖范围:
- 弹窗标题、输入框占位符、发送按钮
- 最小化/关闭按钮提示
- 清空对话按钮及确认弹窗
- Loading 状态文字
- 所有错误提示(网络/超时/服务器/跨域等)
- 知识库下拉框默认选项
- RAG 引用来源标题
- 历史会话面板文本
### 5.2 会话管理面板
弹窗头部新增时钟图标按钮,点击展开历史会话面板:
- 展示当前用户的历史会话列表(从 `/conversation/list` 接口获取)
- 支持导出会话(下载文本文件)
- 支持删除会话(二次确认)
- 空状态提示
### 5.3 控制台日志体系
SDK 全流程结构化日志,带 `[ChatbotSDK]` 前缀:
| 生命周期 | 日志内容 |
|----------|---------|
| `init()` | `初始化完成 integrateId={} requestDomain={}` |
| `sendMessage()` | `发送消息 integrateId={} length={}` |
| `收到回复` | `AI 回复 integrateId={} length={} duration={}ms` |
| `流式完成` | `流式回复完成 integrateId={} length={} duration={}ms` |
| `请求失败` | `请求失败 integrateId={} status={} message={}` |
| `clearHistory()` | `清空会话 integrateId={}` |
| `destroy()` | `销毁实例 integrateId={}` |
| `分类切换` | `切换知识库分类 categoryId={}` |
- `config.debug = false` 关闭 info/warn 日志,error 始终输出
- 内置性能计时器,自动记录 AI 回复耗时
---
## 六、对接的后端接口
### P0 — 基础对话
```
GET /ai/assistant_app/chat/sync
GET /ai/assistant_app/chat/sse
```
### RAG 引用来源(P1 阶段启用)
### P1 — 知识库联动
```
GET /ai/assistant_app/rag/sources
?message={message}
&chatId={integrateId}
&categoryId={categoryId}
GET /ai/assistant_app/chat/rag/sse # RAG 增强流式对话
GET /ai/assistant_app/rag/sources # RAG 引用来源
GET /category/tree # 分类树(下拉框数据源)
GET /category/list # 分类列表
```
### 分类列表(P1 阶段启用)
### P2 — 会话管理
```
GET /category/list
GET /category/tree
GET /conversation/list # 会话列表
GET /conversation/{id}/messages # 会话消息
DELETE /conversation/{id} # 删除会话
GET /conversation/{id}/export # 导出会话
```
---
## 七、CSS 命名空间隔离
## 七、参数映射关系
**核心映射(SDK → 后端):**
| SDK 入参 | 后端参数 | 说明 |
|----------|----------|------|
| `integrateId` | `roleId` | 客服角色 ID(决定 AI 人设和知识库检索范围) |
| `userId` | `accountId` | 客户账号 ID(账号可绑定角色,绑定后服务端覆盖 roleId) |
| (自动管理) | `chatId` | 对话 ID(自动从 `/conversation/list` 获取或生成,格式 `sdk_时间戳_随机串`) |
**chatId 自动管理逻辑:**
1. SDK 初始化时,查询 `/conversation/list?accountId={userId}&roleId={integrateId}`
2. 有匹配会话 → 使用最新会话的 `conversationId` 作为 `chatId`
3. 无匹配会话 → 自动生成新 `chatId`(格式:`sdk_时间戳_随机串`)
4. `chatId` 缓存在 localStorage(key: `csk_chatId_{integrateId}_{userId}`
5. `clearHistory()` 会生成新的 `chatId`,开始全新对话
---
## 八、CSS 命名空间隔离
所有 DOM 元素的 class/id 均使用 `csk-` 前缀,不会与宿主页面样式冲突:
@ -173,13 +273,15 @@ GET /category/tree
| 消息区 | `#csk-messages` `.csk-messages` |
| 输入框 | `#csk-input` `.csk-input` |
| 发送按钮 | `#csk-send-btn` `.csk-send-btn` |
| 知识库下拉 | `#csk-category-select` `.csk-category-select` |
| 历史面板 | `.csk-history-panel` |
| 样式标签 | `style[data-csk-sdk]` |
z-index 分层:悬浮按钮 9998,弹窗 9999。
---
## 、localStorage 缓存
## 、localStorage 缓存
缓存 key 格式:`csk_history_{integrateId}`
@ -191,7 +293,8 @@ z-index 分层:悬浮按钮 9998,弹窗 9999。
"id": "uuid",
"role": "user|ai",
"content": "消息内容",
"timestamp": 1700000000000
"timestamp": 1700000000000,
"sources": []
}
],
"updatedAt": 1700000000000
@ -201,40 +304,39 @@ z-index 分层:悬浮按钮 9998,弹窗 9999。
- 消息上限 200 条,超出自动裁剪最早 50 条
- 按 `integrateId` 隔离,不同接入方互不影响
- `init()` 时自动恢复历史消息
- `clearHistory()` 清空本地缓存
- RAG 引用来源同步缓存到 `sources` 字段
---
## 、错误处理
## 、错误处理
所有错误不抛异常、不阻塞宿主页面,仅 `console.error` 输出。
| 错误场景 | 提示信息 |
|----------|----------|
| 缺失 `integrateId` | `integrateId 是必传参数` |
| 缺失 `requestDomain` | `requestDomain 是必传参数` |
| 非法 URL | `requestDomain 不是合法的 URL 格式` |
| 网络断开 | `网络连接失败,请检查网络` |
| 请求超时 | `请求超时,请稍后重试`(超时时间 30s) |
| HTTP 401 | `鉴权失败,请联系管理员` |
| HTTP 403 | `无访问权限,请联系管理员配置` |
| HTTP 500 | `服务器异常,请稍后重试` |
| 跨域阻断 | `跨域请求被拦截,请联系管理员将当前域名加入 API 白名单` |
| localStorage 满 | `localStorage 空间不足,会话历史保存失败` |
| 错误场景 | 中文提示 | 英文提示 |
|----------|---------|---------|
| 网络断开 | 网络连接失败,请检查网络 | Network connection failed |
| 请求超时 | 请求超时,请稍后重试 | Request timed out |
| HTTP 500 | 服务器异常,请稍后重试 | Server error |
| 跨域阻断 | 跨域请求被拦截... | CORS request blocked... |
| HTTP 401 | 鉴权失败,请联系管理员 | Authentication failed |
| HTTP 403 | 无访问权限 | Access denied |
| 流中断 | 回复被中断 | Response interrupted |
---
## 十、技术栈
## 十、技术栈
- **语言**:TypeScript → ES2017
- **打包**:Rollup → IIFE 格式
- **压缩**:Terser
- **Markdown**:内置轻量级渲染器(零外部依赖)
- **国际化**:内置 i18n 字典
- **浏览器兼容**:Chrome 70+, Firefox 70+, Safari 13+, Edge 79+
- **产物大小**:`.min.js` ~22KB(gzip ~8KB)
- **产物大小**:`.min.js` ~45KB(gzip ~15KB)
---
## 十、开发与构建
## 十、开发与构建
```bash
# 进入 SDK 工程目录
@ -258,17 +360,16 @@ client/
│ ├── index.ts # 入口:window.ChatbotSDK 挂载
│ ├── types.ts # TypeScript 类型定义
│ ├── config.ts # 配置解析 + 参数校验
│ ├── logger.ts # 日志模块
│ ├── logger.ts # 日志模块(P2 增强:计时+生命周期)
│ ├── storage.ts # localStorage 封装
│ ├── api.ts # HTTP 封装 + SSE 流式
│ ├── dom.ts # DOM 构建 + 拖拽
│ ├── styles.ts # CSS 注入 + 主题定制
│ ├── chat.ts # 对话核心
│ ├── api.ts # HTTP 封装 + SSE 流式 + P1 RAG + P2 会话
│ ├── dom.ts # DOM 构建 + 拖拽 + P1 来源/分类 + P2 面板
│ ├── styles.ts # CSS 注入 + 主题 + P1 Markdown/来源 + P2 面板
│ ├── chat.ts # 对话核心 + P1 Markdown/RAG + P2 i18n
│ ├── markdown.ts # P1 轻量级 Markdown 渲染器
│ ├── i18n.ts # P2 多语言国际化
│ └── utils.ts # UUID、防抖、XSS 转义
├── dist/ # 构建产物
│ ├── chatbot-sdk.js
│ ├── chatbot-sdk.min.js
│ └── *.map
├── package.json
├── tsconfig.json
├── rollup.config.js
@ -277,23 +378,25 @@ client/
---
## 十二、P0 → P1 → P2 路线图
## 十三、功能路线图
| 阶段 | 状态 | 内容 |
|------|------|------|
| **P0 核心链路** | ✅ 已完成 | 项目骨架、配置校验、样式注入、悬浮按钮+弹窗、HTTP 通信、对话核心、本地缓存、打包构建 |
| **P1 体验增强** | 🔜 待开发 | SSE 流式打字机效果、知识库下拉切换、RAG 引用来源展示、UI 全配置化、Markdown 渲染、弹窗拖拽 |
| **P2 运营完善** | 🔜 待开发 | 会话管理面板、控制台日志体系、多语言国际化、知识库管理嵌入 |
| **P1 体验增强** | ✅ 已完成 | SSE 流式打字机、Markdown 渲染、知识库下拉切换、RAG 引用来源展示、UI 全配置化、弹窗拖拽 |
| **P2 运营完善** | ✅ 已完成 | 多语言国际化(zh-CN/en)、控制台日志体系、会话管理面板(列表/导出/删除) |
| **可选扩展** | 🔜 待定 | 知识库管理嵌入(`showAdminPanel`)、更多语言、主题皮肤 |
---
## 十、验证测试
## 十、验证测试
访问 `http://localhost:9090/sdk/test.html` 运行自动化验证。
测试覆盖:
1. ✅ `window.ChatbotSDK` 全局挂载
2. ✅ 配置校验(必传参数 + 默认值 + 错误提示)
3. ✅ DOM 挂载(悬浮按钮 + 弹窗 + 输入框 + 样式注入)
4. ✅ localStorage 缓存读写
5. ✅ 与后端 API 的实际对话(需后端运行)
测试覆盖(22 个用例):
| 编号 | 阶段 | 功能 |
|------|------|------|
| T1-T13 | P0 | 全局挂载、参数校验、DOM 创建、open/close、默认值、主题色、位置、destroy、缓存、API 对话 |
| T14-T19 | P1 | Markdown 样式、知识库分类切换、RAG 来源样式、RAG SSE 接口、RAG 来源接口、分类树接口 |
| T20-T22 | P2 | 多语言国际化、会话管理面板、会话列表接口 |

1715
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

437
client/src/api.ts

@ -1,8 +1,14 @@
/**
* API - HTTP + +
*
* SDK
* integrateId roleId ID
* userId accountId ID
* chatId ID /conversation/list
*/
import { ResolvedConfig, ChatMessage, ApiResponse } from './types';
import { ResolvedConfig, ApiResponse, CategoryNode } from './types';
import { logger } from './logger';
import { t } from './i18n';
/** 请求超时时间(毫秒) */
const REQUEST_TIMEOUT = 30000;
@ -14,6 +20,18 @@ export function setApiConfig(config: ResolvedConfig): void {
currentConfig = config;
}
/** 更新当前 chatId(对话 ID) */
export function updateChatId(chatId: string): void {
if (currentConfig) {
currentConfig.chatId = chatId;
}
}
/** 获取当前 chatId */
export function getChatId(): string {
return currentConfig?.chatId || '';
}
/** 构建完整请求 URL,自动防御双斜杠 */
export function buildUrl(path: string): string {
if (!currentConfig) {
@ -24,41 +42,85 @@ export function buildUrl(path: string): string {
return `${domain}${cleanPath}`;
}
/** 构建同步对话请求 URL */
/**
* value
*/
function setIfPresent(params: URLSearchParams, key: string, value: string | number | undefined): void {
if (value === undefined || value === null) return;
if (typeof value === 'string' && value.trim() === '') return;
params.set(key, String(value));
}
// ==================== 对话接口 URL 构建 ====================
/**
* URL
* - integrateId roleId
* - userId accountId
* - chatId ID
*/
function buildChatUrl(message: string): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.integrateId);
params.set('chatId', currentConfig!.chatId);
// integrateId 映射为 roleId
setIfPresent(params, 'roleId', currentConfig!.integrateId);
// userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'roleId', currentConfig!.roleId);
setIfPresent(params, 'categoryId', currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
}
/** 构建 SSE 流式请求 URL */
function buildChatSSEUrl(message: string): string {
/**
* SSE URL
*/
function buildChatSSEUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.integrateId);
params.set('chatId', currentConfig!.chatId);
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'roleId', currentConfig!.roleId);
setIfPresent(params, 'categoryId', currentConfig!.categoryId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/**
* value
* RAG URL
*/
function setIfPresent(params: URLSearchParams, key: string, value: string | number | undefined): void {
if (value === undefined || value === null) return;
if (typeof value === 'string' && value.trim() === '') return;
params.set(key, String(value));
function buildChatRAGSSEUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/rag/sse?${params.toString()}`);
}
/**
* RAG URL
*/
function buildRagSourcesUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`);
}
// ==================== HTTP 基础封装 ====================
/** 带超时的 fetch 封装 */
async function safeFetch(
url: string,
@ -78,15 +140,12 @@ async function safeFetch(
return response;
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new CskError('请求超时,请稍后重试', 'timeout');
throw new CskError(t('error_timeout'), 'timeout');
}
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
throw new CskError(
'跨域请求被拦截,请联系管理员将当前域名加入 API 白名单',
'cors'
);
throw new CskError(t('error_cors'), 'cors');
}
throw new CskError('网络连接失败,请检查网络', 'network');
throw new CskError(t('error_network'), 'network');
} finally {
clearTimeout(timer);
}
@ -102,91 +161,80 @@ export class CskError extends Error {
}
}
/** 根据 HTTP 状态码返回对应的中文错误消息 */
/** 根据 HTTP 状态码返回对应的国际化错误消息 */
function getHttpErrorMessage(status: number): string {
switch (status) {
case 401:
return '鉴权失败,请联系管理员';
case 403:
return '无访问权限,请联系管理员配置';
case 404:
return '请求的资源不存在';
case 429:
return '请求过于频繁,请稍后重试';
case 500:
return '服务器异常,请稍后重试';
case 401: return t('error_auth');
case 403: return t('error_forbidden');
case 404: return t('error_not_found');
case 429: return t('error_rate_limit');
case 500: return t('error_server');
case 502:
case 503:
return '服务暂不可用,请稍后重试';
default:
return `请求失败(状态码 ${status}`;
case 503: return t('error_unavailable');
default: return `${t('error_unknown')}${status}`;
}
}
// ==================== 对话请求 ====================
/**
*
*/
export async function chatRequest(message: string): Promise<string> {
const url = buildChatUrl(message);
const startTime = Date.now();
logger.info(`发送消息 integrateId=${currentConfig!.integrateId} length=${message.length}`);
logger.lifecycleSend(currentConfig!.integrateId, message.length);
try {
const response = await safeFetch(url);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.error(`请求失败 integrateId=${currentConfig!.integrateId} status=${response.status} message=${errorMsg}`);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
throw new CskError(errorMsg, `http_${response.status}`);
}
const text = await response.text();
const duration = Date.now() - startTime;
logger.info(`AI 回复 integrateId=${currentConfig!.integrateId} length=${text.length} duration=${duration}ms`);
logger.lifecycleReply(currentConfig!.integrateId, text.length);
return text;
} catch (err) {
if (err instanceof CskError) {
throw err;
}
logger.error(`请求异常 integrateId=${currentConfig!.integrateId}`, err);
throw new CskError('请求发生未知错误', 'unknown');
if (err instanceof CskError) throw err;
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
throw new CskError(t('error_unknown'), 'unknown');
}
}
/**
* SSE
* @param message
* @param onChunk
* @param onDone
* @param onError
* @param useRag 使 RAG
* @param categoryId ID
*/
export async function chatSSERequest(
message: string,
onChunk: (text: string) => void,
onDone: () => void,
onError: (error: CskError) => void
onError: (error: CskError) => void,
categoryId?: number,
useRag?: boolean
): Promise<void> {
const url = buildChatSSEUrl(message);
const startTime = Date.now();
const url = useRag
? buildChatRAGSSEUrl(message, categoryId)
: buildChatSSEUrl(message, categoryId);
let totalText = '';
logger.info(`发送流式消息 integrateId=${currentConfig!.integrateId} length=${message.length}`);
logger.lifecycleSend(currentConfig!.integrateId, message.length);
try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2); // SSE 超时更长
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.error(`流式请求失败 integrateId=${currentConfig!.integrateId} status=${response.status} message=${errorMsg}`);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
onError(new CskError(errorMsg, `http_${response.status}`));
return;
}
const reader = response.body?.getReader();
if (!reader) {
onError(new CskError('浏览器不支持流式读取', 'stream_unsupported'));
onError(new CskError(t('error_stream_unsupported'), 'stream_unsupported'));
return;
}
@ -196,61 +244,41 @@ export async function chatSSERequest(
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (done) break;
// 解码并追加到 buffer
buffer += decoder.decode(value, { stream: true });
// 按行解析 SSE 数据
const lines = buffer.split('\n');
// 最后一行可能不完整,保留到下次
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) {
// 跳过空行和 SSE 注释
continue;
}
if (!trimmed || trimmed.startsWith(':')) continue;
// 解析 "data: xxx" 格式
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5).trim();
if (data) {
totalText += data;
onChunk(data);
}
if (data) { totalText += data; onChunk(data); }
} else if (trimmed === '[DONE]') {
// 流结束标记(OpenAI 风格,此处做兼容)
break;
} else if (!trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) {
// 可能是 Flux 裸文本格式(无 data: 前缀)
totalText += trimmed;
onChunk(trimmed);
}
}
}
// 处理 buffer 中剩余的数据
if (buffer.trim()) {
const trimmed = buffer.trim();
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5).trim();
if (data) {
totalText += data;
onChunk(data);
}
if (data) { totalText += data; onChunk(data); }
} else if (trimmed !== '[DONE]') {
totalText += trimmed;
onChunk(trimmed);
}
}
} catch (readErr: unknown) {
// 流中断不丢已接收的文本
if (totalText.length > 0) {
onChunk('\n\n[网络不稳定,内容可能不完整]');
onChunk('\n\n' + t('stream_unstable'));
} else {
throw readErr;
}
@ -258,15 +286,246 @@ export async function chatSSERequest(
reader.releaseLock();
}
const duration = Date.now() - startTime;
logger.info(`流式回复完成 integrateId=${currentConfig!.integrateId} length=${totalText.length} duration=${duration}ms`);
logger.lifecycleStreamDone(currentConfig!.integrateId, totalText.length);
onDone();
} catch (err) {
if (err instanceof CskError) {
onError(err);
} else {
logger.error(`流式请求异常 integrateId=${currentConfig!.integrateId}`, err);
onError(new CskError('网络连接失败,请检查网络', 'network'));
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
onError(new CskError(t('error_network'), 'network'));
}
}
}
// ==================== P1: 知识库分类 ====================
/**
*
*/
export async function fetchCategoryTree(): Promise<CategoryNode[]> {
const url = buildUrl('/category/tree');
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse<CategoryNode[]> = await response.json();
if (json.success && Array.isArray(json.data)) {
logger.info(`加载分类树成功 count=${json.data.length}`);
return json.data;
}
return [];
} catch (err) {
if (err instanceof CskError) logger.error(`加载分类树失败: ${err.message}`);
else logger.error('加载分类树失败', err);
return [];
}
}
// ==================== P1: RAG 引用来源 ====================
/** RAG 来源响应数据项 */
interface RagSourceData {
documentId?: string;
title?: string;
sourceName?: string;
chunkIndex?: number;
score?: number;
snippet?: string;
}
/**
* RAG
*/
export async function fetchRagSources(message: string, categoryId?: number): Promise<RagSourceData[]> {
const url = buildRagSourcesUrl(message, categoryId);
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse<RagSourceData[]> = await response.json();
if (json.success && Array.isArray(json.data)) {
logger.info(`获取引用来源 count=${json.data.length}`);
return json.data;
}
return [];
} catch (err) {
logger.error('获取引用来源失败', err);
return [];
}
}
// ==================== P2: 会话管理 + chatId 初始化 ====================
/** 会话列表项 */
interface ConversationItem {
conversationId?: string;
chatId?: string;
accountId?: string;
roleId?: number;
messageCount?: number;
lastMessageTime?: string;
firstMessageTime?: string;
lastMessagePreview?: string;
createdAt?: string;
}
/**
*
*/
export async function fetchConversationList(
page: number = 1,
size: number = 20,
accountId?: string,
roleId?: string
): Promise<{
list: ConversationItem[];
total: number;
pages: number;
}> {
let path = `/conversation/list?page=${page}&size=${size}`;
if (accountId) path += `&accountId=${encodeURIComponent(accountId)}`;
if (roleId) path += `&roleId=${encodeURIComponent(roleId)}`;
const url = buildUrl(path);
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse<ConversationItem[]> = await response.json();
return {
list: json.success && Array.isArray(json.data) ? json.data : [],
total: json.total || 0,
pages: json.pages || 0,
};
} catch (err) {
logger.error('加载会话列表失败', err);
return { list: [], total: 0, pages: 0 };
}
}
/**
*
*/
export async function fetchConversationMessages(conversationId: string): Promise<{
messages: Array<{ messageType: string; content: string; createTime: string }>;
total: number;
}> {
const url = buildUrl(`/conversation/${conversationId}/messages`);
try {
const response = await safeFetch(url);
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json = await response.json();
return {
messages: json.success && Array.isArray(json.data) ? json.data : [],
total: json.total || 0,
};
} catch (err) {
logger.error('加载会话消息失败', err);
return { messages: [], total: 0 };
}
}
/**
*
*/
export async function deleteConversation(conversationId: string): Promise<boolean> {
const url = buildUrl(`/conversation/${conversationId}`);
try {
const response = await safeFetch(url, { method: 'DELETE' });
if (!response.ok) throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json: ApiResponse = await response.json();
logger.info(`删除会话 id=${conversationId} success=${json.success}`);
return json.success || false;
} catch (err) {
logger.error('删除会话失败', err);
return false;
}
}
/**
* URL
*/
export function getConversationExportUrl(conversationId: string): string {
return buildUrl(`/conversation/${conversationId}/export`);
}
// ==================== chatId 自动初始化 ====================
/**
* chatId
*
*
* 1. localStorage chatId integrateId + userId
* 2. /conversation/list?accountId=X&roleId=Y
* 3. 使 conversationId chatId
* 4. chatIdsdk_timestamp_random
*/
export async function initChatId(): Promise<string> {
if (!currentConfig) return '';
// 1. 先尝试从 localStorage 恢复
const cachedChatId = loadCachedChatId(currentConfig.integrateId, currentConfig.userId);
if (cachedChatId) {
currentConfig.chatId = cachedChatId;
logger.info(`从缓存恢复 chatId=${cachedChatId}`);
return cachedChatId;
}
// 2. 查询后端会话列表
try {
const result = await fetchConversationList(1, 5, currentConfig.userId, currentConfig.integrateId);
if (result.list.length > 0) {
// 使用最新会话的 conversationId 作为 chatId
const latestConv = result.list[0];
const chatId = latestConv.conversationId || latestConv.chatId || '';
if (chatId) {
currentConfig.chatId = chatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, chatId);
logger.info(`从后端恢复会话 chatId=${chatId} messageCount=${latestConv.messageCount}`);
return chatId;
}
}
} catch (err) {
logger.warn('查询后端会话列表失败,将生成新 chatId', err);
}
// 3. 生成新的 chatId
const newChatId = generateChatId();
currentConfig.chatId = newChatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, newChatId);
logger.info(`生成新 chatId=${newChatId}`);
return newChatId;
}
/** 生成 chatId(格式:sdk_timestamp_random) */
function generateChatId(): string {
const random = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID().substring(0, 8)
: Math.random().toString(36).substring(2, 10);
return `sdk_${Date.now()}_${random}`;
}
/** localStorage key 格式 */
function chatIdStorageKey(integrateId: string, userId?: string): string {
return `csk_chatId_${integrateId}${userId ? '_' + userId : ''}`;
}
/** 从 localStorage 加载 chatId */
function loadCachedChatId(integrateId: string, userId?: string): string {
try {
return localStorage.getItem(chatIdStorageKey(integrateId, userId)) || '';
} catch {
return '';
}
}
/** 保存 chatId 到 localStorage */
export function saveCachedChatId(integrateId: string, userId?: string, chatId?: string): void {
try {
if (chatId) {
localStorage.setItem(chatIdStorageKey(integrateId, userId), chatId);
} else {
localStorage.removeItem(chatIdStorageKey(integrateId, userId));
}
} catch {
// localStorage 不可用则忽略
}
}

346
client/src/chat.ts

@ -1,16 +1,40 @@
/**
* - //
*
*
* integrateId roleId ID
* userId accountId ID
* chatId /conversation/list
*/
import { ResolvedConfig, ChatMessage } from './types';
import { chatRequest, chatSSERequest, CskError } from './api';
import { ResolvedConfig, ChatMessage, RagSource } from './types';
import {
chatRequest,
chatSSERequest,
fetchCategoryTree,
fetchRagSources,
fetchConversationList,
fetchConversationMessages,
deleteConversation,
getConversationExportUrl,
initChatId,
updateChatId,
getChatId,
saveCachedChatId,
CskError,
} from './api';
import {
renderUserBubble,
renderAIBubble,
createEmptyAIBubble,
scrollToBottom,
renderSources,
renderHistoryList,
HistoryItemData,
} from './dom';
import { saveMessages, loadMessages, clearMessages } from './storage';
import { renderMarkdown } from './markdown';
import { logger } from './logger';
import { t } from './i18n';
import { uuid, now } from './utils';
let config: ResolvedConfig | null = null;
@ -19,10 +43,18 @@ let messagesContainer: HTMLElement | null = null;
let inputEl: HTMLTextAreaElement | null = null;
let sendBtn: HTMLElement | null = null;
let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null;
let historyPanel: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null;
let isSending = false;
/** 当前选中的知识库分类 ID */
let currentCategoryId: number | undefined;
/** 当前是否使用 RAG 对话 */
let useRag = false;
/**
*
*/
@ -33,6 +65,8 @@ export function initChat(
inputEl: HTMLTextAreaElement;
sendBtn: HTMLElement;
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
historyPanel: HTMLElement;
showLoading: () => HTMLElement;
hideLoading: () => void;
}
@ -42,17 +76,76 @@ export function initChat(
inputEl = dom.inputEl;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel;
showLoadingFn = dom.showLoading;
hideLoadingFn = dom.hideLoading;
// 初始化知识库分类
currentCategoryId = cfg.categoryId;
useRag = !!cfg.categoryId || !!cfg.showCategorySwitch;
// 绑定发送事件
bindSendEvents();
// 恢复历史消息
const history = loadMessages(cfg.integrateId);
if (history.length > 0) {
messages = history;
renderHistory();
// 加载知识库分类下拉框
if (cfg.showCategorySwitch && categorySelect) {
loadCategories();
}
}
/**
* chatId
* chatId
*/
export async function initChatHistory(): Promise<void> {
if (!config || !messagesContainer) return;
// 1. 初始化 chatId(从后端获取已有会话或自动生成)
await initChatId();
// 2. 尝试从后端加载对话历史
await loadHistoryFromBackend();
// 3. 如果后端无历史,尝试从 localStorage 恢复
if (messages.length === 0) {
const cached = loadMessages(config.integrateId);
if (cached.length > 0) {
messages = cached;
renderHistory();
logger.info(`从本地缓存恢复 ${cached.length} 条消息`);
}
}
}
/**
*
*/
async function loadHistoryFromBackend(): Promise<void> {
if (!config || !messagesContainer) return;
const chatId = getChatId();
if (!chatId) return;
try {
const result = await fetchConversationMessages(chatId);
if (result.messages.length > 0) {
// 将后端消息转换为 ChatMessage 格式
messages = result.messages.map((msg, idx) => ({
id: uuid(),
role: msg.messageType === 'USER' ? 'user' : 'ai' as const,
content: msg.content,
timestamp: new Date(msg.createTime).getTime(),
}));
renderHistory();
logger.info(`从后端加载 ${messages.length} 条历史消息`);
// 同步到 localStorage
saveMessages(config.integrateId, messages);
}
} catch (err) {
logger.warn('从后端加载历史消息失败', err);
}
}
@ -60,12 +153,8 @@ export function initChat(
function bindSendEvents(): void {
if (!inputEl || !sendBtn) return;
// 发送按钮点击
sendBtn.addEventListener('click', () => {
handleSend();
});
sendBtn.addEventListener('click', () => handleSend());
// 输入框键盘事件:回车发送 / Shift+Enter 换行
inputEl.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@ -73,16 +162,10 @@ function bindSendEvents(): void {
}
});
// 输入框内容变化时启用/禁用发送按钮
inputEl.addEventListener('input', () => {
updateSendBtnState();
});
inputEl.addEventListener('input', () => updateSendBtnState());
// 清空按钮
if (clearBtn) {
clearBtn.addEventListener('click', () => {
handleClear();
});
clearBtn.addEventListener('click', () => handleClear());
}
}
@ -104,35 +187,25 @@ async function handleSend(): Promise<void> {
const text = inputEl.value.trim();
if (text === '') return;
// 清空输入框
inputEl.value = '';
updateSendBtnState();
// 自动调整 textarea 高度
inputEl.style.height = 'auto';
isSending = true;
updateSendBtnState();
// 确保 chatId 已初始化
if (!config.chatId) {
await initChatId();
}
// 1. 渲染用户气泡
const userTimestamp = now();
if (messagesContainer) {
renderUserBubble(messagesContainer, text, userTimestamp);
}
const userMsg: ChatMessage = {
id: uuid(),
role: 'user',
content: text,
timestamp: userTimestamp,
};
if (messagesContainer) renderUserBubble(messagesContainer, text, userTimestamp);
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp };
messages.push(userMsg);
// 显示清空按钮
if (clearBtn && messages.length > 0) {
clearBtn.style.display = 'inline-flex';
}
// 滚动到底部
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
if (messagesContainer) scrollToBottom(messagesContainer);
// 2. 显示 loading
@ -143,41 +216,31 @@ async function handleSend(): Promise<void> {
try {
let aiContent: string;
const aiTimestamp = now();
const shouldUseRag = useRag && (currentCategoryId !== undefined || config.categoryId !== undefined);
if (config.streaming) {
// 流式输出:气泡由 sendStreamMessage 内部的 onChunk 回调创建
aiContent = await sendStreamMessage(text, aiTimestamp);
aiContent = await sendStreamMessage(text, aiTimestamp, shouldUseRag);
} else {
// 同步请求:需要在此渲染 AI 气泡
aiContent = await chatRequest(text);
}
// 4. 隐藏 loading(流式模式在 onChunk 中已隐藏,此处做兜底)
if (hideLoadingFn) hideLoadingFn();
// 5. 非流式模式:在此渲染 AI 气泡;流式模式气泡已在 stream 回调中创建
if (!config.streaming && messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp);
renderAIBubble(messagesContainer, aiContent, aiTimestamp, renderMarkdown);
}
const aiMsg: ChatMessage = {
id: uuid(),
role: 'ai',
content: aiContent,
timestamp: aiTimestamp,
};
const aiMsg: ChatMessage = { id: uuid(), role: 'ai', content: aiContent, timestamp: aiTimestamp };
messages.push(aiMsg);
// 6. 保存到 localStorage
saveMessages(config.integrateId, messages);
// 7. 滚动到底部
if (messagesContainer) scrollToBottom(messagesContainer);
// RAG 引用来源
if (shouldUseRag) fetchAndRenderSources(text, aiMsg);
} catch (err) {
// 隐藏 loading
if (hideLoadingFn) hideLoadingFn();
// 渲染错误提示
const errMsg = err instanceof CskError ? err.message : '发送失败,请稍后重试';
const errMsg = err instanceof CskError ? err.message : t('error_send');
if (messagesContainer) {
const errorBubble = document.createElement('div');
errorBubble.className = 'csk-msg csk-msg--ai';
@ -196,104 +259,197 @@ async function handleSend(): Promise<void> {
}
/** 流式发送消息 */
async function sendStreamMessage(text: string, aiTimestamp: number): Promise<string> {
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean): Promise<string> {
return new Promise((resolve, reject) => {
let bubbleEl: HTMLElement | null = null;
let wrapperEl: HTMLElement | null = null;
let accumulated = '';
let streamStarted = false;
chatSSERequest(
text,
// onChunk
(chunk: string) => {
accumulated += chunk;
if (!streamStarted && messagesContainer) {
// 隐藏 loading,创建空 AI 气泡
if (hideLoadingFn) hideLoadingFn();
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp);
wrapperEl = wrapper;
const { bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp);
bubbleEl = bubble;
streamStarted = true;
}
if (bubbleEl) {
bubbleEl.textContent = accumulated;
}
if (bubbleEl) bubbleEl.textContent = accumulated;
if (messagesContainer) scrollToBottom(messagesContainer);
},
// onDone
() => {
// 如果流没有产生任何内容,回退同步请求
if (!streamStarted && accumulated === '') {
chatRequest(text)
.then((content) => resolve(content))
.catch(reject);
chatRequest(text).then(resolve).catch(reject);
return;
}
if (bubbleEl && accumulated) bubbleEl.innerHTML = renderMarkdown(accumulated);
resolve(accumulated);
},
// onError
(error: CskError) => {
if (accumulated.length > 0) {
// 有部分内容,保留并添加提示
if (bubbleEl) {
bubbleEl.textContent = accumulated + '\n\n[回复被中断]';
}
if (bubbleEl) bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));
resolve(accumulated);
} else {
reject(error);
}
}
},
currentCategoryId,
shouldUseRag
);
});
}
/** 获取并渲染 RAG 引用来源 */
async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promise<void> {
try {
const sources = await fetchRagSources(message, currentCategoryId);
if (sources.length > 0) {
const ragSources: RagSource[] = sources.map(s => ({
documentId: s.documentId || '',
title: s.title || '',
sourceName: s.sourceName || '',
chunkIndex: s.chunkIndex ?? 0,
score: s.score ?? 0,
snippet: s.snippet || '',
}));
aiMsg.sources = ragSources;
if (messagesContainer) {
const lastAiMsg = messagesContainer.querySelector('.csk-msg--ai:last-of-type');
if (lastAiMsg) renderSources(lastAiMsg as HTMLElement, ragSources);
}
if (config) saveMessages(config.integrateId, messages);
}
} catch (err) {
logger.warn('获取引用来源失败', err);
}
}
/** 加载知识库分类到下拉框 */
async function loadCategories(): Promise<void> {
if (!categorySelect) return;
try {
const tree = await fetchCategoryTree();
if (tree.length === 0) return;
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
const addOptions = (nodes: typeof tree, indent: number = 0) => {
for (const node of nodes) {
const option = document.createElement('option');
option.value = String(node.id);
option.textContent = `${' '.repeat(indent)}${node.name}`;
if (currentCategoryId !== undefined && String(node.id) === String(currentCategoryId)) option.selected = true;
categorySelect!.appendChild(option);
if (node.children && node.children.length > 0) addOptions(node.children, indent + 1);
}
};
addOptions(tree);
logger.info(`知识库分类加载成功 count=${tree.length}`);
} catch (err) {
logger.error(t('category_load_error'), err);
}
}
/** 渲染历史消息 */
function renderHistory(): void {
if (!messagesContainer) return;
// 清空容器
messagesContainer.innerHTML = '';
const historyPanelEl = messagesContainer.querySelector('.csk-history-panel');
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
for (const msg of messages) {
if (msg.role === 'user') {
renderUserBubble(messagesContainer, msg.content, msg.timestamp);
} else {
renderAIBubble(messagesContainer, msg.content, msg.timestamp);
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, renderMarkdown);
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources);
}
}
scrollToBottom(messagesContainer);
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
// 显示清空按钮
if (clearBtn && messages.length > 0) {
clearBtn.style.display = 'inline-flex';
if (historyPanelEl && !messagesContainer.contains(historyPanelEl)) {
messagesContainer.appendChild(historyPanelEl);
}
}
/** 清空对话历史 */
/** 清空对话历史(生成新 chatId) */
function handleClear(): void {
if (!config) return;
if (!confirm('确定清空所有对话记录?')) {
return;
}
if (!confirm(t('clear_confirm'))) return;
messages = [];
if (messagesContainer) {
messagesContainer.innerHTML = '';
}
if (clearBtn) {
clearBtn.style.display = 'none';
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
}
if (clearBtn) clearBtn.style.display = 'none';
clearMessages(config.integrateId);
logger.info(`清空会话 integrateId=${config.integrateId}`);
// 生成新的 chatId,开始新会话
const newId = generateNewChatId();
updateChatId(newId);
saveCachedChatId(config.integrateId, config.userId, newId);
logger.lifecycleClear(config.integrateId);
logger.info(`新 chatId=${newId}`);
}
/**
*
*/
/** 生成新 chatId */
function generateNewChatId(): string {
const random = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID().substring(0, 8)
: Math.random().toString(36).substring(2, 10);
return `sdk_${Date.now()}_${random}`;
}
/** 设置当前知识库分类 */
export function setCategory(categoryId: number | undefined): void {
currentCategoryId = categoryId;
useRag = categoryId !== undefined;
logger.lifecycleCategoryChange(categoryId ?? '全部');
}
// ==================== 会话管理面板 ====================
/** 加载会话列表并渲染 */
export async function loadHistoryConversations(): Promise<void> {
if (!historyPanel || !config) return;
const listEl = historyPanel.querySelector('#csk-history-list') as HTMLElement;
if (!listEl) return;
listEl.innerHTML = `<div class="csk-history-panel__loading">加载中...</div>`;
try {
const result = await fetchConversationList(1, 50, config.userId, config.integrateId);
const items: HistoryItemData[] = result.list.map(c => ({
id: c.conversationId || c.chatId || '',
chatId: c.conversationId || c.chatId || '',
messageCount: c.messageCount,
lastMessageTime: c.lastMessageTime,
createdAt: c.firstMessageTime || c.createdAt,
}));
renderHistoryList(
listEl,
items,
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
async (id: string) => {
if (!confirm(t('history_delete_confirm'))) return;
const ok = await deleteConversation(id);
if (ok) loadHistoryConversations();
}
);
} catch (err) {
logger.error(t('history_load_error'), err);
listEl.innerHTML = `<div class="csk-history-panel__empty"><div class="csk-history-panel__empty-icon">⚠</div><div>${t('history_load_error')}</div></div>`;
}
}
/** 获取当前消息列表 */
export function getMessages(): ChatMessage[] {
return messages;
}

23
client/src/config.ts

@ -1,5 +1,10 @@
/**
* - +
*
*
* integrateId roleId ID
* userId accountId ID
* chatId
*/
import { SDKConfig, ResolvedConfig } from './types';
import { logger } from './logger';
@ -14,15 +19,16 @@ const DEFAULT_LAUNCHER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="28
*
*/
export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
// 校验必传参数:integrateId
if (!raw.integrateId || typeof raw.integrateId !== 'string' || raw.integrateId.trim() === '') {
logger.error('integrateId 是必传参数,请检查 init() 调用。示例:ChatbotSDK.init({ integrateId: "my-app", requestDomain: "https://api.example.com" })');
// 校验必传参数:integrateId(对应后端 roleId)
if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number')
|| (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) {
logger.error('integrateId 是必传参数(对应后端 roleId 客服角色 ID),请检查 init() 调用。示例:ChatbotSDK.init({ integrateId: 1, requestDomain: "https://api.example.com" })');
return null;
}
// 校验必传参数:requestDomain
if (!raw.requestDomain || typeof raw.requestDomain !== 'string' || raw.requestDomain.trim() === '') {
logger.error('requestDomain 是必传参数,请检查 init() 调用。示例:ChatbotSDK.init({ integrateId: "my-app", requestDomain: "https://api.example.com" })');
logger.error('requestDomain 是必传参数,请检查 init() 调用。示例:ChatbotSDK.init({ integrateId: 1, requestDomain: "https://api.example.com" })');
return null;
}
@ -34,12 +40,14 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
return null;
}
// integrateId 统一转为字符串(后端 roleId 为 Long,但 query param 传字符串也可接收)
const integrateIdStr = String(raw.integrateId).trim();
// 填充默认值
const config: ResolvedConfig = {
integrateId: raw.integrateId.trim(),
integrateId: integrateIdStr,
requestDomain: raw.requestDomain.replace(/\/+$/, ''), // 去掉末尾斜杠
userId: raw.userId,
roleId: raw.roleId,
categoryId: raw.categoryId,
showCategorySwitch: raw.showCategorySwitch ?? false,
title: raw.title || 'AI 智能助手',
@ -52,8 +60,9 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
streaming: raw.streaming ?? true,
locale: raw.locale || 'zh-CN',
debug: raw.debug ?? true,
chatId: '', // 初始为空,由 chatId 初始化流程填充
};
logger.info(`配置解析完成 integrateId=${config.integrateId} requestDomain=${config.requestDomain}`);
logger.info(`配置解析完成 integrateId(=roleId)=${config.integrateId} userId(=accountId)=${config.userId || '(未设置)'} requestDomain=${config.requestDomain}`);
return config;
}

275
client/src/dom.ts

@ -1,8 +1,11 @@
/**
* DOM - +
* P1 RAG
* P2
*/
import { ResolvedConfig } from './types';
import { ResolvedConfig, RagSource } from './types';
import { debounce } from './utils';
import { t } from './i18n';
// ==================== 悬浮按钮 ====================
@ -43,6 +46,8 @@ export function createChatWindow(config: ResolvedConfig): {
inputEl: HTMLTextAreaElement;
sendBtn: HTMLElement;
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
historyPanel: HTMLElement;
showLoading: () => HTMLElement;
hideLoading: () => void;
} {
@ -62,10 +67,16 @@ export function createChatWindow(config: ResolvedConfig): {
const actions = document.createElement('div');
actions.className = 'csk-header__actions';
// 历史会话按钮(P2)
const historyBtn = document.createElement('button');
historyBtn.className = 'csk-history-btn';
historyBtn.setAttribute('title', t('history_title'));
historyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
// 最小化按钮
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
minimizeBtn.setAttribute('title', '最小化');
minimizeBtn.setAttribute('title', t('minimize'));
minimizeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
minimizeBtn.addEventListener('click', () => {
windowEl.classList.add('csk-window--hidden');
@ -74,12 +85,13 @@ export function createChatWindow(config: ResolvedConfig): {
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.className = 'csk-header__btn csk-header__btn--close';
closeBtn.setAttribute('title', '关闭');
closeBtn.setAttribute('title', t('close'));
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.addEventListener('click', () => {
windowEl.classList.add('csk-window--hidden');
});
actions.appendChild(historyBtn);
actions.appendChild(minimizeBtn);
actions.appendChild(closeBtn);
header.appendChild(titleEl);
@ -90,6 +102,75 @@ export function createChatWindow(config: ResolvedConfig): {
messagesContainer.id = 'csk-messages';
messagesContainer.className = 'csk-messages';
// === 会话管理面板(P2,默认隐藏) ===
const historyPanel = document.createElement('div');
historyPanel.className = 'csk-history-panel csk-history-panel--hidden';
historyPanel.innerHTML = `
<div class="csk-history-panel__header">
<span class="csk-history-panel__title">${t('history_title')}</span>
<button class="csk-history-panel__back" id="csk-history-back">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
${t('close')}
</button>
</div>
<div class="csk-history-panel__list" id="csk-history-list"></div>
`;
messagesContainer.appendChild(historyPanel);
// 历史面板返回按钮
const backBtn = historyPanel.querySelector('#csk-history-back');
if (backBtn) {
backBtn.addEventListener('click', () => {
historyPanel.classList.add('csk-history-panel--hidden');
});
}
// 历史按钮点击
historyBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isHidden = historyPanel.classList.contains('csk-history-panel--hidden');
historyPanel.classList.toggle('csk-history-panel--hidden');
if (isHidden) {
// 触发自定义事件,通知加载会话列表
windowEl.dispatchEvent(new CustomEvent('csk:loadHistory'));
}
});
// === 知识库分类下拉框(P1) ===
let categorySelect: HTMLSelectElement | null = null;
if (config.showCategorySwitch) {
const categoryBar = document.createElement('div');
categoryBar.className = 'csk-category-bar';
const categoryLabel = document.createElement('span');
categoryLabel.className = 'csk-category-bar__label';
categoryLabel.textContent = '📚';
categorySelect = document.createElement('select');
categorySelect.id = 'csk-category-select';
categorySelect.className = 'csk-category-select';
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
// onChange 触发自定义事件
categorySelect.addEventListener('change', () => {
const selectedId = categorySelect!.value;
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
detail: { categoryId: selectedId ? Number(selectedId) : undefined }
}));
});
categoryBar.appendChild(categoryLabel);
categoryBar.appendChild(categorySelect);
// 插入到 messages 和 inputArea 之间
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
windowEl.appendChild(categoryBar);
} else {
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
}
// === 输入区 ===
const inputArea = document.createElement('div');
inputArea.className = 'csk-input-area';
@ -97,23 +178,19 @@ export function createChatWindow(config: ResolvedConfig): {
const inputEl = document.createElement('textarea');
inputEl.id = 'csk-input';
inputEl.className = 'csk-input';
inputEl.setAttribute('placeholder', '输入您的问题...');
inputEl.setAttribute('placeholder', t('placeholder'));
inputEl.setAttribute('rows', '1');
inputEl.setAttribute('autofocus', '');
const sendBtn = document.createElement('button');
sendBtn.id = 'csk-send-btn';
sendBtn.className = 'csk-send-btn';
sendBtn.setAttribute('title', '发送');
sendBtn.setAttribute('title', t('send'));
sendBtn.setAttribute('disabled', 'true');
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
inputArea.appendChild(inputEl);
inputArea.appendChild(sendBtn);
// === 组装 ===
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
windowEl.appendChild(inputArea);
// 清空按钮(可选)
@ -121,9 +198,9 @@ export function createChatWindow(config: ResolvedConfig): {
if (config.showClear) {
clearBtn = document.createElement('button');
clearBtn.className = 'csk-clear-btn';
clearBtn.textContent = '清空对话';
clearBtn.textContent = t('clear');
clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示
// 插入到 messages 之后、inputArea 之前
// 插入到 categoryBar/inputArea 之前
windowEl.insertBefore(clearBtn, inputArea);
}
@ -160,6 +237,8 @@ export function createChatWindow(config: ResolvedConfig): {
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
showLoading,
hideLoading,
};
@ -237,14 +316,19 @@ export function renderUserBubble(container: HTMLElement, text: string, timestamp
return wrapper;
}
/** 渲染 AI 消息气泡 */
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
/** 渲染 AI 消息气泡(支持 Markdown) */
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.textContent = text;
// 支持 Markdown 渲染,传入渲染函数则使用,否则纯文本
if (renderMd) {
bubble.innerHTML = renderMd(text);
} else {
bubble.textContent = text;
}
const time = document.createElement('div');
time.className = 'csk-msg__time';
@ -277,6 +361,169 @@ export function createEmptyAIBubble(container: HTMLElement, timestamp: number):
return { wrapper, bubble };
}
// ==================== P1: RAG 引用来源渲染 ====================
/** 渲染 RAG 引用来源卡片 */
export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void {
// 移除已有的来源卡片
const existing = wrapper.querySelector('.csk-sources');
if (existing) existing.remove();
if (!sources || sources.length === 0) return;
const sourcesEl = document.createElement('div');
sourcesEl.className = 'csk-sources csk-sources--collapsed';
// 头部
const header = document.createElement('div');
header.className = 'csk-sources__header';
const titleSpan = document.createElement('span');
titleSpan.className = 'csk-sources__title';
titleSpan.textContent = `📚 ${t('source_count', { n: sources.length })}`;
const arrow = document.createElement('span');
arrow.className = 'csk-sources__arrow';
arrow.textContent = '▼';
header.appendChild(titleSpan);
header.appendChild(arrow);
// 点击折叠/展开
header.addEventListener('click', () => {
sourcesEl.classList.toggle('csk-sources--collapsed');
});
// 内容
const body = document.createElement('div');
body.className = 'csk-sources__body';
for (const src of sources) {
const item = document.createElement('div');
item.className = 'csk-source-item';
const name = document.createElement('div');
name.className = 'csk-source-item__name';
name.textContent = src.title || src.sourceName || '未知文档';
if (src.snippet) {
const snippet = document.createElement('div');
snippet.className = 'csk-source-item__snippet';
snippet.textContent = src.snippet;
item.appendChild(snippet);
}
const meta = document.createElement('div');
meta.className = 'csk-source-item__meta';
const metaParts: string[] = [];
if (src.sourceName) metaParts.push(src.sourceName);
if (src.chunkIndex !== undefined) metaParts.push(`分块 #${src.chunkIndex}`);
if (src.score !== undefined) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`);
meta.textContent = metaParts.join(' · ');
item.appendChild(name);
item.appendChild(meta);
body.appendChild(item);
}
sourcesEl.appendChild(header);
sourcesEl.appendChild(body);
// 插入到气泡和时间戳之间
const timeEl = wrapper.querySelector('.csk-msg__time');
if (timeEl) {
wrapper.insertBefore(sourcesEl, timeEl);
} else {
wrapper.appendChild(sourcesEl);
}
}
// ==================== P2: 会话管理面板渲染 ====================
/** 会话列表项数据 */
export interface HistoryItemData {
id: string;
chatId?: string;
messageCount?: number;
lastMessageTime?: string;
createdAt?: string;
}
/** 渲染会话列表 */
export function renderHistoryList(
listEl: HTMLElement,
items: HistoryItemData[],
onExport: (id: string) => void,
onDelete: (id: string) => void,
emptyText?: string
): void {
listEl.innerHTML = '';
if (items.length === 0) {
const empty = document.createElement('div');
empty.className = 'csk-history-panel__empty';
empty.innerHTML = `
<div class="csk-history-panel__empty-icon">💬</div>
<div>${emptyText || t('history_empty')}</div>
`;
listEl.appendChild(empty);
return;
}
for (const item of items) {
const el = document.createElement('div');
el.className = 'csk-history-item';
const info = document.createElement('div');
info.className = 'csk-history-item__info';
const idEl = document.createElement('div');
idEl.className = 'csk-history-item__id';
idEl.textContent = item.chatId || item.id;
const metaEl = document.createElement('div');
metaEl.className = 'csk-history-item__meta';
const metaParts: string[] = [];
if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`);
if (item.lastMessageTime) metaParts.push(item.lastMessageTime);
else if (item.createdAt) metaParts.push(item.createdAt);
metaEl.textContent = metaParts.join(' · ');
info.appendChild(idEl);
info.appendChild(metaEl);
const actionsEl = document.createElement('div');
actionsEl.className = 'csk-history-item__actions';
// 导出按钮
const exportBtn = document.createElement('button');
exportBtn.className = 'csk-history-action csk-history-action--export';
exportBtn.setAttribute('title', t('history_export'));
exportBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
onExport(item.id);
});
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'csk-history-action csk-history-action--delete';
deleteBtn.setAttribute('title', t('history_delete'));
deleteBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
onDelete(item.id);
});
actionsEl.appendChild(exportBtn);
actionsEl.appendChild(deleteBtn);
el.appendChild(info);
el.appendChild(actionsEl);
listEl.appendChild(el);
}
}
/** 滚动消息区到底部 */
export function scrollToBottom(container: HTMLElement): void {
container.scrollTop = container.scrollHeight;

152
client/src/i18n.ts

@ -0,0 +1,152 @@
/**
* - i18n +
*/
/** 语言包字典 */
const dictionaries: Record<string, Record<string, string>> = {
'zh-CN': {
// 头部
title: 'AI 智能助手',
minimize: '最小化',
close: '关闭',
// 输入区
placeholder: '输入您的问题...',
send: '发送',
// 消息
loading: '正在思考...',
stream_interrupted: '回复被中断',
stream_unstable: '网络不稳定,内容可能不完整',
// 知识库
category_placeholder: '选择知识库分类',
category_all: '全部分类',
category_load_error: '加载分类失败',
source_title: '参考来源',
source_count: '{n} 条参考来源',
source_loading: '加载来源中...',
// 清空/管理
clear: '清空对话',
clear_confirm: '确定清空所有对话记录?',
// 历史会话
history_title: '历史会话',
history_empty: '暂无历史会话',
history_load_error: '加载会话列表失败',
history_delete_confirm: '确定删除该会话?',
history_export: '导出',
history_delete: '删除',
// 错误提示
error_network: '网络连接失败,请检查网络',
error_timeout: '请求超时,请稍后重试',
error_server: '服务器异常,请稍后重试',
error_cors: '跨域请求被拦截,请联系管理员将当前域名加入 API 白名单',
error_auth: '鉴权失败,请联系管理员',
error_forbidden: '无访问权限,请联系管理员配置',
error_not_found: '请求的资源不存在',
error_rate_limit: '请求过于频繁,请稍后重试',
error_unavailable: '服务暂不可用,请稍后重试',
error_unknown: '请求发生未知错误',
error_send: '发送失败,请稍后重试',
error_stream_unsupported: '浏览器不支持流式读取',
},
'en': {
// Header
title: 'AI Assistant',
minimize: 'Minimize',
close: 'Close',
// Input
placeholder: 'Type your question...',
send: 'Send',
// Messages
loading: 'Thinking...',
stream_interrupted: 'Response interrupted',
stream_unstable: 'Network unstable, content may be incomplete',
// Knowledge base
category_placeholder: 'Select category',
category_all: 'All categories',
category_load_error: 'Failed to load categories',
source_title: 'Sources',
source_count: '{n} source(s)',
source_loading: 'Loading sources...',
// Clear/Management
clear: 'Clear chat',
clear_confirm: 'Clear all conversation history?',
// History
history_title: 'History',
history_empty: 'No conversations yet',
history_load_error: 'Failed to load conversations',
history_delete_confirm: 'Delete this conversation?',
history_export: 'Export',
history_delete: 'Delete',
// Errors
error_network: 'Network connection failed',
error_timeout: 'Request timed out, please try again',
error_server: 'Server error, please try again later',
error_cors: 'CORS request blocked. Please contact admin to whitelist your domain',
error_auth: 'Authentication failed, please contact admin',
error_forbidden: 'Access denied, please contact admin',
error_not_found: 'Resource not found',
error_rate_limit: 'Too many requests, please try again later',
error_unavailable: 'Service temporarily unavailable',
error_unknown: 'Unknown request error',
error_send: 'Failed to send, please try again',
error_stream_unsupported: 'Browser does not support streaming',
},
};
/** 当前语言 */
let currentLocale = 'zh-CN';
/**
*
*/
export function setLocale(locale: string): void {
if (dictionaries[locale]) {
currentLocale = locale;
} else {
// 尝试匹配语言前缀(如 zh -> zh-CN)
const prefix = locale.split('-')[0];
const matched = Object.keys(dictionaries).find(k => k.startsWith(prefix));
if (matched) {
currentLocale = matched;
}
// 未匹配则保持默认 zh-CN
}
}
/**
*
* @param key key
* @param params { n: 3 } {n}
*/
export function t(key: string, params?: Record<string, string | number>): string {
const dict = dictionaries[currentLocale] || dictionaries['zh-CN'];
let text = dict[key] || dictionaries['zh-CN'][key] || key;
// 简单插值替换:{n} → 实际值
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, String(v));
}
}
return text;
}
/**
*
*/
export function getLocale(): string {
return currentLocale;
}

98
client/src/index.ts

@ -1,6 +1,11 @@
/**
* ChatbotSDK
* IIFE window.ChatbotSDK
*
*
* integrateId roleId ID
* userId accountId ID
* chatId /conversation/list
*/
import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types';
import { parseConfig } from './config';
@ -8,8 +13,9 @@ import { setDebug, logger } from './logger';
import { setApiConfig } from './api';
import { injectStyles, removeStyles } from './styles';
import { createLauncher, createChatWindow, enableDrag } from './dom';
import { initChat, getMessages } from './chat';
import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConversations } from './chat';
import { clearMessages } from './storage';
import { setLocale } from './i18n';
// ==================== 单例状态 ====================
@ -21,6 +27,8 @@ let messagesContainer: HTMLElement | null = null;
let inputEl: HTMLTextAreaElement | null = null;
let sendBtn: HTMLElement | null = null;
let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null;
let historyPanel: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null;
let dragCleanup: (() => void) | null = null;
@ -36,81 +44,85 @@ function init(rawConfig: SDKConfig): void {
// 1. 配置解析与校验
const parsed = parseConfig(rawConfig);
if (!parsed) {
return; // parseConfig 已输出错误
}
if (!parsed) return;
config = parsed;
// 2. 设置日志级别
// 2. 设置国际化语言
setLocale(config.locale);
// 3. 设置日志级别
setDebug(config.debug);
// 3. 设置 API 配置
// 4. 设置 API 配置
setApiConfig(config);
// 4. 注入样式
// 5. 注入样式
injectStyles(config);
// 5. 创建悬浮按钮
// 6. 创建悬浮按钮
launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl);
// 6. 创建聊天弹窗
// 7. 创建聊天弹窗
const dom = createChatWindow(config);
windowEl = dom.window;
messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel;
showLoadingFn = dom.showLoading;
hideLoadingFn = dom.hideLoading;
document.body.appendChild(windowEl);
// 7. 启用拖拽
// 8. 启用拖拽
const headerEl = windowEl.querySelector('.csk-header') as HTMLElement;
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl);
}
// 8. 初始化对话模块
// 9. 初始化对话模块
initChat(config, {
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
showLoading: showLoadingFn,
hideLoading: hideLoadingFn,
});
// 10. 监听知识库分类切换事件
windowEl.addEventListener('csk:categoryChange', ((e: CustomEvent) => {
setCategory(e.detail.categoryId);
}) as EventListener);
// 11. 监听会话管理面板加载事件
windowEl.addEventListener('csk:loadHistory', () => {
loadHistoryConversations();
});
isInitialized = true;
logger.info(`初始化完成 integrateId=${config.integrateId} requestDomain=${config.requestDomain}`);
logger.lifecycleInit(config.integrateId, config.requestDomain);
// 12. 异步初始化 chatId 和对话历史(不阻塞 UI)
initChatHistory().catch(err => {
logger.warn('chatId 初始化失败,将在发送消息时重试', err);
});
}
/** 销毁 SDK 实例 */
function destroy(): void {
if (!isInitialized) {
return;
}
if (!isInitialized) return;
// 移除 DOM 元素
if (launcherEl && launcherEl.parentNode) {
launcherEl.parentNode.removeChild(launcherEl);
launcherEl = null;
}
if (windowEl && windowEl.parentNode) {
windowEl.parentNode.removeChild(windowEl);
windowEl = null;
}
if (launcherEl && launcherEl.parentNode) { launcherEl.parentNode.removeChild(launcherEl); launcherEl = null; }
if (windowEl && windowEl.parentNode) { windowEl.parentNode.removeChild(windowEl); windowEl = null; }
if (dragCleanup) { dragCleanup(); dragCleanup = null; }
// 移除拖拽事件
if (dragCleanup) {
dragCleanup();
dragCleanup = null;
}
// 移除样式
removeStyles();
// 重置状态
const oldIntegrateId = config?.integrateId;
config = null;
isInitialized = false;
@ -118,47 +130,38 @@ function destroy(): void {
inputEl = null;
sendBtn = null;
clearBtn = null;
categorySelect = null;
historyPanel = null;
showLoadingFn = null;
hideLoadingFn = null;
logger.info(`销毁实例 integrateId=${oldIntegrateId}`);
logger.lifecycleDestroy(oldIntegrateId || '');
}
/** 打开聊天窗口 */
function open(): void {
if (!windowEl) return;
windowEl.classList.remove('csk-window--hidden');
}
/** 关闭聊天窗口 */
function close(): void {
if (!windowEl) return;
windowEl.classList.add('csk-window--hidden');
}
/** 切换窗口显示/隐藏 */
function toggle(): void {
if (!windowEl) return;
if (windowEl.classList.contains('csk-window--hidden')) {
open();
// 聚焦输入框
setTimeout(() => {
if (inputEl) inputEl.focus();
}, 100);
setTimeout(() => { if (inputEl) inputEl.focus(); }, 100);
} else {
close();
}
}
/** 清空当前会话历史 */
function clearHistory(): void {
if (!config) return;
// 通过触发自定义事件,让 chat 模块处理
if (clearBtn) {
clearBtn.click();
} else if (confirm('确定清空所有对话记录?')) {
clearMessages(config.integrateId);
}
if (clearBtn) { clearBtn.click(); }
else if (confirm('确定清空所有对话记录?')) { clearMessages(config.integrateId); }
}
// ==================== 挂载到全局 ====================
@ -172,7 +175,6 @@ const ChatbotSDK: ChatbotSDKInstance = {
clearHistory,
};
// IIFE 自动挂载
if (typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).ChatbotSDK = ChatbotSDK;
}

72
client/src/logger.ts

@ -1,5 +1,6 @@
/**
* - [ChatbotSDK]
*
*/
import { ResolvedConfig } from './types';
@ -12,15 +13,18 @@ export function setDebug(enabled: boolean): void {
debugEnabled = enabled;
}
/** 性能计时器 */
const timers: Record<string, number> = {};
export const logger = {
/** 普通信息日志 */
/** 普通信息日志(受 debug 开关控制) */
info(msg: string, data?: unknown): void {
if (debugEnabled) {
console.log(PREFIX, msg, data !== undefined ? data : '');
}
},
/** 警告日志 */
/** 警告日志(受 debug 开关控制) */
warn(msg: string, data?: unknown): void {
if (debugEnabled) {
console.warn(PREFIX, msg, data !== undefined ? data : '');
@ -31,4 +35,68 @@ export const logger = {
error(msg: string, data?: unknown): void {
console.error(PREFIX, msg, data !== undefined ? data : '');
},
/** 开始计时 */
time(label: string): void {
timers[label] = Date.now();
},
/** 结束计时并输出日志 */
timeEnd(label: string, prefix?: string): number {
const start = timers[label];
if (start !== undefined) {
const duration = Date.now() - start;
delete timers[label];
if (debugEnabled) {
const msg = prefix ? `${prefix} ${duration}ms` : `${label} ${duration}ms`;
console.log(PREFIX, msg);
}
return duration;
}
return 0;
},
/** 生命周期日志:init */
lifecycleInit(integrateId: string, requestDomain: string): void {
this.info(`初始化完成 integrateId=${integrateId} requestDomain=${requestDomain}`);
},
/** 生命周期日志:destroy */
lifecycleDestroy(integrateId: string): void {
this.info(`销毁实例 integrateId=${integrateId}`);
},
/** 生命周期日志:sendMessage */
lifecycleSend(integrateId: string, length: number): void {
this.info(`发送消息 integrateId=${integrateId} length=${length}`);
this.time(`send_${integrateId}`);
},
/** 生命周期日志:收到回复 */
lifecycleReply(integrateId: string, length: number): void {
const duration = this.timeEnd(`send_${integrateId}`, 'AI 回复');
this.info(`AI 回复 integrateId=${integrateId} length=${length} duration=${duration}ms`);
},
/** 生命周期日志:请求失败 */
lifecycleError(integrateId: string, status: string, message: string): void {
this.timeEnd(`send_${integrateId}`);
this.error(`请求失败 integrateId=${integrateId} status=${status} message=${message}`);
},
/** 生命周期日志:清空会话 */
lifecycleClear(integrateId: string): void {
this.info(`清空会话 integrateId=${integrateId}`);
},
/** 生命周期日志:流式回复完成 */
lifecycleStreamDone(integrateId: string, length: number): void {
const duration = this.timeEnd(`send_${integrateId}`, '流式回复');
this.info(`流式回复完成 integrateId=${integrateId} length=${length} duration=${duration}ms`);
},
/** 生命周期日志:知识库切换 */
lifecycleCategoryChange(categoryId: string | number): void {
this.info(`切换知识库分类 categoryId=${categoryId}`);
},
};

212
client/src/markdown.ts

@ -0,0 +1,212 @@
/**
* Markdown - XSS
*
*
* HTML Markdown HTML
*/
import { escapeHtml } from './utils';
/** 代码块占位符前缀 */
const CODE_BLOCK_PREFIX = '\x00CODEBLOCK_';
/** 行内代码占位符前缀 */
const INLINE_CODE_PREFIX = '\x00INLINECODE_';
/**
* Markdown HTML
* @param text Markdown
* @returns HTML
*/
export function renderMarkdown(text: string): string {
if (!text || typeof text !== 'string') return '';
// 1. 提取代码块(防止内部 Markdown 被处理)
const codeBlocks: string[] = [];
let processed = text;
// 提取围栏代码块 ```
processed = processed.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const idx = codeBlocks.length;
const escapedCode = escapeHtml(code.trimEnd());
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
codeBlocks.push(`<pre class="csk-md-code-block"><code${langClass}>${escapedCode}</code></pre>`);
return `${CODE_BLOCK_PREFIX}${idx}\x00`;
});
// 2. 提取行内代码
const inlineCodes: string[] = [];
processed = processed.replace(/`([^`\n]+)`/g, (_match, code) => {
const idx = inlineCodes.length;
inlineCodes.push(`<code class="csk-md-inline-code">${escapeHtml(code)}</code>`);
return `${INLINE_CODE_PREFIX}${idx}\x00`;
});
// 3. 转义剩余 HTML(代码块和行内代码已安全处理)
processed = escapeHtml(processed);
// 4. 还原代码块和行内代码占位符(它们已经是安全 HTML)
processed = restorePlaceholders(processed, CODE_BLOCK_PREFIX, codeBlocks);
processed = restorePlaceholders(processed, INLINE_CODE_PREFIX, inlineCodes);
// 5. 逐行处理 Markdown 语法
const lines = processed.split('\n');
const result: string[] = [];
let inList = false;
let listType = ''; // 'ul' 或 'ol'
let inBlockquote = false;
let paragraphBuffer: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 代码块已在占位符还原阶段处理,直接输出
if (line.includes(CODE_BLOCK_PREFIX) || line.includes('<pre class="csk-md-code-block">')) {
flushParagraph();
closeList();
closeBlockquote();
result.push(line);
continue;
}
// 标题
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
flushParagraph();
closeList();
closeBlockquote();
const level = headingMatch[1].length;
result.push(`<h${level} class="csk-md-h${level}">${headingMatch[2]}</h${level}>`);
continue;
}
// 引用
const quoteMatch = line.match(/^&gt;\s?(.*)/);
if (quoteMatch) {
flushParagraph();
closeList();
if (!inBlockquote) {
inBlockquote = true;
result.push('<blockquote class="csk-md-blockquote">');
}
result.push(`<p>${inlineFormat(quoteMatch[1])}</p>`);
continue;
} else if (inBlockquote) {
closeBlockquote();
}
// 无序列表
const ulMatch = line.match(/^[\-\*]\s+(.+)/);
if (ulMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ul') {
closeList();
inList = true;
listType = 'ul';
result.push('<ul class="csk-md-ul">');
}
result.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// 有序列表
const olMatch = line.match(/^\d+\.\s+(.+)/);
if (olMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ol') {
closeList();
inList = true;
listType = 'ol';
result.push('<ol class="csk-md-ol">');
}
result.push(`<li>${inlineFormat(olMatch[1])}</li>`);
continue;
}
// 空行 → 段落分隔
if (line.trim() === '') {
flushParagraph();
closeList();
continue;
}
// 水平线
if (/^(\*{3,}|-{3,}|_{3,})$/.test(line.trim())) {
flushParagraph();
closeList();
closeBlockquote();
result.push('<hr class="csk-md-hr">');
continue;
}
// 普通文本 → 收集到段落缓冲
closeList();
closeBlockquote();
paragraphBuffer.push(inlineFormat(line));
}
flushParagraph();
closeList();
closeBlockquote();
return result.join('\n');
// === 辅助函数 ===
/** 行内格式化:粗体、斜体、链接 */
function inlineFormat(text: string): string {
// 粗体 **text** 或 __text__
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体 *text* 或 _text_
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<em>$1</em>');
// 删除线 ~~text~~
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 链接 [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => {
// 只允许 http/https 链接,防止 javascript: 协议
const safeUrl = /^https?:\/\//i.test(url) ? url : '#';
return `<a class="csk-md-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
});
return text;
}
/** 将段落缓冲输出为 <p> */
function flushParagraph(): void {
if (paragraphBuffer.length > 0) {
result.push(`<p class="csk-md-p">${paragraphBuffer.join('<br>')}</p>`);
paragraphBuffer = [];
}
}
/** 关闭列表 */
function closeList(): void {
if (inList) {
result.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
listType = '';
}
}
/** 关闭引用块 */
function closeBlockquote(): void {
if (inBlockquote) {
result.push('</blockquote>');
inBlockquote = false;
}
}
}
/** 还原占位符为安全 HTML */
function restorePlaceholders(text: string, prefix: string, replacements: string[]): string {
return text.replace(new RegExp(escapeRegex(prefix) + '(\\d+)\x00', 'g'), (_m, idx) => {
return replacements[parseInt(idx)] || '';
});
}
/** 转义正则特殊字符 */
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

362
client/src/styles.ts

@ -1,5 +1,7 @@
/**
* - CSS csk-
* P1 RAG Markdown
* P2
*/
import { ResolvedConfig } from './types';
@ -300,6 +302,366 @@ function getStyles(config: ResolvedConfig): string {
color: #DC2626;
}
/* ========== P1: 知识库分类下拉 ========== */
.csk-category-bar {
display: flex;
align-items: center;
padding: 6px 12px;
border-top: 1px solid #E5E7EB;
background: #F9FAFB;
gap: 8px;
}
.csk-category-bar__label {
font-size: 12px;
color: #6B7280;
white-space: nowrap;
}
.csk-category-select {
flex: 1;
padding: 5px 8px;
border: 1px solid #E5E7EB;
border-radius: 6px;
font-size: 12px;
color: #374151;
background: #fff;
outline: none;
cursor: pointer;
font-family: inherit;
transition: border-color 0.2s;
max-width: 200px;
}
.csk-category-select:focus {
border-color: var(--csk-primary);
}
/* ========== P1: RAG 引用来源卡片 ========== */
.csk-sources {
margin-top: 8px;
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
font-size: 12px;
max-width: 100%;
}
.csk-sources__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #F9FAFB;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.csk-sources__header:hover {
background: #F3F4F6;
}
.csk-sources__title {
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
color: #374151;
}
.csk-sources__arrow {
transition: transform 0.2s;
color: #9CA3AF;
}
.csk-sources--collapsed .csk-sources__arrow {
transform: rotate(-90deg);
}
.csk-sources__body {
border-top: 1px solid #E5E7EB;
padding: 0;
}
.csk-sources--collapsed .csk-sources__body {
display: none;
}
.csk-source-item {
padding: 8px 12px;
border-bottom: 1px solid #F3F4F6;
transition: background 0.15s;
}
.csk-source-item:last-child {
border-bottom: none;
}
.csk-source-item:hover {
background: #F9FAFB;
}
.csk-source-item__name {
font-weight: 500;
color: #1F2937;
margin-bottom: 2px;
}
.csk-source-item__snippet {
color: #6B7280;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.csk-source-item__meta {
font-size: 11px;
color: #9CA3AF;
margin-top: 2px;
}
/* ========== P1: Markdown 渲染样式 ========== */
.csk-msg--ai .csk-msg__bubble .csk-md-p {
margin: 0 0 8px;
}
.csk-msg--ai .csk-msg__bubble .csk-md-p:last-child {
margin-bottom: 0;
}
.csk-msg--ai .csk-msg__bubble .csk-md-h1,
.csk-msg--ai .csk-msg__bubble .csk-md-h2,
.csk-msg--ai .csk-msg__bubble .csk-md-h3,
.csk-msg--ai .csk-msg__bubble .csk-md-h4,
.csk-msg--ai .csk-msg__bubble .csk-md-h5,
.csk-msg--ai .csk-msg__bubble .csk-md-h6 {
margin: 12px 0 6px;
font-weight: 600;
line-height: 1.3;
}
.csk-msg--ai .csk-msg__bubble .csk-md-h1 { font-size: 20px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h2 { font-size: 17px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h3 { font-size: 15px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h4 { font-size: 14px; }
.csk-md-code-block {
background: #1E293B;
color: #E2E8F0;
padding: 12px 14px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
font-size: 13px;
line-height: 1.5;
font-family: 'SF Mono', 'Consolas', 'Menlo', monospace;
}
.csk-md-code-block code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.csk-md-inline-code {
background: #E5E7EB;
color: #DC2626;
padding: 1px 6px;
border-radius: 4px;
font-size: 13px;
font-family: 'SF Mono', 'Consolas', 'Menlo', monospace;
}
.csk-msg--ai .csk-msg__bubble .csk-md-ul,
.csk-msg--ai .csk-msg__bubble .csk-md-ol {
padding-left: 20px;
margin: 6px 0;
}
.csk-msg--ai .csk-msg__bubble .csk-md-ul li,
.csk-msg--ai .csk-msg__bubble .csk-md-ol li {
margin-bottom: 4px;
}
.csk-md-blockquote {
border-left: 3px solid var(--csk-primary);
padding-left: 12px;
margin: 8px 0;
color: #6B7280;
}
.csk-md-link {
color: var(--csk-primary);
text-decoration: none;
}
.csk-md-link:hover {
text-decoration: underline;
}
.csk-md-hr {
border: none;
border-top: 1px solid #E5E7EB;
margin: 12px 0;
}
/* ========== P2: 会话管理面板 ========== */
.csk-history-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #fff;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.csk-history-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.csk-history-panel {
position: absolute;
top: 48px;
left: 0;
right: 0;
bottom: 0;
background: #fff;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
}
.csk-history-panel--hidden {
display: none;
}
.csk-history-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #E5E7EB;
background: #F9FAFB;
}
.csk-history-panel__title {
font-size: 14px;
font-weight: 600;
color: #1F2937;
}
.csk-history-panel__back {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #fff;
color: #374151;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.csk-history-panel__back:hover {
background: #F3F4F6;
}
.csk-history-panel__list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.csk-history-panel__list::-webkit-scrollbar {
width: 4px;
}
.csk-history-panel__list::-webkit-scrollbar-thumb {
background: #E5E7EB;
border-radius: 2px;
}
.csk-history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
margin-bottom: 4px;
}
.csk-history-item:hover {
background: #F3F4F6;
}
.csk-history-item__info {
flex: 1;
min-width: 0;
}
.csk-history-item__id {
font-size: 13px;
font-weight: 500;
color: #1F2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.csk-history-item__meta {
font-size: 11px;
color: #9CA3AF;
margin-top: 2px;
}
.csk-history-item__actions {
display: flex;
gap: 4px;
margin-left: 8px;
opacity: 0;
transition: opacity 0.15s;
}
.csk-history-item:hover .csk-history-item__actions {
opacity: 1;
}
.csk-history-action {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.csk-history-action--export {
background: #EFF6FF;
color: #2563EB;
}
.csk-history-action--export:hover {
background: #DBEAFE;
}
.csk-history-action--delete {
background: #FEF2F2;
color: #DC2626;
}
.csk-history-action--delete:hover {
background: #FEE2E2;
}
.csk-history-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #9CA3AF;
font-size: 13px;
text-align: center;
}
.csk-history-panel__empty-icon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
.csk-history-panel__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #9CA3AF;
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;
}
/* ========== 移动端适配 ========== */
@media (max-width: 480px) {
.csk-window {

68
client/src/types.ts

@ -1,20 +1,23 @@
/**
* ChatbotSDK
*
* SDK
* integrateId roleId ID AI
* userId accountId ID
* chatId /conversation/list
*/
/** SDK 初始化配置 */
export interface SDKConfig {
// === 必传参数 ===
/** 集成标识 → 后端 chatId 参数 + 数据隔离 key */
integrateId: string;
/** 集成标识 → 后端 roleId 参数(客服角色 ID),决定 AI 人设和知识库检索范围 */
integrateId: string | number;
/** 后端 API 域名 */
requestDomain: string;
// === 用户标识 ===
/** 宿主用户标识 → 后端 accountId 参数 */
/** 宿主用户标识 → 后端 accountId 参数(客户账号 ID),账号可绑定角色 */
userId?: string;
/** 客服角色 ID */
roleId?: number;
// === 知识库 ===
/** 默认知识库分类 ID */
@ -49,22 +52,38 @@ export interface SDKConfig {
/** 解析后的完整配置(所有可选字段已填充默认值) */
export interface ResolvedConfig {
/** 集成标识(同时也是 roleId,客服角色 ID) */
integrateId: string;
/** 后端 API 域名 */
requestDomain: string;
/** 客户账号 ID → 后端 accountId */
userId?: string;
roleId?: number;
/** 知识库分类 ID */
categoryId?: number;
/** 是否显示知识库切换 */
showCategorySwitch: boolean;
/** 弹窗标题 */
title: string;
/** 弹窗宽度 */
width: number;
/** 位置 */
position: 'left-bottom' | 'right-bottom';
/** 主色调 */
primaryColor: string;
/** 悬浮按钮图标 */
launcherIcon: string;
/** 显示清空按钮 */
showClear: boolean;
/** 显示管理面板 */
showAdminPanel: boolean;
/** 流式输出 */
streaming: boolean;
/** 界面语言 */
locale: string;
/** 调试日志 */
debug: boolean;
/** 当前对话 ID(自动管理,从 /conversation/list 获取或自动生成) */
chatId: string;
}
/** 聊天消息 */
@ -91,10 +110,45 @@ export interface RagSource {
snippet: string;
}
/** 知识库分类节点(树形结构) */
export interface CategoryNode {
id: string;
name: string;
parentId?: string;
children?: CategoryNode[];
}
/** 知识库分类平铺项 */
export interface CategoryItem {
id: string;
name: string;
parentId?: string;
}
/** 会话摘要 */
export interface ConversationSummary {
id: string;
chatId: string;
accountId?: string;
roleId?: number;
messageCount?: number;
lastMessageTime?: number;
createdAt?: number;
}
/** 会话详情 */
export interface ConversationDetail {
id: string;
chatId: string;
accountId?: string;
messages: ChatMessage[];
}
/** 本地缓存数据结构 */
export interface CacheData {
messages: ChatMessage[];
updatedAt: number;
chatId?: string;
}
/** SDK 公开 API 接口 */
@ -109,7 +163,7 @@ export interface ChatbotSDKInstance {
close(): void;
/** 切换窗口显示/隐藏 */
toggle(): void;
/** 清空当前会话历史 */
/** 清空当前会话历史(生成新的 chatId) */
clearHistory(): void;
}

1715
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

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

@ -8,22 +8,19 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;background:#F0F2F5;min-height:100vh}
.page{display:flex;height:100vh}
/* 左侧面板 */
.panel{width:360px;min-width:360px;background:#fff;border-right:1px solid #E5E7EB;display:flex;flex-direction:column;overflow:hidden}
.panel{width:380px;min-width:380px;background:#fff;border-right:1px solid #E5E7EB;display:flex;flex-direction:column;overflow:hidden}
.panel-header{padding:20px;border-bottom:1px solid #F3F4F6}
.panel-header h1{font-size:20px;color:#111827;margin-bottom:4px}
.panel-header p{font-size:12px;color:#9CA3AF}
.panel-body{flex:1;overflow-y:auto;padding:20px}
.panel-body::-webkit-scrollbar{width:4px}
.panel-body::-webkit-scrollbar-thumb{background:#E5E7EB;border-radius:2px}
/* 表单 */
.fg{margin-bottom:14px}
.fg label{display:block;font-size:12px;font-weight:500;color:#374151;margin-bottom:4px}
.fg input,.fg select{width:100%;padding:8px 10px;border:1px solid #D1D5DB;border-radius:6px;font-size:13px;outline:none;transition:border-color .2s,box-shadow .2s;font-family:inherit}
.fg input:focus,.fg select:focus{border-color:#4F46E5;box-shadow:0 0 0 3px rgba(79,70,229,.1)}
.fg-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.fg .hint{font-size:11px;color:#9CA3AF;margin-top:3px}
/* 按钮 */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 18px;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit}
.btn-primary{background:#4F46E5;color:#fff}.btn-primary:hover{background:#4338CA}
.btn-danger{background:#FFF1F0;color:#CF1322}.btn-danger:hover{background:#FFE4E0}
@ -32,66 +29,78 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
.btn-full{width:100%}
.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
.divider{border:none;border-top:1px solid #F3F4F6;margin:16px 0}
/* 右侧 */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.toolbar{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;background:#fff;border-bottom:1px solid #E5E7EB}
.toolbar h2{font-size:15px;font-weight:600;color:#111827}
/* 用例区 */
.toolbar-tabs{display:flex;gap:4px}
.toolbar-tab{padding:6px 14px;border:1px solid #E5E7EB;border-radius:6px;background:#fff;color:#6B7280;font-size:12px;cursor:pointer;transition:all .2s}
.toolbar-tab--active{background:#4F46E5;color:#fff;border-color:#4F46E5}
.toolbar-tab:hover:not(.toolbar-tab--active){background:#F9FAFB}
.cases{flex:1;overflow-y:auto;padding:20px}
.cases::-webkit-scrollbar{width:4px}
.cases::-webkit-scrollbar-thumb{background:#E5E7EB;border-radius:2px}
.case{background:#fff;border:1px solid #E5E7EB;border-radius:10px;margin-bottom:14px;overflow:hidden}
.case-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#FAFBFC;border-bottom:1px solid #F3F4F6}
.case-title{font-size:13px;font-weight:600;color:#111827}
.case-badge{font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px}
.badge-p0{background:#DBEAFE;color:#1D4ED8}
.badge-p1{background:#FEF3C7;color:#92400E}
.badge-p2{background:#F3E8FF;color:#7C3AED}
.case-body{padding:16px}
.case-desc{font-size:12px;color:#6B7280;margin-bottom:10px;line-height:1.5}
/* 日志 */
.logs{background:#1F2937;color:#D1D5DB;font-size:12px;line-height:1.7;padding:14px;border-radius:6px;max-height:260px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-family:'SF Mono','Consolas','Menlo',monospace}
.logs::-webkit-scrollbar{width:4px}
.logs::-webkit-scrollbar-thumb{background:#4B5563;border-radius:2px}
.log-line--pass{color:#6EE7B7}.log-line--fail{color:#FCA5A5}.log-line--info{color:#93C5FD}.log-line--warn{color:#FCD34D}
/* 状态标签 */
.tag{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;padding:3px 10px;border-radius:12px}
.tag--idle{background:#F3F4F6;color:#6B7280}
.tag--running{background:#DBEAFE;color:#1D4ED8}
.tag--pass{background:#D1FAE5;color:#065F46}
.tag--fail{background:#FEE2E2;color:#991B1B}
/* 概览 */
.overview{display:grid;grid-template-columns:1fr 1fr 1fr 1fr 1fr;gap:10px;margin-bottom:16px}
.overview{display:grid;grid-template-columns:1fr 1fr 1fr 1fr 1fr 1fr;gap:10px;margin-bottom:16px}
.card-stat{background:#fff;border:1px solid #E5E7EB;border-radius:8px;padding:14px;text-align:center}
.card-stat .num{font-size:28px;font-weight:700}
.card-stat .lbl{font-size:11px;color:#6B7280;margin-top:2px}
.num--ok{color:#059669}.num--err{color:#DC2626}.num--total{color:#4F46E5}
/* 底部 */
.footer{padding:10px 20px;background:#fff;border-top:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#9CA3AF}
.mapping-note{background:#FFFBEB;border:1px solid #FDE68A;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:11px;color:#92400E;line-height:1.6}
.mapping-note b{color:#78350F}
</style>
</head>
<body>
<div class="page">
<!-- ===== 左侧配置面板 ===== -->
<div class="panel">
<div class="panel-header">
<h1>🧪 ChatbotSDK 测试面板</h1>
<p>P0 核心链路 · 交互式验证</p>
<p>P0 核心 · P1 体验增强 · P2 运营完善</p>
</div>
<div class="panel-body">
<!-- SDK 状态 -->
<div style="margin-bottom:16px;display:flex;align-items:center;gap:8px">
<span class="tag tag--idle" id="tag-sdk">⭕ SDK 未加载</span>
<span class="tag tag--idle" id="tag-api">⭕ API 未测</span>
</div>
<!-- 参数映射说明 -->
<div class="mapping-note">
<b>📋 参数映射(SDK → 后端)</b><br>
integrateId → <b>roleId</b>(客服角色 ID)<br>
userId → <b>accountId</b>(客户账号 ID)<br>
chatId → <b>自动管理</b>(从 /conversation/list 获取或自动生成)
</div>
<div class="fg">
<label>integrateId(必传)</label>
<input type="text" id="cfg-iid" value="test-sdk-p0" placeholder="集成标识">
<label>integrateId → roleId(必传,客服角色 ID)</label>
<input type="number" id="cfg-iid" value="1" placeholder="客服角色 ID(数字)">
<div class="hint">对应后端 AiController 的 roleId 参数</div>
</div>
<div class="fg">
<label>requestDomain(必传)</label>
<input type="text" id="cfg-domain" value="" placeholder="http://localhost:9090">
</div>
<div class="fg">
<label>userId(可选)</label>
<input type="text" id="cfg-uid" value="" placeholder="宿主用户标识(如 zhangsan)">
<label>userId → accountId(可选,客户账号 ID)</label>
<input type="text" id="cfg-uid" value="" placeholder="客户账号 ID(如 hanlin)">
<div class="hint">对应后端 AiController 的 accountId 参数</div>
</div>
<div class="fg-row">
<div class="fg">
@ -118,6 +127,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
<label>streaming</label>
<select id="cfg-stream"><option value="1" selected>开启流式</option><option value="0">关闭(同步)</option></select>
</div>
<div class="fg">
<label>locale</label>
<select id="cfg-locale"><option value="zh-CN" selected>中文</option><option value="en">English</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>showCategorySwitch</label>
<select id="cfg-cat"><option value="0" selected>关闭</option><option value="1">开启</option></select>
</div>
<div class="fg">
<label>debug</label>
<select id="cfg-debug"><option value="1" selected>开启日志</option><option value="0">关闭日志</option></select>
@ -140,585 +159,199 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
<hr class="divider">
<div style="font-size:12px;color:#6B7280;line-height:1.6">
<b>💡 操作提示</b><br>
1. 点击「初始化 SDK」→ 右下角出现悬浮按钮<br>
2. 点击悬浮按钮 → 打开聊天弹窗<br>
3. 在弹窗里输入问题 → 回车发送<br>
4. 观察 AI 回复(流式/同步)<br>
5. 等待 30s 后刷新页面 → 历史恢复
1. integrateId 填写客服角色 ID(数字,对应后端 roleId)<br>
2. userId 填写客户账号 ID(对应后端 accountId)<br>
3. chatId 自动管理:从 /conversation/list 获取或自动生成<br>
4. 对话历史从后端加载(点击头部时钟图标查看)<br>
5. AI 回复支持 Markdown 渲染<br>
6. 开启 showCategorySwitch 可选择知识库分类
</div>
</div>
</div>
<!-- ===== 右侧测试区 ===== -->
<div class="main">
<div class="toolbar">
<h2>📋 测试用例</h2>
<button class="btn btn-primary btn-sm" onclick="runAll()">▶ 运行全部(约 10s)</button>
<div style="display:flex;gap:8px;align-items:center">
<div class="toolbar-tabs">
<button class="toolbar-tab toolbar-tab--active" onclick="filterTests('all', this)">全部</button>
<button class="toolbar-tab" onclick="filterTests('p0', this)">P0</button>
<button class="toolbar-tab" onclick="filterTests('p1', this)">P1</button>
<button class="toolbar-tab" onclick="filterTests('p2', this)">P2</button>
</div>
<button class="btn btn-primary btn-sm" onclick="runAll()">▶ 运行全部</button>
</div>
</div>
<!-- 概览 -->
<div style="padding:20px 20px 0">
<div class="overview">
<div class="card-stat"><div class="num num--total" id="total-tests">12</div><div class="lbl">总用例数</div></div>
<div class="card-stat"><div class="num num--total" id="total-tests">22</div><div class="lbl">总用例数</div></div>
<div class="card-stat"><div class="num num--ok" id="pass-count">0</div><div class="lbl">通过</div></div>
<div class="card-stat"><div class="num num--err" id="fail-count">0</div><div class="lbl">失败</div></div>
<div class="card-stat"><div class="num" id="api-count" style="color:#2563EB">0</div><div class="lbl">API 调用</div></div>
<div class="card-stat"><div class="num" id="speed-ms" style="color:#7C3AED">-</div><div class="lbl">平均延迟</div></div>
<div class="card-stat"><div class="num" id="skip-count" style="color:#D97706">0</div><div class="lbl">跳过</div></div>
</div>
</div>
<div class="cases" id="cases-container">
<!-- 动态生成 -->
</div>
<div class="cases" id="cases-container"></div>
<div class="footer">
<span id="footer-info">ChatbotSDK v1.0.0-P0 | 后端:检测中...</span>
<span id="footer-info">ChatbotSDK v1.2.0 | 后端:检测中...</span>
<span id="footer-time"></span>
</div>
</div>
</div>
<!-- 引入 SDK -->
<script src="/sdk/chatbot-sdk.min.js"></script>
<script>
(function() {
'use strict';
let passCount = 0, failCount = 0, skipCount = 0, apiCalls = 0, apiDurations = [], currentFilter = 'all';
// ========== 全局状态 ==========
let passCount = 0;
let failCount = 0;
let apiCalls = 0;
let apiDurations = []; // 存储每次API调用的耗时
// ========== 日志工具 ==========
function getLog(id) { return document.getElementById(id); }
function setEl(id, text) { const e = getLog(id); if (e) e.textContent = text; }
function setHtml(id, html) { const e = getLog(id); if (e) e.innerHTML = html; }
function appendLog(id, text, cls) {
const el = getLog(id);
if (!el) return;
const span = document.createElement('span');
span.textContent = text + '\n';
span.className = cls || '';
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
function clearLog(id) {
const el = getLog(id);
if (el) el.innerHTML = '';
}
function getEl(id) { return document.getElementById(id); }
function setEl(id, text) { const e = getEl(id); if (e) e.textContent = text; }
function updateStats() {
setEl('pass-count', passCount);
setEl('fail-count', failCount);
setEl('api-count', apiCalls);
if (apiDurations.length > 0) {
const avg = Math.round(apiDurations.reduce((a, b) => a + b, 0) / apiDurations.length);
setEl('speed-ms', avg + 'ms');
}
setEl('pass-count', passCount); setEl('fail-count', failCount);
setEl('skip-count', skipCount); setEl('api-count', apiCalls);
if (apiDurations.length > 0) setEl('speed-ms', Math.round(apiDurations.reduce((a,b)=>a+b,0)/apiDurations.length)+'ms');
}
// ========== 测试框架 ==========
async function runTest(id, name, desc, fn) {
const container = getLog('cases-container');
async function runTest(id, name, desc, fn, phase) {
const container = getEl('cases-container');
const caseEl = document.createElement('div');
caseEl.className = 'case';
caseEl.id = 'case-' + id;
caseEl.innerHTML = `
<div class="case-header">
<span class="case-title">${id}. ${name}</span>
<span class="tag tag--idle" id="tag-${id}">⏳ 等待</span>
</div>
<div class="case-body">
<div class="case-desc">${desc}</div>
<div class="logs" id="log-${id}"></div>
</div>
`;
caseEl.className = 'case'; caseEl.id = 'case-'+id; caseEl.dataset.phase = phase||'p0';
const badgeClass = phase==='p1'?'badge-p1':phase==='p2'?'badge-p2':'badge-p0';
caseEl.innerHTML = `<div class="case-header"><span class="case-title">${id}. ${name}<span class="case-badge ${badgeClass}">${(phase||'p0').toUpperCase()}</span></span><span class="tag tag--idle" id="tag-${id}">⏳ 等待</span></div><div class="case-body"><div class="case-desc">${desc}</div><div class="logs" id="log-${id}"></div></div>`;
container.appendChild(caseEl);
const tagEl = getLog('tag-' + id);
const logId = 'log-' + id;
function mark(status, text) {
if (status === 'running') {
tagEl.className = 'tag tag--running';
tagEl.textContent = '🔄 ' + text;
} else if (status === 'pass') {
tagEl.className = 'tag tag--pass';
tagEl.textContent = '✅ ' + text;
passCount++;
} else {
tagEl.className = 'tag tag--fail';
tagEl.textContent = '❌ ' + text;
failCount++;
}
const tagEl = getEl('tag-'+id), logId = 'log-'+id;
function mark(s, t) {
if(s==='running'){tagEl.className='tag tag--running';tagEl.textContent='🔄 '+t;}
else if(s==='pass'){tagEl.className='tag tag--pass';tagEl.textContent='✅ '+t;passCount++;}
else if(s==='skip'){tagEl.className='tag tag--idle';tagEl.textContent='⏭ '+t;skipCount++;}
else{tagEl.className='tag tag--fail';tagEl.textContent='❌ '+t;failCount++;}
updateStats();
}
appendLog(logId, '开始执行...', 'log-line--info');
mark('running', '执行中');
try {
await fn(logId, mark);
// 如果 fn 没有主动 mark('pass'),这里自动标记
if (tagEl.className === 'tag tag--running') {
mark('pass', '通过');
}
} catch (e) {
appendLog(logId, '异常: ' + (e.message || e), 'log-line--fail');
mark('fail', e.message || '未知错误');
}
function log(t,c){const el=getEl(logId);if(!el)return;const s=document.createElement('span');s.textContent=t+'\n';s.className=c||'';el.appendChild(s);el.scrollTop=el.scrollHeight;}
log('开始执行...','log-line--info'); mark('running','执行中');
try { await fn(log, mark); if(tagEl.className==='tag tag--running') mark('pass','通过'); }
catch(e) { log('异常: '+(e.message||e),'log-line--fail'); mark('fail',e.message||'未知错误'); }
}
// ========== 辅助函数 ==========
function getCfg() {
return {
integrateId: getLog('cfg-iid').value,
requestDomain: getLog('cfg-domain').value || window.location.origin,
userId: getLog('cfg-uid').value || undefined,
title: getLog('cfg-title').value,
primaryColor: getLog('cfg-color').value,
position: getLog('cfg-pos').value,
width: parseInt(getLog('cfg-width').value) || 380,
streaming: getLog('cfg-stream').value === '1',
debug: getLog('cfg-debug').value === '1',
integrateId: getEl('cfg-iid').value, // 对应后端 roleId
requestDomain: getEl('cfg-domain').value || window.location.origin,
userId: getEl('cfg-uid').value || undefined, // 对应后端 accountId
title: getEl('cfg-title').value,
primaryColor: getEl('cfg-color').value,
position: getEl('cfg-pos').value,
width: parseInt(getEl('cfg-width').value) || 380,
streaming: getEl('cfg-stream').value === '1',
locale: getEl('cfg-locale').value,
showCategorySwitch: getEl('cfg-cat').value === '1',
debug: getEl('cfg-debug').value === '1',
showClear: true,
};
}
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
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 sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function assert(cond, msg) { if (!cond) throw new Error(msg); }
function assertExists(sel, msg) {
const el = document.getElementById(sel) || document.querySelector(sel);
assert(!!el, msg || (sel + ' 不存在'));
return el;
}
// ========== 对外快捷操作 ==========
window.doInit = function() {
ChatbotSDK.destroy();
ChatbotSDK.init(getCfg());
const tag = getLog('tag-sdk');
if (document.getElementById('csk-launcher')) {
tag.className = 'tag tag--pass';
tag.textContent = '✅ SDK 就绪';
}
};
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.doDestroy=function(){ChatbotSDK.destroy();const tag=getEl('tag-sdk');tag.className='tag tag--idle';tag.textContent='⭕ SDK 未加载';};
window.doClearHistory=function(){ChatbotSDK.clearHistory();};
window.domAction=function(act){if(!document.getElementById('csk-launcher'))ChatbotSDK.init(getCfg());ChatbotSDK[act]();};
window.filterTests=function(phase,btn){currentFilter=phase;document.querySelectorAll('.toolbar-tab').forEach(t=>t.classList.remove('toolbar-tab--active'));btn.classList.add('toolbar-tab--active');document.querySelectorAll('.case').forEach(c=>{c.style.display=(phase==='all'||c.dataset.phase===phase)?'':'none';});};
window.doDestroy = function() {
ChatbotSDK.destroy();
const tag = getLog('tag-sdk');
tag.className = 'tag tag--idle';
tag.textContent = '⭕ SDK 未加载';
};
// ==================== P0 ====================
async function test1(log,m){log('检测 window.ChatbotSDK...','log-line--info');assert(typeof window.ChatbotSDK!=='undefined','window.ChatbotSDK 未定义');const ms=['init','destroy','open','close','toggle','clearHistory'];for(const x of ms){assert(typeof window.ChatbotSDK[x]==='function','ChatbotSDK.'+x+' 不是函数');log('✅ ChatbotSDK.'+x+'()','log-line--pass');}m('pass','通过 ('+ms.length+' 个方法)');}
window.doClearHistory = function() { ChatbotSDK.clearHistory(); };
async function test2(log,m){ChatbotSDK.init({});await sleep(200);assert(!document.getElementById('csk-launcher'),'缺失 integrateId 时不应创建 DOM');log('✅ 缺失 integrateId 正确拦截','log-line--pass');m('pass','通过');}
window.domAction = function(act) {
if (!document.getElementById('csk-launcher')) {
ChatbotSDK.init(getCfg());
}
ChatbotSDK[act]();
};
async function test3(log,m){ChatbotSDK.init({integrateId:'t'});await sleep(200);assert(!document.getElementById('csk-launcher'),'缺失 requestDomain 时不应创建 DOM');log('✅ 缺失 requestDomain 正确拦截','log-line--pass');m('pass','通过');}
// ========== P0 测试用例定义 ==========
async function test4(log,m){ChatbotSDK.init({integrateId:'t',requestDomain:'abc'});await sleep(200);assert(!document.getElementById('csk-launcher'),'非法 URL 时不应创建 DOM');log('✅ 非法 URL 正确拦截','log-line--pass');m('pass','通过');}
// T1: SDK 全局挂载检测
function test1_globalMount(logId, mark) {
appendLog(logId, '检测 window.ChatbotSDK...', 'log-line--info');
assert(typeof window.ChatbotSDK !== 'undefined', 'window.ChatbotSDK 未定义,script 引入失败');
appendLog(logId, '✅ window.ChatbotSDK 存在', 'log-line--pass');
async function test5(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(500);assertExists('csk-launcher');log('✅ 悬浮按钮已创建','log-line--pass');assertExists('csk-window');log('✅ 弹窗已创建','log-line--pass');assertExists('#csk-messages');assertExists('#csk-input');assertExists('#csk-send-btn');log('✅ 消息区/输入框/发送按钮齐全','log-line--pass');assert(!!document.querySelector('style[data-csk-sdk]'),'CSS 未注入');log('✅ CSS 已注入','log-line--pass');m('pass','通过');}
const methods = ['init', 'destroy', 'open', 'close', 'toggle', 'clearHistory'];
for (const m of methods) {
assert(typeof window.ChatbotSDK[m] === 'function', `ChatbotSDK.${m} 不是函数`);
appendLog(logId, '✅ ChatbotSDK.' + m + '() 可用', 'log-line--pass');
}
mark('pass', '通过 (' + methods.length + ' 个方法)');
}
async function test6(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(300);const w=document.getElementById('csk-window');ChatbotSDK.open();await sleep(100);assert(!w.classList.contains('csk-window--hidden'),'open() 失败');log('✅ open()','log-line--pass');ChatbotSDK.close();await sleep(100);assert(w.classList.contains('csk-window--hidden'),'close() 失败');log('✅ close()','log-line--pass');m('pass','通过');}
// T2: 配置校验 — 缺失 integrateId
async function test2_missingIntegrateId(logId, mark) {
appendLog(logId, '调用 ChatbotSDK.init({})', 'log-line--info');
ChatbotSDK.init({});
await sleep(200);
const launcher = document.getElementById('csk-launcher');
assert(!launcher, '缺失 integrateId 时不应创建 DOM');
appendLog(logId, '✅ 缺失 integrateId 正确拦截,无 DOM 产生', 'log-line--pass');
mark('pass', '通过');
}
async function test7(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init({integrateId:'1',requestDomain:getCfg().requestDomain});await sleep(300);const h=document.querySelector('#csk-window .csk-header__title');assert(h&&h.textContent==='AI 智能助手','默认标题错误');log('✅ 默认标题 "AI 智能助手"','log-line--pass');m('pass','通过');}
// T3: 配置校验 — 缺失 requestDomain
async function test3_missingDomain(logId, mark) {
appendLog(logId, '调用 ChatbotSDK.init({integrateId:"t"})', 'log-line--info');
ChatbotSDK.init({integrateId:'t'});
await sleep(200);
const launcher = document.getElementById('csk-launcher');
assert(!launcher, '缺失 requestDomain 时不应创建 DOM');
appendLog(logId, '✅ 缺失 requestDomain 正确拦截', 'log-line--pass');
mark('pass', '通过');
}
async function test8(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init({integrateId:'1',requestDomain:getCfg().requestDomain,primaryColor:'#DC2626',title:'测试'});await sleep(300);const s=document.querySelector('style[data-csk-sdk]');assert(s&&s.textContent.includes('#DC2626'),'CSS 应包含自定义主题色');log('✅ #DC2626 已注入','log-line--pass');m('pass','通过');}
// T4: 配置校验 — 非法 URL
async function test4_badUrl(logId, mark) {
appendLog(logId, '调用 init({integrateId:"t", requestDomain:"abc"})', 'log-line--info');
ChatbotSDK.init({integrateId:'t', requestDomain:'abc'});
await sleep(200);
const launcher = document.getElementById('csk-launcher');
assert(!launcher, '非法 URL 时不应创建 DOM');
appendLog(logId, '✅ 非法 URL 正确拦截', 'log-line--pass');
mark('pass', '通过');
}
async function test9(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init({integrateId:'1',requestDomain:getCfg().requestDomain,position:'left-bottom'});await sleep(300);const l=document.getElementById('csk-launcher');assert(l.classList.contains('csk-launcher--left'),'left 位置类');log('✅ position=left-bottom','log-line--pass');m('pass','通过');}
// T5: 正常初始化 — DOM 创建
async function test5_domCreate(logId, mark) {
ChatbotSDK.destroy();
await sleep(100);
ChatbotSDK.init(getCfg());
await sleep(300);
async function test10(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(300);assertExists('csk-launcher');assertExists('csk-window');ChatbotSDK.destroy();await sleep(200);assert(!document.getElementById('csk-launcher'),'destroy 后按钮应移除');log('✅ 按钮已移除','log-line--pass');assert(!document.getElementById('csk-window'),'destroy 后弹窗应移除');log('✅ 弹窗已移除','log-line--pass');assert(document.querySelectorAll('style[data-csk-sdk]').length===0,'CSS 应移除');log('✅ CSS 已移除','log-line--pass');m('pass','通过');}
const launcher = assertExists('csk-launcher', '悬浮按钮未创建');
appendLog(logId, '✅ #csk-launcher 已创建', 'log-line--pass');
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 未回复');}}
const win = assertExists('csk-window', '聊天弹窗未创建');
appendLog(logId, '✅ #csk-window 已创建', 'log-line--pass');
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)');}
assert(win.classList.contains('csk-window--hidden'), '初始状态应为隐藏');
appendLog(logId, '✅ 初始隐藏状态正确', 'log-line--pass');
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)');}
assertExists('#csk-messages', '消息区未创建');
assertExists('#csk-input', '输入框未创建');
assertExists('#csk-send-btn', '发送按钮未创建');
appendLog(logId, '✅ 消息区/输入框/发送按钮 齐全', 'log-line--pass');
// ==================== 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','通过');}
const style = document.querySelector('style[data-csk-sdk]');
assert(!!style, 'CSS 样式未注入');
appendLog(logId, '✅ CSS 样式已注入 (data-csk-sdk)', 'log-line--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','通过');}
mark('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','通过');}
// T6: open/close/toggle API
async function test6_openClose(logId, mark) {
ChatbotSDK.destroy();
await sleep(100);
ChatbotSDK.init(getCfg());
await sleep(300);
const win = document.getElementById('csk-window');
assert(win, '弹窗未初始化');
ChatbotSDK.open();
await sleep(100);
assert(!win.classList.contains('csk-window--hidden'), 'open() 失败');
appendLog(logId, '✅ open() 正确显示弹窗', 'log-line--pass');
ChatbotSDK.close();
await sleep(100);
assert(win.classList.contains('csk-window--hidden'), 'close() 失败');
appendLog(logId, '✅ close() 正确隐藏弹窗', 'log-line--pass');
ChatbotSDK.toggle();
await sleep(100);
assert(!win.classList.contains('csk-window--hidden'), 'toggle() 第一次失败');
ChatbotSDK.toggle();
await sleep(100);
assert(win.classList.contains('csk-window--hidden'), 'toggle() 第二次失败');
appendLog(logId, '✅ toggle() 正确切换显示/隐藏', 'log-line--pass');
mark('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','通过');}
// T7: 配置默认值
async function test7_defaults(logId, mark) {
ChatbotSDK.destroy();
await sleep(100);
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','通过');}
// 只传必传参数,验证默认值是否生效
ChatbotSDK.init({ integrateId: 'test-defaults', requestDomain: getCfg().requestDomain });
await sleep(300);
// ==================== P2 ====================
async function test19(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{locale:'zh-CN'}));await sleep(300);const ph=document.querySelector('#csk-input');assert(ph&&ph.placeholder==='输入您的问题...','中文 placeholder');log('✅ 中文界面','log-line--pass');ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{locale:'en'}));await sleep(300);const phEn=document.querySelector('#csk-input');assert(phEn&&phEn.placeholder==='Type your question...','英文 placeholder');log('✅ 英文界面','log-line--pass');m('pass','通过');}
const header = document.querySelector('#csk-window .csk-header__title');
assert(header && header.textContent === 'AI 智能助手', '默认标题应为 AI 智能助手');
appendLog(logId, '✅ 默认标题 "AI 智能助手"', 'log-line--pass');
async function test20(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(300);const hb=document.querySelector('.csk-history-btn');assert(!!hb,'历史按钮');log('✅ 历史按钮已创建','log-line--pass');const hp=document.querySelector('.csk-history-panel');assert(!!hp,'历史面板');log('✅ 历史面板已创建','log-line--pass');assert(hp.classList.contains('csk-history-panel--hidden'),'初始隐藏');log('✅ 初始隐藏','log-line--pass');m('pass','通过');}
const launcher = document.getElementById('csk-launcher');
assert(launcher.classList.contains('csk-launcher--right'), '默认位置应为右侧');
appendLog(logId, '✅ 默认位置 right-bottom', 'log-line--pass');
async function test21(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/conversation/list?page=1&size=10&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (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','通过');}
const style = document.querySelector('style[data-csk-sdk]');
assert(style && style.textContent.includes('#4F46E5'), '默认主题色应为 #4F46E5');
appendLog(logId, '✅ 默认主题色 #4F46E5', 'log-line--pass');
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');}}
const clearBtn = document.querySelector('.csk-clear-btn');
assert(!!clearBtn, '默认应显示清空按钮');
appendLog(logId, '✅ showClear 默认 true', 'log-line--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 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','通过');}
mark('pass', '通过');
}
// T8: 主题色定制
async function test8_themeColor(logId, mark) {
ChatbotSDK.destroy();
await sleep(100);
ChatbotSDK.init({ integrateId:'t-color', requestDomain:getCfg().requestDomain, primaryColor:'#DC2626', title:'测试' });
await sleep(300);
const style = document.querySelector('style[data-csk-sdk]');
const css = style ? style.textContent : '';
assert(css.includes('#DC2626'), 'CSS 中应包含自定义主题色');
appendLog(logId, '✅ CSS 中已包含自定义主题色 #DC2626', 'log-line--pass');
const header = document.querySelector('#csk-window .csk-header');
assert(header, '头部不存在');
const headerBg = window.getComputedStyle(header).backgroundColor;
appendLog(logId, ' 头部 computed background: ' + headerBg, 'log-line--info');
mark('pass', '通过');
}
// T9: 位置切换(左下)
async function test9_position(logId, mark) {
ChatbotSDK.destroy();
await sleep(100);
ChatbotSDK.init({ integrateId:'t-pos', requestDomain:getCfg().requestDomain, position:'left-bottom' });
await sleep(300);
const launcher = document.getElementById('csk-launcher');
assert(launcher.classList.contains('csk-launcher--left'), '应包含 left 位置类');
assert(!launcher.classList.contains('csk-launcher--right'), '不应包含 right 位置类');
appendLog(logId, '✅ position=left-bottom 正确生效', 'log-line--pass');
const win = document.getElementById('csk-window');
assert(win.classList.contains('csk-window--left'), '弹窗也应左对齐');
appendLog(logId, '✅ 弹窗位置同步为左侧', 'log-line--pass');
mark('pass', '通过');
}
// T10: destroy 清理
async function test10_destroy(logId, mark) {
ChatbotSDK.destroy();
await sleep(100);
ChatbotSDK.init(getCfg());
await sleep(300);
assertExists('csk-launcher');
assertExists('csk-window');
ChatbotSDK.destroy();
await sleep(200);
const l = document.getElementById('csk-launcher');
const w = document.getElementById('csk-window');
assert(!l, 'destroy 后悬浮按钮应被移除');
appendLog(logId, '✅ 悬浮按钮已从 DOM 移除', 'log-line--pass');
assert(!w, 'destroy 后弹窗应被移除');
appendLog(logId, '✅ 聊天弹窗已从 DOM 移除', 'log-line--pass');
const styles = document.querySelectorAll('style[data-csk-sdk]');
assert(styles.length === 0, 'destroy 后样式应被移除(当前' + styles.length + '个)');
appendLog(logId, '✅ CSS 样式已移除', 'log-line--pass');
mark('pass', '通过');
}
// T11: localStorage 缓存
async function test11_storage(logId, mark) {
const key = 'csk_history_test-sdk-p0';
// 写入测试数据
const data = {
messages: [
{ id:'a', role:'user', content:'你好', timestamp: Date.now()-2000 },
{ id:'b', role:'ai', content:'您好!', timestamp: Date.now()-1000 },
],
updatedAt: Date.now()
};
localStorage.setItem(key, JSON.stringify(data));
appendLog(logId, '写入测试数据 key=' + key + ' messages=2', 'log-line--info');
// 通过 init 恢复(需要 SDK 内部调用 loadMessages)
ChatbotSDK.destroy();
await sleep(100);
ChatbotSDK.init(Object.assign(getCfg(), { integrateId:'test-sdk-p0' }));
await sleep(400);
// 验证缓存是否被 SDK 读取
const raw = localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : null;
assert(parsed && Array.isArray(parsed.messages) && parsed.messages.length === 2,
'缓存应包含2条消息,实际: ' + (parsed ? JSON.stringify(parsed).substring(0, 80) : 'null'));
appendLog(logId, '✅ 缓存读取成功,共 ' + parsed.messages.length + ' 条消息', 'log-line--pass');
// 检查历史是否渲染
const messagesEl = document.getElementById('csk-messages');
const bubbles = messagesEl ? messagesEl.querySelectorAll('.csk-msg') : [];
appendLog(logId, '消息区渲染气泡数: ' + bubbles.length, 'log-line--info');
// 清理
localStorage.removeItem(key);
appendLog(logId, '✅ 缓存读写验证通过', 'log-line--pass');
mark('pass', '通过');
}
// T12: API 同步对话
async function test12_apiChat(logId, mark) {
const cfg = getCfg();
const url = cfg.requestDomain + '/ai/assistant_app/chat/sync?message=' + encodeURIComponent('你好,请用一句话介绍你自己') + '&chatId=' + cfg.integrateId;
appendLog(logId, '请求: GET ' + url.substring(0, 60) + '...', 'log-line--info');
apiCalls++;
const t0 = performance.now();
let resp;
try {
resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
} catch(e) {
appendLog(logId, '❌ fetch 失败: ' + e.message, 'log-line--fail');
throw new Error('网络请求失败 — 后端可能未运行');
}
const elapsed = Math.round(performance.now() - t0);
apiDurations.push(elapsed);
updateStats();
appendLog(logId, 'HTTP ' + resp.status + ' 耗时 ' + elapsed + 'ms', 'log-line--info');
if (!resp.ok) {
appendLog(logId, '响应异常 status=' + resp.status, 'log-line--fail');
throw new Error('API 返回 ' + resp.status);
}
const text = await resp.text();
assert(text.length > 5, 'AI 回复过短: ' + text.substring(0, 30));
appendLog(logId, 'AI 回复: ' + text.substring(0, 120) + (text.length > 120 ? '...' : ''), 'log-line--pass');
appendLog(logId, '回复长度: ' + text.length + ' 字符', 'log-line--info');
mark('pass', '通过 (' + elapsed + 'ms)');
}
// T13: SSE 流式对话
async function test13_apiSSE(logId, mark) {
const cfg = getCfg();
const url = cfg.requestDomain + '/ai/assistant_app/chat/sse?message=' + encodeURIComponent('你好') + '&chatId=' + cfg.integrateId;
appendLog(logId, '请求 SSE: GET ' + url.substring(0, 60) + '...', 'log-line--info');
apiCalls++;
const t0 = performance.now();
let resp;
try {
resp = await fetch(url, { signal: AbortSignal.timeout(20000) });
} catch(e) {
appendLog(logId, '❌ fetch 失败: ' + e.message, 'log-line--fail');
throw new Error('SSE 连接失败 — 后端可能未运行');
}
assert(resp.ok, 'SSE 响应异常 HTTP ' + resp.status);
appendLog(logId, 'SSE 连接成功 HTTP ' + resp.status, 'log-line--pass');
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let chunks = 0;
let total = '';
try {
while (true) {
const result = await reader.read();
if (result.done) break;
const text = decoder.decode(result.value, { stream: true });
total += text;
chunks++;
if (chunks <= 3) {
appendLog(logId, 'chunk #' + chunks + ': ' + text.substring(0, 60), 'log-line--info');
}
}
} finally {
reader.releaseLock();
}
const elapsed = Math.round(performance.now() - t0);
apiDurations.push(elapsed);
updateStats();
appendLog(logId, '共收到 ' + chunks + ' 个数据块, ' + total.length + ' 字符', 'log-line--pass');
appendLog(logId, '完整回复: ' + total.substring(0, 200) + (total.length > 200 ? '...' : ''), 'log-line--pass');
assert(total.length > 3, 'SSE 流回复过短');
mark('pass', '通过 (' + elapsed + 'ms, ' + chunks + ' chunks)');
}
// ========== 运行全部 ==========
window.runAll = async function() {
// 重置
passCount = 0;
failCount = 0;
apiCalls = 0;
apiDurations = [];
updateStats();
ChatbotSDK.destroy();
document.getElementById('cases-container').innerHTML = '';
await sleep(300);
passCount=0;failCount=0;skipCount=0;apiCalls=0;apiDurations=[];
updateStats();ChatbotSDK.destroy();getEl('cases-container').innerHTML='';await sleep(300);
const tests = [
[test1_globalMount, 'T1', '全局挂载', '验证 window.ChatbotSDK 及其 6 个公开方法是否存在'],
[test2_missingIntegrateId, 'T2', '必传参数校验 — integrateId', '传入空对象 init({}),确认 SDK 静默拦截不崩溃'],
[test3_missingDomain, 'T3', '必传参数校验 — requestDomain', '传入 init({integrateId:"t"}),确认 SDK 拦截'],
[test4_badUrl, 'T4', '参数校验 — 非法 URL', '传入非法 requestDomain="abc",确认拦截'],
[test5_domCreate, 'T5', 'DOM 创建', '正常初始化后检查悬浮按钮+弹窗+输入框+样式全部创建'],
[test6_openClose, 'T6', 'open/close/toggle', '验证打开/关闭/切换 API 正常工作'],
[test7_defaults, 'T7', '配置默认值', '不传可选参数,验证标题/位置/主题色/清空按钮默认值'],
[test8_themeColor, 'T8', '主题色定制', '传入 primaryColor=#DC2626,验证 CSS 包含自定义颜色'],
[test9_position, 'T9', '位置切换', '传入 position=left-bottom,验证按钮和弹窗左对齐'],
[test10_destroy, 'T10', 'destroy 清理', '验证 destroy() 完全移除 DOM 和 CSS'],
[test11_storage, 'T11', 'localStorage 缓存', '写入缓存 → init 恢复 → 验证可读 → 清理'],
[test12_apiChat, 'T12', 'API 同步对话', '直接调用后端 /ai/assistant_app/chat/sync 验证连通性'],
[test13_apiSSE, 'T13', 'API SSE 流式', '直接调用后端 /ai/assistant_app/chat/sse 验证流式响应'],
[test1,'T1','全局挂载','验证 window.ChatbotSDK 方法','p0'],
[test2,'T2','必传参数 — integrateId','缺失时拦截','p0'],
[test3,'T3','必传参数 — requestDomain','缺失时拦截','p0'],
[test4,'T4','非法 URL','拦截','p0'],
[test5,'T5','DOM 创建','悬浮按钮+弹窗+样式','p0'],
[test6,'T6','open/close','窗口 API','p0'],
[test7,'T7','配置默认值','标题/位置默认','p0'],
[test8,'T8','主题色定制','primaryColor','p0'],
[test9,'T9','位置切换','left-bottom','p0'],
[test10,'T10','destroy 清理','移除 DOM+CSS','p0'],
[test11,'T11','实际对话','发送消息→AI回复','p0'],
[test12,'T12','API 同步对话','chatId+roleId 参数验证','p0'],
[test13,'T13','API SSE 流式','流式参数验证','p0'],
[test14,'T14','Markdown 渲染','CSS 样式验证','p1'],
[test15,'T15','知识库分类切换','下拉框 UI','p1'],
[test16,'T16','API RAG SSE','RAG 流式接口','p1'],
[test17,'T17','API RAG 引用来源','来源接口','p1'],
[test18,'T18','API 分类树','分类列表接口','p1'],
[test19,'T19','多语言国际化','zh-CN / en','p2'],
[test20,'T20','会话管理面板','UI + 样式','p2'],
[test21,'T21','API 会话列表','按 roleId 过滤','p2'],
[test22,'T22','API 会话按账号过滤','accountId+roleId','p2'],
[test23,'T23','参数映射验证','integrateId→roleId, userId→accountId','p0'],
];
for (const [fn, id, name, desc] of tests) {
await runTest(id, name, desc, fn);
await sleep(150); // 测试间隔
}
// 汇总
setEl('footer-info', 'ChatbotSDK v1.0.0-P0 | ' + passCount + ' 通过 / ' + failCount + ' 失败');
setEl('footer-time', new Date().toLocaleTimeString());
setEl('total-tests', tests.length);
for(const [fn,id,name,desc,phase] of tests){await runTest(id,name,desc,fn,phase);await sleep(150);}
setEl('footer-info','ChatbotSDK v1.2.0 | '+passCount+' 通过 / '+failCount+' 失败 / '+skipCount+' 跳过');
setEl('footer-time',new Date().toLocaleTimeString());
};
// ========== 启动自动检测 ==========
(function boot() {
getLog('cfg-domain').value = window.location.origin;
setEl('footer-time', new Date().toLocaleTimeString());
// 检测 SDK 加载状态
if (typeof window.ChatbotSDK !== 'undefined') {
const tag = getLog('tag-sdk');
tag.className = 'tag tag--pass';
tag.textContent = '✅ SDK 已加载';
}
// 检测后端是否可达
fetch(window.location.origin + '/ai/assistant_app/chat/sync?message=' + encodeURIComponent('test') + '&chatId=__probe__', {
signal: AbortSignal.timeout(5000)
}).then(r => {
const tag = getLog('tag-api');
if (r.ok || r.status < 500) {
tag.className = 'tag tag--pass';
tag.textContent = '✅ 后端连通';
setEl('footer-info', 'ChatbotSDK v1.0.0-P0 | 后端:在线 ✅');
}
}).catch(() => {
const tag = getLog('tag-api');
tag.className = 'tag tag--fail';
tag.textContent = '⚠ 后端离线';
});
(function boot(){
getEl('cfg-domain').value=window.location.origin;
setEl('footer-time',new Date().toLocaleTimeString());
if(typeof window.ChatbotSDK!=='undefined'){const tag=getEl('tag-sdk');tag.className='tag tag--pass';tag.textContent='✅ SDK 已加载';}
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='⚠ 后端离线';});
})();
})();
</script>

Loading…
Cancel
Save