Browse Source

ChatSDK重写了 SDK 的视觉与交互

dev-mcp
wanghanlin 4 weeks ago
parent
commit
cfe6fea93e
  1. 105
      client/CLAUDE.md
  2. 774
      client/dist/chatbot-sdk.js
  3. 2
      client/dist/chatbot-sdk.js.map
  4. 2
      client/dist/chatbot-sdk.min.js
  5. 2
      client/dist/chatbot-sdk.min.js.map
  6. 38
      client/src/chat.ts
  7. 108
      client/src/dom.ts
  8. 10
      client/src/i18n.ts
  9. 1
      client/src/index.ts
  10. 653
      client/src/styles.ts
  11. 774
      src/main/resources/static/sdk/chatbot-sdk.js
  12. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  13. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  14. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map

105
client/CLAUDE.md

@ -0,0 +1,105 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目定位
这是 AI 智能客服系统的**前端 SDK 工程**(`chatbot-sdk`),与仓库根目录的 Spring Boot 后端、`src/main/resources/static/` 下的 Vue 3 管理后台是三个独立部分。SDK 产物是一行 `<script>` 标签即可嵌入第三方站点的悬浮客服窗口,IIFE 格式挂载到 `window.ChatbotSDK`
根目录的 `../CLAUDE.md` 描述后端架构,本文件只覆盖 `client/` 工程。
## 构建与开发
```bash
cd client/
# 安装依赖
npm install
# 构建(输出 dist/chatbot-sdk.js 与 dist/chatbot-sdk.min.js)
npm run build
# 开发 watch 模式
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)
- **无测试框架**:验证通过后端的 `http://localhost:9090/sdk/test.html` 运行 22 个浏览器端用例(见 README 第十四节),本工程内没有可运行的自动化测试
## 运行时依赖
SDK 本身不独立运行,需要后端在 `requestDomain`(通常 `http://localhost:9090`)提供以下接口:
- 基础对话:`GET /ai/assistant_app/chat/sync`、`GET /ai/assistant_app/chat/sse`
- RAG:`GET /ai/assistant_app/chat/rag/sse`、`GET /ai/assistant_app/rag/sources`
- 分类:`GET /category/tree`、`GET /category/list`
- 会话:`GET /conversation/list`、`GET /conversation/{id}/messages`、`DELETE /conversation/{id}`、`GET /conversation/{id}/export`
完整接口契约见 `README.md` 第六节。修改 `api.ts` 时务必与后端 `AiController` / `ConversationController` 保持参数一致。
## 架构
### 模块职责与数据流
入口 `src/index.ts` 是单例,按固定顺序串联各模块(`init()` 中的 12 个步骤即生命周期):
```
init(rawConfig)
→ config.ts parseConfig() 解析 + 校验,返回 ResolvedConfig(失败返回 null,不抛异常)
→ i18n.ts setLocale() 设置语言字典(zh-CN / en)
→ logger.ts setDebug() 控制日志级别
→ api.ts setApiConfig() 注入 requestDomain 等,供后续 HTTP/SSE 调用复用
→ styles.ts injectStyles() 注入 <style data-csk-sdk> primaryColor
→ dom.ts createLauncher() / createChatWindow() 构建 DOM,返回元素引用与 loading 控制函数
→ dom.ts enableDrag() 弹窗头部拖拽,返回 cleanup 函数
→ chat.ts initChat() 绑定事件、注入 DOM 引用,建立对话核心状态
→ chat.ts initChatHistory() 异步加载 chatId 与历史(不阻塞 UI)
```
模块间通过两种方式通信:
1. **闭包状态**:`index.ts`、`chat.ts`、`api.ts` 各自持有模块级变量,靠 `init*` 函数注入
2. **CustomEvent**:DOM 层(`dom.ts`)派发 `csk:categoryChange`、`csk:loadHistory`,`index.ts` 监听后转发给 `chat.ts`
### 关键模块
- **`chat.ts`**(最大、最核心):对话状态机。处理发送、SSE 流式追加、Markdown 渲染、RAG 来源展示、流中断兜底、无流内容降级为同步、`chatId` 自动管理逻辑、清空会话。修改对话行为优先改这里。
- **`api.ts`**:HTTP 封装 + SSE 流解析。三种 SSE 接口(普通流式、RAG 流式、同步)的解析逻辑都在这里,注意流式解析的边界处理。
- **`dom.ts`**:纯 DOM 构建与事件绑定,含知识库下拉、RAG 来源卡片、历史会话面板的渲染。
- **`styles.ts`**:所有 CSS 字符串模板,按 `primaryColor` 动态着色。改动 UI 视觉改这里,**不要**在 `dom.ts` 里写内联样式。
- **`markdown.ts`**:零依赖轻量 Markdown 渲染器。**安全模型**:所有非代码块内容先 HTML 转义再转换语法,代码块内容同样转义;链接只允许 http/https。修改渲染器时必须维持这一 XSS 防护顺序。
### 单例与生命周期
`index.ts` 用模块级变量持有 `config` 和所有 DOM 引用,`isInitialized` 防止重复初始化。`destroy()` 必须清理:移除 DOM、调用 `dragCleanup()`、`removeStyles()`、置空所有引用。新增 DOM 引用时记得在 `destroy()` 中同步置空。
## 关键约定
### 参数映射(SDK → 后端)
| SDK 入参 | 后端参数 | 说明 |
|----------|----------|------|
| `integrateId` | `roleId` | 客服角色 ID,决定 AI 人设和知识库范围(**必传**) |
| `userId` | `accountId` | 客户账号 ID,账号绑定角色后服务端会覆盖 roleId |
| (自动管理) | `chatId` | 从 `/conversation/list` 取或生成 `sdk_时间戳_随机串` |
`chatId` 缓存在 localStorage(key: `csk_chatId_{integrateId}_{userId}`),`clearHistory()` 会重新生成。改 `chat.ts` 的 chatId 逻辑时注意与后端 `ConversationController` 的会话匹配规则一致。
### CSS 命名空间
所有 class/id 用 `csk-` 前缀,样式注入到 `<style data-csk-sdk>`,z-index:悬浮按钮 9998,弹窗 9999。新增 DOM 元素必须带前缀,避免污染宿主页面。
### localStorage
- 消息历史 key:`csk_history_{integrateId}`,上限 200 条,超出裁剪最早 50 条
- chatId key:`csk_chatId_{integrateId}_{userId}`
- 不同 `integrateId` 隔离,互不影响
### 错误处理
**所有错误不抛异常、不阻塞宿主页面**,仅 `console.error` 输出(`error` 始终输出,`info`/`warn` 受 `debug` 控制)。新增异步逻辑要 try/catch 包裹,失败走 `logger.warn` / `logger.error`
## 部署
构建后将 `dist/chatbot-sdk.min.js` 上传到后端 `src/main/resources/static/sdk/`(或 CDN),宿主页面通过 `<script src="/sdk/chatbot-sdk.min.js"></script>` 引入。`README.md` 有完整接入示例与全部配置参数表,文档是 SDK 对外契约的一部分,改公开 API(`init/destroy/open/close/toggle/clearHistory`)或 `SDKConfig` 字段时同步更新 README。

