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,
|
get_org_tree_cached,
|
||||||
)
|
)
|
||||||
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
|
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 app.services.closing_service import ClosingService
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -1023,6 +1026,26 @@ async def h5_get_messages(
|
|||||||
messages.reverse()
|
messages.reverse()
|
||||||
|
|
||||||
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
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
|
# 判断是否还有更多:查询的消息数是否等于 limit
|
||||||
has_more = len(messages) == 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.conversation_annotation import router as annotation_router
|
||||||
from app.api.statistics import router as statistics_router
|
from app.api.statistics import router as statistics_router
|
||||||
from app.api.h5 import router as h5_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.agent_notes import router as agent_notes_router
|
||||||
from app.api.system import router as system_router
|
from app.api.system import router as system_router
|
||||||
from app.api.wingman import router as wingman_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 — 获取软件下载
|
# GET /api/h5/software-downloads — 获取软件下载
|
||||||
api_router.include_router(h5_router, tags=["H5用户端"])
|
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
|
# 坐席备注 API
|
||||||
# GET /api/agent-notes/{employee_id} — 获取员工备注
|
# GET /api/agent-notes/{employee_id} — 获取员工备注
|
||||||
# POST /api/agent-notes — 添加备注
|
# 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.tasks.h5_ai_task import process_h5_ai_reply
|
||||||
from app.utils.sensitive import mask_message_content, mask_sensitive_text
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# WebSocket 路由器(不挂 /api 前缀,直接注册在应用根路径)
|
# WebSocket 路由器(不挂 /api 前缀,直接注册在应用根路径)
|
||||||
@@ -422,6 +431,43 @@ async def _handle_option_select(
|
|||||||
except Exception as ws_err:
|
except Exception as ws_err:
|
||||||
logger.warning(f"option_select: 坐席广播失败(消息已落库): {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 反馈(异步后台任务,不阻塞)
|
# 4. 触发 Dify 反馈(异步后台任务,不阻塞)
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
process_h5_ai_reply(
|
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)
|
# 4. 广播坐席端(new_message + conversation_updated)
|
||||||
try:
|
try:
|
||||||
await ws_manager.broadcast({
|
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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"puppeteer": "^23.0.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.3.0",
|
"vite": "^5.3.0",
|
||||||
"vitest": "^4.1.10",
|
"vitest": "^4.1.10",
|
||||||
|
|||||||
Vendored
+2
-1
@@ -24,12 +24,13 @@ declare module 'vue' {
|
|||||||
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']
|
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']
|
||||||
|
IntegrationZone: typeof import('./src/components/chat/IntegrationZone.vue')['default']
|
||||||
InviteParticipantSheet: typeof import('./src/components/chat/InviteParticipantSheet.vue')['default']
|
InviteParticipantSheet: typeof import('./src/components/chat/InviteParticipantSheet.vue')['default']
|
||||||
ITHealthDashboard: typeof import('./src/components/assistant/ITHealthDashboard.vue')['default']
|
ITHealthDashboard: typeof import('./src/components/assistant/ITHealthDashboard.vue')['default']
|
||||||
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
||||||
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
|
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
|
||||||
ParticipantStrip: typeof import('./src/components/chat/ParticipantStrip.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']
|
RecommendCard: typeof import('./src/components/assistant/RecommendCard.vue')['default']
|
||||||
ResolveConfirmCard: typeof import('./src/components/chat/ResolveConfirmCard.vue')['default']
|
ResolveConfirmCard: typeof import('./src/components/chat/ResolveConfirmCard.vue')['default']
|
||||||
ResolveFeedback: typeof import('./src/components/ResolveFeedback.vue')['default']
|
ResolveFeedback: typeof import('./src/components/ResolveFeedback.vue')['default']
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vant/auto-import-resolver": "^1.2.0",
|
"@vant/auto-import-resolver": "^1.2.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"puppeteer": "^23.0.0",
|
||||||
"sass-embedded": "^1.100.0",
|
"sass-embedded": "^1.100.0",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"unplugin-vue-components": "^0.27.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>
|
</div>
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
|
<!-- 🆕 v1.1 Bug 5 视觉分组:改用 store.groupedMessagesByQuestion -->
|
||||||
|
<!-- 规则:同 question_id 多次重选时,仅最新一条 active,其余 collapsed -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
v-for="msg in store.messages"
|
v-for="msg in store.groupedMessagesByQuestion"
|
||||||
:key="msg.message_id"
|
:key="msg.message_id"
|
||||||
:msg="msg"
|
:msg="msg"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -171,9 +171,15 @@
|
|||||||
<!-- v3.2 P0-A: 选项气泡(员工确认选择的回执) -->
|
<!-- v3.2 P0-A: 选项气泡(员工确认选择的回执) -->
|
||||||
<!-- 修复:此前无 option_select 分支,命中 v-else fallback 显示"📄 媒体消息" -->
|
<!-- 修复:此前无 option_select 分支,命中 v-else fallback 显示"📄 媒体消息" -->
|
||||||
<!-- 渲染:靠右蓝底气泡 + ✓ 选项内容 + ISO 时间 -->
|
<!-- 渲染:靠右蓝底气泡 + ✓ 选项内容 + ISO 时间 -->
|
||||||
|
<!-- 🆕 v1.1 Bug 5 视觉分组:data-question-id 属性 + collapsed-question-group class -->
|
||||||
<template v-else-if="msg.msg_type === 'option_select'">
|
<template v-else-if="msg.msg_type === 'option_select'">
|
||||||
<p class="message-bubble__text message-bubble__text--option-select" style="white-space: pre-wrap;">
|
<p
|
||||||
<span class="message-bubble__option-tick">✓</span>
|
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 }}
|
{{ msg.content }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -237,8 +243,10 @@ const props = defineProps<{
|
|||||||
|
|
||||||
// v2.0: 获取 conversation store 实例(用于选项按钮点击回传 sendOptionSelect)
|
// v2.0: 获取 conversation store 实例(用于选项按钮点击回传 sendOptionSelect)
|
||||||
// v3.0: 解构 selectedOptionLabels 用于显示选中状态
|
// v3.0: 解构 selectedOptionLabels 用于显示选中状态
|
||||||
|
// 🆕 v1.1 Bug 6 修复:改用派生 computed selectedOptionIdsFromHistory(基于 messages 过滤)
|
||||||
|
// 旧的 selectedOptionLabels 仅维护在内存中,刷新 / 长会话滚动 → ✓ 视觉丢失
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const { selectedOptionLabels } = conversationStore
|
const { selectedOptionLabels, selectedOptionIdsFromHistory, groupedMessagesByQuestion } = conversationStore
|
||||||
|
|
||||||
// 引用回复点击事件(模板中使用 $emit 触发)
|
// 引用回复点击事件(模板中使用 $emit 触发)
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -326,22 +334,46 @@ const { displayText: typewriterText, isTyping } = useTypewriter(typewriterSource
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* v3.1: 判断 ai_structured.options[] 中某项是否被选中(兼容 store 本地 selectedOptionLabels)
|
* v3.1: 判断 ai_structured.options[] 中某项是否被选中(兼容 store 本地 selectedOptionLabels)
|
||||||
* 同步坐席端 isOptionSelected 的语义:优先按 option.value / option.id 匹配,
|
* 🆕 v1.1 Bug 6 修复:优先从 store.messages 派生的 selectedOptionIdsFromHistory 匹配
|
||||||
* 兼容旧版 store 的 label-based 匹配。
|
* (基于 question_id + option_id 联合键),保证刷新后 ✓ 仍在
|
||||||
|
* 旧逻辑(仅靠 selectedOptionLabels 内存 ref)保留兜底,
|
||||||
|
* 但 selectedOptionIdsFromHistory 命中即视为选中。
|
||||||
*/
|
*/
|
||||||
function isOptionSelected(option: any): boolean {
|
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 = [
|
const candidates = [
|
||||||
option?.value,
|
option?.value,
|
||||||
option?.id,
|
option?.id,
|
||||||
option?.option_id,
|
option?.option_id,
|
||||||
option?.label,
|
option?.label,
|
||||||
].filter((v) => typeof v === 'string' && v.length > 0) as string[]
|
].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) {
|
for (const c of candidates) {
|
||||||
if (selectedOptionLabels.includes(c)) return true
|
if (selectedOptionLabels.includes(c)) return true
|
||||||
}
|
}
|
||||||
return false
|
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 类名 */
|
/** 消息内容的 CSS 类名 */
|
||||||
const contentClass = computed(() => {
|
const contentClass = computed(() => {
|
||||||
return `message-bubble__content--${props.msg.message_type}`
|
return `message-bubble__content--${props.msg.message_type}`
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ export const wsConnected = ref(false)
|
|||||||
*/
|
*/
|
||||||
export function sendWsMessage(data: object): boolean {
|
export function sendWsMessage(data: object): boolean {
|
||||||
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
|
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))
|
wsInstance.send(JSON.stringify(data))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -71,6 +86,42 @@ export function sendWsMessage(data: object): boolean {
|
|||||||
return false
|
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 组合式函数
|
* H5员工端 WebSocket 组合式函数
|
||||||
*
|
*
|
||||||
@@ -174,6 +225,31 @@ export function useH5WebSocket() {
|
|||||||
ws.onmessage = (event: MessageEvent) => {
|
ws.onmessage = (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data)
|
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)
|
handleMessage(msg)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[H5 WS] 消息解析失败:', error)
|
console.error('[H5 WS] 消息解析失败:', error)
|
||||||
|
|||||||
@@ -471,6 +471,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
}): void {
|
}): void {
|
||||||
// WS-06 消息去重:检查 message_id 是否已处理过
|
// WS-06 消息去重:检查 message_id 是否已处理过
|
||||||
|
// 🆕 v1.1 Bug 4 防御:processedMessageIds 在 switchToConversation / leaveAsParticipant
|
||||||
|
// 时已 clear(见下两处),此处仅做幂等检查,不再依赖未清空的状态
|
||||||
if (processedMessageIds.value.has(data.message_id)) {
|
if (processedMessageIds.value.has(data.message_id)) {
|
||||||
console.log(`[H5 WS去重] 跳过重复消息: ${data.message_id}`)
|
console.log(`[H5 WS去重] 跳过重复消息: ${data.message_id}`)
|
||||||
return
|
return
|
||||||
@@ -1244,6 +1246,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
// 清空消息列表,重新加载历史消息
|
// 清空消息列表,重新加载历史消息
|
||||||
messages.value = []
|
messages.value = []
|
||||||
lastMessageId.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()
|
await fetchMessages()
|
||||||
console.log('[Store] 已切换到邀请会话:', conversationId)
|
console.log('[Store] 已切换到邀请会话:', conversationId)
|
||||||
@@ -1272,6 +1279,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
participants.value = []
|
participants.value = []
|
||||||
messages.value = []
|
messages.value = []
|
||||||
lastMessageId.value = ''
|
lastMessageId.value = ''
|
||||||
|
// 🆕 v1.1 Bug 4 防御:参与者退出时清空 processedMessageIds
|
||||||
|
processedMessageIds.value = new Set<string>()
|
||||||
|
pendingOptionSelect.value = null
|
||||||
// 停止轮询
|
// 停止轮询
|
||||||
stopPolling()
|
stopPolling()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1392,12 +1402,20 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
match: currentConversation.value?.conversation_id === data.conversation_id,
|
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) {
|
if (currentConversation.value?.conversation_id !== data.conversation_id) {
|
||||||
console.warn('[H5 WS] conversation_id 不匹配,丢弃 AI 回复:', {
|
console.warn(
|
||||||
current: currentConversation.value?.conversation_id,
|
'[H5 WS soft_match_fallback] conversation_id 不匹配,但仍尝试落 UI(避免切换竞态丢消息):',
|
||||||
data: data.conversation_id,
|
{
|
||||||
})
|
current: currentConversation.value?.conversation_id,
|
||||||
return
|
data: data.conversation_id,
|
||||||
|
message_id: data.message_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// 不 return — 软校验通过;继续走下面的去重 + push 逻辑
|
||||||
}
|
}
|
||||||
|
|
||||||
// WS-06 消息去重:检查 message_id 是否已处理过(防止 WS 重连后重复推送)
|
// WS-06 消息去重:检查 message_id 是否已处理过(防止 WS 重连后重复推送)
|
||||||
@@ -1609,7 +1627,79 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
// 选项提交状态(防止重复点击)
|
// 选项提交状态(防止重复点击)
|
||||||
const optionSubmitting = ref(false)
|
const optionSubmitting = ref(false)
|
||||||
const lastSentOptionContent = ref('')
|
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[]>([])
|
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 以便重试复用 + 失败后兜底)
|
// REQ-通用-005: pending payload(保存 UUID 以便重试复用 + 失败后兜底)
|
||||||
const pendingOptionSelect = ref<{
|
const pendingOptionSelect = ref<{
|
||||||
clientMsgId: string
|
clientMsgId: string
|
||||||
@@ -2145,6 +2235,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
optionSubmitting,
|
optionSubmitting,
|
||||||
lastSentOptionContent,
|
lastSentOptionContent,
|
||||||
selectedOptionLabels,
|
selectedOptionLabels,
|
||||||
|
// 🆕 v1.1 Bug 6 修复:从 store.messages 派生(刷新生效)
|
||||||
|
selectedOptionIdsFromHistory,
|
||||||
|
// 🆕 v1.1 Bug 5 视觉分组:按 question_id 折叠
|
||||||
|
groupedMessagesByQuestion,
|
||||||
dynamicRecommendations,
|
dynamicRecommendations,
|
||||||
unreadRecommendCount,
|
unreadRecommendCount,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user