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.
834 lines
27 KiB
834 lines
27 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,
|
|
submitFeedbackApi,
|
|
CskError,
|
|
} from './api';
|
|
import {
|
|
renderUserBubble,
|
|
renderAIBubble,
|
|
createEmptyAIBubble,
|
|
scrollToBottom,
|
|
renderSources,
|
|
renderHistoryList,
|
|
appendStreamingCaret,
|
|
finalizeAIBubble,
|
|
updateFeedbackUI,
|
|
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 inputWrap: HTMLElement | null = null;
|
|
let sendBtn: HTMLElement | null = null;
|
|
let clearBtn: HTMLElement | null = null;
|
|
let categorySelect: HTMLSelectElement | null = null;
|
|
let historyPanel: HTMLElement | null = null;
|
|
let welcomeEl: HTMLElement | null = null;
|
|
let newMsgBtn: HTMLElement | null = null;
|
|
let searchInput: HTMLInputElement | null = null;
|
|
let ariaLiveEl: HTMLElement | null = null;
|
|
let showLoadingFn: (() => HTMLElement) | null = null;
|
|
let hideLoadingFn: (() => void) | null = null;
|
|
let isSending = false;
|
|
|
|
/** 当前流式请求的中断控制器(用于"停止生成") */
|
|
let abortController: AbortController | null = null;
|
|
/** 用户是否停留在底部附近(用于智能滚动 + 新消息提示) */
|
|
let isNearBottom = true;
|
|
|
|
/** 缓存的历史会话列表(供搜索过滤用) */
|
|
let historyItems: HistoryItemData[] = [];
|
|
/** 当前历史搜索关键字 */
|
|
let historySearchText = '';
|
|
|
|
/** 当前选中的知识库分类 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;
|
|
welcomeEl: HTMLElement;
|
|
newMsgBtn: HTMLElement;
|
|
searchInput: HTMLInputElement | null;
|
|
ariaLiveEl: HTMLElement;
|
|
showLoading: () => HTMLElement;
|
|
hideLoading: () => void;
|
|
}
|
|
): void {
|
|
config = cfg;
|
|
messagesContainer = dom.messagesContainer;
|
|
inputEl = dom.inputEl;
|
|
inputWrap = dom.inputEl.parentElement;
|
|
sendBtn = dom.sendBtn;
|
|
clearBtn = dom.clearBtn;
|
|
categorySelect = dom.categorySelect;
|
|
historyPanel = dom.historyPanel;
|
|
welcomeEl = dom.welcomeEl;
|
|
newMsgBtn = dom.newMsgBtn;
|
|
searchInput = dom.searchInput;
|
|
ariaLiveEl = dom.ariaLiveEl;
|
|
showLoadingFn = dom.showLoading;
|
|
hideLoadingFn = dom.hideLoading;
|
|
|
|
// 初始化知识库分类
|
|
currentCategoryId = cfg.categoryId;
|
|
useRag = !!cfg.enableRag || !!cfg.categoryId || !!cfg.showCategorySwitch;
|
|
|
|
// 绑定发送事件
|
|
bindSendEvents();
|
|
|
|
// 绑定滚动监听(智能滚动 + 新消息提示)
|
|
bindScrollEvents();
|
|
|
|
// 绑定历史搜索过滤
|
|
bindHistorySearch();
|
|
|
|
// 加载知识库分类下拉框
|
|
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', () => {
|
|
if (isSending && abortController) {
|
|
abortController.abort();
|
|
return;
|
|
}
|
|
handleSend();
|
|
});
|
|
|
|
inputEl.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
// IME 组合输入期间(中文/日文输入法选词回车)不触发发送
|
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
});
|
|
|
|
inputEl.addEventListener('input', () => {
|
|
updateSendBtnState();
|
|
autoResizeInput();
|
|
});
|
|
|
|
// 输入框聚焦/失焦高亮容器
|
|
inputEl.addEventListener('focus', () => {
|
|
if (inputWrap) inputWrap.classList.add('csk-input-wrap--focus');
|
|
});
|
|
inputEl.addEventListener('blur', () => {
|
|
if (inputWrap) inputWrap.classList.remove('csk-input-wrap--focus');
|
|
});
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => handleClear());
|
|
}
|
|
}
|
|
|
|
/** 绑定滚动监听:判断是否在底部,控制新消息提示按钮 */
|
|
function bindScrollEvents(): void {
|
|
if (!messagesContainer) return;
|
|
messagesContainer.addEventListener('scroll', () => {
|
|
if (!messagesContainer) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
|
|
// 距底部 80px 内视为"在底部"
|
|
isNearBottom = scrollHeight - scrollTop - clientHeight < 80;
|
|
if (isNearBottom) hideNewMsgBtn();
|
|
});
|
|
// 新消息提示按钮点击 → 回到底部
|
|
if (newMsgBtn) {
|
|
newMsgBtn.addEventListener('click', () => {
|
|
if (messagesContainer) scrollToBottom(messagesContainer);
|
|
isNearBottom = true;
|
|
hideNewMsgBtn();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** 显示新消息提示按钮 */
|
|
function showNewMsgBtn(): void {
|
|
if (newMsgBtn) newMsgBtn.classList.remove('csk-newmsg--hidden');
|
|
}
|
|
|
|
/** 隐藏新消息提示按钮 */
|
|
function hideNewMsgBtn(): void {
|
|
if (newMsgBtn) newMsgBtn.classList.add('csk-newmsg--hidden');
|
|
}
|
|
|
|
/** 智能滚动到底部:用户在底部时自动滚,上滑时显示新消息提示 */
|
|
function smartScrollToBottom(): void {
|
|
if (!messagesContainer) return;
|
|
if (isNearBottom) {
|
|
scrollToBottom(messagesContainer);
|
|
} else {
|
|
showNewMsgBtn();
|
|
}
|
|
}
|
|
|
|
/** 切换发送按钮模式:发送 / 停止生成 */
|
|
function setSendButtonMode(mode: 'send' | 'stop'): void {
|
|
if (!sendBtn) return;
|
|
if (mode === 'stop') {
|
|
sendBtn.classList.add('csk-send-btn--stop');
|
|
sendBtn.removeAttribute('disabled');
|
|
sendBtn.setAttribute('title', t('stop'));
|
|
sendBtn.setAttribute('aria-label', t('stop'));
|
|
sendBtn.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"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
|
|
} else {
|
|
sendBtn.classList.remove('csk-send-btn--stop');
|
|
sendBtn.setAttribute('title', t('send'));
|
|
sendBtn.setAttribute('aria-label', t('send'));
|
|
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>`;
|
|
updateSendBtnState();
|
|
}
|
|
}
|
|
|
|
// ==================== 历史会话搜索过滤 ====================
|
|
|
|
/** 绑定历史搜索输入事件 */
|
|
function bindHistorySearch(): void {
|
|
if (!searchInput) return;
|
|
searchInput.addEventListener('input', () => {
|
|
historySearchText = searchInput!.value.trim().toLowerCase();
|
|
renderFilteredHistory();
|
|
});
|
|
}
|
|
|
|
/** 按搜索关键字过滤并重新渲染历史列表 */
|
|
function renderFilteredHistory(): void {
|
|
const listEl = historyPanel?.querySelector('#csk-history-list') as HTMLElement | null;
|
|
if (!listEl || !config) return;
|
|
|
|
const filtered = historySearchText
|
|
? historyItems.filter(item => {
|
|
const preview = (item.lastMessagePreview || item.chatId || item.id || '').toLowerCase();
|
|
return preview.includes(historySearchText);
|
|
})
|
|
: historyItems;
|
|
|
|
renderHistoryList(
|
|
listEl,
|
|
filtered,
|
|
(conversationId: string) => { switchToConversation(conversationId); },
|
|
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
|
|
async (id: string) => {
|
|
if (!confirm(t('history_delete_confirm'))) return;
|
|
const ok = await deleteConversation(id);
|
|
if (ok) {
|
|
if (id === getChatId()) {
|
|
messages = [];
|
|
if (messagesContainer) {
|
|
messagesContainer.querySelectorAll('.csk-msg, .csk-loading').forEach(el => el.remove());
|
|
}
|
|
if (clearBtn) clearBtn.style.display = 'none';
|
|
updateEmptyState();
|
|
}
|
|
// 从缓存中移除并重新渲染
|
|
historyItems = historyItems.filter(it => (it.chatId || it.id) !== id);
|
|
renderFilteredHistory();
|
|
}
|
|
},
|
|
getChatId()
|
|
);
|
|
}
|
|
|
|
// ==================== a11y 消息播报 ====================
|
|
|
|
/** 通过 aria-live 区域播报新消息(供屏幕阅读器使用) */
|
|
function announceMessage(text: string): void {
|
|
if (!ariaLiveEl) return;
|
|
// 截取前 120 字符,避免过长播报
|
|
const snippet = text.length > 120 ? text.substring(0, 120) + '...' : text;
|
|
ariaLiveEl.textContent = t('new_msg_announce') + ':' + snippet;
|
|
}
|
|
|
|
// ==================== 消息反馈(👍 / 👎) ====================
|
|
|
|
/**
|
|
* 处理消息反馈:切换 AI 消息的点赞/点踩状态
|
|
* 前端状态持久化(存入 messages 数组 + localStorage)+ 调用后端 API 记录反馈
|
|
*/
|
|
export function handleFeedback(msgId: string, value: 'up' | 'down'): void {
|
|
if (!messagesContainer) return;
|
|
|
|
const msg = messages.find(m => m.id === msgId && m.role === 'ai');
|
|
if (!msg) return;
|
|
|
|
// 切换逻辑:同值取消,异值切换
|
|
const newValue = msg.feedback === value ? undefined : value;
|
|
msg.feedback = newValue;
|
|
|
|
// 更新 DOM 状态
|
|
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
|
|
if (wrapper) updateFeedbackUI(wrapper, newValue);
|
|
|
|
// 持久化(localStorage)
|
|
if (config) saveMessages(config.integrateId, messages);
|
|
|
|
// 调用后端 API 记录反馈
|
|
if (newValue) {
|
|
const feedbackType = newValue === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN';
|
|
submitFeedbackApi(String(msgId), feedbackType).then(success => {
|
|
if (success) {
|
|
logger.info(`消息反馈已提交 msgId=${msgId} value=${newValue}`);
|
|
} else {
|
|
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
|
|
}
|
|
});
|
|
} else {
|
|
logger.info(`消息反馈已取消 msgId=${msgId}`);
|
|
}
|
|
}
|
|
function autoResizeInput(): void {
|
|
if (!inputEl) return;
|
|
inputEl.style.height = 'auto';
|
|
inputEl.style.height = `${Math.min(inputEl.scrollHeight, 120)}px`;
|
|
}
|
|
|
|
/** 根据消息数量切换欢迎空状态显隐 */
|
|
function updateEmptyState(): void {
|
|
if (!welcomeEl) return;
|
|
const hasMessages = messages.length > 0 ||
|
|
(messagesContainer && messagesContainer.querySelector('.csk-msg'));
|
|
welcomeEl.style.display = hasMessages ? 'none' : '';
|
|
}
|
|
|
|
/** 更新发送按钮状态 */
|
|
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';
|
|
|
|
// 1. 渲染用户气泡
|
|
const userTimestamp = now();
|
|
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: text, timestamp: userTimestamp };
|
|
if (messagesContainer) renderUserBubble(messagesContainer, text, userTimestamp);
|
|
messages.push(userMsg);
|
|
|
|
updateEmptyState();
|
|
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
|
|
if (messagesContainer) smartScrollToBottom();
|
|
|
|
// 2. 生成 AI 回复
|
|
await produceAIReply(text);
|
|
}
|
|
|
|
/**
|
|
* 生成 AI 回复(发送请求 + 渲染气泡 + 持久化)
|
|
* 复用于:正常发送、重试。调用方负责先渲染用户气泡。
|
|
*/
|
|
async function produceAIReply(userText: string): Promise<void> {
|
|
if (!config || !messagesContainer) return;
|
|
|
|
isSending = true;
|
|
setSendButtonMode('stop');
|
|
|
|
// 确保 chatId 已初始化
|
|
if (!config.chatId) {
|
|
await initChatId();
|
|
}
|
|
|
|
const aiTimestamp = now();
|
|
// RAG 启用条件:显式开启 enableRag,或已选择知识库分类
|
|
const shouldUseRag = useRag && (config.enableRag || currentCategoryId !== undefined || config.categoryId !== undefined);
|
|
|
|
// 显示 loading
|
|
if (showLoadingFn) showLoadingFn();
|
|
if (messagesContainer) smartScrollToBottom();
|
|
|
|
const aiMsgId = uuid();
|
|
let aiContent = '';
|
|
|
|
try {
|
|
if (config.streaming) {
|
|
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId);
|
|
} else {
|
|
aiContent = await chatRequest(userText);
|
|
if (hideLoadingFn) hideLoadingFn();
|
|
if (messagesContainer) {
|
|
renderAIBubble(messagesContainer, aiContent, aiTimestamp, renderMarkdown, aiMsgId);
|
|
}
|
|
}
|
|
const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp };
|
|
messages.push(aiMsg);
|
|
|
|
saveMessages(config.integrateId, messages);
|
|
if (messagesContainer) smartScrollToBottom();
|
|
|
|
// a11y 播报新 AI 消息
|
|
announceMessage(aiContent);
|
|
|
|
// 通知 launcher 显示未读徽章(弹窗关闭时生效,由 index.ts 监听)
|
|
if (messagesContainer) {
|
|
messagesContainer.dispatchEvent(new CustomEvent('csk:newMessage', { bubbles: true, detail: { msg: aiMsg } }));
|
|
}
|
|
|
|
// RAG 引用来源
|
|
if (shouldUseRag) fetchAndRenderSources(userText, 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;
|
|
abortController = null;
|
|
setSendButtonMode('send');
|
|
updateSendBtnState();
|
|
}
|
|
}
|
|
|
|
/** 流式发送消息 */
|
|
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string): Promise<string> {
|
|
// 创建中断控制器,供"停止生成"使用
|
|
abortController = new AbortController();
|
|
const signal = abortController.signal;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let bubbleEl: HTMLElement | null = null;
|
|
let wrapperEl: HTMLElement | null = null;
|
|
let accumulated = '';
|
|
let streamStarted = false;
|
|
|
|
chatSSERequest(
|
|
text,
|
|
(chunk: string) => {
|
|
accumulated += chunk;
|
|
if (!streamStarted && messagesContainer) {
|
|
if (hideLoadingFn) hideLoadingFn();
|
|
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp, aiMsgId);
|
|
wrapperEl = wrapper;
|
|
bubbleEl = bubble;
|
|
streamStarted = true;
|
|
}
|
|
if (bubbleEl) {
|
|
bubbleEl.textContent = accumulated;
|
|
appendStreamingCaret(bubbleEl);
|
|
}
|
|
if (messagesContainer) smartScrollToBottom();
|
|
},
|
|
() => {
|
|
// 流结束
|
|
if (wrapperEl && bubbleEl) {
|
|
// 无流内容降级为同步请求
|
|
if (!streamStarted && accumulated === '') {
|
|
chatRequest(text).then(resolve).catch(reject);
|
|
return;
|
|
}
|
|
if (accumulated) bubbleEl.innerHTML = renderMarkdown(accumulated);
|
|
finalizeAIBubble(wrapperEl, bubbleEl);
|
|
}
|
|
resolve(accumulated);
|
|
},
|
|
(error: CskError) => {
|
|
if (accumulated.length > 0) {
|
|
if (bubbleEl) {
|
|
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));
|
|
if (wrapperEl) finalizeAIBubble(wrapperEl, bubbleEl);
|
|
}
|
|
resolve(accumulated);
|
|
} else {
|
|
reject(error);
|
|
}
|
|
},
|
|
currentCategoryId,
|
|
shouldUseRag,
|
|
signal
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 快捷问题发送:欢迎态点击芯片,直接以该文本发送
|
|
*/
|
|
export function sendQuickReply(text: string): Promise<void> {
|
|
if (!inputEl || isSending) return Promise.resolve();
|
|
inputEl.value = text;
|
|
updateSendBtnState();
|
|
return handleSend();
|
|
}
|
|
|
|
/**
|
|
* 重试:根据 AI 消息 ID 重新生成该回复
|
|
* 找到该 AI 消息及其上一条用户消息,移除该 AI 消息(及之后的所有消息)后重新请求
|
|
*/
|
|
export async function retryFromMessage(msgId: string): Promise<void> {
|
|
if (!config || !messagesContainer || isSending) return;
|
|
|
|
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai');
|
|
if (aiIndex < 0) return;
|
|
|
|
// 找到上一条用户消息文本
|
|
let userText = '';
|
|
for (let i = aiIndex - 1; i >= 0; i--) {
|
|
if (messages[i].role === 'user') { userText = messages[i].content; break; }
|
|
}
|
|
if (!userText) return;
|
|
|
|
// 移除该 AI 消息及其后所有消息(本地 + DOM)
|
|
const removed = messages.splice(aiIndex);
|
|
const removedIds = new Set(removed.map(m => m.id));
|
|
const nodes = messagesContainer.querySelectorAll('.csk-msg');
|
|
nodes.forEach(node => {
|
|
const id = (node as HTMLElement).dataset.cskMsgId;
|
|
// 无 id 的(如错误气泡、被中断的)按顺序兜底:此处仅按 id 精确移除
|
|
if (id && removedIds.has(id)) node.remove();
|
|
});
|
|
|
|
saveMessages(config.integrateId, messages);
|
|
updateEmptyState();
|
|
|
|
// 重新生成(不重复渲染用户气泡)
|
|
await produceAIReply(userText);
|
|
}
|
|
|
|
/** 获取并渲染 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, msg.id);
|
|
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources);
|
|
if (msg.feedback) updateFeedbackUI(wrapper, msg.feedback);
|
|
}
|
|
}
|
|
|
|
isNearBottom = true;
|
|
scrollToBottom(messagesContainer);
|
|
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
|
|
updateEmptyState();
|
|
|
|
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';
|
|
updateEmptyState();
|
|
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);
|
|
historyItems = result.list.map(c => ({
|
|
id: c.conversationId || '',
|
|
chatId: c.conversationId || '',
|
|
messageCount: c.messageCount,
|
|
lastMessageTime: c.lastMessageTime,
|
|
lastMessagePreview: c.lastMessagePreview,
|
|
createdAt: c.firstMessageTime || c.createdAt,
|
|
}));
|
|
|
|
// 重置搜索(新数据到达时清空过滤)
|
|
historySearchText = '';
|
|
if (searchInput) searchInput.value = '';
|
|
|
|
renderHistoryList(
|
|
listEl,
|
|
historyItems,
|
|
(conversationId: string) => { switchToConversation(conversationId); },
|
|
(id: string) => { window.open(getConversationExportUrl(id), '_blank'); },
|
|
async (id: string) => {
|
|
if (!confirm(t('history_delete_confirm'))) return;
|
|
const ok = await deleteConversation(id);
|
|
if (ok) {
|
|
if (id === getChatId()) {
|
|
messages = [];
|
|
if (messagesContainer) {
|
|
messagesContainer.querySelectorAll('.csk-msg, .csk-loading').forEach(el => el.remove());
|
|
}
|
|
if (clearBtn) clearBtn.style.display = 'none';
|
|
updateEmptyState();
|
|
}
|
|
// 从缓存中移除并重新渲染(不再重新请求后端)
|
|
historyItems = historyItems.filter(it => (it.chatId || it.id) !== id);
|
|
renderFilteredHistory();
|
|
}
|
|
},
|
|
getChatId()
|
|
);
|
|
} 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>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 切换到指定会话:加载上下文并继续对话
|
|
* @param conversationId 会话 ID(即 chatId)
|
|
*/
|
|
export async function switchToConversation(conversationId: string): Promise<void> {
|
|
if (!config || !messagesContainer) return;
|
|
|
|
logger.info(`切换到会话 conversationId=${conversationId}`);
|
|
|
|
// 1. 更新 chatId
|
|
updateChatId(conversationId);
|
|
saveCachedChatId(config.integrateId, config.userId, conversationId);
|
|
|
|
// 2. 关闭历史面板
|
|
if (historyPanel) {
|
|
historyPanel.classList.add('csk-history-panel--hidden');
|
|
}
|
|
|
|
// 3. 清空当前消息
|
|
messages = [];
|
|
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
|
|
msgs.forEach(el => el.remove());
|
|
|
|
// 4. 从后端加载该会话的消息
|
|
try {
|
|
const result = await fetchConversationMessages(conversationId);
|
|
if (result.messages.length > 0) {
|
|
messages = result.messages.map((msg) => ({
|
|
id: uuid(),
|
|
role: msg.messageType === 'USER' ? 'user' : 'ai' as const,
|
|
content: msg.content,
|
|
timestamp: new Date(msg.createTime).getTime(),
|
|
}));
|
|
|
|
renderHistory();
|
|
logger.info(`加载会话 ${conversationId} 的 ${messages.length} 条消息`);
|
|
|
|
// 同步到 localStorage
|
|
saveMessages(config.integrateId, messages);
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`加载会话消息失败 conversationId=${conversationId}`, err);
|
|
}
|
|
|
|
// 5. 显示清空按钮
|
|
if (clearBtn && messages.length > 0) {
|
|
clearBtn.style.display = 'inline-flex';
|
|
}
|
|
updateEmptyState();
|
|
}
|
|
|
|
/** 获取当前消息列表 */
|
|
export function getMessages(): ChatMessage[] {
|
|
return messages;
|
|
}
|