774
client/dist/chatbot-sdk.js
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

38
client/src/chat.ts

@ -41,10 +41,12 @@ 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 clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null;
let historyPanel: HTMLElement | null = null;
let welcomeEl: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null;
let isSending = false;
@ -67,6 +69,7 @@ export function initChat(
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
historyPanel: HTMLElement;
welcomeEl: HTMLElement;
showLoading: () => HTMLElement;
hideLoading: () => void;
}
@ -74,10 +77,12 @@ export function initChat(
config = cfg;
messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl;
inputWrap = dom.inputEl.parentElement;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel;
welcomeEl = dom.welcomeEl;
showLoadingFn = dom.showLoading;
hideLoadingFn = dom.hideLoading;
@ -162,13 +167,39 @@ function bindSendEvents(): void {
}
});
inputEl.addEventListener('input', () => updateSendBtnState());
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());
}
}
/** 文本框自动撑高 */
function autoResizeInput(): void {
if (!inputEl) return;
inputEl.style.height = 'auto';
inputEl.style.height = `${Math.min(inputEl.scrollHeight, 120)}px`;
}
/** 根据消息数量切换欢迎空状态显隐 */
function updateEmptyState(): void {
if (!welcomeEl) return;
const hasMessages = messages.length > 0 ||
(messagesContainer && messagesContainer.querySelector('.csk-msg'));
welcomeEl.style.display = hasMessages ? 'none' : '';
}
/** 更新发送按钮状态 */
function updateSendBtnState(): void {
if (!sendBtn || !inputEl) return;
@ -205,6 +236,7 @@ async function handleSend(): Promise<void> {
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp };
messages.push(userMsg);
updateEmptyState();
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
if (messagesContainer) scrollToBottom(messagesContainer);
@ -369,6 +401,7 @@ function renderHistory(): void {
scrollToBottom(messagesContainer);
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
updateEmptyState();
if (historyPanelEl && !messagesContainer.contains(historyPanelEl)) {
messagesContainer.appendChild(historyPanelEl);
@ -386,6 +419,7 @@ function handleClear(): void {
msgs.forEach(el => el.remove());
}
if (clearBtn) clearBtn.style.display = 'none';
updateEmptyState();
clearMessages(config.integrateId);
// 生成新的 chatId,开始新会话
@ -456,6 +490,7 @@ export async function loadHistoryConversations(): Promise<void> {
msgs.forEach(el => el.remove());
}
if (clearBtn) clearBtn.style.display = 'none';
updateEmptyState();
}
loadHistoryConversations();
}
@ -517,6 +552,7 @@ export async function switchToConversation(conversationId: string): Promise<void
if (clearBtn && messages.length > 0) {
clearBtn.style.display = 'inline-flex';
}
updateEmptyState();
}
/** 获取当前消息列表 */

