22 Commits
23f195326d
...
08fdc2e82d
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
08fdc2e82d |
修改样式
|
4 days ago |
|
|
321bee93ff |
fix(mcp): 修复 SSE 端点带路径前缀时连接 404
HttpClientSseClientTransport 内部用 URI.resolve 拼接端点,对以 / 开头的 sseEndpoint 会整体替换 path,导致 /mcp-cdw/sse 被解析成 /sse 丢失前缀。 改为显式拆分 origin 与 path,分别作为 baseUri 与 sseEndpoint 传入, 并兼容用户填 base 或完整 SSE URL 两种写法。 |
4 days ago |
|
|
11572a7023 |
修改样式
|
4 days ago |
|
|
83964afcfc |
refactor(client): 外围组件原生化并清理死代码,缩小 SDK 体积
- t-select/t-dialog/t-alert/t-tag/t-button 替换为原生 DOM,删 5 条组件 import 与 omi 直接依赖,min 版缩小约 212KB - 清理死代码:images.d.ts、失效测试、dist 残留、getMessages、死类型、showAdminPanel、未用 i18n key、t-tag 死 CSS - 修复 i18n 缺失 confirm/cancel 翻译与自绘确认框 ESC 冲突 |
4 days ago |
|
|
a2971ba2c2 |
修改样式
|
4 days ago |
|
|
b56cea0c8c |
fix(client): 历史会话时间智能分档显示并修复操作栏按钮 tooltip
历史会话列表时间由原始 ISO 字符串改为智能分档:今天/昨天显示 HH:mm,同年显示 MM-DD HH:mm,跨年显示 YYYY-MM-DD HH:mm。 操作栏按钮(重新生成/复制/点赞/点踩)补齐 hover 文字说明:修复 tooltip 浮层 z-index 低于聊天窗(9999)被遮挡,及 .t-tooltip 深色背景样式在 light DOM 浮层缺失的问题(globalCSS 只对 shadow DOM 生效)。 |
4 days ago |
|
|
431b1f2b1d |
fix(client): 修复第三方接入 SDK 后 markdown 排版与标签样式缺失
根因:tdesign-web-components 的聊天/markdown 变量(--td-chat-*/--td-chat-md-*/--td-chat-input-*) 定义在 globalCSS 的 constructable stylesheet 中,选择器为 :root;但 adoptedStyleSheets 里的 :root 不匹配 shadow root,导致这些变量无法进入渲染 markdown 的 shadow DOM,标题/列表/引用/ 代码块回退为初始值。t-tag 是 light DOM 组件,其 .t-tag 样式同样只在 globalCSS 中、进不了 light DOM。 改动: - styles.ts 新增 tdChatVars(),将聊天/markdown/input 变量自包含声明在 .csk-root 上 - styles.ts 补注入 .csk-root .t-tag 系列 light DOM 样式 - 配合此前 tdThemeVars() 的主题变量,SDK 样式完全自包含,不再依赖宿主页面 |
5 days ago |
|
|
da031c6ff0 |
fix(client): 修复重新生成清空历史,优化引用来源/推荐问题与历史滚动
- 重新生成改为调用后端会话截断接口,同步前后端数据,避免历史回复被清空 - 引用来源折叠开关与推荐问题改用 t-button,风格对齐 ChatActionbar 操作栏 - 历史消息渲染后延迟滚动到底部,修复停在滚动条中间的问题 |
5 days ago |
|
|
021dfa9199 |
回复内容自适应 SDK 弹窗宽度
|
5 days ago |
|
|
a1328994a5 |
feat(mcp): MCP 服务配置支持自定义请求头鉴权
- mcp_server_config 新增 headers JSONB 字段,SSE 模式通过 customizeRequest 注入请求头 - 敏感头(Authorization 等)返回前脱敏,更新时未改动保留原值 - DatabaseInitConfig 与 init-database.sql 同步新增 headers 列迁移 - McpClientManager 增加 @DependsOn(databaseInitConfig) 保证列迁移先于客户端加载 - 前端 MCP 配置弹窗新增请求头输入框 |
5 days ago |
|
|
97f8f1a8f0 |
回复内容自适应 SDK 弹窗宽度
|
5 days ago |
|
|
e23b70ffca |
feat(client): SDK 界面重构改用 TDesign Web Components 组件
- 聊天核心:消息气泡/输入框/操作栏/加载态改用 t-chat-message/t-chat-sender/t-chat-action/t-chat-loading - 外围 UI:知识库下拉/推荐问题/离线横幅改用 t-select/t-tag/t-alert - Markdown 渲染由 t-chat-item 内置 cherry-markdown 承担,移除自研 marked 渲染器与 marked.min.js - 构建管线:引入 tdesign-web-components@1.2.10 按需打包,mermaid 用本地 stub 替换省约 1.2MB - 清理重构后死代码与死样式(escapeHtml/getLocale/appendStreamingCaret/offlineBannerEl 等) - 同步更新测试面板断言(T3/T6/T10 适配 t-chat-sender)与 README/CLAUDE.md |
6 days ago |
|
|
b9f91d0903 |
fix(sse): 修复 SSE 鉴权头覆盖问题并同步测试用例文档
- sse.ts authHeaders 仅当调用方未显式提供 Authorization 时才注入管理后台 token,避免覆盖测试面板传入的 SDK Token - client/README.md、client/CLAUDE.md 同步测试用例数量 22 → 10 |
6 days ago |
|
|
8132f26891 |
feat(sdk-test): 测试面板重构为 Vue 3 + TDesign Chat 应用
- 新增 Vite 多页入口 sdk-test.html 与 SdkTestPanel.vue,用 TDesign Chat(ChatList/ChatSender/ChatActionbar)渲染对话演示 - 对话演示直调 GET /ai/chat/stream(OpenAI 格式),复用 readSSEStream 解析 delta.content,支持同步/流式、RAG 开关与来源展示 - SDK 集成验证区精简为 10 个核心用例,接口统一迁到新路径 /ai/chat* - cp-to-static.mjs 构建后将 sdk-test.html 移动到 static/sdk/test.html |
6 days ago |
|
|
2f09b3fabf |
feat(client): SDK 悬浮窗口对话视觉对齐 TDesign Chat 规范
- styles.ts 对齐气泡圆角/头像/时间戳/输入框/发送按钮/加载态/操作栏/来源卡片/快捷芯片视觉 - dom.ts 微调时间戳与操作栏相对位置 - 保持零依赖 IIFE、csk-* 类名零增删改、主色变量与暗色模式同步 |
6 days ago |
|
|
940caedea8 |
feat(部署): 前端静态资源免认证访问与打包配置
- pom.xml 将 static/** 单独作为资源目录,关闭 filtering 并指定 targetPath 避免平铺到 classes 根目录 - JwtAuthFilter 跳过首页与静态资源路径,避免管理后台密钥误校验产生 WARN 日志 - SecurityConfig 放行 /、/index.html、/assets/**、/sdk/** 等静态资源与首页 |
6 days ago |
|
|
60d336d384 |
fix(前端): 反馈看板时间列统一 formatDate 格式化
- 反馈看板「时间」列改用 formatDate 输出 YYYY-MM-DD HH:mm:ss,空值显示 - - 提示词追踪移除重复的 fmtDateTime 内联实现,复用统一 formatDate - 反馈看板新增「补充说明」列及消息详情补充说明展示 |
6 days ago |
|
|
6be019082e |
docs: 同步对话接口迁移与 OpenAI SSE 格式说明
- README.md、SDK-INTEGRATION.md、client/README.md、client/CLAUDE.md 四处接口表更新为新路径 /ai/chat、/ai/chat/stream、/ai/chat/sources - 补充流式接口 OpenAI Chat Completions SSE 格式说明(data: JSON chunk + data: [DONE]) - 删除废弃的 /ai/assistant_app/* 路径与 rag/sync 描述 |
6 days ago |
|
|
b0319392a2 |
feat(client): 前端与 SDK 迁移新对话接口并支持 OpenAI SSE 解析
- 前端 chat.ts 合并为 chatSync/chatSSEUrl/ragSources 三个函数,统一走 /ai/chat、/ai/chat/stream、/ai/chat/sources,用 ChatOptions + enableRag 布尔参数统一普通/RAG 路径 - 前端 sse.ts 新增 extractOpenAIDelta 解析 choices[0].delta.content,识别 error chunk,JSON 解析失败回退纯文本,旧纯文本流向后兼容 - ChatPanel.vue 将 RAG sync/SSE/普通 sync/SSE 四分支合并为 sync/SSE 两分支 - SDK api.ts 迁移 URL 至 /ai/chat 系列,chatSSERequest 抽取 emitRawChunk 解析 OpenAI delta 与 error,正确处理 data: [DONE] |
6 days ago |
|
|
1cd8ed1248 |
feat(chat): 流式对话输出对齐 OpenAI Chat Completions 标准 SSE 格式
- 新增 AssistantApp.chatStreamOpenAi,复用 chatStream 编排逻辑,将流式文本分片包装为 data: {"choices":[{"delta":{"content":"..."}}]} 标准 JSON chunk
- 首片 delta 携带 role=assistant,流结束时追加 finish_reason=stop chunk 与 data: [DONE];熔断降级、FAQ 命中、错误兜底统一走 openAiFallbackStream
- AiController、OpenApiController 的流式接口改调 chatStreamOpenAi,旧路径保留纯文本输出向后兼容
|
6 days ago |
|
|
953a3c96ab |
fix(chat): 修复 ChatList 消息显示为骨架屏的问题
ChatList 内部会把任何 truthy 的 status 强制重写为 "pending", 导致所有历史消息都显示为骨架屏。移除 chatAdapter 中的 status 字段, 由 ChatPanel.vue 的 #content slot 自行控制流式/错误态渲染。 |
6 days ago |
|
|
7f6b696885 |
refactor(chat): 使用 TDesign Chat 组件重构聊天面板
安装 @tdesign-vue-next/chat@0.6.0 并新增 chatAdapter.ts 统一消息格式适配; 将 ChatPanel.vue 的手写消息列表、输入框与操作栏替换为 ChatList/ChatSender/ChatActionbar,保留 SSE、RAG、MCP、反馈、推荐问题、编辑及重新生成等业务逻辑; 同步优化 vite.config.ts 的 manualChunks,将 tdesign-chat、tdesign-web-components 与 tdesign 拆分为独立 chunk。 |
1 week ago |
50 changed files with 7408 additions and 2765 deletions
-
10README.md
-
9SDK-INTEGRATION.md
-
18client/CLAUDE.md
-
95client/README.md
-
69client/assets/marked.min.js
-
2526client/package-lock.json
-
6client/package.json
-
24client/rollup.config.js
-
129client/src/api.ts
-
348client/src/chat.ts
-
17client/src/config.ts
-
629client/src/dom.ts
-
39client/src/i18n.ts
-
5client/src/images.d.ts
-
81client/src/index.ts
-
10client/src/logger.ts
-
242client/src/markdown.ts
-
39client/src/mermaid-stub.ts
-
1322client/src/styles.ts
-
37client/src/types.ts
-
33client/src/utils.ts
-
100client/tests/i18n.test.ts
-
86client/tests/utils.test.ts
-
12frontend/cp-to-static.mjs
-
1112frontend/package-lock.json
-
1frontend/package.json
-
12frontend/sdk-test.html
-
72frontend/src/api/chat.ts
-
1477frontend/src/sdk-test/SdkTestPanel.vue
-
14frontend/src/sdk-test/main.ts
-
1frontend/src/types/models.ts
-
50frontend/src/utils/chatAdapter.ts
-
37frontend/src/utils/sse.ts
-
370frontend/src/views/ChatPanel.vue
-
12frontend/src/views/FeedbackOps.vue
-
16frontend/src/views/McpServerManager.vue
-
9frontend/src/views/PromptTracePanel.vue
-
15frontend/vite.config.ts
-
9pom.xml
-
165src/main/java/com/wok/supportbot/app/AssistantApp.java
-
25src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
36src/main/java/com/wok/supportbot/config/McpClientManager.java
-
2src/main/java/com/wok/supportbot/controller/AiController.java
-
2src/main/java/com/wok/supportbot/controller/OpenApiController.java
-
6src/main/java/com/wok/supportbot/entity/McpServerConfig.java
-
12src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
-
3src/main/java/com/wok/supportbot/security/SecurityConfig.java
-
102src/main/java/com/wok/supportbot/service/McpServerConfigService.java
-
2src/main/resources/init-database.sql
-
725src/main/resources/static/sdk/test.html
69
client/assets/marked.min.js
File diff suppressed because it is too large
View File
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
File diff suppressed because it is too large
View File
@ -1,5 +0,0 @@ |
|||||
/** 图片导入类型声明(Rollup @rollup/plugin-image 会将 PNG 转为 base64 data URL) */ |
|
||||
declare module '*.png' { |
|
||||
const src: string; |
|
||||
export default src; |
|
||||
} |
|
||||
@ -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; |
|
||||
} |
|
||||
@ -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,模拟「同步版 mermaid」(render 为 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
File diff suppressed because it is too large
View File
@ -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; |
|
||||
} |
|
||||
@ -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( |
|
||||
'<script>alert("xss")</script>' |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
it('转义所有五种危险字符', () => { |
|
||||
expect(escapeHtml('&<>"\'')).toBe('&<>"''); |
|
||||
}); |
|
||||
|
|
||||
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(); |
|
||||
}); |
|
||||
}); |
|
||||
1112
frontend/package-lock.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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> |
||||
1477
frontend/src/sdk-test/SdkTestPanel.vue
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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') |
||||
@ -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 |
||||
|
} |
||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); |
|
||||
const re = /(<!--[\s\S]*?-->)|('[^']*'|"[^"]*")|(\/\/[^\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> |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue