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.
1376 lines
47 KiB
1376 lines
47 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);
|
|
setIfPresent(params, 'accountId', currentConfig.userId);
|
|
setIfPresent(params, 'roleId', currentConfig.roleId);
|
|
setIfPresent(params, 'categoryId', 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);
|
|
setIfPresent(params, 'accountId', currentConfig.userId);
|
|
setIfPresent(params, 'roleId', currentConfig.roleId);
|
|
setIfPresent(params, 'categoryId', currentConfig.categoryId);
|
|
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
|
|
}
|
|
/**
|
|
* 安全设置可选参数:仅当 value 非空时追加,数字类型直接转字符串
|
|
*/
|
|
function setIfPresent(params, key, value) {
|
|
if (value === undefined || value === null)
|
|
return;
|
|
if (typeof value === 'string' && value.trim() === '')
|
|
return;
|
|
params.set(key, String(value));
|
|
}
|
|
/** 带超时的 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) {
|
|
// 流式输出:气泡由 sendStreamMessage 内部的 onChunk 回调创建
|
|
aiContent = await sendStreamMessage(text, aiTimestamp);
|
|
}
|
|
else {
|
|
// 同步请求:需要在此渲染 AI 气泡
|
|
aiContent = await chatRequest(text);
|
|
}
|
|
// 4. 隐藏 loading(流式模式在 onChunk 中已隐藏,此处做兜底)
|
|
if (hideLoadingFn$1)
|
|
hideLoadingFn$1();
|
|
// 5. 非流式模式:在此渲染 AI 气泡;流式模式气泡已在 stream 回调中创建
|
|
if (!config$1.streaming && 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
|