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.
979 lines
32 KiB
979 lines
32 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,
|
|
truncateConversation,
|
|
initChatId,
|
|
updateChatId,
|
|
getChatId,
|
|
saveCachedChatId,
|
|
submitFeedbackApi,
|
|
setActiveRoleId,
|
|
getActiveIntegrateId,
|
|
CskError,
|
|
fetchSuggestions,
|
|
} from './api';
|
|
import {
|
|
renderUserBubble,
|
|
renderAIBubble,
|
|
renderErrorBubble,
|
|
createEmptyAIBubble,
|
|
scrollToBottom,
|
|
renderSources,
|
|
renderHistoryList,
|
|
finalizeAIBubble,
|
|
showOfflineBanner,
|
|
hideOfflineBanner,
|
|
renderSuggestions,
|
|
showFeedbackReasonPanel,
|
|
resetChatActionFeedback,
|
|
confirmWithDialog,
|
|
HistoryItemData,
|
|
} from './dom';
|
|
import { saveMessages, loadMessages, clearMessages } from './storage';
|
|
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: HTMLElement | null = null;
|
|
let clearBtn: HTMLElement | null = null;
|
|
let categorySelect: HTMLElement | null = null;
|
|
let roleSelect: HTMLElement | 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;
|
|
/** 是否正在「重新生成」中(独立于 isSending,覆盖截断后端会话的异步窗口,防止重复触发) */
|
|
let isRetrying = 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: HTMLElement;
|
|
clearBtn: HTMLElement | null;
|
|
categorySelect: HTMLElement | null;
|
|
roleSelect: HTMLElement | 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;
|
|
clearBtn = dom.clearBtn;
|
|
categorySelect = dom.categorySelect;
|
|
roleSelect = dom.roleSelect;
|
|
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;
|
|
|
|
// 绑定发送事件
|
|
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(使用当前活跃角色 ID)
|
|
saveMessages(getActiveIntegrateId(), messages);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('从后端加载历史消息失败', err);
|
|
}
|
|
}
|
|
|
|
/** 绑定发送相关事件 */
|
|
function bindSendEvents(): void {
|
|
if (!inputEl) return;
|
|
|
|
// t-chat-sender:send 事件(点击发送/回车)携带 value;stop 事件(loading 态点击)中断流式
|
|
inputEl.addEventListener('send', (e) => {
|
|
const detail = (e as CustomEvent).detail as { value?: string } | undefined;
|
|
const value = (detail?.value || '').trim();
|
|
if (!value || isSending) return;
|
|
handleSend(value);
|
|
});
|
|
|
|
inputEl.addEventListener('stop', () => {
|
|
if (isSending && abortController) {
|
|
abortController.abort();
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/** 切换发送按钮模式:发送 / 停止生成(由 t-chat-sender 的 loading 属性承载) */
|
|
function setSendButtonMode(mode: 'send' | 'stop'): void {
|
|
if (!inputEl) return;
|
|
// loading 为 Boolean 简单类型,omi 下须用 setAttribute/removeAttribute(property 赋值静默失效)
|
|
if (mode === 'stop') {
|
|
inputEl.setAttribute('loading', '');
|
|
} else {
|
|
inputEl.removeAttribute('loading');
|
|
}
|
|
}
|
|
|
|
// ==================== 历史会话搜索过滤 ====================
|
|
|
|
/** 绑定历史搜索输入事件 */
|
|
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 (!(await confirmWithDialog(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 记录反馈
|
|
* 图标高亮由 t-chat-action 内部管理,active 反映本次点击是激活还是取消
|
|
* 点踩时弹出原因选择弹窗,点赞直接提交
|
|
*/
|
|
export function handleFeedback(msgId: string, value: 'up' | 'down', active: boolean): void {
|
|
if (!messagesContainer || !config) return;
|
|
const cfg = config;
|
|
|
|
const msg = messages.find(m => m.id === msgId && m.role === 'ai');
|
|
if (!msg) return;
|
|
|
|
if (value === 'up') {
|
|
// 点赞:直接切换提交,无需原因
|
|
msg.feedback = active ? 'up' : undefined;
|
|
msg.feedbackReason = undefined;
|
|
msg.feedbackComment = undefined;
|
|
|
|
saveMessages(cfg.integrateId, messages);
|
|
|
|
if (active) {
|
|
submitFeedbackApi(String(msgId), 'THUMBS_UP').then(success => {
|
|
if (success) {
|
|
logger.info(`消息反馈已提交 msgId=${msgId} value=up`);
|
|
} else {
|
|
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
|
|
}
|
|
});
|
|
} else {
|
|
logger.info(`消息反馈已取消 msgId=${msgId}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 点踩
|
|
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${msgId}"]`) as HTMLElement;
|
|
if (!wrapper) return;
|
|
|
|
if (!active) {
|
|
// 取消点踩(图标已由组件内部回滚)
|
|
msg.feedback = undefined;
|
|
msg.feedbackReason = undefined;
|
|
msg.feedbackComment = undefined;
|
|
saveMessages(cfg.integrateId, messages);
|
|
logger.info(`消息反馈已取消 msgId=${msgId}`);
|
|
return;
|
|
}
|
|
|
|
// 点踩:弹出原因选择面板;用户关闭面板(不选原因)时回滚图标
|
|
showFeedbackReasonPanel(wrapper, msgId, (reason, comment) => {
|
|
msg.feedback = 'down';
|
|
msg.feedbackReason = reason;
|
|
msg.feedbackComment = comment;
|
|
|
|
saveMessages(cfg.integrateId, messages);
|
|
|
|
submitFeedbackApi(String(msgId), 'THUMBS_DOWN', reason, comment).then(success => {
|
|
if (success) {
|
|
logger.info(`消息反馈已提交 msgId=${msgId} value=down reason=${reason}`);
|
|
} else {
|
|
logger.warn(`消息反馈提交失败 msgId=${msgId}(本地状态已更新)`);
|
|
}
|
|
});
|
|
}, () => {
|
|
resetChatActionFeedback(wrapper);
|
|
});
|
|
}
|
|
|
|
/** 根据消息数量切换欢迎空状态显隐 */
|
|
function updateEmptyState(): void {
|
|
if (!welcomeEl) return;
|
|
const hasMessages = messages.length > 0 ||
|
|
(messagesContainer && messagesContainer.querySelector('.csk-msg'));
|
|
welcomeEl.style.display = hasMessages ? 'none' : '';
|
|
}
|
|
|
|
/** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */
|
|
async function handleSend(text?: string): Promise<void> {
|
|
if (!config || isSending) return;
|
|
|
|
const input = (text ?? '').trim();
|
|
if (input === '') return;
|
|
|
|
// 1. 渲染用户气泡
|
|
const userTimestamp = now();
|
|
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: input, timestamp: userTimestamp };
|
|
if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp);
|
|
messages.push(userMsg);
|
|
|
|
updateEmptyState();
|
|
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
|
|
if (messagesContainer) smartScrollToBottom();
|
|
|
|
// 2. 生成 AI 回复
|
|
await produceAIReply(input);
|
|
}
|
|
|
|
/**
|
|
* 生成 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;
|
|
|
|
// 显示 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, 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);
|
|
|
|
// 拉取并展示推荐问题(suggest-message-list)
|
|
if (config.suggestions) {
|
|
fetchAndShowSuggestions(aiMsgId);
|
|
}
|
|
|
|
// 发送成功后清除离线横幅(网络已恢复)
|
|
hideOfflineBanner();
|
|
} catch (err) {
|
|
if (hideLoadingFn) hideLoadingFn();
|
|
|
|
const errMsg = err instanceof CskError ? err.message : t('error_send');
|
|
if (messagesContainer) {
|
|
renderErrorBubble(messagesContainer, errMsg, now());
|
|
}
|
|
logger.error(`发送失败 integrateId=${config.integrateId}`, err);
|
|
} finally {
|
|
isSending = false;
|
|
abortController = null;
|
|
setSendButtonMode('send');
|
|
}
|
|
}
|
|
|
|
/** 流式发送消息 */
|
|
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) => {
|
|
// 直接拼接:后端每个 SSE 事件是模型一个原始 token,
|
|
// token 内的换行已由 api.ts 的 eventLines.join('\n') 还原,
|
|
// chunk 之间不能再加 \n,否则会把单词/短句拆成多行、破坏 Markdown 结构
|
|
accumulated += chunk;
|
|
if (!streamStarted && messagesContainer) {
|
|
if (hideLoadingFn) hideLoadingFn();
|
|
const { wrapper, bubble } = createEmptyAIBubble(messagesContainer, aiTimestamp, aiMsgId);
|
|
wrapperEl = wrapper;
|
|
bubbleEl = bubble;
|
|
streamStarted = true;
|
|
}
|
|
if (bubbleEl) {
|
|
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
|
|
}
|
|
if (messagesContainer) smartScrollToBottom();
|
|
},
|
|
() => {
|
|
// 流结束
|
|
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
|
|
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
|
|
if (!streamStarted && accumulated === '') {
|
|
chatRequest(text).then(resolve).catch(reject);
|
|
return;
|
|
}
|
|
if (wrapperEl && bubbleEl) {
|
|
if (accumulated) {
|
|
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: accumulated }];
|
|
}
|
|
finalizeAIBubble(wrapperEl, bubbleEl);
|
|
}
|
|
resolve(accumulated);
|
|
},
|
|
(error: CskError) => {
|
|
// 离线检测:网络不可达时展示横幅提示,下次发送成功自动清除
|
|
if (error.type === 'network' || error.type === 'cors') {
|
|
if (messagesContainer) showOfflineBanner(messagesContainer);
|
|
}
|
|
if (accumulated.length > 0) {
|
|
if (bubbleEl) {
|
|
(bubbleEl as unknown as { content: unknown[] }).content = [{ type: 'markdown', data: 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 (isSending) return Promise.resolve();
|
|
return handleSend(text);
|
|
}
|
|
|
|
/**
|
|
* 重试:根据 AI 消息 ID 重新生成该回复
|
|
* 采用「从该轮重新开始」语义(与后台管理端 ChatPanel 一致):
|
|
* 定位该 AI 回复所属的那轮用户提问,先调用后端截断接口删除该提问及其后的全部消息,
|
|
* 再本地截断到该提问之前、重新渲染历史,最后重新发送该提问。
|
|
* 这样不会只删前端而留下后端旧消息,避免刷新后旧回复"复活"导致的数据错乱。
|
|
*/
|
|
export async function retryFromMessage(msgId: string): Promise<void> {
|
|
if (!config || !messagesContainer || isSending || isRetrying) return;
|
|
isRetrying = true;
|
|
try {
|
|
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai');
|
|
if (aiIndex < 0) return;
|
|
|
|
// 找到该 AI 回复所属的那轮用户提问
|
|
let userIndex = -1;
|
|
for (let i = aiIndex - 1; i >= 0; i--) {
|
|
if (messages[i].role === 'user') { userIndex = i; break; }
|
|
}
|
|
if (userIndex < 0) return;
|
|
const userText = messages[userIndex].content;
|
|
if (!userText) return;
|
|
|
|
// 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息
|
|
let userTurn = 0;
|
|
for (let i = 0; i <= userIndex; i++) {
|
|
if (messages[i].role === 'user') userTurn++;
|
|
}
|
|
|
|
// 1. 先截断后端会话(删除该轮提问及其之后的所有消息),失败则不触碰本地数据
|
|
const chatId = getChatId();
|
|
if (chatId) {
|
|
const ok = await truncateConversation(chatId, userTurn);
|
|
if (!ok) {
|
|
logger.error(`重新生成失败:后端会话截断失败 chatId=${chatId} userTurn=${userTurn}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 2. 本地截断到该轮提问之前(删除该轮提问、被点击的回复及其后的所有消息)
|
|
messages = messages.slice(0, userIndex);
|
|
saveMessages(config.integrateId, messages);
|
|
|
|
// 3. 重新渲染历史(清除 DOM 中被删除的消息),保证界面与数据一致
|
|
renderHistory();
|
|
|
|
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
|
|
await handleSend(userText);
|
|
} finally {
|
|
isRetrying = false;
|
|
}
|
|
}
|
|
|
|
/** 获取并渲染 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;
|
|
|
|
const select = categorySelect as HTMLSelectElement;
|
|
// 清空后重建(首项为"全部分类"占位)
|
|
select.innerHTML = '';
|
|
select.appendChild(new Option(t('category_all'), ''));
|
|
|
|
const addOptions = (nodes: typeof tree, indent: number = 0) => {
|
|
for (const node of nodes) {
|
|
select.appendChild(new Option(`${' '.repeat(indent)}${node.name}`, String(node.id)));
|
|
if (node.children && node.children.length > 0) addOptions(node.children, indent + 1);
|
|
}
|
|
};
|
|
addOptions(tree);
|
|
|
|
// 若已有选中分类,回填选中值
|
|
if (currentCategoryId !== undefined) {
|
|
select.value = String(currentCategoryId);
|
|
}
|
|
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, msg.id, msg.feedback);
|
|
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources);
|
|
}
|
|
}
|
|
|
|
isNearBottom = true;
|
|
// 延迟滚动到底部:t-chat-item 的 shadow DOM(含 cherry-markdown)异步渲染,
|
|
// 若同步设置 scrollTop,此时 scrollHeight 尚未更新到最终值,历史会话会停在滚动条中间。
|
|
// 用双 rAF 等待组件渲染与布局稳定后再滚到底部。
|
|
const container = messagesContainer;
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (container && container.isConnected) scrollToBottom(container);
|
|
});
|
|
});
|
|
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;
|
|
|
|
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 = config?.enableRag ?? true;
|
|
logger.lifecycleCategoryChange(categoryId ?? '全部');
|
|
}
|
|
|
|
// ==================== 角色切换 ====================
|
|
|
|
/**
|
|
* 切换客服角色
|
|
* - 清理当前对话 + 生成新 chatId
|
|
* - 更新 API 层的 roleId 参数
|
|
* - 重新加载新角色的对话历史
|
|
*/
|
|
export async function switchRole(newRoleId: string): Promise<void> {
|
|
if (!config || !messagesContainer) return;
|
|
|
|
const oldRoleId = getActiveIntegrateId();
|
|
|
|
// 1. 判断是否与当前角色相同(防重复切换)
|
|
if (newRoleId === oldRoleId) return;
|
|
|
|
// 2. 保存当前消息到 localStorage(与旧 integrateId 关联)
|
|
if (messages.length > 0) {
|
|
saveMessages(oldRoleId, messages);
|
|
}
|
|
|
|
// 3. 中断正在进行的流式请求
|
|
if (abortController) {
|
|
abortController.abort();
|
|
abortController = null;
|
|
}
|
|
isSending = false;
|
|
setSendButtonMode('send');
|
|
|
|
// 4. 清空消息数组和 DOM
|
|
messages = [];
|
|
const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading');
|
|
msgNodes.forEach(el => el.remove());
|
|
|
|
// 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前
|
|
clearMessages(oldRoleId);
|
|
saveCachedChatId(oldRoleId, config.userId, undefined);
|
|
clearMessages(newRoleId);
|
|
saveCachedChatId(newRoleId, config.userId, undefined);
|
|
|
|
// 6. 更新 API 层的 integrateId(必须在 initChatId 之前)
|
|
setActiveRoleId(newRoleId);
|
|
|
|
// 7. 更新角色下拉框选中项
|
|
if (roleSelect) {
|
|
(roleSelect as HTMLSelectElement).value = newRoleId;
|
|
}
|
|
|
|
// 8. 强制查询后端会话列表(跳过 localStorage 缓存)
|
|
const foundChatId = await initChatId(true);
|
|
logger.info(`角色切换 initChatId 完成 chatId=${foundChatId} roleId=${newRoleId}`);
|
|
|
|
// 9. 从后端加载新角色的对话历史
|
|
if (foundChatId) {
|
|
try {
|
|
await loadHistoryFromBackend();
|
|
} catch (err) {
|
|
logger.warn(`切换角色后加载后端历史失败 roleId=${newRoleId}`, err);
|
|
}
|
|
}
|
|
|
|
// 10. 隐藏新对话按钮 + 更新欢迎态
|
|
if (clearBtn && messages.length === 0) clearBtn.style.display = 'none';
|
|
updateEmptyState();
|
|
|
|
// 11. 重置滚动状态
|
|
isNearBottom = true;
|
|
if (messagesContainer) scrollToBottom(messagesContainer);
|
|
|
|
logger.info(`角色切换完成 ${oldRoleId} -> ${newRoleId}`);
|
|
}
|
|
|
|
// ==================== 会话管理面板 ====================
|
|
|
|
/** 加载会话列表并渲染 */
|
|
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, getActiveIntegrateId());
|
|
historyItems = result.list.map(c => ({
|
|
id: c.conversationId || '',
|
|
chatId: c.conversationId || '',
|
|
roleId: c.roleId,
|
|
roleName: c.roleName,
|
|
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 (!(await confirmWithDialog(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)
|
|
*/
|
|
async function switchToConversation(conversationId: string): Promise<void> {
|
|
if (!config || !messagesContainer) return;
|
|
|
|
// 找到该会话对应的历史条目,获取其所属角色
|
|
const historyItem = historyItems.find(
|
|
it => (it.chatId || it.id) === conversationId
|
|
);
|
|
|
|
// 如果会话所属角色与当前活跃角色不同,先静默切换角色
|
|
if (historyItem && historyItem.roleId !== undefined) {
|
|
const convRoleId = String(historyItem.roleId);
|
|
const currentRoleId = getActiveIntegrateId();
|
|
if (convRoleId && convRoleId !== currentRoleId) {
|
|
logger.info(`会话角色不匹配,自动切换角色 ${currentRoleId} -> ${convRoleId}`);
|
|
setActiveRoleId(convRoleId);
|
|
// 更新角色下拉框选中项
|
|
if (roleSelect) {
|
|
(roleSelect as HTMLSelectElement).value = convRoleId;
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`切换到会话 conversationId=${conversationId}`);
|
|
|
|
// 1. 更新 chatId
|
|
updateChatId(conversationId);
|
|
saveCachedChatId(getActiveIntegrateId(), 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();
|
|
}
|
|
|
|
// ==================== 建议问题(suggest-message-list) ====================
|
|
|
|
/**
|
|
* 拉取并展示 AI 推荐问题
|
|
* 在 AI 回复成功后调用,从后端缓存中读取本次对话生成的建议问题
|
|
*/
|
|
async function fetchAndShowSuggestions(aiMsgId: string): Promise<void> {
|
|
const chatId = getChatId();
|
|
if (!chatId || !messagesContainer) return;
|
|
try {
|
|
const suggestions = await fetchSuggestions(chatId);
|
|
if (suggestions.length > 0 && messagesContainer) {
|
|
const wrapper = messagesContainer.querySelector(`[data-csk-msg-id="${aiMsgId}"]`) as HTMLElement;
|
|
if (wrapper) renderSuggestions(wrapper, suggestions, sendQuickReply);
|
|
}
|
|
} catch (err) {
|
|
// 推荐问题拉取失败不影响主流程,仅记录日志便于排查
|
|
logger.warn('拉取推荐问题失败', err);
|
|
}
|
|
}
|