本地 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.
 
 
 
 
 

2764 lines
98 KiB

var ChatbotSDK = (function () {
'use strict';
const PREFIX = '[ChatbotSDK]';
let debugEnabled = true;
/** 设置是否开启调试日志 */
function setDebug(enabled) {
debugEnabled = enabled;
}
/** 性能计时器 */
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 开关控制) */
error(msg, data) {
console.error(PREFIX, msg, data !== undefined ? data : '');
},
/** 开始计时 */
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}`);
},
};
/** 默认悬浮按钮 SVG 图标(客服对话气泡) */
const DEFAULT_LAUNCHER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M8 9h8"/><path d="M8 13h6"/>
</svg>`;
/**
* 解析并校验用户传入的配置,填充默认值
*/
function parseConfig(raw) {
var _a, _b, _c, _d, _e, _f;
// 校验必传参数: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 (_g) {
logger.error(`requestDomain 不是合法的 URL 格式:${raw.requestDomain}。请提供完整的域名,如 https://api.example.com`);
return null;
}
// integrateId 统一转为字符串(后端 roleId 为 Long,但 query param 传字符串也可接收)
const integrateIdStr = String(raw.integrateId).trim();
// 填充默认值
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,
position: raw.position === 'left-bottom' ? 'left-bottom' : 'right-bottom',
primaryColor: raw.primaryColor || '#4F46E5',
launcherIcon: raw.launcherIcon || DEFAULT_LAUNCHER_ICON,
showClear: (_c = raw.showClear) !== null && _c !== void 0 ? _c : true,
showAdminPanel: (_d = raw.showAdminPanel) !== null && _d !== void 0 ? _d : false,
streaming: (_e = raw.streaming) !== null && _e !== void 0 ? _e : true,
locale: raw.locale || 'zh-CN',
debug: (_f = raw.debug) !== null && _f !== void 0 ? _f : true,
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: '关闭',
// 输入区
placeholder: '输入您的问题...',
send: '发送',
// 消息
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: '删除',
// 错误提示
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',
// Input
placeholder: 'Type your question...',
send: 'Send',
// 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',
// 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;
}
/** 更新当前 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', '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', '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 封装 */
async function safeFetch(url, options = {}, timeout = REQUEST_TIMEOUT) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, Object.assign(Object.assign({}, options), { signal: controller.signal, mode: 'cors', credentials: 'include' }));
return response;
}
catch (err) {
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) {
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);
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 (totalText.length > 0) {
onChunk('\n\n' + t('stream_unstable'));
}
else {
throw readErr;
}
}
finally {
reader.releaseLock();
}
logger.lifecycleStreamDone(currentConfig.integrateId, totalText.length);
onDone();
}
catch (err) {
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 [];
}
}
/**
* 获取会话列表
*/
async function fetchConversationList(page = 1, size = 20, accountId, roleId) {
let path = `/conversation/list?page=${page}&size=${size}`;
if (accountId)
path += `&accountId=${encodeURIComponent(accountId)}`;
if (roleId)
path += `&roleId=${encodeURIComponent(roleId)}`;
const url = buildUrl(path);
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 };
}
}
/**
* 获取会话消息
*/
async function fetchConversationMessages(conversationId) {
const url = buildUrl(`/conversation/${conversationId}/messages`);
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 };
}
}
/**
* 删除会话
*/
async function deleteConversation(conversationId) {
const url = buildUrl(`/conversation/${conversationId}`);
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
*/
function getConversationExportUrl(conversationId) {
return buildUrl(`/conversation/${conversationId}/export`);
}
// ==================== chatId 自动初始化 ====================
/**
* 初始化 chatId:查询后端已有会话,找到则复用,否则生成新的
*
* 逻辑:
* 1. 先查 localStorage 缓存的 chatId(同一 integrateId + userId 可能复用)
* 2. 查 /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;
/** CSS 变量:将配置中的主题色转换为 CSS 自定义属性 */
function cssVars(config) {
// 简单的主色调加深(hover 用)
const darker = adjustColor(config.primaryColor, -15);
return `
--csk-primary: ${config.primaryColor};
--csk-primary-hover: ${darker};
--csk-bg-user: var(--csk-primary);
--csk-bg-ai: #F3F4F6;
--csk-text-user: #FFFFFF;
--csk-text-ai: #1F2937;
--csk-window-width: ${config.width}px;
`;
}
/** 简单的颜色加深(HSL 方式) */
function adjustColor(hex, amount) {
// 如果是 hex 格式,简单地对每个通道加减
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", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1F2937;
}
/* ========== 悬浮按钮 ========== */
.csk-launcher {
position: fixed;
bottom: 20px;
z-index: 9998;
width: 56px;
height: 56px;
border-radius: 50%;
background: #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
border: none;
color: var(--csk-primary);
user-select: none;
}
.csk-launcher--right {
right: 20px;
}
.csk-launcher--left {
left: 20px;
}
.csk-launcher:hover {
transform: scale(1.1);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
}
.csk-launcher:active {
transform: scale(0.95);
}
/* ========== 聊天弹窗 ========== */
.csk-window {
position: fixed;
bottom: 20px;
z-index: 9999;
width: var(--csk-window-width);
height: 560px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.csk-window--right {
right: 20px;
}
.csk-window--left {
left: 20px;
}
.csk-window--hidden {
display: none;
}
/* ========== 头部 ========== */
.csk-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
min-height: 48px;
background: var(--csk-primary);
color: #fff;
border-radius: 12px 12px 0 0;
cursor: move;
user-select: none;
}
.csk-header__title {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.csk-header__actions {
display: flex;
align-items: center;
gap: 4px;
}
.csk-header__btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #fff;
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.csk-header__btn:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ========== 消息区 ========== */
.csk-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: #FAFAFA;
scroll-behavior: smooth;
}
.csk-messages::-webkit-scrollbar {
width: 5px;
}
.csk-messages::-webkit-scrollbar-track {
background: transparent;
}
.csk-messages::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 3px;
}
/* 消息气泡 */
.csk-msg {
display: flex;
flex-direction: column;
margin-bottom: 16px;
max-width: 85%;
word-break: break-word;
}
.csk-msg--user {
margin-left: auto;
align-items: flex-end;
}
.csk-msg--ai {
margin-right: auto;
align-items: flex-start;
}
.csk-msg__bubble {
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
}
.csk-msg--user .csk-msg__bubble {
background: var(--csk-bg-user);
color: var(--csk-text-user);
border-radius: 12px 12px 4px 12px;
}
.csk-msg--ai .csk-msg__bubble {
background: var(--csk-bg-ai);
color: var(--csk-text-ai);
border-radius: 12px 12px 12px 4px;
}
.csk-msg__time {
font-size: 11px;
color: #9CA3AF;
margin-top: 4px;
padding: 0 4px;
}
/* ========== Loading 动画 ========== */
.csk-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
}
.csk-loading__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #D1D5DB;
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); }
40% { transform: scale(1); }
}
/* ========== 输入区 ========== */
.csk-input-area {
display: flex;
align-items: center;
padding: 10px 12px;
border-top: 1px solid #E5E7EB;
background: #fff;
gap: 8px;
}
.csk-input {
flex: 1;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
resize: none;
min-height: 20px;
max-height: 100px;
}
.csk-input:focus {
border-color: var(--csk-primary);
}
.csk-input::placeholder {
color: #9CA3AF;
}
.csk-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
min-width: 40px;
border: none;
border-radius: 8px;
background: var(--csk-primary);
color: #fff;
cursor: pointer;
transition: background 0.2s;
}
.csk-send-btn:hover {
background: var(--csk-primary-hover);
}
.csk-send-btn:disabled {
background: #D1D5DB;
cursor: not-allowed;
}
/* ========== 清空按钮 ========== */
.csk-clear-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #fff;
color: #6B7280;
font-size: 12px;
cursor: pointer;
margin: 0 auto 8px;
transition: all 0.15s;
}
.csk-clear-btn:hover {
background: #FEE2E2;
border-color: #FCA5A5;
color: #DC2626;
}
/* ========== P1: 知识库分类下拉 ========== */
.csk-category-bar {
display: flex;
align-items: center;
padding: 6px 12px;
border-top: 1px solid #E5E7EB;
background: #F9FAFB;
gap: 8px;
}
.csk-category-bar__label {
font-size: 12px;
color: #6B7280;
white-space: nowrap;
}
.csk-category-select {
flex: 1;
padding: 5px 8px;
border: 1px solid #E5E7EB;
border-radius: 6px;
font-size: 12px;
color: #374151;
background: #fff;
outline: none;
cursor: pointer;
font-family: inherit;
transition: border-color 0.2s;
max-width: 200px;
}
.csk-category-select:focus {
border-color: var(--csk-primary);
}
/* ========== P1: RAG 引用来源卡片 ========== */
.csk-sources {
margin-top: 8px;
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
font-size: 12px;
max-width: 100%;
}
.csk-sources__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #F9FAFB;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.csk-sources__header:hover {
background: #F3F4F6;
}
.csk-sources__title {
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
color: #374151;
}
.csk-sources__arrow {
transition: transform 0.2s;
color: #9CA3AF;
}
.csk-sources--collapsed .csk-sources__arrow {
transform: rotate(-90deg);
}
.csk-sources__body {
border-top: 1px solid #E5E7EB;
padding: 0;
}
.csk-sources--collapsed .csk-sources__body {
display: none;
}
.csk-source-item {
padding: 8px 12px;
border-bottom: 1px solid #F3F4F6;
transition: background 0.15s;
}
.csk-source-item:last-child {
border-bottom: none;
}
.csk-source-item:hover {
background: #F9FAFB;
}
.csk-source-item__name {
font-weight: 500;
color: #1F2937;
margin-bottom: 2px;
}
.csk-source-item__snippet {
color: #6B7280;
line-height: 1.4;
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: 2px;
}
/* ========== P1: Markdown 渲染样式 ========== */
.csk-msg--ai .csk-msg__bubble .csk-md-p {
margin: 0 0 8px;
}
.csk-msg--ai .csk-msg__bubble .csk-md-p:last-child {
margin-bottom: 0;
}
.csk-msg--ai .csk-msg__bubble .csk-md-h1,
.csk-msg--ai .csk-msg__bubble .csk-md-h2,
.csk-msg--ai .csk-msg__bubble .csk-md-h3,
.csk-msg--ai .csk-msg__bubble .csk-md-h4,
.csk-msg--ai .csk-msg__bubble .csk-md-h5,
.csk-msg--ai .csk-msg__bubble .csk-md-h6 {
margin: 12px 0 6px;
font-weight: 600;
line-height: 1.3;
}
.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: 8px;
overflow-x: auto;
margin: 8px 0;
font-size: 13px;
line-height: 1.5;
font-family: 'SF Mono', 'Consolas', 'Menlo', monospace;
}
.csk-md-code-block code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.csk-md-inline-code {
background: #E5E7EB;
color: #DC2626;
padding: 1px 6px;
border-radius: 4px;
font-size: 13px;
font-family: 'SF Mono', 'Consolas', 'Menlo', monospace;
}
.csk-msg--ai .csk-msg__bubble .csk-md-ul,
.csk-msg--ai .csk-msg__bubble .csk-md-ol {
padding-left: 20px;
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-left: 12px;
margin: 8px 0;
color: #6B7280;
}
.csk-md-link {
color: var(--csk-primary);
text-decoration: none;
}
.csk-md-link:hover {
text-decoration: underline;
}
.csk-md-hr {
border: none;
border-top: 1px solid #E5E7EB;
margin: 12px 0;
}
/* ========== P2: 会话管理面板 ========== */
.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: 6px;
transition: background 0.15s;
}
.csk-history-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.csk-history-panel {
position: absolute;
top: 48px;
left: 0;
right: 0;
bottom: 0;
background: #fff;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
}
.csk-history-panel--hidden {
display: none;
}
.csk-history-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #E5E7EB;
background: #F9FAFB;
}
.csk-history-panel__title {
font-size: 14px;
font-weight: 600;
color: #1F2937;
}
.csk-history-panel__back {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #fff;
color: #374151;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.csk-history-panel__back:hover {
background: #F3F4F6;
}
.csk-history-panel__list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.csk-history-panel__list::-webkit-scrollbar {
width: 4px;
}
.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: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
margin-bottom: 4px;
}
.csk-history-item:hover {
background: #F3F4F6;
}
.csk-history-item--active {
background: #EEF2FF;
border-left: 3px solid var(--csk-primary);
}
.csk-history-item--active:hover {
background: #E0E7FF;
}
.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: 2px;
}
.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: 6px;
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: 40px 20px;
color: #9CA3AF;
font-size: 13px;
text-align: center;
}
.csk-history-panel__empty-icon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
.csk-history-panel__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
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;
}
/* ========== 移动端适配 ========== */
@media (max-width: 480px) {
.csk-window {
width: 100vw !important;
height: 100vh !important;
bottom: 0 !important;
right: 0 !important;
left: 0 !important;
border-radius: 0;
}
.csk-header {
border-radius: 0;
}
}
`;
}
/**
* 注入样式到 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;
}
// 同时移除可能残留的其他 style 标签
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();
}
// ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */
function createLauncher(config, onClick) {
const launcher = document.createElement('div');
launcher.id = 'csk-launcher';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
launcher.setAttribute('tabindex', '0');
// 图标内容
launcher.innerHTML = config.launcherIcon;
// 点击事件(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`;
// === 头部 ===
const header = document.createElement('div');
header.className = 'csk-header';
const titleEl = document.createElement('span');
titleEl.className = 'csk-header__title';
titleEl.textContent = config.title;
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(titleEl);
header.appendChild(actions);
// === 消息区 ===
const messagesContainer = document.createElement('div');
messagesContainer.id = 'csk-messages';
messagesContainer.className = 'csk-messages';
// === 会话管理面板(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__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 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>`;
inputArea.appendChild(inputEl);
inputArea.appendChild(sendBtn);
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__dot"></div>
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
`;
messagesContainer.appendChild(el);
loadingEl = el;
return el;
}
function hideLoading() {
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl);
loadingEl = null;
}
}
return {
window: windowEl,
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
showLoading,
hideLoading,
};
}
// ==================== 拖拽支持 ====================
/** 启用弹窗拖拽 */
function enableDrag(headerEl, windowEl) {
let dragging = false;
let startX = 0;
let startY = 0;
let offsetX = 0;
let offsetY = 0;
const onMouseDown = (e) => {
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);
};
headerEl.addEventListener('mousedown', onMouseDown);
// 清理函数
return () => {
headerEl.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}
// ==================== 消息渲染 ====================
/** 渲染用户消息气泡 */
function renderUserBubble(container, text, timestamp) {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--user';
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);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
container.appendChild(wrapper);
return wrapper;
}
/** 渲染 AI 消息气泡(支持 Markdown) */
function renderAIBubble(container, text, timestamp, renderMd) {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
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);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
container.appendChild(wrapper);
return wrapper;
}
/** 创建空的 AI 气泡(流式追加用) */
function createEmptyAIBubble(container, timestamp) {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
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);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
container.appendChild(wrapper);
return { wrapper, 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);
// 插入到气泡和时间戳之间
const timeEl = wrapper.querySelector('.csk-msg__time');
if (timeEl) {
wrapper.insertBefore(sourcesEl, timeEl);
}
else {
wrapper.appendChild(sourcesEl);
}
}
/** 渲染会话列表 */
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;
}
for (const item of 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);
}
}
/** 滚动消息区到底部 */
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 sendBtn$1 = null;
let clearBtn$1 = null;
let categorySelect$1 = null;
let historyPanel$1 = null;
let showLoadingFn$1 = null;
let hideLoadingFn$1 = null;
let isSending = false;
/** 当前选中的知识库分类 ID */
let currentCategoryId;
/** 当前是否使用 RAG 对话 */
let useRag = false;
/**
* 初始化对话模块
*/
function initChat(cfg, dom) {
config$1 = cfg;
messagesContainer$1 = dom.messagesContainer;
inputEl$1 = dom.inputEl;
sendBtn$1 = dom.sendBtn;
clearBtn$1 = dom.clearBtn;
categorySelect$1 = dom.categorySelect;
historyPanel$1 = dom.historyPanel;
showLoadingFn$1 = dom.showLoading;
hideLoadingFn$1 = dom.hideLoading;
// 初始化知识库分类
currentCategoryId = cfg.categoryId;
useRag = !!cfg.categoryId || !!cfg.showCategorySwitch;
// 绑定发送事件
bindSendEvents();
// 加载知识库分类下拉框
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', () => handleSend());
inputEl$1.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
});
inputEl$1.addEventListener('input', () => updateSendBtnState());
if (clearBtn$1) {
clearBtn$1.addEventListener('click', () => handleClear());
}
}
/** 更新发送按钮状态 */
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';
isSending = true;
updateSendBtnState();
// 确保 chatId 已初始化
if (!config$1.chatId) {
await initChatId();
}
// 1. 渲染用户气泡
const userTimestamp = now();
if (messagesContainer$1)
renderUserBubble(messagesContainer$1, text, userTimestamp);
const userMsg = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp };
messages.push(userMsg);
if (clearBtn$1 && messages.length > 0)
clearBtn$1.style.display = 'inline-flex';
if (messagesContainer$1)
scrollToBottom(messagesContainer$1);
// 2. 显示 loading
if (showLoadingFn$1)
showLoadingFn$1();
if (messagesContainer$1)
scrollToBottom(messagesContainer$1);
// 3. 发送请求
try {
let aiContent;
const aiTimestamp = now();
const shouldUseRag = useRag && (currentCategoryId !== undefined || config$1.categoryId !== undefined);
if (config$1.streaming) {
aiContent = await sendStreamMessage(text, aiTimestamp, shouldUseRag);
}
else {
aiContent = await chatRequest(text);
}
if (hideLoadingFn$1)
hideLoadingFn$1();
if (!config$1.streaming && messagesContainer$1) {
renderAIBubble(messagesContainer$1, aiContent, aiTimestamp, renderMarkdown);
}
const aiMsg = { id: uuid(), role: 'ai', content: aiContent, timestamp: aiTimestamp };
messages.push(aiMsg);
saveMessages(config$1.integrateId, messages);
if (messagesContainer$1)
scrollToBottom(messagesContainer$1);
// RAG 引用来源
if (shouldUseRag)
fetchAndRenderSources(text, 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;
updateSendBtnState();
}
}
/** 流式发送消息 */
async function sendStreamMessage(text, aiTimestamp, shouldUseRag) {
return new Promise((resolve, reject) => {
let bubbleEl = null;
let accumulated = '';
let streamStarted = false;
chatSSERequest(text, (chunk) => {
accumulated += chunk;
if (!streamStarted && messagesContainer$1) {
if (hideLoadingFn$1)
hideLoadingFn$1();
const { bubble } = createEmptyAIBubble(messagesContainer$1, aiTimestamp);
bubbleEl = bubble;
streamStarted = true;
}
if (bubbleEl)
bubbleEl.textContent = accumulated;
if (messagesContainer$1)
scrollToBottom(messagesContainer$1);
}, () => {
if (!streamStarted && accumulated === '') {
chatRequest(text).then(resolve).catch(reject);
return;
}
if (bubbleEl && accumulated)
bubbleEl.innerHTML = renderMarkdown(accumulated);
resolve(accumulated);
}, (error) => {
if (accumulated.length > 0) {
if (bubbleEl)
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));
resolve(accumulated);
}
else {
reject(error);
}
}, currentCategoryId, shouldUseRag);
});
}
/** 获取并渲染 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);
if (msg.sources && msg.sources.length > 0)
renderSources(wrapper, msg.sources);
}
}
scrollToBottom(messagesContainer$1);
if (clearBtn$1 && messages.length > 0)
clearBtn$1.style.display = 'inline-flex';
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';
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) {
currentCategoryId = categoryId;
useRag = categoryId !== undefined;
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);
const items = result.list.map(c => ({
id: c.conversationId || '',
chatId: c.conversationId || '', // conversationId 就是 chatId
messageCount: c.messageCount,
lastMessageTime: c.lastMessageTime,
lastMessagePreview: c.lastMessagePreview,
createdAt: c.firstMessageTime || c.createdAt,
}));
renderHistoryList(listEl, items,
// onSelect: 切换到选中的会话
(conversationId) => {
switchToConversation(conversationId);
},
// onExport
(id) => { window.open(getConversationExportUrl(id), '_blank'); },
// onDelete
async (id) => {
if (!confirm(t('history_delete_confirm')))
return;
const ok = await deleteConversation(id);
if (ok) {
// 如果删的是当前会话,清空聊天窗口
if (id === getChatId()) {
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';
}
loadHistoryConversations();
}
},
// 当前活跃 chatId,用于高亮
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';
}
}
// ==================== 单例状态 ====================
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;
// ==================== 公开 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);
// 4. 设置 API 配置
setApiConfig(config);
// 5. 注入样式
injectStyles(config);
// 6. 创建悬浮按钮
launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl);
// 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. 启用拖拽
const headerEl = windowEl.querySelector('.csk-header');
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl);
}
// 9. 初始化对话模块
initChat(config, {
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
showLoading: showLoadingFn,
hideLoading: hideLoadingFn,
});
// 10. 监听知识库分类切换事件
windowEl.addEventListener('csk:categoryChange', ((e) => {
setCategory(e.detail.categoryId);
}));
// 11. 监听会话管理面板加载事件
windowEl.addEventListener('csk:loadHistory', () => {
loadHistoryConversations();
});
isInitialized = true;
logger.lifecycleInit(config.integrateId, config.requestDomain);
// 12. 异步初始化 chatId 和对话历史(不阻塞 UI)
initChatHistory().catch(err => {
logger.warn('chatId 初始化失败,将在发送消息时重试', err);
});
}
/** 销毁 SDK 实例 */
function destroy() {
if (!isInitialized)
return;
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;
}
removeStyles();
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;
logger.lifecycleDestroy(oldIntegrateId || '');
}
function open() {
if (!windowEl)
return;
windowEl.classList.remove('csk-window--hidden');
}
function close() {
if (!windowEl)
return;
windowEl.classList.add('csk-window--hidden');
}
function toggle() {
if (!windowEl)
return;
if (windowEl.classList.contains('csk-window--hidden')) {
open();
setTimeout(() => { if (inputEl)
inputEl.focus(); }, 100);
}
else {
close();
}
}
function clearHistory() {
if (!config)
return;
if (clearBtn) {
clearBtn.click();
}
else if (confirm('确定清空所有对话记录?')) {
clearMessages(config.integrateId);
}
}
// ==================== 挂载到全局 ====================
const ChatbotSDK = {
init,
destroy,
open,
close,
toggle,
clearHistory,
};
if (typeof window !== 'undefined') {
window.ChatbotSDK = ChatbotSDK;
}
return ChatbotSDK;
})();
//# sourceMappingURL=chatbot-sdk.js.map