本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

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;
}