Files
wecom_it_smart_desk/docs/02-技术文档/技术方案-REQ-坐席-010-AI回复三态开关.md
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

697 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术方案 - AI 回复三态开关(REQ-坐席-010
> **REQ编号**: REQ-坐席-010
> **版本**: v1.0
> **优先级**: P0(含现状行为缺口修复)
> **日期**: 2026-08-04
> **架构师**: 高见远(Gao
> **上游 PRD**: `docs/01-产品文档/04-坐席工作台/PRD-REQ-坐席-010-AI回复三态开关.md`
> **代码事实核验**: 已逐文件 grep/read 确认(见附录 A「代码事实核验表」)
---
## 一、实现方案(Implementation Approach
### 1.1 核心难点
| # | 难点 | 解法 |
|---|------|------|
| D1 | AI 自动回复存在**两条完全独立路径**,历史上门控口径不一致(路径 A 有 status 门控、路径 B 零门控) | 抽取**单一门控判定函数** `ai_reply_gate.py`,两条路径共用同一套判定,杜绝口径漂移 |
| D2 | 路径 B 是 `asyncio.create_task` 后台任务,任务内部再判断会**白白占用 DB session + Dify 配额** | 门控前置到**调用点**(`h5.py` / `ws.py`),`create_task` 之前拦截 |
| D3 | `employee_and_agent` 的"推坐席"在两条路径上机制完全不同:路径 A **根本没有**坐席 WS 广播(只 `wecom send`),路径 B **默认就广播**(需反向收紧) | 路径 A **补齐**广播;路径 B **加条件**收紧。两侧都收敛到同一 helper `should_push_to_agent()` |
| D4 | 路径 B 的坐席广播点分散在 `h5_ai_task.py` 的 4 个函数中,逐点改易漏 | 统一用 helper 包装,任务清单**逐行号列出全部 4 处**(附录 B) |
| D5 | 字段需与 `status` 状态机严格正交,且存量数据要有默认值 | 新增独立 `String(20)` 字段(**不用 DB 原生 ENUM**,与既有 `status` 写法一致,兼容 SQLite/PG),Alembic 迁移带 `server_default` |
### 1.2 框架选型(全部沿用既有,无新增)
| 层 | 技术 | 说明 |
|----|------|------|
| 后端 Web | FastAPI + Pydantic v2 | 沿用;新端点复用 `success_response` / `AppException` / `@require_permission` |
| ORM | SQLAlchemy 2.0 (`Mapped` / `mapped_column`) | 沿用;新字段写法对齐既有 `status` 字段 |
| 迁移 | **Alembic**(已确认:`src/backend/alembic/``alembic.ini`,当前 head = `057_troubleshooting_templates` | 新增 revision `058_add_ai_reply_mode` |
| 实时通道 | 进程内单例 `app.services.ws_manager.manager` | 沿用 `broadcast()`(全坐席)/ `broadcast_to_employees()`(指定员工) |
| 前端 | Vue3 `<script setup>` + Element Plus + Vite + Pinia | 沿用;`el-popover` + `el-radio-group`,复用 `.tb-btn` / `.tb-sep` 既有样式 |
### 1.3 架构决策记录(ADR,已拍板)
| # | 决策 | 理由 |
|---|------|------|
| ADR-1 | 字段名 `ai_reply_mode`,类型 `String(20)` 而非 SQLAlchemy `Enum` | 与既有 `status`/`emotion_state` 写法一致;避免 PG 原生 enum 类型带来的迁移/回滚复杂度;SQLite 本地开发兼容 |
| ADR-2 | 枚举值以**模块常量**集中定义(`app/constants/ai_reply_mode.py`),前后端各持一份,不引入代码生成 | 项目现无 schema 共享机制,硬编码字符串分散风险高,集中常量 + 前端 `as const` 是最小成本方案 |
| ADR-3 | 端点落在 **`app/api/conversations.py`**,路径 `PUT /api/conversations/{conversation_id}/ai-reply-mode` | 该文件已聚合全部会话级坐席操作(assign/pin/todo/transfer/grab/tags),语义归位;`app/api/agent.py` 在本仓库**不存在**(只有 `agents.py`,是坐席账号域),放 h5.py 则是员工域,均不合适 |
| ADR-4 | 门控判定抽成独立模块 `app/services/ai_reply_gate.py`(纯函数,无 IO) | 两条路径 + 4 个广播点共用,可单测,避免逻辑漂移 |
| ADR-5 | 路径 B 门控条件采用**白名单** `status in ("ai_handling","queued")`,而非黑名单 `status != "serving"` | 白名单同时挡掉 `pending_close` / `resolved`(黑名单会漏),语义更安全。已确认 H5 新建会话 status=`ai_handling``h5.py:876`),不误伤正常流程 |
| ADR-6 | 在途任务**不中断**PRD P1-4 | `asyncio.create_task` 已提交的任务不做 cancel,避免半截消息;门控只作用于「新触发」 |
| ADR-7 | 新增 WS 事件 `ai_reply_mode_changed` 而非复用 `conversation_updated` | `conversation_updated` 在前端会触发会话列表刷新(重逻辑),模式切换是轻量字段同步,独立事件更精准、可单独灰度 |
---
## 二、文件列表(相对路径 · 标注 [新增]/[修改])
### 2.1 后端
| # | 文件 | 标记 | 改动摘要 |
|---|------|------|---------|
| B1 | `src/backend/app/constants/ai_reply_mode.py` | **[新增]** | 三态字符串常量、默认值、合法集合、路径 B status 白名单 |
| B2 | `src/backend/app/models/conversation.py` | [修改] | `Conversation` 新增 `ai_reply_mode: Mapped[str]`String(20), default `employee_only`, NOT NULL |
| B3 | `src/backend/alembic/versions/058_add_ai_reply_mode.py` | **[新增]** | `add_column` + `server_default='employee_only'` + 回填 + downgrade |
| B4 | `src/backend/app/schemas/conversation.py` | [修改] | 新增 `AiReplyModeUpdate`(含 validator)、`AiReplyModeResponse``ConversationResponse``ai_reply_mode: str` |
| B5 | `src/backend/app/services/ai_reply_gate.py` | **[新增]** | `can_ai_auto_reply_path_a()` / `can_ai_auto_reply_path_b()` / `should_push_to_agent()` 三个纯函数 |
| B6 | `src/backend/app/api/conversations.py` | [修改] | 新增 `PUT /conversations/{id}/ai-reply-mode` 端点 + WS 广播 `ai_reply_mode_changed` |
| B7 | `src/backend/app/services/message_router.py` | [修改] | **L176** 门控加 `ai_reply_mode != off`**`_try_ai_reply`(L458)** 末尾在 `employee_and_agent` 时补坐席 WS 广播 |
| B8 | `src/backend/app/api/h5.py` | [修改] | **L939** `create_task` 前加路径 B 门控 |
| B9 | `src/backend/app/api/ws.py` | [修改] | **L472** `create_task` 前加路径 B 门控(option_select 反馈) |
| B10 | `src/backend/app/tasks/h5_ai_task.py` | [修改] | 4 处坐席广播点(L482 / L749 / L891 / L1603)改为 `should_push_to_agent()` 条件广播 |
### 2.2 前端(坐席工作台 `src/frontend-agent`
| # | 文件 | 标记 | 改动摘要 |
|---|------|------|---------|
| F1 | `src/frontend-agent/src/types/ai-reply-mode.ts` | **[新增]** | `AiReplyMode` 联合类型、三态选项元数据(label/desc/icon 色) |
| F2 | `src/frontend-agent/src/api/conversation.ts` | [修改] | `Conversation` 接口增 `ai_reply_mode`;新增 `updateAiReplyMode()` |
| F3 | `src/frontend-agent/src/components/chat/AiReplyModeSwitch.vue` | **[新增]** | 🤖 按钮 + `el-popover` + `el-radio-group` 三态组件(乐观更新 + 失败回滚) |
| F4 | `src/frontend-agent/src/components/chat/ReplyBox.vue` | [修改] | 工具栏最左侧挂载 `<AiReplyModeSwitch>` + `.tb-sep` |
| F5 | `src/frontend-agent/src/stores/conversation.ts` | [修改] | 新增 `setAiReplyMode(convId, mode)` 本地写入 + `handleAiReplyModeChanged()` |
| F6 | `src/frontend-agent/src/composables/useWebSocket.ts` | [修改] | `handleMessage` switch 增 `case 'ai_reply_mode_changed'` |
### 2.3 测试
| # | 文件 | 标记 | 改动摘要 |
|---|------|------|---------|
| T1 | `src/backend/tests/test_ai_reply_gate.py` | **[新增]** | 门控纯函数真值表单测(3 mode × 5 status |
| T2 | `src/backend/tests/test_ai_reply_mode_api.py` | **[新增]** | 端点 200/非法值 400/不存在 404/持久化断言 |
---
## 三、数据结构与接口
### 3.1 数据模型变更(ER 片段)
```
conversations
├─ id VARCHAR(36) PK
├─ status VARCHAR(20) NOT NULL DEFAULT 'queued' ← 既有,本次不动
├─ ai_reply_mode VARCHAR(20) NOT NULL DEFAULT 'employee_only' ← 🆕 本次新增(与 status 正交)
├─ assigned_agent_id VARCHAR(64) NULL
└─ ...(其余字段不变)
```
| 字段 | 类型 | 约束 | 默认 | 说明 |
|------|------|------|------|------|
| `ai_reply_mode` | `VARCHAR(20)` | NOT NULL | `'employee_only'` | 取值 `employee_only` / `employee_and_agent` / `off` |
> **不加索引**:该字段只在「已知 conversation_id 后单行读取」场景使用,无范围查询,加索引纯负收益。
### 3.2 类图(Mermaid classDiagram
```mermaid
classDiagram
class AiReplyMode {
<<constants>>
+str EMPLOYEE_ONLY$ = "employee_only"
+str EMPLOYEE_AND_AGENT$ = "employee_and_agent"
+str OFF$ = "off"
+str DEFAULT$ = "employee_only"
+frozenset ALL$
+frozenset PATH_B_ALLOWED_STATUS$ = {ai_handling, queued}
}
class Conversation {
+str id
+str employee_id
+str status
+str ai_reply_mode
+str assigned_agent_id
+dict tags
+int ai_substantive_reply_count
+__repr__() str
}
class AiReplyGate {
<<module: app.services.ai_reply_gate>>
+can_ai_auto_reply_path_a(conversation) bool
+can_ai_auto_reply_path_b(conversation) bool
+should_push_to_agent(conversation) bool
}
class AiReplyModeUpdate {
<<pydantic BaseModel>>
+str mode
+validate_mode(v) str
}
class AiReplyModeResponse {
<<pydantic BaseModel>>
+str conversation_id
+str ai_reply_mode
}
class ConversationResponse {
<<pydantic BaseModel>>
+str id
+str status
+str ai_reply_mode
}
class MessageRouter {
<<app.services.message_router>>
-AIHandler ai_handler
-WecomService wecom_service
+handle_employee_message(...) Conversation
-_try_ai_reply(conversation, content, from_user_id) bool
}
class H5AiTask {
<<app.tasks.h5_ai_task>>
+process_h5_ai_reply(...) None
-_persist_and_push(...) None
-_persist_and_push_structured(...) None
-_handle_byod_query(...) None
-_step_call_dify(...) AIReplyResult
}
class ConnectionManager {
<<app.services.ws_manager singleton>>
+Dict~str,WebSocket~ active_connections
+Dict~str,WebSocket~ employee_connections
+broadcast(data) None
+send_to_agent(agent_id, data) None
+broadcast_to_employees(ids, data) None
}
class ConversationsAPI {
<<app.api.conversations>>
+update_ai_reply_mode(conversation_id, body, db) dict
}
AiReplyGate ..> AiReplyMode : 读常量
AiReplyGate ..> Conversation : 只读判定
AiReplyModeUpdate ..> AiReplyMode : 值校验
ConversationsAPI --> Conversation : 写 ai_reply_mode
ConversationsAPI --> AiReplyModeUpdate : 请求体
ConversationsAPI --> AiReplyModeResponse : 响应体
ConversationsAPI --> ConnectionManager : broadcast(ai_reply_mode_changed)
MessageRouter ..> AiReplyGate : 路径A门控 + 推坐席判定
MessageRouter --> ConnectionManager : 🆕 employee_and_agent 时广播
H5AiTask ..> AiReplyGate : 推坐席判定
H5AiTask --> ConnectionManager : 条件广播
ConversationResponse ..> Conversation : from_attributes
```
### 3.3 门控真值表(**唯一权威口径**,实现必须与此表逐格一致)
**路径 A(企微 App`message_router.py:176`)— 是否触发 AI 自动回复**
| status \ mode | `employee_only` | `employee_and_agent` | `off` |
|---|---|---|---|
| `ai_handling` | ✅ 触发(仅推员工) | ✅ 触发(推员工 **+ 广播坐席**| ❌ |
| `queued` / `serving` / `pending_close` / `resolved` | ❌(既有行为,不变) | ❌ | ❌ |
**路径 BH5/WS`h5.py:939` / `ws.py:472`)— 是否 `create_task`**
| status \ mode | `employee_only` | `employee_and_agent` | `off` |
|---|---|---|---|
| `ai_handling` | ✅ 触发(AI 回复**仅推员工**,不广播坐席) | ✅ 触发(推员工 **+ 广播坐席**| ❌ |
| `queued` | ✅ 触发(同上) | ✅ 触发 | ❌ |
| `serving` | ❌ **🔴 本次修复的缺口**(原为 ✅ 误触发) | ❌ | ❌ |
| `pending_close` / `resolved` | ❌ | ❌ | ❌ |
> ⚠️ **行为变更提示(供 QA 重点回归)**:路径 B 在 `employee_only` 下**不再向坐席广播 AI 回复**(原为无条件广播)。这是 PRD 三态语义的必然结果——"仅回复员工"意味着员工-AI 对话对坐席不可见。
### 3.4 门控函数签名(`app/services/ai_reply_gate.py`
```python
def can_ai_auto_reply_path_a(conversation: Conversation) -> bool:
"""路径A(企微App)是否允许 AI 自动回复。
条件:status == 'ai_handling' AND ai_reply_mode != 'off'
"""
def can_ai_auto_reply_path_b(conversation: Conversation) -> bool:
"""路径BH5/WS)是否允许发起 process_h5_ai_reply 后台任务。
条件:status in {'ai_handling','queued'} AND ai_reply_mode != 'off'
必须在 asyncio.create_task 之前调用(避免无谓占用 DB session / Dify 配额)。
"""
def should_push_to_agent(conversation: Conversation) -> bool:
"""AI 自动回复是否需要广播给坐席端。
条件:ai_reply_mode == 'employee_and_agent'
"""
```
> **健壮性要求**:三个函数必须用 `getattr(conversation, "ai_reply_mode", None) or DEFAULT` 兜底读取,防止灰度期未迁移实例 / mock 对象缺字段导致 `AttributeError` 打挂主链路。
### 3.4.1 「推坐席」语义(按用户 2026-08-04 15:47 最终口径修正)
> ⚠️ **本节为 2026-08-04 用户拍板澄清后的最终口径**,原 §3.4 单函数描述保留向后兼容,新实现按本节**双函数**结构落地。
**B 口径核心语义**(用户拍板 2026-08-04 15:47):
> a 态(employee_only= AI 只对员工消息/选择作进一步回复;坐席端**仍按现状能看到 AI 回复员工的消息**。
> 即:a 维持现状镜像;b 在 a 基础上**额外**推专属;off 全关。
按此口径,「推坐席」拆分为两个**正交**判定函数(而非合并的 `should_push_to_agent`):
| 函数 | 语义 | 取值条件 | 关系 |
|------|------|---------|------|
| `agent_mirror_enabled(mode)` | 是否保留**坐席侧镜像广播**(既有行为,new_message / conversation_updated / ai_thinking 等) | `mode != "off"` | a ✓,b ✓,off ✗ |
| `agent_dedicated_message(mode)` | 是否**额外**推送坐席专属「AI 协助」消息 | `mode == "employee_and_agent"` | a ✗,b ✓,off ✗ |
**推坐席行为表**(最终):
| mode \ 推坐席行为 | 镜像广播(既有) | 坐席专属消息(新增) |
|------------------|----------------|------------------|
| `employee_only`(默认) | ✅ 保留现状 | ✗ 不推 |
| `employee_and_agent` | ✅ 保留现状 + 额外推专属 | ✅ 推 |
| `off` | ✗ 关闭 | ✗ 不推 |
> **行为变更提示(供 QA 重点回归)**:
> 1. 路径 B 在 `employee_only` 下**不再**向坐席镜像广播 AI 回复(原为无条件广播)。这是三态语义的必然结果——"仅回复员工"意味着员工-AI 对话对坐席不可见。
> 2. `employee_and_agent` 在「镜像 + 专属」两条路径上**同时**生效:镜像广播触发 `new_message`/`conversation_updated`(与 a 同链路),专属推送触发 `ai_reply_for_agent`(带 `【AI 协助】` 前缀)。
**当前实现已与本口径一致**(详见 §3.4.2):
```python
# app/services/ai_reply_gate.py
def agent_mirror_enabled(mode: Optional[str]) -> bool:
return resolve_mode(mode) != AI_REPLY_MODE_OFF
def agent_dedicated_message(mode: Optional[str]) -> bool:
return resolve_mode(mode) == AI_REPLY_MODE_EMPLOYEE_AND_AGENT
```
**调用点(实际代码)**
- `src/backend/app/tasks/h5_ai_task.py` 4 处坐席广播点(L540 / L817 / L970 / L1684)→ `if agent_mirror_enabled(_conv_mode):` 守卫
- `src/backend/app/tasks/h5_ai_task.py` 2 处专属推送(L567 / L855)→ `_push_agent_dedicated_message(...)`(函数内已 `if not agent_dedicated_message(conversation_mode(conversation)): return` 兜底)
- `src/backend/app/services/message_router.py` 路径 A L580 → `if agent_dedicated_message(conversation_mode(conversation)):` 守卫
### 3.4.2 与 §3.4 单函数版本的差异说明
| 维度 | 原 §3.4 单函数 | 本方案最终实现 |
|------|--------------|--------------|
| 函数数量 | 1 个 `should_push_to_agent()` | 2 个:`agent_mirror_enabled()` + `agent_dedicated_message()` |
| 入参 | `conversation` 对象 | 原始 `mode` 字符串(纯函数) |
| 语义 | 「是否推坐席」(合并) | 镜像 / 专属 两条独立通道 |
| 与 B 口径一致性 | 模糊 | ✅ 严格对齐 |
> 历史文档保留 §3.4 仅作 ADR 记录;**所有新代码一律按 §3.4.1 双函数实现**。
### 3.5 设置 API 契约
```
PUT /api/conversations/{conversation_id}/ai-reply-mode
```
| 项 | 值 |
|---|---|
| 权限 | `@require_permission("conversation", "update", "own")`(对齐 pin/todo |
| Content-Type | `application/json` |
| 幂等 | 是(同值重复提交返回相同结果) |
| 并发 | 最后写入者胜(last-write-wins,无版本号/乐观锁) |
**请求体** `AiReplyModeUpdate`
```json
{ "mode": "employee_and_agent" }
```
**成功响应** `200`(统一 `success_response` 包装)
```json
{
"code": 0,
"message": "success",
"data": { "conversation_id": "b3f1...", "ai_reply_mode": "employee_and_agent" }
}
```
**错误响应**
| code | HTTP | 触发条件 | message |
|------|------|---------|---------|
| `1001` | 200(业务码) | `mode` 不在合法集合 | `AI回复模式非法,仅支持 employee_only/employee_and_agent/off` |
| `1002` | 200(业务码) | conversation 不存在 | `会话不存在` |
> 沿用项目既有 `AppException(code, msg)` 约定(业务码在 bodyHTTP 保持 200),**不要**自创 HTTP 4xx。
### 3.6 WS 消息新增类型
| 事件 | 方向 | 触发点 | payload |
|------|------|--------|---------|
| `ai_reply_mode_changed` | 服务端 → **全体坐席** (`ws_manager.broadcast`) | 设置 API 写库成功后 | `{"type":"ai_reply_mode_changed","data":{"conversation_id":"...","ai_reply_mode":"off"}}` |
> 用 `broadcast()` 而非 `send_to_agent()`:会话可能有协作坐席(`collaborating_agent_ids`+ 参与者,逐个定向推容易漏;前端按 `conversation_id` 自行过滤,成本可忽略。
---
## 四、程序调用流程(时序图)
### 4.1 时序图①:坐席切换 mode 的完整调用链
```mermaid
sequenceDiagram
autonumber
actor Agent as 坐席
participant SW as AiReplyModeSwitch.vue
participant Store as stores/conversation.ts
participant Api as api/conversation.ts
participant EP as conversations.py<br/>PUT /ai-reply-mode
participant Schema as AiReplyModeUpdate
participant DB as PostgreSQL<br/>conversations
participant WS as ws_manager
participant Other as 其他坐席端<br/>useWebSocket.ts
Agent->>SW: 点击 🤖 → el-popover 展开
SW->>Store: 读 currentConversation.ai_reply_mode
Store-->>SW: "employee_only"radio 选中态)
Agent->>SW: 选中「关闭自动回复」
Note over SW,Store: P1-1 乐观更新
SW->>Store: setAiReplyMode(convId, "off")
Store-->>SW: UI 立即反映(🤖 置灰)
SW->>Api: updateAiReplyMode(convId, "off")
Api->>EP: PUT /api/conversations/{id}/ai-reply-mode<br/>{"mode":"off"}
EP->>Schema: AiReplyModeUpdate(mode="off")
alt mode 非法
Schema-->>EP: ValidationError
EP-->>Api: AppException(1001, "AI回复模式非法…")
Api-->>SW: reject
SW->>Store: 回滚为原值 "employee_only"
SW->>Agent: ElMessage.error + popover 保持打开
else mode 合法
EP->>DB: SELECT * FROM conversations WHERE id=:id
alt 会话不存在
DB-->>EP: None
EP-->>Api: AppException(1002, "会话不存在")
Api-->>SW: reject → 回滚 + 报错
else 会话存在
EP->>DB: UPDATE ai_reply_mode='off', updated_at=now()
DB-->>EP: committed
EP->>WS: broadcast(ai_reply_mode_changed)
WS-->>Other: {"type":"ai_reply_mode_changed",<br/>"data":{conversation_id, ai_reply_mode}}
Other->>Other: store.handleAiReplyModeChanged()<br/>同步本地态
EP-->>Api: {code:0, data:{conversation_id, ai_reply_mode:"off"}}
Api-->>SW: resolve
SW->>Agent: ElMessage.success("已切换为:关闭自动回复")<br/>+ 关闭 popover
end
end
Note over Agent,DB: 切到别的会话再切回 → 从 ConversationResponse.ai_reply_mode<br/>读回该会话自己的值(P0-6 单会话持久化)
```
### 4.2 时序图②:路径 B 员工发消息 —— 三态 × status 门控与推送分支
```mermaid
sequenceDiagram
autonumber
actor Emp as 员工(H5)
participant H5 as api/h5.py<br/>send_message
participant DB as conversations / messages
participant Gate as ai_reply_gate
participant Task as tasks/h5_ai_task.py<br/>process_h5_ai_reply
participant Dify as Dify
participant WS as ws_manager
actor AgentUI as 坐席工作台
Emp->>H5: POST /api/h5/.../messages {content}
H5->>DB: 查/建 conversation(新建 status=ai_handling
H5->>DB: INSERT message(sender_type=employee)
H5->>WS: broadcast(new_message, sender=employee)
WS-->>AgentUI: 员工消息(🔸不受本开关影响,始终可见)
H5->>DB: COMMIT(保证后台任务读得到)
rect rgb(255, 244, 230)
Note over H5,Gate: 🔑 门控必须在 asyncio.create_task **之前**h5.py:939 / ws.py:472
H5->>Gate: can_ai_auto_reply_path_b(conversation)
Gate->>Gate: status in {ai_handling, queued}?<br/>AND ai_reply_mode != "off"?
end
alt ❌ mode == "off"
Gate-->>H5: False
H5->>H5: logger.info("AI自动回复已关闭,跳过")
H5-->>Emp: 200 {user_message, ai_reply:null}
Note over Task: 后台任务从未创建 → 零 DB session / 零 Dify 调用
else ❌ status == "serving"(🔴 本次修复的缺口)
Gate-->>H5: False
H5->>H5: logger.info("坐席已接管,跳过AI自动回复")
H5-->>Emp: 200 {user_message, ai_reply:null}
Note over AgentUI: 坐席独占对话,AI 不再抢话(PRD G2 / US2
else ✅ 允许触发
Gate-->>H5: True
H5->>Task: asyncio.create_task(process_h5_ai_reply(...))
H5-->>Emp: 200(立即返回,AI 回复走 WS 异步推)
Task->>DB: _step_load_conversation
Task->>Dify: _step_call_dify
Note over Task,WS: ai_thinking 坐席广播(h5_ai_task.py:1603<br/>同样受 should_push_to_agent 门控
Task->>Gate: should_push_to_agent(conversation)
alt mode == employee_and_agent
Task->>WS: broadcast(ai_thinking)
WS-->>AgentUI: AI 思考中…
end
Dify-->>Task: AIReplyResult
Task->>DB: _persist_and_push*INSERT message(sender_type=ai)
Task->>WS: broadcast_to_employees([emp], ai_reply)
WS-->>Emp: 💬 AI 回复(三态下只要触发就必推员工)
rect rgb(232, 245, 233)
Note over Task,Gate: 坐席广播门控(4 处:L482 / L749 / L891 / L1603
Task->>Gate: should_push_to_agent(conversation)
end
alt mode == "employee_and_agent"
Gate-->>Task: True
Task->>WS: broadcast(new_message, sender_type=ai)
Task->>WS: broadcast(conversation_updated)
WS-->>AgentUI: 👁️ 坐席同步看到 AI 回复内容(P0-7 / US3
else mode == "employee_only"(默认)
Gate-->>Task: False
Note over Task,AgentUI: ⚠️ 行为变更:不再广播坐席<br/>员工-AI 会话对坐席不可见
end
end
Note over H5,Task: ADR-6:切 off / 转 serving **不 cancel** 已提交的在途 task<br/>让其自然结束(PRD P1-4,避免半截消息)
```
---
## 五、任务分解(Task List
> **粒度**:文件级,工程师可照单执行。**顺序**:T01 → T02/T03/T04(可并行)→ T05。
### T01 · 数据层:字段 + 常量 + 迁移 + Schema P0 依赖:无
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/constants/ai_reply_mode.py` | [新增] | 定义 `EMPLOYEE_ONLY="employee_only"` / `EMPLOYEE_AND_AGENT="employee_and_agent"` / `OFF="off"` / `DEFAULT=EMPLOYEE_ONLY` / `ALL=frozenset({...})` / `PATH_B_ALLOWED_STATUS=frozenset({"ai_handling","queued"})`。若 `app/constants/` 目录不存在需一并建 `__init__.py` |
| `src/backend/app/models/conversation.py` | [修改] | 在 `emotion_state`L220-225)之后插入 `ai_reply_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="employee_only", server_default="employee_only", comment="AI自动回复模式: employee_only/employee_and_agent/off(与status正交)")`;同步补类docstring 的 Attributes 说明。**不加索引**ADR |
| `src/backend/alembic/versions/058_add_ai_reply_mode.py` | [新增] | `revision='058_add_ai_reply_mode'`, `down_revision='057_troubleshooting_templates'``upgrade()`:用 `sa.inspect(bind)` 检查列是否已存在(对齐 057 的幂等写法)→ `op.add_column('conversations', sa.Column('ai_reply_mode', sa.String(20), nullable=False, server_default='employee_only'))` → 显式 `op.execute("UPDATE conversations SET ai_reply_mode='employee_only' WHERE ai_reply_mode IS NULL")` 兜底回填。`downgrade()``op.drop_column` |
| `src/backend/app/schemas/conversation.py` | [修改] | ① 新增 `AiReplyModeUpdate(BaseModel)``mode: str = Field(..., description=...)` + `@field_validator("mode")` 校验属于 `ALL`,非法抛 `ValueError`(仿 L122 `ConversationStatusUpdate.validate_status` 写法);② 新增 `AiReplyModeResponse(BaseModel)``conversation_id: str` / `ai_reply_mode: str`;③ `ConversationResponse`L240)增字段 `ai_reply_mode: str = "employee_only"`(**必须加,否则前端拿不到当前态**) |
**验收**`alembic upgrade head` 成功;存量行 `ai_reply_mode='employee_only'``GET /api/conversations` 返回体含该字段。
---
### T02 · 门控内核 + 设置 API + WS 事件 P0 依赖:T01
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/services/ai_reply_gate.py` | [新增] | 实现 §3.4 三个纯函数,逐条对齐 §3.3 真值表。**必须**用 `getattr(conv,"ai_reply_mode",None) or DEFAULT` 兜底。无 IO、无 async,便于单测 |
| `src/backend/app/api/conversations.py` | [修改] | 新增 `PUT /conversations/{conversation_id}/ai-reply-mode`,挂 `@require_permission("conversation","update","own")`(照抄 L366 `toggle_pin` 结构)。流程:`select(Conversation).where(id==...)` → 不存在抛 `AppException(1002,"会话不存在")` → 赋值 `ai_reply_mode` + `updated_at=datetime.now()``commit()``ws_manager.broadcast({"type":"ai_reply_mode_changed","data":{...}})`**try/except 包裹,广播失败只 warning,不回滚写库**)→ `success_response(data=AiReplyModeResponse(...).model_dump())`。需在文件头 import `ai_reply_mode` 常量与 `ws_manager` |
| `src/backend/tests/test_ai_reply_gate.py` | [新增] | 3 mode × 5 status = 15 组参数化断言,逐格比对 §3.3 两张真值表;额外覆盖「对象缺 `ai_reply_mode` 属性时退化为 DEFAULT」 |
| `src/backend/tests/test_ai_reply_mode_api.py` | [新增] | 端点用例:合法值 → code 0 且 DB 已改;非法值 `"xxx"` → code 1001;不存在 conv → code 1002;连续切换两次 → 以最后一次为准 |
**验收**`pytest tests/test_ai_reply_gate.py tests/test_ai_reply_mode_api.py` 全绿。
---
### T03 · 路径 A 门控 + 补齐坐席广播 | P0 | 依赖:T02
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/services/message_router.py` | [修改] | **① L176-179**`if self.ai_handler and conversation.status == "ai_handling":``if self.ai_handler and can_ai_auto_reply_path_a(conversation):`(保留 `self.ai_handler` 判空;`else` 分支加 `logger.info` 记录跳过原因,便于排障)<br>**② `_try_ai_reply`L458-563)末尾**(在 `return True` 之前、`ai_message``flush()` 拿到 id 之后):新增 `if should_push_to_agent(conversation):``await ws_manager.broadcast({"type":"new_message","data":{conversation_id, message_id, sender_type:"ai", sender_id:"ai_bot", sender_name:"Duckula(达寇拉)", content:result.content, msg_type:"text"}})`,字段对齐 `h5_ai_task.py:482` 的 payload 形状。**try/except 包裹**,广播失败只 warning(照抄 L502 注释风格)<br>**③** 文件头 import `ai_reply_gate``ws_manager`(注意:本文件当前**未** import ws_manager,需新增;建议函数内局部 import 以规避潜在循环依赖,参照 `h5_ai_task.py:342` 的局部 import 惯例) |
> **补充说明(解答 PRD Q1**:路径 A 原本**只有** `wecom_service.send_text_message()` 推员工,坐席侧**无任何 WS 广播链路**(消息虽落库但坐席端不实时可见)。本任务即为补齐该链路,且仅在 `employee_and_agent` 下开启。
**验收**`off` 态下路径 A 触发率 0`employee_and_agent` 下坐席端能实时收到 AI 回复气泡;`employee_only` 下坐席端无 AI 气泡(同现状)。
---
### T04 · 路径 B 门控(修复缺口)+ 推坐席语义收紧 | P0 | 依赖:T02
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/backend/app/api/h5.py` | [修改] | **L939** `asyncio.create_task(process_h5_ai_reply(...))` 外层包 `if can_ai_auto_reply_path_b(conversation):``else` 分支 `logger.info(f"跳过AI自动回复: conv={conversation.id}, status={conversation.status}, mode={conversation.ai_reply_mode}")`。<br>⚠️ **门控必须在 `create_task` 之前**,不得下沉进任务内部(否则仍会白占 DB session + Dify 配额)。<br>L950 的 `success_response` 保持不变(`ai_reply:null` 语义已兼容"未触发" |
| `src/backend/app/api/ws.py` | [修改] | **L472** 同上包裹(`conversation` 对象由 L368 `db.get(Conversation, conversation_id)` 提供,作用域内可用)。这是 option_select 选项反馈路径,同样必须门控 |
| `src/backend/app/tasks/h5_ai_task.py` | [修改] | 4 处坐席广播点改为条件广播(**逐点见附录 B**):<br>**① L482** `_persist_and_push``new_message` + `conversation_updated` 整块包 `if should_push_to_agent(conversation):`<br>**② L749** `_persist_and_push_structured`:同上(含 `selected_options` 快照分支一并包入)<br>**③ L891** `_handle_byod_query`:同上<br>**④ L1602-1608** `_step_call_dify` 的坐席 `ai_thinking` 广播:同上(`conversation` 已在入参中)<br>⚠️ **不要动** `broadcast_to_employees(...)` 的任何调用(L378/L443/L685/L734/L871/L1289/L1576/L1596/L1613)——员工侧推送在三态下只要触发就必须照常送达。<br>⚠️ `_persist_and_push_solution`L321-388)**无坐席广播**,本次无需改动 |
**验收**`status=serving` 下 H5 发消息 → 后台任务零创建(日志可证);`off` 态同理;`employee_only` 下坐席端**不再**出现 AI 气泡;`employee_and_agent` 下坐席端正常可见。
---
### T05 · 坐席前端:🤖 三态开关 | P0 | 依赖:T02(接口契约)
| 文件 | 标记 | 改动点 |
|------|------|--------|
| `src/frontend-agent/src/types/ai-reply-mode.ts` | [新增] | `export type AiReplyMode = 'employee_only' \| 'employee_and_agent' \| 'off'``AI_REPLY_MODE_OPTIONS` 常量数组(`value` / `label` / `desc`,文案照抄 PRD §6.2);`DEFAULT_AI_REPLY_MODE = 'employee_only'` |
| `src/frontend-agent/src/api/conversation.ts` | [修改] | ① `Conversation` 接口(L31)增 `ai_reply_mode: AiReplyMode`;② 新增 `export async function updateAiReplyMode(conversationId: string, mode: AiReplyMode)``apiClient.put(`/conversations/${id}/ai-reply-mode`, { mode })`(仿 L199 `togglePin` 写法) |
| `src/frontend-agent/src/components/chat/AiReplyModeSwitch.vue` | [新增] | `<script setup lang="ts">`。Props: `conversationId: string``modelValue: AiReplyMode`。结构:`el-popover(placement="bottom-start", trigger="click", :width="280")` + `#reference` 槽内 `<button class="tb-btn">🤖<span class="tb-tip">AI自动回复</span></button>` + 内容区 `el-radio-group`v-model 绑本地 ref)三项,每项带副标题描述 + 底部「当前:xxx」。<br>逻辑:选中 → **先乐观改本地 + emit**P1-1)→ `await updateAiReplyMode()` → 成功 `ElMessage.success('已切换为:…')` + `popoverVisible=false`(P1-3);失败 → 回滚 ref 到原值 + `ElMessage.error` + **popover 保持打开**。<br>样式(P2-1):按 mode 给按钮加 class —— `employee_only` 正常、`employee_and_agent``.tb-btn--active`(复用 AiAssistToolbar 既有高亮态)、`off``.tb-btn--muted`(新增,灰度 + `opacity:.5`)。**复用 `.tb-btn` 既有样式,不重新造尺寸** |
| `src/frontend-agent/src/components/chat/ReplyBox.vue` | [修改] | 在 `.chat-toolbar`L40**内、`<div class="toolbar-left">`L42)之前**插入:`<AiReplyModeSwitch :conversation-id="conversationStore.currentConversation?.id || ''" :model-value="conversationStore.currentConversation?.ai_reply_mode || 'employee_only'" @update:model-value="onAiReplyModeChange" />` + 紧随一个 `<div class="tb-sep"></div>`PRD §6.1 最左独立段)。`<script setup>` 中 import 组件 + 定义 `onAiReplyModeChange(mode)``conversationStore.setAiReplyMode(convId, mode)`。<br>⚠️ 会话未选中(`currentConversation` 为空)时按钮应 `disabled` |
| `src/frontend-agent/src/stores/conversation.ts` | [修改] | ① `setAiReplyMode(convId: string, mode: AiReplyMode)`:在 `conversations.value` 中定位并就地更新(保证 `currentConversation` computed 响应);② `handleAiReplyModeChanged(data: {conversation_id, ai_reply_mode})`:WS 回调,同样就地更新(**幂等**,与本端乐观更新结果一致时无副作用);③ 二者均需 `export` 到 store 返回对象 |
| `src/frontend-agent/src/composables/useWebSocket.ts` | [修改] | `handleMessage` switchL313)新增 `case 'ai_reply_mode_changed': if (msg.data) conversationStore.handleAiReplyModeChanged(msg.data); break`(插在 `conversation_updated`(L337) 之后) |
**验收**:🤖 与 4 个主动 AI 按钮视觉分离(分列工具栏两端、中间隔 `.tb-sep`);三态可切、即时生效;切会话各自恢复;断网切换会回滚报错;另一端切换本端 3s 内同步。
---
### 5.1 任务依赖图
```mermaid
graph TD
T01["<b>T01 数据层</b><br/>constants + model<br/>+ alembic 058 + schemas<br/><i>P0</i>"]
T02["<b>T02 门控内核 + API</b><br/>ai_reply_gate.py<br/>+ PUT /ai-reply-mode<br/>+ 2 个单测<br/><i>P0</i>"]
T03["<b>T03 路径A</b><br/>message_router.py<br/>门控 + 补坐席广播<br/><i>P0</i>"]
T04["<b>T04 路径B</b><br/>h5.py / ws.py 门控<br/>+ h5_ai_task 4处收紧<br/>🔴含缺口修复<br/><i>P0</i>"]
T05["<b>T05 坐席前端</b><br/>AiReplyModeSwitch.vue<br/>+ ReplyBox / store / WS<br/><i>P0</i>"]
T01 --> T02
T02 --> T03
T02 --> T04
T02 -.->|"仅依赖接口契约<br/>可与 T03/T04 并行"| T05
style T01 fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style T02 fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style T03 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style T04 fill:#ffebee,stroke:#d32f2f,stroke-width:3px
style T05 fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
```
**关键路径**T01 → T02 → T04(T04 含 P0 缺口修复,风险最高,建议优先排期)。
**并行窗口**T02 完成后,T03 / T04 / T05 三者互不冲突,可三线并行。
---
## 六、依赖包
**无新增依赖包。** 全部改动落在既有技术栈内:
| 已有依赖 | 用途 | 位置 |
|---------|------|------|
| `alembic` | 数据库迁移 058 | `requirements.txt` 已含 |
| `sqlalchemy>=2.0` | `Mapped` / `mapped_column` | 已含 |
| `pydantic>=2` | `field_validator` | 已含 |
| `element-plus` | `el-popover` / `el-radio-group` / `ElMessage` | `package.json` 已含 |
---
## 七、共享知识(跨文件约定)
### 7.1 枚举字符串常量(唯一真源)
| 语义 | 字符串字面量 | 后端常量 | 前端常量 |
|------|------------|---------|---------|
| 仅回复员工(默认) | `"employee_only"` | `AiReplyMode.EMPLOYEE_ONLY` | `'employee_only'` |
| 回复员工 + 推送坐席 | `"employee_and_agent"` | `AiReplyMode.EMPLOYEE_AND_AGENT` | `'employee_and_agent'` |
| 关闭自动回复 | `"off"` | `AiReplyMode.OFF` | `'off'` |
> ✅ **禁止**在业务代码里裸写这三个字符串——后端一律 `from app.constants.ai_reply_mode import ...`,前端一律从 `types/ai-reply-mode.ts` 导入。唯一例外:Alembic 迁移文件(迁移必须自包含、不依赖应用代码,允许硬编码)。
### 7.2 字段与默认值
- DB 列:`conversations.ai_reply_mode VARCHAR(20) NOT NULL DEFAULT 'employee_only'`
- ORM 双保险:`default="employee_only"`Python 侧)+ `server_default="employee_only"`DB 侧)
- 读取兜底:`getattr(conv, "ai_reply_mode", None) or DEFAULT`
- **与 `status` 严格正交**:任何代码**不得**因 mode 变化去写 `status`,反之亦然
### 7.3 WS 频道与事件名
| 事件名 | 通道 | 受众 | 本次动作 |
|--------|------|------|---------|
| `ai_reply_mode_changed` | `ws_manager.broadcast()` | 全体坐席 | 🆕 新增 |
| `new_message` (sender_type=`ai`) | `ws_manager.broadcast()` | 全体坐席 | 收紧:仅 `employee_and_agent` 时发 |
| `conversation_updated` | `ws_manager.broadcast()` | 全体坐席 | 收紧:随 AI 回复一同门控 |
| `ai_thinking` | `ws_manager.broadcast()` | 全体坐席 | 收紧:同上 |
| `ai_reply` / `ai_reply_chunk` / `ai_reply_failed` | `ws_manager.broadcast_to_employees()` | 员工 | **不动**(员工侧始终照常) |
### 7.4 错误码
| code | 含义 | 使用位置 |
|------|------|---------|
| `1001` | 参数非法(mode 不在合法集合) | `PUT /ai-reply-mode` |
| `1002` | 会话不存在 | `PUT /ai-reply-mode` |
响应统一走 `app/utils/response.py``success_response()` / `AppException()`,HTTP 状态保持 200,业务码在 body。
### 7.5 日志约定(便于 QA 与线上排障)
门控命中跳过时**必须**打 `logger.info`,统一格式:
```
[ai_reply_gate] 跳过AI自动回复: path={A|B}, conv_id={id}, status={status}, mode={mode}
```
### 7.6 UI 文案(前后端一致,照抄 PRD §6.2)
| mode | 单选项标题 | 副标题 | 切换成功提示 |
|------|----------|--------|------------|
| `employee_only` | 仅回复员工(默认) | AI 只回复员工,与现状一致 | 已切换为:AI回复-仅员工 |
| `employee_and_agent` | 回复员工 + 推送坐席 | AI 回复员工,坐席端同步可见 | 已切换为:AI回复-员工+坐席 |
| `off` | 关闭自动回复 | AI 不再自动回复任何消息 | 已切换为:AI回复-关闭 |
### 7.7 本次**明确不做**的事(防止范围蔓延)
- ❌ 不改 `status` 状态机(`ai_handling→queued→serving→pending_close→resolved`
- ❌ 不中断在途 `asyncio.create_task`ADR-6
- ❌ 不动 4 个主动 AI 按钮(💡🎚️🖌️🔄)——与本开关**完全正交**
- ❌ 不动员工端 H5`src/frontend-h5`)任何代码
- ❌ 不做 P2-2 快捷键 / P2-3 审计留痕 / P2-4 会话详情只读入口(本期不排)
- ❌ 不做全局默认模式配置(作用域严格限单会话)
---
## 八、待明确事项(Open Questions
> PRD §7 的 Q1~Q7 中,Q1/Q2/Q3/Q4/Q5/Q7 已由主理人与本方案拍板,**不再列为待确认**(见 §1.3 ADR 与 §3.3 真值表)。仅剩以下两项需要确认:
| # | 问题 | 影响面 | 建议 | 需谁拍板 |
|---|------|--------|------|---------|
| **O1** | **`employee_only` 下坐席端不再看到 AI 回复**——这是三态语义的必然结果,但属于**对现状的行为收紧**(路径 B 原为无条件广播)。若线上坐席已习惯"边看 AI 边待命",默认态改变会引发体感落差。 | 中。可能引发坐席"消息怎么少了"的反馈 | 两个选项:**(a)** 按本方案执行,用培训/公告消化;**(b)** 把默认值改为 `employee_and_agent`(但与 PRD "默认 employee_only = 等同现状" 的表述冲突,需 PRD 同步改)。**架构师倾向 (a)**,理由:PRD 明文定义默认 = `employee_only`,且"员工-AI 会话对坐席不可见"是该态的定义本身 | 产品经理(对应 PRD Q6 的延伸) |
| **O2** | **多 worker 部署下 WS 广播的可达性**`h5_ai_task.py` 头部注释(L11-12)已明确记载:"ws_manager 是进程内单例,多 worker 时后台任务与员工 WS 连接可能不在同进程,broadcast 会静默丢失(约 50%"。新增的 `ai_reply_mode_changed` 广播同样受此限制——若线上多 worker,其他坐席端的实时同步会概率性失败。 | 低(有兜底)。切换发起端本身走 HTTP 响应,必定正确;只影响"其他端"的实时同步 | 本期**不引入 Redis Pub/Sub**(超出需求范围)。兜底方案:其他端在切会话/刷新时通过 `GET /api/conversations` 拿到权威 `ai_reply_mode`,最终一致。**需运维确认当前生产 worker 数**:若为单 worker 则无影响;若多 worker,需在验收标准中把"跨端实时同步"降级为"非强保证" | 运维 + QA(确认部署形态与验收口径) |
---
## 附录 A · 代码事实核验表(本方案所有行号均已实读确认)
| 断言 | 文件:行 | 核验结果 |
|------|--------|---------|
| 项目使用 Alembic,当前 head | `alembic/versions/057_troubleshooting_templates.py` | ✅ `revision='057_troubleshooting_templates'``down_revision='056_add_moderation_tables'`,无其他分支头 |
| 迁移文件幂等写法惯例 | `057_...py:42-45` | ✅ `bind=op.get_bind()` + `sa.inspect(bind)` + `has_table` 守卫 |
| `Conversation``ai_reply_mode` 字段 | `models/conversation.py:19-399` | ✅ 全文无该字段;`status``String(20)` 非原生 enum |
| 路径 A 门控现状 | `services/message_router.py:176-179` | ✅ `if self.ai_handler and conversation.status == "ai_handling":` |
| 路径 A **无坐席 WS 广播** | `services/message_router.py:458-563` | ✅ 仅 `wecom_service.send_text_message()` + `Message` 落库,**全函数无 ws_manager 调用**(解答 PRD Q1 |
| 路径 B 触发点 1,零门控 | `api/h5.py:939-948` | ✅ 裸 `asyncio.create_task(process_h5_ai_reply(...))` |
| 路径 B 触发点 2,零门控 | `api/ws.py:472-487` | ✅ 同上;`conversation``ws.py:368` `db.get()` 提供,作用域内可用 |
| H5 新建会话初始 status | `api/h5.py:876` | ✅ `status="ai_handling"` → 落在路径 B 白名单内,不误伤 |
| `ws_manager` 接口 | `services/ws_manager.py:115/140/220/260` | ✅ `send_to_agent` / `broadcast` / `send_to_employee` / `broadcast_to_employees``active_connections: Dict[str, WebSocket]`(L51) |
| 端点实现模板 | `api/conversations.py:366-388` | ✅ `@router.post` + `@require_permission("conversation","update","own")` + `success_response(data=...)` |
| Schema 校验模板 | `schemas/conversation.py:113-132` | ✅ `ConversationStatusUpdate``@field_validator` 写法可直接仿制 |
| 前端工具栏结构 | `components/chat/ReplyBox.vue:40-102` | ✅ `.chat-toolbar` > `.toolbar-left`(L42) + `.tb-sep`(L90) + `<AiAssistToolbar>`(L93) |
| 主动 AI 按钮样式 | `components/chat/ai-assist/AiAssistToolbar.vue:20-40` | ✅ `.toolbar-right` > `.tb-btn.tb-btn--ai``.tb-btn--active` 高亮态可复用 |
| 前端 WS 分发点 | `composables/useWebSocket.ts:313-342` | ✅ `handleMessage` switch`case 'conversation_updated'` 在 L337 |
| 前端 API 模板 | `api/conversation.ts:199-210` | ✅ `togglePin` 可仿制 |
## 附录 B · `h5_ai_task.py` 坐席广播点清单(T04 逐点核对用)
| # | 函数 | 行号 | 广播内容 | 本次处理 |
|---|------|------|---------|---------|
| 1 | `_persist_and_push_solution` | L378 | `broadcast_to_employees(ai_reply)` | ⬜ **不动**(员工侧,且本函数无坐席广播) |
| 2 | `_persist_and_push` | **L482 / L494** | `broadcast(new_message)` + `broadcast(conversation_updated)` | ✅ 整块包 `should_push_to_agent()` |
| 3 | `_persist_and_push_structured` | **L749 / L776** | `broadcast(new_message)` + `broadcast(conversation_updated)`(含 `selected_options` 快照) | ✅ 整块包 `should_push_to_agent()` |
| 4 | `_handle_byod_query` | **L891 / L904** | `broadcast(new_message, byod_card)` + `broadcast(conversation_updated)` | ✅ 整块包 `should_push_to_agent()` |
| 5 | `_step_call_dify` | **L1603** | `broadcast(ai_thinking)` 给坐席 | ✅ 包 `should_push_to_agent()` |
| 6 | 员工侧全部推送 | L378/L443/L685/L734/L871/L1289/L1576/L1596/L1613 | `broadcast_to_employees(...)` | ⬜ **一律不动** |
---
**文档结束** · 架构师 高见远(Gao · 2026-08-04