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.
455 lines
14 KiB
455 lines
14 KiB
/**
|
|
* 对话核心模块 - 发送/接收/渲染
|
|
*
|
|
* 核心参数映射:
|
|
* integrateId → roleId(客服角色 ID)
|
|
* userId → accountId(客户账号 ID)
|
|
* chatId → 自动管理(从 /conversation/list 获取或自动生成)
|
|
*/
|
|
import { ResolvedConfig, ChatMessage, RagSource } from './types';
|
|
import {
|
|
chatRequest,
|
|
chatSSERequest,
|
|
fetchCategoryTree,
|
|
fetchRagSources,
|
|
fetchConversationList,
|
|
fetchConversationMessages,
|
|
deleteConversation,
|
|
getConversationExportUrl,
|
|
initChatId,
|
|
updateChatId,
|
|
getChatId,
|
|
saveCachedChatId,
|
|
CskError,
|
|
} from './api';
|
|
import {
|
|
renderUserBubble,
|
|
renderAIBubble,
|
|
createEmptyAIBubble,
|
|
scrollToBottom,
|
|
renderSources,
|
|
renderHistoryList,
|
|
HistoryItemData,
|
|
} from './dom';
|
|
import { saveMessages, loadMessages, clearMessages } from './storage';
|
|
import { renderMarkdown } from './markdown';
|
|
import { logger } from './logger';
|
|
import { t } from './i18n';
|
|
import { uuid, now } from './utils';
|
|
|
|
let config: ResolvedConfig | null = null;
|
|
let messages: ChatMessage[] = [];
|
|
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 isSending = false;
|
|
|
|
/** 当前选中的知识库分类 ID */
|
|
let currentCategoryId: number | undefined;
|
|
|
|
/** 当前是否使用 RAG 对话 */
|
|
let useRag = false;
|
|
|
|
/**
|
|
* 初始化对话模块
|
|
*/
|
|
export function initChat(
|
|
cfg: ResolvedConfig,
|
|
dom: {
|
|
messagesContainer: HTMLElement;
|
|
inputEl: HTMLTextAreaElement;
|
|
sendBtn: HTMLElement;
|
|
clearBtn: HTMLElement | null;
|
|
categorySelect: HTMLSelectElement | null;
|
|
historyPanel: HTMLElement;
|
|
showLoading: () => HTMLElement;
|
|
hideLoading: () => void;
|
|
}
|
|
): void {
|
|
config = cfg;
|
|
messagesContainer = dom.messagesContainer;
|
|
inputEl = dom.inputEl;
|
|
sendBtn = dom.sendBtn;
|
|
clearBtn = dom.clearBtn;
|
|
categorySelect = dom.categorySelect;
|
|
historyPanel = dom.historyPanel;
|
|
showLoadingFn = dom.showLoading;
|
|
hideLoadingFn = dom.hideLoading;
|
|
|
|
// 初始化知识库分类
|
|
currentCategoryId = cfg.categoryId;
|
|
useRag = !!cfg.categoryId || !!cfg.showCategorySwitch;
|
|
|
|
// 绑定发送事件
|
|
bindSendEvents();
|
|
|
|
// 加载知识库分类下拉框
|
|
if (cfg.showCategorySwitch && categorySelect) {
|
|
loadCategories();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 初始化 chatId 并加载对话历史
|
|
* 异步流程:查后端会话 → 恢复 chatId → 加载历史消息
|
|
*/
|
|
export async function initChatHistory(): Promise<void> {
|
|
if (!config || !messagesContainer) return;
|
|
|
|
// 1. 初始化 chatId(从后端获取已有会话或自动生成)
|
|
await initChatId();
|
|
|
|
// 2. 尝试从后端加载对话历史
|
|
await loadHistoryFromBackend();
|
|
|
|
// 3. 如果后端无历史,尝试从 localStorage 恢复
|
|
if (messages.length === 0) {
|
|
const cached = loadMessages(config.integrateId);
|
|
if (cached.length > 0) {
|
|
messages = cached;
|
|
renderHistory();
|
|
logger.info(`从本地缓存恢复 ${cached.length} 条消息`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 从后端加载对话历史
|
|
*/
|
|
async function loadHistoryFromBackend(): Promise<void> {
|
|
if (!config || !messagesContainer) 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' as const,
|
|
content: msg.content,
|
|
timestamp: new Date(msg.createTime).getTime(),
|
|
}));
|
|
|
|
renderHistory();
|
|
logger.info(`从后端加载 ${messages.length} 条历史消息`);
|
|
|
|
// 同步到 localStorage
|
|
saveMessages(config.integrateId, messages);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('从后端加载历史消息失败', err);
|
|
}
|
|
}
|
|
|
|
/** 绑定发送相关事件 */
|
|
function bindSendEvents(): void {
|
|
if (!inputEl || !sendBtn) return;
|
|
|
|
sendBtn.addEventListener('click', () => handleSend());
|
|
|
|
inputEl.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
});
|
|
|
|
inputEl.addEventListener('input', () => updateSendBtnState());
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => handleClear());
|
|
}
|
|
}
|
|
|
|
/** 更新发送按钮状态 */
|
|
function updateSendBtnState(): void {
|
|
if (!sendBtn || !inputEl) return;
|
|
const hasText = inputEl.value.trim().length > 0;
|
|
if (hasText && !isSending) {
|
|
sendBtn.removeAttribute('disabled');
|
|
} else {
|
|
sendBtn.setAttribute('disabled', 'true');
|
|
}
|
|
}
|
|
|
|
/** 处理发送消息 */
|
|
async function handleSend(): Promise<void> {
|
|
if (!inputEl || !config || isSending) return;
|
|
|
|
const text = inputEl.value.trim();
|
|
if (text === '') return;
|
|
|
|
inputEl.value = '';
|
|
updateSendBtnState();
|
|
inputEl.style.height = 'auto';
|
|
|
|
isSending = true;
|
|
updateSendBtnState();
|
|
|
|
// 确保 chatId 已初始化
|
|
if (!config.chatId) {
|
|
await initChatId();
|
|
}
|
|
|
|
// 1. 渲染用户气泡
|
|
const userTimestamp = now();
|
|
if (messagesContainer) renderUserBubble(messagesContainer, text, userTimestamp);
|
|
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp };
|
|
messages.push(userMsg);
|
|
|
|
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
|
|
if (messagesContainer) scrollToBottom(messagesContainer);
|
|
|
|
// 2. 显示 loading
|
|
if (showLoadingFn) showLoadingFn();
|
|
if (messagesContainer) scrollToBottom(messagesContainer);
|
|
|
|
// 3. 发送请求
|
|
try {
|
|
let aiContent: string;
|
|
const aiTimestamp = now();
|
|
const shouldUseRag = useRag && (currentCategoryId !== undefined || config.categoryId !== undefined);
|
|
|
|
if (config.streaming) {
|
|
aiContent = await sendStreamMessage(text, aiTimestamp, shouldUseRag);
|
|
} else {
|
|
aiContent = await chatRequest(text);
|
|
}
|
|
|
|
if (hideLoadingFn) hideLoadingFn();
|
|
|
|
if (!config.streaming && messagesContainer) {
|
|
renderAIBubble(messagesContainer, aiContent, aiTimestamp, renderMarkdown);
|
|
}
|
|
const aiMsg: ChatMessage = { id: uuid(), role: 'ai', content: aiContent, timestamp: aiTimestamp };
|
|
messages.push(aiMsg);
|
|
|
|
saveMessages(config.integrateId, messages);
|
|
if (messagesContainer) scrollToBottom(messagesContainer);
|
|
|
|
// RAG 引用来源
|
|
if (shouldUseRag) fetchAndRenderSources(text, aiMsg);
|
|
} catch (err) {
|
|
if (hideLoadingFn) hideLoadingFn();
|
|
|
|
const errMsg = err instanceof CskError ? err.message : t('error_send');
|
|
if (messagesContainer) {
|
|
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.appendChild(errorBubble);
|
|
}
|
|
logger.error(`发送失败 integrateId=${config.integrateId}`, err);
|
|
} finally {
|
|
isSending = false;
|
|
updateSendBtnState();
|
|
}
|
|
}
|
|
|
|
/** 流式发送消息 */
|
|
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let bubbleEl: HTMLElement | null = null;
|
|
let accumulated = '';
|
|
let streamStarted = false;
|
|
|
|
chatSSERequest(
|
|
text,
|
|
(chunk: string) => {
|
|
accumulated += chunk;
|
|
if (!streamStarted && messagesContainer) {
|
|
if (hideLoadingFn) hideLoadingFn();
|
|
const { bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp);
|
|
bubbleEl = bubble;
|
|
streamStarted = true;
|
|
}
|
|
if (bubbleEl) bubbleEl.textContent = accumulated;
|
|
if (messagesContainer) scrollToBottom(messagesContainer);
|
|
},
|
|
() => {
|
|
if (!streamStarted && accumulated === '') {
|
|
chatRequest(text).then(resolve).catch(reject);
|
|
return;
|
|
}
|
|
if (bubbleEl && accumulated) bubbleEl.innerHTML = renderMarkdown(accumulated);
|
|
resolve(accumulated);
|
|
},
|
|
(error: CskError) => {
|
|
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: string, aiMsg: ChatMessage): Promise<void> {
|
|
try {
|
|
const sources = await fetchRagSources(message, currentCategoryId);
|
|
if (sources.length > 0) {
|
|
const ragSources: RagSource[] = sources.map(s => ({
|
|
documentId: s.documentId || '',
|
|
title: s.title || '',
|
|
sourceName: s.sourceName || '',
|
|
chunkIndex: s.chunkIndex ?? 0,
|
|
score: s.score ?? 0,
|
|
snippet: s.snippet || '',
|
|
}));
|
|
aiMsg.sources = ragSources;
|
|
if (messagesContainer) {
|
|
const lastAiMsg = messagesContainer.querySelector('.csk-msg--ai:last-of-type');
|
|
if (lastAiMsg) renderSources(lastAiMsg as HTMLElement, ragSources);
|
|
}
|
|
if (config) saveMessages(config.integrateId, messages);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('获取引用来源失败', err);
|
|
}
|
|
}
|
|
|
|
/** 加载知识库分类到下拉框 */
|
|
async function loadCategories(): Promise<void> {
|
|
if (!categorySelect) return;
|
|
try {
|
|
const tree = await fetchCategoryTree();
|
|
if (tree.length === 0) return;
|
|
categorySelect.innerHTML = `<option value="">${t('category_all')}</option>`;
|
|
|
|
const addOptions = (nodes: typeof tree, indent: number = 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!.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(): void {
|
|
if (!messagesContainer) return;
|
|
|
|
const historyPanelEl = messagesContainer.querySelector('.csk-history-panel');
|
|
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
|
|
msgs.forEach(el => el.remove());
|
|
|
|
for (const msg of messages) {
|
|
if (msg.role === 'user') {
|
|
renderUserBubble(messagesContainer, msg.content, msg.timestamp);
|
|
} else {
|
|
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, renderMarkdown);
|
|
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources);
|
|
}
|
|
}
|
|
|
|
scrollToBottom(messagesContainer);
|
|
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
|
|
|
|
if (historyPanelEl && !messagesContainer.contains(historyPanelEl)) {
|
|
messagesContainer.appendChild(historyPanelEl);
|
|
}
|
|
}
|
|
|
|
/** 清空对话历史(生成新 chatId) */
|
|
function handleClear(): void {
|
|
if (!config) return;
|
|
if (!confirm(t('clear_confirm'))) return;
|
|
|
|
messages = [];
|
|
if (messagesContainer) {
|
|
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
|
|
msgs.forEach(el => el.remove());
|
|
}
|
|
if (clearBtn) clearBtn.style.display = 'none';
|
|
clearMessages(config.integrateId);
|
|
|
|
// 生成新的 chatId,开始新会话
|
|
const newId = generateNewChatId();
|
|
updateChatId(newId);
|
|
saveCachedChatId(config.integrateId, config.userId, newId);
|
|
|
|
logger.lifecycleClear(config.integrateId);
|
|
logger.info(`新 chatId=${newId}`);
|
|
}
|
|
|
|
/** 生成新 chatId */
|
|
function generateNewChatId(): string {
|
|
const random = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
? crypto.randomUUID().substring(0, 8)
|
|
: Math.random().toString(36).substring(2, 10);
|
|
return `sdk_${Date.now()}_${random}`;
|
|
}
|
|
|
|
/** 设置当前知识库分类 */
|
|
export function setCategory(categoryId: number | undefined): void {
|
|
currentCategoryId = categoryId;
|
|
useRag = categoryId !== undefined;
|
|
logger.lifecycleCategoryChange(categoryId ?? '全部');
|
|
}
|
|
|
|
// ==================== 会话管理面板 ====================
|
|
|
|
/** 加载会话列表并渲染 */
|
|
export async function loadHistoryConversations(): Promise<void> {
|
|
if (!historyPanel || !config) return;
|
|
|
|
const listEl = historyPanel.querySelector('#csk-history-list') as HTMLElement;
|
|
if (!listEl) return;
|
|
|
|
listEl.innerHTML = `<div class="csk-history-panel__loading">加载中...</div>`;
|
|
|
|
try {
|
|
const result = await fetchConversationList(1, 50, config.userId, config.integrateId);
|
|
const items: HistoryItemData[] = 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: string) => { window.open(getConversationExportUrl(id), '_blank'); },
|
|
async (id: string) => {
|
|
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>`;
|
|
}
|
|
}
|
|
|
|
/** 获取当前消息列表 */
|
|
export function getMessages(): ChatMessage[] {
|
|
return messages;
|
|
}
|