Browse Source

SDK 新增 6 项功能完善(类型导出/窗口配置/错误回调/测试/分组/通知)

1. 类型导出:tsconfig 开启 declaration,新增 build:types 脚本,index.ts re-export 公开类型
  2. 窗口高度可配置 + 记忆位置:height 配置项(默认 520,最小 300),拖拽位置 localStorage 持久化
  3. 错误监控回调:新增 onError/onReady/onMessage 三个生命周期回调
  4. 自动化测试:引入 Vitest,4 个测试文件共 67 个用例(markdown/config/i18n/utils)
  5. 会话分组:历史面板按今天/昨天/本周/更早分组渲染
  6. 声音/桌面通知:Web Audio API 合成提示音 + Notification API 桌面通知
dev-mcp
wanghanlin 4 weeks ago
parent
commit
bc334a92b1
  1. 438
      client/dist/chatbot-sdk.js
  2. 2
      client/dist/chatbot-sdk.js.map
  3. 2
      client/dist/chatbot-sdk.min.js
  4. 2
      client/dist/chatbot-sdk.min.js.map
  5. 1019
      client/icon-designs-preview.html
  6. 1136
      client/package-lock.json
  7. 11
      client/package.json
  8. 3
      client/rollup.config.js
  9. 2
      client/src/chat.ts
  10. 111
      client/src/config.ts
  11. 70
      client/src/dom.ts
  12. 8
      client/src/i18n.ts
  13. 130
      client/src/index.ts
  14. 19
      client/src/logger.ts
  15. 109
      client/src/styles.ts
  16. 32
      client/src/types.ts
  17. 134
      client/tests/config.test.ts
  18. 100
      client/tests/i18n.test.ts
  19. 150
      client/tests/markdown.test.ts
  20. 86
      client/tests/utils.test.ts
  21. 3
      client/tsconfig.json
  22. 9
      client/vitest.config.ts
  23. 438
      src/main/resources/static/sdk/chatbot-sdk.js
  24. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  25. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  26. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map
  27. 31
      src/main/resources/static/sdk/test.html

438
client/dist/chatbot-sdk.js

