22 Commits

Author SHA1 Message Date
wanghanlin 08fdc2e82d 修改样式 4 days ago
wanghanlin 321bee93ff fix(mcp): 修复 SSE 端点带路径前缀时连接 404 4 days ago
wanghanlin 11572a7023 修改样式 4 days ago
wanghanlin 83964afcfc refactor(client): 外围组件原生化并清理死代码,缩小 SDK 体积 4 days ago
wanghanlin a2971ba2c2 修改样式 4 days ago
wanghanlin b56cea0c8c fix(client): 历史会话时间智能分档显示并修复操作栏按钮 tooltip 4 days ago
wanghanlin 431b1f2b1d fix(client): 修复第三方接入 SDK 后 markdown 排版与标签样式缺失 5 days ago
wanghanlin da031c6ff0 fix(client): 修复重新生成清空历史,优化引用来源/推荐问题与历史滚动 5 days ago
wanghanlin 021dfa9199 回复内容自适应 SDK 弹窗宽度 5 days ago
wanghanlin a1328994a5 feat(mcp): MCP 服务配置支持自定义请求头鉴权 5 days ago
wanghanlin 97f8f1a8f0 回复内容自适应 SDK 弹窗宽度 5 days ago
wanghanlin e23b70ffca feat(client): SDK 界面重构改用 TDesign Web Components 组件 6 days ago
wanghanlin b9f91d0903 fix(sse): 修复 SSE 鉴权头覆盖问题并同步测试用例文档 6 days ago
wanghanlin 8132f26891 feat(sdk-test): 测试面板重构为 Vue 3 + TDesign Chat 应用 6 days ago
wanghanlin 2f09b3fabf feat(client): SDK 悬浮窗口对话视觉对齐 TDesign Chat 规范 6 days ago
wanghanlin 940caedea8 feat(部署): 前端静态资源免认证访问与打包配置 6 days ago
wanghanlin 60d336d384 fix(前端): 反馈看板时间列统一 formatDate 格式化 6 days ago
wanghanlin 6be019082e docs: 同步对话接口迁移与 OpenAI SSE 格式说明 6 days ago
wanghanlin b0319392a2 feat(client): 前端与 SDK 迁移新对话接口并支持 OpenAI SSE 解析 6 days ago
wanghanlin 1cd8ed1248 feat(chat): 流式对话输出对齐 OpenAI Chat Completions 标准 SSE 格式 6 days ago
wanghanlin 953a3c96ab fix(chat): 修复 ChatList 消息显示为骨架屏的问题 6 days ago
wanghanlin 7f6b696885 refactor(chat): 使用 TDesign Chat 组件重构聊天面板 1 week ago
  1. 10
      README.md
  2. 9
      SDK-INTEGRATION.md
  3. 18
      client/CLAUDE.md
  4. 95
      client/README.md
  5. 69
      client/assets/marked.min.js
  6. 2526
      client/package-lock.json
  7. 6
      client/package.json
  8. 24
      client/rollup.config.js
  9. 129
      client/src/api.ts
  10. 348
      client/src/chat.ts
  11. 17
      client/src/config.ts
  12. 629
      client/src/dom.ts
  13. 39
      client/src/i18n.ts
  14. 5
      client/src/images.d.ts
  15. 81
      client/src/index.ts
  16. 10
      client/src/logger.ts
  17. 242
      client/src/markdown.ts
  18. 39
      client/src/mermaid-stub.ts
  19. 1322
      client/src/styles.ts
  20. 37
      client/src/types.ts
  21. 33
      client/src/utils.ts
  22. 100
      client/tests/i18n.test.ts
  23. 86
      client/tests/utils.test.ts
  24. 12
      frontend/cp-to-static.mjs
  25. 1112
      frontend/package-lock.json
  26. 1
      frontend/package.json
  27. 12
      frontend/sdk-test.html
  28. 72
      frontend/src/api/chat.ts
  29. 1477
      frontend/src/sdk-test/SdkTestPanel.vue
  30. 14
      frontend/src/sdk-test/main.ts
  31. 1
      frontend/src/types/models.ts
  32. 50
      frontend/src/utils/chatAdapter.ts
  33. 37
      frontend/src/utils/sse.ts
  34. 370
      frontend/src/views/ChatPanel.vue
  35. 12
      frontend/src/views/FeedbackOps.vue
  36. 16
      frontend/src/views/McpServerManager.vue
  37. 9
      frontend/src/views/PromptTracePanel.vue
  38. 15
      frontend/vite.config.ts
  39. 9
      pom.xml
  40. 165
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  41. 25
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  42. 36
      src/main/java/com/wok/supportbot/config/McpClientManager.java
  43. 2
      src/main/java/com/wok/supportbot/controller/AiController.java
  44. 2
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  45. 6
      src/main/java/com/wok/supportbot/entity/McpServerConfig.java
  46. 12
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  47. 3
      src/main/java/com/wok/supportbot/security/SecurityConfig.java
  48. 102
      src/main/java/com/wok/supportbot/service/McpServerConfigService.java
  49. 2
      src/main/resources/init-database.sql
  50. 725
      src/main/resources/static/sdk/test.html

10
README.md

