/** * 对话核心模块 - 发送/接收/渲染 * * 核心参数映射: * 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, setActiveRoleId, getActiveIntegrateId, 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 roleSelect: 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; roleSelect: 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; 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 { 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 { 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 || !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 = ``; } else { sendBtn.classList.remove('csk-send-btn--stop'); sendBtn.setAttribute('title', t('send')); sendBtn.setAttribute('aria-label', t('send')); sendBtn.innerHTML = ``; 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 { 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 { 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, 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 { // 创建中断控制器,供"停止生成"使用 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.innerHTML = renderMarkdown(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 { if (!inputEl || isSending) return Promise.resolve(); inputEl.value = text; updateSendBtnState(); return handleSend(); } /** * 重试:根据 AI 消息 ID 重新生成该回复 * 找到该 AI 消息及其上一条用户消息,移除该 AI 消息(及之后的所有消息)后重新请求 */ export async function retryFromMessage(msgId: string): Promise { 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 { 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 { if (!categorySelect) return; try { const tree = await fetchCategoryTree(); if (tree.length === 0) return; categorySelect.innerHTML = ``; 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 = config?.enableRag ?? true; logger.lifecycleCategoryChange(categoryId ?? '全部'); } // ==================== 角色切换 ==================== /** * 切换客服角色 * - 清理当前对话 + 生成新 chatId * - 更新 API 层的 roleId 参数 * - 重新加载新角色的对话历史 */ export async function switchRole(newRoleId: string): Promise { 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.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 { if (!historyPanel || !config) return; const listEl = historyPanel.querySelector('#csk-history-list') as HTMLElement; if (!listEl) return; listEl.innerHTML = `
加载中...
`; 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 (!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 = `
${t('history_load_error')}
`; } } /** * 切换到指定会话:加载上下文并继续对话 * 如果会话所属角色与当前角色不同,自动同步切换角色 * @param conversationId 会话 ID(即 chatId) */ export async function switchToConversation(conversationId: string): Promise { 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.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(); } /** 获取当前消息列表 */ export function getMessages(): ChatMessage[] { return messages; }