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

356 lines
10 KiB

/**
* ChatbotSDK 入口文件
* 单例模式,IIFE 挂载 window.ChatbotSDK
*
* 参数映射:
* integrateId → roleId(客服角色 ID)
* userId → accountId(客户账号 ID)
* chatId → 自动管理(从 /conversation/list 获取或自动生成)
*/
import { SDKConfig, ResolvedConfig, ChatbotSDKInstance } from './types';
import { parseConfig } from './config';
import { setDebug, logger } from './logger';
import { setApiConfig } from './api';
import { injectStyles, removeStyles } from './styles';
import { createLauncher, createChatWindow, enableDrag } from './dom';
import { initChat, initChatHistory, getMessages, setCategory, loadHistoryConversations, sendQuickReply, retryFromMessage, handleFeedback } from './chat';
import { clearMessages } from './storage';
import { setLocale } from './i18n';
// ==================== 单例状态 ====================
let config: ResolvedConfig | null = null;
let isInitialized = false;
let launcherEl: HTMLElement | null = null;
let windowEl: HTMLElement | null = null;
let messagesContainer: HTMLElement | null = null;
let inputEl: HTMLTextAreaElement | null = null;
let sendBtn: HTMLElement | null = null;
let clearBtn: HTMLElement | null = null;
let categorySelect: HTMLSelectElement | null = null;
let historyPanel: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null;
let dragCleanup: (() => void) | null = null;
/** 提示气泡元素 */
let teaserEl: HTMLElement | null = null;
/** 提示气泡定时器 */
let teaserTimer: ReturnType<typeof setTimeout> | null = null;
/** 未读徽章元素(挂在 launcher 内部) */
let badgeEl: HTMLElement | null = null;
// ==================== 公开 API ====================
/** 初始化 SDK */
function init(rawConfig: SDKConfig): void {
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);
// 获取未读徽章引用(由 createLauncher 创建)
badgeEl = launcherEl.querySelector('.csk-launcher__badge');
// 7. 创建聊天弹窗
const dom = createChatWindow(config);
windowEl = dom.window;
messagesContainer = dom.messagesContainer;
inputEl = dom.inputEl;
sendBtn = dom.sendBtn;
clearBtn = dom.clearBtn;
categorySelect = dom.categorySelect;
historyPanel = dom.historyPanel;
showLoadingFn = dom.showLoading;
hideLoadingFn = dom.hideLoading;
document.body.appendChild(windowEl);
// 8. 安置提示气泡(挂在 launcher 同层,由配置控制显示)
teaserEl = dom.teaserEl;
document.body.appendChild(teaserEl);
positionTeaser();
// 9. 启用拖拽
const headerEl = windowEl.querySelector('.csk-header') as HTMLElement;
if (headerEl) {
dragCleanup = enableDrag(headerEl, windowEl);
}
// 10. 初始化对话模块
initChat(config, {
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
welcomeEl: dom.welcomeEl,
newMsgBtn: dom.newMsgBtn,
searchInput: dom.searchInput,
ariaLiveEl: dom.ariaLiveEl,
showLoading: showLoadingFn,
hideLoading: hideLoadingFn,
});
// 11. 监听知识库分类切换事件
windowEl.addEventListener('csk:categoryChange', ((e: CustomEvent) => {
setCategory(e.detail.categoryId);
}) as EventListener);
// 12. 监听会话管理面板加载事件
windowEl.addEventListener('csk:loadHistory', () => {
loadHistoryConversations();
});
// 13. 监听快捷问题芯片点击
windowEl.addEventListener('csk:quickReply', ((e: CustomEvent) => {
sendQuickReply(e.detail.text);
}) as EventListener);
// 14. 监听 AI 消息重试事件
windowEl.addEventListener('csk:retry', ((e: CustomEvent) => {
retryFromMessage(e.detail.msgId);
}) as EventListener);
// 15. 监听消息反馈事件(👍 / 👎)
windowEl.addEventListener('csk:feedback', ((e: CustomEvent) => {
handleFeedback(e.detail.msgId, e.detail.value);
}) as EventListener);
// 16. 新消息到达通知(用于未读徽章)
windowEl.addEventListener('csk:newMessage', () => {
notifyNewMessage();
});
// 17. ESC 键关闭弹窗
document.addEventListener('keydown', onKeyDown);
// 17. 窗口 resize 时重新定位 teaser
window.addEventListener('resize', onResize);
isInitialized = true;
logger.lifecycleInit(config.integrateId, config.requestDomain);
// 18. 首访提示气泡(延迟显示,弹窗未打开时生效)
if (config.showTeaser) {
teaserTimer = setTimeout(() => {
// 弹窗关闭时才显示
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showTeaser();
}
}, 1500);
}
// 19. 异步初始化 chatId 和对话历史(不阻塞 UI)
initChatHistory().catch(err => {
logger.warn('chatId 初始化失败,将在发送消息时重试', err);
});
}
// ==================== 提示气泡控制 ====================
/** 根据 launcher 位置定位 teaser 气泡 */
function positionTeaser(): void {
if (!teaserEl || !launcherEl) return;
const rect = launcherEl.getBoundingClientRect();
const isLeft = config?.position === 'left-bottom';
if (isLeft) {
teaserEl.style.left = `${rect.left}px`;
} else {
teaserEl.style.right = `${window.innerWidth - rect.right}px`;
}
teaserEl.style.bottom = `${window.innerHeight - rect.top + 8}px`;
}
function showTeaser(): void {
if (!teaserEl) return;
positionTeaser();
teaserEl.classList.remove('csk-teaser--hidden');
// 点击气泡本身 → 打开弹窗
teaserEl.addEventListener('click', onTeaserClick);
// 关闭按钮(阻止冒泡,不打开弹窗)
const closeBtn = teaserEl.querySelector('.csk-teaser__close');
if (closeBtn) {
closeBtn.addEventListener('click', onTeaserClose);
}
}
function hideTeaser(): void {
if (!teaserEl) return;
teaserEl.classList.add('csk-teaser--hidden');
teaserEl.removeEventListener('click', onTeaserClick);
const closeBtn = teaserEl.querySelector('.csk-teaser__close');
if (closeBtn) closeBtn.removeEventListener('click', onTeaserClose);
}
function onTeaserClick(e: Event): void {
// 关闭按钮点击不打开弹窗
if ((e.target as HTMLElement).closest('.csk-teaser__close')) return;
hideTeaser();
open();
}
function onTeaserClose(e: Event): void {
e.stopPropagation();
hideTeaser();
}
function onResize(): void {
if (teaserEl && !teaserEl.classList.contains('csk-teaser--hidden')) {
positionTeaser();
}
}
// ==================== 未读徽章 ====================
function showBadge(): void {
if (badgeEl) badgeEl.classList.remove('csk-launcher__badge--hidden');
}
function hideBadge(): void {
if (badgeEl) badgeEl.classList.add('csk-launcher__badge--hidden');
}
/** 新消息到达时弹窗关闭,则显示徽章 */
function notifyNewMessage(): void {
if (windowEl && windowEl.classList.contains('csk-window--hidden')) {
showBadge();
}
}
// ==================== a11y 焦点陷阱 ====================
/** 弹窗内可聚焦元素选择器 */
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function trapFocus(e: KeyboardEvent): void {
if (!windowEl || windowEl.classList.contains('csk-window--hidden')) return;
if (e.key !== 'Tab') return;
const focusables = Array.from(windowEl.querySelectorAll(FOCUSABLE)) as HTMLElement[];
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
// Shift+Tab:在第一个元素上按 → 跳到最后一个
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab:在最后一个元素上按 → 跳到第一个
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// ==================== 全局键盘事件 ====================
function onKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape' && windowEl && !windowEl.classList.contains('csk-window--hidden')) {
close();
}
trapFocus(e);
}
/** 销毁 SDK 实例 */
function destroy(): void {
if (!isInitialized) return;
if (teaserTimer) { clearTimeout(teaserTimer); teaserTimer = null; }
hideTeaser();
if (teaserEl && teaserEl.parentNode) { teaserEl.parentNode.removeChild(teaserEl); teaserEl = null; }
if (launcherEl && launcherEl.parentNode) { launcherEl.parentNode.removeChild(launcherEl); launcherEl = null; }
if (windowEl && windowEl.parentNode) { windowEl.parentNode.removeChild(windowEl); windowEl = null; }
if (dragCleanup) { dragCleanup(); dragCleanup = null; }
removeStyles();
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', onResize);
const oldIntegrateId = config?.integrateId;
config = null;
isInitialized = false;
messagesContainer = null;
inputEl = null;
sendBtn = null;
clearBtn = null;
categorySelect = null;
historyPanel = null;
showLoadingFn = null;
hideLoadingFn = null;
badgeEl = null;
logger.lifecycleDestroy(oldIntegrateId || '');
}
function open(): void {
if (!windowEl) return;
windowEl.classList.remove('csk-window--hidden');
hideTeaser();
hideBadge();
setTimeout(() => { if (inputEl) inputEl.focus(); }, 80);
}
function close(): void {
if (!windowEl) return;
windowEl.classList.add('csk-window--hidden');
}
function toggle(): void {
if (!windowEl) return;
if (windowEl.classList.contains('csk-window--hidden')) {
open();
} else {
close();
}
}
function clearHistory(): void {
if (!config) return;
if (clearBtn) { clearBtn.click(); }
else if (confirm('确定清空所有对话记录?')) { clearMessages(config.integrateId); }
}
// ==================== 挂载到全局 ====================
const ChatbotSDK: ChatbotSDKInstance = {
init,
destroy,
open,
close,
toggle,
clearHistory,
};
if (typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).ChatbotSDK = ChatbotSDK;
}
export default ChatbotSDK;