/** * 对话核心模块 - 发送/接收/渲染 * * 核心参数映射: * 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 { 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) 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 { 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 { 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 { // 创建中断控制器,供"停止生成"使用 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 { if (isSending) return Promise.resolve(); return handleSend(text); } /** * 重试:根据 AI 消息 ID 重新生成该回复 * 采用「从该轮重新开始」语义(与后台管理端 ChatPanel 一致): * 定位该 AI 回复所属的那轮用户提问,先调用后端截断接口删除该提问及其后的全部消息, * 再本地截断到该提问之前、重新渲染历史,最后重新发送该提问。 * 这样不会只删前端而留下后端旧消息,避免刷新后旧回复"复活"导致的数据错乱。 */ export async function retryFromMessage(msgId: string): Promise { 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 { 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; 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 { 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 { 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 (!(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 = `
${t('history_load_error')}
`; } } /** * 切换到指定会话:加载上下文并继续对话 * 如果会话所属角色与当前角色不同,自动同步切换角色 * @param conversationId 会话 ID(即 chatId) */ 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 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 { 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); } }