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.
2675 lines
94 KiB
2675 lines
94 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__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 = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
};
|
|
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, onExport, onDelete, 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 info = document.createElement('div');
|
|
info.className = 'csk-history-item__info';
|
|
const idEl = document.createElement('div');
|
|
idEl.className = 'csk-history-item__id';
|
|
idEl.textContent = item.chatId || item.id;
|
|
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);
|
|
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(/^>\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 || c.chatId || '',
|
|
chatId: c.conversationId || c.chatId || '',
|
|
messageCount: c.messageCount,
|
|
lastMessageTime: c.lastMessageTime,
|
|
createdAt: c.firstMessageTime || c.createdAt,
|
|
}));
|
|
renderHistoryList(listEl, items, (id) => { window.open(getConversationExportUrl(id), '_blank'); }, async (id) => {
|
|
if (!confirm(t('history_delete_confirm')))
|
|
return;
|
|
const ok = await deleteConversation(id);
|
|
if (ok)
|
|
loadHistoryConversations();
|
|
});
|
|
}
|
|
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>`;
|
|
}
|
|
}
|
|
|
|
// ==================== 单例状态 ====================
|
|
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
|