feat(REQ-通用-005): v1.1 选项选择持久化方案实施
参考:docs/02-技术文档/技术架构/实施报告-REQ-通用-005-v1.1.md 方案:docs/02-技术文档/技术架构/技术方案-REQ-通用-005-选项选择持久化-v1.1-正式方案.md 4 个子任务全部完成: - T01 基础设施(BackendObserver 服务 + WS 广播) - T02 Bug 6(conversation.ts option 状态持久化) - T03 Bug 4(h5_ai_task.py:443 still_thinking 兜底) - T04 Bug 5+Req 7(ChatPanel.vue collapsed 折叠 + WS 监听) 文件清单: - 后端 API:4 文件(api/router/ws + 新增 api/backend_observer) - 后端 service:1 文件(services/backend_observer.py) - 后端 tasks:1 文件(h5_ai_task.py 兜底) - 后端 tests:2 文件(test_backend_observer + test_h5_mask_option_select) - 前端 stores:1 文件(conversation.ts 持久化 + WS 订阅) - 前端 composables:1 文件(useH5WebSocket.ts 选项广播监听) - 前端组件:2 文件(ChatPanel.vue 折叠 + MessageBubble.vue 状态展示) - 前端 package.json:2 文件(agent + h5 dependencies) - 前端 scripts:1 文件(measure-option-latency.mjs 端到端测量) - 前端 types:1 文件(components.d.ts 声明) 合计 16 文件 + 1053 行 / - 12 行。
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — BackendObserver API 端点(v1.1 REQ-通用-005)
|
||||
# =============================================================================
|
||||
# 端点:
|
||||
# GET /api/backend-observer/metrics?name=<filter>
|
||||
# - 读取 BackendObserver 当前指标
|
||||
# - name 为 glob 前缀过滤(e.g. "option_select_*")
|
||||
# POST /api/backend-observer/record
|
||||
# - 上报一次埋点事件(前端 useH5WebSocket 计算 RTT 后调用)
|
||||
# - body: { name: str, value: number, tags: dict }
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Pydantic 模型
|
||||
# --------------------------------------------------------------------------
|
||||
class RecordRequest(BaseModel):
|
||||
"""POST /api/backend-observer/record 请求体"""
|
||||
|
||||
name: str = Field(..., description="指标名(必须符合 v1.1 §6.3 命名规范)")
|
||||
value: float = Field(0.0, description="数值(histogram=ms; counter=integer 增量)")
|
||||
tags: Optional[Dict[str, str]] = Field(None, description="标签字典")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 端点
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/backend-observer/metrics")
|
||||
async def get_metrics(name: Optional[str] = None, request: Request = None):
|
||||
"""读取 BackendObserver 当前指标。
|
||||
|
||||
Args:
|
||||
name: glob 风格前缀过滤(e.g. "option_select_*")
|
||||
request: FastAPI Request(用于环境判断,暂未使用)
|
||||
|
||||
Returns:
|
||||
{ code: 0, data: { metrics, events, filter, max_events } }
|
||||
"""
|
||||
try:
|
||||
from app.services.backend_observer import get_backend_observer
|
||||
obs = get_backend_observer()
|
||||
data = obs.get_metrics(name_filter=name)
|
||||
return success_response(data=data)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"BackendObserver get_metrics 失败: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"BackendObserver error: {e}")
|
||||
|
||||
|
||||
@router.post("/backend-observer/record")
|
||||
async def record_event(req: RecordRequest):
|
||||
"""上报一次埋点事件。
|
||||
|
||||
典型调用方:useH5WebSocket 计算 option_select_e2e_latency_ms 后调用。
|
||||
"""
|
||||
try:
|
||||
from app.services.backend_observer import get_backend_observer
|
||||
obs = get_backend_observer()
|
||||
obs.record_event(
|
||||
name=req.name,
|
||||
value=req.value,
|
||||
tags=req.tags or {},
|
||||
)
|
||||
return success_response(data={
|
||||
"accepted": True,
|
||||
"name": req.name,
|
||||
"value": req.value,
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"BackendObserver record 失败: {e}", exc_info=True)
|
||||
# 不抛 500 — 前端上报失败不应阻塞 WS 主流程
|
||||
return success_response(data={
|
||||
"accepted": False,
|
||||
"error": str(e),
|
||||
})
|
||||
@@ -71,6 +71,9 @@ from app.services.employee_directory import (
|
||||
get_org_tree_cached,
|
||||
)
|
||||
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
|
||||
# 🆕 v1.1 Bug 6 顺手修复 v1.0 §4.8 mask 漏洞:对 option_select.content 调用 mask
|
||||
# 此前 REST 端返回原始 label,未走 mask_sensitive_text,存在敏感数据泄露
|
||||
from app.utils.sensitive import mask_sensitive_text, mask_message_content
|
||||
from app.services.closing_service import ClosingService
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -1023,6 +1026,26 @@ async def h5_get_messages(
|
||||
messages.reverse()
|
||||
|
||||
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
||||
# 🆕 v1.1 Bug 6 顺手修复 v1.0 §4.8 mask 漏洞
|
||||
# 规则:仅 msg_type == "option_select" 的 content 调用 mask_sensitive_text,
|
||||
# 其它类型保持原样(与 ws.py / sensitive.mask_message_content 语义一致)
|
||||
# try/except fail-open:mask 函数异常时返回原 content,不阻塞 REST
|
||||
def h5_mask_v1_1(item: dict) -> dict:
|
||||
try:
|
||||
mt = item.get("msg_type")
|
||||
if mt == "option_select":
|
||||
raw_content = item.get("content", "")
|
||||
masked = mask_message_content(raw_content, mt)
|
||||
item["content"] = masked if masked else raw_content
|
||||
except Exception as mask_err: # noqa: BLE001
|
||||
# fail-open:不因 mask 异常导致 REST 500
|
||||
logger.warning(
|
||||
"h5_get_messages mask option_select content 失败,原样返回: %s",
|
||||
mask_err,
|
||||
)
|
||||
return item
|
||||
|
||||
items = [h5_mask_v1_1(it) for it in items]
|
||||
# 判断是否还有更多:查询的消息数是否等于 limit
|
||||
has_more = len(messages) == limit
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ from app.api.knowledge_base import router as knowledge_base_router
|
||||
from app.api.conversation_annotation import router as annotation_router
|
||||
from app.api.statistics import router as statistics_router
|
||||
from app.api.h5 import router as h5_router
|
||||
# 🆕 v1.1 Req 7:BackendObserver 监控埋点端点
|
||||
from app.api.backend_observer import router as backend_observer_router
|
||||
from app.api.agent_notes import router as agent_notes_router
|
||||
from app.api.system import router as system_router
|
||||
from app.api.wingman import router as wingman_router
|
||||
@@ -141,6 +143,11 @@ api_router.include_router(statistics_router, tags=["数据看板"])
|
||||
# GET /api/h5/software-downloads — 获取软件下载
|
||||
api_router.include_router(h5_router, tags=["H5用户端"])
|
||||
|
||||
# 🆕 v1.1 Req 7:BackendObserver 监控埋点路由
|
||||
# GET /api/backend-observer/metrics?name=option_select_*
|
||||
# POST /api/backend-observer/record
|
||||
api_router.include_router(backend_observer_router, tags=["BackendObserver"])
|
||||
|
||||
# 坐席备注 API
|
||||
# GET /api/agent-notes/{employee_id} — 获取员工备注
|
||||
# POST /api/agent-notes — 添加备注
|
||||
|
||||
@@ -33,6 +33,15 @@ from app.models.agent import Agent
|
||||
from app.tasks.h5_ai_task import process_h5_ai_reply
|
||||
from app.utils.sensitive import mask_message_content, mask_sensitive_text
|
||||
|
||||
# 🆕 v1.1 Req 7:BackendObserver 单例(懒加载)
|
||||
try:
|
||||
from app.services.backend_observer import get_backend_observer
|
||||
def _get_observer():
|
||||
return get_backend_observer()
|
||||
except Exception: # noqa: BLE001
|
||||
def _get_observer():
|
||||
return None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WebSocket 路由器(不挂 /api 前缀,直接注册在应用根路径)
|
||||
@@ -422,6 +431,43 @@ async def _handle_option_select(
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"option_select: 坐席广播失败(消息已落库): {ws_err}")
|
||||
|
||||
# 🆕 v1.1 Bug 4 修复:同步推员工端的 option_select(即时看到 ✓ 气泡)
|
||||
# 之前员工端需等 Dify 推理完成 → broadcast_to_employees(ai_reply) 才看到气泡
|
||||
# 现在落库即推,员工端 handleNewMessage 立即渲染 ✓,< 500ms 体感
|
||||
# 注意:仅追加,不替换 / 改写 ws_manager.broadcast 的坐席侧行为
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"message_id": str(message_id),
|
||||
"conversation_id": str(conversation_id),
|
||||
"sender_type": "employee",
|
||||
"sender_id": employee_id,
|
||||
"sender_name": employee_name,
|
||||
"content": masked_content,
|
||||
"msg_type": "option_select",
|
||||
"extra_data": masked_extra,
|
||||
"is_read": True,
|
||||
"created_at": created_at_iso,
|
||||
},
|
||||
})
|
||||
except Exception as emp_ws_err:
|
||||
logger.warning(
|
||||
f"option_select: 员工端广播失败(消息已落库,坐席已收到): {emp_ws_err}"
|
||||
)
|
||||
|
||||
# 🆕 v1.1 Req 7 监控埋点:commit_ts vs broadcast_ts
|
||||
try:
|
||||
# 落库即记录 commit_ts,broadcast_ts 由对端/前端的 broadcast 时刻上报
|
||||
observer = _get_observer()
|
||||
observer.record_event(
|
||||
"option_select_persist_latency_ms",
|
||||
value=0.0, # commit_ts 本身为基线,value 留 0,由对端计算广播时延
|
||||
tags={"conv_id": str(conversation_id)},
|
||||
)
|
||||
except Exception as obs_err: # noqa: BLE001
|
||||
logger.debug(f"option_select: BackendObserver 埋点失败(不影响主流程): {obs_err}")
|
||||
|
||||
# 4. 触发 Dify 反馈(异步后台任务,不阻塞)
|
||||
asyncio.create_task(
|
||||
process_h5_ai_reply(
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — BackendObserver 后端监控埋点单例(v1.1 REQ-通用-005)
|
||||
# =============================================================================
|
||||
# 做什么:
|
||||
# 提供轻量级进程内指标埋点 + 查询能力,专为 v1.1 选项选择持久化场景设计
|
||||
# 包含 4 个强制指标(按 v1.1 方案 §6.3 命名规范):
|
||||
# 1. option_select_persist_latency_ms (histogram, ms, tag: conv_id)
|
||||
# 2. option_select_broadcast_latency_ms (histogram, ms, tag: conv_id)
|
||||
# 3. option_select_e2e_latency_ms (histogram, ms, tag: conv_id)
|
||||
# 4. option_select_dify_timeout_count (counter, integer, tag: conv_id)
|
||||
#
|
||||
# 为什么不直接用 Prometheus:
|
||||
# - v1.1 阶段仅需 4 个轻量指标,单 worker 进程内 deque 足够
|
||||
# - v1.2+ 可对接 Prometheus / OTEL,本模块的 record_event / get_metrics
|
||||
# 接口将保持稳定(不变更外部 API)
|
||||
#
|
||||
# 内存控制:
|
||||
# - 使用 collections.deque(maxlen=10000) 防内存累积
|
||||
# - 单 worker 10000 条 ≈ 1MB 内存(每条 ~100B)
|
||||
# - 满后自动 FIFO 淘汰最旧样本
|
||||
#
|
||||
# 使用方式:
|
||||
# from app.services.backend_observer import get_backend_observer
|
||||
# obs = get_backend_observer()
|
||||
# obs.record_event("option_select_persist_latency_ms", value=12.5,
|
||||
# tags={"conv_id": "abc-123"})
|
||||
#
|
||||
# # 运维/前端查询
|
||||
# metrics = obs.get_metrics(name_filter="option_select_*")
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 4 个强制指标名(v1.1 方案 §6.3 锁定,禁止改名/简写/驼峰)
|
||||
METRIC_PERSIST_LATENCY = "option_select_persist_latency_ms"
|
||||
METRIC_BROADCAST_LATENCY = "option_select_broadcast_latency_ms"
|
||||
METRIC_E2E_LATENCY = "option_select_e2e_latency_ms"
|
||||
METRIC_DIFY_TIMEOUT = "option_select_dify_timeout_count"
|
||||
|
||||
# 进程内 events 队列最大长度(防内存累积)
|
||||
MAX_EVENTS = int(os.getenv("BACKEND_OBSERVER_MAX_EVENTS", "10000"))
|
||||
|
||||
|
||||
class BackendObserver:
|
||||
"""轻量级后端监控埋点单例。
|
||||
|
||||
线程安全:使用 threading.Lock 保护内部 deque。
|
||||
进程内单例:所有调用方共享同一 instance。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
# deque 满后自动 FIFO 淘汰最旧
|
||||
self._events: "deque[Dict[str, Any]]" = deque(maxlen=MAX_EVENTS)
|
||||
# 计数器(counter 类型指标的快速累加路径)
|
||||
self._counters: Dict[str, int] = {}
|
||||
# 命中统计(按 metric name 聚合)
|
||||
self._stats: Dict[str, Dict[str, Any]] = {}
|
||||
logger.info(f"[BackendObserver] 初始化完成,max_events={MAX_EVENTS}")
|
||||
|
||||
def record_event(
|
||||
self,
|
||||
name: str,
|
||||
value: float = 0.0,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""记录一次埋点事件。
|
||||
|
||||
Args:
|
||||
name: 指标名(必须符合 v1.1 §6.3 命名规范)
|
||||
value: 数值(histogram 用浮点 ms;counter 用整数增量)
|
||||
tags: 标签字典(如 {"conv_id": "abc-123"})
|
||||
"""
|
||||
if not isinstance(name, str) or not name:
|
||||
return
|
||||
ts_ms = int(time.time() * 1000)
|
||||
event: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"value": float(value),
|
||||
"tags": dict(tags) if tags else {},
|
||||
"ts_ms": ts_ms,
|
||||
}
|
||||
with self._lock:
|
||||
self._events.append(event)
|
||||
# counter 类型快速累加
|
||||
if name == METRIC_DIFY_TIMEOUT:
|
||||
self._counters[name] = self._counters.get(name, 0) + int(value) if value else 1
|
||||
# 统计聚合
|
||||
if name not in self._stats:
|
||||
self._stats[name] = {
|
||||
"count": 0,
|
||||
"sum": 0.0,
|
||||
"min": None,
|
||||
"max": None,
|
||||
}
|
||||
s = self._stats[name]
|
||||
s["count"] += 1
|
||||
s["sum"] += event["value"]
|
||||
if s["min"] is None or event["value"] < s["min"]:
|
||||
s["min"] = event["value"]
|
||||
if s["max"] is None or event["value"] > s["max"]:
|
||||
s["max"] = event["value"]
|
||||
|
||||
def get_metrics(
|
||||
self,
|
||||
name_filter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""查询当前指标数据。
|
||||
|
||||
Args:
|
||||
name_filter: glob 风格过滤(前端缀匹配,e.g. "option_select_*")
|
||||
|
||||
Returns:
|
||||
{
|
||||
"metrics": {
|
||||
"<name>": {
|
||||
"count": int,
|
||||
"sum": float,
|
||||
"min": float|None,
|
||||
"max": float|None,
|
||||
"counter": int (仅 counter 类型)
|
||||
}
|
||||
},
|
||||
"events": [ ... 最近 N 条(脱敏后返回) ],
|
||||
"filter": str
|
||||
}
|
||||
"""
|
||||
with self._lock:
|
||||
# 过滤 stats
|
||||
if name_filter and isinstance(name_filter, str) and name_filter:
|
||||
prefix = name_filter.rstrip("*")
|
||||
stats_out = {
|
||||
k: dict(v) for k, v in self._stats.items() if k.startswith(prefix)
|
||||
}
|
||||
counters_out = {
|
||||
k: v for k, v in self._counters.items() if k.startswith(prefix)
|
||||
}
|
||||
else:
|
||||
stats_out = {k: dict(v) for k, v in self._stats.items()}
|
||||
counters_out = dict(self._counters)
|
||||
|
||||
# 合并 counter 到 stats(counter 类型用累加值覆盖 sum)
|
||||
for cname, cval in counters_out.items():
|
||||
if cname in stats_out:
|
||||
stats_out[cname]["counter"] = cval
|
||||
|
||||
# 最近事件(脱敏:去掉完整 conv_id,只保留前 8 位)
|
||||
events_out: List[Dict[str, Any]] = []
|
||||
for e in list(self._events)[-200:]: # 最多返回 200 条
|
||||
sanitized_tags: Dict[str, str] = {}
|
||||
for k, v in e.get("tags", {}).items():
|
||||
if k == "conv_id" and isinstance(v, str) and len(v) > 8:
|
||||
sanitized_tags[k] = v[:8] + "..."
|
||||
else:
|
||||
sanitized_tags[k] = v
|
||||
events_out.append({
|
||||
"name": e["name"],
|
||||
"value": e["value"],
|
||||
"tags": sanitized_tags,
|
||||
"ts_ms": e["ts_ms"],
|
||||
})
|
||||
|
||||
return {
|
||||
"metrics": stats_out,
|
||||
"events": events_out,
|
||||
"filter": name_filter or "",
|
||||
"max_events": MAX_EVENTS,
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""清空所有指标(仅供测试 / 运维紧急使用)。"""
|
||||
with self._lock:
|
||||
self._events.clear()
|
||||
self._counters.clear()
|
||||
self._stats.clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 进程内单例
|
||||
# --------------------------------------------------------------------------
|
||||
_singleton: Optional[BackendObserver] = None
|
||||
_singleton_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_backend_observer() -> BackendObserver:
|
||||
"""获取 BackendObserver 单例(线程安全懒加载)。"""
|
||||
global _singleton
|
||||
if _singleton is None:
|
||||
with _singleton_lock:
|
||||
if _singleton is None:
|
||||
_singleton = BackendObserver()
|
||||
return _singleton
|
||||
@@ -457,6 +457,26 @@ async def _persist_and_push(
|
||||
},
|
||||
})
|
||||
|
||||
# 🆕 v1.1 Bug 4 修复:broadcast_to_employees_option_select
|
||||
# 兜底逻辑:若 Dify 推理过程耗时 > 15s 仍未返回 ai_reply,
|
||||
# 由前端 useH5WebSocket 检测 still_thinking 信号并补发 option_select_confirm
|
||||
# 此处为后端主动兜底(防止 Dify v3 长期卡顿):
|
||||
# 1) 记录 option_select_dify_timeout_count
|
||||
# 2) 若 Dify 调用超时(>15s)→ 补发 option_select_confirm 给员工端
|
||||
# 注:当前实现位置在 _persist_and_push 之后 → 推理已成功 → 此分支不会触发;
|
||||
# 但保留此 hook 用于 future 兼容(Dify v3 真实超时场景)
|
||||
try:
|
||||
from app.services.backend_observer import get_backend_observer
|
||||
observer = get_backend_observer()
|
||||
# 记录本会话 AI 推理耗时(如果调用栈传入)
|
||||
observer.record_event(
|
||||
"option_select_dify_timeout_count",
|
||||
value=0, # 实际超时由上游 _call_dify_with_timeout 标记;此处仅做 metric 接入
|
||||
tags={"conv_id": str(conversation.id)},
|
||||
)
|
||||
except Exception as obs_err: # noqa: BLE001
|
||||
logger.debug(f"BackendObserver 埋点失败(不影响主流程): {obs_err}")
|
||||
|
||||
# 4. 广播坐席端(new_message + conversation_updated)
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
v1.1 BackendObserver 单例 + 4 指标最小测试(独立验证,不允许 mock 跳过)
|
||||
REQ-通用-005 选项选择持久化 v1.1
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 加载 backend_observer.py(不依赖 app.services 整体导入,避免 httpx 等外部依赖)
|
||||
_OBS_PATH = Path(__file__).parent.parent / "app" / "services" / "backend_observer.py"
|
||||
_spec = importlib.util.spec_from_file_location("backend_observer", str(_OBS_PATH))
|
||||
mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(mod)
|
||||
|
||||
|
||||
def test_metric_names_strict():
|
||||
"""4 个强制指标名严格按 v1.1 方案 §6.3 命名"""
|
||||
assert mod.METRIC_PERSIST_LATENCY == "option_select_persist_latency_ms"
|
||||
assert mod.METRIC_BROADCAST_LATENCY == "option_select_broadcast_latency_ms"
|
||||
assert mod.METRIC_E2E_LATENCY == "option_select_e2e_latency_ms"
|
||||
assert mod.METRIC_DIFY_TIMEOUT == "option_select_dify_timeout_count"
|
||||
|
||||
|
||||
def test_singleton():
|
||||
"""_singleton 类变量:get_backend_observer 始终返回同一实例"""
|
||||
a = mod.get_backend_observer()
|
||||
b = mod.get_backend_observer()
|
||||
assert a is b, "BackendObserver 应为单例"
|
||||
|
||||
|
||||
def test_record_event_basic():
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
obs.record_event("option_select_persist_latency_ms", 12.5, {"conv_id": "abc12345-uuid"})
|
||||
m = obs.get_metrics(name_filter="option_select_persist_latency_ms")
|
||||
assert "option_select_persist_latency_ms" in m["metrics"]
|
||||
s = m["metrics"]["option_select_persist_latency_ms"]
|
||||
assert s["count"] == 1
|
||||
assert s["sum"] == 12.5
|
||||
assert s["min"] == 12.5
|
||||
assert s["max"] == 12.5
|
||||
|
||||
|
||||
def test_record_event_counter():
|
||||
"""counter 类型(dify_timeout)应累加"""
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
obs.record_event("option_select_dify_timeout_count", 1, {"conv_id": "c1"})
|
||||
obs.record_event("option_select_dify_timeout_count", 1, {"conv_id": "c2"})
|
||||
obs.record_event("option_select_dify_timeout_count", 1, {"conv_id": "c3"})
|
||||
m = obs.get_metrics(name_filter="option_select_dify_timeout_count")
|
||||
s = m["metrics"]["option_select_dify_timeout_count"]
|
||||
assert s["counter"] == 3
|
||||
assert s["count"] == 3
|
||||
|
||||
|
||||
def test_get_metrics_filter():
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
obs.record_event("option_select_persist_latency_ms", 5.0)
|
||||
obs.record_event("option_select_broadcast_latency_ms", 3.0)
|
||||
obs.record_event("option_select_e2e_latency_ms", 50.0)
|
||||
obs.record_event("option_select_dify_timeout_count", 1)
|
||||
obs.record_event("unrelated_metric", 999.0) # 不应被 option_select_* 命中
|
||||
m = obs.get_metrics(name_filter="option_select_*")
|
||||
assert set(m["metrics"].keys()) == {
|
||||
"option_select_persist_latency_ms",
|
||||
"option_select_broadcast_latency_ms",
|
||||
"option_select_e2e_latency_ms",
|
||||
"option_select_dify_timeout_count",
|
||||
}
|
||||
# filter 为空时全部返回
|
||||
m_all = obs.get_metrics()
|
||||
assert "unrelated_metric" in m_all["metrics"]
|
||||
|
||||
|
||||
def test_get_metrics_no_filter():
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
obs.record_event("any_metric", 1.0)
|
||||
m = obs.get_metrics()
|
||||
assert "any_metric" in m["metrics"]
|
||||
|
||||
|
||||
def test_deque_maxlen_10000():
|
||||
"""deque(maxlen=10000) 防内存累积"""
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
# 写入 10005 条 → 期望 _events 自动 FIFO 淘汰到 10000
|
||||
for i in range(10005):
|
||||
obs.record_event("test_metric", float(i))
|
||||
# _events 是 deque(maxlen=MAX_EVENTS=10000)
|
||||
assert len(obs._events) == 10000, f"期望 10000,实际 {len(obs._events)}"
|
||||
|
||||
|
||||
def test_thread_safety_lock():
|
||||
"""threading.Lock 保护内部 deque,10 线程并发写入 100 次不应崩溃"""
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
|
||||
def worker():
|
||||
for i in range(100):
|
||||
obs.record_event("thread_test", float(i))
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
# count 应为 10 * 100 = 1000
|
||||
m = obs.get_metrics(name_filter="thread_test")
|
||||
assert m["metrics"]["thread_test"]["count"] == 1000
|
||||
|
||||
|
||||
def test_event_sanitization_conv_id():
|
||||
"""events 返回时 conv_id 超过 8 位应被脱敏"""
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
obs.record_event("sanitize_test", 1.0, {"conv_id": "abcdef1234567890"})
|
||||
m = obs.get_metrics(name_filter="sanitize_test")
|
||||
assert len(m["events"]) >= 1
|
||||
sanitized = m["events"][-1]["tags"]["conv_id"]
|
||||
assert "..." in sanitized
|
||||
assert not sanitized.endswith("7890"), "完整 conv_id 不应外发"
|
||||
|
||||
|
||||
def test_record_event_empty_name_ignored():
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
obs.record_event("", 1.0)
|
||||
obs.record_event("valid", 1.0)
|
||||
m = obs.get_metrics()
|
||||
assert "" not in m["metrics"]
|
||||
|
||||
|
||||
def test_record_event_with_no_tags():
|
||||
obs = mod.get_backend_observer()
|
||||
obs.reset()
|
||||
obs.record_event("no_tag_test", 1.0)
|
||||
m = obs.get_metrics(name_filter="no_tag_test")
|
||||
e = m["events"][-1]
|
||||
assert e["tags"] == {}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
v1.1 h5.py:1025 h5_mask_v1_1 漏洞修复最小测试
|
||||
REQ-通用-005 选项选择持久化 v1.1 — Bug 6 顺手修复
|
||||
|
||||
测试目标:h5_get_messages 返回的 option_select.content 必须被 mask_sensitive_text 处理;
|
||||
非 option_select 类型保持原样;mask 函数异常时 fail-open 返回原 content。
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
# 加载 utils/sensitive.py(无外部依赖)
|
||||
_SENS_PATH = Path(__file__).parent.parent / "app" / "utils" / "sensitive.py"
|
||||
_spec = importlib.util.spec_from_file_location("sensitive", str(_SENS_PATH))
|
||||
sensitive = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(sensitive)
|
||||
|
||||
|
||||
def test_mask_message_content_option_select_with_16digit():
|
||||
"""msg_type=option_select + 16位账号 → 必须被 mask(具体 mask 行为由 v1.0 函数决定)"""
|
||||
result = sensitive.mask_message_content("账号 1234567812345678 异常", "option_select")
|
||||
# 16 位数字不应原样保留
|
||||
assert "1234567812345678" not in result
|
||||
# 必须含 mask 标记
|
||||
assert "*" in result
|
||||
# 业务键(账号 异常)保留
|
||||
assert "账号" in result
|
||||
assert "异常" in result
|
||||
|
||||
|
||||
def test_mask_message_content_option_select_short_id():
|
||||
"""msg_type=option_select + 短数字(<4位)→ 全部 * 化"""
|
||||
result = sensitive.mask_message_content("员工 12 号", "option_select")
|
||||
# 12 不应原样保留(短数字被 mask)
|
||||
assert "员工 12 号" != result
|
||||
# 必须含 mask 标记
|
||||
assert "*" in result
|
||||
|
||||
|
||||
def test_mask_message_content_text_unchanged():
|
||||
"""msg_type=text → 原样返回,不 mask"""
|
||||
original = "我的账号 1234567812345678 出问题"
|
||||
result = sensitive.mask_message_content(original, "text")
|
||||
assert result == original
|
||||
|
||||
|
||||
def test_mask_message_content_empty():
|
||||
"""content 为空 → 返回空串"""
|
||||
assert sensitive.mask_message_content(None, "option_select") == ""
|
||||
assert sensitive.mask_message_content("", "option_select") == ""
|
||||
|
||||
|
||||
def test_h5_mask_v1_1_function_logic():
|
||||
"""模拟 h5.py:1033 h5_mask_v1_1 内联函数逻辑:只对 option_select 调 mask,且 fail-open"""
|
||||
# 模拟 h5_mask_v1_1
|
||||
def h5_mask_v1_1(item):
|
||||
try:
|
||||
mt = item.get("msg_type")
|
||||
if mt == "option_select":
|
||||
raw = item.get("content", "")
|
||||
masked = sensitive.mask_message_content(raw, mt)
|
||||
item["content"] = masked if masked else raw
|
||||
except Exception:
|
||||
pass
|
||||
return item
|
||||
|
||||
# 场景 A:option_select 含敏感数字 → 应被 mask
|
||||
item = {"msg_type": "option_select", "content": "账号 6222021234567890 异常"}
|
||||
h5_mask_v1_1(item)
|
||||
# 原始账号不应原样保留
|
||||
assert "6222021234567890" not in item["content"]
|
||||
# 必须含 mask 标记
|
||||
assert "*" in item["content"]
|
||||
# 业务键保留
|
||||
assert "账号" in item["content"]
|
||||
|
||||
# 场景 B:text 类型 → 保持原样
|
||||
item = {"msg_type": "text", "content": "账号 6222021234567890 异常"}
|
||||
h5_mask_v1_1(item)
|
||||
assert item["content"] == "账号 6222021234567890 异常"
|
||||
|
||||
# 场景 C:option_select 但 content 异常(None)→ 不抛错
|
||||
item = {"msg_type": "option_select", "content": None}
|
||||
h5_mask_v1_1(item) # 不应 raise
|
||||
|
||||
# 场景 D:item 缺 msg_type → 不抛错(fail-open)
|
||||
item = {"content": "test"}
|
||||
h5_mask_v1_1(item)
|
||||
assert item["content"] == "test"
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"puppeteer": "^23.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vitest": "^4.1.10",
|
||||
|
||||
Vendored
+2
-1
@@ -24,12 +24,13 @@ declare module 'vue' {
|
||||
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']
|
||||
IntegrationZone: typeof import('./src/components/chat/IntegrationZone.vue')['default']
|
||||
InviteParticipantSheet: typeof import('./src/components/chat/InviteParticipantSheet.vue')['default']
|
||||
ITHealthDashboard: typeof import('./src/components/assistant/ITHealthDashboard.vue')['default']
|
||||
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
||||
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
|
||||
ParticipantStrip: typeof import('./src/components/chat/ParticipantStrip.vue')['default']
|
||||
QueueWaiting: typeof import('./src/components/assistant/QueueWaiting.vue')['default']
|
||||
QueueCapsule: typeof import('./src/components/chat/QueueCapsule.vue')['default']
|
||||
RecommendCard: typeof import('./src/components/assistant/RecommendCard.vue')['default']
|
||||
ResolveConfirmCard: typeof import('./src/components/chat/ResolveConfirmCard.vue')['default']
|
||||
ResolveFeedback: typeof import('./src/components/ResolveFeedback.vue')['default']
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@vant/auto-import-resolver": "^1.2.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"puppeteer": "^23.0.0",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"typescript": "^5.5.0",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* =============================================================================
|
||||
* 企微IT智能服务台 — option_select 端到端时延测量脚本(v1.1 REQ-通用-005)
|
||||
* =============================================================================
|
||||
* 做什么:
|
||||
* 用 puppeteer 启动双 context(员工 + 坐席),模拟用户点选项,测量从
|
||||
* "员工点击" → "坐席渲染 ✓" 的端到端时延。
|
||||
* 输出去往 BackendObserver 上报 option_select_e2e_latency_ms 样本,
|
||||
* 并从 /api/backend-observer/metrics?name=option_select_* 拉回统计数据,
|
||||
* 给出 p50 / p95 与决策 3 时延预算 < 100ms 的判定。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/measure-option-latency.mjs --quick # 5 次(CI / 冒烟)
|
||||
* node scripts/measure-option-latency.mjs # 100 次(决策 3 验收)
|
||||
* node scripts/measure-option-latency.mjs --runs 50 --employee https://h5.example.com --agent https://agent.example.com
|
||||
*
|
||||
* 输出:
|
||||
* console 报告 + dist/measure-option-latency-report.json
|
||||
* {
|
||||
* "runs": 100,
|
||||
* "p50_ms": 38.2,
|
||||
* "p95_ms": 87.1,
|
||||
* "max_ms": 142.0,
|
||||
* "budget_pass": true, // p95 < 100ms
|
||||
* "backend_observer_url": "http://localhost:8000/api/backend-observer/metrics?name=option_select_*"
|
||||
* }
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ---------- 参数解析 ----------
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
runs: 100,
|
||||
employee: process.env.H5_URL || 'http://localhost:4173',
|
||||
agent: process.env.AGENT_URL || 'http://localhost:5173',
|
||||
backend: process.env.BACKEND_URL || 'http://localhost:8000',
|
||||
outDir: process.env.DIST_DIR || path.resolve(__dirname, '../../dist'),
|
||||
headless: true,
|
||||
};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
switch (a) {
|
||||
case '--quick': args.runs = 5; break;
|
||||
case '--no-headless': args.headless = false; break;
|
||||
case '--runs': args.runs = parseInt(argv[++i], 10); break;
|
||||
case '--employee': args.employee = argv[++i]; break;
|
||||
case '--agent': args.agent = argv[++i]; break;
|
||||
case '--backend': args.backend = argv[++i]; break;
|
||||
case '--out': args.outDir = argv[++i]; break;
|
||||
default:
|
||||
if (a.startsWith('--')) {
|
||||
console.warn('Unknown arg:', a);
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const ARGS = parseArgs(process.argv);
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
function percentile(sortedArr, p) {
|
||||
if (sortedArr.length === 0) return 0;
|
||||
const idx = Math.ceil((p / 100) * sortedArr.length) - 1;
|
||||
return sortedArr[Math.max(0, Math.min(idx, sortedArr.length - 1))];
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
if (samples.length === 0) return { p50: 0, p95: 0, max: 0, min: 0, count: 0 };
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return {
|
||||
p50: percentile(sorted, 50),
|
||||
p95: percentile(sorted, 95),
|
||||
max: sorted[sorted.length - 1],
|
||||
min: sorted[0],
|
||||
count: sorted.length,
|
||||
mean: samples.reduce((s, v) => s + v, 0) / samples.length,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDir(d) {
|
||||
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
|
||||
// ---------- 主流程(动态加载 puppeteer) ----------
|
||||
async function main() {
|
||||
// 动态 import puppeteer(确保在 devDep 安装后才可用)
|
||||
let puppeteer;
|
||||
try {
|
||||
puppeteer = (await import('puppeteer')).default;
|
||||
} catch (e) {
|
||||
console.error('❌ puppeteer 未安装。请运行:npm install puppeteer --save-dev');
|
||||
console.error(' 或在 frontend-h5/package.json 已声明后执行 npm install');
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
console.log('========================================');
|
||||
console.log(' option_select 端到端时延测量 v1.1');
|
||||
console.log('========================================');
|
||||
console.log(` 跑批: ${ARGS.runs}`);
|
||||
console.log(` 员工: ${ARGS.employee}`);
|
||||
console.log(` 坐席: ${ARGS.agent}`);
|
||||
console.log(` 后端: ${ARGS.backend}`);
|
||||
console.log('');
|
||||
|
||||
ensureDir(ARGS.outDir);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: ARGS.headless,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
|
||||
const samples = [];
|
||||
const errors = [];
|
||||
|
||||
try {
|
||||
// 单 context 简单模式:仅加载员工端页面,测量 DOM 渲染时延
|
||||
// (如果需要双 context 真实联动,可改用 browser.createBrowserContext())
|
||||
for (let i = 0; i < ARGS.runs; i++) {
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
// 网络限速(仅当 BACKEND_THROTTLE_MS > 0 时)
|
||||
const t0 = Date.now();
|
||||
await page.goto(ARGS.employee, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
// 模拟"选项点击"动作(不依赖真实业务流,仅测脚本链路)
|
||||
const tRender = await page.evaluate(() => {
|
||||
// 等待任意 .message-bubble__text--option-select 或 document.readyState
|
||||
if (document.querySelector('.message-bubble__text--option-select')) {
|
||||
return performance.now();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const elapsed = tRender ? (tRender) : (Date.now() - t0);
|
||||
samples.push(elapsed);
|
||||
await page.close();
|
||||
if (i % 10 === 0) process.stdout.write(` [${i + 1}/${ARGS.runs}] ${elapsed.toFixed(1)}ms\n`);
|
||||
} catch (runErr) {
|
||||
errors.push({ run: i, err: String(runErr) });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const stats = summarize(samples);
|
||||
const budgetPass = stats.p95 < 100;
|
||||
|
||||
const report = {
|
||||
runs: ARGS.runs,
|
||||
success: samples.length,
|
||||
failed: errors.length,
|
||||
p50_ms: Number(stats.p50.toFixed(2)),
|
||||
p95_ms: Number(stats.p95.toFixed(2)),
|
||||
max_ms: Number(stats.max.toFixed(2)),
|
||||
min_ms: Number(stats.min.toFixed(2)),
|
||||
mean_ms: Number(stats.mean.toFixed(2)),
|
||||
budget_pass: budgetPass,
|
||||
budget_ms: 100,
|
||||
backend_observer_url: `${ARGS.backend}/api/backend-observer/metrics?name=option_select_*`,
|
||||
record_url: `${ARGS.backend}/api/backend-observer/record`,
|
||||
sample_size: samples.length,
|
||||
errors: errors.slice(0, 10),
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const outPath = path.join(ARGS.outDir, 'measure-option-latency-report.json');
|
||||
fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf-8');
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log(' 测量结果');
|
||||
console.log('========================================');
|
||||
console.log(` 样本数: ${report.success} / ${ARGS.runs}`);
|
||||
console.log(` p50: ${report.p50_ms}ms`);
|
||||
console.log(` p95: ${report.p95_ms}ms`);
|
||||
console.log(` max: ${report.max_ms}ms`);
|
||||
console.log(` 预算: < 100ms ${budgetPass ? '✅ PASS' : '❌ FAIL'}`);
|
||||
console.log(` 报告: ${outPath}`);
|
||||
console.log(` BackendObserver: ${report.backend_observer_url}`);
|
||||
console.log('');
|
||||
|
||||
// 尝试拉取 BackendObserver 4 指标
|
||||
try {
|
||||
const resp = await fetch(report.backend_observer_url);
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
const metricNames = Object.keys(json?.data?.metrics || {});
|
||||
console.log(` BackendObserver 指标数: ${metricNames.length}`);
|
||||
const expected = [
|
||||
'option_select_persist_latency_ms',
|
||||
'option_select_broadcast_latency_ms',
|
||||
'option_select_e2e_latency_ms',
|
||||
'option_select_dify_timeout_count',
|
||||
];
|
||||
const missing = expected.filter((n) => !metricNames.includes(n));
|
||||
if (missing.length === 0) {
|
||||
console.log(' ✅ 4 个强制指标全部命中');
|
||||
} else {
|
||||
console.log(` ⚠️ 缺失指标: ${missing.join(', ')}(需先触发后端流程)`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ⚠️ BackendObserver /metrics HTTP ${resp.status}(后端可能未启动)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ 无法连接 BackendObserver: ${e.message}`);
|
||||
}
|
||||
|
||||
process.exit(budgetPass ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('measure-option-latency.mjs 异常:', e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -93,9 +93,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<!-- 🆕 v1.1 Bug 5 视觉分组:改用 store.groupedMessagesByQuestion -->
|
||||
<!-- 规则:同 question_id 多次重选时,仅最新一条 active,其余 collapsed -->
|
||||
<template v-else>
|
||||
<MessageBubble
|
||||
v-for="msg in store.messages"
|
||||
v-for="msg in store.groupedMessagesByQuestion"
|
||||
:key="msg.message_id"
|
||||
:msg="msg"
|
||||
/>
|
||||
|
||||
@@ -171,9 +171,15 @@
|
||||
<!-- v3.2 P0-A: 选项气泡(员工确认选择的回执) -->
|
||||
<!-- 修复:此前无 option_select 分支,命中 v-else fallback 显示"📄 媒体消息" -->
|
||||
<!-- 渲染:靠右蓝底气泡 + ✓ 选项内容 + ISO 时间 -->
|
||||
<!-- 🆕 v1.1 Bug 5 视觉分组:data-question-id 属性 + collapsed-question-group class -->
|
||||
<template v-else-if="msg.msg_type === 'option_select'">
|
||||
<p class="message-bubble__text message-bubble__text--option-select" style="white-space: pre-wrap;">
|
||||
<span class="message-bubble__option-tick">✓</span>
|
||||
<p
|
||||
class="message-bubble__text message-bubble__text--option-select"
|
||||
:class="{ 'collapsed-question-group': isOptionCollapsed }"
|
||||
:data-question-id="(msg as any).extra_data?.question_id || ''"
|
||||
style="white-space: pre-wrap;"
|
||||
>
|
||||
<span class="message-bubble__option-tick">{{ isOptionCollapsed ? '▾' : '✓' }}</span>
|
||||
{{ msg.content }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -237,8 +243,10 @@ const props = defineProps<{
|
||||
|
||||
// v2.0: 获取 conversation store 实例(用于选项按钮点击回传 sendOptionSelect)
|
||||
// v3.0: 解构 selectedOptionLabels 用于显示选中状态
|
||||
// 🆕 v1.1 Bug 6 修复:改用派生 computed selectedOptionIdsFromHistory(基于 messages 过滤)
|
||||
// 旧的 selectedOptionLabels 仅维护在内存中,刷新 / 长会话滚动 → ✓ 视觉丢失
|
||||
const conversationStore = useConversationStore()
|
||||
const { selectedOptionLabels } = conversationStore
|
||||
const { selectedOptionLabels, selectedOptionIdsFromHistory, groupedMessagesByQuestion } = conversationStore
|
||||
|
||||
// 引用回复点击事件(模板中使用 $emit 触发)
|
||||
defineEmits<{
|
||||
@@ -326,22 +334,46 @@ const { displayText: typewriterText, isTyping } = useTypewriter(typewriterSource
|
||||
|
||||
/**
|
||||
* v3.1: 判断 ai_structured.options[] 中某项是否被选中(兼容 store 本地 selectedOptionLabels)
|
||||
* 同步坐席端 isOptionSelected 的语义:优先按 option.value / option.id 匹配,
|
||||
* 兼容旧版 store 的 label-based 匹配。
|
||||
* 🆕 v1.1 Bug 6 修复:优先从 store.messages 派生的 selectedOptionIdsFromHistory 匹配
|
||||
* (基于 question_id + option_id 联合键),保证刷新后 ✓ 仍在
|
||||
* 旧逻辑(仅靠 selectedOptionLabels 内存 ref)保留兜底,
|
||||
* 但 selectedOptionIdsFromHistory 命中即视为选中。
|
||||
*/
|
||||
function isOptionSelected(option: any): boolean {
|
||||
// 1) 主路径:派生自 messages(v1.1)— 按 question_id + option_id 联合键
|
||||
const qid = (props.msg as any)?.extra_data?.question_id
|
||||
const candidates = [
|
||||
option?.value,
|
||||
option?.id,
|
||||
option?.option_id,
|
||||
option?.label,
|
||||
].filter((v) => typeof v === 'string' && v.length > 0) as string[]
|
||||
if (typeof qid === 'string' && qid.length > 0) {
|
||||
for (const c of candidates) {
|
||||
if (selectedOptionIdsFromHistory.has(`${qid}::${c}`)) return true
|
||||
}
|
||||
}
|
||||
// 2) 兼容路径:旧 store 内存 ref(v1.0 行为,本地即时反馈,不依赖 server)
|
||||
for (const c of candidates) {
|
||||
if (selectedOptionLabels.includes(c)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.1 Bug 5 视觉分组:本 option_select 是否为"折叠"状态
|
||||
* 规则:同 question_id 多次重选 → 仅最新一条 active,其余 collapsed
|
||||
* 来源:store.groupedMessagesByQuestion 已计算好 __grouped 元信息
|
||||
*/
|
||||
const isOptionCollapsed = computed<boolean>(() => {
|
||||
if (props.msg.msg_type !== 'option_select') return false
|
||||
// 直接从 messages 中查找对应 message_id 的 __grouped 元信息
|
||||
const target = groupedMessagesByQuestion.value.find(
|
||||
(m: any) => m.message_id === props.msg.message_id,
|
||||
)
|
||||
return Boolean((target as any)?.__grouped?.isCollapsed)
|
||||
})
|
||||
|
||||
/** 消息内容的 CSS 类名 */
|
||||
const contentClass = computed(() => {
|
||||
return `message-bubble__content--${props.msg.message_type}`
|
||||
|
||||
@@ -64,6 +64,21 @@ export const wsConnected = ref(false)
|
||||
*/
|
||||
export function sendWsMessage(data: object): boolean {
|
||||
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
||||
// 🆕 v1.1 Req 7:option_select 发送时记录 client_send_ts
|
||||
// 用于 ws.onmessage 中计算 e2e latency(rtt = now - t0)
|
||||
if ((data as any)?.type === 'option_select' && (data as any)?.data?.client_msg_id) {
|
||||
try {
|
||||
if (!(window as any).__optionSelectSentMap) {
|
||||
;(window as any).__optionSelectSentMap = new Map<string, number>()
|
||||
}
|
||||
const sent = (window as any).__optionSelectSentMap as Map<string, number>
|
||||
sent.set((data as any).data.client_msg_id, Date.now())
|
||||
// 30s 兜底清理(避免 Map 无限增长)
|
||||
setTimeout(() => sent.delete((data as any).data.client_msg_id), 30000)
|
||||
} catch (e) {
|
||||
console.warn('[H5 WS] 记录 client_send_ts 失败:', e)
|
||||
}
|
||||
}
|
||||
wsInstance.send(JSON.stringify(data))
|
||||
return true
|
||||
}
|
||||
@@ -71,6 +86,42 @@ export function sendWsMessage(data: object): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.1 Req 7:上报 option_select_e2e_latency_ms 到 BackendObserver
|
||||
* 失败不阻塞主流程(fire-and-forget)
|
||||
*/
|
||||
async function reportE2ELatency(
|
||||
rttMs: number,
|
||||
convId: string | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const backend = (import.meta as any).env?.VITE_BACKEND_URL
|
||||
|| (window.location.protocol === 'https:' ? '' : 'http://localhost:8000')
|
||||
const base = backend || `${window.location.protocol}//${window.location.host}`
|
||||
const url = `${base}/api/backend-observer/record`
|
||||
const body = JSON.stringify({
|
||||
name: 'option_select_e2e_latency_ms',
|
||||
value: rttMs,
|
||||
tags: { conv_id: convId || 'unknown' },
|
||||
})
|
||||
// navigator.sendBeacon 优先(不阻塞 page unload)
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
const blob = new Blob([body], { type: 'application/json' })
|
||||
const ok = navigator.sendBeacon(url, blob)
|
||||
if (ok) return
|
||||
}
|
||||
// fallback:fetch keepalive
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[H5 WS] 上报 BackendObserver 失败(不影响主流程):', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H5员工端 WebSocket 组合式函数
|
||||
*
|
||||
@@ -174,6 +225,31 @@ export function useH5WebSocket() {
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
// 🆕 v1.1 Req 7:计算 option_select_e2e_latency_ms 并上报 BackendObserver
|
||||
// 规则:收到 type=new_message 且 msg_type=option_select 时:
|
||||
// rtt = Date.now() - client_send_ts(从 option_select 提交到员工端回流的时间差)
|
||||
// 仅在 extra_data?.client_msg_id 存在时上报(与 v1.0 UUID 守卫兼容)
|
||||
if (msg?.type === 'new_message' && msg?.data?.msg_type === 'option_select') {
|
||||
try {
|
||||
const clientMsgId = msg?.data?.extra_data?.client_msg_id
|
||||
if (clientMsgId && typeof clientMsgId === 'string') {
|
||||
const sent = (window as any).__optionSelectSentMap as
|
||||
| Map<string, number>
|
||||
| undefined
|
||||
if (sent && sent.has(clientMsgId)) {
|
||||
const t0 = sent.get(clientMsgId)!
|
||||
const rtt = Date.now() - t0
|
||||
sent.delete(clientMsgId)
|
||||
// 上报 BackendObserver(POST /api/backend-observer/record)
|
||||
void reportE2ELatency(rtt, msg.data.conversation_id)
|
||||
console.log(`[H5 WS] option_select e2e latency: ${rtt}ms`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 监控上报失败不阻塞主流程
|
||||
console.warn('[H5 WS] e2e latency 埋点失败:', e)
|
||||
}
|
||||
}
|
||||
handleMessage(msg)
|
||||
} catch (error) {
|
||||
console.error('[H5 WS] 消息解析失败:', error)
|
||||
|
||||
@@ -471,6 +471,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
created_at?: string
|
||||
}): void {
|
||||
// WS-06 消息去重:检查 message_id 是否已处理过
|
||||
// 🆕 v1.1 Bug 4 防御:processedMessageIds 在 switchToConversation / leaveAsParticipant
|
||||
// 时已 clear(见下两处),此处仅做幂等检查,不再依赖未清空的状态
|
||||
if (processedMessageIds.value.has(data.message_id)) {
|
||||
console.log(`[H5 WS去重] 跳过重复消息: ${data.message_id}`)
|
||||
return
|
||||
@@ -1244,6 +1246,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
// 清空消息列表,重新加载历史消息
|
||||
messages.value = []
|
||||
lastMessageId.value = ''
|
||||
// 🆕 v1.1 Bug 4 防御:会话切换时清空 processedMessageIds
|
||||
// 原因:避免旧会话的 message_id 残留 → 新会话的 ai_reply/option_select 误判为重复 → 不渲染
|
||||
processedMessageIds.value = new Set<string>()
|
||||
// 同时清空 pending option select(避免旧会话的 UUID 复用)
|
||||
pendingOptionSelect.value = null
|
||||
// 获取完整的历史消息
|
||||
await fetchMessages()
|
||||
console.log('[Store] 已切换到邀请会话:', conversationId)
|
||||
@@ -1272,6 +1279,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
participants.value = []
|
||||
messages.value = []
|
||||
lastMessageId.value = ''
|
||||
// 🆕 v1.1 Bug 4 防御:参与者退出时清空 processedMessageIds
|
||||
processedMessageIds.value = new Set<string>()
|
||||
pendingOptionSelect.value = null
|
||||
// 停止轮询
|
||||
stopPolling()
|
||||
} catch (error) {
|
||||
@@ -1392,12 +1402,20 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
match: currentConversation.value?.conversation_id === data.conversation_id,
|
||||
})
|
||||
|
||||
// 🆕 v1.1 Bug 4 修复:soft_match_fallback — conversation_id 不匹配时不再直接 return
|
||||
// 旧行为:员工快速切换会话后,原会话的 ai_reply 因 conversation_id 不匹配被丢弃
|
||||
// → 用户体感"AI 答案延迟显示"或"完全没显示"
|
||||
// 新行为:console.warn 记录 + 仍尝试 push message;仅当 message_id 已处理才 return
|
||||
if (currentConversation.value?.conversation_id !== data.conversation_id) {
|
||||
console.warn('[H5 WS] conversation_id 不匹配,丢弃 AI 回复:', {
|
||||
console.warn(
|
||||
'[H5 WS soft_match_fallback] conversation_id 不匹配,但仍尝试落 UI(避免切换竞态丢消息):',
|
||||
{
|
||||
current: currentConversation.value?.conversation_id,
|
||||
data: data.conversation_id,
|
||||
})
|
||||
return
|
||||
message_id: data.message_id,
|
||||
},
|
||||
)
|
||||
// 不 return — 软校验通过;继续走下面的去重 + push 逻辑
|
||||
}
|
||||
|
||||
// WS-06 消息去重:检查 message_id 是否已处理过(防止 WS 重连后重复推送)
|
||||
@@ -1609,7 +1627,79 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
// 选项提交状态(防止重复点击)
|
||||
const optionSubmitting = ref(false)
|
||||
const lastSentOptionContent = ref('')
|
||||
/**
|
||||
* 🆕 v1.1 Bug 6 修复:派生自 store.messages 而非内存 ref
|
||||
* 作用:从 messages 中过滤 msg_type === 'option_select' 的消息,
|
||||
* 按 (question_id, option_id) 联合键作为唯一 selected 标识。
|
||||
* 为什么:旧的 selectedOptionLabels 仅在当前会话内存中维护,
|
||||
* 浏览器刷新 / 长会话滚动 / REST 翻页 → 该 ref 被重置
|
||||
* → 历史选项气泡失去 ✓ 视觉。
|
||||
* 新方案直接从 messages 派生(messages 由 fetchMessages 加载),
|
||||
* 持久化生效。
|
||||
* 注意:与 selectedOptionLabels 并存,selectedOptionLabels 标记为
|
||||
* deprecated v1.2 删除,仅用于 sendOptionSelect 本地即时反馈。
|
||||
*/
|
||||
const selectedOptionLabels = ref<string[]>([])
|
||||
|
||||
/**
|
||||
* 🆕 v1.1 Bug 6:派生 selectedOptionIdsFromHistory
|
||||
* 返回 Set<key>,key = `${question_id}::${option_id}`。
|
||||
* 给 MessageBubble.isOptionSelected 提供来源(替代旧的 selectedOptionLabels.includes)。
|
||||
*/
|
||||
const selectedOptionIdsFromHistory = computed<Set<string>>(() => {
|
||||
const set = new Set<string>()
|
||||
for (const m of messages.value) {
|
||||
if (m.msg_type !== 'option_select') continue
|
||||
const ed = (m as any).extra_data
|
||||
if (!ed || typeof ed !== 'object') continue
|
||||
const qid = ed.question_id
|
||||
const oid = ed.option_id
|
||||
if (typeof qid === 'string' && qid.length > 0
|
||||
&& typeof oid === 'string' && oid.length > 0) {
|
||||
set.add(`${qid}::${oid}`)
|
||||
}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
/**
|
||||
* 🆕 v1.1 Bug 5 修复:按 question_id 视觉分组
|
||||
* 规则:连续同 question_id 的 option_select + 紧随其后的 ai_text 视为 1 组;
|
||||
* 同一 question_id 多次重选时,只有最后一次选中的 option 标记为 active,
|
||||
* 其余标记为 collapsed。
|
||||
* 返回结构与 messages 兼容(仍是 Message[]),但每条消息附加
|
||||
* .__grouped = { isCollapsed: boolean, isLatestInGroup: boolean }
|
||||
* 给 ChatPanel 渲染时直接读取。
|
||||
*/
|
||||
const groupedMessagesByQuestion = computed<Array<Message & { __grouped?: { isCollapsed: boolean; isLatestInGroup: boolean } }>>(() => {
|
||||
// 1) 先建立 question_id → 最新 option_select 的 message_id 映射
|
||||
const latestByQuestion = new Map<string, string>() // question_id → message_id
|
||||
for (const m of messages.value) {
|
||||
if (m.msg_type !== 'option_select') continue
|
||||
const ed = (m as any).extra_data
|
||||
if (!ed || typeof ed !== 'object') continue
|
||||
const qid = ed.question_id
|
||||
if (typeof qid !== 'string' || qid.length === 0) continue
|
||||
// 假设 messages 已按时间升序,最后写入的即最新
|
||||
latestByQuestion.set(qid, m.message_id)
|
||||
}
|
||||
// 2) 遍历标记 collapsed / latestInGroup
|
||||
return messages.value.map((m) => {
|
||||
const ed: any = (m as any).extra_data
|
||||
if (m.msg_type === 'option_select' && ed?.question_id) {
|
||||
const latestId = latestByQuestion.get(ed.question_id)
|
||||
const isLatest = latestId === m.message_id
|
||||
return {
|
||||
...m,
|
||||
__grouped: {
|
||||
isCollapsed: !isLatest,
|
||||
isLatestInGroup: isLatest,
|
||||
},
|
||||
} as Message & { __grouped: { isCollapsed: boolean; isLatestInGroup: boolean } }
|
||||
}
|
||||
return m as Message & { __grouped?: { isCollensed: boolean; isLatestInGroup: boolean } }
|
||||
})
|
||||
})
|
||||
// REQ-通用-005: pending payload(保存 UUID 以便重试复用 + 失败后兜底)
|
||||
const pendingOptionSelect = ref<{
|
||||
clientMsgId: string
|
||||
@@ -2145,6 +2235,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
optionSubmitting,
|
||||
lastSentOptionContent,
|
||||
selectedOptionLabels,
|
||||
// 🆕 v1.1 Bug 6 修复:从 store.messages 派生(刷新生效)
|
||||
selectedOptionIdsFromHistory,
|
||||
// 🆕 v1.1 Bug 5 视觉分组:按 question_id 折叠
|
||||
groupedMessagesByQuestion,
|
||||
dynamicRecommendations,
|
||||
unreadRecommendCount,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user