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:
Simon
2026-08-03 18:45:46 +08:00
parent 5adcb4852a
commit 969f524962
16 changed files with 1053 additions and 12 deletions
+86
View File
@@ -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),
})
+23
View File
@@ -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-openmask 函数异常时返回原 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
+7
View File
@@ -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 7BackendObserver 监控埋点端点
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 7BackendObserver 监控埋点路由
# 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 — 添加备注
+46
View File
@@ -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 7BackendObserver 单例(懒加载)
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_tsbroadcast_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 用浮点 mscounter 用整数增量)
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 到 statscounter 类型用累加值覆盖 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
+20
View File
@@ -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({
+145
View File
@@ -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 保护内部 deque10 线程并发写入 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
# 场景 Aoption_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 异常"
# 场景 Coption_select 但 content 异常(None)→ 不抛错
item = {"msg_type": "option_select", "content": None}
h5_mask_v1_1(item) # 不应 raise
# 场景 Ditem 缺 msg_type → 不抛错(fail-open
item = {"content": "test"}
h5_mask_v1_1(item)
assert item["content"] == "test"
+1
View File
@@ -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",
+2 -1
View File
@@ -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']
+1
View File
@@ -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) messagesv1.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 refv1.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 7option_select 发送时记录 client_send_ts
// 用于 ws.onmessage 中计算 e2e latencyrtt = 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
}
// fallbackfetch 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)
// 上报 BackendObserverPOST /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)
+99 -5
View File
@@ -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 回复:', {
current: currentConversation.value?.conversation_id,
data: data.conversation_id,
})
return
console.warn(
'[H5 WS soft_match_fallback] conversation_id 不匹配,但仍尝试落 UI(避免切换竞态丢消息):',
{
current: currentConversation.value?.conversation_id,
data: data.conversation_id,
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,