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

1378 lines
46 KiB

var ChatbotSDK = (function () {
'use strict';
const PREFIX = '[ChatbotSDK]';
let debugEnabled = true;
/** 设置是否开启调试日志 */
function setDebug(enabled) {
debugEnabled = enabled;
}
const logger = {
/** 普通信息日志 */
info(msg, data) {
if (debugEnabled) {
console.log(PREFIX, msg, data !== undefined ? data : '');
}
},
/** 警告日志 */
warn(msg, data) {
if (debugEnabled) {
console.warn(PREFIX, msg, data !== undefined ? data : '');
}
},
/** 错误日志(始终输出,不受 debug 开关控制) */
error(msg, data) {
console.error(PREFIX, msg, data !== undefined ? data : '');
},
};
/** 默认悬浮按钮 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
if (!raw.integrateId || typeof raw.integrateId !== 'string' || raw.integrateId.trim() === '') {
logger.error('integrateId 是必传参数,请检查 init() 调用。示例:ChatbotSDK.init({ integrateId: "my-app", 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: "my-app", 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;
}
// 填充默认值
const config = {
integrateId: raw.integrateId.trim(),
requestDomain: raw.requestDomain.replace(/\/+$/, ''), // 去掉末尾斜杠
userId: raw.userId,
roleId: raw.roleId,
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,
};
logger.info(`配置解析完成 integrateId=${config.integrateId} requestDomain=${config.requestDomain}`);
return config;
}
/** 请求超时时间(毫秒) */
const REQUEST_TIMEOUT = 30000;
let currentConfig = null;
/** 设置当前配置 */
function setApiConfig(config) {
currentConfig = config;
}
/** 构建完整请求 URL,自动防御双斜杠 */
function buildUrl(path) {
if (!currentConfig) {
throw new Error('API 配置未初始化');
}
const domain = currentConfig.requestDomain.replace(/\/+$/, '');
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${domain}${cleanPath}`;
}
/** 构建同步对话请求 URL */
function buildChatUrl(message) {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.integrateId);
if (currentConfig.userId) {
params.set('accountId', currentConfig.userId);
}
if (currentConfig.roleId) {
params.set('roleId', String(currentConfig.roleId));
}
if (currentConfig.categoryId) {
params.set('categoryId', String(currentConfig.categoryId));
}
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
}
/** 构建 SSE 流式请求 URL */
function buildChatSSEUrl(message) {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.integrateId);
if (currentConfig.userId) {
params.set('accountId', currentConfig.userId);
}
if (currentConfig.roleId) {
params.set('roleId', String(currentConfig.roleId));
}
if (currentConfig.categoryId) {
params.set('categoryId', String(currentConfig.categoryId));
}
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/** 带超时的 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('请求超时,请稍后重试', 'timeout');
}
if (err instanceof TypeError && err.message.includes('Failed to fetch')) {
throw new CskError('跨域请求被拦截,请联系管理员将当前域名加入 API 白名单', 'cors');
}
throw new CskError('网络连接失败,请检查网络', '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 '鉴权失败,请联系管理员';
case 403:
return '无访问权限,请联系管理员配置';
case 404:
return '请求的资源不存在';
case 429:
return '请求过于频繁,请稍后重试';
case 500:
return '服务器异常,请稍后重试';
case 502:
case 503:
return '服务暂不可用,请稍后重试';
default:
return `请求失败(状态码 ${status}`;
}
}
/**
* 同步对话请求
*/
async function chatRequest(message) {
const url = buildChatUrl(message);
const startTime = Date.now();
logger.info(`发送消息 integrateId=${currentConfig.integrateId} length=${message.length}`);
try {
const response = await safeFetch(url);
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.error(`请求失败 integrateId=${currentConfig.integrateId} status=${response.status} message=${errorMsg}`);
throw new CskError(errorMsg, `http_${response.status}`);
}
const text = await response.text();
const duration = Date.now() - startTime;
logger.info(`AI 回复 integrateId=${currentConfig.integrateId} length=${text.length} duration=${duration}ms`);
return text;
}
catch (err) {
if (err instanceof CskError) {
throw err;
}
logger.error(`请求异常 integrateId=${currentConfig.integrateId}`, err);
throw new CskError('请求发生未知错误', 'unknown');
}
}
/**
* SSE 流式对话请求
* @param message 用户消息
* @param onChunk 每次收到文本片段的回调
* @param onDone 流结束时的回调
* @param onError 发生错误时的回调
*/
async function chatSSERequest(message, onChunk, onDone, onError) {
var _a;
const url = buildChatSSEUrl(message);
const startTime = Date.now();
let totalText = '';
logger.info(`发送流式消息 integrateId=${currentConfig.integrateId} length=${message.length}`);
try {
const response = await safeFetch(url, {}, REQUEST_TIMEOUT * 2); // SSE 超时更长
if (!response.ok) {
const errorMsg = getHttpErrorMessage(response.status);
logger.error(`流式请求失败 integrateId=${currentConfig.integrateId} status=${response.status} message=${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('浏览器不支持流式读取', '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
buffer += decoder.decode(value, { stream: true });
// 按行解析 SSE 数据
const lines = buffer.split('\n');
// 最后一行可能不完整,保留到下次
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) {
// 跳过空行和 SSE 注释
continue;
}
// 解析 "data: xxx" 格式
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5).trim();
if (data) {
totalText += data;
onChunk(data);
}
}
else if (trimmed === '[DONE]') {
// 流结束标记(OpenAI 风格,此处做兼容)
break;
}
else if (!trimmed.startsWith('event:') && !trimmed.startsWith('id:') && !trimmed.startsWith('retry:')) {
// 可能是 Flux 裸文本格式(无 data: 前缀)
totalText += trimmed;
onChunk(trimmed);
}
}
}
// 处理 buffer 中剩余的数据
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[网络不稳定,内容可能不完整]');
}
else {
throw readErr;
}
}
finally {
reader.releaseLock();
}
const duration = Date.now() - startTime;
logger.info(`流式回复完成 integrateId=${currentConfig.integrateId} length=${totalText.length} duration=${duration}ms`);
onDone();
}
catch (err) {
if (err instanceof CskError) {
onError(err);
}
else {
logger.error(`流式请求异常 integrateId=${currentConfig.integrateId}`, err);
onError(new CskError('网络连接失败,请检查网络', 'network'));
}
}
}
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;
}
/* ========== 移动端适配 ========== */
@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);
});
}
/** 防抖函数 */
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';
// 最小化按钮
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
minimizeBtn.setAttribute('title', '最小化');
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', '关闭');
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(minimizeBtn);
actions.appendChild(closeBtn);
header.appendChild(titleEl);
header.appendChild(actions);
// === 消息区 ===
const messagesContainer = document.createElement('div');
messagesContainer.id = 'csk-messages';
messagesContainer.className = 'csk-messages';
// === 输入区 ===
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', '输入您的问题...');
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', '发送');
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(header);
windowEl.appendChild(messagesContainer);
windowEl.appendChild(inputArea);
// 清空按钮(可选)
let clearBtn = null;
if (config.showClear) {
clearBtn = document.createElement('button');
clearBtn.className = 'csk-clear-btn';
clearBtn.textContent = '清空对话';
clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示
// 插入到 messages 之后、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,
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 消息气泡 */
function renderAIBubble(container, text, timestamp) {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
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 气泡(流式追加用) */
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 };
}
/** 滚动消息区到底部 */
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);
}
}
let config$1 = null;
let messages = [];
let messagesContainer$1 = null;
let inputEl$1 = null;
let sendBtn$1 = null;
let clearBtn$1 = null;
let showLoadingFn$1 = null;
let hideLoadingFn$1 = null;
let isSending = false;
/**
* 初始化对话模块
*/
function initChat(cfg, dom) {
config$1 = cfg;
messagesContainer$1 = dom.messagesContainer;
inputEl$1 = dom.inputEl;
sendBtn$1 = dom.sendBtn;
clearBtn$1 = dom.clearBtn;
showLoadingFn$1 = dom.showLoading;
hideLoadingFn$1 = dom.hideLoading;
// 绑定发送事件
bindSendEvents();
// 恢复历史消息
const history = loadMessages(cfg.integrateId);
if (history.length > 0) {
messages = history;
renderHistory();
}
}
/** 绑定发送相关事件 */
function bindSendEvents() {
if (!inputEl$1 || !sendBtn$1)
return;
// 发送按钮点击
sendBtn$1.addEventListener('click', () => {
handleSend();
});
// 输入框键盘事件:回车发送 / Shift+Enter 换行
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();
// 自动调整 textarea 高度
inputEl$1.style.height = 'auto';
isSending = true;
updateSendBtnState();
// 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();
if (config$1.streaming) {
// 流式输出
aiContent = await sendStreamMessage(text, aiTimestamp);
}
else {
// 同步请求
aiContent = await chatRequest(text);
}
// 4. 隐藏 loading
if (hideLoadingFn$1)
hideLoadingFn$1();
// 5. 渲染 AI 气泡
if (messagesContainer$1) {
renderAIBubble(messagesContainer$1, aiContent, aiTimestamp);
}
const aiMsg = {
id: uuid(),
role: 'ai',
content: aiContent,
timestamp: aiTimestamp,
};
messages.push(aiMsg);
// 6. 保存到 localStorage
saveMessages(config$1.integrateId, messages);
// 7. 滚动到底部
if (messagesContainer$1)
scrollToBottom(messagesContainer$1);
}
catch (err) {
// 隐藏 loading
if (hideLoadingFn$1)
hideLoadingFn$1();
// 渲染错误提示
const errMsg = err instanceof CskError ? err.message : '发送失败,请稍后重试';
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) {
return new Promise((resolve, reject) => {
let bubbleEl = null;
let wrapperEl = null;
let accumulated = '';
let streamStarted = false;
chatSSERequest(text,
// onChunk
(chunk) => {
accumulated += chunk;
if (!streamStarted && messagesContainer$1) {
// 隐藏 loading,创建空 AI 气泡
if (hideLoadingFn$1)
hideLoadingFn$1();
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer$1, aiTimestamp);
wrapperEl = wrapper;
bubbleEl = bubble;
streamStarted = true;
}
if (bubbleEl) {
bubbleEl.textContent = accumulated;
}
if (messagesContainer$1)
scrollToBottom(messagesContainer$1);
},
// onDone
() => {
// 如果流没有产生任何内容,回退同步请求
if (!streamStarted && accumulated === '') {
chatRequest(text)
.then((content) => resolve(content))
.catch(reject);
return;
}
resolve(accumulated);
},
// onError
(error) => {
if (accumulated.length > 0) {
// 有部分内容,保留并添加提示
if (bubbleEl) {
bubbleEl.textContent = accumulated + '\n\n[回复被中断]';
}
resolve(accumulated);
}
else {
reject(error);
}
});
});
}
/** 渲染历史消息 */
function renderHistory() {
if (!messagesContainer$1)
return;
// 清空容器
messagesContainer$1.innerHTML = '';
for (const msg of messages) {
if (msg.role === 'user') {
renderUserBubble(messagesContainer$1, msg.content, msg.timestamp);
}
else {
renderAIBubble(messagesContainer$1, msg.content, msg.timestamp);
}
}
scrollToBottom(messagesContainer$1);
// 显示清空按钮
if (clearBtn$1 && messages.length > 0) {
clearBtn$1.style.display = 'inline-flex';
}
}
/** 清空对话历史 */
function handleClear() {
if (!config$1)
return;
if (!confirm('确定清空所有对话记录?')) {
return;
}
messages = [];
if (messagesContainer$1) {
messagesContainer$1.innerHTML = '';
}
if (clearBtn$1) {
clearBtn$1.style.display = 'none';
}
clearMessages(config$1.integrateId);
logger.info(`清空会话 integrateId=${config$1.integrateId}`);
}
// ==================== 单例状态 ====================
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 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; // parseConfig 已输出错误
}
config = parsed;
// 2. 设置日志级别
setDebug(config.debug);
// 3. 设置 API 配置
setApiConfig(config);
// 4. 注入样式
injectStyles(config);
// 5. 创建悬浮按钮
launcherEl = createLauncher(config, toggle);
document.body.appendChild(launcherEl);
// 6. 创建聊天弹窗
const dom = createChatWindow(config);
windowEl = dom.window;
messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
showLoadingFn = dom.showLoading;
hideLoadingFn = dom.hideLoading;
document.body.appendChild(windowEl);
// 7. 启用拖拽
const headerEl = windowEl.querySelector('.csk-header');
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl);
}
// 8. 初始化对话模块
initChat(config, {
messagesContainer,
inputEl,
sendBtn,
clearBtn,
showLoading: showLoadingFn,
hideLoading: hideLoadingFn,
});
isInitialized = true;
logger.info(`初始化完成 integrateId=${config.integrateId} requestDomain=${config.requestDomain}`);
}
/** 销毁 SDK 实例 */
function destroy() {
if (!isInitialized) {
return;
}
// 移除 DOM 元素
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;
showLoadingFn = null;
hideLoadingFn = null;
logger.info(`销毁实例 integrateId=${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;
// 通过触发自定义事件,让 chat 模块处理
if (clearBtn) {
clearBtn.click();
}
else if (confirm('确定清空所有对话记录?')) {
clearMessages(config.integrateId);
}
}
// ==================== 挂载到全局 ====================
const ChatbotSDK = {
init,
destroy,
open,
close,
toggle,
clearHistory,
};
// IIFE 自动挂载
if (typeof window !== 'undefined') {
window.ChatbotSDK = ChatbotSDK;
}
return ChatbotSDK;
})();
//# sourceMappingURL=chatbot-sdk.js.map