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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
*.log
+1 -1
View File
@@ -16,7 +16,7 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* ./
# 安装依赖 # 安装依赖
RUN pnpm install --frozen-lockfile RUN pnpm install --no-frozen-lockfile
# 复制源码(后续通过 volume mount 更新) # 复制源码(后续通过 volume mount 更新)
COPY . . COPY . .
+9
View File
@@ -14,7 +14,9 @@ declare module 'vue' {
CallAgentModal: typeof import('./src/components/chat/CallAgentModal.vue')['default'] CallAgentModal: typeof import('./src/components/chat/CallAgentModal.vue')['default']
ChatPanel: typeof import('./src/components/chat/ChatPanel.vue')['default'] ChatPanel: typeof import('./src/components/chat/ChatPanel.vue')['default']
ComingSoon: typeof import('./src/components/assistant/ComingSoon.vue')['default'] ComingSoon: typeof import('./src/components/assistant/ComingSoon.vue')['default']
ConfidenceGateBanner: typeof import('./src/components/ConfidenceGateBanner.vue')['default']
EvaluationDialog: typeof import('./src/components/chat/EvaluationDialog.vue')['default'] EvaluationDialog: typeof import('./src/components/chat/EvaluationDialog.vue')['default']
ImageUploader: typeof import('./src/components/ImageUploader.vue')['default']
InputBar: typeof import('./src/components/chat/InputBar.vue')['default'] InputBar: typeof import('./src/components/chat/InputBar.vue')['default']
InputBox: typeof import('./src/components/chat/InputBox.vue')['default'] InputBox: typeof import('./src/components/chat/InputBox.vue')['default']
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default'] MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
@@ -28,16 +30,23 @@ declare module 'vue' {
ScreenshotEditor: typeof import('./src/components/chat/ScreenshotEditor.vue')['default'] ScreenshotEditor: typeof import('./src/components/chat/ScreenshotEditor.vue')['default']
ShakeButton: typeof import('./src/components/chat/ShakeButton.vue')['default'] ShakeButton: typeof import('./src/components/chat/ShakeButton.vue')['default']
SoftwareDownloads: typeof import('./src/components/assistant/SoftwareDownloads.vue')['default'] SoftwareDownloads: typeof import('./src/components/assistant/SoftwareDownloads.vue')['default']
TriageCard: typeof import('./src/components/TriageCard.vue')['default']
TroubleshootFlow: typeof import('./src/components/chat/TroubleshootFlow.vue')['default'] TroubleshootFlow: typeof import('./src/components/chat/TroubleshootFlow.vue')['default']
TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default'] TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default']
VanButton: typeof import('vant/es')['Button'] VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanConfigProvider: typeof import('vant/es')['ConfigProvider'] VanConfigProvider: typeof import('vant/es')['ConfigProvider']
VanDialog: typeof import('vant/es')['Dialog'] VanDialog: typeof import('vant/es')['Dialog']
VanEmpty: typeof import('vant/es')['Empty'] VanEmpty: typeof import('vant/es')['Empty']
VanField: typeof import('vant/es')['Field'] VanField: typeof import('vant/es')['Field']
VanIcon: typeof import('vant/es')['Icon'] VanIcon: typeof import('vant/es')['Icon']
VanLoading: typeof import('vant/es')['Loading'] VanLoading: typeof import('vant/es')['Loading']
VanNavBar: typeof import('vant/es')['NavBar']
VanPopup: typeof import('vant/es')['Popup'] VanPopup: typeof import('vant/es')['Popup']
VanRate: typeof import('vant/es')['Rate'] VanRate: typeof import('vant/es')['Rate']
VanStep: typeof import('vant/es')['Step']
VanSteps: typeof import('vant/es')['Steps']
VanTag: typeof import('vant/es')['Tag']
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
<!-- 移动端视口设置(适配企微 WebView) --> <!-- 移动端视口设置(适配企微 WebView) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- CSP 安全策略 --> <!-- CSP 安全策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://localhost:8000 ws://127.0.0.1 ws://127.0.0.1:8000;" />
<!-- 页面标题 --> <!-- 页面标题 -->
<title>智能IT支持服务台</title> <title>智能IT支持服务台</title>
<!-- 首屏骨架屏样式 v0.5.2 强化版 --> <!-- 首屏骨架屏样式 v0.5.2 强化版 -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+7 -2
View File
@@ -173,8 +173,13 @@ export interface SoftwareDownload {
export interface SendMessageResponse { export interface SendMessageResponse {
/** 用户发送的消息 */ /** 用户发送的消息 */
user_message: Message user_message: Message
/** AI 自动回复消息 */ /**
ai_reply: Message * AI 自动回复消息
* 注意:改造方案 A 后,HTTP 接口不再同步返回 AI 回复(恒为 null),
* AI 回复由后台任务经 WebSocket 流式推回(ai_reply_chunk / ai_reply)。
* 此处可能为 null,调用方(store)需改为依赖 WS 事件。
*/
ai_reply: Message | null
/** 是否为引导类回复(打招呼/呼叫人工),不计入实质回复 */ /** 是否为引导类回复(打招呼/呼叫人工),不计入实质回复 */
is_guidance: boolean is_guidance: boolean
/** 当前 AI 实质性回复计数 */ /** 当前 AI 实质性回复计数 */
+1 -1
View File
@@ -77,7 +77,7 @@ apiClient.interceptors.response.use(
} }
// 业务成功:Scheme A — 直接返回 inner data(三端统一契约) // 业务成功:Scheme A — 直接返回 inner data(三端统一契约)
return res.data return res.data as any
}, },
async (error) => { async (error) => {
// 网络错误或服务器错误:统一 reject {code, message}CTRT-03 // 网络错误或服务器错误:统一 reject {code, message}CTRT-03
@@ -7,7 +7,6 @@
// ============================================================================= // =============================================================================
import apiClient from './index' import apiClient from './index'
import type { AxiosResponse } from 'axios'
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致 // TypeScript 类型定义 — 与后端 Schema 保持一致
@@ -96,7 +95,7 @@ export interface TroubleshootingTemplateQuery {
export async function getTroubleshootingTemplates( export async function getTroubleshootingTemplates(
params?: TroubleshootingTemplateQuery, params?: TroubleshootingTemplateQuery,
): Promise<TroubleshootingTemplateListData> { ): Promise<TroubleshootingTemplateListData> {
const response: AxiosResponse = await apiClient.get('/troubleshooting-templates', { const response = await apiClient.get('/troubleshooting-templates', {
params: { params: {
category: params?.category || undefined, category: params?.category || undefined,
page: params?.page || 1, page: params?.page || 1,
@@ -115,6 +114,6 @@ export async function getTroubleshootingTemplates(
export async function getTroubleshootingTemplate( export async function getTroubleshootingTemplate(
id: string, id: string,
): Promise<TroubleshootingTemplate> { ): Promise<TroubleshootingTemplate> {
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`) const response = await apiClient.get(`/troubleshooting-templates/${id}`)
return response return response
} }
@@ -0,0 +1,331 @@
<!-- =============================================================================
企微IT智能服务台 H5员工端 置信门控横幅Tier1 / P0-3 / D3
=============================================================================
说明 AI 回复的 confidence 低于全局阈值 0.7
在前端渲染"转人工"入口并附已收集的上下文快照
D3 硬约束
- confidence < 0.7 渲染"转人工"卡片 + 已收集上下文
- 阈值可配置通过 settings.confidence_gate_threshold
- 转人工动作携带上下文快照
- 概率展示为百分比不显示原始 confidence
============================================================================= -->
<template>
<div v-if="visible" class="confidence-gate-banner" :class="severityClass">
<!-- 主提示 -->
<div class="confidence-gate-banner__main">
<span class="confidence-gate-banner__icon">{{ severityIcon }}</span>
<span class="confidence-gate-banner__text">{{ severityText }}</span>
</div>
<!-- 推荐概率 -->
<div class="confidence-gate-banner__confidence">
<span class="confidence-gate-banner__confidence-label">AI 把握度</span>
<span class="confidence-gate-banner__confidence-value">
{{ displayProbability }}%
</span>
</div>
<!-- 已收集上下文快照 -->
<div v-if="contextSnapshot.length > 0" class="confidence-gate-banner__context">
<div class="confidence-gate-banner__context-label">📋 已收集的信息</div>
<div class="confidence-gate-banner__context-list">
<span
v-for="(item, idx) in contextSnapshot"
:key="idx"
class="confidence-gate-banner__context-item"
>
{{ item }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="confidence-gate-banner__actions">
<van-button
round
type="danger"
size="small"
:loading="transferring"
@click="handleTransferToHuman"
>
转人工处理
</van-button>
<van-button
round
type="default"
size="small"
@click="handleContinueAI"
>
继续询问 AI
</van-button>
</div>
<!-- 可选拒绝理由选择 -->
<div v-if="showRejectOptions" class="confidence-gate-banner__reject">
<div class="confidence-gate-banner__reject-label">AI 回复不准确帮助我们改进</div>
<div class="confidence-gate-banner__reject-tags">
<van-tag
v-for="reason in rejectReasons"
:key="reason"
:type="selectedRejectReason === reason ? 'danger' : 'default'"
size="medium"
plain
@click="selectRejectReason(reason)"
>
{{ reason }}
</van-tag>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
/** 组件属性 */
const props = withDefaults(defineProps<{
/** 是否可见(confidence < threshold 时展示) */
visible: boolean
/** AI 原始置信度(0.0-1.0,内部使用) */
confidence?: number
/** 已收集的上下文快照 */
contextSnapshot?: string[]
/** 门控触发原因 */
reason?: string
}>(), {
visible: false,
confidence: 0,
contextSnapshot: () => [],
reason: '',
})
/** 组件事件 */
const emit = defineEmits<{
/** 点击转人工 */
(e: 'transfer-human', context: string[]): void
/** 继续询问 AI */
(e: 'continue-ai'): void
/** 提交拒绝理由 */
(e: 'feedback', reason: string): void
}>()
// ===========================================================================
// 常量
// ===========================================================================
/** 预设拒绝理由选项 */
const rejectReasons = [
'答非所问',
'信息不完整',
'回答不准确',
'过于笼统',
'其他',
]
// ===========================================================================
// 状态
// ===========================================================================
/** 是否正在转人工 */
const transferring = ref(false)
/** 是否展示拒绝理由 */
const showRejectOptions = ref(false)
/** 选中的拒绝理由 */
const selectedRejectReason = ref('')
// ===========================================================================
// 计算属性
// ===========================================================================
/** 展示用概率(百分比,整数,不披露原始confidence */
const displayProbability = computed(() => {
const conf = props.confidence ?? 0
return Math.round(conf * 100)
})
/** 严重程度级别 */
const severityClass = computed(() => {
const conf = props.confidence ?? 0
if (conf < 0.3) return 'confidence-gate-banner--critical'
if (conf < 0.5) return 'confidence-gate-banner--warning'
return 'confidence-gate-banner--caution'
})
/** 严重程度图标 */
const severityIcon = computed(() => {
const conf = props.confidence ?? 0
if (conf < 0.3) return '🚨'
if (conf < 0.5) return '⚠️'
return '💡'
})
/** 严重程度文本 */
const severityText = computed(() => {
const conf = props.confidence ?? 0
if (conf < 0.3) return 'AI 非常不确定此回复,建议转人工处理'
if (conf < 0.5) return 'AI 对此回复把握较低,可能需要人工协助'
return 'AI 对此回复不太确定,你可选择转人工确认'
})
// ===========================================================================
// 方法
// ===========================================================================
/** 转人工处理 */
async function handleTransferToHuman() {
transferring.value = true
try {
emit('transfer-human', props.contextSnapshot ?? [])
// 展示反馈选项
showRejectOptions.value = true
} finally {
transferring.value = false
}
}
/** 继续询问 AI */
function handleContinueAI() {
emit('continue-ai')
showRejectOptions.value = false
}
/** 选择拒绝理由 */
function selectRejectReason(reason: string) {
selectedRejectReason.value = reason
emit('feedback', reason)
}
</script>
<style scoped>
/* =========================================================================
置信门控横幅 — 置顶悬浮,醒目样式
========================================================================= */
.confidence-gate-banner {
margin: 8px 12px;
padding: 12px 14px;
border-radius: 10px;
border-left: 4px solid;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 严重程度配色 */
.confidence-gate-banner--critical {
background: #fff0f0;
border-color: #ee0a24;
}
.confidence-gate-banner--warning {
background: #fff7f0;
border-color: #ff976a;
}
.confidence-gate-banner--caution {
background: #f0f7ff;
border-color: #1989fa;
}
/* 主提示 */
.confidence-gate-banner__main {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.confidence-gate-banner__icon {
font-size: 16px;
}
.confidence-gate-banner__text {
font-size: 13px;
font-weight: 500;
color: #323233;
line-height: 1.4;
}
/* AI 把握度 */
.confidence-gate-banner__confidence {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
font-size: 12px;
}
.confidence-gate-banner__confidence-label {
color: #969799;
}
.confidence-gate-banner__confidence-value {
font-weight: 700;
color: #ee0a24;
}
/* 上下文快照 */
.confidence-gate-banner__context {
margin-bottom: 10px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.6);
border-radius: 6px;
}
.confidence-gate-banner__context-label {
font-size: 11px;
color: #969799;
margin-bottom: 4px;
}
.confidence-gate-banner__context-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.confidence-gate-banner__context-item {
font-size: 11px;
padding: 1px 6px;
background: #e8e8e8;
border-radius: 3px;
color: #646566;
}
/* 操作按钮 */
.confidence-gate-banner__actions {
display: flex;
gap: 8px;
}
/* 拒绝理由 */
.confidence-gate-banner__reject {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.confidence-gate-banner__reject-label {
font-size: 11px;
color: #969799;
margin-bottom: 6px;
}
.confidence-gate-banner__reject-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>
@@ -0,0 +1,413 @@
<!-- =============================================================================
企微IT智能服务台 H5员工端 截图上传组件Tier1 / P1-3 / D5
=============================================================================
说明员工截图上传和中途补图组件调用 /api/vision/analyze 获取
Qwen-VL 视觉模型的结构化描述并将结果注入对话上下文
D5 硬约束
- 截图理解经 Dify 后端调用本地 Qwen-VL
- 支持中途补图会话中追加图片
- 展示"AI 已理解截图内容"反馈
============================================================================= -->
<template>
<div class="image-uploader">
<!-- 上传触发按钮 -->
<div v-if="!selectedFile" class="image-uploader__trigger" @click="openFilePicker">
<span class="image-uploader__trigger-icon">📷</span>
<span class="image-uploader__trigger-text">发截图</span>
</div>
<!-- 已选图片预览 -->
<div v-else class="image-uploader__preview">
<div class="image-uploader__preview-wrapper">
<img
:src="previewUrl"
alt="截图预览"
class="image-uploader__preview-img"
/>
<div class="image-uploader__preview-remove" @click="removeFile">
</div>
</div>
<!-- 追加描述文字 -->
<div class="image-uploader__desc-section">
<textarea
v-model="description"
class="image-uploader__desc-input"
placeholder="补充描述(可选):描述你遇到的问题..."
rows="2"
maxlength="200"
/>
<span class="image-uploader__desc-count">{{ description.length }}/200</span>
</div>
<!-- 操作按钮 -->
<div class="image-uploader__actions">
<van-button
round
type="default"
size="small"
@click="removeFile"
>
取消
</van-button>
<van-button
round
type="primary"
size="small"
:loading="analyzing"
@click="sendImage"
>
{{ analyzing ? 'AI 分析中...' : '发送' }}
</van-button>
</div>
</div>
<!-- 视觉理解结果展示 -->
<div v-if="visionResult" class="image-uploader__result">
<div class="image-uploader__result-header">
<span class="image-uploader__result-icon">🔍</span>
<span class="image-uploader__result-title">AI 已理解截图内容</span>
<span class="image-uploader__result-confidence">
把握度 {{ visionConfidence }}%
</span>
</div>
<div class="image-uploader__result-body">
{{ visionResult }}
</div>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="image-uploader__error">
{{ errorMessage }}
</div>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInput"
type="file"
accept="image/*"
class="image-uploader__file-input"
@change="onFileSelected"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
/** 支持的文件类型 */
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
/** 文件大小上限(10MB */
const MAX_SIZE = 10 * 1024 * 1024
/** 组件属性 */
const props = defineProps<{
/** 会话 ID */
conversationId: string
}>()
/** 组件事件 */
const emit = defineEmits<{
/** 图片发送成功(含视觉描述) */
(e: 'sent', data: { imageBase64: string; description: string; visionResult: any }): void
/** 发送出错 */
(e: 'error', message: string): void
}>()
// ===========================================================================
// 状态
// ===========================================================================
/** 文件选择器引用 */
const fileInput = ref<HTMLInputElement>()
/** 选中的文件 */
const selectedFile = ref<File | null>(null)
/** 预览 URL */
const previewUrl = ref('')
/** 补充描述文字 */
const description = ref('')
/** 是否正在分析 */
const analyzing = ref(false)
/** 视觉理解结果文本 */
const visionResult = ref('')
/** 视觉理解置信度 */
const visionConfidence = ref(0)
/** 错误消息 */
const errorMessage = ref('')
// ===========================================================================
// 方法
// ===========================================================================
/** 打开文件选择器 */
function openFilePicker() {
fileInput.value?.click()
}
/** 文件选择回调 */
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// 校验类型
if (!ALLOWED_TYPES.includes(file.type)) {
errorMessage.value = '不支持的图片格式,请选择 PNG/JPG/GIF/WebP'
return
}
// 校验大小
if (file.size > MAX_SIZE) {
errorMessage.value = `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB),最大 10MB`
return
}
errorMessage.value = ''
selectedFile.value = file
// 生成预览 URL
const reader = new FileReader()
reader.onload = (e) => {
previewUrl.value = e.target?.result as string
}
reader.readAsDataURL(file)
}
/** 移除选中文件 */
function removeFile() {
selectedFile.value = null
previewUrl.value = ''
description.value = ''
visionResult.value = ''
visionConfidence.value = 0
errorMessage.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
/** 发送图片并获取视觉理解结果 */
async function sendImage() {
if (!selectedFile.value) return
analyzing.value = true
errorMessage.value = ''
try {
// 构建 FormData
const formData = new FormData()
formData.append('image', selectedFile.value)
formData.append('conversation_id', props.conversationId)
if (description.value) {
formData.append('description_hint', description.value)
}
// 调用视觉理解 API
const response = await fetch('/api/vision/analyze', {
method: 'POST',
body: formData,
headers: {
// 不要设置 Content-Type,让浏览器自动处理 multipart/form-data
},
})
const result = await response.json()
if (result.code === 0 && result.data) {
visionResult.value = result.data.description || ''
visionConfidence.value = Math.round((result.data.confidence || 0) * 100)
// 触发发送事件
emit('sent', {
imageBase64: previewUrl.value,
description: result.data.description || description.value,
visionResult: result.data,
})
} else {
errorMessage.value = result.message || '视觉分析失败,请重试'
emit('error', errorMessage.value)
}
} catch (err: any) {
errorMessage.value = '网络异常,视觉分析请求失败'
emit('error', errorMessage.value)
} finally {
analyzing.value = false
}
}
</script>
<style scoped>
/* =========================================================================
截图上传组件 — H5 移动端适配
========================================================================= */
.image-uploader {
margin: 4px 0;
}
/* 触发按钮 */
.image-uploader__trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: #f7f8fa;
border: 1px dashed #c8c9cc;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.image-uploader__trigger:hover {
background: #ecf5ff;
border-color: #1989fa;
}
.image-uploader__trigger-icon {
font-size: 16px;
}
.image-uploader__trigger-text {
font-size: 13px;
color: #646566;
}
/* 预览区域 */
.image-uploader__preview {
background: #f7f8fa;
border-radius: 10px;
padding: 10px;
}
.image-uploader__preview-wrapper {
position: relative;
display: inline-block;
border-radius: 8px;
overflow: hidden;
margin-bottom: 8px;
}
.image-uploader__preview-img {
max-width: 200px;
max-height: 200px;
display: block;
border-radius: 8px;
object-fit: contain;
border: 1px solid #ebedf0;
}
.image-uploader__preview-remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
background: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
cursor: pointer;
}
/* 描述输入 */
.image-uploader__desc-section {
position: relative;
margin-bottom: 8px;
}
.image-uploader__desc-input {
width: 100%;
padding: 8px;
border: 1px solid #ebedf0;
border-radius: 6px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
}
.image-uploader__desc-input:focus {
border-color: #1989fa;
}
.image-uploader__desc-count {
position: absolute;
bottom: 4px;
right: 8px;
font-size: 10px;
color: #c8c9cc;
}
/* 操作按钮 */
.image-uploader__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 分析结果 */
.image-uploader__result {
margin-top: 8px;
padding: 10px 12px;
background: #f0f7ff;
border-radius: 8px;
border-left: 3px solid #1989fa;
}
.image-uploader__result-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.image-uploader__result-icon {
font-size: 14px;
}
.image-uploader__result-title {
font-size: 12px;
font-weight: 600;
color: #1989fa;
}
.image-uploader__result-confidence {
font-size: 11px;
color: #07c160;
margin-left: auto;
}
.image-uploader__result-body {
font-size: 13px;
color: #323233;
line-height: 1.5;
}
/* 错误提示 */
.image-uploader__error {
margin-top: 8px;
padding: 6px 10px;
background: #fff0f0;
border-radius: 6px;
font-size: 12px;
color: #ee0a24;
}
/* 隐藏的文件选择器 */
.image-uploader__file-input {
display: none;
}
</style>
+529
View File
@@ -0,0 +1,529 @@
<!-- =============================================================================
企微IT智能服务台 H5员工端 分诊置顶卡片Tier1 / P1-1 / D4
=============================================================================
说明AI 分诊式回复卡片将复杂问题拆分为分步选择题//含概率推荐
卡片置顶悬浮展示支持专家模式切换
D4 硬约束
- 一次给几步由 AI 判复杂度自适应
- 概率展示为百分比不披露原始 confidence 给员工
- 专家模式默认关分步老手可开一把梭
- 卡片置顶/悬浮
============================================================================= -->
<template>
<div v-if="visible" class="triage-card" :class="{ 'triage-card--collapsed': collapsed }">
<!-- 标题栏步骤进度 + 折叠按钮 -->
<div class="triage-card__header" @click="toggleCollapse">
<div class="triage-card__step-badge">
<span class="triage-card__step-icon">🤖</span>
<span class="triage-card__step-text">
AI 分诊 {{ currentStep }}/{{ totalSteps }}
</span>
</div>
<div class="triage-card__header-right">
<!-- 专家模式开关 -->
<van-switch
v-model="expertMode"
size="20px"
active-color="#1989fa"
@click.stop
@change="onExpertModeChange"
/>
<span class="triage-card__expert-label">专家模式</span>
<span class="triage-card__collapse-arrow">
{{ collapsed ? '展开' : '收起' }}
</span>
</div>
</div>
<!-- 题目区域 -->
<div v-show="!collapsed" class="triage-card__body">
<!-- 问题文本 -->
<div class="triage-card__question">
Q: {{ currentQuestion }}
</div>
<!-- 选项列表 -->
<div class="triage-card__options">
<div
v-for="(option, idx) in currentOptions"
:key="idx"
class="triage-card__option"
:class="{
'triage-card__option--selected': option.selected,
'triage-card__option--recommended': option.recommended && !option.selected,
'triage-card__option--excluded': option.excluded,
}"
@click="selectOption(idx)"
>
<div class="triage-card__option-radio">
<span v-if="option.selected" class="triage-card__option-dot" />
</div>
<span class="triage-card__option-label">{{ option.label }}</span>
<!-- 概率推荐标签仅百分比不披露原始confidence -->
<span
v-if="option.probability !== undefined"
class="triage-card__option-probability"
:class="{ 'triage-card__option-probability--high': option.probability >= 70 }"
>
{{ option.probability }}%
</span>
<!-- 推荐标记 -->
<span v-if="option.recommended" class="triage-card__option-recommend">推荐</span>
<!-- 排除标记 -->
<span v-if="option.excluded" class="triage-card__option-excluded">已排除</span>
</div>
</div>
<!-- 已收集上下文P0-3 转人工时附带 -->
<div v-if="collectedContext.length > 0" class="triage-card__context">
<div class="triage-card__context-title"> 已收集上下文</div>
<div class="triage-card__context-tags">
<span
v-for="(ctx, idx) in collectedContext"
:key="idx"
class="triage-card__context-tag"
>
{{ ctx }}
</span>
</div>
</div>
<!-- 专家模式下一步展示 -->
<div v-if="expertMode && nextSteps.length > 0" class="triage-card__expert-steps">
<div class="triage-card__expert-steps-title">📋 后续步骤预览</div>
<div
v-for="(step, idx) in nextSteps"
:key="idx"
class="triage-card__expert-step-item"
>
<span class="triage-card__expert-step-num">{{ idx + 1 + currentStep }}</span>
{{ step.question }}
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div v-show="!collapsed" class="triage-card__footer">
<van-button
round
type="default"
size="small"
@click="handleSkip"
>
跳过
</van-button>
<van-button
round
type="primary"
size="small"
:disabled="!hasSelection"
@click="handleConfirm"
>
{{ isLastStep ? '完成' : '下一步' }}
</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
/** 单个选项结构 */
interface TriageOption {
/** 选项标签 */
label: string
/** 是否被用户选中 */
selected: boolean
/** AI 推荐概率(百分比 0-100,不披露原始confidence */
probability?: number
/** 坐席推荐标记 */
recommended?: boolean
/** 坐席排除标记 */
excluded?: boolean
}
/** 单个分诊步骤 */
interface TriageStep {
/** 问题文本 */
question: string
/** 选项列表 */
options: TriageOption[]
/** 该步骤已收集的上下文 */
collectedContext: string[]
}
/** 组件属性 */
const props = defineProps<{
/** 是否可见 */
visible: boolean
/** 当前步骤序号(1-based */
step: number
/** 总步骤数 */
total: number
/** 所有步骤数据 */
steps: TriageStep[]
/** AI 置信度(内部使用,不展示给员工) */
confidence?: number
}>()
/** 组件事件 */
const emit = defineEmits<{
/** 确认当前步骤 */
(e: 'confirm', stepIndex: number, selectedLabel: string): void
/** 跳过当前步骤 */
(e: 'skip', stepIndex: number): void
/** 专家模式切换 */
(e: 'expert-mode-change', enabled: boolean): void
/** 转人工 */
(e: 'transfer-human', context: string[]): void
}>()
// ===========================================================================
// 状态
// ===========================================================================
/** 是否折叠卡片 */
const collapsed = ref(false)
/** 专家模式开关(默认关) */
const expertMode = ref(false)
/** 当前步骤序号(0-based */
const currentStepIndex = computed(() => Math.max(0, Math.min(props.step - 1, props.steps.length - 1)))
/** 当前步骤数据 */
const currentStepData = computed(() => props.steps[currentStepIndex.value] ?? null)
/** 当前问题文本 */
const currentQuestion = computed(() => currentStepData.value?.question ?? '')
/** 当前选项列表 */
const currentOptions = computed(() => currentStepData.value?.options ?? [])
/** 已收集上下文(所有步骤的上下文汇总) */
const collectedContext = computed(() => {
const ctx: string[] = []
for (let i = 0; i <= currentStepIndex.value; i++) {
const step = props.steps[i]
if (step?.collectedContext) {
ctx.push(...step.collectedContext)
}
}
return ctx
})
/** 总步骤数 */
const totalSteps = computed(() => props.total || props.steps.length)
/** 当前步骤(1-based 展示用) */
const currentStep = computed(() => currentStepIndex.value + 1)
/** 是否有选中项 */
const hasSelection = computed(() => currentOptions.value.some(o => o.selected))
/** 是否为最后一步 */
const isLastStep = computed(() => currentStep.value >= totalSteps.value)
/** 后续步骤预览(专家模式) */
const nextSteps = computed(() => {
if (!expertMode.value || isLastStep.value) return []
return props.steps.slice(currentStepIndex.value + 1)
})
// ===========================================================================
// 方法
// ===========================================================================
/** 折叠/展开卡片 */
function toggleCollapse() {
collapsed.value = !collapsed.value
}
/** 选择选项 */
function selectOption(idx: number) {
// 被排除的选项不可选
const option = currentOptions.value[idx]
if (option?.excluded) return
// 单选模式:取消其他,选中当前
currentOptions.value.forEach((o, i) => {
o.selected = i === idx && !o.selected
})
}
/** 确认当前步骤 */
function handleConfirm() {
const selected = currentOptions.value.find(o => o.selected)
if (selected) {
emit('confirm', currentStepIndex.value, selected.label)
}
}
/** 跳过当前步骤 */
function handleSkip() {
emit('skip', currentStepIndex.value)
}
/** 专家模式切换 */
function onExpertModeChange(enabled: boolean) {
emit('expert-mode-change', enabled)
}
/** 暴露方法供父组件调用 */
defineExpose({
/** 设置坐席排除项 */
setExcludedOptions(labels: string[]) {
currentOptions.value.forEach(o => {
if (labels.includes(o.label)) {
o.excluded = true
o.selected = false
}
})
},
/** 设置坐席推荐项 */
setRecommendedOption(label: string) {
currentOptions.value.forEach(o => {
o.recommended = o.label === label
})
},
})
</script>
<style scoped>
/* =========================================================================
分诊卡片 — 置顶悬浮、Vant 风格
========================================================================= */
.triage-card {
position: sticky;
top: 0;
z-index: 100;
margin: 8px 12px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s ease;
}
.triage-card--collapsed {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* 标题栏 */
.triage-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4fd 100%);
cursor: pointer;
user-select: none;
}
.triage-card__step-badge {
display: flex;
align-items: center;
gap: 6px;
}
.triage-card__step-icon {
font-size: 16px;
}
.triage-card__step-text {
font-size: 13px;
font-weight: 600;
color: #1989fa;
}
.triage-card__header-right {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #969799;
}
.triage-card__expert-label {
margin-right: 8px;
}
.triage-card__collapse-arrow {
font-size: 12px;
color: #1989fa;
}
/* 内容区 */
.triage-card__body {
padding: 12px 14px;
}
.triage-card__question {
font-size: 15px;
font-weight: 500;
color: #323233;
margin-bottom: 12px;
line-height: 1.5;
}
/* 选项列表 */
.triage-card__options {
display: flex;
flex-direction: column;
gap: 8px;
}
.triage-card__option {
display: flex;
align-items: center;
padding: 10px 12px;
border: 1px solid #ebedf0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
gap: 8px;
}
.triage-card__option:hover {
background: #f7f8fa;
}
.triage-card__option--selected {
border-color: #1989fa;
background: #ecf5ff;
}
.triage-card__option--recommended {
border-color: #ff976a;
background: #fff7f0;
}
.triage-card__option--excluded {
border-color: #ebedf0;
background: #f5f5f5;
opacity: 0.5;
cursor: not-allowed;
text-decoration: line-through;
}
.triage-card__option-radio {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #c8c9cc;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.triage-card__option--selected .triage-card__option-radio {
border-color: #1989fa;
}
.triage-card__option-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #1989fa;
}
.triage-card__option-label {
flex: 1;
font-size: 14px;
color: #323233;
}
.triage-card__option-probability {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background: #f0f0f0;
color: #969799;
}
.triage-card__option-probability--high {
background: #e8f8e8;
color: #07c160;
}
.triage-card__option-recommend {
font-size: 12px;
color: #ff976a;
}
.triage-card__option-excluded {
font-size: 12px;
color: #c8c9cc;
}
/* 已收集上下文 */
.triage-card__context {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #ebedf0;
}
.triage-card__context-title {
font-size: 12px;
color: #969799;
margin-bottom: 6px;
}
.triage-card__context-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.triage-card__context-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: #f0f7ff;
color: #1989fa;
}
/* 专家模式预览 */
.triage-card__expert-steps {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #ebedf0;
}
.triage-card__expert-steps-title {
font-size: 12px;
color: #ff976a;
margin-bottom: 6px;
}
.triage-card__expert-step-item {
font-size: 12px;
color: #969799;
line-height: 1.6;
padding: 2px 0;
}
.triage-card__expert-step-num {
display: inline-block;
width: 18px;
height: 18px;
line-height: 18px;
border-radius: 50%;
background: #f0f0f0;
text-align: center;
font-size: 10px;
margin-right: 4px;
}
/* 底部操作栏 */
.triage-card__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #f5f5f5;
}
</style>
@@ -29,8 +29,8 @@
> >
<!-- 发送者名称坐席和 AI 消息显示在左侧 --> <!-- 发送者名称坐席和 AI 消息显示在左侧 -->
<div v-if="msg.message_type !== 'employee'" class="message-bubble__sender"> <div v-if="msg.message_type !== 'employee'" class="message-bubble__sender">
<!-- AI 消息显示 [AI回复] 标签 --> <!-- AI 消息显示达寇拉头像和名称 -->
<span v-if="msg.message_type === 'ai'" class="message-bubble__ai-tag">[AI回复]</span> <img v-if="msg.message_type === 'ai'" src="/duckula.webp" class="message-bubble__ai-avatar" alt="Duckula" />
<span class="message-bubble__sender-name">{{ senderName }}</span> <span class="message-bubble__sender-name">{{ senderName }}</span>
</div> </div>
@@ -220,7 +220,7 @@ const senderName = computed(() => {
return props.msg.sender_name || 'IT坐席' return props.msg.sender_name || 'IT坐席'
} }
if (props.msg.message_type === 'ai') { if (props.msg.message_type === 'ai') {
return 'AI助手' return 'Duckula(达寇拉)'
} }
return props.msg.sender_name return props.msg.sender_name
}) })
@@ -366,6 +366,15 @@ function previewImage(): void {
padding-left: 4px; padding-left: 4px;
} }
/* AI 头像 */
.message-bubble__ai-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
/* AI 标签 */ /* AI 标签 */
.message-bubble__ai-tag { .message-bubble__ai-tag {
display: inline-block; display: inline-block;
@@ -25,7 +25,8 @@
> >
<!-- 发送者名称坐席和 AI 消息显示在左侧 --> <!-- 发送者名称坐席和 AI 消息显示在左侧 -->
<div v-if="msg.message_type !== 'employee'" class="message-item__sender"> <div v-if="msg.message_type !== 'employee'" class="message-item__sender">
<span v-if="msg.message_type === 'ai'" class="message-item__ai-tag">[AI回复]</span> <!-- AI 消息显示达寇拉头像和名称 -->
<img v-if="msg.message_type === 'ai'" src="/duckula.webp" class="message-item__ai-avatar" alt="Duckula" />
<span class="message-item__sender-name">{{ senderName }}</span> <span class="message-item__sender-name">{{ senderName }}</span>
</div> </div>
@@ -199,7 +200,7 @@ const senderName = computed(() => {
return props.msg.sender_name || 'IT坐席' return props.msg.sender_name || 'IT坐席'
} }
if (props.msg.message_type === 'ai') { if (props.msg.message_type === 'ai') {
return 'AI助手' return 'Duckula(达寇拉)'
} }
return props.msg.sender_name return props.msg.sender_name
}) })
@@ -413,6 +414,15 @@ function previewImage(): void {
padding-left: 4px; padding-left: 4px;
} }
/* AI 头像 */
.message-item__ai-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.message-item__ai-tag { .message-item__ai-tag {
display: inline-block; display: inline-block;
font-size: 10px; font-size: 10px;
@@ -0,0 +1,180 @@
// =============================================================================
// 企微IT智能服务台 — H5员工端 置信门控逻辑 ComposablesTier1 / P0-3 / D3
// =============================================================================
// 说明:置信门控逻辑复用 composable,封装 confidence 阈值判定、
// 转人工上下文组装、阈值读取等核心逻辑。
//
// D3 硬约束:
// - 全局阈值 0.7(可通过 settings.confidence_gate_threshold 配置)
// - confidence < 0.7 → 触发门控
// - 转人工携带上下文快照
// =============================================================================
import { ref, computed, readonly } from 'vue'
/** 默认置信门控阈值 */
const DEFAULT_CONFIDENCE_GATE_THRESHOLD = 0.7
/** 置信门控结果 */
interface ConfidenceGateResult {
/** 是否触发门控(confidence < threshold */
triggered: boolean
/** 展示给用户的概率(百分比,0-100) */
displayProbability: number
/** 门控级别:critical(<0.3) / warning(<0.5) / caution */
severity: 'critical' | 'warning' | 'caution' | 'normal'
/** 是否应该展示转人工入口 */
shouldShowTransfer: boolean
}
/** useConfidenceGate Composable */
export function useConfidenceGate(threshold?: number) {
// =========================================================================
// 状态
// =========================================================================
/** 当前置信门控阈值 */
const gateThreshold = ref(threshold ?? DEFAULT_CONFIDENCE_GATE_THRESHOLD)
/** 最近一次 AI 回复的 confidence */
const lastConfidence = ref<number | null>(null)
/** 已收集的上下文快照 */
const contextSnapshot = ref<string[]>([])
/** 门控是否已被用户处理(继续对话/转人工) */
const dismissed = ref(false)
// =========================================================================
// 计算属性
// =========================================================================
/** 当前门控结果 */
const gateResult = computed<ConfidenceGateResult>(() => {
const conf = lastConfidence.value
if (conf === null || conf === undefined) {
return {
triggered: false,
displayProbability: 0,
severity: 'normal',
shouldShowTransfer: false,
}
}
const triggered = conf < gateThreshold.value
const displayProbability = Math.round(conf * 100)
let severity: ConfidenceGateResult['severity'] = 'normal'
if (conf < 0.3) severity = 'critical'
else if (conf < 0.5) severity = 'warning'
else if (conf < gateThreshold.value) severity = 'caution'
return {
triggered,
displayProbability,
severity,
shouldShowTransfer: triggered && !dismissed.value,
}
})
/** 是否应该展示置信门控横幅 */
const showGateBanner = computed(() => gateResult.value.shouldShowTransfer)
/** 门控触发状态(只读) */
const isGateTriggered = computed(() => gateResult.value.triggered)
// =========================================================================
// 方法
// =========================================================================
/**
* 评估 AI 回复的置信度
* @param confidence AI 回复的 confidence 值(0.0-1.0
* @param context 附加上下文信息(已填写的表单数据等)
*/
function evaluateConfidence(confidence: number | null | undefined, context?: string[]) {
lastConfidence.value = confidence ?? null
dismissed.value = false
if (context && context.length > 0) {
// 合并上下文(去重)
const merged = new Set([...contextSnapshot.value, ...context])
contextSnapshot.value = [...merged]
}
}
/**
* 添加上下文快照条目
* @param items 上下文条目
*/
function addContext(...items: string[]) {
const merged = new Set([...contextSnapshot.value, ...items])
contextSnapshot.value = [...merged]
}
/**
* 清除上下文快照
*/
function clearContext() {
contextSnapshot.value = []
}
/**
* 用户选择"继续询问 AI" — 关闭门控横幅
*/
function dismissGate() {
dismissed.value = true
}
/**
* 用户选择"转人工" — 组装上下文并触发转人工
* @returns 上下文快照数组(用于转人工请求)
*/
function prepareTransferToHuman(): string[] {
const snapshot = [...contextSnapshot.value]
dismissed.value = true
return snapshot
}
/**
* 重置所有状态(新会话开始时调用)
*/
function reset() {
lastConfidence.value = null
contextSnapshot.value = []
dismissed.value = false
}
/**
* 动态更新门控阈值
* @param newThreshold 新阈值(0.0-1.0
*/
function updateThreshold(newThreshold: number) {
if (newThreshold >= 0 && newThreshold <= 1) {
gateThreshold.value = newThreshold
}
}
// =========================================================================
// 导出
// =========================================================================
return {
// 只读状态
gateThreshold: readonly(gateThreshold),
lastConfidence: readonly(lastConfidence),
contextSnapshot: readonly(contextSnapshot),
gateResult,
showGateBanner,
isGateTriggered,
// 方法
evaluateConfidence,
addContext,
clearContext,
dismissGate,
prepareTransferToHuman,
reset,
updateThreshold,
}
}
+29 -1
View File
@@ -151,9 +151,13 @@ export function useH5WebSocket() {
// 如果不是主动断开,启动降级和重连 // 如果不是主动断开,启动降级和重连
if (!intentionalDisconnect) { if (!intentionalDisconnect) {
// WS 断连:清理未完成的打字机占位气泡,避免半成品气泡残留
// 为什么:流式推送中途断连时,占位气泡无法收到 ai_reply 终态,
// 交由 3 秒轮询兜底重新拉取完整 AI 消息
const store = useConversationStore()
store.cancelStreamingBubble()
// WS 断连,启动轮询 fallback // WS 断连,启动轮询 fallback
// 为什么:WS 不可用时,仍需通过轮询获取最新数据 // 为什么:WS 不可用时,仍需通过轮询获取最新数据
const store = useConversationStore()
store.startPolling() store.startPolling()
// 尝试重连 // 尝试重连
scheduleReconnect() scheduleReconnect()
@@ -353,6 +357,30 @@ export function useH5WebSocket() {
} }
break break
// ==================================================================
// AI 回复流式推送(改造方案 A:发送瞬时返回,AI 经 WS 推回)
// ==================================================================
case 'ai_reply_chunk':
// 打字机:逐 chunk 累积到临时 AI 气泡
if (msg.data) {
store.handleAiReplyChunk(msg.data)
}
break
case 'ai_reply':
// AI 终态:用真实消息替换打字机气泡
if (msg.data) {
store.handleAiReply(msg.data)
}
break
case 'ai_reply_failed':
// AI 异常:占位气泡替换为错误提示
if (msg.data) {
store.handleAiReplyFailed(msg.data)
}
break
case 'pong': case 'pong':
// 心跳响应,不需要处理 // 心跳响应,不需要处理
break break
+177 -5
View File
@@ -185,6 +185,18 @@ export const useConversationStore = defineStore('conversation', () => {
/** 参与者面板是否展开 */ /** 参与者面板是否展开 */
const participantPanelVisible = ref<boolean>(false) const participantPanelVisible = ref<boolean>(false)
// ==========================================================================
// 流式 AI 回复(打字机)临时状态 — 改造方案 A
// ==========================================================================
/**
* 当前正在流式接收的 AI 消息临时气泡 ID
* 做什么:记录打字机占位气泡的 message_id,流式 chunk 累积到该气泡,
* 收到 ai_reply 终态后替换为真实消息
* 为什么:员工端同时只有一个活跃会话,无需用 Map 存多会话
*/
const streamingAiMessageId = ref<string | null>(null)
// ========================================================================== // ==========================================================================
// 消息去重相关状态(与 agent 端一致,WS-06 修复) // 消息去重相关状态(与 agent 端一致,WS-06 修复)
// ========================================================================== // ==========================================================================
@@ -518,11 +530,9 @@ export const useConversationStore = defineStore('conversation', () => {
messages.value.push({ ...resp.user_message, status: 'sent' }) messages.value.push({ ...resp.user_message, status: 'sent' })
} }
// AI 回复追加到本地列表(如果存在) // 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
if (resp.ai_reply) { // 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
messages.value.push(resp.ai_reply) // 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
lastMessageId.value = resp.ai_reply.message_id
}
// 更新「是否可呼叫坐席」标志 // 更新「是否可呼叫坐席」标志
canCallAgent.value = resp.can_call_agent ?? false canCallAgent.value = resp.can_call_agent ?? false
@@ -961,6 +971,162 @@ export const useConversationStore = defineStore('conversation', () => {
stopPolling() 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. 获取用户信息 * 1. 获取用户信息
@@ -1129,5 +1295,11 @@ export const useConversationStore = defineStore('conversation', () => {
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用) // WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
handleNewMessage, handleNewMessage,
// 流式 AI 回复(改造方案 A:打字机)
handleAiReplyChunk,
handleAiReply,
handleAiReplyFailed,
cancelStreamingBubble,
} }
}) })
+4 -1
View File
@@ -35,7 +35,10 @@ export default defineConfig({
// API 代理:将 /api 请求转发到后端 // API 代理:将 /api 请求转发到后端
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', // 开发代理目标:默认 localhost:8000(宿主机本地直跑后端);
// Docker 化 dev 栈中前端容器内的 localhost 不是后端,需用 compose 服务名 backend:8000。
// 通过 VITE_PROXY_TARGET 注入,避免写死导致容器内 502/ECONNREFUSED。
target: process.env.VITE_PROXY_TARGET || 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
// 本地开发剥离 /api 前缀,因为后端路由不包含 /api(生产 nginx 负责剥离) // 本地开发剥离 /api 前缀,因为后端路由不包含 /api(生产 nginx 负责剥离)
rewrite: (path) => path.replace(/^\/api/, ''), rewrite: (path) => path.replace(/^\/api/, ''),