@ -114,13 +114,13 @@ CREATE TABLE knowledge_document (
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/ai/assistant_app/chat/sync` | 同步对话 |
| GET | `/ai/assistant_app/chat/sse` | SSE 流式对话 (Flux) |
| GET | `/ai/assistant_app/chat/server_sent_event` | SSE 流式对话 (ServerSentEvent) |
| GET | `/ai/assistant_app/chat/sse_emitter` | SSE 流式对话 (SseEmitter) |
| GET | `/ai/assistant_app/chat/rag/sync` | RAG 知识库对话(支持查询重写策略) |
| GET | `/ai/chat` | 同步对话(`enableRag=true` 时走 RAG 增强) |
| GET | `/ai/chat/stream` | SSE 流式对话(OpenAI Chat Completions 兼容格式,输出 `data: {"choices":[{"delta":{"content":"..."}}]}` 分片,结束 `data: [DONE]`) |
| GET | `/ai/chat/sources` | RAG 引用来源 |
| GET | `/ai/product_info_app/chat/sync` | 商品信息结构化提取 | | GET | `/ai/product_info_app/chat/sync` | 商品信息结构化提取 |
> **已废弃(向后兼容保留)**:`/ai/assistant_app/chat/sync`、`/ai/assistant_app/chat/sse`、`/ai/assistant_app/chat/rag/sync`、`/ai/assistant_app/chat/rag/sse`、`/ai/assistant_app/rag/sources` 仍可用,请统一迁移到 `/ai/chat`、`/ai/chat/stream`、`/ai/chat/sources`。`/ai/assistant_app/chat/server_sent_event` 与 `/ai/assistant_app/chat/sse_emitter` 已移除。
### 知识库管理接口 ### 知识库管理接口
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |

9
SDK-INTEGRATION.md

@ -276,10 +276,9 @@ POST /open-api/auth/token
| 接口 | 方法 | 说明 | | 接口 | 方法 | 说明 |
|---|---|---| |---|---|---|
| `/ai/assistant_app/chat/sync` | GET | 同步对话(已统一为 ChatPipeline,支持普通/RAG 自动判断) |
| `/ai/assistant_app/chat/sse` | GET | SSE 流式对话(已统一为 ChatPipeline) |
| `/ai/assistant_app/chat/rag/sse` | GET | RAG 增强流式对话(已统一为 ChatPipeline) |
| `/ai/assistant_app/rag/sources` | GET | 获取 RAG 引用来源 |
| `/ai/chat` | GET | 同步对话(已统一为 ChatPipeline,支持普通/RAG 自动判断) |
| `/ai/chat/stream` | GET | SSE 流式对话(OpenAI Chat Completions 兼容格式,已统一为 ChatPipeline) |
| `/ai/chat/sources` | GET | 获取 RAG 引用来源 |
| `/ai/sdk/conversation/list` | GET | 会话列表 | | `/ai/sdk/conversation/list` | GET | 会话列表 |
| `/ai/sdk/conversation/{id}/messages` | GET | 会话消息 | | `/ai/sdk/conversation/{id}/messages` | GET | 会话消息 |
| `/ai/sdk/conversation/{id}` | DELETE | 删除会话 | | `/ai/sdk/conversation/{id}` | DELETE | 删除会话 |
@ -287,7 +286,7 @@ POST /open-api/auth/token
| `/category/tree` | GET | 知识库分类树 | | `/category/tree` | GET | 知识库分类树 |
| `/feedback` | POST | 消息反馈 | | `/feedback` | POST | 消息反馈 |
> **已废弃**:`/ai/assistant_app/chat/server_sent_event` 和 `/ai/assistant_app/chat/sse_emitter` 已移除,请统一使用 `/ai/assistant_app/chat/sse`
> **已废弃**:`/ai/assistant_app/chat/server_sent_event` 和 `/ai/assistant_app/chat/sse_emitter` 已移除。旧路径 `/ai/assistant_app/chat/sync`、`/ai/assistant_app/chat/sse`、`/ai/assistant_app/chat/rag/sse`、`/ai/assistant_app/rag/sources` 仍保留(向后兼容)但已废弃,请统一使用 `/ai/chat`、`/ai/chat/stream`、`/ai/chat/sources`
### 6.3 Open API 对话接口(第三方系统直接调用) ### 6.3 Open API 对话接口(第三方系统直接调用)

18
client/CLAUDE.md

@ -25,15 +25,15 @@ npm run dev
- 构建工具:Rollup + `@rollup/plugin-typescript` + `@rollup/plugin-terser`,配置见 `rollup.config.js` - 构建工具:Rollup + `@rollup/plugin-typescript` + `@rollup/plugin-terser`,配置见 `rollup.config.js`
- TypeScript 配置:`tsconfig.json`,`target: ES2017`,`strict: true`,`rootDir: ./src`,不生成 `.d.ts` - TypeScript 配置:`tsconfig.json`,`target: ES2017`,`strict: true`,`rootDir: ./src`,不生成 `.d.ts`
- 产物双份:`chatbot-sdk.js`(未压缩 + sourcemap,~93KB)和 `chatbot-sdk.min.js`(压缩,~45KB
- **无测试框架**:验证通过后端的 `http://localhost:9090/sdk/test.html` 运行 22 个浏览器端用例(见 README 第十四节),本工程内没有可运行的自动化测试
- 产物双份:`chatbot-sdk.js`(未压缩 + sourcemap,~5.6MB)和 `chatbot-sdk.min.js`(压缩,~4.0MB,gzip ~1.1MB)。体积主要由内置的 TDesign Web Components 组件库贡献(按需 tree-shaking 后打包进 IIFE
- **无测试框架**:验证通过后端的 `http://localhost:9090/sdk/test.html` 运行 10 个浏览器端核心用例(见 README 第十四节),本工程内没有可运行的自动化测试
## 运行时依赖 ## 运行时依赖
SDK 本身不独立运行,需要后端在 `requestDomain`(通常 `http://localhost:9090`)提供以下接口: SDK 本身不独立运行,需要后端在 `requestDomain`(通常 `http://localhost:9090`)提供以下接口:
- 基础对话:`GET /ai/assistant_app/chat/sync`、`GET /ai/assistant_app/chat/sse`
- RAG:`GET /ai/assistant_app/chat/rag/sse`、`GET /ai/assistant_app/rag/sources`
- 基础对话:`GET /ai/chat`、`GET /ai/chat/stream`
- RAG:`GET /ai/chat/stream`(`enableRag=true`)、`GET /ai/chat/sources`
- 分类:`GET /category/tree`、`GET /category/list` - 分类:`GET /category/tree`、`GET /category/list`
- 会话:`GET /conversation/list`、`GET /conversation/{id}/messages`、`DELETE /conversation/{id}`、`GET /conversation/{id}/export` - 会话:`GET /conversation/list`、`GET /conversation/{id}/messages`、`DELETE /conversation/{id}`、`GET /conversation/{id}/export`
@ -64,11 +64,13 @@ init(rawConfig)
### 关键模块 ### 关键模块
- **`chat.ts`**(最大、最核心):对话状态机。处理发送、SSE 流式追加、Markdown 渲染、RAG 来源展示、流中断兜底、无流内容降级为同步、`chatId` 自动管理逻辑、清空会话。修改对话行为优先改这里。
- **`chat.ts`**(最大、最核心):对话状态机。处理发送、SSE 流式追加、RAG 来源展示、流中断兜底、无流内容降级为同步、`chatId` 自动管理逻辑、清空会话。Markdown 渲染由 `t-chat-item` 内置 cherry-markdown 处理。修改对话行为优先改这里。
- **`api.ts`**:HTTP 封装 + SSE 流解析。三种 SSE 接口(普通流式、RAG 流式、同步)的解析逻辑都在这里,注意流式解析的边界处理。 - **`api.ts`**:HTTP 封装 + SSE 流解析。三种 SSE 接口(普通流式、RAG 流式、同步)的解析逻辑都在这里,注意流式解析的边界处理。
- **`dom.ts`**:纯 DOM 构建与事件绑定,含知识库下拉、RAG 来源卡片、历史会话面板的渲染。 - **`dom.ts`**:纯 DOM 构建与事件绑定,含知识库下拉、RAG 来源卡片、历史会话面板的渲染。
- **`styles.ts`**:所有 CSS 字符串模板,按 `primaryColor` 动态着色。改动 UI 视觉改这里,**不要**在 `dom.ts` 里写内联样式。
- **`markdown.ts`**:基于 marked(源文件 `assets/marked.min.js`,实际为 **v15.0.12**,构建时由 rollup 自动复制到 `dist/` 与后端 `static/sdk/`)的 Markdown 渲染器,`preloadMarked` 异步加载并缓存安全实例。**安全模型**:自定义 renderer 全接管输出,链接/图片仅允许 http/https,禁止 raw HTML,代码内容 escapeHtml 转义;修改渲染器时必须维持这一 XSS 防护顺序。**列表渲染坑**:`list(token)` 渲染列表项必须调用 `this.listitem(item)`,**不可** `this.parser.parse([item])` —— marked v15 的 `Parser.parse``token.type` 做 switch 分发、无 `list_item` 分支,直接 parse 会抛 `Token with "list_item" type was not found`,被 `renderMarkdown` 的 catch 降级为纯文本,导致整段回复挤成一行。
- **`styles.ts`**:所有 CSS 字符串模板,按 `primaryColor` 动态着色,并通过 `--td-*` CSS 变量透传给 TDesign 组件。改动 UI 视觉改这里,**不要**在 `dom.ts` 里写内联样式。
- **主题/聊天变量自包含约定**:TDesign 的变量分为两类,都必须自包含声明在 `.csk-root` 上(`tdThemeVars()` 负责主题变量 `--td-brand/--td-gray/--td-bg/...`,`tdChatVars()` 负责聊天/markdown/input 变量 `--td-chat-*`/`--td-chat-md-*`/`--td-chat-input-*`)。原因:这些变量在 tdesign-web-components 里定义于 `globalCSS` 的 constructable stylesheet、选择器为 `:root`,但 `adoptedStyleSheets` 里的 `:root` 不匹配 shadow root,无法进入 shadow DOM;直接声明在 `.csk-root` 上才能经 CSS 自定义属性继承穿透 shadow DOM。升级 `tdesign-web-components` 版本时需同步核对 `style/index.js``css$2`/`css`/`css$1` 三个变量块。
- **light-DOM 组件样式补注入**:`t-tag` 是 tdesign-web-components 的 light DOM 组件(`isLightDOM: true`),其 `.t-tag` 样式在 `globalCSS` 里只对 shadow DOM 生效、进不了 light DOM,需在 `getStyles()` 里以 `.csk-root .t-tag` 前缀手动补注入(来源 `_chunks/dep-5245073d.js`)。新增 light DOM 组件时同样排查是否需要补样式。
- **`markdown.ts`**:已废弃并移除。原基于 marked 的 Markdown 渲染器改由 TDesign Chat 组件(`t-chat-item`)内置 cherry-markdown 承担,无需额外加载 `marked.min.js`。cherry-markdown 的 XSS 防护由 TDesign 组件保证;如后续需要自定义渲染,仍需维持白名单/转义等安全策略。
### 单例与生命周期 ### 单例与生命周期
@ -102,4 +104,4 @@ init(rawConfig)
## 部署 ## 部署
构建脚本(`rollup.config.js` 的 `copyAssets` 插件)会自动把 `chatbot-sdk.js` / `chatbot-sdk.min.js`、`marked.min.js`(运行时依赖,由 `markdown.ts` 动态加载)`launcher-logo.png` 复制到后端 `src/main/resources/static/sdk/`,宿主页面通过 `<script src="/sdk/chatbot-sdk.min.js"></script>` 引入;CDN 分发时需同时部署这三类文件,且 `marked.min.js` 必须与 SDK 同目录(`getSdkBasePath()` 从 script src 推断加载路径)。`README.md` 有完整接入示例与全部配置参数表,文档是 SDK 对外契约的一部分,改公开 API(`init/destroy/open/close/toggle/clearHistory`)或 `SDKConfig` 字段时同步更新 README。
构建脚本(`rollup.config.js` 的 `copyAssets` 插件)会自动把 `chatbot-sdk.js` / `chatbot-sdk.min.js``launcher-logo.png` 复制到后端 `src/main/resources/static/sdk/`,宿主页面通过 `<script src="/sdk/chatbot-sdk.min.js"></script>` 引入即可;CDN 分发时同样只需部署这三类文件(`chatbot-sdk.js` / `chatbot-sdk.min.js` / `launcher-logo.png`)。`README.md` 有完整接入示例与全部配置参数表,文档是 SDK 对外契约的一部分,改公开 API(`init/destroy/open/close/toggle/clearHistory`)或 `SDKConfig` 字段时同步更新 README。

95
client/README.md

@ -35,7 +35,7 @@
roleId: 1, roleId: 1,
// RAG 知识库检索 // RAG 知识库检索
enableRag: true, // 启用 RAG 增强对话(自动走 /chat/rag/sse)
enableRag: true, // 启用 RAG 增强对话(自动走 /ai/chat/stream,enableRag=true)
categoryId: 5, // 默认知识库分类 categoryId: 5, // 默认知识库分类
showCategorySwitch: true, // 显示知识库分类下拉 showCategorySwitch: true, // 显示知识库分类下拉
@ -63,37 +63,38 @@ SDK 产物位于 `client/dist/` 目录:
| 文件 | 大小 | 说明 | | 文件 | 大小 | 说明 |
|------|------|------| |------|------|------|
| `chatbot-sdk.js` | 93KB | 未压缩开发版(含 sourcemap) |
| `chatbot-sdk.min.js` | 45KB | 压缩生产版(gzip ~15KB) |
| `chatbot-sdk.js` | ~5.6MB | 未压缩开发版(含 sourcemap) |
| `chatbot-sdk.min.js` | ~4.0MB | 压缩生产版(gzip ~1.1MB) |
**部署路径:** 将 `.min.js` 文件上传到后端 `static/sdk/` 目录或任意 CDN。
**部署路径:** 将 `.min.js` 文件上传到后端 `static/sdk/` 目录或任意 CDN。SDK 内置 TDesign Web Components 组件库(`t-chat-*` + 通用组件),按需 tree-shaking 后打包进 IIFE,无需额外加载组件库 CDN。
--- ---
## 二、SDKConfig 完整参数 ## 二、SDKConfig 完整参数
| 参数 | 类型 | 必传 | 默认值 | 阶段 | 说明 |
|------|------|------|--------|------|------|
| `integrateId` | `string \| number` | ✅ | — | P0 | 集成标识 → 后端 `roleId`(客服角色 ID,决定 AI 人设和知识库范围) |
| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 |
| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` |
| `roleId` | `number` | ❌ | — | P0 | 客服角色 ID |
| `enableRag` | `boolean` | ❌ | `false` | P1 | 启用 RAG 知识库检索对话(自动走 `/chat/rag/sse` 接口) |
| `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 |
| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 |
| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 |
| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) |
| 参数 | 类型 | 必传 | 默认值 | 阶段 | 说明 |
|------|------|------|------------------|------|------|
| `integrateId` | `string \| number` | ✅ | — | P0 | 集成标识 → 后端 `roleId`(客服角色 ID,决定 AI 人设和知识库范围) |
| `requestDomain` | `string` | ✅ | — | P0 | 后端 API 域名 |
| `userId` | `string` | ❌ | — | P0 | 宿主用户标识 → 后端 `accountId` |
| `roleId` | `number` | ❌ | — | P0 | 客服角色 ID |
| `enableRag` | `boolean` | ❌ | `false` | P1 | 启用 RAG 知识库检索对话(走 `/ai/chat/stream` 接口,`enableRag=true`) |
| `categoryId` | `number` | ❌ | — | P1 | 默认知识库分类 |
| `showCategorySwitch` | `boolean` | ❌ | `false` | P1 | 是否显示知识库下拉切换 |
| `title` | `string` | ❌ | `"AI 智能助手"` | P0 | 弹窗标题 |
| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) |
| `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 | | `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 |
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 |
| `launcherIcon` | `string` | ❌ | 极光粒子图标 | P0 | 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 |
| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 |
| `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 |
| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` |
| `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) |
| `teaserText` | `string` | ❌ | i18n 默认 | P1 | 提示气泡文字,留空使用语言包默认值 |
| `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 |
| `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` |
| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 |
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 |
| `launcherIcon` | `string` | ❌ | 极光粒子图标 | P0 | 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 |
| `launcherSize` | `number \| string` | ❌ | `80` | P0 | 悬浮按钮尺寸:数字=px(范围 36~96),或 CSS 长度字符串如 `"4rem"`/`"5vw"`/`"clamp(48px,3vw,80px)"` 用于响应式适配 |
| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 |
| `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 |
| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` |
| `showTeaser` | `boolean` | ❌ | `true` | P1 | 首访提示气泡(延迟 1.5s 弹出) |
| `teaserText` | `string` | ❌ | i18n 默认 | P1 | 提示气泡文字,留空使用语言包默认值 |
| `streaming` | `boolean` | ❌ | `true` | P0 | 是否启用 SSE 流式输出 |
| `locale` | `string` | ❌ | `"zh-CN"` | P2 | 界面语言:`zh-CN` / `en` |
| `debug` | `boolean` | ❌ | `true` | P0 | 是否输出调试日志 |
--- ---
@ -131,9 +132,9 @@ AI 回复支持 Markdown 格式渲染,包括:
- 引用块(`>` ) - 引用块(`>` )
- 水平线(`---`) - 水平线(`---`)
> **XSS 安全**所有非代码块内容先转义 HTML,再转换 Markdown 语法为安全 HTML 标签。代码块内容同样转义
> **XSS 安全**Markdown 渲染由 TDesign Chat 组件内置 cherry-markdown 处理,HTML 标签默认被转义,仅保留安全的 Markdown 语法输出
> **实现说明**:渲染基于 marked v15.0.12(外部脚本 `marked.min.js`,SDK 运行时异步加载,未就绪时降级为纯文本)。自定义 renderer 的列表项须用 `this.listitem(item)` 渲染,不能 `this.parser.parse([item])` —— marked v15 的 `Parser.parse``list_item` 分支,会抛异常并降级为纯文本,导致整段回复换行折叠成一行
> **实现说明**:渲染由 TDesign Chat 组件(`<t-chat-item>`)内置 cherry-markdown 承担,无需额外加载 `marked.min.js`。SDK 不再依赖自研 Markdown 渲染器,列表、代码块、表格等样式由 TDesign 统一保证
### 4.3 知识库联动 ### 4.3 知识库联动
@ -161,9 +162,9 @@ ChatbotSDK.init({
}); });
``` ```
- `enableRag: true` 时,所有对话自动走 RAG 增强接口(`/ai/assistant_app/chat/rag/sse`)
- 选择分类后,后续对话也走 RAG 增强接口
- 选择「全部分类」则走普通流式接口
- `enableRag: true` 时,所有对话自动走 RAG 增强(`/ai/chat/stream` 接口,`enableRag=true`)
- 选择分类后,后续对话也走 RAG 增强
- 选择「全部分类」则走普通流式(`enableRag=false`)
- 分类数据从 `/category/tree` 接口动态加载,支持树形缩进显示 - 分类数据从 `/category/tree` 接口动态加载,支持树形缩进显示
### 4.4 RAG 引用来源展示 ### 4.4 RAG 引用来源展示
@ -186,7 +187,7 @@ ChatbotSDK.init({
- 默认折叠,只显示标题行,点击展开/折叠 - 默认折叠,只显示标题行,点击展开/折叠
- 显示文档名称、摘要、来源文件、分块编号、相关度 - 显示文档名称、摘要、来源文件、分块编号、相关度
- 来源数据从 `/ai/assistant_app/rag/sources` 接口获取
- 来源数据从 `/ai/chat/sources` 接口获取
--- ---
@ -246,16 +247,16 @@ SDK 全流程结构化日志,带 `[ChatbotSDK]` 前缀:
### P0 — 基础对话 ### P0 — 基础对话
``` ```
GET /ai/assistant_app/chat/sync
GET /ai/assistant_app/chat/sse
GET /ai/chat # 同步对话
GET /ai/chat/stream # SSE 流式对话
``` ```
### P1 — 知识库联动 ### P1 — 知识库联动
``` ```
GET /ai/assistant_app/chat/rag/sse # RAG 增强流式对话
GET /ai/assistant_app/rag/sources # RAG 引用来源
GET /category/tree # 分类树(下拉框数据源)
GET /category/list # 分类列表
GET /ai/chat/stream # RAG 增强流式对话(enableRag=true)
GET /ai/chat/sources # RAG 引用来源
GET /category/tree # 分类树(下拉框数据源)
GET /category/list # 分类列表
``` ```
### P2 — 会话管理 ### P2 — 会话管理
@ -354,10 +355,11 @@ z-index 分层:悬浮按钮 9998,弹窗 9999。
- **语言**:TypeScript → ES2017 - **语言**:TypeScript → ES2017
- **打包**:Rollup → IIFE 格式 - **打包**:Rollup → IIFE 格式
- **压缩**:Terser - **压缩**:Terser
- **Markdown**:基于 marked v15.0.12(`assets/marked.min.js`,构建时自动复制到 `static/sdk/`
- **UI 组件**:TDesign Web Components 1.2.10(`t-chat-*` 聊天组件 + `t-select`/`t-tag`/`t-button`/`t-alert` 等通用组件)
- **Markdown**:由 TDesign Chat 组件内置 cherry-markdown 渲染
- **国际化**:内置 i18n 字典 - **国际化**:内置 i18n 字典
- **浏览器兼容**:Chrome 70+, Firefox 70+, Safari 13+, Edge 79+ - **浏览器兼容**:Chrome 70+, Firefox 70+, Safari 13+, Edge 79+
- **产物大小**:`.min.js` ~45KB(gzip ~15KB
- **产物大小**:`.min.js` ~4.0MB(gzip ~1.1MB),体积主要由内置的 TDesign Web Components 组件库贡献(按需 tree-shaking 后
--- ---
@ -389,9 +391,8 @@ client/
│ ├── storage.ts # localStorage 封装 │ ├── storage.ts # localStorage 封装
│ ├── api.ts # HTTP 封装 + SSE 流式 + P1 RAG + P2 会话 │ ├── api.ts # HTTP 封装 + SSE 流式 + P1 RAG + P2 会话
│ ├── dom.ts # DOM 构建 + 拖拽 + P1 来源/分类 + P2 面板 │ ├── dom.ts # DOM 构建 + 拖拽 + P1 来源/分类 + P2 面板
│ ├── styles.ts # CSS 注入 + 主题 + P1 Markdown/来源 + P2 面板
│ ├── chat.ts # 对话核心 + P1 Markdown/RAG + P2 i18n
│ ├── markdown.ts # P1 Markdown 渲染器(基于 marked v15)
│ ├── styles.ts # CSS 注入 + 主题(--csk-* 与 --td-* 变量透传)
│ ├── chat.ts # 对话核心 + P1 RAG + P2 i18n(Markdown 由 t-chat-item 内置 cherry-markdown 处理)
│ ├── i18n.ts # P2 多语言国际化 │ ├── i18n.ts # P2 多语言国际化
│ └── utils.ts # UUID、防抖、XSS 转义 │ └── utils.ts # UUID、防抖、XSS 转义
├── dist/ # 构建产物 ├── dist/ # 构建产物
@ -410,7 +411,7 @@ client/
| **P0 核心链路** | ✅ 已完成 | 项目骨架、配置校验、样式注入、悬浮按钮+弹窗、HTTP 通信、对话核心、本地缓存、打包构建 | | **P0 核心链路** | ✅ 已完成 | 项目骨架、配置校验、样式注入、悬浮按钮+弹窗、HTTP 通信、对话核心、本地缓存、打包构建 |
| **P1 体验增强** | ✅ 已完成 | SSE 流式打字机、Markdown 渲染、知识库下拉切换、RAG 引用来源展示、UI 全配置化、弹窗拖拽 | | **P1 体验增强** | ✅ 已完成 | SSE 流式打字机、Markdown 渲染、知识库下拉切换、RAG 引用来源展示、UI 全配置化、弹窗拖拽 |
| **P2 运营完善** | ✅ 已完成 | 多语言国际化(zh-CN/en)、控制台日志体系、会话管理面板(列表/导出/删除) | | **P2 运营完善** | ✅ 已完成 | 多语言国际化(zh-CN/en)、控制台日志体系、会话管理面板(列表/导出/删除) |
| **可选扩展** | 🔜 待定 | 知识库管理嵌入(`showAdminPanel`)、更多语言、主题皮肤 |
| **可选扩展** | 🔜 待定 | 知识库管理嵌入、更多语言、主题皮肤 |
--- ---
@ -418,10 +419,10 @@ client/
访问 `http://localhost:9090/sdk/test.html` 运行自动化验证。 访问 `http://localhost:9090/sdk/test.html` 运行自动化验证。
测试覆盖(22 个用例):
测试覆盖(10 个核心用例):
| 编号 | 阶段 | 功能 | | 编号 | 阶段 | 功能 |
|------|------|------| |------|------|------|
| T1-T13 | P0 | 全局挂载、参数校验、DOM 创建、open/close、默认值、主题色、位置、destroy、缓存、API 对话 |
| T14-T19 | P1 | Markdown 样式、知识库分类切换、RAG 来源样式、RAG SSE 接口、RAG 来源接口、分类树接口 |
| T20-T22 | P2 | 多语言国际化、会话管理面板、会话列表接口 |
| T1-T6 | P0 | 全局加载、参数校验、DOM 创建、open/close、destroy 清理、实际对话 |
| T7-T9 | P1 | SSE 流式接口、RAG 引用来源、知识库分类切换 |
| T10 | P2 | 多语言国际化 |

69
client/assets/marked.min.js
File diff suppressed because it is too large
View File

2526
client/package-lock.json
File diff suppressed because it is too large
View File

6
client/package.json

@ -14,11 +14,17 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^29.0.3",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^6.0.3",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.0", "@rollup/plugin-typescript": "^12.1.0",
"rollup": "^4.30.0", "rollup": "^4.30.0",
"tslib": "^2.8.0", "tslib": "^2.8.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^3.2.6" "vitest": "^3.2.6"
},
"dependencies": {
"tdesign-web-components": "^1.2.10"
} }
} }

24
client/rollup.config.js

@ -1,5 +1,8 @@
import typescript from '@rollup/plugin-typescript'; import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser'; import terser from '@rollup/plugin-terser';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import replace from '@rollup/plugin-replace';
import { copyFileSync, existsSync, mkdirSync } from 'fs'; import { copyFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
@ -9,7 +12,6 @@ const BACKEND_DIR = resolve(ROOT, '../src/main/resources/static/sdk');
const ASSETS = [ const ASSETS = [
{ src: resolve(ROOT, 'assets/launcher-logo.png'), name: 'launcher-logo.png' }, { src: resolve(ROOT, 'assets/launcher-logo.png'), name: 'launcher-logo.png' },
{ src: resolve(ROOT, 'assets/marked.min.js'), name: 'marked.min.js' },
]; ];
const BUNDLE_FILES = ['chatbot-sdk.js', 'chatbot-sdk.min.js']; const BUNDLE_FILES = ['chatbot-sdk.js', 'chatbot-sdk.min.js'];
@ -57,16 +59,36 @@ export default {
format: 'iife', format: 'iife',
name: 'ChatbotSDK', name: 'ChatbotSDK',
sourcemap: false, sourcemap: false,
inlineDynamicImports: true,
}, },
{ {
file: 'dist/chatbot-sdk.min.js', file: 'dist/chatbot-sdk.min.js',
format: 'iife', format: 'iife',
name: 'ChatbotSDK', name: 'ChatbotSDK',
sourcemap: false, sourcemap: false,
inlineDynamicImports: true,
plugins: [terser()], plugins: [terser()],
}, },
], ],
plugins: [ plugins: [
// mermaid 不打包(省约 1.2MB gzip),用本地 stub 替换,避免 markdown-content 模块顶层
// Cherry.usePlugin 注册 mermaid 插件时因缺少 mermaid 抛异常导致 IIFE 加载失败
{
name: 'mermaid-stub',
resolveId(source) {
if (source === 'mermaid') return resolve(ROOT, 'src/mermaid-stub.ts');
return null;
},
},
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production'),
}),
nodeResolve({
browser: true,
preferBuiltins: false,
}),
commonjs(),
typescript({ typescript({
tsconfig: './tsconfig.json', tsconfig: './tsconfig.json',
declaration: false, declaration: false,

129
client/src/api.ts

@ -88,13 +88,13 @@ function buildChatUrl(message: string): string {
// userId 映射为 accountId // userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
return buildUrl(`/ai/chat?${params.toString()}`);
} }
/** /**
* SSE URL * SSE URL
*/ */
function buildChatSSEUrl(message: string, categoryId?: number): string {
function buildChatSSEUrl(message: string, categoryId?: number, useRag?: boolean): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('message', message); params.set('message', message);
params.set('chatId', currentConfig!.chatId); params.set('chatId', currentConfig!.chatId);
@ -103,23 +103,13 @@ function buildChatSSEUrl(message: string, categoryId?: number): string {
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/**
* RAG URL
*/
function buildChatRAGSSEUrl(message: string, categoryId?: number): string {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
// RAG 增强:追加 enableRag=true 与查询重写策略,后端按角色绑定的知识库分类自动检索
if (useRag) {
params.set('enableRag', 'true');
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
}
return buildUrl(`/ai/assistant_app/chat/rag/sse?${params.toString()}`);
return buildUrl(`/ai/chat/stream?${params.toString()}`);
} }
/** /**
@ -135,7 +125,7 @@ function buildRagSourcesUrl(message: string, categoryId?: number): string {
setIfPresent(params, 'accountId', currentConfig!.userId); setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId); setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`);
return buildUrl(`/ai/chat/sources?${params.toString()}`);
} }
// ==================== HTTP 基础封装 ==================== // ==================== HTTP 基础封装 ====================
@ -270,10 +260,52 @@ export async function chatSSERequest(
signal?: AbortSignal signal?: AbortSignal
): Promise<void> { ): Promise<void> {
const url = useRag const url = useRag
? buildChatRAGSSEUrl(message, categoryId)
: buildChatSSEUrl(message, categoryId);
? buildChatSSEUrl(message, categoryId, true)
: buildChatSSEUrl(message, categoryId, false);
let totalText = ''; let totalText = '';
/**
* SSE OpenAI Chat Completions JSON
* 退
* @param raw eventLines
*/
const emitRawChunk = (raw: string): void => {
// 尝试按 OpenAI Chat Completions JSON 解析,解析失败则视为纯文本
let obj: unknown = null;
try {
obj = JSON.parse(raw);
} catch {
obj = null;
}
if (obj && typeof obj === 'object') {
const record = obj as Record<string, unknown>;
// OpenAI 错误 chunk:{ error: { message, type } }
if (record.error) {
const err = record.error as { message?: string; type?: string };
onError(new CskError(String(err.message || err.type || 'stream error'), 'stream_error'));
return;
}
// OpenAI Chat Completions chunk:{ choices: [{ delta: { content } }] }
if (Array.isArray(record.choices)) {
const first = (record.choices as Array<{ delta?: { content?: unknown } }>)[0];
const content = first?.delta?.content;
if (typeof content === 'string' && content.length > 0) {
totalText += content;
onChunk(content);
}
// role / finish_reason 等空 chunk 直接跳过
return;
}
}
// 纯文本回退:整体作为文本追加
totalText += raw;
onChunk(raw);
};
logger.lifecycleSend(getActiveIntegrateId(), message.length); logger.lifecycleSend(getActiveIntegrateId(), message.length);
try { try {
@ -315,9 +347,7 @@ export async function chatSSERequest(
if (eventLines.length > 0) { if (eventLines.length > 0) {
// 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染 // 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染
if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') { if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
emitRawChunk(eventLines.join('\n'));
} }
eventLines = []; eventLines = [];
} }
@ -334,9 +364,7 @@ export async function chatSSERequest(
// [DONE] 信号(结束整次 SSE 流) // [DONE] 信号(结束整次 SSE 流)
if (trimmed === '[DONE]') { if (trimmed === '[DONE]') {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
emitRawChunk(eventLines.join('\n'));
eventLines = []; eventLines = [];
} }
currentEventType = ''; currentEventType = '';
@ -357,6 +385,15 @@ export async function chatSSERequest(
content = trimmed.substring('data:'.length); content = trimmed.substring('data:'.length);
// 去掉 data: 后面紧跟的一个空格(SSE 标准允许) // 去掉 data: 后面紧跟的一个空格(SSE 标准允许)
if (content.startsWith(' ')) content = content.substring(1); if (content.startsWith(' ')) content = content.substring(1);
// OpenAI 格式的结束信号:data: [DONE]
if (content === '[DONE]') {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
emitRawChunk(eventLines.join('\n'));
eventLines = [];
}
currentEventType = '';
break;
}
} else { } else {
content = trimmed; content = trimmed;
} }
@ -379,9 +416,7 @@ export async function chatSSERequest(
} }
// 处理缓冲区剩余 // 处理缓冲区剩余
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') { if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
emitRawChunk(eventLines.join('\n'));
eventLines = []; eventLines = [];
} }
} catch (readErr: unknown) { } catch (readErr: unknown) {
@ -617,6 +652,40 @@ export function getConversationExportUrl(conversationId: string): string {
return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`); return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`);
} }
/**
* SDK
* userTurn
* DatabaseChatMemory
*/
export async function truncateConversation(conversationId: string, userTurn: number): Promise<boolean> {
if (!currentConfig) return false;
const url = buildUrl(`/ai/sdk/conversation/${conversationId}/truncate`);
try {
const body: Record<string, unknown> = { userTurn };
if (currentConfig.userId) body.accountId = currentConfig.userId;
// 后端按 JSON 数字解析 roleId,须转 Number 传入(空串/非数字不传,避免 0 被误判)
const roleId = getActiveIntegrateId();
if (roleId) {
const num = Number(roleId);
if (Number.isFinite(num) && num > 0) body.roleId = num;
}
const response = await safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
logger.error(`会话截断失败 status=${response.status}`);
return false;
}
const json: ApiResponse = await response.json();
return json.success === true;
} catch (err) {
logger.error('会话截断异常', err);
return false;
}
}
// ==================== chatId 自动初始化 ==================== // ==================== chatId 自动初始化 ====================
/** /**

348
client/src/chat.ts

@ -16,6 +16,7 @@ import {
fetchConversationMessages, fetchConversationMessages,
deleteConversation, deleteConversation,
getConversationExportUrl, getConversationExportUrl,
truncateConversation,
initChatId, initChatId,
updateChatId, updateChatId,
getChatId, getChatId,
@ -29,22 +30,21 @@ import {
import { import {
renderUserBubble, renderUserBubble,
renderAIBubble, renderAIBubble,
renderErrorBubble,
createEmptyAIBubble, createEmptyAIBubble,
scrollToBottom, scrollToBottom,
renderSources, renderSources,
renderHistoryList, renderHistoryList,
appendStreamingCaret,
finalizeAIBubble, finalizeAIBubble,
updateFeedbackUI,
showOfflineBanner, showOfflineBanner,
hideOfflineBanner, hideOfflineBanner,
renderSuggestions, renderSuggestions,
removeSuggestions,
showFeedbackReasonPanel, showFeedbackReasonPanel,
resetChatActionFeedback,
confirmWithDialog,
HistoryItemData, HistoryItemData,
} from './dom'; } from './dom';
import { saveMessages, loadMessages, clearMessages } from './storage'; import { saveMessages, loadMessages, clearMessages } from './storage';
import { renderMarkdown } from './markdown';
import { logger } from './logger'; import { logger } from './logger';
import { t } from './i18n'; import { t } from './i18n';
import { uuid, now } from './utils'; import { uuid, now } from './utils';
@ -52,12 +52,10 @@ import { uuid, now } from './utils';
let config: ResolvedConfig | null = null; let config: ResolvedConfig | null = null;
let messages: ChatMessage[] = []; let messages: ChatMessage[] = [];
let messagesContainer: HTMLElement | null = null; let messagesContainer: HTMLElement | null = null;
let inputEl: HTMLTextAreaElement | null = null;
let inputWrap: HTMLElement | null = null;
let sendBtn: HTMLElement | null = null;
let inputEl: HTMLElement | null = null;
let clearBtn: HTMLElement | null = null; let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null;
let roleSelect: HTMLSelectElement | null = null;
let categorySelect: HTMLElement | null = null;
let roleSelect: HTMLElement | null = null;
let historyPanel: HTMLElement | null = null; let historyPanel: HTMLElement | null = null;
let welcomeEl: HTMLElement | null = null; let welcomeEl: HTMLElement | null = null;
let newMsgBtn: HTMLElement | null = null; let newMsgBtn: HTMLElement | null = null;
@ -66,6 +64,8 @@ let ariaLiveEl: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null; let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null; let hideLoadingFn: (() => void) | null = null;
let isSending = false; let isSending = false;
/** 是否正在「重新生成」中(独立于 isSending,覆盖截断后端会话的异步窗口,防止重复触发) */
let isRetrying = false;
/** 当前流式请求的中断控制器(用于"停止生成") */ /** 当前流式请求的中断控制器(用于"停止生成") */
let abortController: AbortController | null = null; let abortController: AbortController | null = null;
@ -90,11 +90,10 @@ export function initChat(
cfg: ResolvedConfig, cfg: ResolvedConfig,
dom: { dom: {
messagesContainer: HTMLElement; messagesContainer: HTMLElement;
inputEl: HTMLTextAreaElement;
sendBtn: HTMLElement;
inputEl: HTMLElement;
clearBtn: HTMLElement | null; clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
categorySelect: HTMLElement | null;
roleSelect: HTMLElement | null;
historyPanel: HTMLElement; historyPanel: HTMLElement;
welcomeEl: HTMLElement; welcomeEl: HTMLElement;
newMsgBtn: HTMLElement; newMsgBtn: HTMLElement;
@ -107,8 +106,6 @@ export function initChat(
config = cfg; config = cfg;
messagesContainer = dom.messagesContainer; messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl; inputEl = dom.inputEl;
inputWrap = dom.inputEl.parentElement;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn; clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect; categorySelect = dom.categorySelect;
roleSelect = dom.roleSelect; roleSelect = dom.roleSelect;
@ -196,38 +193,22 @@ async function loadHistoryFromBackend(): Promise<void> {
/** 绑定发送相关事件 */ /** 绑定发送相关事件 */
function bindSendEvents(): void { function bindSendEvents(): void {
if (!inputEl || !sendBtn) return;
if (!inputEl) return;
// 发送按钮:发送态点击发送,停止态点击中断流式
sendBtn.addEventListener('click', () => {
if (isSending && abortController) {
abortController.abort();
return;
}
handleSend();
// t-chat-sender:send 事件(点击发送/回车)携带 value;stop 事件(loading 态点击)中断流式
inputEl.addEventListener('send', (e) => {
const detail = (e as CustomEvent).detail as { value?: string } | undefined;
const value = (detail?.value || '').trim();
if (!value || isSending) return;
handleSend(value);
}); });
inputEl.addEventListener('keydown', (e: KeyboardEvent) => {
// IME 组合输入期间(中文/日文输入法选词回车)不触发发送
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
handleSend();
inputEl.addEventListener('stop', () => {
if (isSending && abortController) {
abortController.abort();
} }
}); });
inputEl.addEventListener('input', () => {
updateSendBtnState();
autoResizeInput();
});
// 输入框聚焦/失焦高亮容器
inputEl.addEventListener('focus', () => {
if (inputWrap) inputWrap.classList.add('csk-input-wrap--focus');
});
inputEl.addEventListener('blur', () => {
if (inputWrap) inputWrap.classList.remove('csk-input-wrap--focus');
});
if (clearBtn) { if (clearBtn) {
clearBtn.addEventListener('click', () => handleClear()); clearBtn.addEventListener('click', () => handleClear());
} }
@ -273,21 +254,14 @@ function smartScrollToBottom(): void {
} }
} }
/** 切换发送按钮模式:发送 / 停止生成 */
/** 切换发送按钮模式:发送 / 停止生成(由 t-chat-sender 的 loading 属性承载) */
function setSendButtonMode(mode: 'send' | 'stop'): void { function setSendButtonMode(mode: 'send' | 'stop'): void {
if (!sendBtn) return;
if (!inputEl) return;
// loading 为 Boolean 简单类型,omi 下须用 setAttribute/removeAttribute(property 赋值静默失效)
if (mode === 'stop') { if (mode === 'stop') {
sendBtn.classList.add('csk-send-btn--stop');
sendBtn.removeAttribute('disabled');
sendBtn.setAttribute('title', t('stop'));
sendBtn.setAttribute('aria-label', t('stop'));
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
inputEl.setAttribute('loading', '');
} else { } else {
sendBtn.classList.remove('csk-send-btn--stop');
sendBtn.setAttribute('title', t('send'));
sendBtn.setAttribute('aria-label', t('send'));
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
updateSendBtnState();
inputEl.removeAttribute('loading');
} }
} }
@ -320,7 +294,7 @@ function renderFilteredHistory(): void {
(conversationId: string) => { switchToConversation(conversationId); }, (conversationId: string) => { switchToConversation(conversationId); },
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); }, (id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
async (id: string) => { async (id: string) => {
if (!confirm(t('history_delete_confirm'))) return;
if (!(await confirmWithDialog(t('history_delete_confirm')))) return;
const ok = await deleteConversation(id); const ok = await deleteConversation(id);
if (ok) { if (ok) {
if (id === getChatId()) { if (id === getChatId()) {
@ -355,27 +329,25 @@ function announceMessage(text: string): void {
/** /**
* AI / * AI /
* messages + localStorage+ API * messages + localStorage+ API
* t-chat-action active
* *
*/ */
export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
if (!messagesContainer) return;
export function handleFeedback(msgId: string, value: 'up' | 'down', active: boolean): void {
if (!messagesContainer || !config) return;
const cfg = config;
const msg = messages.find(m => m.id === msgId && m.role === 'ai'); const msg = messages.find(m => m.id === msgId && m.role === 'ai');
if (!msg) return; if (!msg) return;
if (value === 'up') { if (value === 'up') {
// 点赞:直接切换提交,无需原因 // 点赞:直接切换提交,无需原因
const newValue = msg.feedback === 'up' ? undefined : 'up';
msg.feedback = newValue;
msg.feedback = active ? 'up' : undefined;
msg.feedbackReason = undefined; msg.feedbackReason = undefined;
msg.feedbackComment = undefined; msg.feedbackComment = undefined;
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
if (wrapper) updateFeedbackUI(wrapper, newValue);
if (config) saveMessages(config.integrateId, messages);
saveMessages(cfg.integrateId, messages);
if (newValue) {
if (active) {
submitFeedbackApi(String(msgId), 'THUMBS_UP').then(success => { submitFeedbackApi(String(msgId), 'THUMBS_UP').then(success => {
if (success) { if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=up`); logger.info(`消息反馈已提交 msgId=${msgId} value=up`);
@ -389,46 +361,38 @@ export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
return; return;
} }
// 点踩:弹出原因选择弹窗
if (value === 'down') {
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
if (!wrapper) return;
// 如果已点踩,取消(同 SDK 现有行为)
if (msg.feedback === 'down') {
msg.feedback = undefined;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
if (wrapper) updateFeedbackUI(wrapper, undefined);
if (config) saveMessages(config.integrateId, messages);
logger.info(`消息反馈已取消 msgId=${msgId}`);
return;
}
// 点踩
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
if (!wrapper) return;
// 弹出原因选择面板
showFeedbackReasonPanel(wrapper, msgId, (reason, comment) => {
msg.feedback = 'down';
msg.feedbackReason = reason;
msg.feedbackComment = comment;
if (!active) {
// 取消点踩(图标已由组件内部回滚)
msg.feedback = undefined;
msg.feedbackReason = undefined;
msg.feedbackComment = undefined;
saveMessages(cfg.integrateId, messages);
logger.info(`消息反馈已取消 msgId=${msgId}`);
return;
}
if (wrapper) updateFeedbackUI(wrapper, 'down');
// 点踩:弹出原因选择面板;用户关闭面板(不选原因)时回滚图标
showFeedbackReasonPanel(wrapper, msgId, (reason, comment) => {
msg.feedback = 'down';
msg.feedbackReason = reason;
msg.feedbackComment = comment;
if (config) saveMessages(config.integrateId, messages);
saveMessages(cfg.integrateId, messages);
submitFeedbackApi(String(msgId), 'THUMBS_DOWN', reason, comment).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=down reason=${reason}`);
} else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
});
submitFeedbackApi(String(msgId), 'THUMBS_DOWN', reason, comment).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=down reason=${reason}`);
} else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
}); });
}
}
function autoResizeInput(): void {
if (!inputEl) return;
inputEl.style.height = 'auto';
inputEl.style.height = `${Math.min(inputEl.scrollHeight, 120)}px`;
}, () => {
resetChatActionFeedback(wrapper);
});
} }
/** 根据消息数量切换欢迎空状态显隐 */ /** 根据消息数量切换欢迎空状态显隐 */
@ -439,32 +403,17 @@ function updateEmptyState(): void {
welcomeEl.style.display = hasMessages ? 'none' : ''; welcomeEl.style.display = hasMessages ? 'none' : '';
} }
/** 更新发送按钮状态 */
function updateSendBtnState(): void {
if (!sendBtn || !inputEl) return;
const hasText = inputEl.value.trim().length > 0;
if (hasText && !isSending) {
sendBtn.removeAttribute('disabled');
} else {
sendBtn.setAttribute('disabled', 'true');
}
}
/** 处理发送消息 */
async function handleSend(): Promise<void> {
if (!inputEl || !config || isSending) return;
const text = inputEl.value.trim();
if (text === '') return;
/** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */
async function handleSend(text?: string): Promise<void> {
if (!config || isSending) return;
inputEl.value = '';
updateSendBtnState();
inputEl.style.height = 'auto';
const input = (text ?? '').trim();
if (input === '') return;
// 1. 渲染用户气泡 // 1. 渲染用户气泡
const userTimestamp = now(); const userTimestamp = now();
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: input, timestamp: userTimestamp };
if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp);
messages.push(userMsg); messages.push(userMsg);
updateEmptyState(); updateEmptyState();
@ -472,7 +421,7 @@ async function handleSend(): Promise<void> {
if (messagesContainer) smartScrollToBottom(); if (messagesContainer) smartScrollToBottom();
// 2. 生成 AI 回复 // 2. 生成 AI 回复
await produceAIReply(text);
await produceAIReply(input);
} }
/** /**
@ -508,7 +457,7 @@ async function produceAIReply(userText: string): Promise<void> {
aiContent = await chatRequest(userText); aiContent = await chatRequest(userText);
if (hideLoadingFn) hideLoadingFn(); if (hideLoadingFn) hideLoadingFn();
if (messagesContainer) { if (messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp, renderMarkdown, aiMsgId);
renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId);
} }
} }
const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp }; const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp };
@ -540,21 +489,13 @@ async function produceAIReply(userText: string): Promise<void> {
const errMsg = err instanceof CskError ? err.message : t('error_send'); const errMsg = err instanceof CskError ? err.message : t('error_send');
if (messagesContainer) { if (messagesContainer) {
const errorBubble = document.createElement('div');
errorBubble.className = 'csk-msg csk-msg--ai';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.style.color = '#DC2626';
bubble.textContent = `${errMsg}`;
errorBubble.appendChild(bubble);
messagesContainer.appendChild(errorBubble);
renderErrorBubble(messagesContainer, errMsg, now());
} }
logger.error(`发送失败 integrateId=${config.integrateId}`, err); logger.error(`发送失败 integrateId=${config.integrateId}`, err);
} finally { } finally {
isSending = false; isSending = false;
abortController = null; abortController = null;
setSendButtonMode('send'); setSendButtonMode('send');
updateSendBtnState();
} }
} }
@ -585,20 +526,22 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
streamStarted = true; streamStarted = true;
} }
if (bubbleEl) { if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated);
appendStreamingCaret(bubbleEl);
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
} }
if (messagesContainer) smartScrollToBottom(); if (messagesContainer) smartScrollToBottom();
}, },
() => { () => {
// 流结束 // 流结束
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
if (!streamStarted && accumulated === '') {
chatRequest(text).then(resolve).catch(reject);
return;
}
if (wrapperEl && bubbleEl) { if (wrapperEl && bubbleEl) {
// 无流内容降级为同步请求
if (!streamStarted && accumulated === '') {
chatRequest(text).then(resolve).catch(reject);
return;
if (accumulated) {
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
} }
if (accumulated) bubbleEl.innerHTML = renderMarkdown(accumulated);
finalizeAIBubble(wrapperEl, bubbleEl); finalizeAIBubble(wrapperEl, bubbleEl);
} }
resolve(accumulated); resolve(accumulated);
@ -610,7 +553,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
} }
if (accumulated.length > 0) { if (accumulated.length > 0) {
if (bubbleEl) { if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated + '\n\n' + t('stream_interrupted') }];
if (wrapperEl) finalizeAIBubble(wrapperEl, bubbleEl); if (wrapperEl) finalizeAIBubble(wrapperEl, bubbleEl);
} }
resolve(accumulated); resolve(accumulated);
@ -629,44 +572,61 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
* *
*/ */
export function sendQuickReply(text: string): Promise<void> { export function sendQuickReply(text: string): Promise<void> {
if (!inputEl || isSending) return Promise.resolve();
inputEl.value = text;
updateSendBtnState();
return handleSend();
if (isSending) return Promise.resolve();
return handleSend(text);
} }
/** /**
* AI ID * AI ID
* AI AI
* ChatPanel
* AI
*
* "复活"
*/ */
export async function retryFromMessage(msgId: string): Promise<void> { export async function retryFromMessage(msgId: string): Promise<void> {
if (!config || !messagesContainer || isSending) return;
if (!config || !messagesContainer || isSending || isRetrying) return;
isRetrying = true;
try {
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai');
if (aiIndex < 0) return;
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai');
if (aiIndex < 0) return;
// 找到该 AI 回复所属的那轮用户提问
let userIndex = -1;
for (let i = aiIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') { userIndex = i; break; }
}
if (userIndex < 0) return;
const userText = messages[userIndex].content;
if (!userText) return;
// 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息
let userTurn = 0;
for (let i = 0; i <= userIndex; i++) {
if (messages[i].role === 'user') userTurn++;
}
// 找到上一条用户消息文本
let userText = '';
for (let i = aiIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') { userText = messages[i].content; break; }
}
if (!userText) return;
// 移除该 AI 消息及其后所有消息(本地 + DOM)
const removed = messages.splice(aiIndex);
const removedIds = new Set(removed.map(m => m.id));
const nodes = messagesContainer.querySelectorAll('.csk-msg');
nodes.forEach(node => {
const id = (node as HTMLElement).dataset.cskMsgId;
// 无 id 的(如错误气泡、被中断的)按顺序兜底:此处仅按 id 精确移除
if (id && removedIds.has(id)) node.remove();
});
// 1. 先截断后端会话(删除该轮提问及其之后的所有消息),失败则不触碰本地数据
const chatId = getChatId();
if (chatId) {
const ok = await truncateConversation(chatId, userTurn);
if (!ok) {
logger.error(`重新生成失败:后端会话截断失败 chatId=${chatId} userTurn=${userTurn}`);
return;
}
}
saveMessages(config.integrateId, messages);
updateEmptyState();
// 2. 本地截断到该轮提问之前(删除该轮提问、被点击的回复及其后的所有消息)
messages = messages.slice(0, userIndex);
saveMessages(config.integrateId, messages);
// 3. 重新渲染历史(清除 DOM 中被删除的消息),保证界面与数据一致
renderHistory();
// 重新生成(不重复渲染用户气泡)
await produceAIReply(userText);
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
await handleSend(userText);
} finally {
isRetrying = false;
}
} }
/** 获取并渲染 RAG 引用来源 */ /** 获取并渲染 RAG 引用来源 */
@ -694,25 +654,30 @@ async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promi
} }
} }
/** 加载知识库分类到下拉框 */
/** 加载知识库分类到原生下拉框(选项含缩进前缀表达层级) */
async function loadCategories(): Promise<void> { async function loadCategories(): Promise<void> {
if (!categorySelect) return; if (!categorySelect) return;
try { try {
const tree = await fetchCategoryTree(); const tree = await fetchCategoryTree();
if (tree.length === 0) return; if (tree.length === 0) return;
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
const select = categorySelect as HTMLSelectElement;
// 清空后重建(首项为"全部分类"占位)
select.innerHTML = '';
select.appendChild(new Option(t('category_all'), ''));
const addOptions = (nodes: typeof tree, indent: number = 0) => { const addOptions = (nodes: typeof tree, indent: number = 0) => {
for (const node of nodes) { 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);
select.appendChild(new Option(`${' '.repeat(indent)}${node.name}`, String(node.id)));
if (node.children && node.children.length > 0) addOptions(node.children, indent + 1); if (node.children && node.children.length > 0) addOptions(node.children, indent + 1);
} }
}; };
addOptions(tree); addOptions(tree);
// 若已有选中分类,回填选中值
if (currentCategoryId !== undefined) {
select.value = String(currentCategoryId);
}
logger.info(`知识库分类加载成功 count=${tree.length}`); logger.info(`知识库分类加载成功 count=${tree.length}`);
} catch (err) { } catch (err) {
logger.error(t('category_load_error'), err); logger.error(t('category_load_error'), err);
@ -731,14 +696,21 @@ function renderHistory(): void {
if (msg.role === 'user') { if (msg.role === 'user') {
renderUserBubble(messagesContainer, msg.content, msg.timestamp); renderUserBubble(messagesContainer, msg.content, msg.timestamp);
} else { } else {
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, renderMarkdown, msg.id);
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, msg.id, msg.feedback);
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources); if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources);
if (msg.feedback) updateFeedbackUI(wrapper, msg.feedback);
} }
} }
isNearBottom = true; isNearBottom = true;
scrollToBottom(messagesContainer);
// 延迟滚动到底部:t-chat-item 的 shadow DOM(含 cherry-markdown)异步渲染,
// 若同步设置 scrollTop,此时 scrollHeight 尚未更新到最终值,历史会话会停在滚动条中间。
// 用双 rAF 等待组件渲染与布局稳定后再滚到底部。
const container = messagesContainer;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (container && container.isConnected) scrollToBottom(container);
});
});
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex'; if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
updateEmptyState(); updateEmptyState();
@ -827,9 +799,9 @@ export async function switchRole(newRoleId: string): Promise<void> {
// 6. 更新 API 层的 integrateId(必须在 initChatId 之前) // 6. 更新 API 层的 integrateId(必须在 initChatId 之前)
setActiveRoleId(newRoleId); setActiveRoleId(newRoleId);
// 7. 更新角色下拉框高亮
// 7. 更新角色下拉框选中
if (roleSelect) { if (roleSelect) {
roleSelect.value = newRoleId;
(roleSelect as HTMLSelectElement).value = newRoleId;
} }
// 8. 强制查询后端会话列表(跳过 localStorage 缓存) // 8. 强制查询后端会话列表(跳过 localStorage 缓存)
@ -890,7 +862,7 @@ export async function loadHistoryConversations(): Promise<void> {
(conversationId: string) => { switchToConversation(conversationId); }, (conversationId: string) => { switchToConversation(conversationId); },
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); }, (id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
async (id: string) => { async (id: string) => {
if (!confirm(t('history_delete_confirm'))) return;
if (!(await confirmWithDialog(t('history_delete_confirm')))) return;
const ok = await deleteConversation(id); const ok = await deleteConversation(id);
if (ok) { if (ok) {
if (id === getChatId()) { if (id === getChatId()) {
@ -919,7 +891,7 @@ export async function loadHistoryConversations(): Promise<void> {
* *
* @param conversationId ID chatId * @param conversationId ID chatId
*/ */
export async function switchToConversation(conversationId: string): Promise<void> {
async function switchToConversation(conversationId: string): Promise<void> {
if (!config || !messagesContainer) return; if (!config || !messagesContainer) return;
// 找到该会话对应的历史条目,获取其所属角色 // 找到该会话对应的历史条目,获取其所属角色
@ -934,9 +906,9 @@ export async function switchToConversation(conversationId: string): Promise<void
if (convRoleId && convRoleId !== currentRoleId) { if (convRoleId && convRoleId !== currentRoleId) {
logger.info(`会话角色不匹配,自动切换角色 ${currentRoleId} -> ${convRoleId}`); logger.info(`会话角色不匹配,自动切换角色 ${currentRoleId} -> ${convRoleId}`);
setActiveRoleId(convRoleId); setActiveRoleId(convRoleId);
// 更新角色下拉框高亮
// 更新角色下拉框选中
if (roleSelect) { if (roleSelect) {
roleSelect.value = convRoleId;
(roleSelect as HTMLSelectElement).value = convRoleId;
} }
} }
} }
@ -985,11 +957,6 @@ export async function switchToConversation(conversationId: string): Promise<void
updateEmptyState(); updateEmptyState();
} }
/** 获取当前消息列表 */
export function getMessages(): ChatMessage[] {
return messages;
}
// ==================== 建议问题(suggest-message-list) ==================== // ==================== 建议问题(suggest-message-list) ====================
/** /**
@ -1005,7 +972,8 @@ async function fetchAndShowSuggestions(aiMsgId: string): Promise<void> {
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${aiMsgId}"]`) as HTMLElement; const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${aiMsgId}"]`) as HTMLElement;
if (wrapper) renderSuggestions(wrapper, suggestions, sendQuickReply); if (wrapper) renderSuggestions(wrapper, suggestions, sendQuickReply);
} }
} catch {
// 静默降级
} catch (err) {
// 推荐问题拉取失败不影响主流程,仅记录日志便于排查
logger.warn('拉取推荐问题失败', err);
} }
} }

17
client/src/config.ts

@ -9,6 +9,15 @@
import { SDKConfig, ResolvedConfig } from './types'; import { SDKConfig, ResolvedConfig } from './types';
import { logger } from './logger'; import { logger } from './logger';
/**
* CSS + clamp()/calc()/min()/max()
* launcherSize 退 60
*/
function isCssLength(value: string): boolean {
return /^-?\d*\.?\d+(px|rem|em|vw|vh|vmin|vmax|%|ch|ex|pt|pc|cm|mm|in)$/.test(value)
|| /^(clamp|calc|min|max)\(/.test(value);
}
/** /**
* *
@ -42,6 +51,12 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
const domain = raw.requestDomain.replace(/\/+$/, ''); const domain = raw.requestDomain.replace(/\/+$/, '');
const resolvedLauncherIcon = raw.launcherIcon || `<img src="${domain}/sdk/launcher-logo.png" alt="" draggable="false" />`; const resolvedLauncherIcon = raw.launcherIcon || `<img src="${domain}/sdk/launcher-logo.png" alt="" draggable="false" />`;
const resolvedPrimaryColor = raw.primaryColor || '#4F46E5'; const resolvedPrimaryColor = raw.primaryColor || '#4F46E5';
// 悬浮按钮尺寸:number → px 并 clamp 到 [36, 96];string → 合法 CSS 长度透传,否则回退默认 60
const launcherSize: number | string = typeof raw.launcherSize === 'number'
? Math.min(96, Math.max(36, raw.launcherSize))
: (typeof raw.launcherSize === 'string' && isCssLength(raw.launcherSize.trim())
? raw.launcherSize.trim()
: 80);
// 填充默认值 // 填充默认值
const config: ResolvedConfig = { const config: ResolvedConfig = {
@ -56,8 +71,8 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom', position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom',
primaryColor: resolvedPrimaryColor, primaryColor: resolvedPrimaryColor,
launcherIcon: resolvedLauncherIcon, launcherIcon: resolvedLauncherIcon,
launcherSize,
showClear: raw.showClear ?? true, showClear: raw.showClear ?? true,
showAdminPanel: raw.showAdminPanel ?? false,
quickReplies: Array.isArray(raw.quickReplies) quickReplies: Array.isArray(raw.quickReplies)
? raw.quickReplies.map(s => String(s).trim()).filter(Boolean) ? raw.quickReplies.map(s => String(s).trim()).filter(Boolean)
: [], : [],

629
client/src/dom.ts

@ -8,7 +8,7 @@
* - * -
*/ */
import { ResolvedConfig, RagSource } from './types'; import { ResolvedConfig, RagSource } from './types';
import { debounce, formatTime } from './utils';
import { debounce, formatTime, formatHistoryTime } from './utils';
import { t } from './i18n'; import { t } from './i18n';
// ==================== 图标常量 ==================== // ==================== 图标常量 ====================
@ -16,9 +16,6 @@ import { t } from './i18n';
/** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */ /** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */
const BOT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="12" height="8" x="6" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 14v2"/><path d="M15 14v2"/><path d="M18 13v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/></svg>`; const BOT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="12" height="8" x="6" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 14v2"/><path d="M15 14v2"/><path d="M18 13v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/></svg>`;
/** 用户头像图标 */
const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
// ==================== 悬浮按钮 ==================== // ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */ /** 创建悬浮按钮 */
@ -31,6 +28,12 @@ export function createLauncher(config: ResolvedConfig, onClick: () => void): HTM
launcher.setAttribute('role', 'button'); launcher.setAttribute('role', 'button');
launcher.setAttribute('tabindex', '0'); launcher.setAttribute('tabindex', '0');
// 悬浮按钮尺寸通过 CSS 变量透传(.csk-launcher 挂在 body、在 .csk-root 之外,无法继承变量,需元素级设置)
launcher.style.setProperty(
'--csk-launcher-size',
typeof config.launcherSize === 'number' ? `${config.launcherSize}px` : config.launcherSize,
);
// 图标内容 // 图标内容
launcher.innerHTML = config.launcherIcon; launcher.innerHTML = config.launcherIcon;
@ -101,11 +104,10 @@ function createWatermarkPattern(config: ResolvedConfig): string {
export function createChatWindow(config: ResolvedConfig): { export function createChatWindow(config: ResolvedConfig): {
window: HTMLElement; window: HTMLElement;
messagesContainer: HTMLElement; messagesContainer: HTMLElement;
inputEl: HTMLTextAreaElement;
sendBtn: HTMLElement;
inputEl: HTMLElement;
clearBtn: HTMLElement | null; clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
categorySelect: HTMLElement | null;
roleSelect: HTMLElement | null;
historyPanel: HTMLElement; historyPanel: HTMLElement;
welcomeEl: HTMLElement; welcomeEl: HTMLElement;
newMsgBtn: HTMLElement; newMsgBtn: HTMLElement;
@ -163,36 +165,31 @@ export function createChatWindow(config: ResolvedConfig): {
headerLeft.appendChild(headerAvatar); headerLeft.appendChild(headerAvatar);
headerLeft.appendChild(headerInfo); headerLeft.appendChild(headerInfo);
// === 角色选择器(仅当有多个角色时显示) ===
let roleSelect: HTMLSelectElement | null = null;
// === 角色选择器(仅当有多个角色时显示,原生 select) ===
let roleSelect: HTMLElement | null = null;
const roles = config.roles; const roles = config.roles;
if (roles && roles.length > 1) { if (roles && roles.length > 1) {
roleSelect = document.createElement('select');
roleSelect.className = 'csk-role-select';
roleSelect.setAttribute('aria-label', t('role_selector_label'));
// 包装后插入到 headerLeft 之后、actions 之前
const roleWrap = document.createElement('div');
roleWrap.className = 'csk-role-select-wrap';
roleSelect = document.createElement('select');
roleSelect.className = 'csk-select';
for (const role of roles) { for (const role of roles) {
const option = document.createElement('option');
option.value = String(role.id);
option.textContent = role.name || String(role.id);
if (String(role.id) === String(config.integrateId)) {
option.selected = true;
}
roleSelect.appendChild(option);
const opt = document.createElement('option');
opt.value = String(role.id);
opt.textContent = role.name || String(role.id);
roleSelect.appendChild(opt);
} }
// onChange 触发自定义事件
(roleSelect as HTMLSelectElement).value = String(config.integrateId);
roleSelect.setAttribute('aria-label', t('role_selector_label'));
roleSelect.addEventListener('change', () => { roleSelect.addEventListener('change', () => {
const selectedValue = roleSelect!.value;
windowEl.dispatchEvent(new CustomEvent('csk:roleChange', { windowEl.dispatchEvent(new CustomEvent('csk:roleChange', {
detail: { roleId: selectedValue }
detail: { roleId: String((roleSelect as HTMLSelectElement).value) }
})); }));
}); });
// 包装后插入到 headerLeft 之后、actions 之前
const roleWrap = document.createElement('div');
roleWrap.className = 'csk-role-select-wrap';
roleWrap.appendChild(roleSelect); roleWrap.appendChild(roleSelect);
header.appendChild(headerLeft); header.appendChild(headerLeft);
header.appendChild(roleWrap); header.appendChild(roleWrap);
} else { } else {
@ -245,7 +242,7 @@ export function createChatWindow(config: ResolvedConfig): {
<div class="csk-welcome__desc">${t('welcome_desc')}</div> <div class="csk-welcome__desc">${t('welcome_desc')}</div>
`; `;
// 快捷问题芯片(点击即自动发送)
// 快捷问题芯片(点击即自动发送,原生 button
if (config.quickReplies.length > 0) { if (config.quickReplies.length > 0) {
const chips = document.createElement('div'); const chips = document.createElement('div');
chips.className = 'csk-quick-replies'; chips.className = 'csk-quick-replies';
@ -312,8 +309,8 @@ export function createChatWindow(config: ResolvedConfig): {
} }
}); });
// === 知识库分类下拉框(P1) ===
let categorySelect: HTMLSelectElement | null = null;
// === 知识库分类下拉框(原生 select,选项由 loadCategories 异步填充) ===
let categorySelect: HTMLElement | null = null;
if (config.showCategorySwitch) { if (config.showCategorySwitch) {
const categoryBar = document.createElement('div'); const categoryBar = document.createElement('div');
categoryBar.className = 'csk-category-bar'; categoryBar.className = 'csk-category-bar';
@ -321,21 +318,19 @@ export function createChatWindow(config: ResolvedConfig): {
const categoryLabel = document.createElement('span'); const categoryLabel = document.createElement('span');
categoryLabel.className = 'csk-category-bar__label'; categoryLabel.className = 'csk-category-bar__label';
categoryLabel.textContent = '📚'; categoryLabel.textContent = '📚';
categoryBar.appendChild(categoryLabel);
categorySelect = document.createElement('select'); categorySelect = document.createElement('select');
categorySelect.className = 'csk-select';
categorySelect.id = 'csk-category-select'; categorySelect.id = 'csk-category-select';
categorySelect.className = 'csk-category-select';
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
// onChange 触发自定义事件
// 初始"全部分类"占位(后续 loadCategories 会重建完整选项)
categorySelect.appendChild(new Option(t('category_all'), ''));
categorySelect.addEventListener('change', () => { categorySelect.addEventListener('change', () => {
const selectedId = categorySelect!.value;
const selectedId = (categorySelect as HTMLSelectElement).value;
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', { windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
detail: { categoryId: selectedId ? Number(selectedId) : undefined } detail: { categoryId: selectedId ? Number(selectedId) : undefined }
})); }));
}); });
categoryBar.appendChild(categoryLabel);
categoryBar.appendChild(categorySelect); categoryBar.appendChild(categorySelect);
// 插入到 messages 和 inputArea 之间 // 插入到 messages 和 inputArea 之间
@ -351,32 +346,32 @@ export function createChatWindow(config: ResolvedConfig): {
const inputArea = document.createElement('div'); const inputArea = document.createElement('div');
inputArea.className = 'csk-input-area'; inputArea.className = 'csk-input-area';
const inputWrap = document.createElement('div');
inputWrap.className = 'csk-input-wrap';
const inputEl = document.createElement('textarea');
// 输入框 + 发送/停止按钮统一由 t-chat-sender 承担(loading 控制 send/stop 切换,Enter 发送已内置)
const inputEl = document.createElement('t-chat-sender');
inputEl.id = 'csk-input'; inputEl.id = 'csk-input';
inputEl.className = 'csk-input';
inputEl.setAttribute('placeholder', t('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', 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>`;
// 压缩输入框:autosize 为 Object 复杂类型,走 property 赋值(同 dom.ts createChatItemMsg 的 content 处理)
(inputEl as unknown as { autosize: { minRows: number } }).autosize = { minRows: 1 };
inputArea.appendChild(inputEl);
inputWrap.appendChild(inputEl);
inputWrap.appendChild(sendBtn);
inputArea.appendChild(inputWrap);
// === 保密声明脚注 ===
// === 保密声明脚注(默认折叠为单行,点击展开完整条文) ===
const disclaimer = document.createElement('div'); const disclaimer = document.createElement('div');
disclaimer.className = 'csk-disclaimer'; disclaimer.className = 'csk-disclaimer';
disclaimer.setAttribute('role', 'button');
disclaimer.setAttribute('tabindex', '0');
disclaimer.setAttribute('aria-expanded', 'false');
const toggleDisclaimer = () => {
const expanded = disclaimer.classList.toggle('csk-disclaimer--expanded');
disclaimer.setAttribute('aria-expanded', String(expanded));
};
disclaimer.addEventListener('click', toggleDisclaimer);
disclaimer.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDisclaimer(); }
});
if (config.disclaimer !== undefined) { if (config.disclaimer !== undefined) {
// 用户自定义或传空串隐藏
// 用户自定义或传空串隐藏。
// 注意:disclaimer 语义为「受信 HTML」(由宿主开发者或后端管理员配置),
// 此处直接 innerHTML 注入属设计内行为,请勿传入不可信用户输入。
disclaimer.innerHTML = config.disclaimer; disclaimer.innerHTML = config.disclaimer;
if (!config.disclaimer) { if (!config.disclaimer) {
disclaimer.style.display = 'none'; disclaimer.style.display = 'none';
@ -404,19 +399,12 @@ export function createChatWindow(config: ResolvedConfig): {
function showLoading(): HTMLElement { function showLoading(): HTMLElement {
if (loadingEl) { if (loadingEl) {
loadingEl.style.display = 'flex';
loadingEl.style.display = '';
return loadingEl; return loadingEl;
} }
const el = document.createElement('div');
el.className = 'csk-loading';
el.innerHTML = `
<div class="csk-loading__avatar">${BOT_ICON}</div>
<div class="csk-loading__bubble">
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
</div>
`;
const el = document.createElement('t-chat-loading');
el.setAttribute('animation', 'dots');
el.setAttribute('text', t('loading'));
messagesContainer.appendChild(el); messagesContainer.appendChild(el);
loadingEl = el; loadingEl = el;
return el; return el;
@ -443,10 +431,16 @@ export function createChatWindow(config: ResolvedConfig): {
const teaserEl = document.createElement('div'); const teaserEl = document.createElement('div');
teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`; teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`;
teaserEl.setAttribute('role', 'status'); teaserEl.setAttribute('role', 'status');
teaserEl.innerHTML = `
<button class="csk-teaser__close" aria-label="${t('close')}" type="button">&times;</button>
<span>${config.teaserText || t('teaser_text')}</span>
`;
const teaserCloseBtn = document.createElement('button');
teaserCloseBtn.className = 'csk-teaser__close';
teaserCloseBtn.setAttribute('aria-label', t('close'));
teaserCloseBtn.type = 'button';
teaserCloseBtn.textContent = '×';
const teaserTextSpan = document.createElement('span');
// 文本语义:teaserText 使用 textContent 注入,避免宿主传入未信任内容时构成 XSS
teaserTextSpan.textContent = config.teaserText || t('teaser_text');
teaserEl.appendChild(teaserCloseBtn);
teaserEl.appendChild(teaserTextSpan);
// === 缩放拖拽手柄(右下角) === // === 缩放拖拽手柄(右下角) ===
const resizeHandle = document.createElement('div'); const resizeHandle = document.createElement('div');
@ -460,7 +454,6 @@ export function createChatWindow(config: ResolvedConfig): {
window: windowEl, window: windowEl,
messagesContainer, messagesContainer,
inputEl, inputEl,
sendBtn,
clearBtn, clearBtn,
categorySelect, categorySelect,
historyPanel, historyPanel,
@ -537,9 +530,9 @@ export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement, onDragE
/** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */ /** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */
const LAUNCHER_MIN_BOTTOM = 16; const LAUNCHER_MIN_BOTTOM = 16;
/** Launcher 拖拽:计算当前视口下最大 bottom 值 */
function launcherMaxBottom(): number {
return window.innerHeight - 76; // 按钮高度 60 + 16 间距
/** Launcher 拖拽:计算当前视口下最大 bottom 值(按按钮实际高度动态计算,适配任意尺寸单位) */
function launcherMaxBottom(launcherEl: HTMLElement): number {
return window.innerHeight - (launcherEl.getBoundingClientRect().height || 60) - LAUNCHER_MIN_BOTTOM;
} }
/** /**
@ -611,7 +604,7 @@ export function enableLauncherDrag(
if (hasMoved) { if (hasMoved) {
e.preventDefault(); // 阻止触摸滚动 e.preventDefault(); // 阻止触摸滚动
const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom()));
const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom(launcherEl)));
launcherEl.style.bottom = `${newBottom}px`; launcherEl.style.bottom = `${newBottom}px`;
} }
} }
@ -624,7 +617,7 @@ export function enableLauncherDrag(
// 隐藏拖拽态(保留当前 bottom) // 隐藏拖拽态(保留当前 bottom)
const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom(); const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom();
const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom()));
const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom(launcherEl)));
// 判断吸附到哪一侧(取离哪边更近) // 判断吸附到哪一侧(取离哪边更近)
const rect = launcherEl.getBoundingClientRect(); const rect = launcherEl.getBoundingClientRect();
@ -812,278 +805,146 @@ export function enableResize(
// ==================== 消息渲染 ==================== // ==================== 消息渲染 ====================
/** 渲染用户消息气泡 */ /** 渲染用户消息气泡 */
/** 创建一个 t-chat-item 消息元素(TDesign Chat 消息气泡,实际标签为 t-chat-item) */
function createChatItemMsg(role: 'user' | 'assistant', content: unknown[], timestamp: number, msgId?: string): HTMLElement {
const msg = document.createElement('t-chat-item');
// omi 组件约定:复杂类型(Array/Object)用 property 赋值;简单类型(String/Boolean)用 setAttribute
msg.setAttribute('role', role);
msg.setAttribute('datetime', formatTime(timestamp));
if (msgId) msg.setAttribute('id', msgId);
(msg as unknown as { content: unknown[] }).content = content;
if (role === 'user') {
msg.setAttribute('placement', 'right');
msg.setAttribute('variant', 'base');
} else {
msg.setAttribute('placement', 'left');
msg.setAttribute('variant', 'text');
}
return msg;
}
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement { export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--user'; wrapper.className = 'csk-msg csk-msg--user';
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--user';
avatar.innerHTML = USER_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
wrapper.appendChild(createChatItemMsg('user', [{ type: 'text', data: text }], timestamp));
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.textContent = text;
container.appendChild(wrapper);
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
return wrapper;
}
content.appendChild(bubble);
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
/** 渲染错误气泡(发送失败提示,用 t-chat-item 的 error 状态承载) */
export function renderErrorBubble(container: HTMLElement, errMsg: string, timestamp: number): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
const msg = createChatItemMsg('assistant', [{ type: 'text', data: `${errMsg}` }], timestamp);
msg.setAttribute('status', 'error');
wrapper.appendChild(msg);
container.appendChild(wrapper); container.appendChild(wrapper);
return wrapper; return wrapper;
} }
/** 渲染 AI 消息气泡(支持 Markdown) */
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string, msgId?: string): HTMLElement {
/** 渲染 AI 消息气泡(Markdown 由 t-chat-item 内置 cherry-markdown 渲染) */
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, msgId?: string, feedback?: 'up' | 'down'): HTMLElement {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai'; wrapper.className = 'csk-msg csk-msg--ai';
if (msgId) wrapper.dataset.cskMsgId = msgId; if (msgId) wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
avatar.innerHTML = BOT_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
// 支持 Markdown 渲染,传入渲染函数则使用,否则纯文本
if (renderMd) {
bubble.innerHTML = renderMd(text);
} else {
bubble.textContent = text;
}
const msg = createChatItemMsg('assistant', [{ type: 'markdown', data: text }], timestamp, msgId);
msg.setAttribute('status', 'complete');
// 操作栏投射到 t-chat-item 的 actionbar 插槽
msg.appendChild(createChatAction(wrapper, text, msgId, feedback));
wrapper.appendChild(msg);
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
content.appendChild(bubble);
// 元信息行:时间戳居左,操作按钮居右
const meta = document.createElement('div');
meta.className = 'csk-msg__meta';
meta.appendChild(time);
meta.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(meta);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper); container.appendChild(wrapper);
// 代码块复制按钮
enhanceCodeBlocks(bubble);
return wrapper; return wrapper;
} }
/** 创建空的 AI 气泡(流式追加用) */ /** 创建空的 AI 气泡(流式追加用) */
export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } { export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai csk-msg--streaming';
wrapper.className = 'csk-msg csk-msg--ai';
if (msgId) wrapper.dataset.cskMsgId = msgId; if (msgId) wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
avatar.innerHTML = BOT_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.innerHTML = '';
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
content.appendChild(bubble);
// 元信息行:时间戳居左,操作按钮居右
const meta = document.createElement('div');
meta.className = 'csk-msg__meta';
meta.appendChild(time);
meta.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(meta);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
const msg = createChatItemMsg('assistant', [], timestamp, msgId);
msg.setAttribute('status', 'streaming');
// 操作栏投射到 t-chat-item 的 actionbar 插槽(流式期间 copyText 为空,结束由 finalizeAIBubble 补全)
msg.appendChild(createChatAction(wrapper, '', msgId));
wrapper.appendChild(msg);
container.appendChild(wrapper); container.appendChild(wrapper);
return { wrapper, bubble };
return { wrapper, bubble: msg };
} }
// ==================== AI 消息操作条 + 代码块复制 ==================== // ==================== AI 消息操作条 + 代码块复制 ====================
/** 复制图标 */
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
/** 重试图标 */
const RETRY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`;
/** 构建 AI 消息操作条(复制 / 重试 / 反馈) */
function buildAIActions(bubble: HTMLElement, wrapper: HTMLElement): HTMLElement {
const bar = document.createElement('div');
bar.className = 'csk-msg__actions';
// 复制按钮
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'csk-action-btn';
copyBtn.setAttribute('title', t('copy'));
copyBtn.setAttribute('aria-label', t('copy'));
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const text = bubble.textContent || '';
await copyToClipboard(text, copyBtn);
});
// 重试按钮
const retryBtn = document.createElement('button');
retryBtn.type = 'button';
retryBtn.className = 'csk-action-btn';
retryBtn.setAttribute('title', t('retry'));
retryBtn.setAttribute('aria-label', t('retry'));
retryBtn.innerHTML = RETRY_ICON;
retryBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:retry', {
detail: { msgId: wrapper.dataset.cskMsgId || '' }
}));
}
});
// 反馈按钮(👍 / 👎)
const THUMB_UP = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`;
const THUMB_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>`;
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'csk-feedback-btn';
upBtn.setAttribute('title', t('feedback_up'));
upBtn.setAttribute('aria-label', t('feedback_up'));
upBtn.innerHTML = THUMB_UP;
upBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'up' }
}));
}
});
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'csk-feedback-btn';
downBtn.setAttribute('title', t('feedback_down'));
downBtn.setAttribute('aria-label', t('feedback_down'));
downBtn.innerHTML = THUMB_DOWN;
downBtn.addEventListener('click', (e) => {
e.stopPropagation();
/**
* AI t-chat-action t-chat-item actionbar
* /// TDesign handleAction SDK
*/
function createChatAction(wrapper: HTMLElement, text: string, msgId?: string, feedback?: 'up' | 'down'): HTMLElement {
const actionEl = document.createElement('t-chat-action');
actionEl.setAttribute('slot', 'actionbar');
const el = actionEl as unknown as {
actionBar: string[];
handleAction: (action: string, data: { event?: Event; active?: boolean }) => void;
tooltipProps: Record<string, unknown>;
};
el.actionBar = ['replay', 'copy', 'good', 'bad'];
// 浮层 portal 到 body:zIndex 越过弹窗(9999)防遮挡,overlayClassName 加 SDK 私有类用于补样式
el.tooltipProps = { overlayClassName: 'csk-tooltip', zIndex: 10000 };
// copyText/comment 为 String 简单类型,须用 setAttribute(property 赋值在 omi 下静默失效)
actionEl.setAttribute('copyText', text);
actionEl.setAttribute('comment', feedback === 'up' ? 'good' : feedback === 'down' ? 'bad' : '');
el.handleAction = (action, data) => {
const root = wrapper.closest('.csk-window'); const root = wrapper.closest('.csk-window');
if (root) {
if (!root) return;
if (action === 'replay') {
root.dispatchEvent(new CustomEvent('csk:retry', { detail: { msgId: msgId || '' } }));
} else if (action === 'good' || action === 'bad') {
root.dispatchEvent(new CustomEvent('csk:feedback', { root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'down' }
detail: { msgId: msgId || '', value: action === 'good' ? 'up' : 'down', active: !!data?.active }
})); }));
} }
});
bar.appendChild(copyBtn);
bar.appendChild(retryBtn);
bar.appendChild(upBtn);
bar.appendChild(downBtn);
return bar;
// copy 由 t-chat-action 内部处理(用 copyText 调 navigator.clipboard),无需分发
};
return actionEl;
} }
/** /**
* chat.ts
* @param wrapper wrapper
* @param value 'up' | 'down' | undefined
* t-chat-action comment=''
* t-chat-action comment
*
*/ */
export function updateFeedbackUI(wrapper: HTMLElement, value: 'up' | 'down' | undefined): void {
const btns = wrapper.querySelectorAll('.csk-feedback-btn');
if (btns.length < 2) return;
btns.forEach((btn) => btn.classList.remove('csk-feedback-btn--active', 'down'));
if (value === 'up' && btns[0]) btns[0].classList.add('csk-feedback-btn--active');
if (value === 'down' && btns[1]) btns[1].classList.add('csk-feedback-btn--active', 'down');
export function resetChatActionFeedback(wrapper: HTMLElement): void {
const msg = wrapper.querySelector('t-chat-item');
const actionEl = wrapper.querySelector('t-chat-action');
if (!msg || !actionEl) return;
const text = actionEl.getAttribute('copyText') || '';
const msgId = wrapper.dataset.cskMsgId;
actionEl.remove();
msg.appendChild(createChatAction(wrapper, text, msgId, undefined));
} }
/** 复制文本到剪贴板,带"已复制"反馈 */
async function copyToClipboard(text: string, btn: HTMLElement): Promise<void> {
const original = btn.innerHTML;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// 降级方案:execCommand
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
btn.classList.add('csk-action-btn--done');
setTimeout(() => {
btn.innerHTML = original;
btn.classList.remove('csk-action-btn--done');
}, 1500);
} catch {
// 复制失败静默处理
}
}
/** 为气泡内所有代码块添加复制按钮 */
function enhanceCodeBlocks(bubble: HTMLElement): void {
const blocks = bubble.querySelectorAll('.csk-md-code-block');
blocks.forEach((pre) => {
if (pre.querySelector('.csk-md-code-copy')) return;
const codeEl = pre.querySelector('code');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-md-code-copy';
btn.setAttribute('title', t('copy'));
btn.setAttribute('aria-label', t('copy'));
btn.innerHTML = COPY_ICON;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await copyToClipboard(codeEl?.textContent || '', btn);
});
(pre as HTMLElement).appendChild(btn);
});
}
/** 流式打字光标:追加到气泡末尾(每次 chunk 后调用) */
export function appendStreamingCaret(bubble: HTMLElement): void {
if (bubble.querySelector('.csk-caret')) return;
const caret = document.createElement('span');
caret.className = 'csk-caret';
caret.setAttribute('aria-hidden', 'true');
bubble.appendChild(caret);
}
/** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */
/** 标记 AI 气泡流式结束:切换 status 为 complete,并同步复制内容为最终 markdown 原文 */
export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void { export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void {
wrapper.classList.remove('csk-msg--streaming');
const caret = bubble.querySelector('.csk-caret');
if (caret) caret.remove();
enhanceCodeBlocks(bubble);
bubble.setAttribute('status', 'complete');
// 流式期间 copyText 为空,结束后同步为最终原文(t-chat-action 的 copyText 支持运行时更新)
const contentArr = (bubble as unknown as { content?: Array<{ data?: string }> }).content;
const text = (contentArr?.map(c => c.data || '').join('') || '');
const actionEl = wrapper.querySelector('t-chat-action');
if (actionEl) actionEl.setAttribute('copyText', text);
} }
// ==================== P1: RAG 引用来源渲染 ==================== // ==================== P1: RAG 引用来源渲染 ====================
/** 渲染 RAG 引用来源卡片 */
/**
* RAG button +
*
*/
export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void { export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void {
// 移除已有的来源卡片 // 移除已有的来源卡片
const existing = wrapper.querySelector('.csk-sources'); const existing = wrapper.querySelector('.csk-sources');
@ -1094,24 +955,20 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
const sourcesEl = document.createElement('div'); const sourcesEl = document.createElement('div');
sourcesEl.className = 'csk-sources csk-sources--collapsed'; 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);
// 折叠开关:原生 button(紧凑宽度,默认折叠)
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'csk-sources__toggle';
// 点击折叠/展开
header.addEventListener('click', () => {
const updateToggleLabel = () => {
const collapsed = sourcesEl.classList.contains('csk-sources--collapsed');
toggle.textContent = `📚 ${t('source_count', { n: sources.length })} ${collapsed ? '▶' : '▼'}`;
};
updateToggleLabel();
toggle.addEventListener('click', (e) => {
e.stopPropagation();
sourcesEl.classList.toggle('csk-sources--collapsed'); sourcesEl.classList.toggle('csk-sources--collapsed');
updateToggleLabel();
}); });
// 内容 // 内容
@ -1146,13 +1003,13 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
body.appendChild(item); body.appendChild(item);
} }
sourcesEl.appendChild(header);
sourcesEl.appendChild(toggle);
sourcesEl.appendChild(body); sourcesEl.appendChild(body);
// 插入到元信息行之后(来源卡片紧接在 meta 行下方)
const metaEl = wrapper.querySelector('.csk-msg__meta');
if (metaEl && metaEl.parentNode) {
metaEl.parentNode.insertBefore(sourcesEl, metaEl.nextSibling);
// 插入到消息气泡(t-chat-item)之后,作为来源卡片紧随气泡下方
const msgEl = wrapper.querySelector('t-chat-item');
if (msgEl && msgEl.parentNode) {
msgEl.parentNode.insertBefore(sourcesEl, msgEl.nextSibling);
} else { } else {
wrapper.appendChild(sourcesEl); wrapper.appendChild(sourcesEl);
} }
@ -1277,8 +1134,9 @@ export function renderHistoryList(
const metaParts: string[] = []; const metaParts: string[] = [];
if (item.roleName) metaParts.push(item.roleName); if (item.roleName) metaParts.push(item.roleName);
if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`); if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`);
if (item.lastMessageTime) metaParts.push(item.lastMessageTime);
else if (item.createdAt) metaParts.push(item.createdAt);
const timeStr = item.lastMessageTime || item.createdAt;
const timeText = formatHistoryTime(timeStr);
if (timeText) metaParts.push(timeText);
metaEl.textContent = metaParts.join(' · '); metaEl.textContent = metaParts.join(' · ');
info.appendChild(idEl); info.appendChild(idEl);
@ -1330,9 +1188,7 @@ export function scrollToBottom(container: HTMLElement): void {
// ==================== 离线提示横幅 ==================== // ==================== 离线提示横幅 ====================
let offlineBannerEl: HTMLElement | null = null;
/** 在消息区顶部展示离线提示横幅 */
/** 在消息区顶部展示离线提示横幅(原生 div,warning 样式) */
export function showOfflineBanner(messagesContainer: HTMLElement): void { export function showOfflineBanner(messagesContainer: HTMLElement): void {
if (document.getElementById('csk-offline-banner')) return; if (document.getElementById('csk-offline-banner')) return;
const banner = document.createElement('div'); const banner = document.createElement('div');
@ -1340,14 +1196,12 @@ export function showOfflineBanner(messagesContainer: HTMLElement): void {
banner.className = 'csk-offline-banner'; banner.className = 'csk-offline-banner';
banner.textContent = t('error_offline'); banner.textContent = t('error_offline');
messagesContainer.insertBefore(banner, messagesContainer.firstChild); messagesContainer.insertBefore(banner, messagesContainer.firstChild);
offlineBannerEl = banner;
} }
/** 隐藏离线提示横幅 */ /** 隐藏离线提示横幅 */
export function hideOfflineBanner(): void { export function hideOfflineBanner(): void {
const el = document.getElementById('csk-offline-banner'); const el = document.getElementById('csk-offline-banner');
if (el) el.remove(); if (el) el.remove();
offlineBannerEl = null;
} }
// ==================== 建议问题(suggest-message-list) ==================== // ==================== 建议问题(suggest-message-list) ====================
@ -1375,36 +1229,31 @@ export function renderSuggestions(
list.className = 'csk-suggestions__list'; list.className = 'csk-suggestions__list';
for (const text of suggestions) { for (const text of suggestions) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-suggestion-item';
chip.textContent = text;
chip.addEventListener('click', (e) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-suggestions__item';
btn.textContent = text;
btn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
onClick(text); onClick(text);
}); });
list.appendChild(chip);
list.appendChild(btn);
} }
section.appendChild(list); section.appendChild(list);
// 插入到操作栏和来源卡片之间(在 content 内末尾)
const content = wrapper.querySelector('.csk-msg__content');
if (content) {
content.appendChild(section);
} else {
wrapper.appendChild(section);
}
// 直接追加到 wrapper 末尾(wrapper 内即 t-chat-item,无 .csk-msg__content 中间层)
wrapper.appendChild(section);
} }
/** /**
* wrapper * wrapper
*/ */
export function removeSuggestions(wrapper: HTMLElement): void {
function removeSuggestions(wrapper: HTMLElement): void {
const existing = wrapper.querySelector('.csk-suggestions'); const existing = wrapper.querySelector('.csk-suggestions');
if (existing) existing.remove(); if (existing) existing.remove();
} }
// ==================== 反馈原因选择面板 ====================
// ==================== 确认弹窗 + 反馈原因选择 ====================
/** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */ /** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */
const REASON_OPTIONS: { key: string; labelKey: string }[] = [ const REASON_OPTIONS: { key: string; labelKey: string }[] = [
@ -1415,13 +1264,88 @@ const REASON_OPTIONS: { key: string; labelKey: string }[] = [
]; ];
/** /**
*
* onConfirm(reason, comment) undefined
* confirm()
* @returns true/ false
*/
export function confirmWithDialog(message: string): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const overlay = document.createElement('div');
overlay.className = 'csk-confirm-overlay';
const dialog = document.createElement('div');
dialog.className = 'csk-confirm';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const header = document.createElement('div');
header.className = 'csk-confirm__header';
header.textContent = t('confirm');
dialog.appendChild(header);
const body = document.createElement('div');
body.className = 'csk-confirm__body';
body.textContent = message;
dialog.appendChild(body);
const footer = document.createElement('div');
footer.className = 'csk-confirm__footer';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'csk-btn csk-btn--default';
cancelBtn.textContent = t('cancel');
const confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className = 'csk-btn csk-btn--primary';
confirmBtn.textContent = t('confirm');
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
dialog.appendChild(footer);
overlay.appendChild(dialog);
function cleanup(): void {
document.removeEventListener('keydown', onKeydown, true);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
function finish(val: boolean): void {
if (settled) return;
settled = true;
cleanup();
resolve(val);
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
e.stopPropagation();
finish(false);
}
}
cancelBtn.addEventListener('click', () => finish(false));
confirmBtn.addEventListener('click', () => finish(true));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) finish(false);
});
// 捕获阶段监听 ESC,阻止冒泡到 index.ts 的窗口 ESC 关闭逻辑,避免同时关闭聊天窗口
document.addEventListener('keydown', onKeydown, true);
document.body.appendChild(overlay);
confirmBtn.focus();
});
}
/**
* button
*/ */
export function showFeedbackReasonPanel( export function showFeedbackReasonPanel(
wrapper: HTMLElement, wrapper: HTMLElement,
msgId: string,
_msgId: string,
onConfirm: (reason: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other', comment?: string) => void, onConfirm: (reason: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other', comment?: string) => void,
onCancel?: () => void,
): void { ): void {
// 移除已存在的原因面板 // 移除已存在的原因面板
const existing = wrapper.querySelector('.csk-feedback-reason'); const existing = wrapper.querySelector('.csk-feedback-reason');
@ -1436,7 +1360,7 @@ export function showFeedbackReasonPanel(
title.textContent = t('feedback_reason_title'); title.textContent = t('feedback_reason_title');
panel.appendChild(title); panel.appendChild(title);
// 原因选项按钮
// 原因选项按钮(原生 button,点击即确认)
const optionsRow = document.createElement('div'); const optionsRow = document.createElement('div');
optionsRow.className = 'csk-feedback-reason__options'; optionsRow.className = 'csk-feedback-reason__options';
REASON_OPTIONS.forEach((opt) => { REASON_OPTIONS.forEach((opt) => {
@ -1476,7 +1400,7 @@ export function showFeedbackReasonPanel(
commentRow.appendChild(submitBtn); commentRow.appendChild(submitBtn);
panel.appendChild(commentRow); panel.appendChild(commentRow);
// 关闭按钮
// 关闭按钮(右上角,轻量原生 button 承载 X 图标)
const closeBtn = document.createElement('button'); const closeBtn = document.createElement('button');
closeBtn.type = 'button'; closeBtn.type = 'button';
closeBtn.className = 'csk-feedback-reason__close'; closeBtn.className = 'csk-feedback-reason__close';
@ -1485,6 +1409,7 @@ export function showFeedbackReasonPanel(
closeBtn.addEventListener('click', (e) => { closeBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
panel.remove(); panel.remove();
if (onCancel) onCancel();
}); });
panel.appendChild(closeBtn); panel.appendChild(closeBtn);

39
client/src/i18n.ts

@ -6,9 +6,10 @@
const dictionaries: Record<string, Record<string, string>> = { const dictionaries: Record<string, Record<string, string>> = {
'zh-CN': { 'zh-CN': {
// 头部 // 头部
title: 'AI 智能助手',
minimize: '最小化', minimize: '最小化',
close: '关闭', close: '关闭',
confirm: '确认',
cancel: '取消',
status_online: '在线', status_online: '在线',
role_selector_label: '服务角色', role_selector_label: '服务角色',
@ -18,28 +19,17 @@ const dictionaries: Record<string, Record<string, string>> = {
// 输入区 // 输入区
placeholder: '输入您的问题...', placeholder: '输入您的问题...',
send: '发送',
stop: '停止生成',
new_message: '新消息', new_message: '新消息',
// 消息操作
copy: '复制',
copied: '已复制',
retry: '重新生成',
retrying: '重新生成中...',
// 消息 // 消息
loading: '正在思考...', loading: '正在思考...',
stream_interrupted: '回复被中断', stream_interrupted: '回复被中断',
stream_unstable: '网络不稳定,内容可能不完整', stream_unstable: '网络不稳定,内容可能不完整',
// 知识库 // 知识库
category_placeholder: '选择知识库分类',
category_all: '全部分类', category_all: '全部分类',
category_load_error: '加载分类失败', category_load_error: '加载分类失败',
source_title: '参考来源',
source_count: '{n} 条参考来源', source_count: '{n} 条参考来源',
source_loading: '加载来源中...',
// 新对话 // 新对话
clear: '新对话', clear: '新对话',
@ -58,8 +48,6 @@ const dictionaries: Record<string, Record<string, string>> = {
history_group_earlier: '更早', history_group_earlier: '更早',
// 消息反馈 // 消息反馈
feedback_up: '有帮助',
feedback_down: '没帮助',
feedback_reason_title: '请选择不满意的原因:', feedback_reason_title: '请选择不满意的原因:',
feedback_reason_inaccurate: '信息错误', feedback_reason_inaccurate: '信息错误',
feedback_reason_irrelevant: '不相关', feedback_reason_irrelevant: '不相关',
@ -91,9 +79,10 @@ const dictionaries: Record<string, Record<string, string>> = {
'en': { 'en': {
// Header // Header
title: 'AI Assistant',
minimize: 'Minimize', minimize: 'Minimize',
close: 'Close', close: 'Close',
confirm: 'Confirm',
cancel: 'Cancel',
status_online: 'Online', status_online: 'Online',
role_selector_label: 'Service', role_selector_label: 'Service',
@ -103,28 +92,17 @@ const dictionaries: Record<string, Record<string, string>> = {
// Input // Input
placeholder: 'Type your question...', placeholder: 'Type your question...',
send: 'Send',
stop: 'Stop generating',
new_message: 'New message', new_message: 'New message',
// Message actions
copy: 'Copy',
copied: 'Copied',
retry: 'Regenerate',
retrying: 'Regenerating...',
// Messages // Messages
loading: 'Thinking...', loading: 'Thinking...',
stream_interrupted: 'Response interrupted', stream_interrupted: 'Response interrupted',
stream_unstable: 'Network unstable, content may be incomplete', stream_unstable: 'Network unstable, content may be incomplete',
// Knowledge base // Knowledge base
category_placeholder: 'Select category',
category_all: 'All categories', category_all: 'All categories',
category_load_error: 'Failed to load categories', category_load_error: 'Failed to load categories',
source_title: 'Sources',
source_count: '{n} source(s)', source_count: '{n} source(s)',
source_loading: 'Loading sources...',
// Clear/New chat // Clear/New chat
clear: 'New chat', clear: 'New chat',
@ -143,8 +121,6 @@ const dictionaries: Record<string, Record<string, string>> = {
history_group_earlier: 'Earlier', history_group_earlier: 'Earlier',
// Feedback // Feedback
feedback_up: 'Helpful',
feedback_down: 'Not helpful',
feedback_reason_title: 'Please tell us why:', feedback_reason_title: 'Please tell us why:',
feedback_reason_inaccurate: 'Inaccurate', feedback_reason_inaccurate: 'Inaccurate',
feedback_reason_irrelevant: 'Irrelevant', feedback_reason_irrelevant: 'Irrelevant',
@ -213,10 +189,3 @@ export function t(key: string, params?: Record<string, string | number>): string
return text; return text;
} }
/**
*
*/
function getLocale(): string {
return currentLocale;
}

5
client/src/images.d.ts

@ -1,5 +0,0 @@
/** 图片导入类型声明(Rollup @rollup/plugin-image 会将 PNG 转为 base64 data URL) */
declare module '*.png' {
const src: string;
export default src;
}

81
client/src/index.ts

@ -7,12 +7,17 @@
* userId accountId ID * userId accountId ID
* chatId /conversation/list * chatId /conversation/list
*/ */
// TDesign Web Components:聊天组件注册(side-effect import,构建时打进 IIFE)
// 注意:消息气泡组件 ChatMessage 实际注册的标签是 t-chat-item(非 t-chat-message)
// 说明:chat-action / chat-loading / style 均已被 chat-message 内部 import,无需重复引入
import 'tdesign-web-components/lib/chat-message/index.js';
import 'tdesign-web-components/lib/chat-sender/index.js';
import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types'; import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types';
import { parseConfig } from './config'; import { parseConfig } from './config';
import { setDebug, logger, setErrorCallback } from './logger'; import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api'; import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api';
import { injectStyles, removeStyles } from './styles'; import { injectStyles, removeStyles } from './styles';
import { preloadMarked, resetMarked } from './markdown';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom'; import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom';
import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat'; import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat';
import { clearMessages } from './storage'; import { clearMessages } from './storage';
@ -25,12 +30,45 @@ export type {
ChatMessage, ChatMessage,
RagSource, RagSource,
CategoryNode, CategoryNode,
CategoryItem,
ConversationSummary,
ConversationDetail,
ChatbotSDKInstance, ChatbotSDKInstance,
} from './types'; } from './types';
// ==================== 剪贴板降级 polyfill ====================
// t-chat-action 的复制动作内部直接调用 navigator.clipboard.writeText,
// 在 HTTP(非安全上下文)或旧浏览器下 navigator.clipboard 为 undefined,
// 点击复制会同步抛 TypeError 且无 execCommand 降级。此处兜底:
// 当 navigator.clipboard 缺失时,注入带 execCommand('copy') 降级的实现,
// 保证复制功能在非 HTTPS 环境仍可用。
if (typeof navigator !== 'undefined' && !navigator.clipboard) {
const fallbackCopy = (text: string): Promise<void> => {
return new Promise<void>((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
document.body.removeChild(ta);
ok ? resolve() : reject(new Error('execCommand copy 失败'));
});
};
try {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: fallbackCopy },
configurable: true,
});
} catch {
// 某些宿主环境 navigator 只读,忽略 polyfill 失败
}
}
// ==================== 单例状态 ==================== // ==================== 单例状态 ====================
let config: ResolvedConfig | null = null; let config: ResolvedConfig | null = null;
@ -38,10 +76,9 @@ let isInitialized = false;
let launcherEl: HTMLElement | null = null; let launcherEl: HTMLElement | null = null;
let windowEl: HTMLElement | null = null; let windowEl: HTMLElement | null = null;
let messagesContainer: HTMLElement | null = null; let messagesContainer: HTMLElement | null = null;
let inputEl: HTMLTextAreaElement | null = null;
let sendBtn: HTMLElement | null = null;
let inputEl: HTMLElement | null = null;
let clearBtn: HTMLElement | null = null; let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null;
let categorySelect: HTMLElement | null = null;
let historyPanel: HTMLElement | null = null; let historyPanel: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null; let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null; let hideLoadingFn: (() => void) | null = null;
@ -94,9 +131,6 @@ function init(rawConfig: SDKConfig): void {
// 5. 注入样式 // 5. 注入样式
injectStyles(config); injectStyles(config);
// 5.1 预加载 marked(外部 Markdown 渲染库,异步不阻塞 UI)
preloadMarked();
// 6. 创建悬浮按钮 // 6. 创建悬浮按钮
launcherEl = createLauncher(config, toggle); launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl); document.body.appendChild(launcherEl);
@ -133,7 +167,6 @@ function init(rawConfig: SDKConfig): void {
windowEl = dom.window; windowEl = dom.window;
messagesContainer = dom.messagesContainer; messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl; inputEl = dom.inputEl;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn; clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect; categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel; historyPanel = dom.historyPanel;
@ -172,7 +205,6 @@ function init(rawConfig: SDKConfig): void {
initChat(config, { initChat(config, {
messagesContainer, messagesContainer,
inputEl, inputEl,
sendBtn,
clearBtn, clearBtn,
categorySelect, categorySelect,
historyPanel, historyPanel,
@ -207,7 +239,7 @@ function init(rawConfig: SDKConfig): void {
// 15. 监听消息反馈事件(👍 / 👎) // 15. 监听消息反馈事件(👍 / 👎)
windowEl.addEventListener('csk:feedback', ((e: CustomEvent) => { windowEl.addEventListener('csk:feedback', ((e: CustomEvent) => {
handleFeedback(e.detail.msgId, e.detail.value);
handleFeedback(e.detail.msgId, e.detail.value, !!e.detail.active);
}) as EventListener); }) as EventListener);
// 16. 新消息到达通知(用于未读徽章 + 提示音 + 桌面通知) // 16. 新消息到达通知(用于未读徽章 + 提示音 + 桌面通知)
@ -257,6 +289,7 @@ function init(rawConfig: SDKConfig): void {
if (config.disclaimer === undefined) { if (config.disclaimer === undefined) {
fetchSystemConfig('disclaimer').then(html => { fetchSystemConfig('disclaimer').then(html => {
if (html && disclaimerEl) { if (html && disclaimerEl) {
// 后端 system_config 表中的声明为「受信 HTML」(仅 admin 可配置),直接注入属设计内行为
disclaimerEl.innerHTML = html; disclaimerEl.innerHTML = html;
disclaimerEl.style.display = ''; disclaimerEl.style.display = '';
} }
@ -411,7 +444,6 @@ function destroy(): void {
isInitialized = false; isInitialized = false;
messagesContainer = null; messagesContainer = null;
inputEl = null; inputEl = null;
sendBtn = null;
clearBtn = null; clearBtn = null;
categorySelect = null; categorySelect = null;
historyPanel = null; historyPanel = null;
@ -421,17 +453,34 @@ function destroy(): void {
audioCtx = null; audioCtx = null;
disclaimerEl = null; disclaimerEl = null;
clearApiConfig(); clearApiConfig();
resetMarked(); // 重置 marked 加载状态,允许下次 init 重试
logger.lifecycleDestroy(oldIntegrateId || ''); logger.lifecycleDestroy(oldIntegrateId || '');
} }
// 聚焦输入框:t-chat-sender 是 omi 自定义元素,宿主元素无 tabindex,
// 直接调用原生 HTMLElement.focus() 只会聚焦宿主、光标进不了内部 textarea。
// 主路径调用组件实例暴露的 focus() 方法(聚焦内部 t-textarea → 原生 textarea);
// 若组件内部 ref 尚未就绪导致 focus() 静默无效,再穿透 shadow DOM 兜底聚焦。
function focusInput(): void {
if (!inputEl) return;
// 主路径:调用组件 focus()(omi 在 constructor 用 own property 定义,非原生 focus)
(inputEl as unknown as { focus?: (opts?: FocusOptions) => void }).focus?.();
// 兜底:焦点未真正落到内部 textarea 时,穿透两层 shadow DOM 直接聚焦
const textarea = inputEl.shadowRoot
?.querySelector('t-textarea')
?.shadowRoot
?.querySelector('textarea') as HTMLTextAreaElement | null | undefined;
if (textarea && document.activeElement !== textarea) {
textarea.focus();
}
}
function open(): void { function open(): void {
if (!windowEl) return; if (!windowEl) return;
windowEl.classList.remove('csk-window--hidden'); windowEl.classList.remove('csk-window--hidden');
hideTeaser(); hideTeaser();
hideBadge(); hideBadge();
setTimeout(() => { if (inputEl) inputEl.focus(); }, 80);
setTimeout(() => { focusInput(); }, 80);
} }
function close(): void { function close(): void {

10
client/src/logger.ts

@ -38,16 +38,6 @@ export const logger = {
} }
}, },
/** 开始计时(已禁用) */
time(_label: string): void {
// 控制台日志已禁用
},
/** 结束计时(已禁用) */
timeEnd(_label: string, _prefix?: string): number {
return 0;
},
/** 生命周期日志:init */ /** 生命周期日志:init */
lifecycleInit(_integrateId: string, _requestDomain: string): void { lifecycleInit(_integrateId: string, _requestDomain: string): void {
// 控制台日志已禁用 // 控制台日志已禁用

242
client/src/markdown.ts

@ -1,242 +0,0 @@
/**
* Markdown - marked
*
* marked v15.0.12 SDK 宿
*
*
* XSS
* 1. Renderer / SDK
* 2. / URL http/https
* 3. raw HTML
* 4. 使 escapeHtml
*/
import { escapeHtml } from './utils';
/** marked 是否已加载并完成配置 */
let markedReady = false;
/** 加载 Promise,用于等待就绪 */
let markedLoadPromise: Promise<boolean> | null = null;
/** 已配置安全的 marked 实例闭包引用(不反复读取 window.marked,防止篡改) */
let _markedInstance: any = null;
// ==================== 工具函数 ====================
/** 从当前页面的 chatbot-sdk 脚本 src 推断 SDK 基础路径 */
function getSdkBasePath(): string {
if (typeof document === 'undefined') return '/sdk/';
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
const src = scripts[i].src;
if (src && /\/chatbot-sdk(\.min)?\.js([?#].*)?$/.test(src)) {
return src.replace(/chatbot-sdk(\.min)?\.js.*$/, '');
}
}
return '/sdk/';
}
/** 校验 URL 安全性:仅允许 http/https 且不含 HTML 属性破坏字符 */
function isSafeUrl(url: string): boolean {
return /^https?:\/\/[^\s"'<>]*$/i.test(url.trim());
}
// ==================== marked 配置 ====================
/**
* marked
* - 使 csk-md-* styles.ts
* - / http/https
* - raw HTML
*/
function setupMarked(m: any): void {
// 保存到闭包变量,后续 renderMarkdown 只使用此引用,防止 window.marked 被篡改
_markedInstance = m;
// 全局选项:聊天场景保留换行,启用 GFM
m.setOptions({ breaks: true, gfm: true });
m.use({
renderer: {
/** 标题 → <hN class="csk-md-hN"> */
heading(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<h${token.depth} class="csk-md-h${token.depth}">${text}</h${token.depth}>\n`;
},
/** 段落 → <p class="csk-md-p"> */
paragraph(this: any, token: any): string {
const text = this.parser.parseInline(token.tokens);
return `<p class="csk-md-p">${text}</p>\n`;
},
/** 代码块 → <pre class="csk-md-code-block"><code> */
code(token: any): string {
const langClass = token.lang ? ` class="language-${escapeHtml(token.lang)}"` : '';
return `<pre class="csk-md-code-block"><code${langClass}>${escapeHtml(token.text)}</code></pre>\n`;
},
/** 行内代码 → <code class="csk-md-inline-code"> */
codespan(token: any): string {
return `<code class="csk-md-inline-code">${escapeHtml(token.text)}</code>`;
},
/** 斜体(补充 csk-md-* 类名体系) */
em(this: any, token: any): string {
return `<em class="csk-md-em">${this.parser.parseInline(token.tokens)}</em>`;
},
/** 粗体(补充 csk-md-* 类名体系) */
strong(this: any, token: any): string {
return `<strong class="csk-md-strong">${this.parser.parseInline(token.tokens)}</strong>`;
},
/** 删除线(补充 csk-md-* 类名体系) */
del(this: any, token: any): string {
return `<del class="csk-md-del">${this.parser.parseInline(token.tokens)}</del>`;
},
/** 引用块 → <blockquote class="csk-md-blockquote"> */
blockquote(this: any, token: any): string {
const body = this.parser.parse(token.tokens);
return `<blockquote class="csk-md-blockquote">\n${body}</blockquote>\n`;
},
/** 列表 → <ul class="csk-md-ul"> / <ol class="csk-md-ol"> */
list(this: any, token: any): string {
const tag = token.ordered ? 'ol' : 'ul';
const cls = token.ordered ? 'csk-md-ol' : 'csk-md-ul';
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : '';
// 列表项须用 this.listitem(item) 渲染,不可 this.parser.parse([item]):
// marked v15 的 Parser.parse 按 token.type 做 switch 分发,无 "list_item" 分支,直接 parse 会抛异常
const body = token.items.map((item: any) => this.listitem(item)).join('');
return `<${tag} class="${cls}"${startAttr}>\n${body}</${tag}>\n`;
},
/** 列表项(支持任务列表 checkbox) */
listitem(this: any, token: any): string {
let checkbox = '';
if (token.task) {
checkbox = token.checked
? '<input type="checkbox" checked disabled> '
: '<input type="checkbox" disabled> ';
}
const body = this.parser.parse(token.tokens);
return `<li>${checkbox}${body}</li>\n`;
},
/** 表格(包裹在可滚动容器中) */
table(this: any, token: any): string {
const headerCells = token.header.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<th${align}>${this.parser.parseInline(cell.tokens)}</th>`;
}).join('');
const bodyRows = token.rows.map((row: any) => {
const cells = row.map((cell: any) => {
const align = cell.align ? ` class="csk-md-align-${cell.align}"` : '';
return `<td${align}>${this.parser.parseInline(cell.tokens)}</td>`;
}).join('');
return `<tr>${cells}</tr>`;
}).join('');
return `<div class="csk-md-table-wrap"><table class="csk-md-table">\n<thead><tr>${headerCells}</tr></thead>\n<tbody>${bodyRows}</tbody>\n</table></div>\n`;
},
/** 链接 → <a class="csk-md-link">(仅允许 http/https) */
link(this: any, token: any): string {
const safeHref = isSafeUrl(token.href) ? token.href : '#';
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
const text = this.parser.parseInline(token.tokens);
return `<a class="csk-md-link" href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
},
/** 图片 → <img class="csk-md-img">(仅允许 http/https,非法降级为 alt 文本) */
image(token: any): string {
if (!isSafeUrl(token.href)) return escapeHtml(token.text);
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
return `<img class="csk-md-img" src="${token.href}" alt="${escapeHtml(token.text)}"${titleAttr}>`;
},
/** 水平线 → <hr class="csk-md-hr"> */
hr(): string {
return '<hr class="csk-md-hr">\n';
},
/** 禁止 raw HTML(关键 XSS 防线) */
html(): string {
return '';
},
},
});
}
// ==================== 公开 API ====================
/**
* marked
* SDK init
*/
export function preloadMarked(): void {
if (markedLoadPromise) return;
markedLoadPromise = new Promise<boolean>((resolve) => {
// 1. 检查是否已存在(宿主手动引入或相同域名已加载)
const existing = (window as any).marked;
if (existing && typeof existing.parse === 'function') {
setupMarked(existing);
markedReady = true;
resolve(true);
return;
}
// 2. 动态创建 <script> 加载 marked
const basePath = getSdkBasePath();
const url = basePath + 'marked.min.js';
const el = document.createElement('script');
el.src = url;
el.onload = () => {
const m = (window as any).marked;
if (m && typeof m.parse === 'function') {
setupMarked(m);
markedReady = true;
}
resolve(markedReady);
};
el.onerror = () => {
resolve(false);
};
document.head.appendChild(el);
});
}
/**
* Markdown HTML
*
* @param text Markdown
* @returns HTML
*
* marked GFM
* / <p>
*/
export function renderMarkdown(text: string): string {
if (!text || typeof text !== 'string') return '';
// marked 未就绪 → 降级纯文本
if (!markedReady || !_markedInstance) {
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
}
try {
return _markedInstance.parse(text) as string;
} catch {
// 解析异常降级
return `<p class="csk-md-p">${escapeHtml(text)}</p>`;
}
}
/** 导出复位函数,供 destroy() 调用,允许重新 init 时重试加载 marked */
export function resetMarked(): void {
markedReady = false;
_markedInstance = null;
markedLoadPromise = null;
}

39
client/src/mermaid-stub.ts

@ -0,0 +1,39 @@
/**
* mermaid stub
*
* TDesign Chat markdown-content
* `Cherry.usePlugin(CherryCodeBlockMermaidPlugin, { mermaid, ... })`
* mermaid mermaid API
* `code-block-mermaid-plugin[init]: Package mermaid or mermaidAPI not found`
* IIFE
*
* SDK mermaid 1.2MB gzip SDK marked mermaid
* stub API mermaidrender 3
* - `n.mermaidAPI` `isAsyncRenderVersion()` render.length === 3
* 退 `n` `initialize()`
* - `render` mermaid syncRender try/catch
*
*
* 宿 mermaid SDK mermaid stub
*/
const mermaidStub = {
// 顶层 mermaid:sync 分支下被赋值给 mermaidAPIRefs,需提供 initialize + render
initialize(): void {
// no-op:插件初始化占位
},
// 3 参数签名 → isAsyncRenderVersion() 判定为同步版本,走 syncRender 的 try/catch
render(_code: string, _element: unknown, _callback: unknown): void {
throw new Error('mermaid 未加载,代码块降级为普通代码展示');
},
// mermaidAPI 子对象:构造函数取值链 `n.mermaidAPI` 命中它,isAsyncRenderVersion 读其 render.length
mermaidAPI: {
initialize(): void {
// no-op
},
render(_code: string, _element: unknown, _callback: unknown): void {
throw new Error('mermaid 未加载,代码块降级为普通代码展示');
},
},
};
export default mermaidStub;

1322
client/src/styles.ts
File diff suppressed because it is too large
View File

37
client/src/types.ts

@ -46,10 +46,10 @@ export interface SDKConfig {
primaryColor?: string; primaryColor?: string;
/** 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 */ /** 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 */
launcherIcon?: string; launcherIcon?: string;
/** 悬浮按钮尺寸:数字=px(默认 60,范围 36~96),或任意 CSS 长度字符串(如 "4rem"、"5vw"、"clamp(48px,3vw,80px)") */
launcherSize?: number | string;
/** 是否显示新对话按钮,默认 true */ /** 是否显示新对话按钮,默认 true */
showClear?: boolean; showClear?: boolean;
/** 是否显示管理面板,默认 false */
showAdminPanel?: boolean;
/** 欢迎态快捷问题列表,点击即自动发送,默认空数组 */ /** 欢迎态快捷问题列表,点击即自动发送,默认空数组 */
quickReplies?: string[]; quickReplies?: string[];
/** 是否在 AI 回复后展示推荐问题(suggest-message-list),默认 true */ /** 是否在 AI 回复后展示推荐问题(suggest-message-list),默认 true */
@ -72,7 +72,7 @@ export interface SDKConfig {
streaming?: boolean; streaming?: boolean;
/** /**
* RAG true * RAG true
* 使 /chat/rag/sse
* /ai/chat/stream enableRag=true
* *
*/ */
enableRag?: boolean; enableRag?: boolean;
@ -128,10 +128,10 @@ export interface ResolvedConfig {
primaryColor: string; primaryColor: string;
/** 悬浮按钮图标 */ /** 悬浮按钮图标 */
launcherIcon: string; launcherIcon: string;
/** 悬浮按钮尺寸(number 已归一为 px,string 为 CSS 长度原文) */
launcherSize: number | string;
/** 显示新对话按钮 */ /** 显示新对话按钮 */
showClear: boolean; showClear: boolean;
/** 显示管理面板 */
showAdminPanel: boolean;
/** 欢迎态快捷问题列表 */ /** 欢迎态快捷问题列表 */
quickReplies: string[]; quickReplies: string[];
/** 是否在 AI 回复后展示推荐问题 */ /** 是否在 AI 回复后展示推荐问题 */
@ -208,33 +208,6 @@ export interface CategoryNode {
children?: CategoryNode[]; children?: CategoryNode[];
} }
/** 知识库分类平铺项 */
export interface CategoryItem {
id: string;
name: string;
parentId?: string;
}
/** 会话摘要 */
export interface ConversationSummary {
id: string;
chatId: string;
accountId?: string;
roleId?: number;
roleName?: string;
messageCount?: number;
lastMessageTime?: number;
createdAt?: number;
}
/** 会话详情 */
export interface ConversationDetail {
id: string;
chatId: string;
accountId?: string;
messages: ChatMessage[];
}
/** 本地缓存数据结构 */ /** 本地缓存数据结构 */
export interface CacheData { export interface CacheData {
messages: ChatMessage[]; messages: ChatMessage[];

33
client/src/utils.ts

@ -15,18 +15,6 @@ export function uuid(): string {
}); });
} }
/** XSS 转义 - 防止用户输入中的 HTML 注入 */
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
};
return text.replace(/[&<>"']/g, (ch) => map[ch] || ch);
}
/** 防抖函数 */ /** 防抖函数 */
export function debounce<T extends (...args: unknown[]) => void>( export function debounce<T extends (...args: unknown[]) => void>(
fn: T, fn: T,
@ -52,6 +40,27 @@ export function formatTime(timestamp: number): string {
return `${hh}:${mm}`; return `${hh}:${mm}`;
} }
/** 格式化历史会话时间:今天/昨天显示 HH:mm,同年显示 MM-DD HH:mm,跨年显示 YYYY-MM-DD HH:mm */
export function formatHistoryTime(timeStr: string | undefined): string {
if (!timeStr) return '';
const d = new Date(timeStr);
if (isNaN(d.getTime())) return timeStr; // 解析失败原样返回,避免破坏展示
const pad = (n: number) => String(n).padStart(2, '0');
const hh = pad(d.getHours());
const mm = pad(d.getMinutes());
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const ts = d.getTime();
// 今天或昨天:只显示时分
if (ts >= yesterdayStart) return `${hh}:${mm}`;
const md = `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
// 今年内:月-日 时分
if (d.getFullYear() === now.getFullYear()) return `${md} ${hh}:${mm}`;
// 跨年:完整年月日 时分
return `${d.getFullYear()}-${md} ${hh}:${mm}`;
}
/** 获取当前时间戳(毫秒) */ /** 获取当前时间戳(毫秒) */
export function now(): number { export function now(): number {
return Date.now(); return Date.now();

100
client/tests/i18n.test.ts

@ -1,100 +0,0 @@
/**
* i18n.ts
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { t, setLocale, getLocale } from '../src/i18n';
describe('setLocale / getLocale', () => {
beforeEach(() => {
setLocale('zh-CN');
});
it('默认语言为 zh-CN', () => {
expect(getLocale()).toBe('zh-CN');
});
it('切换为英文', () => {
setLocale('en');
expect(getLocale()).toBe('en');
});
it('匹配语言前缀(zh → zh-CN)', () => {
setLocale('zh');
expect(getLocale()).toBe('zh-CN');
});
it('未知语言保持默认 zh-CN', () => {
setLocale('fr');
expect(getLocale()).toBe('zh-CN');
});
});
describe('t()', () => {
beforeEach(() => {
setLocale('zh-CN');
});
it('返回中文翻译', () => {
expect(t('send')).toBe('发送');
expect(t('close')).toBe('关闭');
});
it('切换英文后返回英文翻译', () => {
setLocale('en');
expect(t('send')).toBe('Send');
expect(t('close')).toBe('Close');
});
it('未知 key 返回 key 本身', () => {
expect(t('nonexistent_key')).toBe('nonexistent_key');
});
it('支持插值参数替换', () => {
setLocale('zh-CN');
expect(t('source_count', { n: 3 })).toBe('3 条参考来源');
});
it('英文插值参数替换', () => {
setLocale('en');
expect(t('source_count', { n: 5 })).toBe('5 source(s)');
});
it('中英文字典 key 数量一致', () => {
// 验证中英文字典完整性:所有中文 key 在英文中都有对应
setLocale('zh-CN');
const zhKeys = Object.keys(getAllKeys('zh-CN'));
setLocale('en');
const enKeys = Object.keys(getAllKeys('en'));
// 每个中文 key 在英文字典中也应存在
for (const key of zhKeys) {
setLocale('en');
const translated = t(key);
expect(translated).not.toBe(key); // 不应回退到 key 本身
}
});
});
/** 辅助函数:获取指定语言的所有 key(通过遍历测试已知 key) */
function getAllKeys(_locale: string): Record<string, string> {
const knownKeys = [
'title', 'minimize', 'close', 'status_online',
'welcome_title', 'welcome_desc',
'placeholder', 'send', 'stop', 'new_message',
'copy', 'copied', 'retry',
'loading', 'stream_interrupted',
'category_all', 'source_count',
'clear', 'clear_confirm',
'history_title', 'history_empty', 'history_search',
'feedback_up', 'feedback_down',
'teaser_text', 'new_msg_announce',
'error_network', 'error_timeout', 'error_send',
'history_group_today', 'history_group_yesterday',
'history_group_week', 'history_group_earlier',
];
const result: Record<string, string> = {};
for (const key of knownKeys) {
result[key] = t(key);
}
return result;
}

86
client/tests/utils.test.ts

@ -1,86 +0,0 @@
/**
* utils.ts
*/
import { describe, it, expect, vi } from 'vitest';
import { escapeHtml, debounce, formatTime, uuid, shortUuid } from '../src/utils';
describe('escapeHtml', () => {
it('转义 HTML 特殊字符', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
);
});
it('转义所有五种危险字符', () => {
expect(escapeHtml('&<>"\'')).toBe('&amp;&lt;&gt;&quot;&#x27;');
});
it('不修改安全文本', () => {
expect(escapeHtml('Hello World 你好世界')).toBe('Hello World 你好世界');
});
it('空字符串返回空字符串', () => {
expect(escapeHtml('')).toBe('');
});
});
describe('uuid', () => {
it('生成符合 UUID v4 格式的字符串', () => {
const id = uuid();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it('两次生成不同的值', () => {
expect(uuid()).not.toBe(uuid());
});
});
describe('shortUuid', () => {
it('生成长度为 8 的字符串', () => {
expect(shortUuid()).toHaveLength(8);
});
});
describe('formatTime', () => {
it('格式化为 HH:mm', () => {
// 2026-01-15 14:30:00 UTC
const ts = new Date(2026, 0, 15, 14, 30, 0).getTime();
expect(formatTime(ts)).toBe('14:30');
});
it('补零处理', () => {
const ts = new Date(2026, 0, 1, 9, 5, 0).getTime();
expect(formatTime(ts)).toBe('09:05');
});
});
describe('debounce', () => {
it('延迟执行函数', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('连续调用只执行最后一次', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
debounced();
debounced();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});

12
frontend/cp-to-static.mjs

@ -2,7 +2,7 @@
* Vite build 后自动将产物复制到 Spring Boot 静态资源目录。 * Vite build 后自动将产物复制到 Spring Boot 静态资源目录。
* 由 npm postbuild 钩子触发。 * 由 npm postbuild 钩子触发。
*/ */
import { cpSync, existsSync, readdirSync, rmSync } from 'node:fs';
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
const DIST = 'dist'; const DIST = 'dist';
@ -25,3 +25,13 @@ if (existsSync(DIST)) {
console.error('✗ dist 目录不存在,请先执行 vite build'); console.error('✗ dist 目录不存在,请先执行 vite build');
process.exit(1); process.exit(1);
} }
// 3. 将 sdk-test 多页产物移动到 sdk/test.html(资源引用为绝对路径 /assets/...,移动后仍可解析)
const sdkTestHtml = join(TARGET, 'sdk-test.html');
if (existsSync(sdkTestHtml)) {
const sdkDir = join(TARGET, 'sdk');
if (!existsSync(sdkDir)) mkdirSync(sdkDir, { recursive: true });
cpSync(sdkTestHtml, join(sdkDir, 'test.html'));
rmSync(sdkTestHtml);
console.log('✓ 已移动 sdk-test.html → sdk/test.html');
}

1112
frontend/package-lock.json
File diff suppressed because it is too large
View File

1
frontend/package.json

@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tdesign-vue-next/chat": "^0.6.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",

12
frontend/sdk-test.html

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
</head>
<body>
<div id="sdk-test-app"></div>
<script type="module" src="/src/sdk-test/main.ts"></script>
</body>
</html>

72
frontend/src/api/chat.ts

@ -12,43 +12,57 @@ function authHeaders(): Record<string, string> {
return headers return headers
} }
/** 同步对话 */
export function chatSync(message: string, chatId: string, roleId?: string, accountId?: string): Promise<string> {
let url = `${API_BASE}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return fetch(url, { headers: authHeaders() }).then(res => res.text())
/** 对话请求可选参数 */
export interface ChatOptions {
roleId?: string
accountId?: string
enableRag?: boolean
rewriteStrategy?: string
categoryId?: string | number
categoryIds?: string[]
} }
/** RAG 同步对话 */
export function chatRagSync(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): Promise<string> {
let url = `${API_BASE}/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return fetch(url, { headers: authHeaders() }).then(res => res.text())
/**
*
* @param message
* @param chatId ID
* @param options
* @param includeEnableRag enableRag sources
*/
function buildChatQueryParams(
message: string,
chatId: string,
options?: ChatOptions,
includeEnableRag = true,
): URLSearchParams {
const params = new URLSearchParams()
params.set('message', message)
params.set('chatId', chatId)
if (options) {
if (options.roleId) params.set('roleId', options.roleId)
if (options.accountId) params.set('accountId', options.accountId)
if (includeEnableRag && options.enableRag === true) params.set('enableRag', 'true')
if (options.rewriteStrategy) params.set('rewriteStrategy', options.rewriteStrategy)
if (options.categoryId !== undefined && options.categoryId !== '') params.set('categoryId', String(options.categoryId))
if (options.categoryIds && options.categoryIds.length > 0) params.set('categoryIds', options.categoryIds.join(','))
}
return params
} }
/** 获取普通 SSE 流式对话 URL */
export function chatSSEUrl(message: string, chatId: string, roleId?: string, accountId?: string): string {
let url = `${API_BASE}/ai/assistant_app/chat/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return url
/** 同步对话:GET /ai/chat */
export function chatSync(message: string, chatId: string, options?: ChatOptions): Promise<string> {
const url = `${API_BASE}/ai/chat?${buildChatQueryParams(message, chatId, options).toString()}`
return fetch(url, { headers: authHeaders() }).then(res => res.text())
} }
/** 获取 RAG 流式对话 URL */
export function chatRagSSEUrl(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): string {
let url = `${API_BASE}/ai/assistant_app/chat/rag/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return url
/** 获取 SSE 流式对话 URL:GET /ai/chat/stream */
export function chatSSEUrl(message: string, chatId: string, options?: ChatOptions): string {
return `${API_BASE}/ai/chat/stream?${buildChatQueryParams(message, chatId, options).toString()}`
} }
/** 获取 RAG 回答命中的知识库片段 */
export function ragSources(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): Promise<ApiResponse> {
let path = `/ai/assistant_app/rag/sources?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`
if (roleId) path += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) path += `&accountId=${encodeURIComponent(accountId)}`
/** 获取 RAG 引用来源:GET /ai/chat/sources(不传 enableRag,其余参数照传) */
export function ragSources(message: string, chatId: string, options?: ChatOptions): Promise<ApiResponse> {
const path = `/ai/chat/sources?${buildChatQueryParams(message, chatId, options, false).toString()}`
return request.get(path).then(r => r.data) return request.get(path).then(r => r.data)
} }

1477
frontend/src/sdk-test/SdkTestPanel.vue
File diff suppressed because it is too large
View File

14
frontend/src/sdk-test/main.ts

@ -0,0 +1,14 @@
/**
* SDK
*
* main dist/sdk-test.html
* cp-to-static.mjs static/sdk/test.html
*/
import { createApp } from 'vue'
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
import SdkTestPanel from './SdkTestPanel.vue'
const app = createApp(SdkTestPanel)
app.use(TDesign)
app.mount('#sdk-test-app')

1
frontend/src/types/models.ts

@ -112,6 +112,7 @@ export interface McpServerConfig {
command?: string command?: string
args?: string[] args?: string[]
env?: Record<string, string> env?: Record<string, string>
headers?: Record<string, string>
url?: string url?: string
enabled: boolean enabled: boolean
createTime?: string createTime?: string

50
frontend/src/utils/chatAdapter.ts

@ -0,0 +1,50 @@
/**
* ChatMessage @tdesign-vue-next/chat ChatItemMeta
*
* ChatList ChatMessage role / name / datetime / avatar / content
* ChatList truthy status "pending" status
* ChatPanel.vue #content slot /
*/
import type { TdChatItemMeta, AIMessageContent, UserMessageContent } from '@tdesign-vue-next/chat'
/** 项目内部消息模型(与 ChatPanel.vue 中 ChatMessage 保持一致) */
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
streaming: boolean
time: string
sources?: any[]
toolCalls?: any[]
error?: boolean
feedback?: string | null
}
/** 转换单条消息 */
export function toChatItemMeta(msg: ChatMessage): TdChatItemMeta {
const isUser = msg.role === 'user'
return {
role: isUser ? 'user' : 'assistant',
name: isUser ? '我' : 'Support Bot',
avatar: isUser ? '' : undefined,
datetime: msg.time,
content: isUser
? ([{ type: 'text', data: msg.content || '' }] as UserMessageContent[])
: ([{ type: 'markdown', data: msg.content || '' }] as AIMessageContent[]),
// 故意不传 status:ChatList 会把 truthy status 强制改为 pending,显示骨架屏
}
}
/** 批量转换 */
export function toChatData(messages: ChatMessage[]): TdChatItemMeta[] {
return messages.map(toChatItemMeta)
}
/** 根据消息索引判断是否为最后一条 assistant 消息 */
export function isLastAssistant(index: number, messages: ChatMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return i === index
}
return false
}

37
frontend/src/utils/sse.ts

@ -11,10 +11,28 @@ import { getToken } from '@/utils/token'
function authHeaders(extra?: Record<string, string>): Record<string, string> { function authHeaders(extra?: Record<string, string>): Record<string, string> {
const token = getToken() const token = getToken()
const base: Record<string, string> = extra ? { ...extra } : {} const base: Record<string, string> = extra ? { ...extra } : {}
if (token) base['Authorization'] = `Bearer ${token}`
// 仅当调用方未显式提供 Authorization 时才注入管理后台 token,避免覆盖调用方传入的鉴权头(如测试面板的 SDK Token)
if (token && !base['Authorization']) base['Authorization'] = `Bearer ${token}`
return base return base
} }
/** 尝试从 OpenAI Chat Completions chunk 中提取 delta.content;返回 undefined 表示非 OpenAI 格式(回退纯文本) */
function extractOpenAIDelta(text: string): string | undefined {
let obj: any
try { obj = JSON.parse(text) } catch { return undefined }
// OpenAI 错误 chunk({"error":{"message":...}}):提取错误信息作为内容,避免原始 JSON 泄漏到对话框
if (obj && obj.error) {
const msg = obj.error.message || obj.error.type
return typeof msg === 'string' && msg ? msg : '服务异常'
}
if (obj && Array.isArray(obj.choices)) {
const delta = obj.choices[0]?.delta
if (delta && typeof delta.content === 'string') return delta.content
return '' // OpenAI 形状但无 content(role/finish_reason 空 chunk)→ 跳过
}
return undefined // 是 JSON 但非 OpenAI 形状 → 回退纯文本
}
/** /**
* SSE Flux<String> / ServerSentEvent / SseEmitter SSE * SSE Flux<String> / ServerSentEvent / SseEmitter SSE
* *
@ -49,6 +67,12 @@ export async function readSSEStream(
if (text === '[DONE]') return if (text === '[DONE]') return
// 跳过 status / faq 等系统事件,不显示在对话框中 // 跳过 status / faq 等系统事件,不显示在对话框中
if (ev === 'status') return if (ev === 'status') return
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
const extracted = extractOpenAIDelta(text)
if (extracted !== undefined) {
if (extracted) onChunk(extracted)
return
}
// 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件) // 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件)
onChunk(text || '\n') onChunk(text || '\n')
} }
@ -139,9 +163,16 @@ export async function readSSEStreamWithEvents(
// 系统状态事件(generating / faq_hit 等),不显示在对话框中 // 系统状态事件(generating / faq_hit 等),不显示在对话框中
break break
case 'message': case 'message':
default:
if (onMessage) onMessage(text)
default: {
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
const extracted = extractOpenAIDelta(text)
if (extracted !== undefined) {
if (extracted && onMessage) onMessage(extracted)
} else if (onMessage) {
onMessage(text)
}
break break
}
} }
} }

370
frontend/src/views/ChatPanel.vue

@ -69,29 +69,21 @@
>{{ q }}</t-tag> >{{ q }}</t-tag>
</div> </div>
<!-- 消息列表 -->
<!-- 消息列表TDesign AI Chat -->
<div class="msg-area chat-msg-area" ref="msgAreaRef"> <div class="msg-area chat-msg-area" ref="msgAreaRef">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
<div class="msg-content">
<!-- 编辑模式 -->
<template v-if="editingIndex === i">
<t-textarea
v-model="editingText"
:autosize="{ minRows: 2, maxRows: 4 }"
@keydown.enter.exact.prevent="submitEdit(i)"
/>
<div class="edit-actions">
<t-button theme="primary" size="small" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</t-button>
<t-button variant="outline" size="small" @click="cancelEdit">取消</t-button>
<span class="edit-hint">将清除此条之后的全部对话并重发</span>
</div>
</template>
<!-- 正常显示模式 -->
<template v-else>
<chat-list
:data="chatData"
layout="both"
:auto-scroll="false"
:is-stream-load="false"
:text-loading="false"
class="chat-list"
>
<template #content="{ index }">
<div class="chat-custom-content">
<!-- MCP 工具调用 --> <!-- MCP 工具调用 -->
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls">
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item">
<div v-if="messages[index]?.toolCalls?.length" class="mcp-tool-calls">
<div v-for="(tc, j) in messages[index].toolCalls" :key="j" class="mcp-tool-item">
<div class="mcp-tool-header"> <div class="mcp-tool-header">
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span> <span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
<span class="mcp-tool-name">{{ tc.tool }}</span> <span class="mcp-tool-name">{{ tc.tool }}</span>
@ -99,60 +91,79 @@
</div> </div>
</div> </div>
</div> </div>
<!-- AI 回复Markdown 渲染 -->
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMarkdown(m.content)"></div>
<!-- AI 思考中 -->
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<!-- 用户消息 -->
<div v-else class="msg-bubble">{{ m.content }}</div>
<!-- 引用来源 -->
<MessageSources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources" />
<!-- 推荐问题suggest-message-list -->
<div class="suggest-row" v-if="i === messages.length - 1 && currentSuggestions.length && !isSending">
<span class="suggest-label">💡 推荐问题</span>
<t-tag
v-for="q in currentSuggestions"
:key="q"
theme="default"
variant="light"
class="quick-tag"
@click="useQuickQuestion(q)"
>{{ q }}</t-tag>
</div>
<!-- 消息操作栏 -->
<div class="msg-tools">
<span>{{ m.time }}</span>
<t-button v-if="m.role === 'assistant' && m.content" variant="text" size="small" @click="copyMessage(m.content)">复制</t-button>
<t-button v-if="m.role === 'assistant' && m.content && !m.streaming" variant="text" size="small" @click="regenerate(i)">重新生成</t-button>
<t-button v-if="m.role === 'user' && !m.streaming" variant="text" size="small" @click="startEditMessage(i)">编辑</t-button>
<t-button v-if="m.error" variant="text" size="small" @click="retryLast">重试</t-button>
<template v-if="m.role === 'assistant' && m.content && !m.streaming">
<t-button variant="text" size="small" :style="{ color: m.feedback === 'up' ? 'var(--td-brand-color)' : '' }" @click="submitFeedback(m.id, 'up')" title="有帮助">👍</t-button>
<t-button variant="text" size="small" :style="{ color: m.feedback === 'down' ? 'var(--td-error-color)' : '' }" @click="submitFeedback(m.id, 'down')" title="没帮助">👎</t-button>
</template>
</div>
</template>
</div>
</div>
<!-- 编辑模式 -->
<template v-if="editingIndex === index">
<t-textarea
v-model="editingText"
:autosize="{ minRows: 2, maxRows: 4 }"
@keydown.enter.exact.prevent="submitEdit(index)"
/>
<div class="edit-actions">
<t-button theme="primary" size="small" @click="submitEdit(index)" :disabled="isSending || !editingText.trim()">重新发送</t-button>
<t-button variant="outline" size="small" @click="cancelEdit">取消</t-button>
<span class="edit-hint">将清除此条之后的全部对话并重发</span>
</div>
</template>
<!-- 正常显示模式 -->
<template v-else>
<!-- AI 回复Markdown 渲染 -->
<div v-if="messages[index]?.role === 'assistant' && messages[index]?.content" class="markdown-body" v-html="renderMarkdown(messages[index].content)"></div>
<!-- AI 思考中 -->
<div v-else-if="messages[index]?.role === 'assistant'" class="thinking"><span>正在思考</span></div>
<!-- 用户消息 -->
<div v-else class="user-text">{{ messages[index]?.content }}</div>
<!-- 引用来源 -->
<MessageSources v-if="messages[index]?.role === 'assistant' && messages[index]?.sources?.length" :sources="messages[index].sources" />
<!-- 推荐问题 -->
<div class="suggest-row" v-if="isLastAssistant(index, messages) && currentSuggestions.length && !isSending">
<span class="suggest-label">💡 推荐问题</span>
<t-tag
v-for="q in currentSuggestions"
:key="q"
theme="default"
variant="light"
class="quick-tag"
@click="useQuickQuestion(q)"
>{{ q }}</t-tag>
</div>
</template>
</div>
</template>
<template #actions="{ index }">
<chat-actionbar
v-if="messages[index]?.role === 'assistant' && messages[index]?.content && !messages[index]?.streaming"
:is-good="messages[index]?.feedback === 'up'"
:is-bad="messages[index]?.feedback === 'down'"
:action-bar="['copy', 'replay', 'good', 'bad']"
@operation="(val) => onMessageAction(val, index)"
/>
<div v-else-if="messages[index]?.role === 'user' && !messages[index]?.streaming" class="msg-extra-actions">
<t-button variant="text" size="small" @click="startEditMessage(index)">编辑</t-button>
</div>
<div v-else-if="messages[index]?.error" class="msg-extra-actions">
<t-button variant="text" size="small" @click="retryLast">重试</t-button>
</div>
</template>
</chat-list>
</div> </div>
<!-- 输入区 --> <!-- 输入区 -->
<footer class="chat-composer"> <footer class="chat-composer">
<div class="composer-box">
<t-textarea
v-model="userInput"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="isSending"
@keydown.enter.exact.prevent="send"
/>
<t-button theme="primary" class="send-btn" @click="send" :disabled="isSending || !userInput.trim()">
{{ isSending ? '发送中' : '发送' }}
</t-button>
</div>
<chat-sender
v-model="userInput"
:loading="isSending"
:disabled="isSending"
:send-btn-disabled="!userInput.trim()"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
:textarea-props="{ autosize: { minRows: 2, maxRows: 4 } }"
@send="send"
@stop="abortChat"
/>
</footer> </footer>
</section> </section>
@ -184,7 +195,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, ragSources, fetchSuggestions } from '@/api/chat'
import {
ChatList,
ChatSender,
ChatActionbar,
} from '@tdesign-vue-next/chat'
import '@tdesign-vue-next/chat/es/style/index.css'
import { chatSync, chatSSEUrl, ragSources, fetchSuggestions, type ChatOptions } from '@/api/chat'
import { getRoleList } from '@/api/role' import { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config' import { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation' import { truncateConversation } from '@/api/conversation'
@ -193,6 +210,8 @@ import { toast } from '@/utils/toast'
import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse' import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { toChatData, isLastAssistant } from '@/utils/chatAdapter'
import type { ChatMessage } from '@/utils/chatAdapter'
import MessageSources from '@/components/MessageSources.vue' import MessageSources from '@/components/MessageSources.vue'
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
@ -243,18 +262,6 @@ let sseAbortController: AbortController | null = null
const currentSuggestions = ref<string[]>([]) // suggest-message-list const currentSuggestions = ref<string[]>([]) // suggest-message-list
// ==================== ==================== // ==================== ====================
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
streaming: boolean
time: string
sources?: any[]
toolCalls?: any[]
error?: boolean
feedback?: string | null
}
const messages = ref<ChatMessage[]>([ const messages = ref<ChatMessage[]>([
{ {
id: generateMsgId(), id: generateMsgId(),
@ -265,6 +272,8 @@ const messages = ref<ChatMessage[]>([
}, },
]) ])
const chatData = computed(() => toChatData(messages.value))
// ==================== ==================== // ==================== ====================
const currentRole = computed(() => roles.value.find(r => r.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE) const currentRole = computed(() => roles.value.find(r => r.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE)
@ -423,7 +432,6 @@ async function send(): Promise<void> {
const cid = chatId.value || ('web_' + Date.now()) const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid chatId.value = cid
const roleId = currentRoleId()
try { try {
// SSE AbortController // SSE AbortController
@ -431,47 +439,56 @@ async function send(): Promise<void> {
sseAbortController = new AbortController() sseAbortController = new AbortController()
const signal = sseAbortController.signal const signal = sseAbortController.signal
if (isRagMode.value && mode.value === 'sync') {
// RAG
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId)
} else if (isRagMode.value) {
// RAG SSE
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId)
await readSSEStreamWithEvents(url, {
onMessage: async (chunk: string) => {
// RAG enableRag
const chatOptions: ChatOptions = {
roleId: currentRoleId(),
enableRag: isRagMode.value,
rewriteStrategy: isRagMode.value ? ragStrategy.value : undefined,
categoryIds: selectedCategoryIds.value,
}
if (mode.value === 'sync') {
//
assistantMsg.content = await chatSync(text, cid, chatOptions)
} else {
// SSE
const url = chatSSEUrl(text, cid, chatOptions)
if (isRagMode.value) {
await readSSEStreamWithEvents(url, {
onMessage: async (chunk: string) => {
assistantMsg.content += chunk
messages.value = [...messages.value]
await scrollToBottom()
},
onToolCallStart: (data: any) => {
assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
},
onToolCallResult: (data: any) => {
const tc = assistantMsg.toolCalls!.find(t => t.tool === data.tool && t.status === 'running')
if (tc) { tc.status = 'done'; tc.result = data.result; tc.latencyMs = data.latencyMs }
messages.value = [...messages.value]
scrollToBottom()
},
onError: (data: any) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
messages.value = [...messages.value]
},
onDone: () => {},
}, undefined, signal)
} else {
await readSSEStream(url, async (chunk: string) => {
assistantMsg.content += chunk assistantMsg.content += chunk
messages.value = [...messages.value]
await scrollToBottom() await scrollToBottom()
},
onToolCallStart: (data: any) => {
assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
},
onToolCallResult: (data: any) => {
const tc = assistantMsg.toolCalls!.find(t => t.tool === data.tool && t.status === 'running')
if (tc) { tc.status = 'done'; tc.result = data.result; tc.latencyMs = data.latencyMs }
scrollToBottom()
},
onError: (data: any) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
},
onDone: () => {},
}, undefined, signal)
} else if (mode.value === 'sync') {
//
assistantMsg.content = await chatSync(text, cid, roleId)
} else {
// SSE
const url = chatSSEUrl(text, cid, roleId)
await readSSEStream(url, async (chunk: string) => {
assistantMsg.content += chunk
await scrollToBottom()
}, () => {}, undefined, signal)
}, () => {}, undefined, signal)
}
} }
// RAG // RAG
if (isRagMode.value) { if (isRagMode.value) {
try { try {
const sj = await ragSources(text, cid, ragStrategy.value, roleId)
const sj = await ragSources(text, cid, chatOptions)
if (sj && sj.success) assistantMsg.sources = sj.data || [] if (sj && sj.success) assistantMsg.sources = sj.data || []
} catch (_) { /* 来源获取失败不影响主回答 */ } } catch (_) { /* 来源获取失败不影响主回答 */ }
} }
@ -487,6 +504,7 @@ async function send(): Promise<void> {
} finally { } finally {
assistantMsg.streaming = false assistantMsg.streaming = false
isSending.value = false isSending.value = false
messages.value = [...messages.value]
// suggest-message-listAbortError / // suggest-message-listAbortError /
if (!sseAbortController?.signal.aborted && assistantMsg.content && !assistantMsg.error) { if (!sseAbortController?.signal.aborted && assistantMsg.content && !assistantMsg.error) {
@ -499,7 +517,40 @@ async function send(): Promise<void> {
} }
} }
// ==================== ====================
function abortChat(): void {
if (sseAbortController) {
sseAbortController.abort()
}
isSending.value = false
const last = messages.value[messages.value.length - 1]
if (last && last.role === 'assistant' && last.streaming) {
last.streaming = false
if (!last.content) last.content = '已取消'
messages.value = [...messages.value]
}
}
// ==================== ==================== // ==================== ====================
function onMessageAction(action: string, index: number): void {
const msg = messages.value[index]
if (!msg) return
switch (action) {
case 'copy':
copyMessage(msg.content)
break
case 'replay':
regenerate(index)
break
case 'good':
submitFeedback(msg.id, 'up')
break
case 'bad':
submitFeedback(msg.id, 'down')
break
}
}
function retryLast(): void { function retryLast(): void {
if (!lastUserInput.value || isSending.value) return if (!lastUserInput.value || isSending.value) return
userInput.value = lastUserInput.value userInput.value = lastUserInput.value
@ -605,7 +656,7 @@ async function submitFeedback(msgId: string, type: 'up' | 'down'): Promise<void>
feedbackType: 'THUMBS_UP', feedbackType: 'THUMBS_UP',
}) })
toast('感谢反馈 👍', 'success') toast('感谢反馈 👍', 'success')
} catch (e) {
} catch (e: any) {
// //
msg.feedback = wasActive ? 'up' : null msg.feedback = wasActive ? 'up' : null
console.error('反馈提交失败:', e) console.error('反馈提交失败:', e)
@ -641,7 +692,7 @@ async function confirmFeedbackReason(): Promise<void> {
reasonComment, reasonComment,
}) })
toast('感谢反馈,我们会持续改进', 'success') toast('感谢反馈,我们会持续改进', 'success')
} catch (e) {
} catch (e: any) {
// //
msg.feedback = null msg.feedback = null
console.error('反馈提交失败:', e) console.error('反馈提交失败:', e)
@ -736,34 +787,55 @@ onBeforeUnmount(() => {
.quick-row { display: flex; gap: 8px; padding: 10px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--td-border-level-1-color); } .quick-row { display: flex; gap: 8px; padding: 10px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--td-border-level-1-color); }
.quick-tag { cursor: pointer; } .quick-tag { cursor: pointer; }
/* ==================== 消息区 ==================== */
/* ==================== 消息区(TDesign ChatList 容器) ==================== */
.msg-area { flex: 1; overflow-y: auto; padding: 16px; } .msg-area { flex: 1; overflow-y: auto; padding: 16px; }
.msg { margin-bottom: 16px; }
.msg.user .msg-content { display: flex; flex-direction: column; align-items: flex-end; }
.msg.user .msg-bubble {
background: var(--td-brand-color); color: var(--td-text-color-anti); border-radius: 12px 12px 4px 12px;
padding: 10px 14px; max-width: 70%; font-size: 14px; line-height: 1.6; white-space: pre-wrap;
}
.msg.assistant .msg-bubble {
background: var(--td-bg-color-secondarycontainer); color: var(--td-text-color-primary); border-radius: 12px 12px 12px 4px;
padding: 12px 16px; max-width: 85%; font-size: 14px; line-height: 1.7;
}
.msg.streaming .msg-content::after { content: ''; }
.thinking { color: var(--td-text-color-placeholder); font-style: italic; animation: blink 1s infinite; }
.chat-list {
--td-chat-item-max-width: 85%;
}
.chat-list :deep(.t-chat__list) {
gap: 16px;
}
.chat-list :deep(.t-chat__inner) {
width: 100%;
}
.chat-list :deep(.t-chat__content) {
max-width: 85%;
}
.chat-list :deep(.t-chat__detail) {
padding: 12px 16px;
border-radius: 12px;
background: var(--td-bg-color-secondarycontainer);
}
.chat-list :deep(.t-chat__inner.user .t-chat__detail) {
background: var(--td-brand-color);
color: var(--td-text-color-anti);
}
.chat-list :deep(.t-chat__actions-margin) {
margin-top: 6px;
}
/* 自定义内容区 */
.chat-custom-content { font-size: 14px; line-height: 1.7; }
.chat-custom-content .user-text {
white-space: pre-wrap;
}
.chat-custom-content .thinking {
color: var(--td-text-color-placeholder); font-style: italic; animation: blink 1s infinite;
}
@keyframes blink { 50% { opacity: 0.5; } } @keyframes blink { 50% { opacity: 0.5; } }
/* Markdown 在气泡内 */ /* Markdown 在气泡内 */
.msg-bubble :deep(h1) { font-size: 1.3em; margin: 12px 0 6px; }
.msg-bubble :deep(h2) { font-size: 1.15em; margin: 10px 0 4px; }
.msg-bubble :deep(h3) { font-size: 1.05em; margin: 8px 0 4px; }
.msg-bubble :deep(p) { margin: 4px 0; }
.msg-bubble :deep(ul), .msg-bubble :deep(ol) { padding-left: 20px; margin: 6px 0; }
.msg-bubble :deep(table) { border-collapse: collapse; margin: 8px 0; width: 100%; }
.msg-bubble :deep(th), .msg-bubble :deep(td) { border: 1px solid var(--td-border-level-2-color); padding: 6px 10px; text-align: left; font-size: 13px; }
.msg-bubble :deep(th) { background: var(--td-bg-color-secondarycontainer); font-weight: 600; }
.msg-bubble :deep(code) { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; }
.msg-bubble :deep(pre) { background: var(--color-code-block-bg); color: var(--color-code-block-text); padding: 12px; border-radius: 8px; overflow-x: auto; }
.msg-bubble :deep(pre code) { background: none; padding: 0; color: inherit; }
.chat-custom-content :deep(h1) { font-size: 1.3em; margin: 12px 0 6px; }
.chat-custom-content :deep(h2) { font-size: 1.15em; margin: 10px 0 4px; }
.chat-custom-content :deep(h3) { font-size: 1.05em; margin: 8px 0 4px; }
.chat-custom-content :deep(p) { margin: 4px 0; }
.chat-custom-content :deep(ul), .chat-custom-content :deep(ol) { padding-left: 20px; margin: 6px 0; }
.chat-custom-content :deep(table) { border-collapse: collapse; margin: 8px 0; width: 100%; }
.chat-custom-content :deep(th), .chat-custom-content :deep(td) { border: 1px solid var(--td-border-level-2-color); padding: 6px 10px; text-align: left; font-size: 13px; }
.chat-custom-content :deep(th) { background: var(--td-bg-color-secondarycontainer); font-weight: 600; }
.chat-custom-content :deep(code) { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; }
.chat-custom-content :deep(pre) { background: var(--color-code-block-bg); color: var(--color-code-block-text); padding: 12px; border-radius: 8px; overflow-x: auto; }
.chat-custom-content :deep(pre code) { background: none; padding: 0; color: inherit; }
/* ==================== MCP 工具调用 ==================== */ /* ==================== MCP 工具调用 ==================== */
.mcp-tool-calls { margin-bottom: 8px; } .mcp-tool-calls { margin-bottom: 8px; }
@ -774,16 +846,16 @@ onBeforeUnmount(() => {
.mcp-tool-latency { font-size: 11px; color: var(--td-text-color-placeholder); margin-left: auto; } .mcp-tool-latency { font-size: 11px; color: var(--td-text-color-placeholder); margin-left: auto; }
/* ==================== 消息操作 ==================== */ /* ==================== 消息操作 ==================== */
.msg-tools { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 12px; color: var(--td-text-color-placeholder); }
.msg-tools span { margin-right: auto; }
.msg-extra-actions { display: inline-flex; }
.edit-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; } .edit-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.edit-hint { font-size: 12px; color: var(--td-text-color-placeholder); } .edit-hint { font-size: 12px; color: var(--td-text-color-placeholder); }
/* ==================== 推荐问题 ==================== */
.suggest-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; align-items: center; }
.suggest-label { font-size: 12px; color: var(--td-text-color-placeholder); }
/* ==================== 输入区 ==================== */ /* ==================== 输入区 ==================== */
.chat-composer { padding: 12px 16px; border-top: 1px solid var(--td-border-level-1-color); } .chat-composer { padding: 12px 16px; border-top: 1px solid var(--td-border-level-1-color); }
.composer-box { display: flex; gap: 10px; align-items: flex-end; }
.composer-box :deep(.t-textarea__inner) { min-height: 44px; }
.send-btn { flex-shrink: 0; height: 44px; }
@media (max-width: 768px) { @media (max-width: 768px) {
.chat-sidebar { display: none; } .chat-sidebar { display: none; }

12
frontend/src/views/FeedbackOps.vue

@ -23,6 +23,10 @@
<t-tag v-if="row.reasonCategory" theme="warning" variant="light">{{ reasonLabel(row.reasonCategory) }}</t-tag> <t-tag v-if="row.reasonCategory" theme="warning" variant="light">{{ reasonLabel(row.reasonCategory) }}</t-tag>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
<template #reasonComment="{ row }">
<span v-if="row.reasonComment" :title="row.reasonComment" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;">{{ truncate(row.reasonComment, 40) }}</span>
<span v-else>-</span>
</template>
<template #processed="{ row }"> <template #processed="{ row }">
<t-tag :theme="row.processed ? 'success' : 'default'" variant="light">{{ row.processed ? '已处理' : '待处理' }}</t-tag> <t-tag :theme="row.processed ? 'success' : 'default'" variant="light">{{ row.processed ? '已处理' : '待处理' }}</t-tag>
</template> </template>
@ -57,6 +61,9 @@
<span v-if="msg.feedback.reasonCategory"> · {{ reasonLabel(msg.feedback.reasonCategory) }}</span> <span v-if="msg.feedback.reasonCategory"> · {{ reasonLabel(msg.feedback.reasonCategory) }}</span>
</t-tag> </t-tag>
</div> </div>
<div v-if="msg.feedback && msg.feedback.reasonComment" class="msg-comment">
💬 补充说明{{ msg.feedback.reasonComment }}
</div>
<div class="msg-body" v-html="msg.messageType === 'ASSISTANT' ? renderMarkdown(msg.content) : msg.content"></div> <div class="msg-body" v-html="msg.messageType === 'ASSISTANT' ? renderMarkdown(msg.content) : msg.content"></div>
</div> </div>
</div> </div>
@ -90,6 +97,7 @@ import { getCategoryTree } from '@/api/category'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { formatDate } from '@/utils/format'
const { confirm } = useConfirm() const { confirm } = useConfirm()
@ -127,10 +135,11 @@ const processedOptions = [
const columns = [ const columns = [
{ colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType' }, { colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType' },
{ colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory' }, { colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory' },
{ colKey: 'reasonComment', title: '补充说明', width: 160, ellipsis: true, cell: 'reasonComment' },
{ colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' }, { colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' },
{ colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' }, { colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' },
{ colKey: 'processed', title: '状态', width: 70, cell: 'processed' }, { colKey: 'processed', title: '状态', width: 70, cell: 'processed' },
{ colKey: 'feedbackTime', title: '时间', width: 150 },
{ colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime) },
{ colKey: 'op', title: '操作', width: 240, cell: 'op' }, { colKey: 'op', title: '操作', width: 240, cell: 'op' },
] ]
@ -309,6 +318,7 @@ async function submitFaqAction() {
.msg-assistant { background: var(--color-bg-subtle); } .msg-assistant { background: var(--color-bg-subtle); }
.msg-system { background: var(--color-warning-bg); } .msg-system { background: var(--color-warning-bg); }
.msg-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .msg-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.msg-comment { font-size: 12px; color: var(--color-text-secondary, #666); background: var(--color-warning-bg); border-radius: 6px; padding: 6px 10px; margin-bottom: 6px; white-space: pre-wrap; word-break: break-word; }
.msg-body { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; } .msg-body { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.dialog-footer { margin-top: 16px; display: flex; justify-content: flex-end; gap: 8px; } .dialog-footer { margin-top: 16px; display: flex; justify-content: flex-end; gap: 8px; }
</style> </style>

16
frontend/src/views/McpServerManager.vue

@ -40,6 +40,10 @@
</t-radio-group> </t-radio-group>
</t-form-item> </t-form-item>
<t-form-item v-if="form.transportType==='sse'" label="Server URL *"><t-input v-model="form.url" placeholder="如:http://localhost:3001/sse" /></t-form-item> <t-form-item v-if="form.transportType==='sse'" label="Server URL *"><t-input v-model="form.url" placeholder="如:http://localhost:3001/sse" /></t-form-item>
<t-form-item v-if="form.transportType==='sse'" label="请求头(可选)">
<t-textarea v-model="form.headersText" :autosize="{minRows:2,maxRows:4}" placeholder="Authorization: Bearer sk-xxx\nX-API-Key: abc123" />
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:4px;">💡 敏感头Authorization 已脱敏未修改则保留原值</div>
</t-form-item>
<template v-if="form.transportType==='stdio'"> <template v-if="form.transportType==='stdio'">
<t-form-item label="Command *"><t-input v-model="form.command" placeholder="如:npx, python, node" /></t-form-item> <t-form-item label="Command *"><t-input v-model="form.command" placeholder="如:npx, python, node" /></t-form-item>
<t-form-item label="Args(每行一个)"><t-textarea v-model="form.argsText" :autosize="{minRows:2,maxRows:4}" placeholder="-m\nmcp-server-fetch" /></t-form-item> <t-form-item label="Args(每行一个)"><t-textarea v-model="form.argsText" :autosize="{minRows:2,maxRows:4}" placeholder="-m\nmcp-server-fetch" /></t-form-item>
@ -66,7 +70,7 @@ const { confirm } = useConfirm()
const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0) const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0)
const showModal=ref(false); const modalMode=ref('add'); const editId=ref<any>(null); const saving=ref(false) const showModal=ref(false); const modalMode=ref('add'); const editId=ref<any>(null); const saving=ref(false)
const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',description:''})
const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''})
const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null
const refreshLoading=ref(false) const refreshLoading=ref(false)
@ -79,19 +83,21 @@ async function load(p=1){currentPage.value=p
try{const r=await api.listMcpServers(p,pageSize.value);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}} try{const r=await api.listMcpServers(p,pageSize.value);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)} function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',description:''}}
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''}}
function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true} function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true}
function openEditModal(s:any){modalMode.value='edit';editId.value=s.id function openEditModal(s:any){modalMode.value='edit';editId.value=s.id
let at='',et=''
let at='',et='',ht=''
if(s.args){if(Array.isArray(s.args))at=s.args.join('\n');else{try{const p=JSON.parse(s.args);if(Array.isArray(p))at=p.join('\n');else at=s.args}catch{at=s.args}}} if(s.args){if(Array.isArray(s.args))at=s.args.join('\n');else{try{const p=JSON.parse(s.args);if(Array.isArray(p))at=p.join('\n');else at=s.args}catch{at=s.args}}}
if(s.env_vars){if(typeof s.env_vars==='object')et=Object.entries(s.env_vars).map(([k,v])=>k+'='+v).join('\n');else{try{const p=JSON.parse(s.env_vars);if(typeof p==='object')et=Object.entries(p).map(([k,v]:any)=>k+'='+v).join('\n');else et=s.env_vars}catch{et=s.env_vars}}} if(s.env_vars){if(typeof s.env_vars==='object')et=Object.entries(s.env_vars).map(([k,v])=>k+'='+v).join('\n');else{try{const p=JSON.parse(s.env_vars);if(typeof p==='object')et=Object.entries(p).map(([k,v]:any)=>k+'='+v).join('\n');else et=s.env_vars}catch{et=s.env_vars}}}
form.value={name:s.name||'',transportType:s.transport_type||'sse',url:s.server_url||'',command:s.command||'',argsText:at,envText:et,description:s.description||''};showModal.value=true}
if(s.headers){if(typeof s.headers==='object')ht=Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n');else{try{const p=JSON.parse(s.headers);if(typeof p==='object')ht=Object.entries(p).map(([k,v]:any)=>k+': '+v).join('\n');else ht=s.headers}catch{ht=s.headers}}}
form.value={name:s.name||'',transportType:s.transport_type||'sse',url:s.server_url||'',command:s.command||'',argsText:at,envText:et,headersText:ht,description:s.description||''};showModal.value=true}
async function save(){ async function save(){
const f=form.value;if(!f.name.trim()){toast('请填写服务名称','error');return} const f=form.value;if(!f.name.trim()){toast('请填写服务名称','error');return}
if(f.transportType==='sse'){if(!f.url.trim()){toast('请填写 URL','error');return}}else{if(!f.command.trim()){toast('请填写 Command','error');return}} if(f.transportType==='sse'){if(!f.url.trim()){toast('请填写 URL','error');return}}else{if(!f.command.trim()){toast('请填写 Command','error');return}}
const d:any={name:f.name,transportType:f.transportType,description:f.description} const d:any={name:f.name,transportType:f.transportType,description:f.description}
if(f.transportType==='sse')d.serverUrl=f.url;else{d.command=f.command;d.args=f.argsText.trim()?f.argsText.split('\n').map(s=>s.trim()).filter(Boolean):[]}
if(f.transportType==='sse'){d.serverUrl=f.url;if(f.headersText.trim()){const h:any={};f.headersText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf(':');if(i>0)h[t.substring(0,i).trim()]=t.substring(i+1).trim()});d.headers=h}}
else{d.command=f.command;d.args=f.argsText.trim()?f.argsText.split('\n').map(s=>s.trim()).filter(Boolean):[]}
if(f.envText.trim()){const e:any={};f.envText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf('=');if(i>0)e[t.substring(0,i)]=t.substring(i+1)});d.envVars=e} if(f.envText.trim()){const e:any={};f.envText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf('=');if(i>0)e[t.substring(0,i)]=t.substring(i+1)});d.envVars=e}
saving.value=true saving.value=true
try{let r;if(modalMode.value==='add')r=await api.createMcpServer(d);else r=await api.updateMcpServer(editId.value,d) try{let r;if(modalMode.value==='add')r=await api.createMcpServer(d);else r=await api.updateMcpServer(editId.value,d)

9
frontend/src/views/PromptTracePanel.vue

@ -594,20 +594,15 @@ function trendTimeRange(): { startTime?: string; endTime?: string } {
const end = new Date() const end = new Date()
if (trendRange.value === '24h') { if (trendRange.value === '24h') {
const start = new Date(end.getTime() - 24 * 3600 * 1000) const start = new Date(end.getTime() - 24 * 3600 * 1000)
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) }
return { startTime: formatDate(start), endTime: formatDate(end) }
} }
if (trendRange.value === '7d') { if (trendRange.value === '7d') {
const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000) const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000)
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) }
return { startTime: formatDate(start), endTime: formatDate(end) }
} }
return {} return {}
} }
function fmtDateTime(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
async function loadTrend() { async function loadTrend() {
trendLoading.value = true trendLoading.value = true
try { try {

15
frontend/vite.config.ts

@ -39,11 +39,20 @@ export default defineConfig({
minify: 'esbuild', // esbuild 比 terser 快 20-40 倍 minify: 'esbuild', // esbuild 比 terser 快 20-40 倍
reportCompressedSize: false, // 跳过压缩体积报告,省 1~3s reportCompressedSize: false, // 跳过压缩体积报告,省 1~3s
rollupOptions: { rollupOptions: {
input: {
// 管理后台主入口
main: resolve(__dirname, 'index.html'),
// SDK 测试面板(多页入口,产物由 cp-to-static 移动到 static/sdk/test.html)
'sdk-test': resolve(__dirname, 'sdk-test.html'),
},
output: { output: {
// 拆出独立 chunk,浏览器可长期缓存 // 拆出独立 chunk,浏览器可长期缓存
manualChunks: {
'tdesign': ['tdesign-vue-next'],
'echarts': ['mermaid'], // mermaid 体积较大,独立打包
manualChunks(id) {
if (id.includes('@tdesign-vue-next/chat')) return 'tdesign-chat'
if (id.includes('tdesign-web-components')) return 'tdesign-web-components'
if (id.includes('tdesign-vue-next')) return 'tdesign'
// 注意:mermaid 不再单独拆包;引入 tdesign-web-components 后,
// mermaid 同时被 PipelineFlow 与 web-components 引用,单独拆出会导致循环 chunk 警告。
}, },
}, },
}, },

9
pom.xml

@ -203,6 +203,15 @@
<resource> <resource>
<directory>src/main/resources</directory> <directory>src/main/resources</directory>
<filtering>true</filtering> <filtering>true</filtering>
<excludes>
<exclude>static/**</exclude>
</excludes>
</resource>
<resource>
<directory>src/main/resources/static</directory>
<!-- 必须指定 targetPath,否则文件会被平铺到 target/classes/ 根目录,而不是 target/classes/static/ -->
<targetPath>static</targetPath>
<filtering>false</filtering>
</resource> </resource>
</resources> </resources>
<plugins> <plugins>

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

@ -38,7 +38,9 @@ import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@ -389,6 +391,169 @@ public class AssistantApp {
.onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage())); .onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage()));
} }
/**
* 流式对话OpenAI Chat Completions 标准 SSE 格式
* <p>
* 复用 {@link #chatStream(ChatContext)} 的完整编排逻辑熔断早退 / FAQ 命中早退 /
* 正常流式调用 / 空白缓冲 / 埋点差异在于把每个文本片段包装为 OpenAI 标准 JSON chunk
* 首片 delta 携带 role=assistant流结束时追加 finish_reason=stop chunk [DONE]
* <p>
* 每个 Flux 元素即一个完整 JSON 字符串Spring WebFlux 自动加 data: 前缀
*
* @param ctx 对话上下文
* @return OpenAI 标准格式的流式回答
*/
public Flux<String> chatStreamOpenAi(ChatContext ctx) {
long startNanos = System.nanoTime();
// OpenAI 标准 chunk 的公共元信息同一次流式回答共享 id / created / model
String completionId = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "");
long created = System.currentTimeMillis() / 1000;
// 活跃模型配置可能不存在返回 null回退 unknown
AiModelConfig cfg = null;
try {
cfg = aiModelConfigService.getActiveConfigWithFullKey(ctx.appType());
} catch (Exception e) {
log.warn("获取活跃模型配置失败,model 回退 unknown: chatId={}, error={}", ctx.chatId(), e.getMessage());
}
String model = (cfg != null && cfg.getModelName() != null) ? cfg.getModelName() : "unknown";
// 熔断全局 AI 调用处于熔断状态不做 buildRequest避免熔断期间仍走意图路由/检索
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(OpenAI 流式),返回降级提示");
recordTrace(ctx, null, CIRCUIT_OPEN_MESSAGE, 0, "BYPASS",
new TraceMeta("CIRCUIT_BREAK", "AI 服务熔断降级", null, null, null, null));
return openAiFallbackStream(completionId, model, created, CIRCUIT_OPEN_MESSAGE, true);
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
// FAQ 命中整段答案包装为 OpenAI chunk随后追加 stop + [DONE]
String faqAnswer = req.faqAnswer().get();
recordTrace(ctx, req, faqAnswer, 0, "FAQ",
new TraceMeta(null, null, null, null, null, null));
return openAiFallbackStream(completionId, model, created, faqAnswer, true);
}
// 显式事件收集器 + 轮次计数器通过 toolContext 传给 McpToolCallback规避 Reactor 跨线程丢 ThreadLocal 的问题
List<ToolCallEvent> events = new CopyOnWriteArrayList<>();
AtomicInteger rounds = new AtomicInteger(0);
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt()
.user(req.finalMessage())
.advisors(s -> s.param(CONVERSATION_ID, ctx.chatId()));
if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt());
}
spec = spec.toolContext(Map.of(
McpToolCallback.MCP_EVENTS_KEY, events,
McpToolCallback.MCP_ROUNDS_KEY, rounds));
// 改为 chatResponse 流以采集 token 用量再映射回纯文本流
AtomicReference<Usage> usageRef = new AtomicReference<>();
AtomicReference<String> errorTypeRef = new AtomicReference<>();
AtomicReference<String> errorMessageRef = new AtomicReference<>();
Flux<ChatResponse> responseFlux = spec.stream().chatResponse();
Flux<String> rawStream = responseFlux
.doOnNext(r -> {
if (r != null && r.getMetadata() != null && r.getMetadata().getUsage() != null) {
usageRef.set(r.getMetadata().getUsage());
}
})
.map(r -> {
String out = r != null && r.getResult() != null && r.getResult().getOutput() != null
? r.getResult().getOutput().getText() : "";
return out != null ? out : "";
});
// 聚合所有分片用于埋点 doFinally 时取完整回复文本
StringBuilder aggregated = new StringBuilder();
// 首片标记第一片 delta 需带 role=assistant后续片仅含 content
AtomicBoolean first = new AtomicBoolean(true);
return preserveTrailingWhitespace(rawStream)
.doOnNext(aggregated::append)
.map(chunk -> buildOpenAiChunk(completionId, model, created, chunk, first.getAndSet(false), null))
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
errorTypeRef.set(classifyError(e));
errorMessageRef.set(maskError(e.getMessage()));
log.error("AI 流式调用失败(OpenAI): chatId={}, error={}", ctx.chatId(), e.getMessage());
})
.doFinally(signalType -> {
// 流式埋点按终止信号区分状态断连/异常也落库events toolContext 显式收集跨线程安全
String status = signalType == SignalType.ON_COMPLETE ? "COMPLETE"
: signalType == SignalType.ON_ERROR ? "ERROR" : "CANCEL";
Usage usage = usageRef.get();
recordTrace(ctx, req, aggregated.toString(), elapsedMillis(startNanos), status,
new TraceMeta(errorTypeRef.get(), errorMessageRef.get(),
usage != null ? usage.getPromptTokens() : null,
usage != null ? usage.getCompletionTokens() : null,
usage != null ? usage.getTotalTokens() : null,
events));
})
// 流正常结束时追加 finish_reason=stop chunk [DONE]
.concatWith(Flux.just(
buildOpenAiChunk(completionId, model, created, "", false, "stop"),
"[DONE]"))
// 错误兜底脱敏错误信息避免泄露内部细节role 是否出现取决于此前是否已发出过内容片
.onErrorResume(e -> openAiFallbackStream(completionId, model, created,
"抱歉,AI 服务调用失败:" + maskError(e.getMessage()), first.get()));
}
/**
* 组装单个 OpenAI Chat Completions 流式 chunkJSON 字符串
* <p>
* 字段顺序固定为 id/object/created/model/choicesdelta role content
* choices 使用 LinkedHashMap 以支持 finish_reason=nullMap.of 不允许 null
*
* @param id chunk 唯一 IDchatcmpl-xxx
* @param model 模型名称
* @param created 创建时间epoch
* @param content 文本片段stop 片传空串
* @param first 是否首片首片 delta 携带 role=assistant
* @param finishReason 结束原因中间片为 nullstop 片为 "stop"
* @return OpenAI 标准 chunk JSON 字符串
*/
private String buildOpenAiChunk(String id, String model, long created, String content, boolean first, String finishReason) {
// delta首片带 role=assistantrole content 后续片仅 contentstop 片为空对象
Map<String, Object> delta = new LinkedHashMap<>();
if (first) {
delta.put("role", "assistant");
}
if (content != null && !content.isEmpty()) {
delta.put("content", content);
}
Map<String, Object> choice = new LinkedHashMap<>();
choice.put("index", 0);
choice.put("delta", delta);
choice.put("finish_reason", finishReason);
Map<String, Object> chunk = new LinkedHashMap<>();
chunk.put("id", id);
chunk.put("object", "chat.completion.chunk");
chunk.put("created", created);
chunk.put("model", model);
chunk.put("choices", List.of(choice));
try {
return OBJECT_MAPPER.writeValueAsString(chunk);
} catch (Exception e) {
log.warn("序列化 OpenAI chunk 失败: {}", e.getMessage());
return "{}";
}
}
/**
* 组装 OpenAI 格式的早退/兜底流内容 chunk + finish_reason=stop + [DONE]
* 用于熔断降级FAQ 命中与错误兜底三种场景
*
* @param id chunk 唯一 ID
* @param model 模型名称
* @param created 创建时间epoch
* @param content 完整回复文本
* @param withRole 首片是否携带 role=assistant熔断/FAQ 早退为 true错误兜底时取决于此前是否已发出内容片
* @return OpenAI 标准格式的流
*/
private Flux<String> openAiFallbackStream(String id, String model, long created, String content, boolean withRole) {
return Flux.just(
buildOpenAiChunk(id, model, created, content, withRole, null),
buildOpenAiChunk(id, model, created, "", false, "stop"),
"[DONE]");
}
/** /**
* 缓冲以空白字符结尾的 chunk将其与下一个 chunk 合并后再发出 * 缓冲以空白字符结尾的 chunk将其与下一个 chunk 合并后再发出
* <p> * <p>

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

@ -211,6 +211,8 @@ public class DatabaseInitConfig {
createMcpServerConfigTable(); createMcpServerConfigTable();
} }
}); });
// 迁移 mcp_server_config.headers 旧表补加自定义请求头列用于鉴权场景
safeInit("迁移 mcp_server_config.headers 列", this::addMcpServerConfigHeadersColumn);
// P0-001: 混合检索 - vector_store 添加全文检索列 // P0-001: 混合检索 - vector_store 添加全文检索列
safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch); safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch);
@ -1251,6 +1253,7 @@ public class DatabaseInitConfig {
command VARCHAR(500), command VARCHAR(500),
args VARCHAR(1000), args VARCHAR(1000),
env_vars JSONB DEFAULT '{}' NOT NULL, env_vars JSONB DEFAULT '{}' NOT NULL,
headers JSONB DEFAULT '{}' NOT NULL,
description VARCHAR(500), description VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE NOT NULL, is_active BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -1262,6 +1265,27 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_mcp_server_config_active ON mcp_server_config (is_active) WHERE is_delete = FALSE"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_mcp_server_config_active ON mcp_server_config (is_active) WHERE is_delete = FALSE");
} }
/**
* 为已存在的 mcp_server_config 表补加 headers JSONB自定义请求头用于鉴权场景
* 幂等仅在列不存在时执行 ALTER保证旧库升级不丢失数据
*/
private void addMcpServerConfigHeadersColumn() {
if (!checkTableExists("mcp_server_config")) {
log.debug("mcp_server_config 表尚未创建,跳过 headers 迁移");
return;
}
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'mcp_server_config' AND column_name = 'headers'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
log.info("添加 mcp_server_config.headers 列");
jdbcTemplate.execute("ALTER TABLE mcp_server_config ADD COLUMN headers JSONB DEFAULT '{}' NOT NULL");
}
} catch (Exception e) {
log.error("添加 mcp_server_config.headers 列失败", e);
}
}
/** /**
* 创建系统配置表 system_configkey-value 模式支持灵活扩展配置项 * 创建系统配置表 system_configkey-value 模式支持灵活扩展配置项
*/ */
@ -1562,6 +1586,7 @@ public class DatabaseInitConfig {
executeComment("COLUMN mcp_server_config.command", "stdio 模式的启动命令"); executeComment("COLUMN mcp_server_config.command", "stdio 模式的启动命令");
executeComment("COLUMN mcp_server_config.args", "stdio 模式的命令参数(多个用逗号分隔)"); executeComment("COLUMN mcp_server_config.args", "stdio 模式的命令参数(多个用逗号分隔)");
executeComment("COLUMN mcp_server_config.env_vars", "环境变量(JSONB 键值对)"); executeComment("COLUMN mcp_server_config.env_vars", "环境变量(JSONB 键值对)");
executeComment("COLUMN mcp_server_config.headers", "自定义请求头(JSONB 键值对,仅 SSE/HTTP 模式生效,用于鉴权)");
executeComment("COLUMN mcp_server_config.description", "描述说明"); executeComment("COLUMN mcp_server_config.description", "描述说明");
executeComment("COLUMN mcp_server_config.is_active", "是否启用"); executeComment("COLUMN mcp_server_config.is_active", "是否启用");
executeComment("COLUMN mcp_server_config.create_time", "创建时间"); executeComment("COLUMN mcp_server_config.create_time", "创建时间");

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

@ -15,6 +15,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.DependsOn;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -33,6 +34,9 @@ import java.util.concurrent.ConcurrentHashMap;
*/ */
@Component @Component
@Slf4j @Slf4j
// 依赖 DatabaseInitConfig确保建表/列迁移 mcp_server_config.headers先于本类 @PostConstruct 执行
// 否则 init() 首次查询 mcp_server_config 时会因新增列尚未迁移而报column does not exist
@DependsOn("databaseInitConfig")
public class McpClientManager { public class McpClientManager {
@Autowired @Autowired
@ -460,14 +464,36 @@ public class McpClientManager {
if ("sse".equals(config.getTransportType())) { if ("sse".equals(config.getTransportType())) {
// 创建 SSE 传输层连接远程 MCP Server // 创建 SSE 传输层连接远程 MCP Server
// HttpClientSseClientTransport 会自动将 /sse 作为 SSE 端点附加到 baseUri
String serverUrl = config.getServerUrl().trim(); String serverUrl = config.getServerUrl().trim();
// 如果 URL /sse 结尾去掉后缀作为 baseUritransport 层会自动附加
String baseUri = serverUrl.replaceAll("/sse/?$", "");
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(baseUri).build();
// 显式拆分 originscheme://host:port path分别作为 baseUri sseEndpoint
// 注意HttpClientSseClientTransport 内部用 URI.resolve 拼接对以 / 开头的 sseEndpoint
// 会整体替换 path因此不能把去掉 /sse 的完整 URL baseUri否则 /mcp-cdw/sse
// resolve /sse丢失路径前缀这里把 origin 作为 baseUri完整 path 作为 sseEndpoint
java.net.URI uri = java.net.URI.create(serverUrl);
String baseUri = uri.getScheme() + "://" + uri.getAuthority();
String ssePath = uri.getPath() == null ? "" : uri.getPath();
// 规范化去掉末尾 / 确保 path /sse 结尾兼容用户填 base 或完整 SSE URL 两种写法
ssePath = ssePath.replaceAll("/+$", "");
if (!ssePath.endsWith("/sse")) {
ssePath = ssePath + "/sse";
}
HttpClientSseClientTransport.Builder transportBuilder =
HttpClientSseClientTransport.builder(baseUri).sseEndpoint(ssePath);
// 注入自定义请求头 Authorization / X-API-Key适配需要鉴权的远程 MCP 服务
Map<String, Object> headers = config.getHeaders();
if (headers != null && !headers.isEmpty()) {
transportBuilder.customizeRequest(req -> headers.forEach((k, v) -> {
if (v != null) {
req.header(k, v.toString());
}
}));
}
HttpClientSseClientTransport transport = transportBuilder.build();
client = buildSyncClient(transport); client = buildSyncClient(transport);
log.info("正在初始化 SSE MCP 客户端: id={}, url={}", config.getId(), baseUri);
log.info("正在初始化 SSE MCP 客户端: id={}, url={}, headers={}", config.getId(), baseUri + ssePath, headers != null ? headers.keySet() : "无");
} else if ("stdio".equals(config.getTransportType())) { } else if ("stdio".equals(config.getTransportType())) {
// 创建 stdio 传输层启动本地 MCP Server 进程 // 创建 stdio 传输层启动本地 MCP Server 进程

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

@ -357,7 +357,7 @@ public class AiController {
ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(), ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(),
ctx.allowedMcpTools(), ctx.categoryIds(), ctx.rewriteStrategy(), ctx.enableRag(), true, ctx.allowedMcpTools(), ctx.categoryIds(), ctx.rewriteStrategy(), ctx.enableRag(), true,
ctx.roleId(), ctx.roleName(), ctx.accountId(), ctx.apiKeyId()); ctx.roleId(), ctx.roleName(), ctx.accountId(), ctx.apiKeyId());
return assistantApp.chatStream(ctx);
return assistantApp.chatStreamOpenAi(ctx);
} }
/** /**

2
src/main/java/com/wok/supportbot/controller/OpenApiController.java

@ -121,7 +121,7 @@ public class OpenApiController {
ChatContext ctx = buildOpenApiChatContext(message, resolvedChatId, apiKey, roleId, ChatContext ctx = buildOpenApiChatContext(message, resolvedChatId, apiKey, roleId,
categoryIds, rewriteStrategy, enableRag, true); categoryIds, rewriteStrategy, enableRag, true);
return assistantApp.chatStream(ctx);
return assistantApp.chatStreamOpenAi(ctx);
} }
/** /**

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

@ -71,6 +71,12 @@ public class McpServerConfig implements Serializable {
@TableField(value = "env_vars", typeHandler = PostgresJsonTypeHandler.class) @TableField(value = "env_vars", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> envVars; private Map<String, Object> envVars;
/**
* 自定义请求头JSONB键值对形式 SSE/HTTP 模式生效用于鉴权等场景
*/
@TableField(value = "headers", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> headers;
/** /**
* 描述说明 * 描述说明
*/ */

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

@ -63,13 +63,19 @@ public class JwtAuthFilter extends OncePerRequestFilter {
} }
/** /**
* 跳过 SDK 路径 这些路径由 SdkAuthFilter 使用独立的 jwt.sdk-secret 验签
* 不应由本 Filter 用管理后台密钥重复验证避免产生误报 WARN 日志
* 跳过 SDK 路径和静态资源路径 这些路径要么 SdkAuthFilter 使用独立的 jwt.sdk-secret 验签
* 要么是不需要认证的首页/前端资源不应由本 Filter 用管理后台密钥重复验证避免产生误报 WARN 日志
*/ */
@Override @Override
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath(); String path = request.getServletPath();
return path.startsWith("/ai/");
return path.startsWith("/ai/")
|| path.isEmpty() || path.equals("/")
|| path.equals("/index.html")
|| path.startsWith("/assets/")
|| path.startsWith("/sdk/")
|| path.equals("/favicon.ico")
|| path.equals("/favicon.svg");
} }
/** /**

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

@ -44,6 +44,9 @@ public class SecurityConfig {
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// ========== 公开接口无需认证 ========== // ========== 公开接口无需认证 ==========
// 静态资源与首页放在最前面优先匹配
.requestMatchers("/", "/index.html", "/favicon.ico", "/favicon.svg").permitAll()
.requestMatchers("/assets/**", "/sdk/**").permitAll()
// SDK 对话接口 // SDK 对话接口
// SdkAuthFilter 作为 Servlet Filter 已在 SecurityFilterChain 之前执行 // SdkAuthFilter 作为 Servlet Filter 已在 SecurityFilterChain 之前执行
// 校验通过时已放行校验失败时已返回 401不会到达此处 // 校验通过时已放行校验失败时已返回 401不会到达此处

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

@ -98,6 +98,7 @@ public class McpServerConfigService {
mcpServerConfigMapper.insert(config); mcpServerConfigMapper.insert(config);
// JSONB 字段显式持久化避免 MyBatis Plus typeHandler 不触发的问题 // JSONB 字段显式持久化避免 MyBatis Plus typeHandler 不触发的问题
persistEnvVars(config.getId(), config.getEnvVars()); persistEnvVars(config.getId(), config.getEnvVars());
persistHeaders(config.getId(), config.getHeaders());
log.info("新建 MCP Server 配置: name={}, transportType={}", config.getName(), config.getTransportType()); log.info("新建 MCP Server 配置: name={}, transportType={}", config.getName(), config.getTransportType());
return formatConfig(mcpServerConfigMapper.selectById(config.getId())); return formatConfig(mcpServerConfigMapper.selectById(config.getId()));
@ -140,6 +141,10 @@ public class McpServerConfigService {
// null 或空 Map 均执行持久化清空语义 LambdaUpdateWrapper 行为一致 // null 或空 Map 均执行持久化清空语义 LambdaUpdateWrapper 行为一致
persistEnvVars(id, config.getEnvVars()); persistEnvVars(id, config.getEnvVars());
// headers 同为 JSONB显式持久化敏感头未修改值含 ****时保留数据库原值避免用脱敏值覆盖真实密钥
Map<String, Object> mergedHeaders = resolveHeadersForUpdate(existing.getHeaders(), config.getHeaders());
persistHeaders(id, mergedHeaders);
log.info("更新 MCP Server 配置: id={}", id); log.info("更新 MCP Server 配置: id={}", id);
return formatConfig(mcpServerConfigMapper.selectById(id)); return formatConfig(mcpServerConfigMapper.selectById(id));
} }
@ -282,6 +287,7 @@ public class McpServerConfigService {
formatted.put("command", config.getCommand()); formatted.put("command", config.getCommand());
formatted.put("args", config.getArgs()); formatted.put("args", config.getArgs());
formatted.put("env_vars", config.getEnvVars()); formatted.put("env_vars", config.getEnvVars());
formatted.put("headers", maskHeaders(config.getHeaders()));
formatted.put("description", config.getDescription()); formatted.put("description", config.getDescription());
formatted.put("is_active", config.getIsActive()); formatted.put("is_active", config.getIsActive());
formatted.put("create_time", config.getCreateTime()); formatted.put("create_time", config.getCreateTime());
@ -317,4 +323,100 @@ public class McpServerConfigService {
throw new RuntimeException("持久化环境变量失败: " + e.getMessage(), e); throw new RuntimeException("持久化环境变量失败: " + e.getMessage(), e);
} }
} }
/**
* 敏感请求头名称集合统一小写这些头的值在返回前端时需脱敏
*/
private static final Set<String> SENSITIVE_HEADERS = Set.of(
"authorization", "x-api-key", "api-key", "apikey", "token",
"cookie", "set-cookie", "x-auth-token", "secret", "password");
/**
* 显式持久化 headers JSONB 字段 persistEnvVars 同理规避 MyBatis Plus typeHandler 不触发的问题
*
* @param id 配置ID
* @param headers 请求头 Map
*/
private void persistHeaders(Long id, Map<String, Object> headers) {
if (id == null) {
return;
}
try {
String json;
if (headers == null || headers.isEmpty()) {
json = "{}";
} else {
json = OBJECT_MAPPER.writeValueAsString(headers);
}
jdbcTemplate.update(
"UPDATE mcp_server_config SET headers = ?::jsonb WHERE id = ?",
json, id);
log.debug("持久化 headers: id={}, headers={}", id, json);
} catch (Exception e) {
log.error("持久化 headers 失败: id={}, error={}", id, e.getMessage());
throw new RuntimeException("持久化请求头失败: " + e.getMessage(), e);
}
}
/**
* 脱敏请求头敏感头名 AuthorizationX-API-Key的值做前4+****+后4 脱敏非敏感原样返回
*
* @param headers 原始请求头 Map
* @return 脱敏后的请求头 Map保持原 key 顺序
*/
private Map<String, Object> maskHeaders(Map<String, Object> headers) {
if (headers == null || headers.isEmpty()) {
return headers;
}
Map<String, Object> masked = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : headers.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (isSensitiveHeader(key) && value != null) {
masked.put(key, AiModelConfigService.maskApiKey(value.toString()));
} else {
masked.put(key, value);
}
}
return masked;
}
/**
* 合并更新时的 headers敏感头且值含脱敏标记****即前端未改动时保留数据库原值否则用新值
* incoming null 时视为清空所有请求头
*
* @param existing 数据库已有的请求头 Map
* @param incoming 前端提交的请求头 Map
* @return 合并后的请求头 Map
*/
private Map<String, Object> resolveHeadersForUpdate(Map<String, Object> existing, Map<String, Object> incoming) {
if (incoming == null) {
return new LinkedHashMap<>();
}
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : incoming.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (isSensitiveHeader(key) && value != null && value.toString().contains("****")
&& existing != null && existing.containsKey(key)) {
result.put(key, existing.get(key));
} else {
result.put(key, value);
}
}
return result;
}
/**
* 判断请求头名是否为敏感头大小写不敏感
*
* @param name 请求头名
* @return true 表示敏感
*/
private boolean isSensitiveHeader(String name) {
if (name == null) {
return false;
}
return SENSITIVE_HEADERS.contains(name.trim().toLowerCase());
}
} }

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

@ -279,6 +279,7 @@ CREATE TABLE IF NOT EXISTS mcp_server_config (
command VARCHAR(500), command VARCHAR(500),
args VARCHAR(1000), args VARCHAR(1000),
env_vars JSONB DEFAULT '{}' NOT NULL, env_vars JSONB DEFAULT '{}' NOT NULL,
headers JSONB DEFAULT '{}' NOT NULL,
description VARCHAR(500), description VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE NOT NULL, is_active BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -296,6 +297,7 @@ COMMENT ON COLUMN mcp_server_config.server_url IS 'SSE 模式的 MCP Server URL'
COMMENT ON COLUMN mcp_server_config.command IS 'stdio 模式的启动命令'; COMMENT ON COLUMN mcp_server_config.command IS 'stdio 模式的启动命令';
COMMENT ON COLUMN mcp_server_config.args IS 'stdio 模式的命令参数(多个用逗号分隔)'; COMMENT ON COLUMN mcp_server_config.args IS 'stdio 模式的命令参数(多个用逗号分隔)';
COMMENT ON COLUMN mcp_server_config.env_vars IS '环境变量(JSONB 键值对)'; COMMENT ON COLUMN mcp_server_config.env_vars IS '环境变量(JSONB 键值对)';
COMMENT ON COLUMN mcp_server_config.headers IS '自定义请求头(JSONB 键值对,仅 SSE/HTTP 模式生效,用于鉴权)';
COMMENT ON COLUMN mcp_server_config.description IS '描述说明'; COMMENT ON COLUMN mcp_server_config.description IS '描述说明';
COMMENT ON COLUMN mcp_server_config.is_active IS '是否启用'; COMMENT ON COLUMN mcp_server_config.is_active IS '是否启用';
COMMENT ON COLUMN mcp_server_config.create_time IS '创建时间'; COMMENT ON COLUMN mcp_server_config.create_time IS '创建时间';

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

@ -1,710 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChatbotSDK 测试面板</title>
<style>
*{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: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}
.btn-outline{background:#fff;border:1px solid #D1D5DB;color:#374151}.btn-outline:hover{background:#F9FAFB}
.btn-sm{padding:5px 10px;font-size:12px}
.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 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}
.code-section{margin-top:12px;border:1px solid #E5E7EB;border-radius:8px;overflow:hidden}
.code-section__header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#F9FAFB;border-bottom:1px solid #E5E7EB}
.code-section__title{font-size:12px;font-weight:600;color:#374151;display:flex;align-items:center;gap:4px}
.code-section__copy{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border:1px solid #D1D5DB;border-radius:4px;background:#fff;color:#374151;font-size:11px;cursor:pointer;transition:all .15s;font-family:inherit}
.code-section__copy:hover{background:#F3F4F6;border-color:#9CA3AF}
.code-section__copy--ok{background:#D1FAE5;border-color:#6EE7B7;color:#065F46}
.code-section__body{padding:0;max-height:260px;overflow-y:auto}
.code-section__body::-webkit-scrollbar{width:4px}
.code-section__body::-webkit-scrollbar-thumb{background:#D1D5DB;border-radius:2px}
.code-section pre{margin:0;padding:12px;font-size:11.5px;line-height:1.6;font-family:'SF Mono','Consolas','Menlo',monospace;white-space:pre;overflow-x:auto;color:#1F2937;tab-size:2}
.code-section .cm{color:#6B7280} /* 注释 */
.code-section .kw{color:#7C3AED} /* 关键字/key */
.code-section .st{color:#059669} /* 字符串 */
.code-section .nu{color:#2563EB} /* 数字 */
.code-section .bl{color:#DC2626} /* 必传高亮 */
.code-tabs{display:flex;gap:4px}
.code-tab{padding:3px 10px;border:1px solid #E5E7EB;border-radius:4px;background:#fff;color:#6B7280;font-size:11px;cursor:pointer;transition:all .15s;font-family:inherit}
.code-tab--active{background:#4F46E5;color:#fff;border-color:#4F46E5}
.code-tab:hover:not(.code-tab--active){background:#F3F4F6}
</style>
</head>
<body>
<div class="page">
<div class="panel">
<div class="panel-header">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<a href="/index.html" style="font-size:12px;color:#4F46E5;text-decoration:none">← 返回管理后台</a>
<a href="/doc.html" target="_blank" style="font-size:12px;color:#6B7280;text-decoration:none">API 文档 ↗</a>
</div>
<h1>🧪 ChatbotSDK 测试面板</h1>
<p>P0 核心 · P1 体验增强 · P2 运营完善</p>
</div>
<div class="panel-body">
<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>
<!-- SDK Token 鉴权 -->
<div style="background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;padding:12px;margin-bottom:14px;">
<div style="font-size:12px;font-weight:600;color:#166534;margin-bottom:8px;">🔐 SDK Token 鉴权</div>
<div class="fg" style="margin-bottom:8px">
<label>API Key(用于换取 Token)</label>
<div style="display:flex;gap:6px">
<input type="text" id="cfg-apikey" placeholder="sk_xxxx..." style="flex:1">
<button class="btn btn-primary btn-sm" onclick="fetchToken()" id="btn-fetch-token">获取 Token</button>
</div>
<div class="hint">调用 POST /open-api/auth/token 换取 SDK Token</div>
</div>
<div class="fg" style="margin-bottom:0">
<label>SDK Token(自动填充或手动粘贴)</label>
<input type="text" id="cfg-token" placeholder="eyJhbGciOi...(获取后自动填充)">
<div class="hint">Token 有效期默认 2 小时,过期需重新获取</div>
</div>
</div>
<div class="fg">
<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 → 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">
<label>title</label>
<input type="text" id="cfg-title" value="AI 助手" placeholder="弹窗标题">
</div>
<div class="fg">
<label>primaryColor</label>
<input type="color" id="cfg-color" value="#4F46E5" style="height:36px;padding:2px">
</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>position</label>
<select id="cfg-pos"><option value="right-bottom" selected>右下角</option><option value="left-bottom">左下角</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>width (px)</label>
<input type="number" id="cfg-width" value="380" min="300" max="1200">
</div>
<div class="fg">
<label>height (px)</label>
<input type="number" id="cfg-height" value="520" min="300" max="1200">
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>resizable 可缩放</label>
<select id="cfg-resizable"><option value="1" selected>开启</option><option value="0">关闭</option></select>
</div>
<div class="fg">
<label>theme 主题模式</label>
<select id="cfg-theme-mode"><option value="light" selected>白天 light</option><option value="dark">暗夜 dark</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>streaming</label>
<select id="cfg-stream"><option value="1" selected>开启流式</option><option value="0">关闭(同步)</option></select>
</div>
<div class="fg">
<label>enableRag 知识库检索</label>
<select id=cfg-rag><option value=1 selected>开启</option><option value=0>关闭</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>locale</label>
<select id="cfg-locale"><option value="zh-CN" selected>中文</option><option value="en">English</option></select>
</div>
<div class="fg">
<label>debug</label>
<select id="cfg-debug"><option value="1" selected>开启日志</option><option value="0">关闭日志</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>showClear 清空按钮</label>
<select id="cfg-clear"><option value="1" selected>显示</option><option value="0">隐藏</option></select>
</div>
<div class="fg">
<label>watermark 水印文字</label>
<input type="text" id="cfg-watermark" value="" placeholder="水印格式:日期 + 此值">
<div class="hint">留空则不显示水印</div>
</div>
</div>
<div class="actions">
<button class="btn btn-primary btn-full" id="btn-init" onclick="doInit()">🚀 初始化 SDK</button>
</div>
<div class="actions">
<button class="btn btn-outline btn-sm" onclick="domAction('open')">👁 打开窗口</button>
<button class="btn btn-outline btn-sm" onclick="domAction('close')">🙈 关闭窗口</button>
<button class="btn btn-outline btn-sm" onclick="domAction('toggle')">🔄 切换</button>
</div>
<div class="actions">
<button class="btn btn-outline btn-sm" onclick="ChatbotSDK.init({})">⚡ 测试空配置</button>
<button class="btn btn-outline btn-sm" onclick="doClearHistory()">🗑 清空对话</button>
<button class="btn btn-danger btn-sm" onclick="doDestroy()">💥 销毁 SDK</button>
</div>
<hr class="divider">
<div style="font-size:12px;color:#6B7280;line-height:1.6">
<b>💡 操作提示</b><br>
1. integrateId 填写客服角色 ID(数字,对应后端 roleId)<br>
2. userId 填写外部用户ID(对应后端 accountId)<br>
3. chatId 自动管理:从 /conversation/list 获取或自动生成<br>
4. 拖拽窗口右下角手柄可缩放窗口,尺寸自动保存<br>
5. 对话历史从后端加载(点击头部时钟图标查看)<br>
6. AI 回复支持 Markdown 渲染<br>
7. SDK 默认启用 RAG 检索;开启 showCategorySwitch 可选择知识库分类
</div>
<hr class="divider">
<!-- 生成接入代码 -->
<div class="code-section">
<div class="code-section__header">
<span class="code-section__title">📋 接入代码</span>
<div style="display:flex;gap:6px;align-items:center">
<div class="code-tabs">
<button class="code-tab code-tab--active" id="tab-snippet" onclick="switchCodeTab('snippet',this)">Script 片段</button>
<button class="code-tab" id="tab-fullpage" onclick="switchCodeTab('fullpage',this)">完整页面</button>
</div>
<button class="code-section__copy" id="btn-copy" onclick="copyCode()">📋 复制代码</button>
</div>
</div>
<div class="code-section__body">
<pre id="code-output"></pre>
</div>
</div>
</div>
</div>
<div class="main">
<div class="toolbar">
<h2>📋 测试用例</h2>
<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">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="footer">
<span id="footer-info">ChatbotSDK v1.2.0 | 后端:检测中...</span>
<span id="footer-time"></span>
</div>
</div>
</div>
<script src="/sdk/chatbot-sdk.min.js?v=2"></script>
<script>
(function() {
'use strict';
let passCount = 0, failCount = 0, skipCount = 0, apiCalls = 0, apiDurations = [], currentFilter = 'all';
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('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, phase) {
const container = getEl('cases-container');
const caseEl = document.createElement('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 = 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();
}
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() {
const token = getEl('cfg-token').value.trim() || undefined;
console.log('[getCfg] token input value:', getEl('cfg-token').value, 'trimmed:', token);
const cfg = {
integrateId: getEl('cfg-iid').value, // 对应后端 roleId
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,
height: parseInt(getEl('cfg-height').value) || 520,
resizable: getEl('cfg-resizable').value === '1',
streaming: getEl('cfg-stream').value === '1',
locale: getEl('cfg-locale').value,
showCategorySwitch: getEl('cfg-cat').value === '1',
enableRag: getEl('cfg-rag').value === '1',
debug: getEl('cfg-debug').value === '1',
theme: getEl('cfg-theme-mode').value,
showClear: getEl('cfg-clear').value === '1',
watermark: getEl('cfg-watermark').value.trim() || undefined,
};
// 有 Token 时传入鉴权参数
if (token) {
cfg.token = token;
// roles 从 Token 换取接口获取(缓存在 window.__sdkRoles)
if (window.__sdkRoles) cfg.roles = window.__sdkRoles;
}
console.log('[getCfg] final cfg:', cfg);
return cfg;
}
/**
* 根据 Token 输入框内容更新初始化按钮状态
*/
function updateInitButton() {
const btn = getEl('btn-init');
if (!btn) return;
const hasToken = !!getEl('cfg-token').value.trim();
btn.disabled = !hasToken;
btn.style.opacity = hasToken ? '1' : '0.6';
btn.title = hasToken ? '' : '请先获取 SDK Token';
}
/**
* 用 API Key 换取 SDK Token
*/
window.fetchToken = async function() {
const apiKey = getEl('cfg-apikey').value.trim();
if (!apiKey) { alert('请先输入 API Key'); return; }
const domain = getEl('cfg-domain').value || window.location.origin;
const btn = getEl('btn-fetch-token');
btn.textContent = '获取中...';
btn.disabled = true;
try {
const res = await fetch(domain + '/open-api/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey
},
body: JSON.stringify({ ttl: 7200 })
});
const data = await res.json();
if (data.success && data.token) {
getEl('cfg-token').value = data.token;
// 缓存 roles 供 SDK 使用
window.__sdkRoles = data.roles;
// 自动填充第一个角色的 ID
if (data.roles && data.roles.length > 0) {
getEl('cfg-iid').value = data.roles[0].id;
}
// 刷新接入代码
generateCode();
// 如果 SDK 已加载,自动用 Token 重新初始化
if (document.getElementById('csk-launcher')) {
ChatbotSDK.destroy();
setTimeout(() => ChatbotSDK.init(getCfg()), 100);
}
btn.textContent = '✅ 已获取';
btn.style.background = '#059669';
setTimeout(() => { btn.textContent = '获取 Token'; btn.style.background = ''; btn.disabled = false; }, 2000);
updateInitButton();
} else {
alert('换取失败: ' + (data.message || '未知错误'));
btn.textContent = '获取 Token';
btn.disabled = false;
updateInitButton();
}
} catch (e) {
alert('请求失败: ' + e.message);
btn.textContent = '获取 Token';
btn.disabled = false;
updateInitButton();
}
};
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function 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;}
/**
* 带 SDK Token 的 fetch 封装(/ai/ 路径自动注入 Authorization)
*/
function authFetch(url, options) {
const token = getEl('cfg-token').value.trim();
const opts = Object.assign({}, options);
if (token && url.includes('/ai/')) {
opts.headers = Object.assign({}, opts.headers || {}, { 'Authorization': 'Bearer ' + token });
}
return fetch(url, opts);
}
// ==================== 接入代码生成 ====================
// 当前代码展示 Tab:'snippet'(Script 片段)| 'fullpage'(完整 HTML 页面)
let codeTab = 'snippet';
// SDK 可选参数的默认值(与 config.ts parseConfig 严格对齐),用于判断是否需要在生成代码中输出
const SDK_DEFAULTS = {
title: 'AI 智能助手',
width: 500,
height: 520,
position: 'right-bottom',
primaryColor: '#4F46E5',
streaming: true,
locale: 'zh-CN',
showCategorySwitch: false,
enableRag: true,
resizable: true,
theme: 'light',
showClear: true,
};
// 生成 Script 片段代码(纯文本)
function buildCodeSnippet() {
const cfg = getCfg();
const domain = cfg.requestDomain;
// integrateId:纯数字输出为数字字面量,否则输出字符串
const iid = String(cfg.integrateId);
const iidLit = /^\d+$/.test(iid) ? iid : JSON.stringify(iid);
const params = [];
// 必传参数
params.push(' integrateId: ' + iidLit + ', // 必传:客服角色 ID(对应后端 roleId)');
params.push(' requestDomain: ' + JSON.stringify(domain) + ', // 必传:后端 API 域名');
// Token 鉴权(推荐)
const token = getEl('cfg-token').value.trim();
if (token) {
params.push(' token: ' + JSON.stringify(token) + ', // SDK JWT Token(有效期约2小时,生产环境请从后端动态换取)');
if (window.__sdkRoles) {
params.push(' roles: ' + JSON.stringify(window.__sdkRoles) + ', // 可用客服角色列表');
}
} else {
params.push(' token: ' + "'<从后端获取的 SDK Token>', // 推荐:SDK JWT Token(POST /open-api/auth/token 换取)");
}
// 可选参数:仅输出与默认值不同的项
if (cfg.userId) params.push(' userId: ' + JSON.stringify(cfg.userId) + ', // 可选:外部用户ID(对应后端 accountId)');
if (cfg.title !== SDK_DEFAULTS.title) params.push(' title: ' + JSON.stringify(cfg.title) + ', // 弹窗标题');
if (cfg.primaryColor !== SDK_DEFAULTS.primaryColor) params.push(' primaryColor: ' + JSON.stringify(cfg.primaryColor) + ', // 主色调');
if (cfg.position !== SDK_DEFAULTS.position) params.push(' position: ' + JSON.stringify(cfg.position) + ', // 悬浮按钮位置');
if (cfg.width !== SDK_DEFAULTS.width) params.push(' width: ' + cfg.width + ', // 弹窗宽度(px)');
if (cfg.height !== SDK_DEFAULTS.height) params.push(' height: ' + cfg.height + ', // 弹窗高度(px),最小 300');
if (cfg.resizable !== SDK_DEFAULTS.resizable) params.push(' resizable: ' + cfg.resizable + ', // 允许拖拽缩放窗口');
if (cfg.theme !== SDK_DEFAULTS.theme) params.push(' theme: ' + JSON.stringify(cfg.theme) + ', // 主题模式');
if (cfg.streaming !== SDK_DEFAULTS.streaming) params.push(' streaming: ' + cfg.streaming + ', // 是否启用流式输出');
if (cfg.locale !== SDK_DEFAULTS.locale) params.push(' locale: ' + JSON.stringify(cfg.locale) + ', // 界面语言');
if (cfg.showCategorySwitch !== SDK_DEFAULTS.showCategorySwitch) params.push(' showCategorySwitch: ' + cfg.showCategorySwitch + ', // 是否显示知识库切换');
if (cfg.enableRag !== SDK_DEFAULTS.enableRag) params.push(' enableRag: ' + cfg.enableRag + ', // 启用 RAG 知识库检索');
if (cfg.showClear !== SDK_DEFAULTS.showClear) params.push(' showClear: ' + cfg.showClear + ', // 显示清空按钮');
const watermark = getEl('cfg-watermark').value.trim();
if (watermark) params.push(' watermark: ' + JSON.stringify(watermark) + ', // 聊天窗口水印文字');
// debug 强制 false(生产环境推荐),无论面板当前值
params.push(' debug: false // 调试日志,生产环境建议关闭');
const initBlock = 'ChatbotSDK.init({\n' + params.join('\n') + '\n});';
return '<!-- ChatbotSDK 智能客服 -->\n' +
'<script src="' + domain + '/sdk/chatbot-sdk.min.js"><\/script>\n' +
'<script>\n' + initBlock + '\n<\/script>';
}
// 生成完整 HTML 页面代码(纯文本)
function buildFullPage() {
const snippet = buildCodeSnippet();
const indented = snippet.split('\n').map(l => ' ' + l).join('\n');
return '<!DOCTYPE html>\n' +
'<html lang="zh-CN">\n' +
'<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>AI 智能助手</title>\n' +
'</head>\n' +
'<body>\n' + indented + '\n</body>\n' +
'</html>';
}
// 根据当前 Tab 返回纯文本代码
function getCurrentCode() {
return codeTab === 'fullpage' ? buildFullPage() : buildCodeSnippet();
}
// 语法高亮:先转义 HTML,再用单遍正则匹配 HTML 注释 / 字符串 / 行注释 / 数字 / 对象 key
function highlightCode(code) {
const esc = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const re = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|watermark|showAdminPanel|launcherIcon|token|roles)(?=\s*:)/g;
return esc.replace(re, (m, htmlCmt, str, cmt, num, key) => {
if (htmlCmt) return '<span class="cm">' + htmlCmt + '</span>';
if (str) return '<span class="st">' + str + '</span>';
if (cmt) return '<span class="cm">' + cmt + '</span>';
if (num) return '<span class="nu">' + num + '</span>';
if (key) return '<span class="kw">' + key + '</span>';
return m;
});
}
// 刷新代码预览区
function generateCode() {
const out = getEl('code-output');
if (out) out.innerHTML = highlightCode(getCurrentCode());
}
// 复制代码到剪贴板(带降级方案)
function fallbackCopy(text, cb) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); cb(); } catch (e) { /* 忽略 */ }
document.body.removeChild(ta);
}
window.copyCode = function() {
const code = getCurrentCode();
const btn = getEl('btn-copy');
const done = function() {
if (btn) {
btn.textContent = '✅ 已复制';
btn.classList.add('code-section__copy--ok');
setTimeout(function() {
btn.textContent = '📋 复制代码';
btn.classList.remove('code-section__copy--ok');
}, 1500);
}
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(done).catch(function() { fallbackCopy(code, done); });
} else {
fallbackCopy(code, done);
}
};
// 切换代码 Tab
window.switchCodeTab = function(tab, btn) {
codeTab = tab;
document.querySelectorAll('.code-tab').forEach(function(t) { t.classList.remove('code-tab--active'); });
if (btn) btn.classList.add('code-tab--active');
generateCode();
};
window.doInit=function(){
const token = getEl('cfg-token').value.trim();
if (!token) {
alert('请先点击「获取 Token」按钮,用 API Key 换取 SDK Token 后再初始化。');
return;
}
ChatbotSDK.destroy();
ChatbotSDK.init(getCfg());
const tag=getEl('tag-sdk');
if(document.getElementById('csk-launcher')){tag.className='tag tag--pass';tag.textContent='✅ SDK 就绪';}
};
window.doDestroy=function(){ChatbotSDK.destroy();const tag=getEl('tag-sdk');tag.className='tag tag--idle';tag.textContent='⭕ SDK 未加载';};
window.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';});};
// ==================== 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+' 个方法)');}
async function test2(log,m){ChatbotSDK.init({});await sleep(200);assert(!document.getElementById('csk-launcher'),'缺失 integrateId 时不应创建 DOM');log('✅ 缺失 integrateId 正确拦截','log-line--pass');m('pass','通过');}
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','通过');}
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','通过');}
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','通过');}
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','通过');}
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','通过');}
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','通过');}
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','通过');}
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','通过');}
async function test11(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(500);ChatbotSDK.open();await sleep(300);const input=getEl('csk-input');if(!input){log('⚠ 输入框不存在','log-line--warn');m('skip','输入框不存在');return;}input.value='你好';input.dispatchEvent(new Event('input'));await sleep(100);const sendBtn=getEl('csk-send-btn');if(sendBtn)sendBtn.click();log('✅ 点击发送按钮','log-line--info');await sleep(8000);const aiMsgs=document.querySelectorAll('.csk-msg--ai .csk-msg__bubble');if(aiMsgs.length>0){const lastAi=aiMsgs[aiMsgs.length-1];log('AI 回复: '+(lastAi.textContent||'').substring(0,100),'log-line--pass');m('pass','通过(有AI回复)');}else{log('⚠ 等待 AI 回复超时','log-line--warn');m('skip','AI 未回复');}}
async function test12(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent('你好')+'&chatId=verify_test&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('❌ fetch 失败','log-line--fail');throw new Error('后端未运行');}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(r.ok,'HTTP '+r.status);const text=await r.text();assert(text.length>3,'回复过短');log('AI 回复: '+text.substring(0,100),'log-line--pass');log('参数: chatId=verify_test roleId='+cfg.integrateId,'log-line--info');m('pass','通过 ('+el+'ms)');}
async function test13(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/sse?message='+encodeURIComponent('你好')+'&chatId=verify_sse&roleId='+cfg.integrateId;apiCalls++;const t0=performance.now();let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('❌ SSE 失败','log-line--fail');throw new Error('SSE 失败');}assert(r.ok,'HTTP '+r.status);const reader=r.body.getReader();const decoder=new TextDecoder();let chunks=0,total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});chunks++;}}finally{reader.releaseLock();}const el=Math.round(performance.now()-t0);apiDurations.push(el);updateStats();assert(total.length>3,'SSE 回复过短');log('✅ SSE '+chunks+' chunks, '+total.length+' chars','log-line--pass');m('pass','通过 ('+el+'ms)');}
// ==================== P1 ====================
async function test14(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(getCfg());await sleep(300);const s=document.querySelector('style[data-csk-sdk]');const css=s?s.textContent:'';assert(css.includes('csk-md-code-block'),'代码块样式');log('✅ 代码块样式','log-line--pass');assert(css.includes('csk-md-link'),'链接样式');log('✅ 链接样式','log-line--pass');m('pass','通过');}
async function test15(log,m){ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:true}));await sleep(300);const sel=document.getElementById('csk-category-select');assert(!!sel,'分类下拉框未创建');log('✅ 分类下拉框已创建','log-line--pass');ChatbotSDK.destroy();await sleep(100);ChatbotSDK.init(Object.assign(getCfg(),{showCategorySwitch:false}));await sleep(300);assert(!document.getElementById('csk-category-select'),'关闭后不应创建');log('✅ showCategorySwitch=false','log-line--pass');m('pass','通过');}
async function test16(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/chat/rag/sse?message='+encodeURIComponent('你好')+'&chatId=verify_rag&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;log('请求 RAG SSE...','log-line--info');let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(20000)});}catch(e){log('⚠ RAG SSE 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const reader=r.body.getReader();const decoder=new TextDecoder();let total='';try{while(true){const x=await reader.read();if(x.done)break;total+=decoder.decode(x.value,{stream:true});}}finally{reader.releaseLock();}assert(total.length>3,'RAG 回复过短');log('✅ RAG SSE 回复 '+total.length+' chars','log-line--pass');m('pass','通过');}
async function test17(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/ai/assistant_app/rag/sources?message='+encodeURIComponent('请假')+'&chatId=verify_sources&roleId='+cfg.integrateId+'&rewriteStrategy=REWRITE';apiCalls++;let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const json=await r.json();log('返回 success='+json.success+' data='+(json.data?json.data.length:0)+' 条','log-line--info');assert(json.success!==undefined,'应返回 success 字段');log('✅ RAG 引用来源接口可用','log-line--pass');m('pass','通过');}
async function test18(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/category/tree';apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 分类树 '+(json.data?json.data.length:0)+' 个根节点','log-line--pass');m('pass','通过');}
// ==================== 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','通过');}
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','通过');}
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','通过');}
async function test22(log,m){const cfg=getCfg();if(cfg.userId){const url=cfg.requestDomain+'/conversation/list?page=1&size=10&accountId='+encodeURIComponent(cfg.userId)+'&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (accountId='+cfg.userId+', roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 按外部用户+角色过滤 total='+json.total,'log-line--pass');m('pass','通过');}else{log('⚠ 未设置 userId,跳过','log-line--warn');m('skip','未设置 userId');}}
// ========== 参数映射验证 ==========
async function test23(log,m){log('验证参数映射:integrateId→roleId, userId→accountId','log-line--info');const cfg=getCfg();const msg='test_mapping';const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent(msg)+'&chatId=verify_mapping&roleId='+cfg.integrateId+(cfg.userId?'&accountId='+encodeURIComponent(cfg.userId):'');log('请求 URL: '+url.substring(0,120)+'...','log-line--info');apiCalls++;let r;try{r=await authFetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ fetch 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const text=await r.text();assert(text.length>0,'应有回复');log('✅ integrateId('+cfg.integrateId+')→roleId, userId('+(cfg.userId||'未设置')+')→accountId 映射正确','log-line--pass');log('AI 回复: '+text.substring(0,80),'log-line--info');m('pass','通过');}
window.runAll = async function() {
passCount=0;failCount=0;skipCount=0;apiCalls=0;apiDurations=[];
updateStats();ChatbotSDK.destroy();getEl('cases-container').innerHTML='';await sleep(300);
const tests = [
[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'],
];
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(){
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 已加载';}
// 监听配置表单变化,实时刷新接入代码
['cfg-iid','cfg-domain','cfg-uid','cfg-title','cfg-color','cfg-pos','cfg-width','cfg-height','cfg-resizable','cfg-stream','cfg-locale','cfg-cat','cfg-rag','cfg-clear','cfg-debug','cfg-theme-mode','cfg-apikey','cfg-token','cfg-watermark'].forEach(function(id){
const el=getEl(id);
if(!el)return;
el.addEventListener('input', generateCode);
el.addEventListener('change', generateCode);
if(id==='cfg-token'){
el.addEventListener('input', updateInitButton);
el.addEventListener('change', updateInitButton);
}
});
generateCode();
updateInitButton();
// 后端连通性探测(使用 Token 或直接请求)
const probeToken = getEl('cfg-token').value.trim();
const probeHeaders = probeToken ? { 'Authorization': 'Bearer ' + probeToken } : {};
fetch(window.location.origin+'/ai/assistant_app/chat/sync?message=test&chatId=__probe__&roleId=1',{signal:AbortSignal.timeout(5000),headers:probeHeaders}).then(r=>{const tag=getEl('tag-api');if(r.ok||r.status<500){tag.className='tag tag--pass';tag.textContent='✅ 后端连通';setEl('footer-info','ChatbotSDK v1.2.0 后端在线 ');}}).catch(()=>{const tag=getEl('tag-api');tag.className='tag tag--fail';tag.textContent='⚠ 后端离线';});
})();
})();
</script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-DSxJA5s7.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-rhtNERDv.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-DVS5zdio.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-CeGDR2d-.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-DYDVv77n.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-iiOpcNx_.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-nsiAALas.css">
</head>
<body>
<div id="sdk-test-app"></div>
</body>
</html> </html>
Loading…
Cancel
Save