Browse Source

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
master
wanghanlin 6 days ago
parent
commit
e23b70ffca
  1. 10
      client/CLAUDE.md
  2. 20
      client/README.md
  3. 69
      client/assets/marked.min.js
  4. 2526
      client/package-lock.json
  5. 6
      client/package.json
  6. 24
      client/rollup.config.js
  7. 257
      client/src/chat.ts
  8. 611
      client/src/dom.ts
  9. 7
      client/src/i18n.ts
  10. 86
      client/src/index.ts
  11. 242
      client/src/markdown.ts
  12. 39
      client/src/mermaid-stub.ts
  13. 581
      client/src/styles.ts
  14. 12
      client/src/utils.ts
  15. 27
      frontend/src/sdk-test/SdkTestPanel.vue
  16. 4
      src/main/resources/static/sdk/test.html

10
client/CLAUDE.md

@ -25,7 +25,7 @@ npm run dev
- 构建工具:Rollup + `@rollup/plugin-typescript` + `@rollup/plugin-terser`,配置见 `rollup.config.js`
- TypeScript 配置:`tsconfig.json`,`target: ES2017`,`strict: true`,`rootDir: ./src`,不生成 `.d.ts`
- 产物双份:`chatbot-sdk.js`(未压缩 + sourcemap,~93KB)和 `chatbot-sdk.min.js`(压缩,~45KB
- 产物双份:`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 第十四节),本工程内没有可运行的自动化测试
## 运行时依赖
@ -64,11 +64,11 @@ 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 流式、同步)的解析逻辑都在这里,注意流式解析的边界处理。
- **`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` 里写内联样式。
- **`markdown.ts`**已废弃并移除。原基于 marked 的 Markdown 渲染器改由 TDesign Chat 组件(`t-chat-item`)内置 cherry-markdown 承担,无需额外加载 `marked.min.js`。cherry-markdown 的 XSS 防护由 TDesign 组件保证;如后续需要自定义渲染,仍需维持白名单/转义等安全策略
### 单例与生命周期
@ -102,4 +102,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。

20
client/README.md

@ -63,10 +63,10 @@ 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。
---
@ -131,9 +131,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 知识库联动
@ -354,10 +354,11 @@ z-index 分层:悬浮按钮 9998,弹窗 9999。
- **语言**:TypeScript → ES2017
- **打包**:Rollup → IIFE 格式
- **压缩**: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 字典
- **浏览器兼容**: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 +390,8 @@ client/
│ ├── storage.ts # localStorage 封装
│ ├── api.ts # HTTP 封装 + SSE 流式 + P1 RAG + P2 会话
│ ├── dom.ts # DOM 构建 + 拖拽 + P1 来源/分类 + P2 面板
│ ├── styles.ts # CSS 注入 + 主题 + P1 Markdown/来源 + P2 面板
│ ├── chat.ts # 对话核心 + P1 Markdown/RAG + P2 i18n
│ ├── markdown.ts # P1 Markdown 渲染器(基于 marked v15)
│ ├── styles.ts # CSS 注入 + 主题(--csk-* 与 --td-* 变量透传)
│ ├── chat.ts # 对话核心 + P1 RAG + P2 i18n(Markdown 由 t-chat-item 内置 cherry-markdown 处理)
│ ├── i18n.ts # P2 多语言国际化
│ └── utils.ts # UUID、防抖、XSS 转义
├── dist/ # 构建产物

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"
},
"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-typescript": "^12.1.0",
"rollup": "^4.30.0",
"tslib": "^2.8.0",
"typescript": "^5.7.0",
"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 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 { dirname, resolve } from 'path';
@ -9,7 +12,6 @@ const BACKEND_DIR = resolve(ROOT, '../src/main/resources/static/sdk');
const ASSETS = [
{ 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'];
@ -57,16 +59,36 @@ export default {
format: 'iife',
name: 'ChatbotSDK',
sourcemap: false,
inlineDynamicImports: true,
},
{
file: 'dist/chatbot-sdk.min.js',
format: 'iife',
name: 'ChatbotSDK',
sourcemap: false,
inlineDynamicImports: true,
plugins: [terser()],
},
],
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({
tsconfig: './tsconfig.json',
declaration: false,

257
client/src/chat.ts

@ -29,22 +29,22 @@ import {
import {
renderUserBubble,
renderAIBubble,
renderErrorBubble,
createEmptyAIBubble,
scrollToBottom,
renderSources,
renderHistoryList,
appendStreamingCaret,
finalizeAIBubble,
updateFeedbackUI,
showOfflineBanner,
hideOfflineBanner,
renderSuggestions,
removeSuggestions,
showFeedbackReasonPanel,
resetChatActionFeedback,
confirmWithDialog,
HistoryItemData,
} from './dom';
import { saveMessages, loadMessages, clearMessages } from './storage';
import { renderMarkdown } from './markdown';
import { logger } from './logger';
import { t } from './i18n';
import { uuid, now } from './utils';
@ -52,12 +52,10 @@ import { uuid, now } from './utils';
let config: ResolvedConfig | null = null;
let messages: ChatMessage[] = [];
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 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 welcomeEl: HTMLElement | null = null;
let newMsgBtn: HTMLElement | null = null;
@ -90,11 +88,10 @@ export function initChat(
cfg: ResolvedConfig,
dom: {
messagesContainer: HTMLElement;
inputEl: HTMLTextAreaElement;
sendBtn: HTMLElement;
inputEl: HTMLElement;
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
categorySelect: HTMLElement | null;
roleSelect: HTMLElement | null;
historyPanel: HTMLElement;
welcomeEl: HTMLElement;
newMsgBtn: HTMLElement;
@ -107,8 +104,6 @@ export function initChat(
config = cfg;
messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl;
inputWrap = dom.inputEl.parentElement;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
roleSelect = dom.roleSelect;
@ -196,38 +191,22 @@ async function loadHistoryFromBackend(): Promise<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) {
clearBtn.addEventListener('click', () => handleClear());
}
@ -273,21 +252,14 @@ function smartScrollToBottom(): void {
}
}
/** 切换发送按钮模式:发送 / 停止生成 */
/** 切换发送按钮模式:发送 / 停止生成(由 t-chat-sender 的 loading 属性承载) */
function setSendButtonMode(mode: 'send' | 'stop'): void {
if (!sendBtn) return;
if (!inputEl) return;
// loading 为 Boolean 简单类型,omi 下须用 setAttribute/removeAttribute(property 赋值静默失效)
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 {
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 +292,7 @@ function renderFilteredHistory(): void {
(conversationId: string) => { switchToConversation(conversationId); },
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
async (id: string) => {
if (!confirm(t('history_delete_confirm'))) return;
if (!(await confirmWithDialog(t('history_delete_confirm')))) return;
const ok = await deleteConversation(id);
if (ok) {
if (id === getChatId()) {
@ -355,27 +327,25 @@ function announceMessage(text: string): void {
/**
* AI /
* 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');
if (!msg) return;
if (value === 'up') {
// 点赞:直接切换提交,无需原因
const newValue = msg.feedback === 'up' ? undefined : 'up';
msg.feedback = newValue;
msg.feedback = active ? 'up' : undefined;
msg.feedbackReason = 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 => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=up`);
@ -389,46 +359,38 @@ export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
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 +401,17 @@ function updateEmptyState(): void {
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;
/** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */
async function handleSend(text?: string): Promise<void> {
if (!config || isSending) return;
const text = inputEl.value.trim();
if (text === '') return;
inputEl.value = '';
updateSendBtnState();
inputEl.style.height = 'auto';
const input = (text ?? '').trim();
if (input === '') return;
// 1. 渲染用户气泡
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);
updateEmptyState();
@ -472,7 +419,7 @@ async function handleSend(): Promise<void> {
if (messagesContainer) smartScrollToBottom();
// 2. 生成 AI 回复
await produceAIReply(text);
await produceAIReply(input);
}
/**
@ -508,7 +455,7 @@ async function produceAIReply(userText: string): Promise<void> {
aiContent = await chatRequest(userText);
if (hideLoadingFn) hideLoadingFn();
if (messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp, renderMarkdown, aiMsgId);
renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId);
}
}
const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp };
@ -540,21 +487,13 @@ async function produceAIReply(userText: string): Promise<void> {
const errMsg = err instanceof CskError ? err.message : t('error_send');
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);
} finally {
isSending = false;
abortController = null;
setSendButtonMode('send');
updateSendBtnState();
}
}
@ -585,20 +524,22 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
streamStarted = true;
}
if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated);
appendStreamingCaret(bubbleEl);
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
}
if (messagesContainer) smartScrollToBottom();
},
() => {
// 流结束
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
if (!streamStarted && accumulated === '') {
chatRequest(text).then(resolve).catch(reject);
return;
}
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);
}
resolve(accumulated);
@ -610,7 +551,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
}
if (accumulated.length > 0) {
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);
}
resolve(accumulated);
@ -629,10 +570,8 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
*
*/
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);
}
/**
@ -694,25 +633,27 @@ async function fetchAndRenderSources(message: string, aiMsg: ChatMessage): Promi
}
}
/** 加载知识库分类到下拉框 */
/** 加载知识库分类到下拉框(t-select,选项含缩进前缀表达层级) */
async function loadCategories(): Promise<void> {
if (!categorySelect) return;
try {
const tree = await fetchCategoryTree();
if (tree.length === 0) return;
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
const options: Array<{ label: string; value: string }> = [{ label: t('category_all'), value: '' }];
const addOptions = (nodes: typeof tree, indent: number = 0) => {
for (const node of nodes) {
const option = document.createElement('option');
option.value = String(node.id);
option.textContent = `${' '.repeat(indent)}${node.name}`;
if (currentCategoryId !== undefined && String(node.id) === String(currentCategoryId)) option.selected = true;
categorySelect!.appendChild(option);
options.push({ label: `${' '.repeat(indent)}${node.name}`, value: String(node.id) });
if (node.children && node.children.length > 0) addOptions(node.children, indent + 1);
}
};
addOptions(tree);
(categorySelect as unknown as { options: Array<{ label: string; value: string }> }).options = options;
// 若已有选中分类,回填选中值
if (currentCategoryId !== undefined) {
(categorySelect as unknown as { value: string }).value = String(currentCategoryId);
}
logger.info(`知识库分类加载成功 count=${tree.length}`);
} catch (err) {
logger.error(t('category_load_error'), err);
@ -731,9 +672,8 @@ function renderHistory(): void {
if (msg.role === 'user') {
renderUserBubble(messagesContainer, msg.content, msg.timestamp);
} 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.feedback) updateFeedbackUI(wrapper, msg.feedback);
}
}
@ -827,9 +767,9 @@ export async function switchRole(newRoleId: string): Promise<void> {
// 6. 更新 API 层的 integrateId(必须在 initChatId 之前)
setActiveRoleId(newRoleId);
// 7. 更新角色下拉框高亮
// 7. 更新角色下拉框选中
if (roleSelect) {
roleSelect.value = newRoleId;
(roleSelect as unknown as { value: string }).value = newRoleId;
}
// 8. 强制查询后端会话列表(跳过 localStorage 缓存)
@ -890,7 +830,7 @@ export async function loadHistoryConversations(): Promise<void> {
(conversationId: string) => { switchToConversation(conversationId); },
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
async (id: string) => {
if (!confirm(t('history_delete_confirm'))) return;
if (!(await confirmWithDialog(t('history_delete_confirm')))) return;
const ok = await deleteConversation(id);
if (ok) {
if (id === getChatId()) {
@ -934,9 +874,9 @@ export async function switchToConversation(conversationId: string): Promise<void
if (convRoleId && convRoleId !== currentRoleId) {
logger.info(`会话角色不匹配,自动切换角色 ${currentRoleId} -> ${convRoleId}`);
setActiveRoleId(convRoleId);
// 更新角色下拉框高亮
// 更新角色下拉框选中
if (roleSelect) {
roleSelect.value = convRoleId;
(roleSelect as unknown as { value: string }).value = convRoleId;
}
}
}
@ -1005,7 +945,8 @@ async function fetchAndShowSuggestions(aiMsgId: string): Promise<void> {
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${aiMsgId}"]`) as HTMLElement;
if (wrapper) renderSuggestions(wrapper, suggestions, sendQuickReply);
}
} catch {
// 静默降级
} catch (err) {
// 推荐问题拉取失败不影响主流程,仅记录日志便于排查
logger.warn('拉取推荐问题失败', err);
}
}

611
client/src/dom.ts

@ -10,6 +10,30 @@
import { ResolvedConfig, RagSource } from './types';
import { debounce, formatTime } from './utils';
import { t } from './i18n';
import { DialogPlugin } from 'tdesign-web-components/lib/dialog/index.js';
// omi 的 render/h 用于创建带 on* 函数回调的组件(见 createEventful 说明)
import { render, h } from 'omi';
/**
* omi render TDesign
*
* createElement + property
* omitdesign-web-components isComplexType /^on|children/ false
* DOM on* onChange/onClick handleComplexProps accessor
* attribute `el.onChange = fn` select/tag
* `this.props.onChange.call()` fire dispatchEvent omi render
* vnode attributes props
*/
function createEventful(
tagName: string,
props: Record<string, unknown>,
container: HTMLElement,
children?: string | unknown[],
): HTMLElement {
// children 作为 vnode 的 children 传入(tag/button 等通过 children/slot 渲染文本,而非 content)
const vnode = children !== undefined ? h(tagName, props, children) : h(tagName, props);
return render(vnode, container) as HTMLElement;
}
// ==================== 图标常量 ====================
@ -101,11 +125,10 @@ function createWatermarkPattern(config: ResolvedConfig): string {
export function createChatWindow(config: ResolvedConfig): {
window: HTMLElement;
messagesContainer: HTMLElement;
inputEl: HTMLTextAreaElement;
sendBtn: HTMLElement;
inputEl: HTMLElement;
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
categorySelect: HTMLElement | null;
roleSelect: HTMLElement | null;
historyPanel: HTMLElement;
welcomeEl: HTMLElement;
newMsgBtn: HTMLElement;
@ -163,36 +186,30 @@ export function createChatWindow(config: ResolvedConfig): {
headerLeft.appendChild(headerAvatar);
headerLeft.appendChild(headerInfo);
// === 角色选择器(仅当有多个角色时显示) ===
let roleSelect: HTMLSelectElement | null = null;
// === 角色选择器(仅当有多个角色时显示,t-select) ===
let roleSelect: HTMLElement | null = null;
const roles = config.roles;
if (roles && roles.length > 1) {
roleSelect = document.createElement('select');
roleSelect.className = 'csk-role-select';
roleSelect.setAttribute('aria-label', t('role_selector_label'));
for (const role of roles) {
const option = document.createElement('option');
option.value = String(role.id);
option.textContent = role.name || String(role.id);
if (String(role.id) === String(config.integrateId)) {
option.selected = true;
}
roleSelect.appendChild(option);
}
// onChange 触发自定义事件
roleSelect.addEventListener('change', () => {
const selectedValue = roleSelect!.value;
windowEl.dispatchEvent(new CustomEvent('csk:roleChange', {
detail: { roleId: selectedValue }
}));
});
// 包装后插入到 headerLeft 之后、actions 之前
const roleWrap = document.createElement('div');
roleWrap.className = 'csk-role-select-wrap';
roleWrap.appendChild(roleSelect);
// on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props
roleSelect = createEventful('t-select', {
options: roles.map(role => ({ label: role.name || String(role.id), value: String(role.id) })),
value: String(config.integrateId),
// onChange 触发自定义事件(受控:同步回写选中值)
onChange: (value: string | number) => {
if (roleSelect) {
(roleSelect as unknown as { value: string | number }).value = String(value);
}
windowEl.dispatchEvent(new CustomEvent('csk:roleChange', {
detail: { roleId: String(value) }
}));
},
}, roleWrap);
roleSelect.setAttribute('aria-label', t('role_selector_label'));
header.appendChild(headerLeft);
header.appendChild(roleWrap);
} else {
@ -245,19 +262,19 @@ export function createChatWindow(config: ResolvedConfig): {
<div class="csk-welcome__desc">${t('welcome_desc')}</div>
`;
// 快捷问题芯片(点击即自动发送)
// 快捷问题芯片(点击即自动发送,由 t-tag 承载
if (config.quickReplies.length > 0) {
const chips = document.createElement('div');
chips.className = 'csk-quick-replies';
for (const reply of config.quickReplies) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-quick-reply';
chip.textContent = reply;
chip.addEventListener('click', () => {
windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } }));
});
chips.appendChild(chip);
// on* 回调(onClick)必须经 omi render 创建;文本经 children 传入(tag 的 children 优先于 content)
createEventful('t-tag', {
theme: 'primary',
variant: 'light-outline',
onClick: () => {
windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } }));
},
}, chips, reply);
}
welcomeEl.appendChild(chips);
}
@ -312,8 +329,8 @@ export function createChatWindow(config: ResolvedConfig): {
}
});
// === 知识库分类下拉框(P1) ===
let categorySelect: HTMLSelectElement | null = null;
// === 知识库分类下拉框(t-select,选项由 loadCategories 异步填充) ===
let categorySelect: HTMLElement | null = null;
if (config.showCategorySwitch) {
const categoryBar = document.createElement('div');
categoryBar.className = 'csk-category-bar';
@ -321,22 +338,24 @@ export function createChatWindow(config: ResolvedConfig): {
const categoryLabel = document.createElement('span');
categoryLabel.className = 'csk-category-bar__label';
categoryLabel.textContent = '📚';
categoryBar.appendChild(categoryLabel);
categorySelect = document.createElement('select');
// on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props
categorySelect = createEventful('t-select', {
options: [{ label: t('category_all'), value: '' }],
value: '',
// onChange 触发自定义事件(受控:同步回写选中值)
onChange: (value: string | number) => {
if (categorySelect) {
(categorySelect as unknown as { value: string | number }).value = String(value);
}
const selectedId = String(value);
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
detail: { categoryId: selectedId ? Number(selectedId) : undefined }
}));
},
}, categoryBar);
categorySelect.id = 'csk-category-select';
categorySelect.className = 'csk-category-select';
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
// onChange 触发自定义事件
categorySelect.addEventListener('change', () => {
const selectedId = categorySelect!.value;
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
detail: { categoryId: selectedId ? Number(selectedId) : undefined }
}));
});
categoryBar.appendChild(categoryLabel);
categoryBar.appendChild(categorySelect);
// 插入到 messages 和 inputArea 之间
windowEl.appendChild(header);
@ -351,32 +370,19 @@ export function createChatWindow(config: ResolvedConfig): {
const inputArea = document.createElement('div');
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.className = 'csk-input';
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>`;
inputWrap.appendChild(inputEl);
inputWrap.appendChild(sendBtn);
inputArea.appendChild(inputWrap);
inputArea.appendChild(inputEl);
// === 保密声明脚注 ===
const disclaimer = document.createElement('div');
disclaimer.className = 'csk-disclaimer';
if (config.disclaimer !== undefined) {
// 用户自定义或传空串隐藏
// 用户自定义或传空串隐藏。
// 注意:disclaimer 语义为「受信 HTML」(由宿主开发者或后端管理员配置),
// 此处直接 innerHTML 注入属设计内行为,请勿传入不可信用户输入。
disclaimer.innerHTML = config.disclaimer;
if (!config.disclaimer) {
disclaimer.style.display = 'none';
@ -404,19 +410,12 @@ export function createChatWindow(config: ResolvedConfig): {
function showLoading(): HTMLElement {
if (loadingEl) {
loadingEl.style.display = 'flex';
loadingEl.style.display = '';
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);
loadingEl = el;
return el;
@ -443,10 +442,16 @@ export function createChatWindow(config: ResolvedConfig): {
const teaserEl = document.createElement('div');
teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`;
teaserEl.setAttribute('role', 'status');
teaserEl.innerHTML = `
<button class="csk-teaser__close" aria-label="${t('close')}" type="button">&times;</button>
<span>${config.teaserText || t('teaser_text')}</span>
`;
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');
@ -460,7 +465,6 @@ export function createChatWindow(config: ResolvedConfig): {
window: windowEl,
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
@ -812,278 +816,145 @@ 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 {
const wrapper = document.createElement('div');
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(time);
content.appendChild(bubble);
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);
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');
wrapper.className = 'csk-msg csk-msg--ai';
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(time);
content.appendChild(bubble);
// 操作栏:位于气泡下方
const meta = document.createElement('div');
meta.className = 'csk-msg__meta';
meta.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(meta);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
// 代码块复制按钮
enhanceCodeBlocks(bubble);
return wrapper;
}
/** 创建空的 AI 气泡(流式追加用) */
export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } {
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;
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(time);
content.appendChild(bubble);
// 操作栏:位于气泡下方
const meta = document.createElement('div');
meta.className = 'csk-msg__meta';
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);
return { wrapper, bubble };
return { wrapper, bubble: msg };
}
// ==================== 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;
};
el.actionBar = ['replay', 'copy', 'good', 'bad'];
// 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');
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', {
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');
}
/** 复制文本到剪贴板,带"已复制"反馈 */
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);
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));
}
/** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */
/** 标记 AI 气泡流式结束:切换 status 为 complete,并同步复制内容为最终 markdown 原文 */
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 引用来源渲染 ====================
/** 渲染 RAG 引用来源卡片 */
/**
* RAG
* <t-collapse> tdesign-web-components collapse/collapse-panel
* omi provide/inject DOMcreateElement + appendChild
* `this.injection.getCollapseValue is not a function`
*/
export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void {
// 移除已有的来源卡片
const existing = wrapper.querySelector('.csk-sources');
@ -1149,10 +1020,10 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
sourcesEl.appendChild(header);
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 {
wrapper.appendChild(sourcesEl);
}
@ -1330,24 +1201,21 @@ export function scrollToBottom(container: HTMLElement): void {
// ==================== 离线提示横幅 ====================
let offlineBannerEl: HTMLElement | null = null;
/** 在消息区顶部展示离线提示横幅 */
/** 在消息区顶部展示离线提示横幅(t-alert,theme=warning) */
export function showOfflineBanner(messagesContainer: HTMLElement): void {
if (document.getElementById('csk-offline-banner')) return;
const banner = document.createElement('div');
const banner = document.createElement('t-alert');
banner.id = 'csk-offline-banner';
banner.className = 'csk-offline-banner';
banner.textContent = t('error_offline');
// message 为复杂类型(Object/Function 等),用 property;theme 为 String 简单类型,用 setAttribute
(banner as unknown as { message: string }).message = t('error_offline');
banner.setAttribute('theme', 'warning');
messagesContainer.insertBefore(banner, messagesContainer.firstChild);
offlineBannerEl = banner;
}
/** 隐藏离线提示横幅 */
export function hideOfflineBanner(): void {
const el = document.getElementById('csk-offline-banner');
if (el) el.remove();
offlineBannerEl = null;
}
// ==================== 建议问题(suggest-message-list) ====================
@ -1375,25 +1243,20 @@ export function renderSuggestions(
list.className = 'csk-suggestions__list';
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) => {
e.stopPropagation();
onClick(text);
});
list.appendChild(chip);
// on* 回调(onClick)必须经 omi render 创建;tag 的 onClick 首参为原生事件,文本经 children 传入
createEventful('t-tag', {
theme: 'primary',
variant: 'light-outline',
onClick: (e: Event) => {
e.stopPropagation();
onClick(text);
},
}, list, text);
}
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);
}
/**
@ -1404,7 +1267,7 @@ export function removeSuggestions(wrapper: HTMLElement): void {
if (existing) existing.remove();
}
// ==================== 反馈原因选择面板 ====================
// ==================== 确认弹窗 + 反馈原因选择 ====================
/** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */
const REASON_OPTIONS: { key: string; labelKey: string }[] = [
@ -1415,13 +1278,50 @@ const REASON_OPTIONS: { key: string; labelKey: string }[] = [
];
/**
*
* onConfirm(reason, comment) undefined
* DialogPlugin.confirm confirm()
* @returns true/ false
*/
export function confirmWithDialog(message: string): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (val: boolean): void => {
if (settled) return;
settled = true;
resolve(val);
};
const dlg = DialogPlugin.confirm({
header: t('confirm'),
body: message,
confirmBtn: t('confirm'),
cancelBtn: t('cancel'),
onConfirm: () => {
finish(true);
dlg.destroy();
},
onCancel: () => {
finish(false);
dlg.destroy();
},
onClose: () => {
// ESC / 遮罩点击 / 关闭按钮兜底
finish(false);
dlg.destroy();
},
});
});
}
/**
* t-button
* <t-dialog> dialog visible/confirmBtn/cancelBtn
* omi propTypes DOM property display:none
* body DialogPlugin t-button
*/
export function showFeedbackReasonPanel(
wrapper: HTMLElement,
msgId: string,
_msgId: string,
onConfirm: (reason: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other', comment?: string) => void,
onCancel?: () => void,
): void {
// 移除已存在的原因面板
const existing = wrapper.querySelector('.csk-feedback-reason');
@ -1436,14 +1336,16 @@ export function showFeedbackReasonPanel(
title.textContent = t('feedback_reason_title');
panel.appendChild(title);
// 原因选项按钮
// 原因选项按钮(t-button,点击即确认)
const optionsRow = document.createElement('div');
optionsRow.className = 'csk-feedback-reason__options';
REASON_OPTIONS.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-feedback-reason__btn';
const btn = document.createElement('t-button');
// t-button 文本通过 <slot> 渲染(light DOM children),须用 textContent 而非 content 属性
btn.textContent = t(opt.labelKey);
btn.setAttribute('theme', 'default');
btn.setAttribute('variant', 'outline');
// button 的 click 经 eventDispose → fire 派发 CustomEvent('click'),用 addEventListener 监听
btn.addEventListener('click', (e) => {
e.stopPropagation();
onConfirm(opt.key as 'inaccurate' | 'irrelevant' | 'incomplete' | 'other');
@ -1463,10 +1365,10 @@ export function showFeedbackReasonPanel(
commentInput.maxLength = 200;
commentRow.appendChild(commentInput);
const submitBtn = document.createElement('button');
submitBtn.type = 'button';
submitBtn.className = 'csk-feedback-reason__submit';
const submitBtn = document.createElement('t-button');
submitBtn.textContent = t('feedback_reason_submit');
submitBtn.setAttribute('theme', 'primary');
submitBtn.setAttribute('variant', 'base');
submitBtn.addEventListener('click', (e) => {
e.stopPropagation();
const comment = commentInput.value.trim() || undefined;
@ -1476,7 +1378,7 @@ export function showFeedbackReasonPanel(
commentRow.appendChild(submitBtn);
panel.appendChild(commentRow);
// 关闭按钮
// 关闭按钮(右上角,轻量原生 button 承载 X 图标)
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'csk-feedback-reason__close';
@ -1485,6 +1387,7 @@ export function showFeedbackReasonPanel(
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
panel.remove();
if (onCancel) onCancel();
});
panel.appendChild(closeBtn);

7
client/src/i18n.ts

@ -213,10 +213,3 @@ export function t(key: string, params?: Record<string, string | number>): string
return text;
}
/**
*
*/
function getLocale(): string {
return currentLocale;
}

86
client/src/index.ts

@ -7,12 +7,25 @@
* userId accountId ID
* chatId /conversation/list
*/
// TDesign Web Components:聊天组件注册(side-effect import,构建时打进 IIFE)
// 注意:消息气泡组件 ChatMessage 实际注册的标签是 t-chat-item(非 t-chat-message)
import 'tdesign-web-components/lib/chat-message/index.js';
import 'tdesign-web-components/lib/chat-sender/index.js';
import 'tdesign-web-components/lib/chat-action/index.js';
import 'tdesign-web-components/lib/chat-loading/index.js';
import 'tdesign-web-components/lib/style/index.js';
// TDesign Web Components:通用组件注册(Phase 2 外围 UI 替换)
import 'tdesign-web-components/lib/alert/index.js';
import 'tdesign-web-components/lib/tag/index.js';
import 'tdesign-web-components/lib/select/index.js';
import 'tdesign-web-components/lib/button/index.js';
import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types';
import { parseConfig } from './config';
import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig, clearApiConfig, fetchSystemConfig } from './api';
import { injectStyles, removeStyles } from './styles';
import { preloadMarked, resetMarked } from './markdown';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom';
import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat';
import { clearMessages } from './storage';
@ -31,6 +44,42 @@ export type {
ChatbotSDKInstance,
} 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;
@ -38,10 +87,9 @@ let isInitialized = false;
let launcherEl: HTMLElement | null = null;
let windowEl: 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 categorySelect: HTMLSelectElement | null = null;
let categorySelect: HTMLElement | null = null;
let historyPanel: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null;
@ -94,9 +142,6 @@ function init(rawConfig: SDKConfig): void {
// 5. 注入样式
injectStyles(config);
// 5.1 预加载 marked(外部 Markdown 渲染库,异步不阻塞 UI)
preloadMarked();
// 6. 创建悬浮按钮
launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl);
@ -133,7 +178,6 @@ function init(rawConfig: SDKConfig): void {
windowEl = dom.window;
messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel;
@ -172,7 +216,6 @@ function init(rawConfig: SDKConfig): void {
initChat(config, {
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
@ -207,7 +250,7 @@ function init(rawConfig: SDKConfig): void {
// 15. 监听消息反馈事件(👍 / 👎)
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);
// 16. 新消息到达通知(用于未读徽章 + 提示音 + 桌面通知)
@ -257,6 +300,7 @@ function init(rawConfig: SDKConfig): void {
if (config.disclaimer === undefined) {
fetchSystemConfig('disclaimer').then(html => {
if (html && disclaimerEl) {
// 后端 system_config 表中的声明为「受信 HTML」(仅 admin 可配置),直接注入属设计内行为
disclaimerEl.innerHTML = html;
disclaimerEl.style.display = '';
}
@ -411,7 +455,6 @@ function destroy(): void {
isInitialized = false;
messagesContainer = null;
inputEl = null;
sendBtn = null;
clearBtn = null;
categorySelect = null;
historyPanel = null;
@ -421,17 +464,34 @@ function destroy(): void {
audioCtx = null;
disclaimerEl = null;
clearApiConfig();
resetMarked(); // 重置 marked 加载状态,允许下次 init 重试
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 {
if (!windowEl) return;
windowEl.classList.remove('csk-window--hidden');
hideTeaser();
hideBadge();
setTimeout(() => { if (inputEl) inputEl.focus(); }, 80);
setTimeout(() => { focusInput(); }, 80);
}
function close(): 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;

581
client/src/styles.ts

@ -40,6 +40,15 @@ function cssVars(config: ResolvedConfig): string {
--csk-text-secondary: rgba(0, 0, 0, 0.6);
--csk-text-muted: rgba(0, 0, 0, 0.4);
--csk-bg-app: #FFFFFF;
/* TDesign 组件品牌色覆盖:让 primaryColor 透传到 t-chat-item/t-chat-sender/t-tag 等 */
/* CSS 变量会穿透 shadow DOM,故在 .csk-root 上定义即可被内部组件继承 */
--td-brand-color: ${config.primaryColor};
--td-brand-color-hover: ${darker};
--td-brand-color-active: ${darker};
--td-brand-color-focus: ${config.primaryColor};
--td-brand-color-disabled: ${lighter};
--td-brand-color-light: ${lighter};
--td-brand-color-light-hover: ${lighter};
`;
}
@ -289,35 +298,6 @@ function getStyles(config: ResolvedConfig): string {
flex-shrink: 0;
margin: 0 4px;
}
.csk-role-select {
appearance: none;
-webkit-appearance: none;
padding: 4px 24px 4px 10px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.15) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='white' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 6px center;
color: #fff;
font-size: 12px;
font-family: inherit;
cursor: pointer;
outline: none;
transition: background 0.15s, border-color 0.15s;
max-width: 120px;
text-overflow: ellipsis;
white-space: nowrap;
}
.csk-role-select:hover {
background-color: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.55);
}
.csk-role-select:focus {
border-color: rgba(255, 255, 255, 0.7);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
.csk-role-select option {
color: #1F2937;
background: #fff;
}
/* ========== 消息区 ========== */
.csk-messages {
@ -335,27 +315,6 @@ function getStyles(config: ResolvedConfig): string {
}
.csk-messages::-webkit-scrollbar-thumb:hover { background: #C2C7D0; }
/* 离线提示横幅 */
.csk-offline-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
margin: 0 0 8px 0;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
background: #FEF2F2;
color: #991B1B;
border: 1px solid #FECACA;
}
.csk-dark .csk-offline-banner {
background: #450A0A;
color: #FCA5A5;
border-color: #7F1D1D;
}
/* 欢迎空状态 */
.csk-welcome {
display: flex;
@ -408,68 +367,6 @@ function getStyles(config: ResolvedConfig): string {
flex-direction: row;
align-items: flex-start;
}
.csk-msg__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin: 0 8px;
box-shadow: var(--csk-shadow-bubble);
}
.csk-msg__avatar svg { width: 18px; height: 18px; }
.csk-msg__avatar--ai {
background: #fff;
color: var(--csk-primary);
border: 1px solid var(--csk-border);
}
.csk-msg__avatar--user {
background: var(--csk-bg-user);
color: #fff;
}
/* AI 头像与气泡顶部对齐(时间戳位于气泡上方时下移) */
.csk-msg--ai .csk-msg__avatar { margin-top: 20px; }
.csk-msg__content {
display: flex;
flex-direction: column;
min-width: 0;
max-width: calc(100% - 48px);
}
.csk-msg--user .csk-msg__content { align-items: flex-end; }
.csk-msg--ai .csk-msg__content { align-items: flex-start; }
.csk-msg__bubble {
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
box-shadow: var(--csk-shadow-bubble);
}
.csk-msg--user .csk-msg__bubble {
background: var(--csk-bg-user);
color: var(--csk-text-user);
}
.csk-msg--ai .csk-msg__bubble {
background: var(--csk-bg-ai);
color: var(--csk-text-ai);
}
.csk-msg__time {
font-size: 12px;
line-height: 1.4;
color: var(--csk-text-muted);
margin-bottom: 4px;
padding: 0 2px;
user-select: none;
}
/* 操作栏容器:位于气泡下方 */
.csk-msg__meta {
display: flex;
align-items: center;
margin-top: 6px;
width: 100%;
}
@keyframes csk-msg-in {
from { opacity: 0; transform: translateY(8px); }
@ -480,62 +377,6 @@ function getStyles(config: ResolvedConfig): string {
to { opacity: 1; }
}
/* ========== Loading 动画 ========== */
.csk-loading {
display: flex;
align-items: center;
margin-bottom: 16px;
animation: csk-msg-in 0.28s ease;
}
.csk-loading__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #fff;
color: var(--csk-primary);
border: 1px solid var(--csk-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin: 0 8px;
box-shadow: var(--csk-shadow-bubble);
}
.csk-loading__avatar svg { width: 18px; height: 18px; }
.csk-loading__bubble {
position: relative;
width: 40px;
height: 40px;
padding: 0;
background: var(--csk-bg-ai);
border-radius: 12px;
box-shadow: var(--csk-shadow-bubble);
}
.csk-loading__dot {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--csk-primary);
}
/* TDesign 三点加载:三圆呈三角排列并向中心聚拢 */
.csk-loading__dot:nth-child(1) { top: 9px; left: 16px; animation: csk-dot-top 0.6s ease-in-out alternate infinite; }
.csk-loading__dot:nth-child(2) { bottom: 9px; left: 9px; animation: csk-dot-left 0.6s ease-in-out alternate infinite; }
.csk-loading__dot:nth-child(3) { bottom: 9px; right: 9px; animation: csk-dot-right 0.6s ease-in-out alternate infinite; }
@keyframes csk-dot-top {
from { transform: scale(1); top: 9px; }
to { transform: scale(1.75); top: 16px; }
}
@keyframes csk-dot-left {
from { transform: scale(1); bottom: 9px; left: 9px; }
to { transform: scale(1.75); bottom: 16px; left: 16px; }
}
@keyframes csk-dot-right {
from { transform: scale(1); bottom: 9px; right: 9px; }
to { transform: scale(1.75); bottom: 16px; right: 16px; }
}
/* ========== 输入区 ========== */
.csk-input-area {
padding: 10px 12px 14px;
@ -543,71 +384,6 @@ function getStyles(config: ResolvedConfig): string {
border-top: 1px solid var(--csk-border);
flex-shrink: 0;
}
.csk-input-wrap {
display: flex;
align-items: flex-end;
gap: 8px;
background: #fff;
border: 1px solid var(--csk-border-strong);
border-radius: 16px;
padding: 6px 6px 6px 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.csk-input-wrap:hover {
border-color: var(--csk-primary);
box-shadow: var(--csk-shadow-bubble);
}
.csk-input-wrap--focus {
border-color: var(--csk-primary);
background: #fff;
box-shadow: 0 0 0 2px rgba(var(--csk-primary-rgb), 0.2);
}
.csk-input-wrap--focus:hover {
box-shadow: 0 0 0 2px rgba(var(--csk-primary-rgb), 0.2);
}
.csk-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 0;
font-size: 14px;
outline: none;
font-family: inherit;
resize: none;
min-height: 22px;
max-height: 120px;
line-height: 1.5;
color: #1F2937;
}
.csk-input::placeholder { color: var(--csk-text-muted); }
.csk-root .csk-input:focus-visible { outline: none; }
.csk-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border: none;
border-radius: 50%;
background: var(--csk-bg-user);
color: #fff;
cursor: pointer;
transition: transform 0.18s, box-shadow 0.18s, opacity 0.18s;
box-shadow: 0 3px 10px rgba(var(--csk-primary-rgb), 0.3);
}
.csk-send-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(var(--csk-primary-rgb), 0.4);
}
.csk-send-btn:active:not(:disabled) { transform: scale(0.92); }
.csk-send-btn:disabled {
background: var(--csk-border-strong);
box-shadow: none;
cursor: not-allowed;
opacity: 0.7;
}
.csk-send-btn svg { width: 18px; height: 18px; }
/* ========== 保密声明脚注 ========== */
.csk-disclaimer {
@ -662,26 +438,6 @@ function getStyles(config: ResolvedConfig): string {
font-size: 13px;
white-space: nowrap;
}
.csk-category-select {
flex: 1;
padding: 6px 28px 6px 10px;
border: 1px solid var(--csk-border);
border-radius: 8px;
font-size: 12.5px;
color: #374151;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239CA3AF' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 9px center;
appearance: none;
-webkit-appearance: none;
outline: none;
cursor: pointer;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
max-width: 220px;
}
.csk-category-select:focus {
border-color: var(--csk-primary);
box-shadow: 0 0 0 3px rgba(var(--csk-primary-rgb), 0.12);
}
/* ========== P1: RAG 引用来源卡片 ========== */
.csk-sources {
@ -749,121 +505,6 @@ function getStyles(config: ResolvedConfig): string {
margin-top: 2px;
}
/* ========== P1: Markdown 渲染样式 ========== */
.csk-msg--ai .csk-msg__bubble .csk-md-p { margin: 0 0 8px; }
.csk-msg--ai .csk-msg__bubble .csk-md-p:last-child { margin-bottom: 0; }
.csk-msg--ai .csk-msg__bubble .csk-md-h1,
.csk-msg--ai .csk-msg__bubble .csk-md-h2,
.csk-msg--ai .csk-msg__bubble .csk-md-h3,
.csk-msg--ai .csk-msg__bubble .csk-md-h4,
.csk-msg--ai .csk-msg__bubble .csk-md-h5,
.csk-msg--ai .csk-msg__bubble .csk-md-h6 {
margin: 14px 0 6px;
font-weight: 600;
line-height: 1.3;
color: #111827;
}
.csk-msg--ai .csk-msg__bubble .csk-md-h1 { font-size: 20px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h2 { font-size: 17px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h3 { font-size: 15px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h4 { font-size: 14px; }
.csk-md-code-block {
position: relative;
background: #1E293B;
color: #E2E8F0;
padding: 12px 14px;
border-radius: 10px;
overflow-x: auto;
margin: 8px 0;
font-size: 13px;
line-height: 1.55;
font-family: 'SF Mono', 'Consolas', 'Menlo', 'Monaco', monospace;
}
.csk-md-code-block code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.csk-md-inline-code {
background: rgba(var(--csk-primary-rgb), 0.1);
color: var(--csk-primary);
padding: 1px 6px;
border-radius: 5px;
font-size: 13px;
font-family: 'SF Mono', 'Consolas', 'Menlo', 'Monaco', monospace;
}
.csk-msg--ai .csk-msg__bubble .csk-md-ul,
.csk-msg--ai .csk-msg__bubble .csk-md-ol {
padding-left: 22px;
margin: 6px 0;
}
.csk-msg--ai .csk-msg__bubble .csk-md-ul li,
.csk-msg--ai .csk-msg__bubble .csk-md-ol li { margin-bottom: 4px; }
.csk-md-blockquote {
border-left: 3px solid var(--csk-primary);
padding: 2px 12px;
margin: 8px 0;
color: #6B7280;
background: rgba(var(--csk-primary-rgb), 0.05);
border-radius: 0 6px 6px 0;
}
.csk-md-link {
color: var(--csk-primary);
text-decoration: none;
border-bottom: 1px solid rgba(var(--csk-primary-rgb), 0.3);
transition: border-color 0.15s;
}
.csk-md-link:hover { border-bottom-color: var(--csk-primary); }
.csk-md-hr {
border: none;
border-top: 1px solid var(--csk-border);
margin: 12px 0;
}
.csk-md-table-wrap {
max-width: 100%;
overflow-x: auto;
margin: 10px 0;
border: 1px solid var(--csk-border);
border-radius: 8px;
}
.csk-md-table {
width: 100%;
min-width: 360px;
border-collapse: collapse;
font-size: 12.5px;
line-height: 1.5;
}
.csk-md-table th,
.csk-md-table td {
padding: 7px 9px;
border-right: 1px solid var(--csk-border);
border-bottom: 1px solid var(--csk-border);
text-align: left;
vertical-align: top;
}
.csk-md-table th:last-child,
.csk-md-table td:last-child { border-right: none; }
.csk-md-table tbody tr:last-child td { border-bottom: none; }
.csk-md-table th {
background: rgba(var(--csk-primary-rgb), 0.07);
color: #374151;
font-weight: 600;
}
.csk-md-table tbody tr:nth-child(even) {
background: rgba(148, 163, 184, 0.06);
}
.csk-md-table .csk-md-align-left { text-align: left; }
.csk-md-table .csk-md-align-center { text-align: center; }
.csk-md-table .csk-md-align-right { text-align: right; }
/* marked 新增元素 */
.csk-md-img { max-width: 100%; height: auto; display: block; margin: 6px 0; border-radius: 8px; }
.csk-msg--ai .csk-msg__bubble .csk-md-ul input[type="checkbox"],
.csk-msg--ai .csk-msg__bubble .csk-md-ol input[type="checkbox"] { margin-right: 6px; vertical-align: middle; }
/* ========== P2: 会话管理面板 ========== */
.csk-history-panel {
position: absolute;
@ -1007,24 +648,6 @@ function getStyles(config: ResolvedConfig): string {
margin-top: 18px;
max-width: 320px;
}
.csk-quick-reply {
border: 1px solid transparent;
background: var(--csk-bg-ai);
color: var(--csk-text-secondary);
font-size: 12px;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
line-height: 1.3;
transition: all 0.15s ease;
max-width: 100%;
}
.csk-quick-reply:hover {
background: var(--csk-border);
color: var(--csk-text-ai);
}
.csk-quick-reply:active { transform: scale(0.96); }
/* ========== 推荐问题(suggest-message-list) ========== */
.csk-suggestions {
@ -1036,97 +659,6 @@ function getStyles(config: ResolvedConfig): string {
flex-wrap: wrap;
gap: 4px;
}
.csk-suggestion-item {
border: 1px solid transparent;
background: var(--csk-bg-ai);
color: var(--csk-text-secondary);
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
line-height: 1.3;
transition: all 0.15s ease;
max-width: 100%;
text-align: left;
}
.csk-suggestion-item:hover {
background: var(--csk-border);
color: var(--csk-text-ai);
}
.csk-suggestion-item:active { transform: scale(0.96); }
/* ========== AI 消息操作条(TDesign 操作条:浅灰胶囊容器) ========== */
.csk-msg__actions {
display: flex;
gap: 0;
padding: 3px;
background: var(--csk-bg-ai);
border: 1px solid var(--csk-border-strong);
border-radius: 6px;
overflow: hidden;
opacity: 0;
transition: opacity 0.15s ease;
}
.csk-msg--ai:hover .csk-msg__actions { opacity: 1; }
.csk-msg--streaming .csk-msg__actions { display: none; }
.csk-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--csk-text-secondary);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.csk-action-btn:hover {
background: var(--csk-border);
color: var(--csk-text-ai);
}
.csk-action-btn--done { color: #10B981; }
.csk-action-btn:active { transform: scale(0.9); }
/* ========== 流式打字光标 ========== */
.csk-caret {
display: inline-block;
width: 7px;
height: 15px;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--csk-primary);
border-radius: 1px;
animation: csk-caret-blink 1s step-end infinite;
}
@keyframes csk-caret-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* ========== 代码块复制按钮 ========== */
.csk-md-code-copy {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: rgba(255, 255, 255, 0.12);
color: #CBD5E1;
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
}
.csk-md-code-block:hover .csk-md-code-copy { opacity: 1; }
.csk-md-code-copy:hover { background: rgba(255, 255, 255, 0.22); color: #fff; }
.csk-md-code-copy:active { transform: scale(0.9); }
/* ========== 新消息提示按钮 ========== */
.csk-newmsg {
@ -1155,15 +687,6 @@ function getStyles(config: ResolvedConfig): string {
.csk-newmsg:active { transform: translateX(-50%) scale(0.95); }
.csk-newmsg--hidden { display: none; }
/* ========== 发送按钮停止态 ========== */
.csk-send-btn--stop {
background: #EF4444 !important;
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35) !important;
}
.csk-send-btn--stop:hover:not(:disabled) {
box-shadow: 0 5px 14px rgba(239, 68, 68, 0.45) !important;
}
/* ========== 窗口缩放拖拽手柄 ========== */
.csk-resize-handle {
position: absolute;
@ -1261,6 +784,15 @@ function getStyles(config: ResolvedConfig): string {
--csk-text-secondary: rgba(255, 255, 255, 0.6);
--csk-text-muted: rgba(255, 255, 255, 0.4);
--csk-bg-app: #141421;
/* TDesign t-chat-item/t-chat-sender/t-tag
--td-bg-color-container / --td-text-color-primary */
--td-gray-color-1: #1E1E2E;
--td-gray-color-2: #141421;
--td-gray-color-3: #3B3B52;
--td-gray-color-4: #454560;
--td-font-gray-1: rgba(255, 255, 255, 0.9);
--td-font-gray-2: rgba(255, 255, 255, 0.6);
--td-font-gray-3: rgba(255, 255, 255, 0.4);
color: #E2E8F0;
}
.csk-dark .csk-window {
@ -1271,21 +803,8 @@ function getStyles(config: ResolvedConfig): string {
opacity: 1;
}
.csk-dark .csk-header::after { background: linear-gradient(to bottom, rgba(0,0,0,0.12), transparent); }
.csk-dark .csk-msg__avatar--ai {
background: var(--csk-bg-ai);
color: var(--csk-primary);
border-color: var(--csk-border);
}
.csk-dark .csk-input-area { background: #1A1A2E; border-top-color: var(--csk-border); }
.csk-dark .csk-input-wrap { background: var(--csk-bg-app); border-color: var(--csk-border-strong); }
.csk-dark .csk-input-wrap:hover { border-color: var(--csk-primary); box-shadow: none; }
.csk-dark .csk-input-wrap--focus { background: #1A1A2E; border-color: var(--csk-primary); }
.csk-dark .csk-input { color: #E2E8F0; }
.csk-dark .csk-loading__avatar { background: var(--csk-bg-ai); border-color: var(--csk-border); }
.csk-dark .csk-loading__bubble { background: var(--csk-bg-ai); border-color: var(--csk-border); }
.csk-dark .csk-loading__dot { background: var(--csk-primary-light); }
.csk-dark .csk-category-bar { background: #1A1A2E; border-top-color: var(--csk-border); }
.csk-dark .csk-category-select { background-color: var(--csk-bg-app); border-color: var(--csk-border); color: #E2E8F0; }
.csk-dark .csk-sources { background: #1E1E2E; border-color: var(--csk-border); }
.csk-dark .csk-sources__header { background: #25253A; }
.csk-dark .csk-sources__header:hover { background: #2D2D48; }
@ -1301,40 +820,20 @@ function getStyles(config: ResolvedConfig): string {
.csk-dark .csk-history-item__meta { color: #6B7280; }
.csk-dark .csk-history-item--active { background: rgba(var(--csk-primary-rgb), 0.15); }
.csk-dark .csk-history-item--active:hover { background: rgba(var(--csk-primary-rgb), 0.22); }
.csk-dark .csk-msg__actions { background: var(--csk-bg-ai); border-color: var(--csk-border-strong); }
.csk-dark .csk-action-btn:hover { background: #2D2D48; color: #E2E8F0; }
.csk-dark .csk-clear-btn { background: var(--csk-bg-app); border-color: var(--csk-border); color: #9CA3AF; }
.csk-dark .csk-clear-btn:hover { background: #3B1A1A; border-color: #7F1D1D; color: #FCA5A5; }
.csk-dark .csk-md-code-block { background: #0F0F1A; }
.csk-dark .csk-md-blockquote { background: rgba(var(--csk-primary-rgb), 0.08); }
.csk-dark .csk-md-link { color: var(--csk-primary-light); }
.csk-dark .csk-md-table th { background: rgba(var(--csk-primary-rgb), 0.14); color: #E2E8F0; }
.csk-dark .csk-md-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, 0.05); }
.csk-dark .csk-md-img { opacity: 0.9; }
.csk-dark .csk-msg--ai .csk-msg__bubble { background: var(--csk-bg-ai); color: var(--csk-text-ai); border-color: var(--csk-border); }
.csk-dark .csk-welcome__title { color: #E2E8F0; }
.csk-dark .csk-welcome__desc { color: #6B7280; }
.csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-suggestion-item { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-suggestion-item:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); }
.csk-dark .csk-teaser::after { border-top-color: #2D2D48; }
.csk-dark .csk-teaser__close { color: #6B7280; }
.csk-dark .csk-teaser__close:hover { color: #9CA3AF; }
.csk-dark .csk-feedback-btn { background: transparent; color: var(--csk-text-secondary); }
.csk-dark .csk-feedback-btn:hover { background: #2D2D48; color: #E2E8F0; }
.csk-dark .csk-feedback-btn--active { background: transparent; color: var(--csk-primary-light); }
.csk-dark .csk-history-panel__search-wrap { background: var(--csk-bg-app); border-color: var(--csk-border); }
.csk-dark .csk-history-panel__search-wrap:focus-within { border-color: var(--csk-primary); }
.csk-dark .csk-history-panel__search { color: #E2E8F0; }
.csk-dark .csk-launcher__badge { box-shadow: 0 0 0 2px #1A1A2E; }
.csk-dark .csk-welcome__avatar { box-shadow: 0 6px 18px rgba(var(--csk-primary-rgb), 0.25); }
.csk-dark .csk-header__avatar { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.1); }
.csk-dark .csk-role-select option {
background: #1A1A2E;
color: #E2E8F0;
}
/* Launcher 拖拽态:微放大 + 阴影加深 */
.csk-launcher--dragging {
@ -1425,39 +924,15 @@ function getStyles(config: ResolvedConfig): string {
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ========== 消息反馈按钮(👍 / 👎) ========== */
.csk-feedback-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--csk-text-secondary);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
padding: 0;
}
.csk-feedback-btn:hover { background: var(--csk-border); color: var(--csk-text-ai); }
.csk-feedback-btn--active {
background: transparent;
color: var(--csk-primary);
}
.csk-feedback-btn--active.down { color: #D54941; }
.csk-feedback-btn:active { transform: scale(0.9); }
.csk-msg--streaming .csk-feedback-btn { display: none; }
/* ========== 点踩原因选择面板 ========== */
.csk-feedback-reason {
position: relative;
margin-top: 6px;
padding: 10px 12px;
background: var(--csk-bg);
background: #fff;
border: 1px solid var(--csk-border);
border-radius: 8px;
animation: cskFadeIn 0.15s ease;
animation: csk-fade-in 0.15s ease;
}
.csk-feedback-reason__title {
font-size: 12px;
@ -1475,8 +950,8 @@ function getStyles(config: ResolvedConfig): string {
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 14px;
background: var(--csk-bg);
color: var(--csk-text);
background: #fff;
color: #1F2937;
cursor: pointer;
transition: all 0.15s ease;
}
@ -1495,8 +970,8 @@ function getStyles(config: ResolvedConfig): string {
font-size: 12px;
border: 1px solid var(--csk-border);
border-radius: 6px;
background: var(--csk-bg);
color: var(--csk-text);
background: #fff;
color: #1F2937;
outline: none;
}
.csk-feedback-reason__comment:focus { border-color: var(--csk-primary); }
@ -1526,12 +1001,12 @@ function getStyles(config: ResolvedConfig): string {
justify-content: center;
border-radius: 4px;
}
.csk-feedback-reason__close:hover { color: var(--csk-text); background: var(--csk-hover); }
.csk-dark .csk-feedback-reason { background: var(--csk-bg); border-color: var(--csk-border); }
.csk-feedback-reason__close:hover { color: #1F2937; background: #F3F4F6; }
.csk-dark .csk-feedback-reason { background: #1A1A2E; border-color: var(--csk-border); }
.csk-dark .csk-feedback-reason__btn {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
color: #E2E8F0;
}
.csk-dark .csk-feedback-reason__btn:hover {
border-color: var(--csk-primary-light);
@ -1541,7 +1016,7 @@ function getStyles(config: ResolvedConfig): string {
.csk-dark .csk-feedback-reason__comment {
background: rgba(255,255,255,0.06);
border-color: var(--csk-border);
color: var(--csk-text);
color: #E2E8F0;
}
/* ========== 历史会话搜索框 ========== */

12
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>(
fn: T,

27
frontend/src/sdk-test/SdkTestPanel.vue

@ -828,8 +828,8 @@ const testResults = ref<TestCaseResult[]>([
assert(qid('csk-window'), '弹窗未创建')
assert(qid('csk-messages'), '#csk-messages 未创建')
assert(qid('csk-input'), '#csk-input 未创建')
assert(qid('csk-send-btn'), '#csk-send-btn 未创建')
log('✓ 消息区/输入框/发送按钮齐全', 'pass')
assert(qid('csk-input')?.tagName === 'T-CHAT-SENDER', '输入区应为 t-chat-sender')
log('✓ 消息区/输入框(t-chat-sender)齐全', 'pass')
assert(q('style[data-csk-sdk]'), 'CSS 未注入')
log('✓ CSS 已注入', 'pass')
mark('pass', '通过')
@ -884,23 +884,22 @@ const testResults = ref<TestCaseResult[]>([
await sleep(500)
sdk.open()
await sleep(300)
const input = qid('csk-input') as HTMLInputElement | null
if (!input) {
const sender = qid('csk-input')
if (!sender) {
log('⚠ 输入框不存在', 'warn')
mark('skip', '输入框不存在')
return
}
input.value = '你好'
input.dispatchEvent(new Event('input', { bubbles: true }))
await sleep(100)
const sendBtn = qid('csk-send-btn')
if (sendBtn) (sendBtn as HTMLButtonElement).click()
log('已点击发送按钮,等待 AI 回复...', 'info')
// t-chat-sender send value
sender.dispatchEvent(new CustomEvent('send', { detail: { value: '你好' } }))
log('已触发发送,等待 AI 回复...', 'info')
await sleep(9000)
const aiMsgs = document.querySelectorAll('.csk-msg--ai .csk-msg__bubble')
const aiMsgs = document.querySelectorAll('.csk-msg--ai t-chat-item')
if (aiMsgs.length > 0) {
const last = aiMsgs[aiMsgs.length - 1]
log('AI 回复: ' + (last.textContent || '').substring(0, 100), 'pass')
// t-chat-item shadow DOM shadowRoot
const shadowText = (last as HTMLElement).shadowRoot?.textContent || ''
log('AI 回复: ' + shadowText.substring(0, 100), 'pass')
mark('pass', '通过(有 AI 回复)')
} else {
log('⚠ 等待 AI 回复超时', 'warn')
@ -998,14 +997,14 @@ const testResults = ref<TestCaseResult[]>([
sdk.init({ ...buildSdkInitConfig(), locale: 'zh-CN' })
await sleep(300)
const zh = qid('csk-input')
assert(zh && (zh as HTMLInputElement).placeholder === '输入您的问题...', '中文 placeholder 错误')
assert(zh && zh.getAttribute('placeholder') === '输入您的问题...', '中文 placeholder 错误')
log('✓ 中文界面', 'pass')
sdk.destroy()
await sleep(100)
sdk.init({ ...buildSdkInitConfig(), locale: 'en' })
await sleep(300)
const en = qid('csk-input')
assert(en && (en as HTMLInputElement).placeholder === 'Type your question...', '英文 placeholder 错误')
assert(en && en.getAttribute('placeholder') === 'Type your question...', '英文 placeholder 错误')
log('✓ 英文界面', 'pass')
mark('pass', '通过')
},

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

@ -4,7 +4,7 @@
<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-DxurNOkQ.js"></script>
<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">
@ -13,7 +13,7 @@
<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-CG92De7i.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-nsiAALas.css">
</head>
<body>
<div id="sdk-test-app"></div>

Loading…
Cancel
Save