108
client/src/dom.ts

@ -1,12 +1,24 @@
/**
* DOM - +
* P1 RAG
* P2
*
*
* - + 线
* -
* -
* -
*/
import { ResolvedConfig, RagSource } from './types';
import { debounce } from './utils';
import { t } from './i18n';
// ==================== 图标常量 ====================
/** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */
const BOT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="12" height="8" x="6" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 14v2"/><path d="M15 14v2"/><path d="M18 13v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/></svg>`;
/** 用户头像图标 */
const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
// ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */
@ -48,6 +60,7 @@ export function createChatWindow(config: ResolvedConfig): {
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
historyPanel: HTMLElement;
welcomeEl: HTMLElement;
showLoading: () => HTMLElement;
hideLoading: () => void;
} {
@ -60,10 +73,29 @@ export function createChatWindow(config: ResolvedConfig): {
const header = document.createElement('div');
header.className = 'csk-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'csk-header__left';
const headerAvatar = document.createElement('div');
headerAvatar.className = 'csk-header__avatar';
headerAvatar.innerHTML = BOT_ICON;
const headerInfo = document.createElement('div');
headerInfo.className = 'csk-header__info';
const titleEl = document.createElement('span');
titleEl.className = 'csk-header__title';
titleEl.textContent = config.title;
const statusEl = document.createElement('span');
statusEl.className = 'csk-header__status';
statusEl.innerHTML = `<i class="csk-status-dot"></i>${t('status_online')}`;
headerInfo.appendChild(titleEl);
headerInfo.appendChild(statusEl);
headerLeft.appendChild(headerAvatar);
headerLeft.appendChild(headerInfo);
const actions = document.createElement('div');
actions.className = 'csk-header__actions';
@ -94,7 +126,7 @@ export function createChatWindow(config: ResolvedConfig): {
actions.appendChild(historyBtn);
actions.appendChild(minimizeBtn);
actions.appendChild(closeBtn);
header.appendChild(titleEl);
header.appendChild(headerLeft);
header.appendChild(actions);
// === 消息区 ===
@ -102,6 +134,16 @@ export function createChatWindow(config: ResolvedConfig): {
messagesContainer.id = 'csk-messages';
messagesContainer.className = 'csk-messages';
// === 欢迎空状态 ===
const welcomeEl = document.createElement('div');
welcomeEl.className = 'csk-welcome';
welcomeEl.innerHTML = `
<div class="csk-welcome__avatar">${BOT_ICON}</div>
<div class="csk-welcome__title">${t('welcome_title')}</div>
<div class="csk-welcome__desc">${t('welcome_desc')}</div>
`;
messagesContainer.appendChild(welcomeEl);
// === 会话管理面板(P2,默认隐藏) ===
const historyPanel = document.createElement('div');
historyPanel.className = 'csk-history-panel csk-history-panel--hidden';
@ -175,6 +217,9 @@ 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');
inputEl.id = 'csk-input';
inputEl.className = 'csk-input';
@ -189,8 +234,9 @@ export function createChatWindow(config: ResolvedConfig): {
sendBtn.setAttribute('disabled', 'true');
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
inputArea.appendChild(inputEl);
inputArea.appendChild(sendBtn);
inputWrap.appendChild(inputEl);
inputWrap.appendChild(sendBtn);
inputArea.appendChild(inputWrap);
windowEl.appendChild(inputArea);
// 清空按钮(可选)
@ -215,9 +261,12 @@ export function createChatWindow(config: ResolvedConfig): {
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>
`;
messagesContainer.appendChild(el);
loadingEl = el;
@ -239,6 +288,7 @@ export function createChatWindow(config: ResolvedConfig): {
clearBtn,
categorySelect,
historyPanel,
welcomeEl,
showLoading,
hideLoading,
};
@ -255,6 +305,9 @@ export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement): () =>
let offsetY = 0;
const onMouseDown = (e: MouseEvent) => {
// 忽略头部按钮点击触发的拖拽
const target = e.target as HTMLElement;
if (target.closest('button')) return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
@ -301,6 +354,13 @@ export function renderUserBubble(container: HTMLElement, text: string, timestamp
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';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.textContent = text;
@ -309,8 +369,10 @@ export function renderUserBubble(container: HTMLElement, text: string, timestamp
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
content.appendChild(bubble);
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
return wrapper;
@ -321,6 +383,13 @@ export function renderAIBubble(container: HTMLElement, text: string, timestamp:
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
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 渲染,传入渲染函数则使用,否则纯文本
@ -334,8 +403,10 @@ export function renderAIBubble(container: HTMLElement, text: string, timestamp:
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
content.appendChild(bubble);
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
return wrapper;
@ -346,6 +417,13 @@ export function createEmptyAIBubble(container: HTMLElement, timestamp: number):
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
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 = '';
@ -354,8 +432,10 @@ export function createEmptyAIBubble(container: HTMLElement, timestamp: number):
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
content.appendChild(bubble);
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
return { wrapper, bubble };
@ -429,10 +509,10 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
sourcesEl.appendChild(header);
sourcesEl.appendChild(body);
// 插入到气泡和时间戳之间
// 插入到气泡和时间戳之间(时间戳在 content 容器内,故用其父节点插入)
const timeEl = wrapper.querySelector('.csk-msg__time');
if (timeEl) {
wrapper.insertBefore(sourcesEl, timeEl);
if (timeEl && timeEl.parentNode) {
timeEl.parentNode.insertBefore(sourcesEl, timeEl);
} else {
wrapper.appendChild(sourcesEl);
}

10
client/src/i18n.ts

@ -9,6 +9,11 @@ const dictionaries: Record<string, Record<string, string>> = {
title: 'AI 智能助手',
minimize: '最小化',
close: '关闭',
status_online: '在线',
// 欢迎空状态
welcome_title: '你好,我是 AI 智能助手',
welcome_desc: '有什么可以帮你的吗?在下方输入框开始提问吧~',
// 输入区
placeholder: '输入您的问题...',
@ -59,6 +64,11 @@ const dictionaries: Record<string, Record<string, string>> = {
title: 'AI Assistant',
minimize: 'Minimize',
close: 'Close',
status_online: 'Online',
// Welcome
welcome_title: 'Hi, I am your AI assistant',
welcome_desc: 'How can I help you? Type your question below to get started.',
// Input
placeholder: 'Type your question...',

1
client/src/index.ts

@ -90,6 +90,7 @@ function init(rawConfig: SDKConfig): void {
clearBtn,
categorySelect,
historyPanel,
welcomeEl: dom.welcomeEl,
showLoading: showLoadingFn,
hideLoading: hideLoadingFn,
});

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

774
src/main/resources/static/sdk/chatbot-sdk.js
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

Loading…
Cancel
Save