feat(h5): confidence gate + triage + image uploader + ws composable
新增 ConfidenceGateBanner/ImageUploader/TriageCard 组件与 useConfidenceGate; useH5WebSocket 增强; 对话/API 适配。
This commit is contained in:
@@ -185,6 +185,18 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
/** 参与者面板是否展开 */
|
||||
const participantPanelVisible = ref<boolean>(false)
|
||||
|
||||
// ==========================================================================
|
||||
// 流式 AI 回复(打字机)临时状态 — 改造方案 A
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 当前正在流式接收的 AI 消息临时气泡 ID
|
||||
* 做什么:记录打字机占位气泡的 message_id,流式 chunk 累积到该气泡,
|
||||
* 收到 ai_reply 终态后替换为真实消息
|
||||
* 为什么:员工端同时只有一个活跃会话,无需用 Map 存多会话
|
||||
*/
|
||||
const streamingAiMessageId = ref<string | null>(null)
|
||||
|
||||
// ==========================================================================
|
||||
// 消息去重相关状态(与 agent 端一致,WS-06 修复)
|
||||
// ==========================================================================
|
||||
@@ -518,11 +530,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
messages.value.push({ ...resp.user_message, status: 'sent' })
|
||||
}
|
||||
|
||||
// 将 AI 回复追加到本地列表(如果存在)
|
||||
if (resp.ai_reply) {
|
||||
messages.value.push(resp.ai_reply)
|
||||
lastMessageId.value = resp.ai_reply.message_id
|
||||
}
|
||||
// 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
|
||||
// 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
|
||||
// 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
|
||||
|
||||
// 更新「是否可呼叫坐席」标志
|
||||
canCallAgent.value = resp.can_call_agent ?? false
|
||||
@@ -961,6 +971,162 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WS 流式 AI 回复 chunk(打字机效果)
|
||||
* 做什么:首个 chunk 时创建临时 AI 气泡,后续 chunk 累积到该气泡 content
|
||||
* 为什么:后端 AI 推理(Dify)流式返回,逐字推送以获得打字机体验,
|
||||
* 避免整段等待(原同步方案卡"发送中"3~15s)
|
||||
*
|
||||
* @param data - { conversation_id, chunk }
|
||||
*/
|
||||
function handleAiReplyChunk(data: { conversation_id: string; chunk: string }): void {
|
||||
// 仅处理当前会话,避免串台
|
||||
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
||||
if (!data.chunk) return
|
||||
|
||||
// 首个 chunk:创建占位气泡(不设置 status,避免误显示"发送中")
|
||||
if (!streamingAiMessageId.value) {
|
||||
const tempId = `ai_stream_${data.conversation_id}_${Date.now()}`
|
||||
streamingAiMessageId.value = tempId
|
||||
messages.value.push({
|
||||
message_id: tempId,
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: 'ai',
|
||||
msg_type: 'text',
|
||||
content: '',
|
||||
sender_name: 'Duckula(达寇拉)',
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// 累积 chunk(Vue3 ref 深层响应式,直接改属性即可触发重渲染 → 打字机)
|
||||
const idx = messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
if (idx !== -1) {
|
||||
messages.value[idx].content += data.chunk
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WS AI 回复终态
|
||||
* 做什么:用真实 AI 消息(含 DB message_id)替换打字机占位气泡,
|
||||
* 登记 message_id 去重,同步计数 / 可呼叫坐席 / 会话状态
|
||||
* 为什么:打字机结束后需落定为真实消息,且防止轮询兜底重复添加同一消息
|
||||
*
|
||||
* @param data - 后端 _persist_and_push 推送的 ai_reply 数据
|
||||
*/
|
||||
function handleAiReply(data: {
|
||||
message_id: string
|
||||
conversation_id: string
|
||||
sender_type: string
|
||||
sender_id: string
|
||||
sender_name: string
|
||||
content: string
|
||||
msg_type: string
|
||||
is_guidance: boolean
|
||||
ai_reply_count: number
|
||||
can_call_agent: boolean
|
||||
conversation_status: string
|
||||
}): void {
|
||||
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
||||
|
||||
const finalMessage: Message = {
|
||||
message_id: data.message_id,
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: (data.sender_type || 'ai') as MessageType,
|
||||
msg_type: (data.msg_type || 'text') as MsgContentType,
|
||||
content: data.content,
|
||||
sender_name: data.sender_name || 'Duckula(达寇拉)',
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'sent',
|
||||
}
|
||||
|
||||
// 用真实消息替换占位气泡(若存在)
|
||||
const idx = streamingAiMessageId.value
|
||||
? messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
: -1
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = finalMessage
|
||||
} else {
|
||||
// 没有占位气泡(如 WS 重连后首条 ai_reply),直接追加
|
||||
messages.value.push(finalMessage)
|
||||
}
|
||||
streamingAiMessageId.value = null
|
||||
|
||||
// 去重登记:防止轮询兜底重复添加同一 AI 消息
|
||||
trackProcessedMessageId(data.message_id)
|
||||
lastMessageId.value = data.message_id
|
||||
|
||||
// 同步计数与可呼叫坐席状态
|
||||
canCallAgent.value = data.can_call_agent ?? false
|
||||
if (currentConversation.value) {
|
||||
currentConversation.value.can_call_agent = data.can_call_agent ?? false
|
||||
currentConversation.value.ai_substantive_reply_count = data.ai_reply_count ?? 0
|
||||
// 后端状态值(ai_handling/queued/serving/resolved)映射到 H5 识别的子集
|
||||
// H5 ConversationInfo.status 仅接受 waiting/serving/resolved,
|
||||
// ai_handling/queued 均表示"进行中未结单",映射为 waiting
|
||||
const statusMap: Record<string, 'waiting' | 'serving' | 'resolved'> = {
|
||||
ai_handling: 'waiting',
|
||||
queued: 'waiting',
|
||||
serving: 'serving',
|
||||
resolved: 'resolved',
|
||||
}
|
||||
currentConversation.value.status =
|
||||
statusMap[data.conversation_status] || currentConversation.value.status
|
||||
}
|
||||
|
||||
// 持久化缓存
|
||||
const convId = currentConversation.value?.conversation_id
|
||||
if (convId) saveMessagesToCache(convId, messages.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WS AI 回复失败(兜底)
|
||||
* 做什么:将占位气泡(或新气泡)替换为错误提示,引导用户转人工
|
||||
* 为什么:Dify 异常时不应让用户一直看到空白打字气泡
|
||||
*
|
||||
* @param data - { conversation_id, message }
|
||||
*/
|
||||
function handleAiReplyFailed(data: { conversation_id: string; message: string }): void {
|
||||
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
||||
|
||||
const errorMsg: Message = {
|
||||
message_id: `ai_failed_${data.conversation_id}_${Date.now()}`,
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: 'ai',
|
||||
msg_type: 'text',
|
||||
content: data.message || '⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。',
|
||||
sender_name: 'Duckula(达寇拉)',
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'sent',
|
||||
}
|
||||
|
||||
// 若仍有占位气泡,替换之;否则直接追加
|
||||
const idx = streamingAiMessageId.value
|
||||
? messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
: -1
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = errorMsg
|
||||
} else {
|
||||
messages.value.push(errorMsg)
|
||||
}
|
||||
streamingAiMessageId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消流式气泡(WS 断连时调用)
|
||||
* 做什么:若 WS 在流式推送中途断开,移除未完成的占位气泡,
|
||||
* 交由 3 秒轮询兜底重新拉取完整 AI 消息
|
||||
* 为什么:避免断连后留下半成品打字气泡
|
||||
*/
|
||||
function cancelStreamingBubble(): void {
|
||||
if (!streamingAiMessageId.value) return
|
||||
const idx = messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
if (idx !== -1) {
|
||||
messages.value.splice(idx, 1)
|
||||
}
|
||||
streamingAiMessageId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用
|
||||
* 1. 获取用户信息
|
||||
@@ -1129,5 +1295,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
|
||||
handleNewMessage,
|
||||
|
||||
// 流式 AI 回复(改造方案 A:打字机)
|
||||
handleAiReplyChunk,
|
||||
handleAiReply,
|
||||
handleAiReplyFailed,
|
||||
cancelStreamingBubble,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user