import type { ChatBizResult, ChatStreamCallbacks, MateChatConfig, MateChatMessage, } from '@af-mobile-client-vue3/components/common/MateChat/types' import { useChatHistoryCache } from '@af-mobile-client-vue3/components/common/MateChat/composables/useChatHistoryCache' import { useChatMessagesCache } from '@af-mobile-client-vue3/components/common/MateChat/composables/useChatMessagesCache' import { showToast } from 'vant' import { ref } from 'vue' import { chatBiz, chatCompletionsStream, getPaginationRecords } from '../apiService' /** * 生成随机 chatId */ function generateChatId(): string { return `chat_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` } /** * 封装 MateChat 核心对话逻辑的组合式函数 * - 负责 startPage / inputValue / messages 等状态 * - 根据配置中的 useStream 决定使用非流式(chatBiz)还是流式(chatCompletionsStream) * @param config MateChat 配置对象 */ export function useMateChat(config: MateChatConfig) { const startPage = ref(true) const inputValue = ref('') const messages = ref([]) const chatId = ref(generateChatId()) const useStream = config.useStream // 使用历史会话缓存 const { clearCache } = useChatHistoryCache() // 使用会话消息缓存 const { getCachedMessages, setCachedMessages, appendMessages } = useChatMessagesCache() /** * 新建会话:回到起始页并清空历史消息,生成新的 chatId * 同时清除历史会话列表缓存,以便下次打开时重新获取 */ function newConversation() { startPage.value = true messages.value = [] chatId.value = generateChatId() // 清除历史会话列表缓存,下次打开历史会话时会重新获取 clearCache(config.appId, config.appKey) } /** * 发送一条消息 * - 推入用户消息 * - 添加一条 loading 的模型消息 * - 根据 useStream 调用对应的接口 */ async function onSubmit(evt: string) { if (!evt.trim()) { return } // 如果是从空状态发起的新会话,清除历史会话列表缓存 const isNewConversation = startPage.value if (isNewConversation) { clearCache(config.appId, config.appKey) } inputValue.value = '' startPage.value = false // 用户发送消息 messages.value.push({ from: 'user', content: evt, }) // 添加 loading 状态的 model 消息 const loadingMessageIndex = messages.value.length messages.value.push({ from: 'model', content: '', loading: true, }) if (!useStream) { // 非流式:一次性拿到完整结果 try { const result: ChatBizResult = await chatBiz( evt, config.appId, config.appKey, chatId.value, ) if (result.type === 'transfer') { // 移除 loading 消息 messages.value.splice(loadingMessageIndex, 1) // 添加人工客服消息 messages.value.push({ from: 'service', content: '您好,客服xxx工号xxx为你服务。功能开发中,敬请期待。', }) // 更新缓存:追加用户消息和客服消息 appendMessages(chatId.value, [ { from: 'user', content: evt }, { from: 'service', content: '您好,客服xxx工号xxx为你服务。功能开发中,敬请期待。' }, ]) } else { let typing = true try { const parsed = JSON.parse(result.content) if (parsed && typeof parsed === 'object' && 'msgType' in (parsed as Record)) { typing = false } } catch {} // 正常消息:替换 loading 为模型回复,并标记为打字机消息 messages.value[loadingMessageIndex] = { from: 'model', content: result.content, loading: false, typing, } // 更新缓存:追加用户消息和模型回复 appendMessages(chatId.value, [ { from: 'user', content: evt }, // 缓存中仅用于渲染,不做二次打字机动画,这里也保留 typing 标记,方便后续扩展 { from: 'model', content: result.content, loading: false, typing: false }, ]) } } catch (error: any) { // 处理错误 console.error('聊天请求失败:', error) messages.value[loadingMessageIndex] = { from: 'model', content: '抱歉,服务暂时不可用,请稍后再试。', loading: false, } // 更新缓存:追加用户消息和错误消息 appendMessages(chatId.value, [ { from: 'user', content: evt }, { from: 'model', content: '抱歉,服务暂时不可用,请稍后再试。', loading: false }, ]) showToast(error?.message || '请求失败,请稍后再试') } return } // 流式:使用 FastGPT SSE,增量更新最后一条模型消息内容 // 规则: // 1)每次收到 chunk 都立即累加到 msg.content,保证实时流式效果 // 2)在累加后的内容上做一次“是否为 JSON 消息”的前缀粗判:以 {"msgType 开头 // 3)如果是判断为 JSON(可能是转人工或卡片),则保持 loading 状态,等待 onComplete 再统一处理整条消息 // 4)如果判断不是 JSON,则当作普通文本流式展示,onComplete 时仅结束 loading const jsonPrefix = '{"msgType' let checkedType = false // 是否已经做过类型判定 let isJsonMessage = false // 是否判定为 JSON 消息 const callbacks: ChatStreamCallbacks = { onMessage(chunk) { const msg = messages.value[loadingMessageIndex] if (!msg) { return } // 1. 实时累加内容,保证流式体验 msg.content += chunk // 2. 如果还没有做过类型判定,基于当前累积内容做一次前缀粗判 if (!checkedType) { const trimmed = msg.content.trimStart() // 内容太短,无法判断,继续等待更多 chunk if (!trimmed || trimmed.length < jsonPrefix.length) { return } if (trimmed.startsWith(jsonPrefix)) { // 以 {"msgType 开头,标记为“可能是 JSON 消息” isJsonMessage = true } else { // 不以 {"msgType 开头,当作普通文本处理 isJsonMessage = false msg.loading = false } checkedType = true } }, onComplete() { const msg = messages.value[loadingMessageIndex] if (!msg) { return } // 根据前面判定结果决定如何处理整条消息 if (isJsonMessage) { // 尝试按 JSON 解析 try { const contentText = msg.content.trim() const parsed = JSON.parse(contentText) if (parsed && parsed.msgType === 'transfer') { // 确认为转人工:移除模型气泡,插入客服气泡 messages.value.splice(loadingMessageIndex, 1) messages.value.push({ from: 'service', content: '您好,客服xxx工号xxx为你服务。功能开发中,敬请期待。', }) // 转人工情况:更新缓存 appendMessages(chatId.value, [ { from: 'user', content: evt }, { from: 'service', content: '您好,客服xxx工号xxx为你服务。功能开发中,敬请期待。' }, ]) return } // 如果是 card,不进入转人工逻辑,保持为普通模型回复气泡,UI 层会解析 content 展示卡片 msg.loading = false msg.typing = false // JSON 卡片不需要打字机效果 } catch { // 解析失败则回退为普通文本处理 msg.loading = false msg.typing = true } } else { // 普通消息:结束 loading msg.loading = false msg.typing = true } // 统一更新缓存 appendMessages(chatId.value, [ { from: 'user', content: evt }, { from: 'model', content: msg.content, loading: false, typing: false }, ]) }, onError(error) { console.error('聊天请求失败:', error) const msg = messages.value[loadingMessageIndex] if (!msg) { return } msg.content = '抱歉,服务暂时不可用,请稍后再试。' msg.loading = false // 更新缓存:追加用户消息和错误消息 appendMessages(chatId.value, [ { from: 'user', content: evt }, { from: 'model', content: '抱歉,服务暂时不可用,请稍后再试。', loading: false }, ]) showToast((error as any)?.message || '请求失败,请稍后再试') }, } try { await chatCompletionsStream( evt, config.appId, config.appKey, chatId.value, callbacks, ) } catch (error: any) { // 兜底错误处理(理论上 callbacks.onError 已经处理) console.error('聊天流式请求异常:', error) } } /** * 加载历史会话消息 * @param targetChatId 会话 ID */ async function loadHistoryMessages(targetChatId: string) { // 先检查缓存 const cachedMessages = getCachedMessages(targetChatId) if (cachedMessages) { // 使用缓存数据,历史消息不需要打字机效果 messages.value = cachedMessages.map(msg => ({ ...msg, typing: false, })) chatId.value = targetChatId startPage.value = false return } // 缓存不存在,从服务器获取 try { const response = await getPaginationRecords( config.appId, config.appKey, targetChatId, 0, 10, true, ) console.log('getPaginationRecords 响应:', response) // 检查响应格式 if (!response) { throw new Error('响应数据为空') } if (response.code !== 200) { throw new Error(response.message || '获取历史消息失败') } if (!response.data) { throw new Error('响应数据格式错误:缺少 data 字段') } if (!Array.isArray(response.data.list)) { throw new TypeError('响应数据格式错误:list 不是数组') } // 清空当前消息 messages.value = [] // 设置 chatId chatId.value = targetChatId // 转换历史消息格式 const historyMessages: MateChatMessage[] = [] for (const record of response.data.list) { if (record.hideInUI) { continue } // 提取消息内容 const content = record.value?.[0]?.text?.content || '' if (!content) { // 跳过空内容的消息 continue } if (record.obj === 'Human') { historyMessages.push({ from: 'user', content, }) } else if (record.obj === 'AI') { historyMessages.push({ from: 'model', content, loading: false, // 历史消息不需要打字机效果 typing: false, }) } } messages.value = historyMessages startPage.value = false // 缓存历史消息 setCachedMessages(targetChatId, historyMessages) console.log('历史消息加载成功,共', historyMessages.length, '条消息') } catch (error: any) { console.error('加载历史消息失败:', error) showToast(error?.message || '加载历史消息失败,请稍后再试') } } return { startPage, inputValue, messages, chatId, newConversation, onSubmit, loadHistoryMessages, } }