feat(h5): confidence gate + triage + image uploader + ws composable

新增 ConfidenceGateBanner/ImageUploader/TriageCard 组件与 useConfidenceGate; useH5WebSocket 增强; 对话/API 适配。
This commit is contained in:
Simon
2026-07-09 11:49:50 +08:00
parent 5e53146a9a
commit 12d89dfb35
18 changed files with 1712 additions and 20 deletions
+177 -5
View File
@@ -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(),
})
}
// 累积 chunkVue3 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,
}
})