本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

5157 lines
199 KiB

var ChatbotSDK = (function () {
'use strict';
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 = {
/** 普通信息日志(受 debug 开关控制) */
info(msg, data) {
if (debugEnabled) {
console.log(PREFIX, msg, data !== undefined ? data : '');
}
},
/** 警告日志(受 debug 开关控制) */
warn(msg, data) {
if (debugEnabled) {
console.warn(PREFIX, msg, data !== undefined ? data : '');
}
},
/** 错误日志(始终输出,不受 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) {
timers[label] = Date.now();
},
/** 结束计时并输出日志 */
timeEnd(label, prefix) {
const start = timers[label];
if (start !== undefined) {
const duration = Date.now() - start;
delete timers[label];
if (debugEnabled) {
const msg = prefix ? `${prefix} ${duration}ms` : `${label} ${duration}ms`;
console.log(PREFIX, msg);
}
return duration;
}
return 0;
},
/** 生命周期日志:init */
lifecycleInit(integrateId, requestDomain) {
this.info(`初始化完成 integrateId=${integrateId} requestDomain=${requestDomain}`);
},
/** 生命周期日志:destroy */
lifecycleDestroy(integrateId) {
this.info(`销毁实例 integrateId=${integrateId}`);
},
/** 生命周期日志:sendMessage */
lifecycleSend(integrateId, length) {
this.info(`发送消息 integrateId=${integrateId} length=${length}`);
this.time(`send_${integrateId}`);
},
/** 生命周期日志:收到回复 */
lifecycleReply(integrateId, length) {
const duration = this.timeEnd(`send_${integrateId}`, 'AI 回复');
this.info(`AI 回复 integrateId=${integrateId} length=${length} duration=${duration}ms`);
},
/** 生命周期日志:请求失败 */
lifecycleError(integrateId, status, message) {
this.timeEnd(`send_${integrateId}`);
this.error(`请求失败 integrateId=${integrateId} status=${status} message=${message}`);
},
/** 生命周期日志:清空会话 */
lifecycleClear(integrateId) {
this.info(`清空会话 integrateId=${integrateId}`);
},
/** 生命周期日志:流式回复完成 */
lifecycleStreamDone(integrateId, length) {
const duration = this.timeEnd(`send_${integrateId}`, '流式回复');
this.info(`流式回复完成 integrateId=${integrateId} length=${length} duration=${duration}ms`);
},
/** 生命周期日志:知识库切换 */
lifecycleCategoryChange(categoryId) {
this.info(`切换知识库分类 categoryId=${categoryId}`);
},
};
/** 默认悬浮按钮图标 — 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, 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, _h, _j, _k, _l, _m;
// 校验必传参数:integrateId(对应后端 roleId)
if (!raw.integrateId || (typeof raw.integrateId !== 'string' && typeof raw.integrateId !== 'number')
|| (typeof raw.integrateId === 'string' && raw.integrateId.trim() === '')) {
logger.error('integrateId 是必传参数(对应后端 roleId 客服角色 ID),请检查 init() 调用。示例:ChatbotSDK.init({ integrateId: 1, requestDomain: "https://api.example.com" })');
return null;
}
// 校验必传参数:requestDomain
if (!raw.requestDomain || typeof raw.requestDomain !== 'string' || raw.requestDomain.trim() === '') {
logger.error('requestDomain 是必传参数,请检查 init() 调用。示例:ChatbotSDK.init({ integrateId: 1, requestDomain: "https://api.example.com" })');
return null;
}
// 校验 requestDomain 是否为合法 URL 格式
try {
new URL(raw.requestDomain);
}
catch (_o) {
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,
requestDomain: raw.requestDomain.replace(/\/+$/, ''), // 去掉末尾斜杠
userId: raw.userId,
categoryId: raw.categoryId,
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: 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: (_f = raw.showTeaser) !== null && _f !== void 0 ? _f : true,
teaserText: (typeof raw.teaserText === 'string' && raw.teaserText.trim()) || '',
resizable: (_g = raw.resizable) !== null && _g !== void 0 ? _g : true,
streaming: (_h = raw.streaming) !== null && _h !== void 0 ? _h : true,
enableRag: (_j = raw.enableRag) !== null && _j !== void 0 ? _j : true,
rewriteStrategy: raw.rewriteStrategy || 'REWRITE',
locale: raw.locale || 'zh-CN',
debug: (_k = raw.debug) !== null && _k !== void 0 ? _k : true,
sound: (_l = raw.sound) !== null && _l !== void 0 ? _l : false,
notification: (_m = raw.notification) !== null && _m !== void 0 ? _m : 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,
token: raw.token,
roles: raw.roles,
chatId: '', // 初始为空,由 chatId 初始化流程填充
};
logger.info(`配置解析完成 integrateId(=roleId)=${config.integrateId} userId(=accountId)=${config.userId || '(未设置)'} requestDomain=${config.requestDomain}`);
return config;
}
/**
* 多语言国际化模块 - i18n 字典 + 翻译函数
*/
/** 语言包字典 */
const dictionaries = {
'zh-CN': {
// 头部
title: 'AI 智能助手',
minimize: '最小化',
close: '关闭',
status_online: '在线',
// 欢迎空状态
welcome_title: '你好,我是 AI 智能助手',
welcome_desc: '有什么可以帮你的吗?在下方输入框开始提问吧~',
// 输入区
placeholder: '输入您的问题...',
send: '发送',
stop: '停止生成',
new_message: '新消息',
// 消息操作
copy: '复制',
copied: '已复制',
retry: '重新生成',
retrying: '重新生成中...',
// 消息
loading: '正在思考...',
stream_interrupted: '回复被中断',
stream_unstable: '网络不稳定,内容可能不完整',
// 知识库
category_placeholder: '选择知识库分类',
category_all: '全部分类',
category_load_error: '加载分类失败',
source_title: '参考来源',
source_count: '{n} 条参考来源',
source_loading: '加载来源中...',
// 清空/管理
clear: '清空对话',
clear_confirm: '确定清空所有对话记录?',
// 历史会话
history_title: '历史会话',
history_empty: '暂无历史会话',
history_load_error: '加载会话列表失败',
history_delete_confirm: '确定删除该会话?',
history_export: '导出',
history_delete: '删除',
history_search: '搜索会话...',
history_group_today: '今天',
history_group_yesterday: '昨天',
history_group_week: '本周',
history_group_earlier: '更早',
// 消息反馈
feedback_up: '有帮助',
feedback_down: '没帮助',
// 提示气泡
teaser_text: '有什么可以帮你的吗?',
new_msg_announce: '收到新消息',
resize: '拖拽缩放窗口',
// 错误提示
error_network: '网络连接失败,请检查网络',
error_timeout: '请求超时,请稍后重试',
error_server: '服务器异常,请稍后重试',
error_cors: '跨域请求被拦截,请联系管理员将当前域名加入 API 白名单',
error_auth: '鉴权失败,请联系管理员',
error_forbidden: '无访问权限,请联系管理员配置',
error_not_found: '请求的资源不存在',
error_rate_limit: '请求过于频繁,请稍后重试',
error_unavailable: '服务暂不可用,请稍后重试',
error_unknown: '请求发生未知错误',
error_send: '发送失败,请稍后重试',
error_stream_unsupported: '浏览器不支持流式读取',
},
'en': {
// Header
title: 'AI Assistant',
minimize: 'Minimize',
close: 'Close',
status_online: 'Online',
// Welcome
welcome_title: 'Hi, I am your AI assistant',
welcome_desc: 'How can I help you? Type your question below to get started.',
// Input
placeholder: 'Type your question...',
send: 'Send',
stop: 'Stop generating',
new_message: 'New message',
// Message actions
copy: 'Copy',
copied: 'Copied',
retry: 'Regenerate',
retrying: 'Regenerating...',
// Messages
loading: 'Thinking...',
stream_interrupted: 'Response interrupted',
stream_unstable: 'Network unstable, content may be incomplete',
// Knowledge base
category_placeholder: 'Select category',
category_all: 'All categories',
category_load_error: 'Failed to load categories',
source_title: 'Sources',
source_count: '{n} source(s)',
source_loading: 'Loading sources...',
// Clear/Management
clear: 'Clear chat',
clear_confirm: 'Clear all conversation history?',
// History
history_title: 'History',
history_empty: 'No conversations yet',
history_load_error: 'Failed to load conversations',
history_delete_confirm: 'Delete this conversation?',
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',
// Teaser
teaser_text: 'How can I help you?',
new_msg_announce: 'New message received',
resize: 'Resize window',
// Errors
error_network: 'Network connection failed',
error_timeout: 'Request timed out, please try again',
error_server: 'Server error, please try again later',
error_cors: 'CORS request blocked. Please contact admin to whitelist your domain',
error_auth: 'Authentication failed, please contact admin',
error_forbidden: 'Access denied, please contact admin',
error_not_found: 'Resource not found',
error_rate_limit: 'Too many requests, please try again later',
error_unavailable: 'Service temporarily unavailable',
error_unknown: 'Unknown request error',
error_send: 'Failed to send, please try again',
error_stream_unsupported: 'Browser does not support streaming',
},
};
/** 当前语言 */
let currentLocale = 'zh-CN';
/**
* 设置当前语言
*/
function setLocale(locale) {
if (dictionaries[locale]) {
currentLocale = locale;
}
else {
// 尝试匹配语言前缀(如 zh -> zh-CN)
const prefix = locale.split('-')[0];
const matched = Object.keys(dictionaries).find(k => k.startsWith(prefix));
if (matched) {
currentLocale = matched;
}
// 未匹配则保持默认 zh-CN
}
}
/**
* 获取翻译文本
* @param key 翻译 key
* @param params 插值参数,如 { n: 3 } 替换 {n}
*/
function t(key, params) {
const dict = dictionaries[currentLocale] || dictionaries['zh-CN'];
let text = dict[key] || dictionaries['zh-CN'][key] || key;
// 简单插值替换:{n} → 实际值
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, String(v));
}
}
return text;
}
/** 请求超时时间(毫秒) */
const REQUEST_TIMEOUT = 30000;
let currentConfig = null;
/** 设置当前配置 */
function setApiConfig(config) {
currentConfig = config;
}
/** 清空当前配置(销毁 SDK 时调用) */
function clearApiConfig() {
currentConfig = null;
}
/** 更新当前 chatId(对话 ID) */
function updateChatId(chatId) {
if (currentConfig) {
currentConfig.chatId = chatId;
}
}
/** 获取当前 chatId */
function getChatId() {
return (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.chatId) || '';
}
/** 构建完整请求 URL,自动防御双斜杠 */
function buildUrl(path) {
if (!currentConfig) {
throw new Error('API 配置未初始化');
}
const domain = currentConfig.requestDomain.replace(/\/+$/, '');
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${domain}${cleanPath}`;
}
/**
* 安全设置可选参数:仅当 value 非空时追加
*/
function setIfPresent(params, key, value) {
if (value === undefined || value === null)
return;
if (typeof value === 'string' && value.trim() === '')
return;
params.set(key, String(value));
}
// ==================== 对话接口 URL 构建 ====================
/**
* 构建同步对话请求 URL
* - integrateId → roleId
* - userId → accountId
* - chatId → 自动管理的对话 ID
*/
function buildChatUrl(message) {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.chatId);
// integrateId 映射为 roleId
setIfPresent(params, 'roleId', currentConfig.integrateId);
// userId 映射为 accountId
setIfPresent(params, 'accountId', currentConfig.userId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
}
/**
* 构建 SSE 流式请求 URL
*/
function buildChatSSEUrl(message, categoryId) {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.chatId);
setIfPresent(params, 'roleId', currentConfig.integrateId);
setIfPresent(params, 'accountId', currentConfig.userId);
setIfPresent(params, 'categoryId', categoryId !== null && categoryId !== void 0 ? categoryId : currentConfig.categoryId);
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/**
* 构建 RAG 增强流式请求 URL
*/
function buildChatRAGSSEUrl(message, categoryId) {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.chatId);
params.set('rewriteStrategy', currentConfig.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig.integrateId);
setIfPresent(params, 'accountId', currentConfig.userId);
setIfPresent(params, 'categoryId', categoryId !== null && categoryId !== void 0 ? categoryId : currentConfig.categoryId);
return buildUrl(`/ai/assistant_app/chat/rag/sse?${params.toString()}`);
}
/**
* 构建 RAG 引用来源请求 URL
*/
function buildRagSourcesUrl(message, categoryId) {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.chatId);
params.set('rewriteStrategy', currentConfig.rewriteStrategy || 'REWRITE');
setIfPresent(params, 'roleId', currentConfig.integrateId);
setIfPresent(params, 'accountId', currentConfig.userId);
setIfPresent(params, 'categoryId', categoryId !== null && categoryId !== void 0 ? categoryId : currentConfig.categoryId);
return buildUrl(`/ai/assistant_app/rag/sources?${params.toString()}`);
}
// ==================== HTTP 基础封装 ====================
/** 带超时的 fetch 封装,支持外部 AbortSignal(用于流式主动中断) */
async function safeFetch(url, options = {}, timeout = REQUEST_TIMEOUT, externalSignal) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
// 外部信号触发时同步中断内部请求
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
}
else {
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
}
try {
// 注入 SDK Token 认证头(仅对 /ai/ 路径注入,避免将 SDK Token 发送到管理接口)
const headers = {};
if (options.headers) {
if (options.headers instanceof Headers) {
options.headers.forEach((value, key) => { headers[key] = value; });
}
else if (Array.isArray(options.headers)) {
options.headers.forEach(([key, value]) => { headers[key] = value; });
}
else {
Object.assign(headers, options.headers);
}
}
if ((currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.token) && url.includes('/ai/')) {
headers['Authorization'] = `Bearer ${currentConfig.token}`;
}
const response = await fetch(url, Object.assign(Object.assign({}, options), { headers, signal: controller.signal, mode: 'cors', credentials: 'include' }));
if (response.status === 401 && url.includes('/ai/')) {
logger.error('SDK 认证失败:Token 缺失或已过期,请重新调用 /open-api/auth/token 换取 Token');
}
return response;
}
catch (err) {
// 用户主动中断(停止生成),不算错误
if (externalSignal === null || externalSignal === void 0 ? void 0 : externalSignal.aborted) {
throw new CskError('aborted', 'aborted');
}
if (err instanceof DOMException && err.name === 'AbortError') {
throw new CskError(t('error_timeout'), 'timeout');
}
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
throw new CskError(t('error_cors'), 'cors');
}
throw new CskError(t('error_network'), 'network');
}
finally {
clearTimeout(timer);
}
}
/** 自定义错误类型 */
class CskError extends Error {
constructor(message, type) {
super(message);
this.name = 'CskError';
this.type = type;
}
}
/** 根据 HTTP 状态码返回对应的国际化错误消息 */
function getHttpErrorMessage(status) {
switch (status) {
case 401: return t('error_auth');
case 403: return t('error_forbidden');
case 404: return t('error_not_found');
case 429: return t('error_rate_limit');
case 500: return t('error_server');
case 502:
case 503: return t('error_unavailable');
default: return `${t('error_unknown')}${status}`;
}
}
// ==================== 对话请求 ====================
/**
* 同步对话请求
*/
async function chatRequest(message) {
const url = buildChatUrl(message);
logger.lifecycleSend(currentConfig.integrateId, message.length);
try {
const response = await safeFetch(url);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.lifecycleError(currentConfig.integrateId, String(response.status), errorMsg);
throw new CskError(errorMsg, `http_${response.status}`);
}
const text = await response.text();
logger.lifecycleReply(currentConfig.integrateId, text.length);
return text;
}
catch (err) {
if (err instanceof CskError)
throw err;
logger.lifecycleError(currentConfig.integrateId, 'unknown', String(err));
throw new CskError(t('error_unknown'), 'unknown');
}
}
/**
* SSE 流式对话请求
* @param useRag 是否使用 RAG 增强对话
* @param categoryId 知识库分类 ID
*/
async function chatSSERequest(message, onChunk, onDone, onError, categoryId, useRag, signal) {
var _a;
const url = useRag
? buildChatRAGSSEUrl(message, categoryId)
: buildChatSSEUrl(message, categoryId);
let totalText = '';
logger.lifecycleSend(currentConfig.integrateId, 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);
onError(new CskError(errorMsg, `http_${response.status}`));
return;
}
const reader = (_a = response.body) === null || _a === void 0 ? void 0 : _a.getReader();
if (!reader) {
onError(new CskError(t('error_stream_unsupported'), 'stream_unsupported'));
return;
}
const decoder = new TextDecoder('utf-8', { stream: true });
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':'))
continue;
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5).trim();
if (data) {
totalText += data;
onChunk(data);
}
}
else if (trimmed === '[DONE]') {
break;
}
else if (!trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) {
totalText += trimmed;
onChunk(trimmed);
}
}
}
if (buffer.trim()) {
const trimmed = buffer.trim();
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5).trim();
if (data) {
totalText += data;
onChunk(data);
}
}
else if (trimmed !== '[DONE]') {
totalText += trimmed;
onChunk(trimmed);
}
}
}
catch (readErr) {
// 用户主动中断:视为正常结束,保留已生成内容
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
logger.info(`流式被用户中断,保留已生成内容 length=${totalText.length}`);
}
else if (totalText.length > 0) {
onChunk('\n\n' + t('stream_unstable'));
}
else {
throw readErr;
}
}
finally {
reader.releaseLock();
}
logger.lifecycleStreamDone(currentConfig.integrateId, totalText.length);
onDone();
}
catch (err) {
// 用户主动中断不触发 onError,走 onDone
if ((signal === null || signal === void 0 ? void 0 : signal.aborted) || (err instanceof CskError && err.type === 'aborted')) {
onDone();
return;
}
if (err instanceof CskError) {
onError(err);
}
else {
logger.lifecycleError(currentConfig.integrateId, 'unknown', String(err));
onError(new CskError(t('error_network'), 'network'));
}
}
}
// ==================== P1: 知识库分类 ====================
/**
* 获取知识库分类树
*/
async function fetchCategoryTree() {
const url = buildUrl('/category/tree');
try {
const response = await safeFetch(url);
if (!response.ok)
throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json = await response.json();
if (json.success && Array.isArray(json.data)) {
logger.info(`加载分类树成功 count=${json.data.length}`);
return json.data;
}
return [];
}
catch (err) {
if (err instanceof CskError)
logger.error(`加载分类树失败: ${err.message}`);
else
logger.error('加载分类树失败', err);
return [];
}
}
/**
* 获取 RAG 引用来源
*/
async function fetchRagSources(message, categoryId) {
const url = buildRagSourcesUrl(message, categoryId);
try {
const response = await safeFetch(url);
if (!response.ok)
throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json = await response.json();
if (json.success && Array.isArray(json.data)) {
logger.info(`获取引用来源 count=${json.data.length}`);
return json.data;
}
return [];
}
catch (err) {
logger.error('获取引用来源失败', err);
return [];
}
}
// ==================== P0-002: 消息反馈 ====================
/**
* 提交消息反馈(点赞/点踩)
*/
async function submitFeedbackApi(messageId, feedbackType) {
if (!currentConfig)
return false;
const url = buildUrl('/feedback');
try {
const response = await safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messageId: String(messageId),
conversationId: currentConfig.chatId,
feedbackType,
}),
});
if (!response.ok) {
logger.error(`反馈提交失败 status=${response.status}`);
return false;
}
const json = await response.json();
return json.success || false;
}
catch (err) {
logger.error('反馈提交异常', err);
return false;
}
}
/**
* 获取会话列表(SDK 安全端点)
* 使用 /ai/sdk/conversation/list,强制按 accountId + roleId 过滤
*/
async function fetchConversationList(page = 1, size = 20, accountId, roleId) {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('size', String(size));
if (accountId)
params.set('accountId', accountId);
if (roleId)
params.set('roleId', roleId);
const url = buildUrl(`/ai/sdk/conversation/list?${params.toString()}`);
try {
const response = await safeFetch(url);
if (!response.ok)
throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json = await response.json();
return {
list: json.success && Array.isArray(json.data) ? json.data : [],
total: json.total || 0,
pages: json.pages || 0,
};
}
catch (err) {
logger.error('加载会话列表失败', err);
return { list: [], total: 0, pages: 0 };
}
}
/**
* 获取会话消息(SDK 安全端点)
* 使用 /ai/sdk/conversation/{id}/messages,含账户归属校验
*/
async function fetchConversationMessages(conversationId) {
const params = new URLSearchParams();
if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.userId)
params.set('accountId', currentConfig.userId);
if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.integrateId)
params.set('roleId', currentConfig.integrateId);
const url = buildUrl(`/ai/sdk/conversation/${conversationId}/messages?${params.toString()}`);
try {
const response = await safeFetch(url);
if (!response.ok)
throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json = await response.json();
return {
messages: json.success && Array.isArray(json.data) ? json.data : [],
total: json.total || 0,
};
}
catch (err) {
logger.error('加载会话消息失败', err);
return { messages: [], total: 0 };
}
}
/**
* 删除会话(SDK 安全端点)
* 使用 DELETE /ai/sdk/conversation/{id},含账户归属校验
*/
async function deleteConversation(conversationId) {
const params = new URLSearchParams();
if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.userId)
params.set('accountId', currentConfig.userId);
if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.integrateId)
params.set('roleId', currentConfig.integrateId);
const url = buildUrl(`/ai/sdk/conversation/${conversationId}?${params.toString()}`);
try {
const response = await safeFetch(url, { method: 'DELETE' });
if (!response.ok)
throw new CskError(getHttpErrorMessage(response.status), `http_${response.status}`);
const json = await response.json();
logger.info(`删除会话 id=${conversationId} success=${json.success}`);
return json.success || false;
}
catch (err) {
logger.error('删除会话失败', err);
return false;
}
}
/**
* 导出会话 URL(SDK 安全端点)
* 使用 /ai/sdk/conversation/{id}/export,含账户归属参数
*/
function getConversationExportUrl(conversationId) {
const params = new URLSearchParams();
if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.userId)
params.set('accountId', currentConfig.userId);
if (currentConfig === null || currentConfig === void 0 ? void 0 : currentConfig.integrateId)
params.set('roleId', currentConfig.integrateId);
return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`);
}
// ==================== chatId 自动初始化 ====================
/**
* 初始化 chatId:查询后端已有会话,找到则复用,否则生成新的
*
* 逻辑:
* 1. 先查 localStorage 缓存的 chatId(同一 integrateId + userId 可能复用)
* 2. 查 /ai/sdk/conversation/list?accountId=X&roleId=Y 看是否有匹配的会话
* 3. 有会话 → 使用最新会话的 conversationId 作为 chatId
* 4. 无会话 → 自动生成 chatId(格式:sdk_timestamp_random)
*/
async function initChatId() {
if (!currentConfig)
return '';
// 1. 先尝试从 localStorage 恢复
const cachedChatId = loadCachedChatId(currentConfig.integrateId, 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);
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);
logger.info(`从后端恢复会话 chatId=${chatId} messageCount=${latestConv.messageCount}`);
return chatId;
}
}
}
catch (err) {
logger.warn('查询后端会话列表失败,将生成新 chatId', err);
}
// 3. 生成新的 chatId
const newChatId = generateChatId();
currentConfig.chatId = newChatId;
saveCachedChatId(currentConfig.integrateId, currentConfig.userId, newChatId);
logger.info(`生成新 chatId=${newChatId}`);
return newChatId;
}
/** 生成 chatId(格式:sdk_timestamp_random) */
function generateChatId() {
const random = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID().substring(0, 8)
: Math.random().toString(36).substring(2, 10);
return `sdk_${Date.now()}_${random}`;
}
/** localStorage key 格式 */
function chatIdStorageKey(integrateId, userId) {
return `csk_chatId_${integrateId}${userId ? '_' + userId : ''}`;
}
/** 从 localStorage 加载 chatId */
function loadCachedChatId(integrateId, userId) {
try {
return localStorage.getItem(chatIdStorageKey(integrateId, userId)) || '';
}
catch (_a) {
return '';
}
}
/** 保存 chatId 到 localStorage */
function saveCachedChatId(integrateId, userId, chatId) {
try {
if (chatId) {
localStorage.setItem(chatIdStorageKey(integrateId, userId), chatId);
}
else {
localStorage.removeItem(chatIdStorageKey(integrateId, userId));
}
}
catch (_a) {
// localStorage 不可用则忽略
}
}
let styleElement = null;
/** 将 hex 颜色解析为 "r, g, b" 字符串,用于 rgba() 拼接 */
function hexToRgb(hex) {
const match = hex.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
if (!match)
return '79, 70, 229';
return `${parseInt(match[1], 16)}, ${parseInt(match[2], 16)}, ${parseInt(match[3], 16)}`;
}
/** CSS 变量:将配置中的主题色转换为 CSS 自定义属性 */
function cssVars(config) {
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};
--csk-primary-light: ${lighter};
--csk-primary-rgb: ${rgb};
--csk-bg-user: linear-gradient(135deg, ${config.primaryColor}, ${darker});
--csk-bg-ai: #ffffff;
--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};
` : ''}
`;
}
/** 简单的颜色加深(按通道加减) */
function adjustColor(hex, amount) {
const match = hex.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
if (!match) {
return hex;
}
const clamp = (v) => Math.max(0, Math.min(255, v));
const r = clamp(parseInt(match[1], 16) + amount);
const g = clamp(parseInt(match[2], 16) + amount);
const b = clamp(parseInt(match[3], 16) + amount);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
/** 完整 CSS 样式表 */
function getStyles(config) {
return `
/* ChatbotSDK 样式 - csk- 命名空间 */
.csk-root {
${cssVars(config)}
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
line-height: 1.55;
color: #1F2937;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-sizing: border-box;
}
.csk-root *, .csk-root *::before, .csk-root *::after { box-sizing: border-box; }
/* ========== 悬浮按钮 ========== */
.csk-launcher {
position: fixed;
bottom: 24px;
z-index: 9998;
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)
);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
user-select: none;
overflow: visible;
/* 半透明白色光环边框,在白色页面上清晰分离按钮与背景 */
border: 2.5px solid rgba(255, 255, 255, 0.55);
/* 三层投影:接地阴影 + 彩色光晕,把按钮从白底「托」出来 */
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);
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-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%);
}
.csk-launcher:active { transform: scale(0.93); }
.csk-launcher svg {
width: 30px;
height: 30px;
position: relative;
z-index: 1;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15));
}
@keyframes csk-launcher-in {
0% { opacity: 0; transform: scale(0.5) translateY(16px); }
60% { transform: scale(1.06) translateY(-2px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
/* ========== 三点脉冲序列图标 ========== */
/* 极光旋转 */
@keyframes csk-aurora-spin {
to { transform: rotate(360deg); }
}
/* 脉冲点 */
.csk-ico-pulse-dot {
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);
}
.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); }
}
/* 移除旧的 SVG 图标相关样式(已不再使用) */
/* ========== 高光扫过动效 ========== */
.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;
}
@keyframes csk-shimmer {
0%, 100% { left: -60%; opacity: 0; }
20% { opacity: 1; }
60% { opacity: 1; }
80% { left: 120%; opacity: 0; }
}
@keyframes csk-breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.35; }
}
/* ========== 磨砂玻璃质感悬浮按钮 ========== */
.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;
}
/* 玻璃按钮的呼吸色晕 — 更鲜艳、更醒目 */
.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; }
}
/* 三点脉冲图标在玻璃主题下保持可见 */
/* ========== 聊天弹窗 ========== */
.csk-window {
position: fixed;
bottom: 24px;
z-index: 9999;
width: var(--csk-window-width);
height: var(--csk-window-height);
max-height: calc(100vh - 48px);
background: #fff;
border-radius: var(--csk-radius);
box-shadow: var(--csk-shadow-window);
display: flex;
flex-direction: column;
overflow: hidden;
transform-origin: bottom right;
transition: opacity 0.24s ease, transform 0.24s cubic-bezier(0.34, 1.2, 0.64, 1), visibility 0.24s;
opacity: 1;
transform: translateY(0) scale(1);
visibility: visible;
pointer-events: auto;
}
.csk-window--right { right: 24px; transform-origin: bottom right; }
.csk-window--left { left: 24px; transform-origin: bottom left; }
.csk-window--hidden {
opacity: 0;
transform: translateY(12px) scale(0.96);
visibility: hidden;
pointer-events: none;
}
/* ========== 头部 ========== */
.csk-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
min-height: 60px;
background: var(--csk-bg-user);
color: #fff;
cursor: move;
user-select: none;
position: relative;
flex-shrink: 0;
}
.csk-header::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 24px;
background: linear-gradient(to bottom, rgba(0,0,0,0.04), transparent);
pointer-events: none;
}
.csk-header__left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.csk-header__avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.22);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
}
.csk-header__avatar svg { width: 22px; height: 22px; }
.csk-header__info {
display: flex;
flex-direction: column;
min-width: 0;
}
.csk-header__title {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.2px;
}
.csk-header__status {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
opacity: 0.9;
margin-top: 2px;
}
.csk-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #6EE7B7;
box-shadow: 0 0 0 0 rgba(110, 231, 183, 0.7);
animation: csk-status-pulse 2s infinite;
}
@keyframes csk-status-pulse {
0% { box-shadow: 0 0 0 0 rgba(110, 231, 183, 0.7); }
70% { box-shadow: 0 0 0 6px rgba(110, 231, 183, 0); }
100% { box-shadow: 0 0 0 0 rgba(110, 231, 183, 0); }
}
.csk-header__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.csk-header__btn,
.csk-history-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #fff;
cursor: pointer;
border-radius: 8px;
transition: background 0.15s, transform 0.15s;
}
.csk-header__btn:hover,
.csk-history-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.csk-header__btn:active,
.csk-history-btn:active { transform: scale(0.92); }
/* ========== 消息区 ========== */
.csk-messages {
flex: 1;
overflow-y: auto;
padding: 20px 16px 12px;
background: var(--csk-bg-app);
scroll-behavior: smooth;
}
.csk-messages::-webkit-scrollbar { width: 6px; }
.csk-messages::-webkit-scrollbar-track { background: transparent; }
.csk-messages::-webkit-scrollbar-thumb {
background: #D8DCE3;
border-radius: 3px;
}
.csk-messages::-webkit-scrollbar-thumb:hover { background: #C2C7D0; }
/* 欢迎空状态 */
.csk-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px 32px;
color: #6B7280;
animation: csk-fade-in 0.3s ease;
}
.csk-welcome__avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--csk-bg-user);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
box-shadow: 0 6px 18px rgba(var(--csk-primary-rgb), 0.3);
}
.csk-welcome__avatar svg { width: 30px; height: 30px; }
.csk-welcome__title {
font-size: 15px;
font-weight: 600;
color: #1F2937;
margin-bottom: 4px;
}
.csk-welcome__desc {
font-size: 13px;
color: #9CA3AF;
line-height: 1.6;
}
/* 消息气泡 */
.csk-msg {
display: flex;
margin-bottom: 18px;
max-width: 100%;
word-break: break-word;
animation: csk-msg-in 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.csk-msg--user {
flex-direction: row-reverse;
align-items: flex-end;
}
.csk-msg--ai {
flex-direction: row;
align-items: flex-start;
}
.csk-msg__avatar {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin: 0 8px;
box-shadow: var(--csk-shadow-bubble);
}
.csk-msg__avatar svg { width: 18px; height: 18px; }
.csk-msg__avatar--ai {
background: #fff;
color: var(--csk-primary);
border: 1px solid var(--csk-border);
}
.csk-msg__avatar--user {
background: var(--csk-bg-user);
color: #fff;
}
.csk-msg__content {
display: flex;
flex-direction: column;
min-width: 0;
max-width: calc(100% - 46px);
}
.csk-msg--user .csk-msg__content { align-items: flex-end; }
.csk-msg--ai .csk-msg__content { align-items: flex-start; }
.csk-msg__bubble {
padding: 10px 14px;
border-radius: 16px;
font-size: 14px;
line-height: 1.6;
box-shadow: var(--csk-shadow-bubble);
}
.csk-msg--user .csk-msg__bubble {
background: var(--csk-bg-user);
color: var(--csk-text-user);
border-radius: 16px 16px 4px 16px;
}
.csk-msg--ai .csk-msg__bubble {
background: var(--csk-bg-ai);
color: var(--csk-text-ai);
border-radius: 16px 16px 16px 4px;
border: 1px solid var(--csk-border);
}
.csk-msg__time {
font-size: 11px;
color: #9CA3AF;
margin-top: 5px;
padding: 0 4px;
}
@keyframes csk-msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes csk-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ========== Loading 动画 ========== */
.csk-loading {
display: flex;
align-items: center;
margin-bottom: 18px;
animation: csk-msg-in 0.28s ease;
}
.csk-loading__avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: #fff;
color: var(--csk-primary);
border: 1px solid var(--csk-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin: 0 8px;
box-shadow: var(--csk-shadow-bubble);
}
.csk-loading__avatar svg { width: 18px; height: 18px; }
.csk-loading__bubble {
background: #fff;
border: 1px solid var(--csk-border);
border-radius: 16px 16px 16px 4px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 5px;
}
.csk-loading__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #B4B9C2;
animation: csk-bounce 1.4s ease-in-out infinite both;
}
.csk-loading__dot:nth-child(1) { animation-delay: 0s; }
.csk-loading__dot:nth-child(2) { animation-delay: 0.16s; }
.csk-loading__dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes csk-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.6; }
40% { transform: scale(1); opacity: 1; }
}
/* ========== 输入区 ========== */
.csk-input-area {
padding: 10px 12px 14px;
background: #fff;
border-top: 1px solid var(--csk-border);
flex-shrink: 0;
}
.csk-input-wrap {
display: flex;
align-items: flex-end;
gap: 8px;
background: var(--csk-bg-app);
border: 1px solid var(--csk-border);
border-radius: 14px;
padding: 6px 6px 6px 14px;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.csk-input-wrap--focus {
border-color: var(--csk-primary);
background: #fff;
box-shadow: 0 0 0 3px rgba(var(--csk-primary-rgb), 0.12);
}
.csk-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 0;
font-size: 14px;
outline: none;
font-family: inherit;
resize: none;
min-height: 22px;
max-height: 120px;
line-height: 1.5;
color: #1F2937;
}
.csk-input::placeholder { color: #9CA3AF; }
.csk-root .csk-input:focus-visible { outline: none; }
.csk-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
min-width: 36px;
border: none;
border-radius: 10px;
background: var(--csk-bg-user);
color: #fff;
cursor: pointer;
transition: transform 0.18s, box-shadow 0.18s, opacity 0.18s;
box-shadow: 0 3px 10px rgba(var(--csk-primary-rgb), 0.3);
}
.csk-send-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(var(--csk-primary-rgb), 0.4);
}
.csk-send-btn:active:not(:disabled) { transform: scale(0.92); }
.csk-send-btn:disabled {
background: #D1D5DB;
box-shadow: none;
cursor: not-allowed;
opacity: 0.7;
}
.csk-send-btn svg { width: 18px; height: 18px; }
/* ========== 清空按钮 ========== */
.csk-clear-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border: 1px solid var(--csk-border);
border-radius: 999px;
background: #fff;
color: #6B7280;
font-size: 12px;
cursor: pointer;
margin: 0 auto 8px;
transition: all 0.15s;
font-family: inherit;
}
.csk-clear-btn:hover {
background: #FEF2F2;
border-color: #FCA5A5;
color: #DC2626;
}
/* ========== P1: 知识库分类下拉 ========== */
.csk-category-bar {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid var(--csk-border);
background: #FBFBFC;
gap: 8px;
flex-shrink: 0;
}
.csk-category-bar__label {
font-size: 13px;
white-space: nowrap;
}
.csk-category-select {
flex: 1;
padding: 6px 28px 6px 10px;
border: 1px solid var(--csk-border);
border-radius: 8px;
font-size: 12.5px;
color: #374151;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239CA3AF' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 9px center;
appearance: none;
-webkit-appearance: none;
outline: none;
cursor: pointer;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
max-width: 220px;
}
.csk-category-select:focus {
border-color: var(--csk-primary);
box-shadow: 0 0 0 3px rgba(var(--csk-primary-rgb), 0.12);
}
/* ========== P1: RAG 引用来源卡片 ========== */
.csk-sources {
margin-top: 8px;
border: 1px solid var(--csk-border);
border-radius: 12px;
overflow: hidden;
font-size: 12px;
max-width: 100%;
background: #FAFBFC;
}
.csk-sources__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #F3F4F7;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.csk-sources__header:hover { background: #ECEEF2; }
.csk-sources__title {
display: flex;
align-items: center;
gap: 5px;
font-weight: 500;
color: #374151;
}
.csk-sources__arrow {
transition: transform 0.2s;
color: #9CA3AF;
font-size: 10px;
}
.csk-sources--collapsed .csk-sources__arrow { transform: rotate(-90deg); }
.csk-sources__body {
border-top: 1px solid var(--csk-border);
padding: 0;
}
.csk-sources--collapsed .csk-sources__body { display: none; }
.csk-source-item {
padding: 9px 12px;
border-bottom: 1px solid #F0F1F4;
transition: background 0.15s;
}
.csk-source-item:last-child { border-bottom: none; }
.csk-source-item:hover { background: #fff; }
.csk-source-item__name {
font-weight: 500;
color: #1F2937;
margin-bottom: 3px;
}
.csk-source-item__snippet {
color: #6B7280;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.csk-source-item__meta {
font-size: 11px;
color: #9CA3AF;
margin-top: 4px;
}
/* ========== P1: Markdown 渲染样式 ========== */
.csk-msg--ai .csk-msg__bubble .csk-md-p { margin: 0 0 8px; }
.csk-msg--ai .csk-msg__bubble .csk-md-p:last-child { margin-bottom: 0; }
.csk-msg--ai .csk-msg__bubble .csk-md-h1,
.csk-msg--ai .csk-msg__bubble .csk-md-h2,
.csk-msg--ai .csk-msg__bubble .csk-md-h3,
.csk-msg--ai .csk-msg__bubble .csk-md-h4,
.csk-msg--ai .csk-msg__bubble .csk-md-h5,
.csk-msg--ai .csk-msg__bubble .csk-md-h6 {
margin: 14px 0 6px;
font-weight: 600;
line-height: 1.3;
color: #111827;
}
.csk-msg--ai .csk-msg__bubble .csk-md-h1 { font-size: 20px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h2 { font-size: 17px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h3 { font-size: 15px; }
.csk-msg--ai .csk-msg__bubble .csk-md-h4 { font-size: 14px; }
.csk-md-code-block {
background: #1E293B;
color: #E2E8F0;
padding: 12px 14px;
border-radius: 10px;
overflow-x: auto;
margin: 8px 0;
font-size: 13px;
line-height: 1.55;
font-family: 'SF Mono', 'Consolas', 'Menlo', 'Monaco', monospace;
}
.csk-md-code-block code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.csk-md-inline-code {
background: rgba(var(--csk-primary-rgb), 0.1);
color: var(--csk-primary);
padding: 1px 6px;
border-radius: 5px;
font-size: 13px;
font-family: 'SF Mono', 'Consolas', 'Menlo', 'Monaco', monospace;
}
.csk-msg--ai .csk-msg__bubble .csk-md-ul,
.csk-msg--ai .csk-msg__bubble .csk-md-ol {
padding-left: 22px;
margin: 6px 0;
}
.csk-msg--ai .csk-msg__bubble .csk-md-ul li,
.csk-msg--ai .csk-msg__bubble .csk-md-ol li { margin-bottom: 4px; }
.csk-md-blockquote {
border-left: 3px solid var(--csk-primary);
padding: 2px 12px;
margin: 8px 0;
color: #6B7280;
background: rgba(var(--csk-primary-rgb), 0.05);
border-radius: 0 6px 6px 0;
}
.csk-md-link {
color: var(--csk-primary);
text-decoration: none;
border-bottom: 1px solid rgba(var(--csk-primary-rgb), 0.3);
transition: border-color 0.15s;
}
.csk-md-link:hover { border-bottom-color: var(--csk-primary); }
.csk-md-hr {
border: none;
border-top: 1px solid var(--csk-border);
margin: 12px 0;
}
/* ========== P2: 会话管理面板 ========== */
.csk-history-panel {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #fff;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
animation: csk-slide-in 0.24s ease;
}
.csk-history-panel--hidden { display: none; }
@keyframes csk-slide-in {
from { transform: translateX(100%); opacity: 0.4; }
to { transform: translateX(0); opacity: 1; }
}
.csk-history-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--csk-border);
background: var(--csk-bg-app);
flex-shrink: 0;
}
.csk-history-panel__title {
font-size: 14px;
font-weight: 600;
color: #1F2937;
}
.csk-history-panel__back {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
border: 1px solid var(--csk-border);
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.csk-history-panel__back:hover { background: #F3F4F6; }
.csk-history-panel__list {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.csk-history-panel__list::-webkit-scrollbar { width: 5px; }
.csk-history-panel__list::-webkit-scrollbar-thumb { background: #E5E7EB; border-radius: 2px; }
.csk-history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
margin-bottom: 5px;
border: 1px solid transparent;
}
.csk-history-item:hover { background: #F3F4F6; }
.csk-history-item--active {
background: rgba(var(--csk-primary-rgb), 0.08);
border-color: rgba(var(--csk-primary-rgb), 0.2);
}
.csk-history-item--active:hover { background: rgba(var(--csk-primary-rgb), 0.12); }
.csk-history-item__info { flex: 1; min-width: 0; }
.csk-history-item__id {
font-size: 13px;
font-weight: 500;
color: #1F2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.csk-history-item__meta {
font-size: 11px;
color: #9CA3AF;
margin-top: 3px;
}
.csk-history-item__actions {
display: flex;
gap: 4px;
margin-left: 8px;
opacity: 0;
transition: opacity 0.15s;
}
.csk-history-item:hover .csk-history-item__actions { opacity: 1; }
.csk-history-action {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 7px;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.csk-history-action--export { background: #EFF6FF; color: #2563EB; }
.csk-history-action--export:hover { background: #DBEAFE; }
.csk-history-action--delete { background: #FEF2F2; color: #DC2626; }
.csk-history-action--delete:hover { background: #FEE2E2; }
.csk-history-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: #9CA3AF;
font-size: 13px;
text-align: center;
}
.csk-history-panel__empty-icon {
font-size: 34px;
margin-bottom: 10px;
opacity: 0.5;
}
.csk-history-panel__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
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 {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 18px;
max-width: 320px;
}
.csk-quick-reply {
border: 1px solid var(--csk-border);
background: #fff;
color: #374151;
font-size: 12.5px;
padding: 7px 13px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
line-height: 1.3;
transition: all 0.18s ease;
max-width: 100%;
}
.csk-quick-reply:hover {
border-color: var(--csk-primary);
color: var(--csk-primary);
background: rgba(var(--csk-primary-rgb), 0.06);
transform: translateY(-1px);
}
.csk-quick-reply:active { transform: scale(0.96); }
/* ========== AI 消息操作条 ========== */
.csk-msg__actions {
display: flex;
gap: 2px;
margin-top: 5px;
padding: 0 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.csk-msg--ai:hover .csk-msg__actions { opacity: 1; }
.csk-msg--streaming .csk-msg__actions { display: none; }
.csk-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: #9CA3AF;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.csk-action-btn:hover {
background: #F3F4F6;
color: #374151;
}
.csk-action-btn--done { color: #10B981; }
.csk-action-btn:active { transform: scale(0.9); }
/* ========== 流式打字光标 ========== */
.csk-caret {
display: inline-block;
width: 7px;
height: 15px;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--csk-primary);
border-radius: 1px;
animation: csk-caret-blink 1s step-end infinite;
}
@keyframes csk-caret-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* ========== 代码块复制按钮 ========== */
.csk-md-code-block { position: relative; }
.csk-md-code-copy {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: rgba(255, 255, 255, 0.12);
color: #CBD5E1;
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
}
.csk-md-code-block:hover .csk-md-code-copy { opacity: 1; }
.csk-md-code-copy:hover { background: rgba(255, 255, 255, 0.22); color: #fff; }
.csk-md-code-copy:active { transform: scale(0.9); }
/* ========== 新消息提示按钮 ========== */
.csk-newmsg {
position: absolute;
bottom: 82px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
border: none;
background: var(--csk-primary);
color: #fff;
font-size: 12px;
font-family: inherit;
border-radius: 999px;
cursor: pointer;
box-shadow: 0 4px 14px rgba(var(--csk-primary-rgb), 0.38);
transition: transform 0.2s ease, box-shadow 0.2s ease;
animation: csk-msg-in 0.25s ease;
white-space: nowrap;
}
.csk-newmsg:hover { transform: translateX(-50%) translateY(-1px); box-shadow: 0 6px 18px rgba(var(--csk-primary-rgb), 0.48); }
.csk-newmsg:active { transform: translateX(-50%) scale(0.95); }
.csk-newmsg--hidden { display: none; }
/* ========== 发送按钮停止态 ========== */
.csk-send-btn--stop {
background: #EF4444 !important;
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.35) !important;
}
.csk-send-btn--stop:hover:not(:disabled) {
box-shadow: 0 5px 14px rgba(239, 68, 68, 0.45) !important;
}
/* ========== 窗口缩放拖拽手柄 ========== */
.csk-resize-handle {
position: absolute;
right: 4px;
bottom: 4px;
width: 20px;
height: 20px;
display: flex;
align-items: flex-end;
justify-content: flex-end;
cursor: nwse-resize;
z-index: 5;
user-select: none;
-webkit-user-select: none;
/* 圆形半透明背景,与整体圆润风格一致 */
border-radius: 50%;
background: transparent;
transition: background 0.2s ease;
}
.csk-resize-handle:hover {
background: rgba(var(--csk-primary-rgb), 0.08);
}
/* 三斜线抓手 — 纯 CSS 绘制,用两条间距微调的线,视觉上是三条 */
.csk-resize-grip {
display: block;
width: 10px;
height: 10px;
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
#C8CCD2 38%, #C8CCD2 42%,
transparent 42%, transparent 54%,
#C8CCD2 54%, #C8CCD2 58%,
transparent 58%, transparent 70%,
#C8CCD2 70%, #C8CCD2 74%,
transparent 74%
);
transition: background 0.2s ease;
border-radius: 2px;
}
/* hover 时线条变为主色 */
.csk-resize-handle:hover .csk-resize-grip {
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
var(--csk-primary) 38%, var(--csk-primary) 42%,
transparent 42%, transparent 54%,
var(--csk-primary) 54%, var(--csk-primary) 58%,
transparent 58%, transparent 70%,
var(--csk-primary) 70%, var(--csk-primary) 74%,
transparent 74%
);
}
/* 暗色模式 */
.csk-dark .csk-resize-grip {
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
#5B6070 38%, #5B6070 42%,
transparent 42%, transparent 54%,
#5B6070 54%, #5B6070 58%,
transparent 58%, transparent 70%,
#5B6070 70%, #5B6070 74%,
transparent 74%
);
}
.csk-dark .csk-resize-handle:hover .csk-resize-grip {
background:
linear-gradient(135deg,
transparent 0%, transparent 38%,
var(--csk-primary-light) 38%, var(--csk-primary-light) 42%,
transparent 42%, transparent 54%,
var(--csk-primary-light) 54%, var(--csk-primary-light) 58%,
transparent 58%, transparent 70%,
var(--csk-primary-light) 70%, var(--csk-primary-light) 74%,
transparent 74%
);
}
/* 缩放拖拽中:禁用过渡动画 + 全局光标 */
.csk-window--resizing {
transition: none !important;
user-select: none;
}
.csk-window--resizing * { cursor: nwse-resize !important; }
/* ========== 暗色模式 ========== */
.csk-root.csk-dark {
--csk-bg-ai: #1E1E2E;
--csk-text-ai: #E2E8F0;
--csk-border: #3B3B52;
--csk-bg-app: #141421;
color: #E2E8F0;
}
.csk-dark .csk-window {
background: #1A1A2E;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
}
.csk-dark .csk-header::after { background: linear-gradient(to bottom, rgba(0,0,0,0.12), transparent); }
.csk-dark .csk-msg__avatar--ai {
background: var(--csk-bg-ai);
color: var(--csk-primary);
border-color: var(--csk-border);
}
.csk-dark .csk-input-area { background: #1A1A2E; border-top-color: var(--csk-border); }
.csk-dark .csk-input-wrap { background: var(--csk-bg-app); border-color: var(--csk-border); }
.csk-dark .csk-input-wrap--focus { background: #1A1A2E; border-color: var(--csk-primary); }
.csk-dark .csk-input { color: #E2E8F0; }
.csk-dark .csk-loading__avatar { background: var(--csk-bg-ai); border-color: var(--csk-border); }
.csk-dark .csk-loading__bubble { background: var(--csk-bg-ai); border-color: var(--csk-border); }
.csk-dark .csk-loading__dot { background: #6B7280; }
.csk-dark .csk-category-bar { background: #1A1A2E; border-top-color: var(--csk-border); }
.csk-dark .csk-category-select { background-color: var(--csk-bg-app); border-color: var(--csk-border); color: #E2E8F0; }
.csk-dark .csk-sources { background: #1E1E2E; border-color: var(--csk-border); }
.csk-dark .csk-sources__header { background: #25253A; }
.csk-dark .csk-sources__header:hover { background: #2D2D48; }
.csk-dark .csk-source-item:hover { background: var(--csk-bg-ai); }
.csk-dark .csk-source-item__name { color: #E2E8F0; }
.csk-dark .csk-source-item__meta { color: #6B7280; }
.csk-dark .csk-history-panel { background: #1A1A2E; }
.csk-dark .csk-history-panel__header { background: var(--csk-bg-app); border-bottom-color: var(--csk-border); }
.csk-dark .csk-history-panel__back { background: var(--csk-bg-app); border-color: var(--csk-border); color: #E2E8F0; }
.csk-dark .csk-history-panel__back:hover { background: #2D2D48; }
.csk-dark .csk-history-item:hover { background: #25253A; }
.csk-dark .csk-history-item__id { color: #E2E8F0; }
.csk-dark .csk-history-item__meta { color: #6B7280; }
.csk-dark .csk-history-item--active { background: rgba(var(--csk-primary-rgb), 0.15); }
.csk-dark .csk-history-item--active:hover { background: rgba(var(--csk-primary-rgb), 0.22); }
.csk-dark .csk-action-btn:hover { background: #2D2D48; color: #E2E8F0; }
.csk-dark .csk-clear-btn { background: var(--csk-bg-app); border-color: var(--csk-border); color: #9CA3AF; }
.csk-dark .csk-clear-btn:hover { background: #3B1A1A; border-color: #7F1D1D; color: #FCA5A5; }
.csk-dark .csk-md-code-block { background: #0F0F1A; }
.csk-dark .csk-md-blockquote { background: rgba(var(--csk-primary-rgb), 0.08); }
.csk-dark .csk-md-link { color: var(--csk-primary-light); }
.csk-dark .csk-msg--ai .csk-msg__bubble { background: var(--csk-bg-ai); color: var(--csk-text-ai); border-color: var(--csk-border); }
.csk-dark .csk-welcome__title { color: #E2E8F0; }
.csk-dark .csk-welcome__desc { color: #6B7280; }
.csk-dark .csk-quick-reply { background: var(--csk-bg-app); border-color: var(--csk-border); color: #CBD5E1; }
.csk-dark .csk-quick-reply:hover { background: rgba(var(--csk-primary-rgb), 0.12); border-color: var(--csk-primary); color: var(--csk-primary-light); }
.csk-dark .csk-teaser { background: #2D2D48; color: #E2E8F0; box-shadow: 0 4px 20px rgba(0,0,0,0.35); }
.csk-dark .csk-teaser::after { border-top-color: #2D2D48; }
.csk-dark .csk-teaser__close { color: #6B7280; }
.csk-dark .csk-teaser__close:hover { color: #9CA3AF; }
.csk-dark .csk-feedback-btn { background: rgba(var(--csk-primary-rgb), 0.1); color: #9CA3AF; }
.csk-dark .csk-feedback-btn:hover { background: rgba(var(--csk-primary-rgb), 0.2); color: var(--csk-primary-light); }
.csk-dark .csk-feedback-btn--active { background: rgba(var(--csk-primary-rgb), 0.28); color: var(--csk-primary-light); }
.csk-dark .csk-history-panel__search-wrap { background: var(--csk-bg-app); border-color: var(--csk-border); }
.csk-dark .csk-history-panel__search-wrap:focus-within { border-color: var(--csk-primary); }
.csk-dark .csk-history-panel__search { color: #E2E8F0; }
.csk-dark .csk-launcher__badge { box-shadow: 0 0 0 2px #1A1A2E; }
.csk-dark .csk-welcome__avatar { box-shadow: 0 6px 18px rgba(var(--csk-primary-rgb), 0.25); }
.csk-dark .csk-header__avatar { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.1); }
/* Launcher 拖拽态:微放大 + 阴影加深 */
.csk-launcher--dragging {
cursor: grabbing !important;
transform: scale(1.1) !important;
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;
}
.csk-launcher--dragging::before,
.csk-launcher--dragging::after { animation-play-state: paused !important; }
/* 拖拽中隐藏未读徽章 */
.csk-launcher--dragging .csk-launcher__badge { display: none !important; }
/* 吸附过渡动画(释放时的缓动) */
.csk-launcher--snap {
transition: left 0.3s ease-out, right 0.3s ease-out, bottom 0.3s ease-out !important;
}
/* ========== Launcher 未读徽章 ========== */
.csk-launcher__badge {
position: absolute;
top: -2px;
right: -2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #EF4444;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #fff;
animation: csk-badge-in 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
}
.csk-launcher__badge--hidden { display: none; }
@keyframes csk-badge-in {
from { transform: scale(0); }
to { transform: scale(1); }
}
/* ========== Launcher 提示气泡 ========== */
.csk-teaser {
position: fixed;
bottom: 90px;
z-index: 9997;
max-width: 240px;
padding: 10px 14px;
background: #fff;
color: #1F2937;
font-size: 13px;
line-height: 1.5;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(15, 23, 42, 0.15);
animation: csk-teaser-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
cursor: pointer;
user-select: none;
}
.csk-teaser--right { right: 24px; }
.csk-teaser--left { left: 24px; }
.csk-teaser::after {
content: '';
position: absolute;
bottom: -6px;
right: 28px;
width: 12px;
height: 12px;
background: inherit;
border-radius: 2px;
transform: rotate(45deg);
box-shadow: 2px 2px 4px rgba(15, 23, 42, 0.08);
}
.csk-teaser--left::after { right: auto; left: 28px; }
.csk-teaser__close {
position: absolute;
top: 4px;
right: 6px;
background: none;
border: none;
font-size: 16px;
color: #9CA3AF;
cursor: pointer;
padding: 2px 4px;
line-height: 1;
border-radius: 4px;
}
.csk-teaser__close:hover { color: #374151; }
.csk-teaser--hidden { display: none; }
@keyframes csk-teaser-in {
from { opacity: 0; transform: translateY(8px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ========== 消息反馈按钮(👍 / 👎) ========== */
.csk-feedback-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: rgba(var(--csk-primary-rgb), 0.08);
color: #9CA3AF;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.csk-feedback-btn:hover { background: rgba(var(--csk-primary-rgb), 0.18); color: var(--csk-primary); }
.csk-feedback-btn--active {
background: rgba(var(--csk-primary-rgb), 0.28);
color: var(--csk-primary);
}
.csk-feedback-btn--active.down { background: #FEF2F2; color: #EF4444; }
.csk-feedback-btn:active { transform: scale(0.9); }
.csk-msg--streaming .csk-feedback-btn { display: none; }
/* ========== 历史会话搜索框 ========== */
.csk-history-panel__search-wrap {
padding: 8px 10px;
border-bottom: 1px solid var(--csk-border);
flex-shrink: 0;
}
.csk-history-panel__search-wrap:focus-within {
border-bottom-color: var(--csk-primary);
}
.csk-history-panel__search {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--csk-border);
border-radius: 8px;
font-size: 12.5px;
font-family: inherit;
color: #1F2937;
background: var(--csk-bg-app);
outline: none;
transition: border-color 0.2s;
}
.csk-history-panel__search:focus { border-color: var(--csk-primary); }
.csk-history-panel__search::placeholder { color: #9CA3AF; }
/* ========== a11y 焦点指示 ========== */
.csk-root :focus-visible {
outline: 2px solid var(--csk-primary);
outline-offset: 2px;
}
.csk-root button:focus-visible {
outline: 2px solid var(--csk-primary);
outline-offset: 2px;
border-radius: inherit;
}
/* 弹窗打开时隐藏 launcher 上的焦点环 */
.csk-launcher:focus-visible {
outline: none;
border-color: rgba(255, 255, 255, 0.75);
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 0 0 4px rgba(var(--csk-primary-rgb), 0.3);
}
/* ========== a11y aria-live 播报区(视觉隐藏) ========== */
.csk-sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0,0,0,0) !important;
white-space: nowrap !important;
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 {
width: 100vw !important;
height: 100vh !important;
max-height: 100vh;
bottom: 0 !important;
right: 0 !important;
left: 0 !important;
border-radius: 0;
}
.csk-resize-handle { display: none !important; }
.csk-window--hidden { transform: translateY(100%); }
/* 移动端默认位置,仅在未被 JS 拖拽覆盖时生效 */
.csk-launcher:not([style*="bottom"]) { bottom: 20px; }
.csk-launcher--right:not([style*="bottom"]) { right: 20px; }
.csk-launcher--left:not([style*="bottom"]) { left: 20px; }
}
`;
}
/**
* 注入样式到 document.head
*/
function injectStyles(config) {
// 避免重复注入
if (document.querySelector('style[data-csk-sdk]')) {
return;
}
styleElement = document.createElement('style');
styleElement.setAttribute('data-csk-sdk', '');
styleElement.textContent = getStyles(config);
document.head.appendChild(styleElement);
}
/**
* 移除注入的样式
*/
function removeStyles() {
if (styleElement && styleElement.parentNode) {
styleElement.parentNode.removeChild(styleElement);
styleElement = null;
}
document.querySelectorAll('style[data-csk-sdk]').forEach((el) => el.remove());
}
/**
* 工具函数模块
*/
/** 生成简短 UUID(取 crypto.randomUUID 前 8 位) */
/** 生成完整 UUID */
function uuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/** XSS 转义 - 防止用户输入中的 HTML 注入 */
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
};
return text.replace(/[&<>"']/g, (ch) => map[ch] || ch);
}
/** 防抖函数 */
function debounce(fn, delay) {
let timer = null;
return function (...args) {
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
/** 获取当前时间戳(毫秒) */
function now() {
return Date.now();
}
// ==================== 图标常量 ====================
/** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */
const BOT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="12" height="8" x="6" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 14v2"/><path d="M15 14v2"/><path d="M18 13v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/></svg>`;
/** 用户头像图标 */
const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
// ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */
function createLauncher(config, onClick) {
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.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
launcher.setAttribute('tabindex', '0');
// 图标内容
launcher.innerHTML = config.launcherIcon;
// 未读徽章(初始隐藏,有未读消息时由 index.ts 显示)
const badge = document.createElement('div');
badge.className = 'csk-launcher__badge csk-launcher__badge--hidden';
launcher.appendChild(badge);
// 点击事件(300ms 防抖)
const debouncedClick = debounce(onClick, 300);
launcher.addEventListener('click', debouncedClick);
// 键盘支持
launcher.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
debouncedClick();
}
});
return launcher;
}
// ==================== 聊天弹窗 ====================
/** 创建聊天弹窗完整结构,返回各区域引用 */
function createChatWindow(config) {
// 最外层容器
const windowEl = document.createElement('div');
windowEl.id = 'csk-window';
windowEl.className = `csk-root csk-window csk-window--${config.position === 'left-bottom' ? 'left' : 'right'} csk-window--hidden${config.theme === 'dark' ? ' csk-dark' : ''}`;
windowEl.setAttribute('role', 'dialog');
windowEl.setAttribute('aria-label', config.title);
windowEl.setAttribute('aria-modal', 'false');
// === 头部 ===
const header = document.createElement('div');
header.className = 'csk-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'csk-header__left';
const headerAvatar = document.createElement('div');
headerAvatar.className = 'csk-header__avatar';
headerAvatar.innerHTML = BOT_ICON;
const headerInfo = document.createElement('div');
headerInfo.className = 'csk-header__info';
const titleEl = document.createElement('span');
titleEl.className = 'csk-header__title';
titleEl.textContent = config.title;
const statusEl = document.createElement('span');
statusEl.className = 'csk-header__status';
statusEl.innerHTML = `<i class="csk-status-dot"></i>${t('status_online')}`;
headerInfo.appendChild(titleEl);
headerInfo.appendChild(statusEl);
headerLeft.appendChild(headerAvatar);
headerLeft.appendChild(headerInfo);
const actions = document.createElement('div');
actions.className = 'csk-header__actions';
// 历史会话按钮(P2)
const historyBtn = document.createElement('button');
historyBtn.className = 'csk-history-btn';
historyBtn.setAttribute('title', t('history_title'));
historyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
// 最小化按钮
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
minimizeBtn.setAttribute('title', t('minimize'));
minimizeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
minimizeBtn.addEventListener('click', () => {
windowEl.classList.add('csk-window--hidden');
});
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.className = 'csk-header__btn csk-header__btn--close';
closeBtn.setAttribute('title', t('close'));
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.addEventListener('click', () => {
windowEl.classList.add('csk-window--hidden');
});
actions.appendChild(historyBtn);
actions.appendChild(minimizeBtn);
actions.appendChild(closeBtn);
header.appendChild(headerLeft);
header.appendChild(actions);
// === 消息区 ===
const messagesContainer = document.createElement('div');
messagesContainer.id = 'csk-messages';
messagesContainer.className = 'csk-messages';
// === 欢迎空状态 ===
const welcomeEl = document.createElement('div');
welcomeEl.className = 'csk-welcome';
welcomeEl.innerHTML = `
<div class="csk-welcome__avatar">${BOT_ICON}</div>
<div class="csk-welcome__title">${t('welcome_title')}</div>
<div class="csk-welcome__desc">${t('welcome_desc')}</div>
`;
// 快捷问题芯片(点击即自动发送)
if (config.quickReplies.length > 0) {
const chips = document.createElement('div');
chips.className = 'csk-quick-replies';
for (const reply of config.quickReplies) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-quick-reply';
chip.textContent = reply;
chip.addEventListener('click', () => {
windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } }));
});
chips.appendChild(chip);
}
welcomeEl.appendChild(chips);
}
messagesContainer.appendChild(welcomeEl);
// === 新消息提示按钮(用户上滑时新消息到达显示) ===
// 挂到 windowEl 而非 messagesContainer,绝对定位悬浮于输入区上方,避免随内容滚动
const newMsgBtn = document.createElement('button');
newMsgBtn.type = 'button';
newMsgBtn.className = 'csk-newmsg csk-newmsg--hidden';
newMsgBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<span>${t('new_message')}</span>
`;
windowEl.appendChild(newMsgBtn);
// === 会话管理面板(P2,默认隐藏) ===
const historyPanel = document.createElement('div');
historyPanel.className = 'csk-history-panel csk-history-panel--hidden';
historyPanel.innerHTML = `
<div class="csk-history-panel__header">
<span class="csk-history-panel__title">${t('history_title')}</span>
<button class="csk-history-panel__back" id="csk-history-back">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
${t('close')}
</button>
</div>
<div class="csk-history-panel__search-wrap">
<input type="text" class="csk-history-panel__search" id="csk-history-search" placeholder="${t('history_search')}" />
</div>
<div class="csk-history-panel__list" id="csk-history-list"></div>
`;
messagesContainer.appendChild(historyPanel);
// 历史面板返回按钮
const backBtn = historyPanel.querySelector('#csk-history-back');
if (backBtn) {
backBtn.addEventListener('click', () => {
historyPanel.classList.add('csk-history-panel--hidden');
});
}
// 历史按钮点击
historyBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isHidden = historyPanel.classList.contains('csk-history-panel--hidden');
historyPanel.classList.toggle('csk-history-panel--hidden');
if (isHidden) {
// 触发自定义事件,通知加载会话列表
windowEl.dispatchEvent(new CustomEvent('csk:loadHistory'));
}
});
// === 知识库分类下拉框(P1) ===
let categorySelect = null;
if (config.showCategorySwitch) {
const categoryBar = document.createElement('div');
categoryBar.className = 'csk-category-bar';
const categoryLabel = document.createElement('span');
categoryLabel.className = 'csk-category-bar__label';
categoryLabel.textContent = '📚';
categorySelect = document.createElement('select');
categorySelect.id = 'csk-category-select';
categorySelect.className = 'csk-category-select';
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
// onChange 触发自定义事件
categorySelect.addEventListener('change', () => {
const selectedId = categorySelect.value;
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
detail: { categoryId: selectedId ? Number(selectedId) : undefined }
}));
});
categoryBar.appendChild(categoryLabel);
categoryBar.appendChild(categorySelect);
// 插入到 messages 和 inputArea 之间
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
windowEl.appendChild(categoryBar);
}
else {
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
}
// === 输入区 ===
const inputArea = document.createElement('div');
inputArea.className = 'csk-input-area';
const inputWrap = document.createElement('div');
inputWrap.className = 'csk-input-wrap';
const inputEl = document.createElement('textarea');
inputEl.id = 'csk-input';
inputEl.className = 'csk-input';
inputEl.setAttribute('placeholder', t('placeholder'));
inputEl.setAttribute('rows', '1');
inputEl.setAttribute('autofocus', '');
const sendBtn = document.createElement('button');
sendBtn.id = 'csk-send-btn';
sendBtn.className = 'csk-send-btn';
sendBtn.setAttribute('title', t('send'));
sendBtn.setAttribute('disabled', 'true');
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
inputWrap.appendChild(inputEl);
inputWrap.appendChild(sendBtn);
inputArea.appendChild(inputWrap);
windowEl.appendChild(inputArea);
// 清空按钮(可选)
let clearBtn = null;
if (config.showClear) {
clearBtn = document.createElement('button');
clearBtn.className = 'csk-clear-btn';
clearBtn.textContent = t('clear');
clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示
// 插入到 categoryBar/inputArea 之前
windowEl.insertBefore(clearBtn, inputArea);
}
// === Loading 动画 ===
let loadingEl = null;
function showLoading() {
if (loadingEl) {
loadingEl.style.display = 'flex';
return loadingEl;
}
const el = document.createElement('div');
el.className = 'csk-loading';
el.innerHTML = `
<div class="csk-loading__avatar">${BOT_ICON}</div>
<div class="csk-loading__bubble">
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
</div>
`;
messagesContainer.appendChild(el);
loadingEl = el;
return el;
}
function hideLoading() {
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl);
loadingEl = null;
}
}
// === a11y: aria-live 播报区域(视觉隐藏,供屏幕阅读器监听新消息) ===
const ariaLiveEl = document.createElement('div');
ariaLiveEl.className = 'csk-sr-only';
ariaLiveEl.setAttribute('aria-live', 'polite');
ariaLiveEl.setAttribute('aria-atomic', 'true');
windowEl.appendChild(ariaLiveEl);
// === 历史搜索输入框引用 ===
const searchInput = historyPanel.querySelector('#csk-history-search');
// === Launcher 提示气泡(首访引导,初始隐藏,由 index.ts 定时控制) ===
const teaserEl = document.createElement('div');
teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`;
teaserEl.setAttribute('role', 'status');
teaserEl.innerHTML = `
<button class="csk-teaser__close" aria-label="${t('close')}" type="button">&times;</button>
<span>${config.teaserText || t('teaser_text')}</span>
`;
// === 缩放拖拽手柄(右下角) ===
const resizeHandle = document.createElement('div');
resizeHandle.className = 'csk-resize-handle';
resizeHandle.setAttribute('aria-label', t('resize'));
// 纯 CSS 渲染三条斜线,无需 SVG
resizeHandle.innerHTML = '<span class="csk-resize-grip"></span>';
windowEl.appendChild(resizeHandle);
return {
window: windowEl,
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
welcomeEl,
newMsgBtn,
teaserEl,
resizeHandle,
searchInput,
ariaLiveEl,
showLoading,
hideLoading,
};
}
// ==================== 拖拽支持 ====================
/** 启用弹窗拖拽,onDragEnd 回调返回最终位置用于持久化 */
function enableDrag(headerEl, windowEl, onDragEnd) {
let dragging = false;
let startX = 0;
let startY = 0;
let offsetX = 0;
let offsetY = 0;
const onMouseDown = (e) => {
// 忽略头部按钮点击触发的拖拽
const target = e.target;
if (target.closest('button'))
return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = windowEl.getBoundingClientRect();
offsetX = startX - rect.left;
offsetY = startY - rect.top;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e) => {
if (!dragging)
return;
const x = e.clientX - offsetX;
const y = e.clientY - offsetY;
// 边界限制,防止拖出视口
const maxX = window.innerWidth - windowEl.offsetWidth;
const maxY = window.innerHeight - windowEl.offsetHeight;
windowEl.style.right = 'auto';
windowEl.style.bottom = 'auto';
windowEl.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
windowEl.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
};
const onMouseUp = () => {
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);
// 清理函数
return () => {
headerEl.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}
/** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */
const LAUNCHER_MIN_BOTTOM = 16;
/** Launcher 拖拽:计算当前视口下最大 bottom 值 */
function launcherMaxBottom() {
return window.innerHeight - 76; // 按钮高度 60 + 16 间距
}
/**
* 启用 Launcher 按钮的拖拽功能(边缘吸附式)
*
* 设计要点:
* - 仅允许垂直方向自由移动,水平方向释放时自动吸附到最近的左/右边缘
* - 移动距离 < 5px 视为点击(不干扰原有的 open/close 逻辑)
* - 拖拽中按钮微放大 + 隐藏未读徽章,释放后带缓动吸附动画
* - 支持鼠标 + 触摸事件,触摸拖拽时阻止页面滚动
*/
function enableLauncherDrag(launcherEl, config, onDragEnd) {
let dragging = false;
let startX = 0;
let startY = 0;
let hasMoved = false;
let startBottom = 0;
let startRight = 0;
let startLeft = 0;
// 标记是否正在吸附过渡中,避免过渡期间的 mouseup 再次触发
let isSnapping = false;
/** 获取当前 bottom 值(纯数值) */
function getCurrentBottom() {
const val = parseFloat(getComputedStyle(launcherEl).bottom);
return isNaN(val) ? 24 : val;
}
function onPointerDown(clientX, clientY) {
if (isSnapping)
return;
dragging = true;
hasMoved = false;
startX = clientX;
startY = clientY;
startBottom = getCurrentBottom();
// 记录水平起始位置(用于判断当前在哪一侧)
const style = launcherEl.style;
const cs = getComputedStyle(launcherEl);
startRight = style.right ? parseFloat(style.right) : parseFloat(cs.right);
startLeft = style.left ? parseFloat(style.left) : parseFloat(cs.left);
}
function onPointerMove(clientX, clientY, e) {
if (!dragging)
return;
const dx = clientX - startX;
const dy = clientY - startY;
if (!hasMoved) {
if (Math.abs(dx) < 5 && Math.abs(dy) < 5)
return;
hasMoved = true;
// 进入拖拽态
launcherEl.classList.add('csk-launcher--dragging');
launcherEl.classList.remove('csk-launcher--right', 'csk-launcher--left');
launcherEl.style.transition = 'none';
// 同步当前水平位置(从吸附态的 right/left 转换为固定像素)
if (!isNaN(startRight) && startRight >= 0 && isNaN(startLeft)) {
launcherEl.style.right = `${startRight}px`;
launcherEl.style.left = 'auto';
}
else if (!isNaN(startLeft) && startLeft >= 0) {
launcherEl.style.left = `${startLeft}px`;
launcherEl.style.right = 'auto';
}
}
if (hasMoved) {
e.preventDefault(); // 阻止触摸滚动
const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom()));
launcherEl.style.bottom = `${newBottom}px`;
}
}
function onPointerUp() {
if (!dragging)
return;
dragging = false;
if (!hasMoved)
return; // 移动距离不足 5px,视为点击,不处理
// 隐藏拖拽态(保留当前 bottom)
const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom();
const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom()));
// 判断吸附到哪一侧(取离哪边更近)
const rect = launcherEl.getBoundingClientRect();
const center = rect.left + rect.width / 2;
const snapSide = center <= window.innerWidth / 2 ? 'left' : 'right';
// 切换到吸附过渡动画
launcherEl.classList.remove('csk-launcher--dragging');
launcherEl.classList.add(`csk-launcher--${snapSide}`, 'csk-launcher--snap');
launcherEl.style.left = '';
launcherEl.style.right = '';
launcherEl.style.bottom = `${clampedBottom}px`;
// 过渡结束后清理(保留 bottom,否则 CSS 默认 24px 会覆盖拖拽位置)
isSnapping = true;
const onTransitionEnd = () => {
launcherEl.removeEventListener('transitionend', onTransitionEnd);
launcherEl.classList.remove('csk-launcher--snap');
launcherEl.style.transition = '';
// 注意:不清除 style.bottom,保留用户拖拽的垂直位置
isSnapping = false;
};
launcherEl.addEventListener('transitionend', onTransitionEnd);
// 兜底:350ms 后强制清理(transitionend 可能不触发)
setTimeout(() => {
if (isSnapping)
onTransitionEnd();
}, 350);
// 阻止本次拖拽触发 click 事件(mousedown → mousemove → mouseup → click 的浏览器默认链路)
const preventClick = (ev) => {
ev.stopPropagation();
launcherEl.removeEventListener('click', preventClick, true);
};
launcherEl.addEventListener('click', preventClick, true);
// 恢复未读徽章
const badge = launcherEl.querySelector('.csk-launcher__badge');
if (badge)
badge.style.display = '';
if (onDragEnd) {
onDragEnd({ side: snapSide, bottom: clampedBottom });
}
}
// --- 鼠标事件 ---
function onMouseDown(e) { onPointerDown(e.clientX, e.clientY); }
function onMouseMove(e) { onPointerMove(e.clientX, e.clientY, e); }
function onMouseUp() { onPointerUp(); }
// --- 触摸事件 ---
function onTouchStart(e) {
if (e.touches.length === 1)
onPointerDown(e.touches[0].clientX, e.touches[0].clientY);
}
function onTouchMove(e) {
if (e.touches.length === 1)
onPointerMove(e.touches[0].clientX, e.touches[0].clientY, e);
}
function onTouchEnd() { onPointerUp(); }
launcherEl.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
launcherEl.addEventListener('touchstart', onTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
return () => {
launcherEl.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
launcherEl.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
};
}
// ==================== 窗口缩放支持 ====================
/** 窗口最小尺寸 */
const MIN_WIDTH = 300;
const MIN_HEIGHT = 300;
/**
* 启用窗口右下角拖拽缩放
* @param handleEl 缩放手柄元素
* @param windowEl 聊天窗口元素
* @param onResizeEnd 缩放结束回调,返回最终宽高用于持久化
* @returns 清理函数
*/
function enableResize(handleEl, windowEl, onResizeEnd) {
let resizing = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
const onPointerDown = (clientX, clientY) => {
resizing = true;
startX = clientX;
startY = clientY;
const rect = windowEl.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
windowEl.classList.add('csk-window--resizing');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
};
const onPointerMove = (clientX, clientY, e) => {
if (!resizing)
return;
if (e)
e.preventDefault();
const dx = clientX - startX;
const dy = clientY - startY;
const newWidth = Math.max(MIN_WIDTH, Math.min(startWidth + dx, window.innerWidth - 24));
const newHeight = Math.max(MIN_HEIGHT, Math.min(startHeight + dy, window.innerHeight - 24));
windowEl.style.width = `${newWidth}px`;
windowEl.style.height = `${newHeight}px`;
// 同步更新 CSS 变量(供内部布局使用)
windowEl.style.setProperty('--csk-window-width', `${newWidth}px`);
windowEl.style.setProperty('--csk-window-height', `${newHeight}px`);
// 拖拽缩放后清除 bottom/right 定位,避免与固定定位冲突
windowEl.style.bottom = '';
windowEl.style.right = '';
};
const onPointerUp = () => {
if (!resizing)
return;
resizing = false;
windowEl.classList.remove('csk-window--resizing');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
const rect = windowEl.getBoundingClientRect();
if (onResizeEnd) {
onResizeEnd({ width: rect.width, height: rect.height });
}
};
// 鼠标事件(DOM 事件监听器包装)
function onMouseMove(e) { onPointerMove(e.clientX, e.clientY, e); }
function onMouseUp() { onPointerUp(); }
// 鼠标事件
function onMouseDown(e) {
e.preventDefault();
e.stopPropagation();
onPointerDown(e.clientX, e.clientY);
}
// 触摸事件
function onTouchStart(e) {
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 1)
onPointerDown(e.touches[0].clientX, e.touches[0].clientY);
}
function onTouchMove(e) {
if (e.touches.length === 1)
onPointerMove(e.touches[0].clientX, e.touches[0].clientY, e);
}
function onTouchEnd() { onPointerUp(); }
handleEl.addEventListener('mousedown', onMouseDown);
handleEl.addEventListener('touchstart', onTouchStart, { passive: false });
return () => {
handleEl.removeEventListener('mousedown', onMouseDown);
handleEl.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
};
}
// ==================== 消息渲染 ====================
/** 渲染用户消息气泡 */
function renderUserBubble(container, text, timestamp) {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--user';
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--user';
avatar.innerHTML = USER_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.textContent = text;
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
content.appendChild(bubble);
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
return wrapper;
}
/** 渲染 AI 消息气泡(支持 Markdown) */
function renderAIBubble(container, text, timestamp, renderMd, msgId) {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
if (msgId)
wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
avatar.innerHTML = BOT_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
// 支持 Markdown 渲染,传入渲染函数则使用,否则纯文本
if (renderMd) {
bubble.innerHTML = renderMd(text);
}
else {
bubble.textContent = text;
}
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
content.appendChild(bubble);
// AI 操作条(复制 / 重试),插入到气泡与时间戳之间
content.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
// 代码块复制按钮
enhanceCodeBlocks(bubble);
return wrapper;
}
/** 创建空的 AI 气泡(流式追加用) */
function createEmptyAIBubble(container, timestamp, msgId) {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai csk-msg--streaming';
if (msgId)
wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
avatar.innerHTML = BOT_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.innerHTML = '';
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
content.appendChild(bubble);
content.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
return { wrapper, bubble };
}
// ==================== AI 消息操作条 + 代码块复制 ====================
/** 复制图标 */
const COPY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
/** 重试图标 */
const RETRY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`;
/** 构建 AI 消息操作条(复制 / 重试 / 反馈) */
function buildAIActions(bubble, wrapper) {
const bar = document.createElement('div');
bar.className = 'csk-msg__actions';
// 复制按钮
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'csk-action-btn';
copyBtn.setAttribute('title', t('copy'));
copyBtn.setAttribute('aria-label', t('copy'));
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const text = bubble.textContent || '';
await copyToClipboard(text, copyBtn);
});
// 重试按钮
const retryBtn = document.createElement('button');
retryBtn.type = 'button';
retryBtn.className = 'csk-action-btn';
retryBtn.setAttribute('title', t('retry'));
retryBtn.setAttribute('aria-label', t('retry'));
retryBtn.innerHTML = RETRY_ICON;
retryBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:retry', {
detail: { msgId: wrapper.dataset.cskMsgId || '' }
}));
}
});
// 反馈按钮(👍 / 👎)
const THUMB_UP = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`;
const THUMB_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>`;
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'csk-feedback-btn';
upBtn.setAttribute('title', t('feedback_up'));
upBtn.setAttribute('aria-label', t('feedback_up'));
upBtn.innerHTML = THUMB_UP;
upBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'up' }
}));
}
});
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'csk-feedback-btn';
downBtn.setAttribute('title', t('feedback_down'));
downBtn.setAttribute('aria-label', t('feedback_down'));
downBtn.innerHTML = THUMB_DOWN;
downBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'down' }
}));
}
});
bar.appendChild(copyBtn);
bar.appendChild(retryBtn);
bar.appendChild(upBtn);
bar.appendChild(downBtn);
return bar;
}
/**
* 更新反馈按钮的视觉状态(由 chat.ts 调用)
* @param wrapper 消息 wrapper 元素
* @param value 当前反馈值:'up' | 'down' | undefined
*/
function updateFeedbackUI(wrapper, value) {
const btns = wrapper.querySelectorAll('.csk-feedback-btn');
if (btns.length < 2)
return;
btns.forEach((btn) => btn.classList.remove('csk-feedback-btn--active', 'down'));
if (value === 'up' && btns[0])
btns[0].classList.add('csk-feedback-btn--active');
if (value === 'down' && btns[1])
btns[1].classList.add('csk-feedback-btn--active', 'down');
}
/** 复制文本到剪贴板,带"已复制"反馈 */
async function copyToClipboard(text, btn) {
const original = btn.innerHTML;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
}
else {
// 降级方案:execCommand
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
btn.classList.add('csk-action-btn--done');
setTimeout(() => {
btn.innerHTML = original;
btn.classList.remove('csk-action-btn--done');
}, 1500);
}
catch (_a) {
// 复制失败静默处理
}
}
/** 为气泡内所有代码块添加复制按钮 */
function enhanceCodeBlocks(bubble) {
const blocks = bubble.querySelectorAll('.csk-md-code-block');
blocks.forEach((pre) => {
if (pre.querySelector('.csk-md-code-copy'))
return;
const codeEl = pre.querySelector('code');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-md-code-copy';
btn.setAttribute('title', t('copy'));
btn.setAttribute('aria-label', t('copy'));
btn.innerHTML = COPY_ICON;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await copyToClipboard((codeEl === null || codeEl === void 0 ? void 0 : codeEl.textContent) || '', btn);
});
pre.appendChild(btn);
});
}
/** 流式打字光标:追加到气泡末尾(每次 chunk 后调用) */
function appendStreamingCaret(bubble) {
if (bubble.querySelector('.csk-caret'))
return;
const caret = document.createElement('span');
caret.className = 'csk-caret';
caret.setAttribute('aria-hidden', 'true');
bubble.appendChild(caret);
}
/** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */
function finalizeAIBubble(wrapper, bubble) {
wrapper.classList.remove('csk-msg--streaming');
const caret = bubble.querySelector('.csk-caret');
if (caret)
caret.remove();
enhanceCodeBlocks(bubble);
}
// ==================== P1: RAG 引用来源渲染 ====================
/** 渲染 RAG 引用来源卡片 */
function renderSources(wrapper, sources) {
// 移除已有的来源卡片
const existing = wrapper.querySelector('.csk-sources');
if (existing)
existing.remove();
if (!sources || sources.length === 0)
return;
const sourcesEl = document.createElement('div');
sourcesEl.className = 'csk-sources csk-sources--collapsed';
// 头部
const header = document.createElement('div');
header.className = 'csk-sources__header';
const titleSpan = document.createElement('span');
titleSpan.className = 'csk-sources__title';
titleSpan.textContent = `📚 ${t('source_count', { n: sources.length })}`;
const arrow = document.createElement('span');
arrow.className = 'csk-sources__arrow';
arrow.textContent = '▼';
header.appendChild(titleSpan);
header.appendChild(arrow);
// 点击折叠/展开
header.addEventListener('click', () => {
sourcesEl.classList.toggle('csk-sources--collapsed');
});
// 内容
const body = document.createElement('div');
body.className = 'csk-sources__body';
for (const src of sources) {
const item = document.createElement('div');
item.className = 'csk-source-item';
const name = document.createElement('div');
name.className = 'csk-source-item__name';
name.textContent = src.title || src.sourceName || '未知文档';
if (src.snippet) {
const snippet = document.createElement('div');
snippet.className = 'csk-source-item__snippet';
snippet.textContent = src.snippet;
item.appendChild(snippet);
}
const meta = document.createElement('div');
meta.className = 'csk-source-item__meta';
const metaParts = [];
if (src.sourceName)
metaParts.push(src.sourceName);
if (src.chunkIndex !== undefined)
metaParts.push(`分块 #${src.chunkIndex}`);
if (src.score !== undefined)
metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`);
meta.textContent = metaParts.join(' · ');
item.appendChild(name);
item.appendChild(meta);
body.appendChild(item);
}
sourcesEl.appendChild(header);
sourcesEl.appendChild(body);
// 插入到气泡和时间戳之间(时间戳在 content 容器内,故用其父节点插入)
const timeEl = wrapper.querySelector('.csk-msg__time');
if (timeEl && timeEl.parentNode) {
timeEl.parentNode.insertBefore(sourcesEl, timeEl);
}
else {
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) {
const empty = document.createElement('div');
empty.className = 'csk-history-panel__empty';
empty.innerHTML = `
<div class="csk-history-panel__empty-icon">💬</div>
<div>${t('history_empty')}</div>
`;
listEl.appendChild(empty);
return;
}
// 按日期分组渲染
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';
// 高亮当前活跃会话
const convId = item.chatId || item.id;
if (activeChatId && convId === activeChatId) {
el.classList.add('csk-history-item--active');
}
const info = document.createElement('div');
info.className = 'csk-history-item__info';
const idEl = document.createElement('div');
idEl.className = 'csk-history-item__id';
// 显示最后一条消息预览,没有则显示 chatId
if (item.lastMessagePreview) {
idEl.textContent = item.lastMessagePreview.length > 60
? item.lastMessagePreview.substring(0, 60) + '...'
: item.lastMessagePreview;
}
else {
idEl.textContent = convId;
}
const metaEl = document.createElement('div');
metaEl.className = 'csk-history-item__meta';
const metaParts = [];
if (item.messageCount !== undefined)
metaParts.push(`${item.messageCount} 条消息`);
if (item.lastMessageTime)
metaParts.push(item.lastMessageTime);
else if (item.createdAt)
metaParts.push(item.createdAt);
metaEl.textContent = metaParts.join(' · ');
info.appendChild(idEl);
info.appendChild(metaEl);
const actionsEl = document.createElement('div');
actionsEl.className = 'csk-history-item__actions';
// 导出按钮
const exportBtn = document.createElement('button');
exportBtn.className = 'csk-history-action csk-history-action--export';
exportBtn.setAttribute('title', t('history_export'));
exportBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
onExport(item.id);
});
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'csk-history-action csk-history-action--delete';
deleteBtn.setAttribute('title', t('history_delete'));
deleteBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
onDelete(item.id);
});
actionsEl.appendChild(exportBtn);
actionsEl.appendChild(deleteBtn);
el.appendChild(info);
el.appendChild(actionsEl);
// 点击整行 → 切换到该会话
el.addEventListener('click', () => {
onSelect(convId);
});
listEl.appendChild(el);
}
} // 关闭 group 循环
}
/** 滚动消息区到底部 */
function scrollToBottom(container) {
container.scrollTop = container.scrollHeight;
}
/** 格式化时间戳 */
function formatTime(timestamp) {
const d = new Date(timestamp);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
const STORAGE_PREFIX = 'csk_history_';
const MAX_MESSAGES = 200;
const TRIM_COUNT = 50;
/** 生成存储 key */
function storageKey(integrateId) {
return `${STORAGE_PREFIX}${integrateId}`;
}
/**
* 保存消息到 localStorage
*/
function saveMessages(integrateId, messages) {
try {
// 消息上限裁剪:保留最新 200 条,超出裁剪最早 50 条
let trimmed = messages;
if (trimmed.length > MAX_MESSAGES) {
trimmed = trimmed.slice(TRIM_COUNT);
logger.warn(`消息数量达到上限,已裁剪最早 ${TRIM_COUNT} 条,当前 ${trimmed.length}`);
}
const data = {
messages: trimmed,
updatedAt: Date.now(),
};
localStorage.setItem(storageKey(integrateId), JSON.stringify(data));
}
catch (e) {
if (e instanceof Error && e.name === 'QuotaExceededError') {
logger.error('localStorage 空间不足,会话历史保存失败。建议清空历史记录。');
}
else {
logger.error('保存会话历史失败', e);
}
}
}
/**
* 从 localStorage 加载消息
*/
function loadMessages(integrateId) {
try {
const raw = localStorage.getItem(storageKey(integrateId));
if (!raw) {
return [];
}
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.messages)) {
return [];
}
logger.info(`加载历史消息 integrateId=${integrateId} count=${data.messages.length}`);
return data.messages;
}
catch (e) {
logger.warn('加载会话历史失败', e);
return [];
}
}
/**
* 清空指定 integrateId 的本地缓存
*/
function clearMessages(integrateId) {
try {
localStorage.removeItem(storageKey(integrateId));
}
catch (e) {
logger.warn('清空会话历史失败', e);
}
}
/**
* 轻量级 Markdown 渲染器 - 无外部依赖,XSS 安全
*
* 支持:代码块、行内代码、标题、粗体、斜体、列表、链接、引用、段落
* 策略:先转义 HTML,再转 Markdown 为安全 HTML 标签
*/
/** 代码块占位符前缀 */
const CODE_BLOCK_PREFIX = '\x00CODEBLOCK_';
/** 行内代码占位符前缀 */
const INLINE_CODE_PREFIX = '\x00INLINECODE_';
/**
* 渲染 Markdown 文本为安全 HTML
* @param text Markdown 源文本
* @returns 安全 HTML 字符串
*/
function renderMarkdown(text) {
if (!text || typeof text !== 'string')
return '';
// 1. 提取代码块(防止内部 Markdown 被处理)
const codeBlocks = [];
let processed = text;
// 提取围栏代码块 ```
processed = processed.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const idx = codeBlocks.length;
const escapedCode = escapeHtml(code.trimEnd());
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
codeBlocks.push(`<pre class="csk-md-code-block"><code${langClass}>${escapedCode}</code></pre>`);
return `${CODE_BLOCK_PREFIX}${idx}\x00`;
});
// 2. 提取行内代码
const inlineCodes = [];
processed = processed.replace(/`([^`\n]+)`/g, (_match, code) => {
const idx = inlineCodes.length;
inlineCodes.push(`<code class="csk-md-inline-code">${escapeHtml(code)}</code>`);
return `${INLINE_CODE_PREFIX}${idx}\x00`;
});
// 3. 转义剩余 HTML(代码块和行内代码已安全处理)
processed = escapeHtml(processed);
// 4. 还原代码块和行内代码占位符(它们已经是安全 HTML)
processed = restorePlaceholders(processed, CODE_BLOCK_PREFIX, codeBlocks);
processed = restorePlaceholders(processed, INLINE_CODE_PREFIX, inlineCodes);
// 5. 逐行处理 Markdown 语法
const lines = processed.split('\n');
const result = [];
let inList = false;
let listType = ''; // 'ul' 或 'ol'
let inBlockquote = false;
let paragraphBuffer = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 代码块已在占位符还原阶段处理,直接输出
if (line.includes(CODE_BLOCK_PREFIX) || line.includes('<pre class="csk-md-code-block">')) {
flushParagraph();
closeList();
closeBlockquote();
result.push(line);
continue;
}
// 标题
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
flushParagraph();
closeList();
closeBlockquote();
const level = headingMatch[1].length;
result.push(`<h${level} class="csk-md-h${level}">${headingMatch[2]}</h${level}>`);
continue;
}
// 引用
const quoteMatch = line.match(/^&gt;\s?(.*)/);
if (quoteMatch) {
flushParagraph();
closeList();
if (!inBlockquote) {
inBlockquote = true;
result.push('<blockquote class="csk-md-blockquote">');
}
result.push(`<p>${inlineFormat(quoteMatch[1])}</p>`);
continue;
}
else if (inBlockquote) {
closeBlockquote();
}
// 无序列表
const ulMatch = line.match(/^[\-\*]\s+(.+)/);
if (ulMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ul') {
closeList();
inList = true;
listType = 'ul';
result.push('<ul class="csk-md-ul">');
}
result.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// 有序列表
const olMatch = line.match(/^\d+\.\s+(.+)/);
if (olMatch) {
flushParagraph();
closeBlockquote();
if (!inList || listType !== 'ol') {
closeList();
inList = true;
listType = 'ol';
result.push('<ol class="csk-md-ol">');
}
result.push(`<li>${inlineFormat(olMatch[1])}</li>`);
continue;
}
// 空行 → 段落分隔
if (line.trim() === '') {
flushParagraph();
closeList();
continue;
}
// 水平线
if (/^(\*{3,}|-{3,}|_{3,})$/.test(line.trim())) {
flushParagraph();
closeList();
closeBlockquote();
result.push('<hr class="csk-md-hr">');
continue;
}
// 普通文本 → 收集到段落缓冲
closeList();
closeBlockquote();
paragraphBuffer.push(inlineFormat(line));
}
flushParagraph();
closeList();
closeBlockquote();
return result.join('\n');
// === 辅助函数 ===
/** 行内格式化:粗体、斜体、链接 */
function inlineFormat(text) {
// 粗体 **text** 或 __text__
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体 *text* 或 _text_
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<em>$1</em>');
// 删除线 ~~text~~
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 链接 [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, linkText, url) => {
// 只允许 http/https 链接,防止 javascript: 协议
const safeUrl = /^https?:\/\//i.test(url) ? url : '#';
return `<a class="csk-md-link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
});
return text;
}
/** 将段落缓冲输出为 <p> */
function flushParagraph() {
if (paragraphBuffer.length > 0) {
result.push(`<p class="csk-md-p">${paragraphBuffer.join('<br>')}</p>`);
paragraphBuffer = [];
}
}
/** 关闭列表 */
function closeList() {
if (inList) {
result.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
listType = '';
}
}
/** 关闭引用块 */
function closeBlockquote() {
if (inBlockquote) {
result.push('</blockquote>');
inBlockquote = false;
}
}
}
/** 还原占位符为安全 HTML */
function restorePlaceholders(text, prefix, replacements) {
return text.replace(new RegExp(escapeRegex(prefix) + '(\\d+)\x00', 'g'), (_m, idx) => {
return replacements[parseInt(idx)] || '';
});
}
/** 转义正则特殊字符 */
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let config$1 = null;
let messages = [];
let messagesContainer$1 = null;
let inputEl$1 = null;
let inputWrap = null;
let sendBtn$1 = null;
let clearBtn$1 = null;
let categorySelect$1 = null;
let historyPanel$1 = null;
let welcomeEl = null;
let newMsgBtn = null;
let searchInput = null;
let ariaLiveEl = null;
let showLoadingFn$1 = null;
let hideLoadingFn$1 = null;
let isSending = false;
/** 当前流式请求的中断控制器(用于"停止生成") */
let abortController = null;
/** 用户是否停留在底部附近(用于智能滚动 + 新消息提示) */
let isNearBottom = true;
/** 缓存的历史会话列表(供搜索过滤用) */
let historyItems = [];
/** 当前历史搜索关键字 */
let historySearchText = '';
/** 当前选中的知识库分类 ID */
let currentCategoryId;
/** 当前是否使用 RAG 对话 */
let useRag = false;
/**
* 初始化对话模块
*/
function initChat(cfg, dom) {
config$1 = cfg;
messagesContainer$1 = dom.messagesContainer;
inputEl$1 = dom.inputEl;
inputWrap = dom.inputEl.parentElement;
sendBtn$1 = dom.sendBtn;
clearBtn$1 = dom.clearBtn;
categorySelect$1 = dom.categorySelect;
historyPanel$1 = dom.historyPanel;
welcomeEl = dom.welcomeEl;
newMsgBtn = dom.newMsgBtn;
searchInput = dom.searchInput;
ariaLiveEl = dom.ariaLiveEl;
showLoadingFn$1 = dom.showLoading;
hideLoadingFn$1 = dom.hideLoading;
// 初始化知识库分类
currentCategoryId = cfg.categoryId;
useRag = cfg.enableRag;
// 绑定发送事件
bindSendEvents();
// 绑定滚动监听(智能滚动 + 新消息提示)
bindScrollEvents();
// 绑定历史搜索过滤
bindHistorySearch();
// 加载知识库分类下拉框
if (cfg.showCategorySwitch && categorySelect$1) {
loadCategories();
}
}
/**
* 初始化 chatId 并加载对话历史
* 异步流程:查后端会话 → 恢复 chatId → 加载历史消息
*/
async function initChatHistory() {
if (!config$1 || !messagesContainer$1)
return;
// 1. 初始化 chatId(从后端获取已有会话或自动生成)
await initChatId();
// 2. 尝试从后端加载对话历史
await loadHistoryFromBackend();
// 3. 如果后端无历史,尝试从 localStorage 恢复
if (messages.length === 0) {
const cached = loadMessages(config$1.integrateId);
if (cached.length > 0) {
messages = cached;
renderHistory();
logger.info(`从本地缓存恢复 ${cached.length} 条消息`);
}
}
}
/**
* 从后端加载对话历史
*/
async function loadHistoryFromBackend() {
if (!config$1 || !messagesContainer$1)
return;
const chatId = getChatId();
if (!chatId)
return;
try {
const result = await fetchConversationMessages(chatId);
if (result.messages.length > 0) {
// 将后端消息转换为 ChatMessage 格式
messages = result.messages.map((msg, idx) => ({
id: uuid(),
role: msg.messageType === 'USER' ? 'user' : 'ai',
content: msg.content,
timestamp: new Date(msg.createTime).getTime(),
}));
renderHistory();
logger.info(`从后端加载 ${messages.length} 条历史消息`);
// 同步到 localStorage
saveMessages(config$1.integrateId, messages);
}
}
catch (err) {
logger.warn('从后端加载历史消息失败', err);
}
}
/** 绑定发送相关事件 */
function bindSendEvents() {
if (!inputEl$1 || !sendBtn$1)
return;
// 发送按钮:发送态点击发送,停止态点击中断流式
sendBtn$1.addEventListener('click', () => {
if (isSending && abortController) {
abortController.abort();
return;
}
handleSend();
});
inputEl$1.addEventListener('keydown', (e) => {
// IME 组合输入期间(中文/日文输入法选词回车)不触发发送
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
handleSend();
}
});
inputEl$1.addEventListener('input', () => {
updateSendBtnState();
autoResizeInput();
});
// 输入框聚焦/失焦高亮容器
inputEl$1.addEventListener('focus', () => {
if (inputWrap)
inputWrap.classList.add('csk-input-wrap--focus');
});
inputEl$1.addEventListener('blur', () => {
if (inputWrap)
inputWrap.classList.remove('csk-input-wrap--focus');
});
if (clearBtn$1) {
clearBtn$1.addEventListener('click', () => handleClear());
}
}
/** 绑定滚动监听:判断是否在底部,控制新消息提示按钮 */
function bindScrollEvents() {
if (!messagesContainer$1)
return;
messagesContainer$1.addEventListener('scroll', () => {
if (!messagesContainer$1)
return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer$1;
// 距底部 80px 内视为"在底部"
isNearBottom = scrollHeight - scrollTop - clientHeight < 80;
if (isNearBottom)
hideNewMsgBtn();
});
// 新消息提示按钮点击 → 回到底部
if (newMsgBtn) {
newMsgBtn.addEventListener('click', () => {
if (messagesContainer$1)
scrollToBottom(messagesContainer$1);
isNearBottom = true;
hideNewMsgBtn();
});
}
}
/** 显示新消息提示按钮 */
function showNewMsgBtn() {
if (newMsgBtn)
newMsgBtn.classList.remove('csk-newmsg--hidden');
}
/** 隐藏新消息提示按钮 */
function hideNewMsgBtn() {
if (newMsgBtn)
newMsgBtn.classList.add('csk-newmsg--hidden');
}
/** 智能滚动到底部:用户在底部时自动滚,上滑时显示新消息提示 */
function smartScrollToBottom() {
if (!messagesContainer$1)
return;
if (isNearBottom) {
scrollToBottom(messagesContainer$1);
}
else {
showNewMsgBtn();
}
}
/** 切换发送按钮模式:发送 / 停止生成 */
function setSendButtonMode(mode) {
if (!sendBtn$1)
return;
if (mode === 'stop') {
sendBtn$1.classList.add('csk-send-btn--stop');
sendBtn$1.removeAttribute('disabled');
sendBtn$1.setAttribute('title', t('stop'));
sendBtn$1.setAttribute('aria-label', t('stop'));
sendBtn$1.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
}
else {
sendBtn$1.classList.remove('csk-send-btn--stop');
sendBtn$1.setAttribute('title', t('send'));
sendBtn$1.setAttribute('aria-label', t('send'));
sendBtn$1.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
updateSendBtnState();
}
}
// ==================== 历史会话搜索过滤 ====================
/** 绑定历史搜索输入事件 */
function bindHistorySearch() {
if (!searchInput)
return;
searchInput.addEventListener('input', () => {
historySearchText = searchInput.value.trim().toLowerCase();
renderFilteredHistory();
});
}
/** 按搜索关键字过滤并重新渲染历史列表 */
function renderFilteredHistory() {
const listEl = historyPanel$1 === null || historyPanel$1 === void 0 ? void 0 : historyPanel$1.querySelector('#csk-history-list');
if (!listEl || !config$1)
return;
const filtered = historySearchText
? historyItems.filter(item => {
const preview = (item.lastMessagePreview || item.chatId || item.id || '').toLowerCase();
return preview.includes(historySearchText);
})
: historyItems;
renderHistoryList(listEl, filtered, (conversationId) => { switchToConversation(conversationId); }, (id) => { window.open(getConversationExportUrl(id), '_blank'); }, async (id) => {
if (!confirm(t('history_delete_confirm')))
return;
const ok = await deleteConversation(id);
if (ok) {
if (id === getChatId()) {
messages = [];
if (messagesContainer$1) {
messagesContainer$1.querySelectorAll('.csk-msg, .csk-loading').forEach(el => el.remove());
}
if (clearBtn$1)
clearBtn$1.style.display = 'none';
updateEmptyState();
}
// 从缓存中移除并重新渲染
historyItems = historyItems.filter(it => (it.chatId || it.id) !== id);
renderFilteredHistory();
}
}, getChatId());
}
// ==================== a11y 消息播报 ====================
/** 通过 aria-live 区域播报新消息(供屏幕阅读器使用) */
function announceMessage(text) {
if (!ariaLiveEl)
return;
// 截取前 120 字符,避免过长播报
const snippet = text.length > 120 ? text.substring(0, 120) + '...' : text;
ariaLiveEl.textContent = t('new_msg_announce') + ':' + snippet;
}
// ==================== 消息反馈(👍 / 👎) ====================
/**
* 处理消息反馈:切换 AI 消息的点赞/点踩状态
* 前端状态持久化(存入 messages 数组 + localStorage)+ 调用后端 API 记录反馈
*/
function handleFeedback(msgId, value) {
if (!messagesContainer$1)
return;
const msg = messages.find(m => m.id === msgId && m.role === 'ai');
if (!msg)
return;
// 切换逻辑:同值取消,异值切换
const newValue = msg.feedback === value ? undefined : value;
msg.feedback = newValue;
// 更新 DOM 状态
const wrapper = messagesContainer$1.querySelector(`[data-csk-msg-id="${msgId}"]`);
if (wrapper)
updateFeedbackUI(wrapper, newValue);
// 持久化(localStorage)
if (config$1)
saveMessages(config$1.integrateId, messages);
// 调用后端 API 记录反馈
if (newValue) {
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
submitFeedbackApi(String(msgId), feedbackType).then(success => {
if (success) {
logger.info(`消息反馈已提交 msgId=${msgId} value=${newValue}`);
}
else {
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
}
});
}
else {
logger.info(`消息反馈已取消 msgId=${msgId}`);
}
}
function autoResizeInput() {
if (!inputEl$1)
return;
inputEl$1.style.height = 'auto';
inputEl$1.style.height = `${Math.min(inputEl$1.scrollHeight, 120)}px`;
}
/** 根据消息数量切换欢迎空状态显隐 */
function updateEmptyState() {
if (!welcomeEl)
return;
const hasMessages = messages.length > 0 ||
(messagesContainer$1 && messagesContainer$1.querySelector('.csk-msg'));
welcomeEl.style.display = hasMessages ? 'none' : '';
}
/** 更新发送按钮状态 */
function updateSendBtnState() {
if (!sendBtn$1 || !inputEl$1)
return;
const hasText = inputEl$1.value.trim().length > 0;
if (hasText && !isSending) {
sendBtn$1.removeAttribute('disabled');
}
else {
sendBtn$1.setAttribute('disabled', 'true');
}
}
/** 处理发送消息 */
async function handleSend() {
if (!inputEl$1 || !config$1 || isSending)
return;
const text = inputEl$1.value.trim();
if (text === '')
return;
inputEl$1.value = '';
updateSendBtnState();
inputEl$1.style.height = 'auto';
// 1. 渲染用户气泡
const userTimestamp = now();
const userMsg = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp };
if (messagesContainer$1)
renderUserBubble(messagesContainer$1, text, userTimestamp);
messages.push(userMsg);
updateEmptyState();
if (clearBtn$1 && messages.length > 0)
clearBtn$1.style.display = 'inline-flex';
if (messagesContainer$1)
smartScrollToBottom();
// 2. 生成 AI 回复
await produceAIReply(text);
}
/**
* 生成 AI 回复(发送请求 + 渲染气泡 + 持久化)
* 复用于:正常发送、重试。调用方负责先渲染用户气泡。
*/
async function produceAIReply(userText) {
if (!config$1 || !messagesContainer$1)
return;
isSending = true;
setSendButtonMode('stop');
// 确保 chatId 已初始化
if (!config$1.chatId) {
await initChatId();
}
const aiTimestamp = now();
// RAG 启用条件:由 enableRag 控制
const shouldUseRag = useRag;
// 显示 loading
if (showLoadingFn$1)
showLoadingFn$1();
if (messagesContainer$1)
smartScrollToBottom();
const aiMsgId = uuid();
let aiContent = '';
try {
if (config$1.streaming) {
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId);
}
else {
aiContent = await chatRequest(userText);
if (hideLoadingFn$1)
hideLoadingFn$1();
if (messagesContainer$1) {
renderAIBubble(messagesContainer$1, aiContent, aiTimestamp, renderMarkdown, aiMsgId);
}
}
const aiMsg = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp };
messages.push(aiMsg);
saveMessages(config$1.integrateId, messages);
if (messagesContainer$1)
smartScrollToBottom();
// a11y 播报新 AI 消息
announceMessage(aiContent);
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
if (messagesContainer$1) {
messagesContainer$1.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } }));
}
// RAG 引用来源
if (shouldUseRag)
fetchAndRenderSources(userText, aiMsg);
}
catch (err) {
if (hideLoadingFn$1)
hideLoadingFn$1();
const errMsg = err instanceof CskError ? err.message : t('error_send');
if (messagesContainer$1) {
const errorBubble = document.createElement('div');
errorBubble.className = 'csk-msg csk-msg--ai';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.style.color = '#DC2626';
bubble.textContent = `${errMsg}`;
errorBubble.appendChild(bubble);
messagesContainer$1.appendChild(errorBubble);
}
logger.error(`发送失败 integrateId=${config$1.integrateId}`, err);
}
finally {
isSending = false;
abortController = null;
setSendButtonMode('send');
updateSendBtnState();
}
}
/** 流式发送消息 */
async function sendStreamMessage(text, aiTimestamp, shouldUseRag, aiMsgId) {
// 创建中断控制器,供"停止生成"使用
abortController = new AbortController();
const signal = abortController.signal;
return new Promise((resolve, reject) => {
let bubbleEl = null;
let wrapperEl = null;
let accumulated = '';
let streamStarted = false;
chatSSERequest(text, (chunk) => {
accumulated += chunk;
if (!streamStarted && messagesContainer$1) {
if (hideLoadingFn$1)
hideLoadingFn$1();
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer$1, aiTimestamp, aiMsgId);
wrapperEl = wrapper;
bubbleEl = bubble;
streamStarted = true;
}
if (bubbleEl) {
bubbleEl.textContent = accumulated;
appendStreamingCaret(bubbleEl);
}
if (messagesContainer$1)
smartScrollToBottom();
}, () => {
// 流结束
if (wrapperEl && bubbleEl) {
// 无流内容降级为同步请求
if (!streamStarted && accumulated === '') {
chatRequest(text).then(resolve).catch(reject);
return;
}
if (accumulated)
bubbleEl.innerHTML = renderMarkdown(accumulated);
finalizeAIBubble(wrapperEl, bubbleEl);
}
resolve(accumulated);
}, (error) => {
if (accumulated.length > 0) {
if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));
if (wrapperEl)
finalizeAIBubble(wrapperEl, bubbleEl);
}
resolve(accumulated);
}
else {
reject(error);
}
}, currentCategoryId, shouldUseRag, signal);
});
}
/**
* 快捷问题发送:欢迎态点击芯片,直接以该文本发送
*/
function sendQuickReply(text) {
if (!inputEl$1 || isSending)
return Promise.resolve();
inputEl$1.value = text;
updateSendBtnState();
return handleSend();
}
/**
* 重试:根据 AI 消息 ID 重新生成该回复
* 找到该 AI 消息及其上一条用户消息,移除该 AI 消息(及之后的所有消息)后重新请求
*/
async function retryFromMessage(msgId) {
if (!config$1 || !messagesContainer$1 || isSending)
return;
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai');
if (aiIndex < 0)
return;
// 找到上一条用户消息文本
let userText = '';
for (let i = aiIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
userText = messages[i].content;
break;
}
}
if (!userText)
return;
// 移除该 AI 消息及其后所有消息(本地 + DOM)
const removed = messages.splice(aiIndex);
const removedIds = new Set(removed.map(m => m.id));
const nodes = messagesContainer$1.querySelectorAll('.csk-msg');
nodes.forEach(node => {
const id = node.dataset.cskMsgId;
// 无 id 的(如错误气泡、被中断的)按顺序兜底:此处仅按 id 精确移除
if (id && removedIds.has(id))
node.remove();
});
saveMessages(config$1.integrateId, messages);
updateEmptyState();
// 重新生成(不重复渲染用户气泡)
await produceAIReply(userText);
}
/** 获取并渲染 RAG 引用来源 */
async function fetchAndRenderSources(message, aiMsg) {
try {
const sources = await fetchRagSources(message, currentCategoryId);
if (sources.length > 0) {
const ragSources = sources.map(s => {
var _a, _b;
return ({
documentId: s.documentId || '',
title: s.title || '',
sourceName: s.sourceName || '',
chunkIndex: (_a = s.chunkIndex) !== null && _a !== void 0 ? _a : 0,
score: (_b = s.score) !== null && _b !== void 0 ? _b : 0,
snippet: s.snippet || '',
});
});
aiMsg.sources = ragSources;
if (messagesContainer$1) {
const lastAiMsg = messagesContainer$1.querySelector('.csk-msg--ai:last-of-type');
if (lastAiMsg)
renderSources(lastAiMsg, ragSources);
}
if (config$1)
saveMessages(config$1.integrateId, messages);
}
}
catch (err) {
logger.warn('获取引用来源失败', err);
}
}
/** 加载知识库分类到下拉框 */
async function loadCategories() {
if (!categorySelect$1)
return;
try {
const tree = await fetchCategoryTree();
if (tree.length === 0)
return;
categorySelect$1.innerHTML = `<option value="">${t('category_all')}</option>`;
const addOptions = (nodes, indent = 0) => {
for (const node of nodes) {
const option = document.createElement('option');
option.value = String(node.id);
option.textContent = `${' '.repeat(indent)}${node.name}`;
if (currentCategoryId !== undefined && String(node.id) === String(currentCategoryId))
option.selected = true;
categorySelect$1.appendChild(option);
if (node.children && node.children.length > 0)
addOptions(node.children, indent + 1);
}
};
addOptions(tree);
logger.info(`知识库分类加载成功 count=${tree.length}`);
}
catch (err) {
logger.error(t('category_load_error'), err);
}
}
/** 渲染历史消息 */
function renderHistory() {
if (!messagesContainer$1)
return;
const historyPanelEl = messagesContainer$1.querySelector('.csk-history-panel');
const msgs = messagesContainer$1.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
for (const msg of messages) {
if (msg.role === 'user') {
renderUserBubble(messagesContainer$1, msg.content, msg.timestamp);
}
else {
const wrapper = renderAIBubble(messagesContainer$1, msg.content, msg.timestamp, renderMarkdown, msg.id);
if (msg.sources && msg.sources.length > 0)
renderSources(wrapper, msg.sources);
if (msg.feedback)
updateFeedbackUI(wrapper, msg.feedback);
}
}
isNearBottom = true;
scrollToBottom(messagesContainer$1);
if (clearBtn$1 && messages.length > 0)
clearBtn$1.style.display = 'inline-flex';
updateEmptyState();
if (historyPanelEl && !messagesContainer$1.contains(historyPanelEl)) {
messagesContainer$1.appendChild(historyPanelEl);
}
}
/** 清空对话历史(生成新 chatId) */
function handleClear() {
if (!config$1)
return;
if (!confirm(t('clear_confirm')))
return;
messages = [];
if (messagesContainer$1) {
const msgs = messagesContainer$1.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
}
if (clearBtn$1)
clearBtn$1.style.display = 'none';
updateEmptyState();
clearMessages(config$1.integrateId);
// 生成新的 chatId,开始新会话
const newId = generateNewChatId();
updateChatId(newId);
saveCachedChatId(config$1.integrateId, config$1.userId, newId);
logger.lifecycleClear(config$1.integrateId);
logger.info(`新 chatId=${newId}`);
}
/** 生成新 chatId */
function generateNewChatId() {
const random = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID().substring(0, 8)
: Math.random().toString(36).substring(2, 10);
return `sdk_${Date.now()}_${random}`;
}
/** 设置当前知识库分类 */
function setCategory(categoryId) {
var _a;
currentCategoryId = categoryId;
useRag = (_a = config$1 === null || config$1 === void 0 ? void 0 : config$1.enableRag) !== null && _a !== void 0 ? _a : true;
logger.lifecycleCategoryChange(categoryId !== null && categoryId !== void 0 ? categoryId : '全部');
}
// ==================== 会话管理面板 ====================
/** 加载会话列表并渲染 */
async function loadHistoryConversations() {
if (!historyPanel$1 || !config$1)
return;
const listEl = historyPanel$1.querySelector('#csk-history-list');
if (!listEl)
return;
listEl.innerHTML = `<div class="csk-history-panel__loading">加载中...</div>`;
try {
const result = await fetchConversationList(1, 50, config$1.userId, config$1.integrateId);
historyItems = result.list.map(c => ({
id: c.conversationId || '',
chatId: c.conversationId || '',
messageCount: c.messageCount,
lastMessageTime: c.lastMessageTime,
lastMessagePreview: c.lastMessagePreview,
createdAt: c.firstMessageTime || c.createdAt,
}));
// 重置搜索(新数据到达时清空过滤)
historySearchText = '';
if (searchInput)
searchInput.value = '';
renderHistoryList(listEl, historyItems, (conversationId) => { switchToConversation(conversationId); }, (id) => { window.open(getConversationExportUrl(id), '_blank'); }, async (id) => {
if (!confirm(t('history_delete_confirm')))
return;
const ok = await deleteConversation(id);
if (ok) {
if (id === getChatId()) {
messages = [];
if (messagesContainer$1) {
messagesContainer$1.querySelectorAll('.csk-msg, .csk-loading').forEach(el => el.remove());
}
if (clearBtn$1)
clearBtn$1.style.display = 'none';
updateEmptyState();
}
// 从缓存中移除并重新渲染(不再重新请求后端)
historyItems = historyItems.filter(it => (it.chatId || it.id) !== id);
renderFilteredHistory();
}
}, getChatId());
}
catch (err) {
logger.error(t('history_load_error'), err);
listEl.innerHTML = `<div class="csk-history-panel__empty"><div class="csk-history-panel__empty-icon">⚠</div><div>${t('history_load_error')}</div></div>`;
}
}
/**
* 切换到指定会话:加载上下文并继续对话
* @param conversationId 会话 ID(即 chatId)
*/
async function switchToConversation(conversationId) {
if (!config$1 || !messagesContainer$1)
return;
logger.info(`切换到会话 conversationId=${conversationId}`);
// 1. 更新 chatId
updateChatId(conversationId);
saveCachedChatId(config$1.integrateId, config$1.userId, conversationId);
// 2. 关闭历史面板
if (historyPanel$1) {
historyPanel$1.classList.add('csk-history-panel--hidden');
}
// 3. 清空当前消息
messages = [];
const msgs = messagesContainer$1.querySelectorAll('.csk-msg, .csk-loading');
msgs.forEach(el => el.remove());
// 4. 从后端加载该会话的消息
try {
const result = await fetchConversationMessages(conversationId);
if (result.messages.length > 0) {
messages = result.messages.map((msg) => ({
id: uuid(),
role: msg.messageType === 'USER' ? 'user' : 'ai',
content: msg.content,
timestamp: new Date(msg.createTime).getTime(),
}));
renderHistory();
logger.info(`加载会话 ${conversationId}${messages.length} 条消息`);
// 同步到 localStorage
saveMessages(config$1.integrateId, messages);
}
}
catch (err) {
logger.warn(`加载会话消息失败 conversationId=${conversationId}`, err);
}
// 5. 显示清空按钮
if (clearBtn$1 && messages.length > 0) {
clearBtn$1.style.display = 'inline-flex';
}
updateEmptyState();
}
// ==================== 单例状态 ====================
let config = null;
let isInitialized = false;
let launcherEl = null;
let windowEl = null;
let messagesContainer = null;
let inputEl = null;
let sendBtn = null;
let clearBtn = null;
let categorySelect = null;
let historyPanel = null;
let showLoadingFn = null;
let hideLoadingFn = null;
let dragCleanup = null;
let launcherDragCleanup = null;
let resizeCleanup = null;
/** Launcher 位置记忆状态(拖拽后更新) */
let launcherPos = null;
/** 提示气泡元素 */
let teaserEl = null;
/** 提示气泡定时器 */
let teaserTimer = null;
/** 未读徽章元素(挂在 launcher 内部) */
let badgeEl = null;
/** 音频上下文(提示音用) */
let audioCtx = null;
// ==================== 公开 API ====================
/** 初始化 SDK */
function init(rawConfig) {
if (isInitialized) {
logger.warn('SDK 已初始化,请先调用 destroy() 再重新初始化');
return;
}
// 1. 配置解析与校验
const parsed = parseConfig(rawConfig);
if (!parsed)
return;
config = parsed;
// 2. 设置国际化语言
setLocale(config.locale);
// 3. 设置日志级别
setDebug(config.debug);
// 3.1 设置错误回调
setErrorCallback(config.onError);
// 4. 设置 API 配置
setApiConfig(config);
// 5. 注入样式
injectStyles(config);
// 6. 创建悬浮按钮
launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl);
// 获取未读徽章引用(由 createLauncher 创建)
badgeEl = launcherEl.querySelector('.csk-launcher__badge');
// 6.1 恢复 Launcher 位置(必须在挂载到 DOM 之后、拖拽绑定之前)
restoreLauncherPosition(config.integrateId, launcherEl);
// 6.2 启用 Launcher 拖拽(边缘吸附式,支持触摸 + 鼠标)
launcherDragCleanup = enableLauncherDrag(launcherEl, config, (pos) => {
launcherPos = pos;
saveLauncherPosition(config.integrateId, pos);
// 同步聊天弹窗对齐侧(若弹窗已被用户拖拽过,清除内联定位让 CSS 类接管)
resetWindowPosition();
syncWindowAlignment(pos.side);
// 重新定位 teaser 气泡(若正在显示)
if (teaserEl && !teaserEl.classList.contains('csk-teaser--hidden')) {
positionTeaser();
}
});
// 6.3 恢复弹窗对齐侧(与 Launcher 同侧)
if (launcherPos) {
syncWindowAlignment(launcherPos.side);
}
// 7. 创建聊天弹窗
const dom = createChatWindow(config);
windowEl = dom.window;
messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel;
showLoadingFn = dom.showLoading;
hideLoadingFn = dom.hideLoading;
document.body.appendChild(windowEl);
// 8. 安置提示气泡(挂在 launcher 同层,由配置控制显示)
teaserEl = dom.teaserEl;
document.body.appendChild(teaserEl);
positionTeaser();
// 9. 启用拖拽(含位置持久化)
const headerEl = windowEl.querySelector('.csk-header');
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl, (pos) => {
saveWindowPosition(config.integrateId, pos);
});
}
// 9.1 恢复上次拖拽位置
restoreWindowPosition(config.integrateId, windowEl);
// 9.2 启用缩放(resizable 配置控制)
if (config.resizable) {
const resizeHandle = dom.resizeHandle;
resizeCleanup = enableResize(resizeHandle, windowEl, (size) => {
saveWindowSize(config.integrateId, size);
});
// 恢复上次缩放的尺寸
restoreWindowSize(config.integrateId, windowEl);
}
// 10. 初始化对话模块
initChat(config, {
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
welcomeEl: dom.welcomeEl,
newMsgBtn: dom.newMsgBtn,
searchInput: dom.searchInput,
ariaLiveEl: dom.ariaLiveEl,
showLoading: showLoadingFn,
hideLoading: hideLoadingFn,
});
// 11. 监听知识库分类切换事件
windowEl.addEventListener('csk:categoryChange', ((e) => {
setCategory(e.detail.categoryId);
}));
// 12. 监听会话管理面板加载事件
windowEl.addEventListener('csk:loadHistory', () => {
loadHistoryConversations();
});
// 13. 监听快捷问题芯片点击
windowEl.addEventListener('csk:quickReply', ((e) => {
sendQuickReply(e.detail.text);
}));
// 14. 监听 AI 消息重试事件
windowEl.addEventListener('csk:retry', ((e) => {
retryFromMessage(e.detail.msgId);
}));
// 15. 监听消息反馈事件(👍 / 👎)
windowEl.addEventListener('csk:feedback', ((e) => {
handleFeedback(e.detail.msgId, e.detail.value);
}));
// 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(() => {
// 弹窗关闭时才显示
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showTeaser();
}
}, 1500);
}
// 19. 异步初始化 chatId 和对话历史(不阻塞 UI)
initChatHistory().catch(err => {
logger.warn('chatId 初始化失败,将在发送消息时重试', err);
});
}
// ==================== 提示气泡控制 ====================
/** 根据 launcher 位置定位 teaser 气泡 */
function positionTeaser() {
if (!teaserEl || !launcherEl)
return;
// launcher 被拖拽过则用实际位置,否则用 DOM 实时计算
const rect = launcherEl.getBoundingClientRect();
const isLeft = launcherPos ? launcherPos.side === 'left' : (config === null || config === void 0 ? void 0 : config.position) === 'left-bottom';
if (isLeft) {
teaserEl.style.left = `${rect.left}px`;
teaserEl.style.right = 'auto';
}
else {
teaserEl.style.right = `${window.innerWidth - rect.right}px`;
teaserEl.style.left = 'auto';
}
teaserEl.style.bottom = `${window.innerHeight - rect.top + 8}px`;
// 同步 teaser 类名(箭头方向)
teaserEl.classList.toggle('csk-teaser--left', !!isLeft);
teaserEl.classList.toggle('csk-teaser--right', !isLeft);
}
function showTeaser() {
if (!teaserEl)
return;
positionTeaser();
teaserEl.classList.remove('csk-teaser--hidden');
// 点击气泡本身 → 打开弹窗
teaserEl.addEventListener('click', onTeaserClick);
// 关闭按钮(阻止冒泡,不打开弹窗)
const closeBtn = teaserEl.querySelector('.csk-teaser__close');
if (closeBtn) {
closeBtn.addEventListener('click', onTeaserClose);
}
}
function hideTeaser() {
if (!teaserEl)
return;
teaserEl.classList.add('csk-teaser--hidden');
teaserEl.removeEventListener('click', onTeaserClick);
const closeBtn = teaserEl.querySelector('.csk-teaser__close');
if (closeBtn)
closeBtn.removeEventListener('click', onTeaserClose);
}
function onTeaserClick(e) {
// 关闭按钮点击不打开弹窗
if (e.target.closest('.csk-teaser__close'))
return;
hideTeaser();
open();
}
function onTeaserClose(e) {
e.stopPropagation();
hideTeaser();
}
function onResize() {
if (teaserEl && !teaserEl.classList.contains('csk-teaser--hidden')) {
positionTeaser();
}
}
// ==================== 未读徽章 ====================
function showBadge() {
if (badgeEl)
badgeEl.classList.remove('csk-launcher__badge--hidden');
}
function hideBadge() {
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 焦点陷阱 ====================
/** 弹窗内可聚焦元素选择器 */
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function trapFocus(e) {
if (!windowEl || windowEl.classList.contains('csk-window--hidden'))
return;
if (e.key !== 'Tab')
return;
const focusables = Array.from(windowEl.querySelectorAll(FOCUSABLE));
if (focusables.length === 0)
return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
// Shift+Tab:在第一个元素上按 → 跳到最后一个
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
}
else {
// Tab:在最后一个元素上按 → 跳到第一个
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// ==================== 全局键盘事件 ====================
function onKeyDown(e) {
if (e.key === 'Escape' && windowEl && !windowEl.classList.contains('csk-window--hidden')) {
close();
}
trapFocus(e);
}
/** 销毁 SDK 实例 */
function destroy() {
if (!isInitialized)
return;
if (teaserTimer) {
clearTimeout(teaserTimer);
teaserTimer = null;
}
hideTeaser();
if (teaserEl && teaserEl.parentNode) {
teaserEl.parentNode.removeChild(teaserEl);
teaserEl = null;
}
if (launcherEl && launcherEl.parentNode) {
launcherEl.parentNode.removeChild(launcherEl);
launcherEl = null;
}
if (windowEl && windowEl.parentNode) {
windowEl.parentNode.removeChild(windowEl);
windowEl = null;
}
if (dragCleanup) {
dragCleanup();
dragCleanup = null;
}
if (launcherDragCleanup) {
launcherDragCleanup();
launcherDragCleanup = null;
}
if (resizeCleanup) {
resizeCleanup();
resizeCleanup = null;
}
launcherPos = null;
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;
messagesContainer = null;
inputEl = null;
sendBtn = null;
clearBtn = null;
categorySelect = null;
historyPanel = null;
showLoadingFn = null;
hideLoadingFn = null;
badgeEl = null;
audioCtx = null;
clearApiConfig();
logger.lifecycleDestroy(oldIntegrateId || '');
}
function open() {
if (!windowEl)
return;
windowEl.classList.remove('csk-window--hidden');
hideTeaser();
hideBadge();
setTimeout(() => { if (inputEl)
inputEl.focus(); }, 80);
}
function close() {
if (!windowEl)
return;
windowEl.classList.add('csk-window--hidden');
}
function toggle() {
if (!windowEl)
return;
if (windowEl.classList.contains('csk-window--hidden')) {
open();
}
else {
close();
}
}
function clearHistory() {
if (!config)
return;
if (clearBtn) {
clearBtn.click();
}
else if (confirm('确定清空所有对话记录?')) {
clearMessages(config.integrateId);
}
}
// ==================== 窗口尺寸记忆 ====================
/** localStorage key:窗口尺寸 */
function sizeKey(integrateId) {
return `csk_size_${integrateId}`;
}
/** 保存窗口缩放的尺寸到 localStorage */
function saveWindowSize(integrateId, size) {
try {
localStorage.setItem(sizeKey(integrateId), JSON.stringify(size));
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
/** 恢复窗口缩放尺寸 */
function restoreWindowSize(integrateId, winEl) {
try {
const raw = localStorage.getItem(sizeKey(integrateId));
if (!raw)
return;
const size = JSON.parse(raw);
if (typeof size.width !== 'number' || typeof size.height !== 'number')
return;
const w = Math.max(300, Math.min(size.width, window.innerWidth - 24));
const h = Math.max(300, Math.min(size.height, window.innerHeight - 24));
winEl.style.width = `${w}px`;
winEl.style.height = `${h}px`;
winEl.style.setProperty('--csk-window-width', `${w}px`);
winEl.style.setProperty('--csk-window-height', `${h}px`);
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
// ==================== 窗口位置记忆 ====================
/** 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) { /* 忽略 */ }
}
// ==================== Launcher 位置记忆 ====================
/** localStorage key:Launcher 位置 */
function launcherPosKey(integrateId) {
return `csk_launcher_pos_${integrateId}`;
}
/** 保存 Launcher 拖拽位置到 localStorage */
function saveLauncherPosition(integrateId, pos) {
try {
localStorage.setItem(launcherPosKey(integrateId), JSON.stringify(pos));
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
/** 恢复 Launcher 位置(初始化时调用) */
function restoreLauncherPosition(integrateId, el) {
try {
const raw = localStorage.getItem(launcherPosKey(integrateId));
if (!raw)
return;
const pos = JSON.parse(raw);
if ((pos.side !== 'left' && pos.side !== 'right') || typeof pos.bottom !== 'number')
return;
const maxBottom = window.innerHeight - 76;
const clampedBottom = Math.max(16, Math.min(pos.bottom, maxBottom));
// 覆盖默认对齐方向
el.classList.remove('csk-launcher--right', 'csk-launcher--left');
el.classList.add(`csk-launcher--${pos.side}`);
el.style.bottom = `${clampedBottom}px`;
// 保存到模块状态,供后续 positionTeaser / 弹窗对齐使用
launcherPos = { side: pos.side, bottom: clampedBottom };
}
catch ( /* 忽略 */_a) { /* 忽略 */ }
}
/** 清除弹窗的内联定位样式,使其回到 CSS 类控制的默认位置 */
function resetWindowPosition() {
if (!windowEl)
return;
windowEl.style.left = '';
windowEl.style.top = '';
windowEl.style.right = '';
windowEl.style.bottom = '';
}
/** 同步聊天弹窗对齐侧(Launcher 拖拽到左/右侧时跟随) */
function syncWindowAlignment(side) {
if (!windowEl)
return;
windowEl.classList.remove('csk-window--right', 'csk-window--left');
windowEl.classList.add(`csk-window--${side}`);
// 同步 teaser 箭头方向
if (teaserEl) {
teaserEl.classList.remove('csk-teaser--right', 'csk-teaser--left');
teaserEl.classList.add(`csk-teaser--${side}`);
}
}
// ==================== 提示音 + 桌面通知 ====================
/** 播放短促提示音(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,
destroy,
open,
close,
toggle,
clearHistory,
};
if (typeof window !== 'undefined') {
window.ChatbotSDK = ChatbotSDK;
}
return ChatbotSDK;
})();
//# sourceMappingURL=chatbot-sdk.js.map