Browse Source

入口图标改造 + SDK 瘦身

dev-mcp
wanghanlin 2 weeks ago
parent
commit
0bc4b66e93
  1. 43
      SDK-INTEGRATION.md
  2. 2
      client/README.md
  3. 680
      client/dist/chatbot-sdk.js
  4. 2
      client/dist/chatbot-sdk.js.map
  5. 2
      client/dist/chatbot-sdk.min.js
  6. 2
      client/dist/chatbot-sdk.min.js.map
  7. 69
      client/src/api.ts
  8. 107
      client/src/chat.ts
  9. 107
      client/src/config.ts
  10. 60
      client/src/dom.ts
  11. 4
      client/src/i18n.ts
  12. 8
      client/src/index.ts
  13. 385
      client/src/styles.ts
  14. 11
      client/src/types.ts
  15. 9
      client/src/utils.ts
  16. 10
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  17. 17
      src/main/resources/static/components/ApiKeyManager.js
  18. 680
      src/main/resources/static/sdk/chatbot-sdk.js
  19. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  20. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  21. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map
  22. 16
      src/main/resources/static/sdk/test.html

43
SDK-INTEGRATION.md

@ -11,6 +11,7 @@
3. [第二步:绑定客服角色(可选)](#3-第二步绑定客服角色可选)
4. [第三步:后端换取 SDK Token](#4-第三步后端换取-sdk-token)
5. [第四步:前端嵌入 Chat SDK](#5-第四步前端嵌入-chat-sdk)
- [5.5 角色切换](#55-角色切换)
6. [API 接口参考](#6-api-接口参考)
7. [SDK 配置参数参考](#7-sdk-配置参数参考)
8. [错误码与排查](#8-错误码与排查)
@ -179,7 +180,7 @@ const chatbot = ChatbotSDK.init({
// ========== 鉴权参数(推荐) ==========
token: 'eyJhbGciOiJIUzI1NiJ9...', // 后端换取的 SDK Token
roles: [ // 可选,角色选择器数据
roles: [ // 可用角色列表(传多个时 SDK header 显示角色切换下拉框)
{ id: '1', key: 'general', name: '通用客服' },
{ id: '2', key: 'finance', name: '财务顾问' }
],
@ -223,7 +224,41 @@ ChatbotSDK.init({
});
```
兼容模式下,`integrateId` 直接作为 `roleId` 传递给后端,无需 Token 换取步骤。
兼容模式下,`integrateId` 直接作为 `roleId` 传递给后端,无需 Token 换取步骤。由于不传 `roles`,SDK 不会显示角色选择器,用户只能使用初始指定的单一角色。
### 5.5 角色切换
`roles` 数组长度 > 1 时,SDK 会在聊天窗口头部自动显示角色选择下拉框,用户可随时切换当前使用的客服角色。
**触发条件:**
- `ChatbotSDK.init()` 时传入的 `roles` 数组包含 2 个及以上角色
**切换行为:**
1. 自动保存当前对话到 localStorage(与旧角色关联)
2. 清空当前消息列表
3. 生成新的 `chatId`(与新角色关联)
4. 尝试从 localStorage 恢复新角色的缓存消息
5. 异步从后端加载新角色的历史会话
**示例:**
```javascript
// 1. 后端换取 Token,获取可用角色列表
const data = await fetch('/api/chatbot/token').then(r => r.json());
// 2. 初始化 SDK,传入角色列表
ChatbotSDK.init({
integrateId: data.roles[0].id, // 默认使用第一个角色
requestDomain: 'https://your-domain.com',
token: data.token,
roles: data.roles, // 传入多个角色 → header 显示角色选择器
userId: 'current_user_id'
});
```
**安全说明:**
- 角色切换由 SDK 前端发起,后端 `SdkAuthFilter` 会校验 JWT Token 中的 `rids` claim,确保用户只能切换到被授权的角色
- 切换角色不需要重新换取 Token,JWT 中已包含允许的角色 ID 列表
---
@ -287,7 +322,7 @@ POST /open-api/auth/token
| `integrateId` | String/Number | **必填** | 客服角色 ID |
| `requestDomain` | String | **必填** | 后端域名 |
| `token` | String | - | SDK JWT Token(推荐) |
| `roles` | Array | - | 角色列表 `[{id, key, name}]` |
| `roles` | Array | - | 角色列表 `[{id, key, name}]`,长度 > 1 时 header 显示切换下拉框 |
| `userId` | String | - | 用户标识(会话隔离) |
| `categoryId` | String | - | 默认知识库分类 ID |
| `showCategorySwitch` | Boolean | false | 显示分类切换器 |
@ -425,7 +460,7 @@ app.listen(3000);
integrateId: data.roles[0].id, // 默认使用第一个角色
requestDomain: 'https://your-ai-domain.com',
token: data.token,
roles: data.roles,
roles: data.roles, // 传入多个角色时自动显示角色选择器
userId: 'current_user_id',
title: '在线客服',
theme: 'light',

2
client/README.md

@ -85,7 +85,7 @@ SDK 产物位于 `client/dist/` 目录:
| `width` | `number` | ❌ | `380` | P0 | 弹窗宽度(px) |
| `position` | `string` | ❌ | `"right-bottom"` | P0 | 悬浮按钮位置 |
| `primaryColor` | `string` | ❌ | `"#4F46E5"` | P0 | 主色调 |
| `launcherIcon` | `string` | ❌ | SVG 图标 | P0 | 悬浮按钮图标 |
| `launcherIcon` | `string` | ❌ | 极光粒子图标 | P0 | 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 |
| `showClear` | `boolean` | ❌ | `true` | P0 | 是否显示清空按钮 |
| `quickReplies` | `string[]` | ❌ | `[]` | P1 | 欢迎态快捷问题芯片,点击即自动发送 |
| `theme` | `string` | ❌ | `"light"` | P2 | 主题模式:`"light"` / `"dark"` |

680
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

69
client/src/api.ts

@ -15,6 +15,9 @@ const REQUEST_TIMEOUT = 30000;
let currentConfig: ResolvedConfig | null = null;
/** 运行时动态设置的角色 ID(通过角色选择器切换),优先于 currentConfig.integrateId */
let activeIntegrateId: string | null = null;
/** 设置当前配置 */
export function setApiConfig(config: ResolvedConfig): void {
currentConfig = config;
@ -23,6 +26,17 @@ export function setApiConfig(config: ResolvedConfig): void {
/** 清空当前配置(销毁 SDK 时调用) */
export function clearApiConfig(): void {
currentConfig = null;
activeIntegrateId = null;
}
/** 设置当前活跃角色 ID(角色切换时调用),影响后续所有 API 调用的 roleId 参数 */
export function setActiveRoleId(roleId: string | null): void {
activeIntegrateId = roleId;
}
/** 获取当前有效的 integrateId(优先使用运行时设置,回退到配置默认值) */
export function getActiveIntegrateId(): string {
return activeIntegrateId ?? currentConfig?.integrateId ?? '';
}
/** 更新当前 chatId(对话 ID) */
@ -38,7 +52,7 @@ export function getChatId(): string {
}
/** 构建完整请求 URL,自动防御双斜杠 */
export function buildUrl(path: string): string {
function buildUrl(path: string): string {
if (!currentConfig) {
throw new Error('API 配置未初始化');
}
@ -70,7 +84,7 @@ function buildChatUrl(message: string): string {
params.set('chatId', currentConfig!.chatId);
// integrateId 映射为 roleId
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
// userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig!.userId);
@ -85,7 +99,7 @@ function buildChatSSEUrl(message: string, categoryId?: number): string {
params.set('message', message);
params.set('chatId', currentConfig!.chatId);
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
@ -101,7 +115,7 @@ function buildChatRAGSSEUrl(message: string, categoryId?: number): string {
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
@ -117,7 +131,7 @@ function buildRagSourcesUrl(message: string, categoryId?: number): string {
params.set('chatId', currentConfig!.chatId);
params.set('rewriteStrategy', currentConfig!.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig!.integrateId);
setIfPresent(params, 'roleId', getActiveIntegrateId());
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'categoryId', categoryId ?? currentConfig!.categoryId);
@ -220,22 +234,22 @@ function getHttpErrorMessage(status: number): string {
*/
export async function chatRequest(message: string): Promise<string> {
const url = buildChatUrl(message);
logger.lifecycleSend(currentConfig!.integrateId, message.length);
logger.lifecycleSend(getActiveIntegrateId(), message.length);
try {
const response = await safeFetch(url);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
throw new CskError(errorMsg, `http_${response.status}`);
}
const text = await response.text();
logger.lifecycleReply(currentConfig!.integrateId, text.length);
logger.lifecycleReply(getActiveIntegrateId(), text.length);
return text;
} catch (err) {
if (err instanceof CskError) throw err;
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
throw new CskError(t('error_unknown'), 'unknown');
}
}
@ -259,14 +273,14 @@ export async function chatSSERequest(
: buildChatSSEUrl(message, categoryId);
let totalText = '';
logger.lifecycleSend(currentConfig!.integrateId, message.length);
logger.lifecycleSend(getActiveIntegrateId(), message.length);
try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2, signal);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(currentConfig!.integrateId, String(response.status), errorMsg);
logger.lifecycleError(getActiveIntegrateId(), String(response.status), errorMsg);
onError(new CskError(errorMsg, `http_${response.status}`));
return;
}
@ -328,7 +342,7 @@ export async function chatSSERequest(
reader.releaseLock();
}
logger.lifecycleStreamDone(currentConfig!.integrateId, totalText.length);
logger.lifecycleStreamDone(getActiveIntegrateId(), totalText.length);
onDone();
} catch (err) {
// 用户主动中断不触发 onError,走 onDone
@ -339,7 +353,7 @@ export async function chatSSERequest(
if (err instanceof CskError) {
onError(err);
} else {
logger.lifecycleError(currentConfig!.integrateId, 'unknown', String(err));
logger.lifecycleError(getActiveIntegrateId(), 'unknown', String(err));
onError(new CskError(t('error_network'), 'network'));
}
}
@ -441,6 +455,7 @@ interface ConversationItem {
chatId?: string;
accountId?: string;
roleId?: number;
roleName?: string;
messageCount?: number;
lastMessageTime?: string;
firstMessageTime?: string;
@ -494,7 +509,7 @@ export async function fetchConversationMessages(conversationId: string): Promise
}> {
const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
const url = buildUrl(`/ai/sdk/conversation/${conversationId}/messages?${params.toString()}`);
try {
const response = await safeFetch(url);
@ -517,7 +532,7 @@ export async function fetchConversationMessages(conversationId: string): Promise
export async function deleteConversation(conversationId: string): Promise<boolean> {
const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
const url = buildUrl(`/ai/sdk/conversation/${conversationId}?${params.toString()}`);
try {
const response = await safeFetch(url, { method: 'DELETE' });
@ -538,7 +553,7 @@ export async function deleteConversation(conversationId: string): Promise<boolea
export function getConversationExportUrl(conversationId: string): string {
const params = new URLSearchParams();
if (currentConfig?.userId) params.set('accountId', currentConfig.userId);
if (currentConfig?.integrateId) params.set('roleId', currentConfig.integrateId);
if (getActiveIntegrateId()) params.set('roleId', getActiveIntegrateId());
return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`);
}
@ -547,33 +562,33 @@ export function getConversationExportUrl(conversationId: string): string {
/**
* chatId
*
*
* 1. localStorage chatId integrateId + userId
* 2. /ai/sdk/conversation/list?accountId=X&roleId=Y
* 3. 使 conversationId chatId
* 4. chatIdsdk_timestamp_random
* @param forceRefresh true localStorage
*/
export async function initChatId(): Promise<string> {
export async function initChatId(forceRefresh: boolean = false): Promise<string> {
if (!currentConfig) return '';
// 1. 先尝试从 localStorage 恢复
const cachedChatId = loadCachedChatId(currentConfig.integrateId, currentConfig.userId);
const activeId = getActiveIntegrateId();
// 1. 先从 localStorage 恢复(非强制刷新时)
if (!forceRefresh) {
const cachedChatId = loadCachedChatId(activeId, currentConfig.userId);
if (cachedChatId) {
currentConfig.chatId = cachedChatId;
logger.info(`从缓存恢复 chatId=${cachedChatId}`);
return cachedChatId;
}
}
// 2. 查询后端会话列表
try {
const result = await fetchConversationList(1, 5, currentConfig.userId, currentConfig.integrateId);
const result = await fetchConversationList(1, 5, currentConfig.userId, activeId);
if (result.list.length > 0) {
// 使用最新会话的 conversationId 作为 chatId
const latestConv = result.list[0];
const chatId = latestConv.conversationId || latestConv.chatId || '';
if (chatId) {
currentConfig.chatId = chatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, chatId);
saveCachedChatId(activeId, currentConfig.userId, chatId);
logger.info(`从后端恢复会话 chatId=${chatId} messageCount=${latestConv.messageCount}`);
return chatId;
}
@ -585,7 +600,7 @@ export async function initChatId(): Promise<string> {
// 3. 生成新的 chatId
const newChatId = generateChatId();
currentConfig.chatId = newChatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, newChatId);
saveCachedChatId(activeId, currentConfig.userId, newChatId);
logger.info(`生成新 chatId=${newChatId}`);
return newChatId;
}

107
client/src/chat.ts

@ -21,6 +21,8 @@ import {
getChatId,
saveCachedChatId,
submitFeedbackApi,
setActiveRoleId,
getActiveIntegrateId,
CskError,
} from './api';
import {
@ -49,6 +51,7 @@ let inputWrap: HTMLElement | null = null;
let sendBtn: HTMLElement | null = null;
let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null;
let roleSelect: HTMLSelectElement | null = null;
let historyPanel: HTMLElement | null = null;
let welcomeEl: HTMLElement | null = null;
let newMsgBtn: HTMLElement | null = null;
@ -85,6 +88,7 @@ export function initChat(
sendBtn: HTMLElement;
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
historyPanel: HTMLElement;
welcomeEl: HTMLElement;
newMsgBtn: HTMLElement;
@ -101,6 +105,7 @@ export function initChat(
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
roleSelect = dom.roleSelect;
historyPanel = dom.historyPanel;
welcomeEl = dom.welcomeEl;
newMsgBtn = dom.newMsgBtn;
@ -175,8 +180,8 @@ async function loadHistoryFromBackend(): Promise<void> {
renderHistory();
logger.info(`从后端加载 ${messages.length} 条历史消息`);
// 同步到 localStorage
saveMessages(config.integrateId, messages);
// 同步到 localStorage(使用当前活跃角色 ID)
saveMessages(getActiveIntegrateId(), messages);
}
} catch (err) {
logger.warn('从后端加载历史消息失败', err);
@ -721,6 +726,78 @@ export function setCategory(categoryId: number | undefined): void {
logger.lifecycleCategoryChange(categoryId ?? '全部');
}
// ==================== 角色切换 ====================
/**
*
* - + chatId
* - API roleId
* -
*/
export async function switchRole(newRoleId: string): Promise<void> {
if (!config || !messagesContainer) return;
const oldRoleId = getActiveIntegrateId();
// 1. 判断是否与当前角色相同(防重复切换)
if (newRoleId === oldRoleId) return;
// 2. 保存当前消息到 localStorage(与旧 integrateId 关联)
if (messages.length > 0) {
saveMessages(oldRoleId, messages);
}
// 3. 中断正在进行的流式请求
if (abortController) {
abortController.abort();
abortController = null;
}
isSending = false;
setSendButtonMode('send');
// 4. 清空消息数组和 DOM
messages = [];
const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
msgNodes.forEach(el => el.remove());
// 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前
clearMessages(oldRoleId);
saveCachedChatId(oldRoleId, config.userId, undefined);
clearMessages(newRoleId);
saveCachedChatId(newRoleId, config.userId, undefined);
// 6. 更新 API 层的 integrateId(必须在 initChatId 之前)
setActiveRoleId(newRoleId);
// 7. 更新角色下拉框高亮项
if (roleSelect) {
roleSelect.value = newRoleId;
}
// 8. 强制查询后端会话列表(跳过 localStorage 缓存)
const foundChatId = await initChatId(true);
logger.info(`角色切换 initChatId 完成 chatId=${foundChatId} roleId=${newRoleId}`);
// 9. 从后端加载新角色的对话历史
if (foundChatId) {
try {
await loadHistoryFromBackend();
} catch (err) {
logger.warn(`切换角色后加载后端历史失败 roleId=${newRoleId}`, err);
}
}
// 10. 隐藏清空按钮 + 更新欢迎态
if (clearBtn && messages.length === 0) clearBtn.style.display = 'none';
updateEmptyState();
// 11. 重置滚动状态
isNearBottom = true;
if (messagesContainer) scrollToBottom(messagesContainer);
logger.info(`角色切换完成 ${oldRoleId} -> ${newRoleId}`);
}
// ==================== 会话管理面板 ====================
/** 加载会话列表并渲染 */
@ -733,10 +810,12 @@ export async function loadHistoryConversations(): Promise<void> {
listEl.innerHTML = `<div class="csk-history-panel__loading">加载中...</div>`;
try {
const result = await fetchConversationList(1, 50, config.userId, config.integrateId);
const result = await fetchConversationList(1, 50, config.userId, getActiveIntegrateId());
historyItems = result.list.map(c => ({
id: c.conversationId || '',
chatId: c.conversationId || '',
roleId: c.roleId,
roleName: c.roleName,
messageCount: c.messageCount,
lastMessageTime: c.lastMessageTime,
lastMessagePreview: c.lastMessagePreview,
@ -779,16 +858,36 @@ export async function loadHistoryConversations(): Promise<void> {
/**
*
*
* @param conversationId ID chatId
*/
export async function switchToConversation(conversationId: string): Promise<void> {
if (!config || !messagesContainer) return;
// 找到该会话对应的历史条目,获取其所属角色
const historyItem = historyItems.find(
it => (it.chatId || it.id) === conversationId
);
// 如果会话所属角色与当前活跃角色不同,先静默切换角色
if (historyItem && historyItem.roleId !== undefined) {
const convRoleId = String(historyItem.roleId);
const currentRoleId = getActiveIntegrateId();
if (convRoleId && convRoleId !== currentRoleId) {
logger.info(`会话角色不匹配,自动切换角色 ${currentRoleId} -> ${convRoleId}`);
setActiveRoleId(convRoleId);
// 更新角色下拉框高亮项
if (roleSelect) {
roleSelect.value = convRoleId;
}
}
}
logger.info(`切换到会话 conversationId=${conversationId}`);
// 1. 更新 chatId
updateChatId(conversationId);
saveCachedChatId(config.integrateId, config.userId, conversationId);
saveCachedChatId(getActiveIntegrateId(), config.userId, conversationId);
// 2. 关闭历史面板
if (historyPanel) {

107
client/src/config.ts

@ -9,87 +9,8 @@
import { SDKConfig, ResolvedConfig } from './types';
import { logger } from './logger';
/** 默认悬浮按钮图标 — C3 三点脉冲序列:极光旋转渐变底 + 三颗顺序脉冲白点 */
const DEFAULT_LAUNCHER_ICON = `<span class="csk-ico-pulse-dot csk-ico-pulse-dot--1"></span><span class="csk-ico-pulse-dot csk-ico-pulse-dot--2"></span><span class="csk-ico-pulse-dot csk-ico-pulse-dot--3"></span>`;
// ==================== 马卡龙色系玻璃质感图标 ====================
/**
* SVG
* + + +
*/
function buildGlassIcon(eyeColor: string, smileColor: string, bubbleColor: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 40 40" fill="none">
<defs>
<!-- -->
<linearGradient id="csk-glass-hi" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#fff" stop-opacity="0.72"/>
<stop offset="100%" stop-color="#fff" stop-opacity="0.18"/>
</linearGradient>
<!-- -->
<linearGradient id="csk-bot-body" x1="8" y1="10" x2="32" y2="34" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#fff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#fff" stop-opacity="0.78"/>
</linearGradient>
</defs>
<!-- + -->
<rect x="7" y="10" width="26" height="18" rx="5" fill="url(#csk-bot-body)" stroke="rgba(255,255,255,0.6)" stroke-width="0.8"/>
<path d="M12 28L8 33.5l7-5.5z" fill="url(#csk-bot-body)" stroke="rgba(255,255,255,0.5)" stroke-width="0.5"/>
<!-- -->
<ellipse cx="16" cy="14" rx="9" ry="4.5" fill="url(#csk-glass-hi)" opacity="0.55"/>
<!-- 线 + -->
<line x1="20" y1="10" x2="20" y2="5" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity="0.85"/>
<circle cx="20" cy="4" r="2" fill="#fff" opacity="0.9" class="csk-ico-antenna"/>
<!-- -->
<rect x="12" y="16" width="4.5" height="4" rx="1.5" fill="${eyeColor}" class="csk-ico-eye"/>
<rect x="23.5" y="16" width="4.5" height="4" rx="1.5" fill="${eyeColor}" class="csk-ico-eye"/>
<!-- -->
<circle cx="13.5" cy="17" r="0.9" fill="#fff" opacity="0.95"/>
<circle cx="25" cy="17" r="0.9" fill="#fff" opacity="0.95"/>
<!-- -->
<path d="M16 23 Q20 25.8 24 23" stroke="${smileColor}" stroke-width="1.4" fill="none" stroke-linecap="round"/>
<!-- 3 -->
<circle cx="31" cy="8" r="2.2" fill="${bubbleColor}" class="csk-ico-bubble csk-ico-bubble--1"/>
<circle cx="35" cy="5" r="1.6" fill="${bubbleColor}" class="csk-ico-bubble csk-ico-bubble--2" opacity="0.7"/>
<circle cx="28" cy="4.5" r="1.2" fill="${bubbleColor}" class="csk-ico-bubble csk-ico-bubble--3" opacity="0.5"/>
</svg>`;
}
/** 梦幻紫粉主题图标 */
const GLASS_ICON_DREAM_PURPLE = buildGlassIcon(
'rgba(139,92,246,0.6)', // 眼睛:紫色
'rgba(139,92,246,0.45)', // 微笑
'rgba(240,171,252,0.85)' // 气泡:粉紫
);
/** 薄荷青绿主题图标 */
const GLASS_ICON_MINT_TECH = buildGlassIcon(
'rgba(16,185,129,0.6)', // 眼睛:翠绿
'rgba(16,185,129,0.45)', // 微笑
'rgba(110,231,183,0.85)' // 气泡:薄荷
);
/** 珊瑚蜜桃主题图标 */
const GLASS_ICON_CORAL_PEACH = buildGlassIcon(
'rgba(244,63,94,0.55)', // 眼睛:珊瑚红
'rgba(244,63,94,0.4)', // 微笑
'rgba(253,186,116,0.85)' // 气泡:蜜桃
);
/** 天空蓝紫主题图标 */
const GLASS_ICON_SKY_BLUE = buildGlassIcon(
'rgba(56,189,248,0.6)', // 眼睛:天蓝
'rgba(56,189,248,0.45)', // 微笑
'rgba(167,139,250,0.85)' // 气泡:淡紫
);
/** 主题 → 默认主色映射(当用户未指定 primaryColor 时自动使用) */
const THEME_PRIMARY_COLORS: Record<string, string> = {
'dream-purple': '#A78BFA',
'mint-tech': '#34D399',
'coral-peach': '#FB7185',
'sky-blue': '#7DD3FC',
};
/** 默认悬浮按钮图标 — E5 梦幻拖尾·全粒子:极光旋转渐变底 + 20 颗粒子系统 */
const DEFAULT_LAUNCHER_ICON = `<span class="csk-pt csk-pt-burst" style="--dx:-16px;--dy:-12px"></span><span class="csk-pt csk-pt-burst" style="--dx:16px;--dy:-10px"></span><span class="csk-pt csk-pt-burst" style="--dx:-14px;--dy:14px"></span><span class="csk-pt csk-pt-burst" style="--dx:13px;--dy:13px"></span><span class="csk-pt csk-pt-burst" style="--dx:0px;--dy:-18px"></span><span class="csk-pt csk-pt-burst" style="--dx:18px;--dy:3px"></span><span class="csk-pt csk-pt-burst" style="--dx:-18px;--dy:-3px"></span><span class="csk-pt csk-pt-burst" style="--dx:0px;--dy:16px"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-float"></span><span class="csk-pt csk-pt-pulse"></span><span class="csk-pt csk-pt-pulse"></span><span class="csk-pt csk-pt-elliptic"></span><span class="csk-pt csk-pt-elliptic"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-pt csk-pt-ring"></span><span class="csk-sheen"></span><span class="csk-dual"></span>`;
/**
*
@ -119,26 +40,9 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
// integrateId 统一转为字符串(后端 roleId 为 Long,但 query param 传字符串也可接收)
const integrateIdStr = String(raw.integrateId).trim();
// 解析 launcherTheme:根据主题自动选择图标和默认主色
const launcherTheme = raw.launcherTheme || undefined;
let resolvedLauncherIcon = raw.launcherIcon || DEFAULT_LAUNCHER_ICON;
let resolvedPrimaryColor = raw.primaryColor || '#4F46E5';
if (launcherTheme && !raw.launcherIcon) {
// 有主题且未自定义图标 → 使用主题专属玻璃质感图标
const themeIconsMap: Record<string, string> = {
'dream-purple': GLASS_ICON_DREAM_PURPLE,
'mint-tech': GLASS_ICON_MINT_TECH,
'coral-peach': GLASS_ICON_CORAL_PEACH,
'sky-blue': GLASS_ICON_SKY_BLUE,
};
resolvedLauncherIcon = themeIconsMap[launcherTheme] || DEFAULT_LAUNCHER_ICON;
// 若未自定义主色 → 使用主题默认色
if (!raw.primaryColor) {
resolvedPrimaryColor = THEME_PRIMARY_COLORS[launcherTheme] || '#4F46E5';
}
}
// 解析图标:用户传入优先,否则使用默认极光粒子图标
const resolvedLauncherIcon = raw.launcherIcon || DEFAULT_LAUNCHER_ICON;
const resolvedPrimaryColor = raw.primaryColor || '#4F46E5';
// 填充默认值
const config: ResolvedConfig = {
@ -152,7 +56,6 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
height: Math.max(300, raw.height ?? 520),
position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom',
primaryColor: resolvedPrimaryColor,
launcherTheme: launcherTheme,
launcherIcon: resolvedLauncherIcon,
showClear: raw.showClear ?? true,
showAdminPanel: raw.showAdminPanel ?? false,

60
client/src/dom.ts

@ -8,7 +8,7 @@
* -
*/
import { ResolvedConfig, RagSource } from './types';
import { debounce } from './utils';
import { debounce, formatTime } from './utils';
import { t } from './i18n';
// ==================== 图标常量 ====================
@ -25,9 +25,7 @@ const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" f
export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement {
const launcher = document.createElement('div');
launcher.id = 'csk-launcher';
// 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式
const glassClass = config.launcherTheme ? ' csk-launcher--glass' : '';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`;
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
@ -66,6 +64,7 @@ export function createChatWindow(config: ResolvedConfig): {
sendBtn: HTMLElement;
clearBtn: HTMLElement | null;
categorySelect: HTMLSelectElement | null;
roleSelect: HTMLSelectElement | null;
historyPanel: HTMLElement;
welcomeEl: HTMLElement;
newMsgBtn: HTMLElement;
@ -111,6 +110,42 @@ export function createChatWindow(config: ResolvedConfig): {
headerLeft.appendChild(headerAvatar);
headerLeft.appendChild(headerInfo);
// === 角色选择器(仅当有多个角色时显示) ===
let roleSelect: HTMLSelectElement | 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);
header.appendChild(headerLeft);
header.appendChild(roleWrap);
} else {
header.appendChild(headerLeft);
}
const actions = document.createElement('div');
actions.className = 'csk-header__actions';
@ -141,7 +176,6 @@ export function createChatWindow(config: ResolvedConfig): {
actions.appendChild(historyBtn);
actions.appendChild(minimizeBtn);
actions.appendChild(closeBtn);
header.appendChild(headerLeft);
header.appendChild(actions);
// === 消息区 ===
@ -368,6 +402,7 @@ export function createChatWindow(config: ResolvedConfig): {
resizeHandle,
searchInput,
ariaLiveEl,
roleSelect,
showLoading,
hideLoading,
};
@ -934,7 +969,7 @@ async function copyToClipboard(text: string, btn: HTMLElement): Promise<void> {
}
/** 为气泡内所有代码块添加复制按钮 */
export function enhanceCodeBlocks(bubble: HTMLElement): void {
function enhanceCodeBlocks(bubble: HTMLElement): void {
const blocks = bubble.querySelectorAll('.csk-md-code-block');
blocks.forEach((pre) => {
if (pre.querySelector('.csk-md-code-copy')) return;
@ -1053,6 +1088,10 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
export interface HistoryItemData {
id: string;
chatId?: string;
/** 会话所属角色 ID */
roleId?: number;
/** 会话所属角色名称 */
roleName?: string;
messageCount?: number;
lastMessageTime?: string;
lastMessagePreview?: string;
@ -1160,6 +1199,7 @@ export function renderHistoryList(
const metaEl = document.createElement('div');
metaEl.className = 'csk-history-item__meta';
const metaParts: string[] = [];
if (item.roleName) metaParts.push(item.roleName);
if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`);
if (item.lastMessageTime) metaParts.push(item.lastMessageTime);
else if (item.createdAt) metaParts.push(item.createdAt);
@ -1211,11 +1251,3 @@ export function renderHistoryList(
export function scrollToBottom(container: HTMLElement): void {
container.scrollTop = container.scrollHeight;
}
/** 格式化时间戳 */
function formatTime(timestamp: number): string {
const d = new Date(timestamp);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}

4
client/src/i18n.ts

@ -10,6 +10,7 @@ const dictionaries: Record<string, Record<string, string>> = {
minimize: '最小化',
close: '关闭',
status_online: '在线',
role_selector_label: '服务角色',
// 欢迎空状态
welcome_title: '你好,我是 AI 智能助手',
@ -87,6 +88,7 @@ const dictionaries: Record<string, Record<string, string>> = {
minimize: 'Minimize',
close: 'Close',
status_online: 'Online',
role_selector_label: 'Service',
// Welcome
welcome_title: 'Hi, I am your AI assistant',
@ -201,6 +203,6 @@ export function t(key: string, params?: Record<string, string | number>): string
/**
*
*/
export function getLocale(): string {
function getLocale(): string {
return currentLocale;
}

8
client/src/index.ts

@ -13,7 +13,7 @@ import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig, clearApiConfig } from './api';
import { injectStyles, removeStyles } from './styles';
import { createLauncher, createChatWindow, enableDrag, enableLauncherDrag, enableResize } from './dom';
import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback } from './chat';
import { initChat, initChatHistory, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback, switchRole } from './chat';
import { clearMessages } from './storage';
import { setLocale } from './i18n';
@ -169,6 +169,7 @@ function init(rawConfig: SDKConfig): void {
newMsgBtn: dom.newMsgBtn,
searchInput: dom.searchInput,
ariaLiveEl: dom.ariaLiveEl,
roleSelect: dom.roleSelect,
showLoading: showLoadingFn,
hideLoading: hideLoadingFn,
});
@ -207,6 +208,11 @@ function init(rawConfig: SDKConfig): void {
}
}) as EventListener);
// 16.5 监听角色切换事件
windowEl.addEventListener('csk:roleChange', ((e: CustomEvent) => {
if (e.detail.roleId) switchRole(String(e.detail.roleId));
}) as EventListener);
// 17. ESC 键关闭弹窗
document.addEventListener('keydown', onKeyDown);

385
client/src/styles.ts

@ -21,16 +21,6 @@ function cssVars(config: ResolvedConfig): string {
const lighter = adjustColor(config.primaryColor, 18);
const rgb = hexToRgb(config.primaryColor);
// 马卡龙主题渐变色映射
const themeGradients: Record<string, { grad1: string; grad2: string; glow: string }> = {
'dream-purple': { grad1: '#A78BFA', grad2: '#F0ABFC', glow: '167, 139, 250' },
'mint-tech': { grad1: '#6EE7B7', grad2: '#34D399', glow: '52, 211, 153' },
'coral-peach': { grad1: '#FB7185', grad2: '#FDBA74', glow: '251, 113, 133' },
'sky-blue': { grad1: '#7DD3FC', grad2: '#A78BFA', glow: '125, 211, 252' },
};
const themeColors = config.launcherTheme ? themeGradients[config.launcherTheme] : null;
return `
--csk-primary: ${config.primaryColor};
--csk-primary-hover: ${darker};
@ -47,11 +37,6 @@ function cssVars(config: ResolvedConfig): string {
--csk-shadow-bubble: 0 1px 2px rgba(15, 23, 42, 0.06);
--csk-border: #ECEEF2;
--csk-bg-app: #F6F7F9;
${themeColors ? `
--csk-launcher-grad-1: ${themeColors.grad1};
--csk-launcher-grad-2: ${themeColors.grad2};
--csk-launcher-glow: ${themeColors.glow};
` : ''}
`;
}
@ -92,13 +77,8 @@ function getStyles(config: ResolvedConfig): string {
width: 60px;
height: 60px;
border-radius: 50%;
/* 极光旋转渐变底 — 默认蓝紫系,由 primaryColor 驱动的 conic-gradient */
background: conic-gradient(from 0deg,
var(--csk-primary-light, #06b6d4),
var(--csk-primary, #3b82f6),
var(--csk-primary-hover, #8b5cf6),
var(--csk-primary-light, #06b6d4)
);
/* E5 梦幻拖尾 — 四段极光渐变慢旋 */
background: conic-gradient(from 0deg, #ec4899 0deg, #8b5cf6 90deg, #3b82f6 180deg, #06b6d4 270deg, #ec4899 360deg);
display: flex;
align-items: center;
justify-content: center;
@ -112,63 +92,27 @@ function getStyles(config: ResolvedConfig): string {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 4px 16px rgba(0, 0, 0, 0.10),
0 6px 24px rgba(var(--csk-primary-rgb), 0.28),
0 0 48px rgba(var(--csk-primary-rgb), 0.12);
0 6px 24px rgba(236, 72, 153, 0.28),
0 0 48px rgba(236, 72, 153, 0.12);
transition:
transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease,
border-color 0.25s ease;
animation:
csk-aurora-spin 4s linear infinite,
csk-aurora-spin 5s linear infinite,
csk-launcher-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.csk-launcher--right { right: 24px; }
.csk-launcher--left { left: 24px; }
/* 高光扫过动效 */
.csk-launcher::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 40%;
height: 200%;
border-radius: 50%;
background: linear-gradient(
105deg,
transparent 30%,
rgba(255, 255, 255, 0.28) 48%,
rgba(255, 255, 255, 0.1) 52%,
transparent 70%
);
transform: rotate(25deg);
animation: csk-shimmer 4s ease-in-out infinite;
pointer-events: none;
}
/* 呼吸色晕 — 配合接地阴影,强化白底上的立体存在感 */
.csk-launcher::after {
content: '';
position: absolute;
inset: -18px;
border-radius: 50%;
background: radial-gradient(circle, rgba(var(--csk-primary-rgb), 0.18) 0%, rgba(var(--csk-primary-rgb), 0.05) 45%, transparent 68%);
animation: csk-breathe 3.5s ease-in-out infinite;
pointer-events: none;
}
.csk-launcher:hover {
transform: translateY(-3px) scale(1.08);
border-color: rgba(255, 255, 255, 0.75);
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.10),
0 8px 24px rgba(0, 0, 0, 0.14),
0 10px 36px rgba(var(--csk-primary-rgb), 0.32),
0 0 56px rgba(var(--csk-primary-rgb), 0.14);
}
.csk-launcher:hover::before { animation-play-state: paused; }
.csk-launcher:hover::after {
animation: none;
inset: -22px;
background: radial-gradient(circle, rgba(var(--csk-primary-rgb), 0.22) 0%, rgba(var(--csk-primary-rgb), 0.06) 45%, transparent 68%);
0 10px 36px rgba(236, 72, 153, 0.32),
0 0 56px rgba(236, 72, 153, 0.14);
}
.csk-launcher:active { transform: scale(0.93); }
.csk-launcher svg {
@ -185,140 +129,149 @@ function getStyles(config: ResolvedConfig): string {
100% { opacity: 1; transform: scale(1) translateY(0); }
}
/* ========== 三点脉冲序列图标 ========== */
/* ========== E5 梦幻拖尾·全粒子动画系统 ========== */
/* 极光旋转 */
@keyframes csk-aurora-spin {
to { transform: rotate(360deg); }
}
/* 脉冲点 */
.csk-ico-pulse-dot {
/* 粒子通用 */
.csk-pt {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
z-index: 5;
animation: csk-seq-pulse 1.2s ease-in-out infinite;
box-shadow: 0 0 6px 1px rgba(255, 255, 255, 0.5);
z-index: 4;
top: 50%;
left: 50%;
pointer-events: none;
}
.csk-ico-pulse-dot--1 { top: 4px; left: 50%; margin-left: -3px; }
.csk-ico-pulse-dot--2 { top: 50%; right: 5px; margin-top: -3px; animation-delay: 0.3s; }
.csk-ico-pulse-dot--3 { bottom: 5px; left: 50%; margin-left: -3px; animation-delay: 0.6s; }
@keyframes csk-seq-pulse {
0%, 100% { transform: scale(0.4); opacity: 0.3; box-shadow: 0 0 4px 1px rgba(255, 255, 255, 0.3); }
35% { transform: scale(1.8); opacity: 1; box-shadow: 0 0 12px 4px rgba(255, 255, 255, 0.8); }
70% { transform: scale(0.4); opacity: 0.3; box-shadow: 0 0 4px 1px rgba(255, 255, 255, 0.3); }
/* ── 爆散粒子(8颗,不同方向/距离/大小/延迟)── */
.csk-pt-burst { animation: csk-burst 2.8s ease-in-out infinite; }
.csk-pt-burst:nth-child(1) { width:5px; height:5px; animation-delay:0s; }
.csk-pt-burst:nth-child(2) { width:4px; height:4px; animation-delay:0.35s; }
.csk-pt-burst:nth-child(3) { width:5px; height:5px; animation-delay:0.7s; }
.csk-pt-burst:nth-child(4) { width:3px; height:3px; animation-delay:1.05s; }
.csk-pt-burst:nth-child(5) { width:5px; height:5px; animation-delay:1.4s; }
.csk-pt-burst:nth-child(6) { width:4px; height:4px; animation-delay:1.75s; }
.csk-pt-burst:nth-child(7) { width:3px; height:3px; animation-delay:2.1s; }
.csk-pt-burst:nth-child(8) { width:5px; height:5px; animation-delay:2.45s; }
@keyframes csk-burst {
0% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
12% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
32% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1.5); opacity: 1; }
48% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(0.5); opacity: .55; }
62% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
100% { transform: translate(-50%,-50%) scale(0); opacity: 0; }
}
/* 移除旧的 SVG 图标相关样式(已不再使用) */
/* ── 浮游粒子(持续轨道运动,永不停歇)── */
.csk-pt-float {
animation: csk-float-orbit 3s linear infinite;
box-shadow: 0 0 4px 1px rgba(255,255,255,.5);
}
.csk-pt-float:nth-child(9) { width:3px; height:3px; animation-duration:2.4s; animation-delay:0s; }
.csk-pt-float:nth-child(10) { width:4px; height:4px; animation-duration:3.2s; animation-delay:-0.6s; }
.csk-pt-float:nth-child(11) { width:3px; height:3px; animation-duration:2.8s; animation-delay:-1.2s; }
.csk-pt-float:nth-child(12) { width:4px; height:4px; animation-duration:3.6s; animation-delay:-1.8s; }
/* ========== 高光扫过动效 ========== */
.csk-launcher::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 40%;
height: 200%;
border-radius: 50%;
background: linear-gradient(
105deg,
transparent 30%,
rgba(255, 255, 255, 0.28) 48%,
rgba(255, 255, 255, 0.1) 52%,
transparent 70%
);
transform: rotate(25deg);
animation: csk-shimmer 4s ease-in-out infinite;
pointer-events: none;
@keyframes csk-float-orbit {
0% { transform: translate(-50%,-50%) rotate(0deg) translateX(20px) rotate(0deg); }
100% { transform: translate(-50%,-50%) rotate(360deg) translateX(20px) rotate(-360deg); }
}
/* 呼吸色晕 — 配合接地阴影,强化白底上的立体存在感 */
.csk-launcher::after {
content: '';
position: absolute;
inset: -18px;
border-radius: 50%;
background: radial-gradient(circle, rgba(var(--csk-primary-rgb), 0.18) 0%, rgba(var(--csk-primary-rgb), 0.05) 45%, transparent 68%);
animation: csk-breathe 3.5s ease-in-out infinite;
pointer-events: none;
/* ── 心跳粒子(中心脉冲 + 呼吸缩放)── */
.csk-pt-pulse {
animation: csk-heart-pulse 1.4s ease-in-out infinite;
}
@keyframes csk-shimmer {
0%, 100% { left: -60%; opacity: 0; }
20% { opacity: 1; }
60% { opacity: 1; }
80% { left: 120%; opacity: 0; }
.csk-pt-pulse:nth-child(13) {
width: 8px; height: 8px;
box-shadow: 0 0 14px 4px rgba(255,255,255,.8), 0 0 28px 8px rgba(255,255,255,.3);
animation-delay: 0s;
}
@keyframes csk-breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.35; }
.csk-pt-pulse:nth-child(14) {
width: 4px; height: 4px;
box-shadow: 0 0 8px 2px rgba(255,255,255,.6);
animation-delay: 0.7s;
}
/* ========== 磨砂玻璃质感悬浮按钮 ========== */
.csk-launcher--glass {
/* 覆盖旋转动画为静止渐变 */
animation:
csk-launcher-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both !important;
background: linear-gradient(
135deg,
var(--csk-launcher-grad-1, var(--csk-primary)) 0%,
var(--csk-launcher-grad-2, var(--csk-primary-light)) 100%
) !important;
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border: 1.5px solid rgba(255, 255, 255, 0.55) !important;
/* 多层阴影:接地 + 彩色光晕 + 内阴影立体感 */
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.04),
0 6px 20px rgba(0, 0, 0, 0.08),
0 8px 32px rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.30),
inset 0 1px 1px rgba(255, 255, 255, 0.35),
inset 0 -1px 1px rgba(0, 0, 0, 0.04) !important;
}
.csk-launcher--glass:hover {
border-color: rgba(255, 255, 255, 0.75) !important;
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.06),
0 10px 28px rgba(0, 0, 0, 0.10),
0 12px 40px rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.38),
inset 0 1px 2px rgba(255, 255, 255, 0.45),
inset 0 -1px 1px rgba(0, 0, 0, 0.04) !important;
@keyframes csk-heart-pulse {
0%, 100% { transform: translate(-50%,-50%) scale(0.4); opacity: .3; }
25% { transform: translate(-50%,-50%) scale(1.6); opacity: 1; }
50% { transform: translate(-50%,-50%) scale(0.4); opacity: .3; }
75% { transform: translate(-50%,-50%) scale(0.9); opacity: .6; }
}
/* 玻璃按钮的呼吸色晕 — 更鲜艳、更醒目 */
.csk-launcher--glass::after {
background: radial-gradient(
circle,
rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.28) 0%,
rgba(var(--csk-launcher-glow, var(--csk-primary-rgb)), 0.10) 40%,
transparent 65%
) !important;
animation: csk-breathe-glow 3.5s ease-in-out infinite;
}
@keyframes csk-breathe-glow {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.22); opacity: 0.25; }
/* ── 甩尾粒子(椭圆轨道 + 变速,近大远小)── */
.csk-pt-elliptic {
animation: csk-elliptic-orbit 2.6s ease-in-out infinite;
box-shadow: 0 0 5px 1px rgba(255,255,255,.6);
}
.csk-pt-elliptic:nth-child(15) { width:4px; height:4px; animation-delay:0s; }
.csk-pt-elliptic:nth-child(16) { width:4px; height:4px; animation-delay:-1.3s; }
/* 玻璃按钮的高光扫过 — 更柔和的玻璃质感 */
.csk-launcher--glass::before {
background: linear-gradient(
115deg,
transparent 25%,
rgba(255, 255, 255, 0.35) 45%,
rgba(255, 255, 255, 0.15) 55%,
transparent 75%
) !important;
width: 50%;
animation: csk-shimmer-glass 5s ease-in-out infinite;
}
@keyframes csk-shimmer-glass {
0%, 100% { left: -70%; opacity: 0; }
15% { opacity: 1; }
55% { opacity: 1; }
70% { left: 130%; opacity: 0; }
@keyframes csk-elliptic-orbit {
0% { transform: translate(-50%,-50%) rotate(0deg) translateX(18px) rotate(0deg) scaleY(1); }
25% { transform: translate(-50%,-50%) rotate(90deg) translateX(18px) rotate(-90deg) scaleY(0.45); }
50% { transform: translate(-50%,-50%) rotate(180deg) translateX(18px) rotate(-180deg) scaleY(1); }
75% { transform: translate(-50%,-50%) rotate(270deg) translateX(18px) rotate(-270deg) scaleY(0.45); }
100% { transform: translate(-50%,-50%) rotate(360deg) translateX(18px) rotate(-360deg) scaleY(1); }
}
/* 三点脉冲图标在玻璃主题下保持可见 */
/* ── 环绕流光(外层慢速光环拖尾)── */
.csk-pt-ring {
width: 3px; height: 3px;
background: rgba(255,255,255,.9);
box-shadow: 0 0 6px 2px rgba(255,255,255,.7);
animation: csk-ring-orbit 5s linear infinite;
}
.csk-pt-ring:nth-child(17) { animation-delay: 0s; }
.csk-pt-ring:nth-child(18) { animation-delay: -1.25s; }
.csk-pt-ring:nth-child(19) { animation-delay: -2.5s; }
.csk-pt-ring:nth-child(20) { animation-delay: -3.75s; }
@keyframes csk-ring-orbit {
0% { transform: translate(-50%,-50%) rotate(0deg) translateX(23px) rotate(0deg) scale(1); opacity: .8; }
50% { transform: translate(-50%,-50%) rotate(180deg) translateX(23px) rotate(-180deg) scale(1.8); opacity: 1; }
100% { transform: translate(-50%,-50%) rotate(360deg) translateX(23px) rotate(-360deg) scale(1); opacity: .8; }
}
/* ── 极光扫光叠加层 ── */
.csk-sheen {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 3;
overflow: hidden;
}
.csk-sheen::after {
content: '';
position: absolute;
top: -60%; left: -60%;
width: 35%; height: 220%;
background: rgba(255,255,255,.15);
transform: rotate(22deg);
animation: csk-shimmer-sweep 2.8s ease-in-out infinite;
}
@keyframes csk-shimmer-sweep {
0% { left: -60%; }
40% { left: 50%; }
60% { left: 50%; }
100% { left: 130%; }
}
/* ── 双层极光圈(反向旋转,增加层次)── */
.csk-dual {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
z-index: 2;
background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,.08) 35%, transparent 65%);
animation: csk-aurora-spin 2s linear infinite reverse;
}
/* ========== 聊天弹窗 ========== */
.csk-window {
@ -452,6 +405,43 @@ function getStyles(config: ResolvedConfig): string {
.csk-header__btn:active,
.csk-history-btn:active { transform: scale(0.92); }
/* ========== 角色选择器 ========== */
.csk-role-select-wrap {
display: flex;
align-items: center;
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 {
flex: 1;
@ -839,6 +829,7 @@ function getStyles(config: ResolvedConfig): string {
.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;
@ -1025,19 +1016,6 @@ function getStyles(config: ResolvedConfig): string {
color: #9CA3AF;
font-size: 13px;
}
.csk-history-panel__loadmore {
display: block;
width: 100%;
padding: 10px;
border: none;
background: #F9FAFB;
color: #6B7280;
font-size: 12px;
cursor: pointer;
text-align: center;
transition: background 0.15s;
}
.csk-history-panel__loadmore:hover { background: #F3F4F6; }
/* ========== 快捷问题芯片 ========== */
.csk-quick-replies {
@ -1117,7 +1095,6 @@ function getStyles(config: ResolvedConfig): string {
}
/* ========== 代码块复制按钮 ========== */
.csk-md-code-block { position: relative; }
.csk-md-code-copy {
position: absolute;
top: 8px;
@ -1329,6 +1306,10 @@ function getStyles(config: ResolvedConfig): string {
.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 {
@ -1337,11 +1318,9 @@ function getStyles(config: ResolvedConfig): string {
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.14),
0 12px 32px rgba(0, 0, 0, 0.18),
0 14px 40px rgba(var(--csk-primary-rgb), 0.36),
0 0 56px rgba(var(--csk-primary-rgb), 0.18) !important;
0 14px 40px rgba(236, 72, 153, 0.36),
0 0 56px rgba(236, 72, 153, 0.18) !important;
}
.csk-launcher--dragging::before,
.csk-launcher--dragging::after { animation-play-state: paused !important; }
/* 拖拽中隐藏未读徽章 */
.csk-launcher--dragging .csk-launcher__badge { display: none !important; }
/* 吸附过渡动画(释放时的缓动) */
@ -1555,26 +1534,6 @@ export function injectStyles(config: ResolvedConfig): void {
document.head.appendChild(styleElement);
}
/**
* CSS 使
*/
export function updateTheme(config: ResolvedConfig): void {
const root = document.querySelector('.csk-root') as HTMLElement;
if (!root) {
return;
}
const vars = cssVars(config);
vars.split(';').forEach((pair) => {
const idx = pair.indexOf(':');
if (idx < 0) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key && value) {
root.style.setProperty(key, value);
}
});
}
/**
*
*/

11
client/src/types.ts

@ -23,7 +23,7 @@ export interface SDKConfig {
/** SDK JWT Token(从 /open-api/auth/token 换取),用于访问 /ai/** 接口 */
token?: string;
/** 可用客服角色列表(与 token 配套使用,决定用户可选择哪些角色) */
roles?: Array<{ id: string | number; name?: string }>;
roles?: Array<{ id: string | number; key?: string; name?: string }>;
// === 知识库 ===
/** 默认知识库分类 ID */
@ -42,9 +42,7 @@ export interface SDKConfig {
position?: 'left-bottom' | 'right-bottom';
/** 主色调,默认 "#4F46E5" */
primaryColor?: string;
/** 悬浮按钮主题风格(马卡龙色系磨砂玻璃),不传则使用 primaryColor 渐变 */
launcherTheme?: 'dream-purple' | 'mint-tech' | 'coral-peach' | 'sky-blue';
/** 悬浮按钮图标(可传 URL 或 SVG 字符串) */
/** 悬浮按钮图标(可传 URL 或 SVG 字符串),默认使用极光粒子动画图标 */
launcherIcon?: string;
/** 是否显示清空对话按钮,默认 true */
showClear?: boolean;
@ -103,7 +101,7 @@ export interface ResolvedConfig {
/** SDK JWT Token → 访问 /ai/** 接口的认证头 */
token?: string;
/** 可用客服角色列表 */
roles?: Array<{ id: string | number; name?: string }>;
roles?: Array<{ id: string | number; key?: string; name?: string }>;
/** 知识库分类 ID */
categoryId?: number;
/** 是否显示知识库切换 */
@ -118,8 +116,6 @@ export interface ResolvedConfig {
position: 'left-bottom' | 'right-bottom';
/** 主色调 */
primaryColor: string;
/** 悬浮按钮主题风格 */
launcherTheme?: 'dream-purple' | 'mint-tech' | 'coral-peach' | 'sky-blue';
/** 悬浮按钮图标 */
launcherIcon: string;
/** 显示清空按钮 */
@ -207,6 +203,7 @@ export interface ConversationSummary {
chatId: string;
accountId?: string;
roleId?: number;
roleName?: string;
messageCount?: number;
lastMessageTime?: number;
createdAt?: number;

9
client/src/utils.ts

@ -2,15 +2,6 @@
*
*/
/** 生成简短 UUID(取 crypto.randomUUID 前 8 位) */
export function shortUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID().substring(0, 8);
}
// fallback
return Math.random().toString(36).substring(2, 10);
}
/** 生成完整 UUID */
export function uuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {

10
src/main/java/com/wok/supportbot/security/SdkAuthFilter.java

@ -40,6 +40,10 @@ public class SdkAuthFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
// 放行 CORS 预检请求OPTIONS 请求不需要 Token 鉴权
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 仅拦截 /ai/ 路径排除静态资源和 swagger
return !path.startsWith("/ai/");
}
@ -100,10 +104,16 @@ public class SdkAuthFilter extends OncePerRequestFilter {
/**
* 输出 JSON 格式的错误响应 ApiKeyAuthFilter 保持一致的格式
* 同时添加 CORS 避免浏览器将鉴权失败误报为跨域错误
*/
private void writeError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
// 添加 CORS 使浏览器能正确读取错误响应
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials", "true");
Map<String, Object> body = Map.of(
"success", false,
"message", message,

17
src/main/resources/static/components/ApiKeyManager.js

@ -148,13 +148,14 @@ export default {
<div v-else-if="allRoles.length === 0" style="text-align:center;padding:20px;color:#999;">暂无可用角色请先创建客服角色</div>
<div v-else>
<div v-for="role in allRoles" :key="role.id"
style="display:flex;align-items:center;gap:8px;padding:10px 12px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;cursor:pointer;"
@click="toggleRole(Number(role.id))">
<input type="checkbox" :checked="selectedRoleIds.includes(Number(role.id))" @click.stop style="cursor:pointer;" />
<div style="flex:1;">
style="display:flex;align-items:center;gap:8px;padding:10px 12px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;">
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;flex:1;">
<input type="checkbox" v-model="selectedRoleIds" :value="Number(role.id)" />
<div>
<div style="font-weight:500;font-size:14px;">{{ role.name }}</div>
<div style="font-size:12px;color:#999;">{{ role.role_key }}<span v-if="role.description"> · {{ role.description }}</span></div>
</div>
</label>
</div>
</div>
@ -288,6 +289,12 @@ export default {
else selectedRoleIds.value.push(roleId)
}
function toggleRole(roleId) {
const idx = selectedRoleIds.value.indexOf(roleId)
if (idx >= 0) selectedRoleIds.value.splice(idx, 1)
else selectedRoleIds.value.push(roleId)
}
async function doSaveRoles() {
try {
const res = await updateApiKeyRoles(editingKeyId.value, selectedRoleIds.value)
@ -351,7 +358,7 @@ export default {
allRoles, roleLoading, showRoleDialog, editingKeyId, editingKeyName, selectedRoleIds,
loadList, openCreateDialog, doCreate,
doRevoke, doEnable, doDelete,
openRoleDialog, toggleRole, doSaveRoles,
openRoleDialog, doSaveRoles,
getBoundRoleCount, maskKey, copyKey, formatDate
}
}

680
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

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

@ -205,15 +205,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
</div>
<div class="fg-row">
<div class="fg">
<label>launcherTheme 悬浮按钮主题</label>
<select id="cfg-theme">
<option value="" selected>默认(三点脉冲旋转极光)</option>
<option value="dream-purple">🔮 梦幻紫粉</option>
<option value="mint-tech">🌿 薄荷青绿</option>
<option value="coral-peach">🍑 珊瑚蜜桃</option>
<option value="sky-blue">🌊 天空蓝紫</option>
</select>
<div class="hint">磨砂玻璃 + 呼吸发光</div>
<label>launcherIcon 悬浮按钮图标</label>
<input id="cfg-launcher-icon" type="text" placeholder="URL 或 SVG 字符串,留空使用默认极光粒子图标" style="width:100%;box-sizing:border-box;" />
<div class="hint">留空则使用默认极光粒子动画</div>
</div>
<div class="fg">
<label>showClear 清空按钮</label>
@ -354,7 +348,6 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
debug: getEl('cfg-debug').value === '1',
theme: getEl('cfg-theme-mode').value,
showClear: getEl('cfg-clear').value === '1',
launcherTheme: getEl('cfg-theme').value || undefined,
};
// 有 Token 时传入鉴权参数
if (token) {
@ -501,7 +494,6 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
if (cfg.showCategorySwitch !== SDK_DEFAULTS.showCategorySwitch) params.push(' showCategorySwitch: ' + cfg.showCategorySwitch + ', // 是否显示知识库切换');
if (cfg.enableRag !== SDK_DEFAULTS.enableRag) params.push(' enableRag: ' + cfg.enableRag + ', // 启用 RAG 知识库检索');
if (cfg.showClear !== SDK_DEFAULTS.showClear) params.push(' showClear: ' + cfg.showClear + ', // 显示清空按钮');
if (cfg.launcherTheme) params.push(' launcherTheme: ' + JSON.stringify(cfg.launcherTheme) + ', // 悬浮按钮主题(磨砂玻璃质感)');
// debug 强制 false(生产环境推荐),无论面板当前值
params.push(' debug: false // 调试日志,生产环境建议关闭');
const initBlock = 'ChatbotSDK.init({\n' + params.join('\n') + '\n});';
@ -533,7 +525,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
// 语法高亮:先转义 HTML,再用单遍正则匹配 HTML 注释 / 字符串 / 行注释 / 数字 / 对象 key
function highlightCode(code) {
const esc = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const re = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|showAdminPanel|launcherIcon|launcherTheme|token|roles)(?=\s*:)/g;
const re = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|enableRag|rewriteStrategy|debug|showClear|showAdminPanel|launcherIcon|token|roles)(?=\s*:)/g;
return esc.replace(re, (m, htmlCmt, str, cmt, num, key) => {
if (htmlCmt) return '<span class="cm">' + htmlCmt + '</span>';
if (str) return '<span class="st">' + str + '</span>';

Loading…
Cancel
Save