@ -3,10 +3,16 @@ var ChatbotSDK = (function () {
const PREFIX = '[ChatbotSDK]';
let debugEnabled = true;
/** 错误回调函数(由宿主通过 onError 配置注入) */
let errorCallback = null;
/** 设置是否开启调试日志 */
function setDebug(enabled) {
debugEnabled = enabled;
}
/** 设置错误回调(宿主 onError 配置) */
function setErrorCallback(cb) {
errorCallback = cb || null;
}
/** 性能计时器 */
const timers = {};
const logger = {
@ -22,9 +28,17 @@ var ChatbotSDK = (function () {
console.warn(PREFIX, msg, data !== undefined ? data : '');
}
},
/** 错误日志(始终输出,不受 debug 开关控制) */
/** 错误日志(始终输出,不受 debug 开关控制;同时触发 onError 回调) */
error(msg, data) {
console.error(PREFIX, msg, data !== undefined ? data : '');
// 触发宿主的 onError 回调
if (errorCallback) {
try {
const code = data instanceof Error ? data.type || 'error' : 'error';
errorCallback({ message: msg, code: String(code), detail: data });
}
catch ( /* 回调异常不影响 SDK */_a) { /* 回调异常不影响 SDK */ }
}
},
/** 开始计时 */
time(label) {
@ -100,11 +114,79 @@ var ChatbotSDK = (function () {
<circle cx="20" cy="14.2" r="0.7" fill="#fff" opacity="0.9"/>
<path d="M12.5 20 Q16 22.5 19.5 20" stroke="rgba(99,102,241,0.4)" stroke-width="1.2" fill="none" stroke-linecap="round"/>
</svg>`;
// ==================== 马卡龙色系玻璃质感图标 ====================
/**
* 生成玻璃质感机器人 SVG 图标
* 包含圆润机器人头部 + 眨眼动效 + 跳动气泡 + 高光反射层
*/
function buildGlassIcon(eyeColor, smileColor, bubbleColor) {
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 = {
'dream-purple': '#A78BFA',
'mint-tech': '#34D399',
'coral-peach': '#FB7185',
'sky-blue': '#7DD3FC',
};
/**
* 解析并校验用户传入的配置填充默认值
*/
function parseConfig(raw) {
var _a, _b, _c, _d, _e, _f, _g;
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
// 校验必传参数:integrateId(对应后端 roleId)
if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number')
|| (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) {
@ -120,12 +202,30 @@ var ChatbotSDK = (function () {
try {
new URL(raw.requestDomain);
}
catch (_h) {
catch (_l) {
logger.error(`requestDomain 不是合法的 URL 格式:${raw.requestDomain}。请提供完整的域名,如 https://api.example.com`);
return 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 = {
'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 config = {
integrateId: integrateIdStr,
@ -135,20 +235,27 @@ var ChatbotSDK = (function () {
showCategorySwitch: (_a = raw.showCategorySwitch) !== null && _a !== void 0 ? _a : false,
title: raw.title || 'AI 智能助手',
width: (_b = raw.width) !== null && _b !== void 0 ? _b : 380,
height: Math.max(300, (_c = raw.height) !== null && _c !== void 0 ? _c : 520),
position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom',
primaryColor: raw.primaryColor || '#4F46E5',
launcherIcon: raw.launcherIcon || DEFAULT_LAUNCHER_ICON,
showClear: (_c = raw.showClear) !== null && _c !== void 0 ? _c : true,
showAdminPanel: (_d = raw.showAdminPanel) !== null && _d !== void 0 ? _d : false,
primaryColor: resolvedPrimaryColor,
launcherTheme: launcherTheme,
launcherIcon: resolvedLauncherIcon,
showClear: (_d = raw.showClear) !== null && _d !== void 0 ? _d : true,
showAdminPanel: (_e = raw.showAdminPanel) !== null && _e !== void 0 ? _e : false,
quickReplies: Array.isArray(raw.quickReplies)
? raw.quickReplies.map(s => String(s).trim()).filter(Boolean)
: [],
theme: raw.theme === 'dark' ? 'dark' : 'light',
showTeaser: (_e = raw.showTeaser) !== null && _e !== void 0 ? _e : true,
showTeaser: (_f = raw.showTeaser) !== null && _f !== void 0 ? _f : true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',
streaming: (_f = raw.streaming) !== null && _f !== void 0 ? _f : true,
streaming: (_g = raw.streaming) !== null && _g !== void 0 ? _g : true,
locale: raw.locale || 'zh-CN',
debug: (_g = raw.debug) !== null && _g !== void 0 ? _g : true,
debug: (_h = raw.debug) !== null && _h !== void 0 ? _h : true,
sound: (_j = raw.sound) !== null && _j !== void 0 ? _j : false,
notification: (_k = raw.notification) !== null && _k !== void 0 ? _k : false,
onError: typeof raw.onError === 'function' ? raw.onError : undefined,
onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined,
onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined,
chatId: '', // 初始为空,由 chatId 初始化流程填充
};
logger.info(`配置解析完成 integrateId(=roleId)=${config.integrateId} userId(=accountId)=${config.userId || '(未设置)'} requestDomain=${config.requestDomain}`);
@ -201,6 +308,10 @@ var ChatbotSDK = (function () {
history_export: '导出',
history_delete: '删除',
history_search: '搜索会话...',
history_group_today: '今天',
history_group_yesterday: '昨天',
history_group_week: '本周',
history_group_earlier: '更早',
// 消息反馈
feedback_up: '有帮助',
feedback_down: '没帮助',
@ -262,6 +373,10 @@ var ChatbotSDK = (function () {
history_export: 'Export',
history_delete: 'Delete',
history_search: 'Search conversations...',
history_group_today: 'Today',
history_group_yesterday: 'Yesterday',
history_group_week: 'This Week',
history_group_earlier: 'Earlier',
// Feedback
feedback_up: 'Helpful',
feedback_down: 'Not helpful',
@ -803,6 +918,14 @@ var ChatbotSDK = (function () {
const darker = adjustColor(config.primaryColor, -15);
const lighter = adjustColor(config.primaryColor, 18);
const rgb = hexToRgb(config.primaryColor);
// 马卡龙主题渐变色映射
const themeGradients = {
'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};
@ -813,11 +936,17 @@ var ChatbotSDK = (function () {
--csk-text-user: #ffffff;
--csk-text-ai: #1F2937;
--csk-window-width: ${config.width}px;
--csk-window-height: ${config.height}px;
--csk-radius: 16px;
--csk-shadow-window: 0 12px 48px rgba(15, 23, 42, 0.18), 0 2px 8px rgba(15, 23, 42, 0.06);
--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};
` : ''}
`;
}
/** 简单的颜色加深(按通道加减) */
@ -968,13 +1097,88 @@ var ChatbotSDK = (function () {
50% { opacity: 1; }
}
/* ========== 磨砂玻璃质感悬浮按钮 ========== */
.csk-launcher--glass {
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;
}
/* 玻璃按钮的呼吸色晕 — 更鲜艳、更醒目 */
.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-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; }
}
/* SVG 内部动效:跳动气泡(3 个小圆点交错弹跳) */
.csk-launcher .csk-ico-bubble {
animation: csk-bubble-jump 1.4s ease-in-out infinite;
}
.csk-launcher .csk-ico-bubble--1 { animation-delay: 0s; }
.csk-launcher .csk-ico-bubble--2 { animation-delay: 0.18s; }
.csk-launcher .csk-ico-bubble--3 { animation-delay: 0.36s; }
@keyframes csk-bubble-jump {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-3.5px); }
60% { transform: translateY(-1px); }
}
/* ========== 聊天弹窗 ========== */
.csk-window {
position: fixed;
bottom: 24px;
z-index: 9999;
width: var(--csk-window-width);
height: 600px;
height: var(--csk-window-height);
max-height: calc(100vh - 48px);
background: #fff;
border-radius: var(--csk-radius);
@ -2045,6 +2249,21 @@ var ChatbotSDK = (function () {
border: 0 !important;
}
/* ========== 会话分组标题 ========== */
.csk-history-group {
margin-bottom: 4px;
}
.csk-history-group__label {
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 12px 4px;
user-select: none;
}
.csk-dark .csk-history-group__label { color: #6B7280; }
/* ========== 移动端适配 ========== */
@media (max-width: 480px) {
.csk-window {
@ -2142,7 +2361,9 @@ var ChatbotSDK = (function () {
function createLauncher(config, onClick) {
const launcher = document.createElement('div');
launcher.id = 'csk-launcher';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
// 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式
const glassClass = config.launcherTheme ? ' csk-launcher--glass' : '';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`;
launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
@ -2418,8 +2639,8 @@ var ChatbotSDK = (function () {
};
}
// ==================== 拖拽支持 ====================
/** 启用弹窗拖拽 */
function enableDrag(headerEl, windowEl) {
/** 启用弹窗拖拽,onDragEnd 回调返回最终位置用于持久化 */
function enableDrag(headerEl, windowEl, onDragEnd) {
let dragging = false;
let startX = 0;
let startY = 0;
@ -2456,6 +2677,11 @@ var ChatbotSDK = (function () {
dragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// 拖拽结束回调:返回当前位置供持久化
if (onDragEnd) {
const rect = windowEl.getBoundingClientRect();
onDragEnd({ x: rect.left, y: rect.top });
}
};
headerEl.addEventListener('mousedown', onMouseDown);
// 清理函数
@ -2770,7 +2996,47 @@ var ChatbotSDK = (function () {
wrapper.appendChild(sourcesEl);
}
}
/** 渲染会话列表 */
/** 按日期分组会话列表:今天 / 昨天 / 本周 / 更早 */
function groupHistoryByDate(items) {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const weekStart = todayStart - (now.getDay() || 7) * 86400000 + 86400000; // 本周一
const groups = {
today: [],
yesterday: [],
week: [],
earlier: [],
};
for (const item of items) {
// 尝试解析时间:优先 lastMessageTime,其次 createdAt
const timeStr = item.lastMessageTime || item.createdAt;
const ts = timeStr ? new Date(timeStr).getTime() : 0;
if (ts >= todayStart) {
groups.today.push(item);
}
else if (ts >= yesterdayStart) {
groups.yesterday.push(item);
}
else if (ts >= weekStart) {
groups.week.push(item);
}
else {
groups.earlier.push(item);
}
}
const result = [];
if (groups.today.length)
result.push({ label: t('history_group_today'), items: groups.today });
if (groups.yesterday.length)
result.push({ label: t('history_group_yesterday'), items: groups.yesterday });
if (groups.week.length)
result.push({ label: t('history_group_week'), items: groups.week });
if (groups.earlier.length)
result.push({ label: t('history_group_earlier'), items: groups.earlier });
return result;
}
/** 渲染会话列表(带日期分组) */
function renderHistoryList(listEl, items, onSelect, onExport, onDelete, activeChatId, emptyText) {
listEl.innerHTML = '';
if (items.length === 0) {
@ -2783,7 +3049,20 @@ var ChatbotSDK = (function () {
listEl.appendChild(empty);
return;
}
for (const item of items) {
// 按日期分组渲染
const groups = groupHistoryByDate(items);
for (const group of groups) {
// 分组标题
if (groups.length > 1) {
const groupEl = document.createElement('div');
groupEl.className = 'csk-history-group';
const labelEl = document.createElement('div');
labelEl.className = 'csk-history-group__label';
labelEl.textContent = group.label;
groupEl.appendChild(labelEl);
listEl.appendChild(groupEl);
}
for (const item of group.items) {
const el = document.createElement('div');
el.className = 'csk-history-item';
// 高亮当前活跃会话
@ -2846,6 +3125,7 @@ var ChatbotSDK = (function () {
});
listEl.appendChild(el);
}
} // 关闭 group 循环
}
/** 滚动消息区到底部 */
function scrollToBottom(container) {
@ -3488,7 +3768,7 @@ var ChatbotSDK = (function () {
announceMessage(aiContent);
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
if (messagesContainer$1) {
messagesContainer$1.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true }));
messagesContainer$1.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } }));
}
// RAG 引用来源
if (shouldUseRag)
@ -3850,6 +4130,8 @@ var ChatbotSDK = (function () {
let teaserTimer = null;
/** 未读徽章元素(挂在 launcher 内部) */
let badgeEl = null;
/** 音频上下文(提示音用) */
let audioCtx = null;
// ==================== 公开 API ====================
/** 初始化 SDK */
function init(rawConfig) {
@ -3866,6 +4148,8 @@ var ChatbotSDK = (function () {
setLocale(config.locale);
// 3. 设置日志级别
setDebug(config.debug);
// 3.1 设置错误回调
setErrorCallback(config.onError);
// 4. 设置 API 配置
setApiConfig(config);
// 5. 注入样式
@ -3891,11 +4175,15 @@ var ChatbotSDK = (function () {
teaserEl = dom.teaserEl;
document.body.appendChild(teaserEl);
positionTeaser();
// 9. 启用拖拽
// 9. 启用拖拽(含位置持久化)
const headerEl = windowEl.querySelector('.csk-header');
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl);
dragCleanup = enableDrag(headerEl, windowEl, (pos) => {
saveWindowPosition(config.integrateId, pos);
});
}
// 9.1 恢复上次拖拽位置
restoreWindowPosition(config.integrateId, windowEl);
// 10. 初始化对话模块
initChat(config, {
messagesContainer,
@ -3931,16 +4219,31 @@ var ChatbotSDK = (function () {
windowEl.addEventListener('csk:feedback', ((e) => {
handleFeedback(e.detail.msgId, e.detail.value);
}));
// 16. 新消息到达通知(用于未读徽章)
windowEl.addEventListener('csk:newMessage', () => {
// 16. 新消息到达通知(用于未读徽章 + 提示音 + 桌面通知)
windowEl.addEventListener('csk:newMessage', ((e) => {
var _a;
notifyNewMessage();
});
// 触发 onMessage 回调
if ((config === null || config === void 0 ? void 0 : config.onMessage) && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.msg)) {
try {
config.onMessage(e.detail.msg);
}
catch ( /* 回调异常不影响 SDK */_b) { /* 回调异常不影响 SDK */ }
}
}));
// 17. ESC 键关闭弹窗
document.addEventListener('keydown', onKeyDown);
// 17. 窗口 resize 时重新定位 teaser
window.addEventListener('resize', onResize);
isInitialized = true;
logger.lifecycleInit(config.integrateId, config.requestDomain);
// 触发 onReady 回调
if (config.onReady) {
try {
config.onReady();
}
catch ( /* 回调异常不影响 SDK */_a) { /* 回调异常不影响 SDK */ }
}
// 18. 首访提示气泡(延迟显示,弹窗未打开时生效)
if (config.showTeaser) {
teaserTimer = setTimeout(() => {
@ -4017,10 +4320,16 @@ var ChatbotSDK = (function () {
if (badgeEl)
badgeEl.classList.add('csk-launcher__badge--hidden');
}
/** 新消息到达时弹窗关闭,则显示徽章 */
/** 新消息到达时弹窗关闭,则显示徽章 + 播放提示音 + 桌面通知 */
function notifyNewMessage() {
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showBadge();
// 提示音
if (config === null || config === void 0 ? void 0 : config.sound)
playNotificationSound();
// 桌面通知
if (config === null || config === void 0 ? void 0 : config.notification)
sendDesktopNotification();
}
}
// ==================== a11y 焦点陷阱 ====================
@ -4086,6 +4395,7 @@ var ChatbotSDK = (function () {
removeStyles();
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', onResize);
setErrorCallback(undefined);
const oldIntegrateId = config === null || config === void 0 ? void 0 : config.integrateId;
config = null;
isInitialized = false;
@ -4098,6 +4408,7 @@ var ChatbotSDK = (function () {
showLoadingFn = null;
hideLoadingFn = null;
badgeEl = null;
audioCtx = null;
logger.lifecycleDestroy(oldIntegrateId || '');
}
function open() {
@ -4134,6 +4445,87 @@ var ChatbotSDK = (function () {
clearMessages(config.integrateId);
}
}
// ==================== 窗口位置记忆 ====================
/** localStorage key:窗口位置 */
function positionKey(integrateId) {
return `csk_position_${integrateId}`;
}
/** 保存窗口拖拽位置到 localStorage */
function saveWindowPosition(integrateId, pos) {
try {
localStorage.setItem(positionKey(integrateId), JSON.stringify(pos));
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
/** 恢复窗口拖拽位置 */
function restoreWindowPosition(integrateId, winEl) {
try {
const raw = localStorage.getItem(positionKey(integrateId));
if (!raw)
return;
const pos = JSON.parse(raw);
if (typeof pos.x !== 'number' || typeof pos.y !== 'number')
return;
// 校验位置在当前视口内
const maxX = window.innerWidth - winEl.offsetWidth;
const maxY = window.innerHeight - winEl.offsetHeight;
const x = Math.max(0, Math.min(pos.x, maxX));
const y = Math.max(0, Math.min(pos.y, maxY));
winEl.style.right = 'auto';
winEl.style.bottom = 'auto';
winEl.style.left = `${x}px`;
winEl.style.top = `${y}px`;
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
// ==================== 提示音 + 桌面通知 ====================
/** 播放短促提示音(Web Audio API 合成,无需音频文件) */
function playNotificationSound() {
try {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
const ctx = audioCtx;
if (ctx.state === 'suspended') {
ctx.resume();
return;
}
// 合成两声短促"叮"
const now = ctx.currentTime;
[0, 0.15].forEach((offset, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = i === 0 ? 880 : 1100;
gain.gain.setValueAtTime(0.15, now + offset);
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + offset);
osc.stop(now + offset + 0.12);
});
}
catch ( /* 音频 API 不可用则忽略 */_a) { /* 音频 API 不可用则忽略 */ }
}
/** 发送桌面通知(需用户授权) */
function sendDesktopNotification() {
try {
if (!('Notification' in window))
return;
if (Notification.permission === 'granted') {
new Notification((config === null || config === void 0 ? void 0 : config.title) || 'AI 智能助手', {
body: '收到新消息',
icon: undefined,
silent: true,
});
}
else if (Notification.permission !== 'denied') {
// 首次请求权限
Notification.requestPermission();
}
}
catch ( /* 通知 API 不可用则忽略 */_a) { /* 通知 API 不可用则忽略 */ }
}
// ==================== 挂载到全局 ====================
const ChatbotSDK = {
init,

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

1019
client/icon-designs-preview.html
File diff suppressed because it is too large
View File

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

11
client/package.json

@ -3,16 +3,21 @@
"version": "1.0.0",
"description": "AI 智能客服 Chatbot SDK - 一行 script 标签嵌入即可对话",
"main": "dist/chatbot-sdk.js",
"types": "dist/types/index.d.ts",
"type": "module",
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w"
"build": "rollup -c && npm run build:types",
"build:types": "tsc --emitDeclarationOnly --outDir dist/types",
"dev": "rollup -c -w",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.0",
"rollup": "^4.30.0",
"tslib": "^2.8.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.2.6"
}
}

3
client/rollup.config.js

@ -21,6 +21,9 @@ export default {
plugins: [
typescript({
tsconfig: './tsconfig.json',
// Rollup 构建不生成 .d.ts,由独立的 tsc --emitDeclarationOnly 负责
declaration: false,
declarationDir: undefined,
}),
],
};

2
client/src/chat.ts

@ -460,7 +460,7 @@ async function produceAIReply(userText: string): Promise<void> {
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
if (messagesContainer) {
messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true }));
messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } }));
}
// RAG 引用来源

111
client/src/config.ts

@ -28,6 +28,85 @@ const DEFAULT_LAUNCHER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="30
<path d="M12.5 20 Q16 22.5 19.5 20" stroke="rgba(99,102,241,0.4)" stroke-width="1.2" fill="none" stroke-linecap="round"/>
</svg>`;
// ==================== 马卡龙色系玻璃质感图标 ====================
/**
* 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',
};
/**
*
*/
@ -56,6 +135,27 @@ 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 config: ResolvedConfig = {
integrateId: integrateIdStr,
@ -65,9 +165,11 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
showCategorySwitch: raw.showCategorySwitch ?? false,
title: raw.title || 'AI 智能助手',
width: raw.width ?? 380,
height: Math.max(300, raw.height ?? 520),
position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom',
primaryColor: raw.primaryColor || '#4F46E5',
launcherIcon: raw.launcherIcon || DEFAULT_LAUNCHER_ICON,
primaryColor: resolvedPrimaryColor,
launcherTheme: launcherTheme,
launcherIcon: resolvedLauncherIcon,
showClear: raw.showClear ?? true,
showAdminPanel: raw.showAdminPanel ?? false,
quickReplies: Array.isArray(raw.quickReplies)
@ -79,6 +181,11 @@ export function parseConfig(raw: SDKConfig): ResolvedConfig | null {
streaming: raw.streaming ?? true,
locale: raw.locale || 'zh-CN',
debug: raw.debug ?? true,
sound: raw.sound ?? false,
notification: raw.notification ?? false,
onError: typeof raw.onError === 'function' ? raw.onError : undefined,
onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined,
onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined,
chatId: '', // 初始为空,由 chatId 初始化流程填充
};

70
client/src/dom.ts

@ -25,7 +25,9 @@ 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';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
// 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式
const glassClass = config.launcherTheme ? ' csk-launcher--glass' : '';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`;
launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
@ -363,8 +365,8 @@ export function createChatWindow(config: ResolvedConfig): {
// ==================== 拖拽支持 ====================
/** 启用弹窗拖拽 */
export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement): () => void {
/** 启用弹窗拖拽,onDragEnd 回调返回最终位置用于持久化 */
export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement, onDragEnd?: (pos: { x: number; y: number }) => void): () => void {
let dragging = false;
let startX = 0;
let startY = 0;
@ -402,6 +404,11 @@ export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement): () =>
dragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// 拖拽结束回调:返回当前位置供持久化
if (onDragEnd) {
const rect = windowEl.getBoundingClientRect();
onDragEnd({ x: rect.left, y: rect.top });
}
};
headerEl.addEventListener('mousedown', onMouseDown);
@ -768,7 +775,45 @@ export interface HistoryItemData {
createdAt?: string;
}
/** 渲染会话列表 */
/** 按日期分组会话列表:今天 / 昨天 / 本周 / 更早 */
function groupHistoryByDate(items: HistoryItemData[]): { label: string; items: HistoryItemData[] }[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const weekStart = todayStart - (now.getDay() || 7) * 86400000 + 86400000; // 本周一
const groups: Record<string, HistoryItemData[]> = {
today: [],
yesterday: [],
week: [],
earlier: [],
};
for (const item of items) {
// 尝试解析时间:优先 lastMessageTime,其次 createdAt
const timeStr = item.lastMessageTime || item.createdAt;
const ts = timeStr ? new Date(timeStr).getTime() : 0;
if (ts >= todayStart) {
groups.today.push(item);
} else if (ts >= yesterdayStart) {
groups.yesterday.push(item);
} else if (ts >= weekStart) {
groups.week.push(item);
} else {
groups.earlier.push(item);
}
}
const result: { label: string; items: HistoryItemData[] }[] = [];
if (groups.today.length) result.push({ label: t('history_group_today'), items: groups.today });
if (groups.yesterday.length) result.push({ label: t('history_group_yesterday'), items: groups.yesterday });
if (groups.week.length) result.push({ label: t('history_group_week'), items: groups.week });
if (groups.earlier.length) result.push({ label: t('history_group_earlier'), items: groups.earlier });
return result;
}
/** 渲染会话列表(带日期分组) */
export function renderHistoryList(
listEl: HTMLElement,
items: HistoryItemData[],
@ -791,7 +836,21 @@ export function renderHistoryList(
return;
}
for (const item of items) {
// 按日期分组渲染
const groups = groupHistoryByDate(items);
for (const group of groups) {
// 分组标题
if (groups.length > 1) {
const groupEl = document.createElement('div');
groupEl.className = 'csk-history-group';
const labelEl = document.createElement('div');
labelEl.className = 'csk-history-group__label';
labelEl.textContent = group.label;
groupEl.appendChild(labelEl);
listEl.appendChild(groupEl);
}
for (const item of group.items) {
const el = document.createElement('div');
el.className = 'csk-history-item';
// 高亮当前活跃会话
@ -861,6 +920,7 @@ export function renderHistoryList(
listEl.appendChild(el);
}
} // 关闭 group 循环
}
/** 滚动消息区到底部 */

8
client/src/i18n.ts

@ -52,6 +52,10 @@ const dictionaries: Record<string, Record<string, string>> = {
history_export: '导出',
history_delete: '删除',
history_search: '搜索会话...',
history_group_today: '今天',
history_group_yesterday: '昨天',
history_group_week: '本周',
history_group_earlier: '更早',
// 消息反馈
feedback_up: '有帮助',
@ -124,6 +128,10 @@ const dictionaries: Record<string, Record<string, string>> = {
history_export: 'Export',
history_delete: 'Delete',
history_search: 'Search conversations...',
history_group_today: 'Today',
history_group_yesterday: 'Yesterday',
history_group_week: 'This Week',
history_group_earlier: 'Earlier',
// Feedback
feedback_up: 'Helpful',

130
client/src/index.ts

@ -9,7 +9,7 @@
*/
import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types';
import { parseConfig } from './config';
import { setDebug, logger } from './logger';
import { setDebug, logger, setErrorCallback } from './logger';
import { setApiConfig } from './api';
import { injectStyles, removeStyles } from './styles';
import { createLauncher, createChatWindow, enableDrag } from './dom';
@ -17,6 +17,19 @@ import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConvers
import { clearMessages } from './storage';
import { setLocale } from './i18n';
// 重新导出公开类型,供 TS 宿主项目使用
export type {
SDKConfig,
ResolvedConfig,
ChatMessage,
RagSource,
CategoryNode,
CategoryItem,
ConversationSummary,
ConversationDetail,
ChatbotSDKInstance,
} from './types';
// ==================== 单例状态 ====================
let config: ResolvedConfig | null = null;
@ -40,6 +53,9 @@ let teaserTimer: ReturnType<typeof setTimeout> | null = null;
/** 未读徽章元素(挂在 launcher 内部) */
let badgeEl: HTMLElement | null = null;
/** 音频上下文(提示音用) */
let audioCtx: AudioContext | null = null;
// ==================== 公开 API ====================
/** 初始化 SDK */
@ -60,6 +76,9 @@ function init(rawConfig: SDKConfig): void {
// 3. 设置日志级别
setDebug(config.debug);
// 3.1 设置错误回调
setErrorCallback(config.onError);
// 4. 设置 API 配置
setApiConfig(config);
@ -91,12 +110,17 @@ function init(rawConfig: SDKConfig): void {
document.body.appendChild(teaserEl);
positionTeaser();
// 9. 启用拖拽
// 9. 启用拖拽(含位置持久化)
const headerEl = windowEl.querySelector('.csk-header') as HTMLElement;
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl);
dragCleanup = enableDrag(headerEl, windowEl, (pos) => {
saveWindowPosition(config!.integrateId, pos);
});
}
// 9.1 恢复上次拖拽位置
restoreWindowPosition(config.integrateId, windowEl);
// 10. 初始化对话模块
initChat(config, {
messagesContainer,
@ -138,10 +162,14 @@ function init(rawConfig: SDKConfig): void {
handleFeedback(e.detail.msgId, e.detail.value);
}) as EventListener);
// 16. 新消息到达通知(用于未读徽章)
windowEl.addEventListener('csk:newMessage', () => {
// 16. 新消息到达通知(用于未读徽章 + 提示音 + 桌面通知
windowEl.addEventListener('csk:newMessage', ((e: CustomEvent) => {
notifyNewMessage();
});
// 触发 onMessage 回调
if (config?.onMessage && e.detail?.msg) {
try { config.onMessage(e.detail.msg); } catch { /* 回调异常不影响 SDK */ }
}
}) as EventListener);
// 17. ESC 键关闭弹窗
document.addEventListener('keydown', onKeyDown);
@ -152,6 +180,11 @@ function init(rawConfig: SDKConfig): void {
isInitialized = true;
logger.lifecycleInit(config.integrateId, config.requestDomain);
// 触发 onReady 回调
if (config.onReady) {
try { config.onReady(); } catch { /* 回调异常不影响 SDK */ }
}
// 18. 首访提示气泡(延迟显示,弹窗未打开时生效)
if (config.showTeaser) {
teaserTimer = setTimeout(() => {
@ -232,10 +265,14 @@ function hideBadge(): void {
if (badgeEl) badgeEl.classList.add('csk-launcher__badge--hidden');
}
/** 新消息到达时弹窗关闭,则显示徽章 */
/** 新消息到达时弹窗关闭,则显示徽章 + 播放提示音 + 桌面通知 */
function notifyNewMessage(): void {
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showBadge();
// 提示音
if (config?.sound) playNotificationSound();
// 桌面通知
if (config?.notification) sendDesktopNotification();
}
}
@ -293,6 +330,7 @@ function destroy(): void {
removeStyles();
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', onResize);
setErrorCallback(undefined);
const oldIntegrateId = config?.integrateId;
config = null;
@ -306,6 +344,7 @@ function destroy(): void {
showLoadingFn = null;
hideLoadingFn = null;
badgeEl = null;
audioCtx = null;
logger.lifecycleDestroy(oldIntegrateId || '');
}
@ -338,6 +377,83 @@ function clearHistory(): void {
else if (confirm('确定清空所有对话记录?')) { clearMessages(config.integrateId); }
}
// ==================== 窗口位置记忆 ====================
/** localStorage key:窗口位置 */
function positionKey(integrateId: string): string {
return `csk_position_${integrateId}`;
}
/** 保存窗口拖拽位置到 localStorage */
function saveWindowPosition(integrateId: string, pos: { x: number; y: number }): void {
try {
localStorage.setItem(positionKey(integrateId), JSON.stringify(pos));
} catch { /* 忽略 */ }
}
/** 恢复窗口拖拽位置 */
function restoreWindowPosition(integrateId: string, winEl: HTMLElement): void {
try {
const raw = localStorage.getItem(positionKey(integrateId));
if (!raw) return;
const pos = JSON.parse(raw) as { x: number; y: number };
if (typeof pos.x !== 'number' || typeof pos.y !== 'number') return;
// 校验位置在当前视口内
const maxX = window.innerWidth - winEl.offsetWidth;
const maxY = window.innerHeight - winEl.offsetHeight;
const x = Math.max(0, Math.min(pos.x, maxX));
const y = Math.max(0, Math.min(pos.y, maxY));
winEl.style.right = 'auto';
winEl.style.bottom = 'auto';
winEl.style.left = `${x}px`;
winEl.style.top = `${y}px`;
} catch { /* 忽略 */ }
}
// ==================== 提示音 + 桌面通知 ====================
/** 播放短促提示音(Web Audio API 合成,无需音频文件) */
function playNotificationSound(): void {
try {
if (!audioCtx) {
audioCtx = new (window.AudioContext || (window as unknown as Record<string, unknown>).webkitAudioContext as typeof AudioContext)();
}
const ctx = audioCtx;
if (ctx.state === 'suspended') { ctx.resume(); return; }
// 合成两声短促"叮"
const now = ctx.currentTime;
[0, 0.15].forEach((offset, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = i === 0 ? 880 : 1100;
gain.gain.setValueAtTime(0.15, now + offset);
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + offset);
osc.stop(now + offset + 0.12);
});
} catch { /* 音频 API 不可用则忽略 */ }
}
/** 发送桌面通知(需用户授权) */
function sendDesktopNotification(): void {
try {
if (!('Notification' in window)) return;
if (Notification.permission === 'granted') {
new Notification(config?.title || 'AI 智能助手', {
body: '收到新消息',
icon: undefined,
silent: true,
});
} else if (Notification.permission !== 'denied') {
// 首次请求权限
Notification.requestPermission();
}
} catch { /* 通知 API 不可用则忽略 */ }
}
// ==================== 挂载到全局 ====================
const ChatbotSDK: ChatbotSDKInstance = {

19
client/src/logger.ts

@ -1,6 +1,6 @@
/**
* - [ChatbotSDK]
*
*
*/
import { ResolvedConfig } from './types';
@ -8,11 +8,19 @@ const PREFIX = '[ChatbotSDK]';
let debugEnabled = true;
/** 错误回调函数(由宿主通过 onError 配置注入) */
let errorCallback: ((error: { message: string; code: string; detail?: unknown }) => void) | null = null;
/** 设置是否开启调试日志 */
export function setDebug(enabled: boolean): void {
debugEnabled = enabled;
}
/** 设置错误回调(宿主 onError 配置) */
export function setErrorCallback(cb: ((error: { message: string; code: string; detail?: unknown }) => void) | undefined): void {
errorCallback = cb || null;
}
/** 性能计时器 */
const timers: Record<string, number> = {};
@ -31,9 +39,16 @@ export const logger = {
}
},
/** 错误日志(始终输出,不受 debug 开关控制) */
/** 错误日志(始终输出,不受 debug 开关控制;同时触发 onError 回调) */
error(msg: string, data?: unknown): void {
console.error(PREFIX, msg, data !== undefined ? data : '');
// 触发宿主的 onError 回调
if (errorCallback) {
try {
const code = data instanceof Error ? (data as Error & { type?: string }).type || 'error' : 'error';
errorCallback({ message: msg, code: String(code), detail: data });
} catch { /* 回调异常不影响 SDK */ }
}
},
/** 开始计时 */

109
client/src/styles.ts

@ -20,6 +20,17 @@ function cssVars(config: ResolvedConfig): string {
const darker = adjustColor(config.primaryColor, -15);
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};
@ -30,11 +41,17 @@ function cssVars(config: ResolvedConfig): string {
--csk-text-user: #ffffff;
--csk-text-ai: #1F2937;
--csk-window-width: ${config.width}px;
--csk-window-height: ${config.height}px;
--csk-radius: 16px;
--csk-shadow-window: 0 12px 48px rgba(15, 23, 42, 0.18), 0 2px 8px rgba(15, 23, 42, 0.06);
--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};
` : ''}
`;
}
@ -187,13 +204,88 @@ function getStyles(config: ResolvedConfig): string {
50% { opacity: 1; }
}
/* ========== 磨砂玻璃质感悬浮按钮 ========== */
.csk-launcher--glass {
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;
}
/* 玻璃按钮的呼吸色晕 — 更鲜艳、更醒目 */
.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-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; }
}
/* SVG 内部动效:跳动气泡(3 个小圆点交错弹跳) */
.csk-launcher .csk-ico-bubble {
animation: csk-bubble-jump 1.4s ease-in-out infinite;
}
.csk-launcher .csk-ico-bubble--1 { animation-delay: 0s; }
.csk-launcher .csk-ico-bubble--2 { animation-delay: 0.18s; }
.csk-launcher .csk-ico-bubble--3 { animation-delay: 0.36s; }
@keyframes csk-bubble-jump {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-3.5px); }
60% { transform: translateY(-1px); }
}
/* ========== 聊天弹窗 ========== */
.csk-window {
position: fixed;
bottom: 24px;
z-index: 9999;
width: var(--csk-window-width);
height: 600px;
height: var(--csk-window-height);
max-height: calc(100vh - 48px);
background: #fff;
border-radius: var(--csk-radius);
@ -1264,6 +1356,21 @@ function getStyles(config: ResolvedConfig): string {
border: 0 !important;
}
/* ========== 会话分组标题 ========== */
.csk-history-group {
margin-bottom: 4px;
}
.csk-history-group__label {
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 12px 4px;
user-select: none;
}
.csk-dark .csk-history-group__label { color: #6B7280; }
/* ========== 移动端适配 ========== */
@media (max-width: 480px) {
.csk-window {

32
client/src/types.ts

@ -30,10 +30,14 @@ export interface SDKConfig {
title?: string;
/** 弹窗宽度(px),默认 380 */
width?: number;
/** 弹窗高度(px),默认 520,最小 300 */
height?: number;
/** 悬浮按钮位置,默认 "right-bottom" */
position?: 'left-bottom' | 'right-bottom';
/** 主色调,默认 "#4F46E5" */
primaryColor?: string;
/** 悬浮按钮主题风格(马卡龙色系磨砂玻璃),不传则使用 primaryColor 渐变 */
launcherTheme?: 'dream-purple' | 'mint-tech' | 'coral-peach' | 'sky-blue';
/** 悬浮按钮图标(可传 URL 或 SVG 字符串) */
launcherIcon?: string;
/** 是否显示清空对话按钮,默认 true */
@ -56,6 +60,20 @@ export interface SDKConfig {
locale?: string;
/** 是否输出调试日志,默认 true */
debug?: boolean;
// === 通知配置 ===
/** 弹窗关闭时收到新消息是否播放提示音,默认 false */
sound?: boolean;
/** 弹窗关闭时收到新消息是否发送桌面通知,默认 false */
notification?: boolean;
// === 生命周期回调 ===
/** SDK 内部异常回调(网络错误、SSE 解析失败等),宿主可接入监控 */
onError?: (error: { message: string; code: string; detail?: unknown }) => void;
/** SDK 初始化完成回调 */
onReady?: () => void;
/** 收到新 AI 消息回调 */
onMessage?: (msg: ChatMessage) => void;
}
/** 解析后的完整配置(所有可选字段已填充默认值) */
@ -74,10 +92,14 @@ export interface ResolvedConfig {
title: string;
/** 弹窗宽度 */
width: number;
/** 弹窗高度 */
height: number;
/** 位置 */
position: 'left-bottom' | 'right-bottom';
/** 主色调 */
primaryColor: string;
/** 悬浮按钮主题风格 */
launcherTheme?: 'dream-purple' | 'mint-tech' | 'coral-peach' | 'sky-blue';
/** 悬浮按钮图标 */
launcherIcon: string;
/** 显示清空按钮 */
@ -98,6 +120,16 @@ export interface ResolvedConfig {
locale: string;
/** 调试日志 */
debug: boolean;
/** 提示音 */
sound: boolean;
/** 桌面通知 */
notification: boolean;
/** 异常回调 */
onError?: (error: { message: string; code: string; detail?: unknown }) => void;
/** 初始化完成回调 */
onReady?: () => void;
/** 新消息回调 */
onMessage?: (msg: ChatMessage) => void;
/** 当前对话 ID(自动管理,从 /conversation/list 获取或自动生成) */
chatId: string;
}

134
client/tests/config.test.ts

@ -0,0 +1,134 @@
/**
* config.ts
*/
import { describe, it, expect } from 'vitest';
import { parseConfig } from '../src/config';
const validConfig = {
integrateId: '123',
requestDomain: 'https://api.example.com',
};
describe('parseConfig - 必传参数校验', () => {
it('缺少 integrateId 返回 null', () => {
expect(parseConfig({ requestDomain: 'https://api.example.com' } as never)).toBeNull();
});
it('integrateId 为空字符串返回 null', () => {
expect(parseConfig({ integrateId: '', requestDomain: 'https://api.example.com' })).toBeNull();
});
it('缺少 requestDomain 返回 null', () => {
expect(parseConfig({ integrateId: '123' } as never)).toBeNull();
});
it('requestDomain 不是合法 URL 返回 null', () => {
expect(parseConfig({ integrateId: '123', requestDomain: 'not-a-url' })).toBeNull();
});
it('合法配置正常解析', () => {
const result = parseConfig(validConfig);
expect(result).not.toBeNull();
expect(result!.integrateId).toBe('123');
expect(result!.requestDomain).toBe('https://api.example.com');
});
it('数字 integrateId 转为字符串', () => {
const result = parseConfig({ integrateId: 42, requestDomain: 'https://api.example.com' });
expect(result!.integrateId).toBe('42');
});
});
describe('parseConfig - 默认值填充', () => {
it('width 默认 380', () => {
const result = parseConfig(validConfig);
expect(result!.width).toBe(380);
});
it('height 默认 520', () => {
const result = parseConfig(validConfig);
expect(result!.height).toBe(520);
});
it('height 最小值 300', () => {
const result = parseConfig({ ...validConfig, height: 100 });
expect(result!.height).toBe(300);
});
it('title 默认 "AI 智能助手"', () => {
const result = parseConfig(validConfig);
expect(result!.title).toBe('AI 智能助手');
});
it('theme 默认 light', () => {
const result = parseConfig(validConfig);
expect(result!.theme).toBe('light');
});
it('theme dark 正常解析', () => {
const result = parseConfig({ ...validConfig, theme: 'dark' });
expect(result!.theme).toBe('dark');
});
it('streaming 默认 true', () => {
const result = parseConfig(validConfig);
expect(result!.streaming).toBe(true);
});
it('debug 默认 true', () => {
const result = parseConfig(validConfig);
expect(result!.debug).toBe(true);
});
it('sound 默认 false', () => {
const result = parseConfig(validConfig);
expect(result!.sound).toBe(false);
});
it('notification 默认 false', () => {
const result = parseConfig(validConfig);
expect(result!.notification).toBe(false);
});
it('quickReplies 默认空数组', () => {
const result = parseConfig(validConfig);
expect(result!.quickReplies).toEqual([]);
});
it('quickReplies 过滤空字符串', () => {
const result = parseConfig({ ...validConfig, quickReplies: ['你好', '', ' ', '世界'] });
expect(result!.quickReplies).toEqual(['你好', '世界']);
});
it('requestDomain 去除末尾斜杠', () => {
const result = parseConfig({ ...validConfig, requestDomain: 'https://api.example.com///' });
expect(result!.requestDomain).toBe('https://api.example.com');
});
it('position 仅接受两个合法值', () => {
expect(parseConfig({ ...validConfig, position: 'left-bottom' })!.position).toBe('left-bottom');
expect(parseConfig({ ...validConfig, position: 'right-bottom' })!.position).toBe('right-bottom');
expect(parseConfig({ ...validConfig, position: 'top' as never })!.position).toBe('right-bottom');
});
});
describe('parseConfig - 回调函数', () => {
it('onError 回调正常保留', () => {
const onError = () => {};
const result = parseConfig({ ...validConfig, onError });
expect(result!.onError).toBe(onError);
});
it('非函数的 onError 被忽略', () => {
const result = parseConfig({ ...validConfig, onError: 'not-a-function' as never });
expect(result!.onError).toBeUndefined();
});
it('onReady / onMessage 回调正常保留', () => {
const onReady = () => {};
const onMessage = () => {};
const result = parseConfig({ ...validConfig, onReady, onMessage });
expect(result!.onReady).toBe(onReady);
expect(result!.onMessage).toBe(onMessage);
});
});

100
client/tests/i18n.test.ts

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

150
client/tests/markdown.test.ts

@ -0,0 +1,150 @@
/**
* markdown.ts Markdown
*
*
* 1.
* 2. XSS http/https
*/
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from '../src/markdown';
describe('renderMarkdown - 基础语法', () => {
it('空字符串返回空', () => {
expect(renderMarkdown('')).toBe('');
});
it('null/undefined 返回空', () => {
expect(renderMarkdown(null as never)).toBe('');
expect(renderMarkdown(undefined as never)).toBe('');
});
it('普通文本渲染为段落', () => {
expect(renderMarkdown('你好世界')).toContain('<p class="csk-md-p">你好世界</p>');
});
it('标题渲染 h1-h6', () => {
expect(renderMarkdown('# 标题一')).toContain('<h1 class="csk-md-h1">');
expect(renderMarkdown('## 标题二')).toContain('<h2 class="csk-md-h2">');
expect(renderMarkdown('### 标题三')).toContain('<h3 class="csk-md-h3">');
});
it('粗体渲染', () => {
expect(renderMarkdown('**粗体文本**')).toContain('<strong>粗体文本</strong>');
});
it('斜体渲染', () => {
expect(renderMarkdown('*斜体文本*')).toContain('<em>斜体文本</em>');
});
it('删除线渲染', () => {
expect(renderMarkdown('~~删除文本~~')).toContain('<del>删除文本</del>');
});
it('无序列表渲染', () => {
const result = renderMarkdown('- 项目一\n- 项目二');
expect(result).toContain('<ul class="csk-md-ul">');
expect(result).toContain('<li>');
expect(result).toContain('项目一');
expect(result).toContain('项目二');
});
it('有序列表渲染', () => {
const result = renderMarkdown('1. 第一步\n2. 第二步');
expect(result).toContain('<ol class="csk-md-ol">');
expect(result).toContain('<li>');
});
it('引用渲染', () => {
const result = renderMarkdown('> 这是引用');
expect(result).toContain('<blockquote class="csk-md-blockquote">');
expect(result).toContain('这是引用');
});
it('链接渲染(http 协议)', () => {
const result = renderMarkdown('[百度](https://www.baidu.com)');
expect(result).toContain('href="https://www.baidu.com"');
expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});
it('水平线渲染', () => {
expect(renderMarkdown('---')).toContain('<hr class="csk-md-hr">');
});
});
describe('renderMarkdown - 代码', () => {
it('行内代码渲染', () => {
const result = renderMarkdown('使用 `console.log` 调试');
expect(result).toContain('<code class="csk-md-inline-code">console.log</code>');
});
it('代码块渲染', () => {
const result = renderMarkdown('```js\nconsole.log("hello");\n```');
expect(result).toContain('<pre class="csk-md-code-block">');
expect(result).toContain('class="language-js"');
expect(result).toContain('console.log');
});
it('代码块内容不被 Markdown 处理', () => {
const result = renderMarkdown('```\n**粗体** *斜体*\n```');
// 代码块内的 Markdown 语法不应被转换
expect(result).toContain('**粗体**');
expect(result).toContain('*斜体*');
expect(result).not.toContain('<strong>');
});
});
describe('renderMarkdown - XSS 防护', () => {
it('转义 HTML 标签', () => {
const result = renderMarkdown('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
it('转义 img 标签的 onerror', () => {
const result = renderMarkdown('<img src=x onerror=alert(1)>');
// < 被转义为 &lt;,标签不会执行,onerror 变为纯文本
expect(result).not.toContain('<img');
expect(result).toContain('&lt;img');
});
it('链接只允许 http/https 协议', () => {
const result = renderMarkdown('[恶意](javascript:alert(1))');
expect(result).toContain('href="#"');
expect(result).not.toContain('javascript:');
});
it('允许 http 链接', () => {
const result = renderMarkdown('[安全](http://example.com)');
expect(result).toContain('href="http://example.com"');
});
it('代码块内容同样被转义', () => {
const result = renderMarkdown('```\n<script>alert(1)</script>\n```');
expect(result).not.toContain('<script>alert');
expect(result).toContain('&lt;script&gt;');
});
it('行内代码内容同样被转义', () => {
const result = renderMarkdown('`<img src=x>`');
expect(result).toContain('&lt;img');
expect(result).not.toContain('<img');
});
});
describe('renderMarkdown - 复合内容', () => {
it('混合标题和段落', () => {
const result = renderMarkdown('# 标题\n\n这是正文');
expect(result).toContain('<h1');
expect(result).toContain('<p class="csk-md-p">这是正文</p>');
});
it('多段落正确分隔', () => {
const result = renderMarkdown('第一段\n\n第二段');
expect(result).toContain('第一段');
expect(result).toContain('第二段');
// 应有两个 <p> 标签
const pCount = (result.match(/<p class="csk-md-p">/g) || []).length;
expect(pCount).toBe(2);
});
});

86
client/tests/utils.test.ts

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

3
client/tsconfig.json

@ -10,7 +10,8 @@
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
"declaration": false
"declaration": true,
"declarationDir": "./dist/types"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]

9
client/vitest.config.ts

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
},
});

438
src/main/resources/static/sdk/chatbot-sdk.js

@ -3,10 +3,16 @@ var ChatbotSDK = (function () {
const PREFIX = '[ChatbotSDK]';
let debugEnabled = true;
/** 错误回调函数(由宿主通过 onError 配置注入) */
let errorCallback = null;
/** 设置是否开启调试日志 */
function setDebug(enabled) {
debugEnabled = enabled;
}
/** 设置错误回调(宿主 onError 配置) */
function setErrorCallback(cb) {
errorCallback = cb || null;
}
/** 性能计时器 */
const timers = {};
const logger = {
@ -22,9 +28,17 @@ var ChatbotSDK = (function () {
console.warn(PREFIX, msg, data !== undefined ? data : '');
}
},
/** 错误日志(始终输出,不受 debug 开关控制) */
/** 错误日志(始终输出,不受 debug 开关控制;同时触发 onError 回调) */
error(msg, data) {
console.error(PREFIX, msg, data !== undefined ? data : '');
// 触发宿主的 onError 回调
if (errorCallback) {
try {
const code = data instanceof Error ? data.type || 'error' : 'error';
errorCallback({ message: msg, code: String(code), detail: data });
}
catch ( /* 回调异常不影响 SDK */_a) { /* 回调异常不影响 SDK */ }
}
},
/** 开始计时 */
time(label) {
@ -100,11 +114,79 @@ var ChatbotSDK = (function () {
<circle cx="20" cy="14.2" r="0.7" fill="#fff" opacity="0.9"/>
<path d="M12.5 20 Q16 22.5 19.5 20" stroke="rgba(99,102,241,0.4)" stroke-width="1.2" fill="none" stroke-linecap="round"/>
</svg>`;
// ==================== 马卡龙色系玻璃质感图标 ====================
/**
* 生成玻璃质感机器人 SVG 图标
* 包含圆润机器人头部 + 眨眼动效 + 跳动气泡 + 高光反射层
*/
function buildGlassIcon(eyeColor, smileColor, bubbleColor) {
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 = {
'dream-purple': '#A78BFA',
'mint-tech': '#34D399',
'coral-peach': '#FB7185',
'sky-blue': '#7DD3FC',
};
/**
* 解析并校验用户传入的配置填充默认值
*/
function parseConfig(raw) {
var _a, _b, _c, _d, _e, _f, _g;
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
// 校验必传参数:integrateId(对应后端 roleId)
if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number')
|| (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) {
@ -120,12 +202,30 @@ var ChatbotSDK = (function () {
try {
new URL(raw.requestDomain);
}
catch (_h) {
catch (_l) {
logger.error(`requestDomain 不是合法的 URL 格式:${raw.requestDomain}。请提供完整的域名,如 https://api.example.com`);
return 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 = {
'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 config = {
integrateId: integrateIdStr,
@ -135,20 +235,27 @@ var ChatbotSDK = (function () {
showCategorySwitch: (_a = raw.showCategorySwitch) !== null && _a !== void 0 ? _a : false,
title: raw.title || 'AI 智能助手',
width: (_b = raw.width) !== null && _b !== void 0 ? _b : 380,
height: Math.max(300, (_c = raw.height) !== null && _c !== void 0 ? _c : 520),
position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom',
primaryColor: raw.primaryColor || '#4F46E5',
launcherIcon: raw.launcherIcon || DEFAULT_LAUNCHER_ICON,
showClear: (_c = raw.showClear) !== null && _c !== void 0 ? _c : true,
showAdminPanel: (_d = raw.showAdminPanel) !== null && _d !== void 0 ? _d : false,
primaryColor: resolvedPrimaryColor,
launcherTheme: launcherTheme,
launcherIcon: resolvedLauncherIcon,
showClear: (_d = raw.showClear) !== null && _d !== void 0 ? _d : true,
showAdminPanel: (_e = raw.showAdminPanel) !== null && _e !== void 0 ? _e : false,
quickReplies: Array.isArray(raw.quickReplies)
? raw.quickReplies.map(s => String(s).trim()).filter(Boolean)
: [],
theme: raw.theme === 'dark' ? 'dark' : 'light',
showTeaser: (_e = raw.showTeaser) !== null && _e !== void 0 ? _e : true,
showTeaser: (_f = raw.showTeaser) !== null && _f !== void 0 ? _f : true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',
streaming: (_f = raw.streaming) !== null && _f !== void 0 ? _f : true,
streaming: (_g = raw.streaming) !== null && _g !== void 0 ? _g : true,
locale: raw.locale || 'zh-CN',
debug: (_g = raw.debug) !== null && _g !== void 0 ? _g : true,
debug: (_h = raw.debug) !== null && _h !== void 0 ? _h : true,
sound: (_j = raw.sound) !== null && _j !== void 0 ? _j : false,
notification: (_k = raw.notification) !== null && _k !== void 0 ? _k : false,
onError: typeof raw.onError === 'function' ? raw.onError : undefined,
onReady: typeof raw.onReady === 'function' ? raw.onReady : undefined,
onMessage: typeof raw.onMessage === 'function' ? raw.onMessage : undefined,
chatId: '', // 初始为空,由 chatId 初始化流程填充
};
logger.info(`配置解析完成 integrateId(=roleId)=${config.integrateId} userId(=accountId)=${config.userId || '(未设置)'} requestDomain=${config.requestDomain}`);
@ -201,6 +308,10 @@ var ChatbotSDK = (function () {
history_export: '导出',
history_delete: '删除',
history_search: '搜索会话...',
history_group_today: '今天',
history_group_yesterday: '昨天',
history_group_week: '本周',
history_group_earlier: '更早',
// 消息反馈
feedback_up: '有帮助',
feedback_down: '没帮助',
@ -262,6 +373,10 @@ var ChatbotSDK = (function () {
history_export: 'Export',
history_delete: 'Delete',
history_search: 'Search conversations...',
history_group_today: 'Today',
history_group_yesterday: 'Yesterday',
history_group_week: 'This Week',
history_group_earlier: 'Earlier',
// Feedback
feedback_up: 'Helpful',
feedback_down: 'Not helpful',
@ -803,6 +918,14 @@ var ChatbotSDK = (function () {
const darker = adjustColor(config.primaryColor, -15);
const lighter = adjustColor(config.primaryColor, 18);
const rgb = hexToRgb(config.primaryColor);
// 马卡龙主题渐变色映射
const themeGradients = {
'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};
@ -813,11 +936,17 @@ var ChatbotSDK = (function () {
--csk-text-user: #ffffff;
--csk-text-ai: #1F2937;
--csk-window-width: ${config.width}px;
--csk-window-height: ${config.height}px;
--csk-radius: 16px;
--csk-shadow-window: 0 12px 48px rgba(15, 23, 42, 0.18), 0 2px 8px rgba(15, 23, 42, 0.06);
--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};
` : ''}
`;
}
/** 简单的颜色加深(按通道加减) */
@ -968,13 +1097,88 @@ var ChatbotSDK = (function () {
50% { opacity: 1; }
}
/* ========== 磨砂玻璃质感悬浮按钮 ========== */
.csk-launcher--glass {
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;
}
/* 玻璃按钮的呼吸色晕 — 更鲜艳、更醒目 */
.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-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; }
}
/* SVG 内部动效:跳动气泡(3 个小圆点交错弹跳) */
.csk-launcher .csk-ico-bubble {
animation: csk-bubble-jump 1.4s ease-in-out infinite;
}
.csk-launcher .csk-ico-bubble--1 { animation-delay: 0s; }
.csk-launcher .csk-ico-bubble--2 { animation-delay: 0.18s; }
.csk-launcher .csk-ico-bubble--3 { animation-delay: 0.36s; }
@keyframes csk-bubble-jump {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-3.5px); }
60% { transform: translateY(-1px); }
}
/* ========== 聊天弹窗 ========== */
.csk-window {
position: fixed;
bottom: 24px;
z-index: 9999;
width: var(--csk-window-width);
height: 600px;
height: var(--csk-window-height);
max-height: calc(100vh - 48px);
background: #fff;
border-radius: var(--csk-radius);
@ -2045,6 +2249,21 @@ var ChatbotSDK = (function () {
border: 0 !important;
}
/* ========== 会话分组标题 ========== */
.csk-history-group {
margin-bottom: 4px;
}
.csk-history-group__label {
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 12px 4px;
user-select: none;
}
.csk-dark .csk-history-group__label { color: #6B7280; }
/* ========== 移动端适配 ========== */
@media (max-width: 480px) {
.csk-window {
@ -2142,7 +2361,9 @@ var ChatbotSDK = (function () {
function createLauncher(config, onClick) {
const launcher = document.createElement('div');
launcher.id = 'csk-launcher';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
// 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式
const glassClass = config.launcherTheme ? ' csk-launcher--glass' : '';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`;
launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
@ -2418,8 +2639,8 @@ var ChatbotSDK = (function () {
};
}
// ==================== 拖拽支持 ====================
/** 启用弹窗拖拽 */
function enableDrag(headerEl, windowEl) {
/** 启用弹窗拖拽,onDragEnd 回调返回最终位置用于持久化 */
function enableDrag(headerEl, windowEl, onDragEnd) {
let dragging = false;
let startX = 0;
let startY = 0;
@ -2456,6 +2677,11 @@ var ChatbotSDK = (function () {
dragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// 拖拽结束回调:返回当前位置供持久化
if (onDragEnd) {
const rect = windowEl.getBoundingClientRect();
onDragEnd({ x: rect.left, y: rect.top });
}
};
headerEl.addEventListener('mousedown', onMouseDown);
// 清理函数
@ -2770,7 +2996,47 @@ var ChatbotSDK = (function () {
wrapper.appendChild(sourcesEl);
}
}
/** 渲染会话列表 */
/** 按日期分组会话列表:今天 / 昨天 / 本周 / 更早 */
function groupHistoryByDate(items) {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const weekStart = todayStart - (now.getDay() || 7) * 86400000 + 86400000; // 本周一
const groups = {
today: [],
yesterday: [],
week: [],
earlier: [],
};
for (const item of items) {
// 尝试解析时间:优先 lastMessageTime,其次 createdAt
const timeStr = item.lastMessageTime || item.createdAt;
const ts = timeStr ? new Date(timeStr).getTime() : 0;
if (ts >= todayStart) {
groups.today.push(item);
}
else if (ts >= yesterdayStart) {
groups.yesterday.push(item);
}
else if (ts >= weekStart) {
groups.week.push(item);
}
else {
groups.earlier.push(item);
}
}
const result = [];
if (groups.today.length)
result.push({ label: t('history_group_today'), items: groups.today });
if (groups.yesterday.length)
result.push({ label: t('history_group_yesterday'), items: groups.yesterday });
if (groups.week.length)
result.push({ label: t('history_group_week'), items: groups.week });
if (groups.earlier.length)
result.push({ label: t('history_group_earlier'), items: groups.earlier });
return result;
}
/** 渲染会话列表(带日期分组) */
function renderHistoryList(listEl, items, onSelect, onExport, onDelete, activeChatId, emptyText) {
listEl.innerHTML = '';
if (items.length === 0) {
@ -2783,7 +3049,20 @@ var ChatbotSDK = (function () {
listEl.appendChild(empty);
return;
}
for (const item of items) {
// 按日期分组渲染
const groups = groupHistoryByDate(items);
for (const group of groups) {
// 分组标题
if (groups.length > 1) {
const groupEl = document.createElement('div');
groupEl.className = 'csk-history-group';
const labelEl = document.createElement('div');
labelEl.className = 'csk-history-group__label';
labelEl.textContent = group.label;
groupEl.appendChild(labelEl);
listEl.appendChild(groupEl);
}
for (const item of group.items) {
const el = document.createElement('div');
el.className = 'csk-history-item';
// 高亮当前活跃会话
@ -2846,6 +3125,7 @@ var ChatbotSDK = (function () {
});
listEl.appendChild(el);
}
} // 关闭 group 循环
}
/** 滚动消息区到底部 */
function scrollToBottom(container) {
@ -3488,7 +3768,7 @@ var ChatbotSDK = (function () {
announceMessage(aiContent);
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
if (messagesContainer$1) {
messagesContainer$1.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true }));
messagesContainer$1.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } }));
}
// RAG 引用来源
if (shouldUseRag)
@ -3850,6 +4130,8 @@ var ChatbotSDK = (function () {
let teaserTimer = null;
/** 未读徽章元素(挂在 launcher 内部) */
let badgeEl = null;
/** 音频上下文(提示音用) */
let audioCtx = null;
// ==================== 公开 API ====================
/** 初始化 SDK */
function init(rawConfig) {
@ -3866,6 +4148,8 @@ var ChatbotSDK = (function () {
setLocale(config.locale);
// 3. 设置日志级别
setDebug(config.debug);
// 3.1 设置错误回调
setErrorCallback(config.onError);
// 4. 设置 API 配置
setApiConfig(config);
// 5. 注入样式
@ -3891,11 +4175,15 @@ var ChatbotSDK = (function () {
teaserEl = dom.teaserEl;
document.body.appendChild(teaserEl);
positionTeaser();
// 9. 启用拖拽
// 9. 启用拖拽(含位置持久化)
const headerEl = windowEl.querySelector('.csk-header');
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl);
dragCleanup = enableDrag(headerEl, windowEl, (pos) => {
saveWindowPosition(config.integrateId, pos);
});
}
// 9.1 恢复上次拖拽位置
restoreWindowPosition(config.integrateId, windowEl);
// 10. 初始化对话模块
initChat(config, {
messagesContainer,
@ -3931,16 +4219,31 @@ var ChatbotSDK = (function () {
windowEl.addEventListener('csk:feedback', ((e) => {
handleFeedback(e.detail.msgId, e.detail.value);
}));
// 16. 新消息到达通知(用于未读徽章)
windowEl.addEventListener('csk:newMessage', () => {
// 16. 新消息到达通知(用于未读徽章 + 提示音 + 桌面通知)
windowEl.addEventListener('csk:newMessage', ((e) => {
var _a;
notifyNewMessage();
});
// 触发 onMessage 回调
if ((config === null || config === void 0 ? void 0 : config.onMessage) && ((_a = e.detail) === null || _a === void 0 ? void 0 : _a.msg)) {
try {
config.onMessage(e.detail.msg);
}
catch ( /* 回调异常不影响 SDK */_b) { /* 回调异常不影响 SDK */ }
}
}));
// 17. ESC 键关闭弹窗
document.addEventListener('keydown', onKeyDown);
// 17. 窗口 resize 时重新定位 teaser
window.addEventListener('resize', onResize);
isInitialized = true;
logger.lifecycleInit(config.integrateId, config.requestDomain);
// 触发 onReady 回调
if (config.onReady) {
try {
config.onReady();
}
catch ( /* 回调异常不影响 SDK */_a) { /* 回调异常不影响 SDK */ }
}
// 18. 首访提示气泡(延迟显示,弹窗未打开时生效)
if (config.showTeaser) {
teaserTimer = setTimeout(() => {
@ -4017,10 +4320,16 @@ var ChatbotSDK = (function () {
if (badgeEl)
badgeEl.classList.add('csk-launcher__badge--hidden');
}
/** 新消息到达时弹窗关闭,则显示徽章 */
/** 新消息到达时弹窗关闭,则显示徽章 + 播放提示音 + 桌面通知 */
function notifyNewMessage() {
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showBadge();
// 提示音
if (config === null || config === void 0 ? void 0 : config.sound)
playNotificationSound();
// 桌面通知
if (config === null || config === void 0 ? void 0 : config.notification)
sendDesktopNotification();
}
}
// ==================== a11y 焦点陷阱 ====================
@ -4086,6 +4395,7 @@ var ChatbotSDK = (function () {
removeStyles();
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', onResize);
setErrorCallback(undefined);
const oldIntegrateId = config === null || config === void 0 ? void 0 : config.integrateId;
config = null;
isInitialized = false;
@ -4098,6 +4408,7 @@ var ChatbotSDK = (function () {
showLoadingFn = null;
hideLoadingFn = null;
badgeEl = null;
audioCtx = null;
logger.lifecycleDestroy(oldIntegrateId || '');
}
function open() {
@ -4134,6 +4445,87 @@ var ChatbotSDK = (function () {
clearMessages(config.integrateId);
}
}
// ==================== 窗口位置记忆 ====================
/** localStorage key:窗口位置 */
function positionKey(integrateId) {
return `csk_position_${integrateId}`;
}
/** 保存窗口拖拽位置到 localStorage */
function saveWindowPosition(integrateId, pos) {
try {
localStorage.setItem(positionKey(integrateId), JSON.stringify(pos));
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
/** 恢复窗口拖拽位置 */
function restoreWindowPosition(integrateId, winEl) {
try {
const raw = localStorage.getItem(positionKey(integrateId));
if (!raw)
return;
const pos = JSON.parse(raw);
if (typeof pos.x !== 'number' || typeof pos.y !== 'number')
return;
// 校验位置在当前视口内
const maxX = window.innerWidth - winEl.offsetWidth;
const maxY = window.innerHeight - winEl.offsetHeight;
const x = Math.max(0, Math.min(pos.x, maxX));
const y = Math.max(0, Math.min(pos.y, maxY));
winEl.style.right = 'auto';
winEl.style.bottom = 'auto';
winEl.style.left = `${x}px`;
winEl.style.top = `${y}px`;
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
// ==================== 提示音 + 桌面通知 ====================
/** 播放短促提示音(Web Audio API 合成,无需音频文件) */
function playNotificationSound() {
try {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
const ctx = audioCtx;
if (ctx.state === 'suspended') {
ctx.resume();
return;
}
// 合成两声短促"叮"
const now = ctx.currentTime;
[0, 0.15].forEach((offset, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = i === 0 ? 880 : 1100;
gain.gain.setValueAtTime(0.15, now + offset);
gain.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + offset);
osc.stop(now + offset + 0.12);
});
}
catch ( /* 音频 API 不可用则忽略 */_a) { /* 音频 API 不可用则忽略 */ }
}
/** 发送桌面通知(需用户授权) */
function sendDesktopNotification() {
try {
if (!('Notification' in window))
return;
if (Notification.permission === 'granted') {
new Notification((config === null || config === void 0 ? void 0 : config.title) || 'AI 智能助手', {
body: '收到新消息',
icon: undefined,
silent: true,
});
}
else if (Notification.permission !== 'denied') {
// 首次请求权限
Notification.requestPermission();
}
}
catch ( /* 通知 API 不可用则忽略 */_a) { /* 通知 API 不可用则忽略 */ }
}
// ==================== 挂载到全局 ====================
const ChatbotSDK = {
init,

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

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

@ -136,30 +136,47 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>showCategorySwitch</label>
<select id="cfg-cat"><option value="0" selected>关闭</option><option value="1">开启</option></select>
</div>
<div class="fg">
<label>position</label>
<select id="cfg-pos"><option value="right-bottom" selected>右下角</option><option value="left-bottom">左下角</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>width (px)</label>
<input type="number" id="cfg-width" value="380" min="300" max="600">
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>streaming</label>
<select id="cfg-stream"><option value="1" selected>开启流式</option><option value="0">关闭(同步)</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>locale</label>
<select id="cfg-locale"><option value="zh-CN" selected>中文</option><option value="en">English</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>showCategorySwitch</label>
<select id="cfg-cat"><option value="0" selected>关闭</option><option value="1">开启</option></select>
</div>
</div>
<div class="fg-row">
<div class="fg">
<label>launcherTheme 悬浮按钮主题</label>
<select id="cfg-theme">
<option value="" selected>默认(primaryColor 渐变)</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>
</div>
<div class="fg">
<label>debug</label>
<select id="cfg-debug"><option value="1" selected>开启日志</option><option value="0">关闭日志</option></select>
@ -292,6 +309,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
showCategorySwitch: getEl('cfg-cat').value === '1',
debug: getEl('cfg-debug').value === '1',
showClear: true,
launcherTheme: getEl('cfg-theme').value || undefined,
};
}
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
@ -333,6 +351,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
if (cfg.streaming !== SDK_DEFAULTS.streaming) params.push(' streaming: ' + cfg.streaming + ', // 是否启用流式输出');
if (cfg.locale !== SDK_DEFAULTS.locale) params.push(' locale: ' + JSON.stringify(cfg.locale) + ', // 界面语言');
if (cfg.showCategorySwitch !== SDK_DEFAULTS.showCategorySwitch) params.push(' showCategorySwitch: ' + cfg.showCategorySwitch + ', // 是否显示知识库切换');
if (cfg.launcherTheme) params.push(' launcherTheme: ' + JSON.stringify(cfg.launcherTheme) + ', // 悬浮按钮主题(磨砂玻璃质感)');
// debug 强制 false(生产环境推荐),无论面板当前值
params.push(' debug: false // 调试日志,生产环境建议关闭');
const initBlock = 'ChatbotSDK.init({\n' + params.join('\n') + '\n});';
@ -364,7 +383,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|debug|showClear|showAdminPanel|launcherIcon)(?=\s*:)/g;
const re = /(&lt;!--[\s\S]*?--&gt;)|('[^']*'|"[^"]*")|(\/\/[^\n]*)|\b(\d+)\b|\b(integrateId|requestDomain|userId|categoryId|title|primaryColor|position|width|streaming|locale|showCategorySwitch|debug|showClear|showAdminPanel|launcherIcon|launcherTheme)(?=\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>';
@ -513,7 +532,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
setEl('footer-time',new Date().toLocaleTimeString());
if(typeof window.ChatbotSDK!=='undefined'){const tag=getEl('tag-sdk');tag.className='tag tag--pass';tag.textContent='✅ SDK 已加载';}
// 监听配置表单变化,实时刷新接入代码
['cfg-iid','cfg-domain','cfg-uid','cfg-title','cfg-color','cfg-pos','cfg-width','cfg-stream','cfg-locale','cfg-cat','cfg-debug'].forEach(function(id){
['cfg-iid','cfg-domain','cfg-uid','cfg-title','cfg-color','cfg-pos','cfg-width','cfg-stream','cfg-locale','cfg-cat','cfg-debug','cfg-theme'].forEach(function(id){
const el=getEl(id);
if(!el)return;
el.addEventListener('input', generateCode);

Loading…
Cancel
Save