feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3

## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS

## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS

## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)

## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code

## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)

## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过

## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务

## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记已实施
- 新增架构图/时序图/类图(mermaid)

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
This commit is contained in:
Simon
2026-07-13 02:17:03 +08:00
parent bea288e414
commit 449c6d4875
176 changed files with 46637 additions and 4805 deletions
+431 -9
View File
@@ -12,6 +12,7 @@
import json
import logging
import asyncio
import time
from typing import Any, Dict, List, Optional, AsyncGenerator
import httpx
@@ -24,13 +25,21 @@ logger = logging.getLogger(__name__)
class AIService:
"""AI 服务:封装 Dify API,提供 AI 回复能力。
支持种调用模式:
1. 非流式(简单场景):一次性获取完整回复
2. 流式(推荐):SSE 流式返回,前端可逐字显示
支持种调用模式:
1. 非流式(简单场景):一次性获取完整回复(经 dify2openai 代理)
2. 流式(推荐):SSE 流式返回,前端可逐字显示(经 dify2openai 代理)
3. ★ 原生直连(v2.1 新增):绕过代理,直连 Dify /v1/chat-messages
v2.1 改造原因:
- dify2openai 代理存在 [object Object] 序列化 bug
- 直连 Dify 原生 API 响应格式更简单(answer 字段直接返回内容)
- 结构化回复(get_structured_reply)优先使用原生 API
参考:现有系统交接文档
- API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
- Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat
- 代理 URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
- 原生 URL: http://yw-dify.dc.servyou-it.com/v1/chat-messages
- Key: app-7jkRkAzvX4QM9v9SM3P8mMEO(审批意图副本,推荐使用)
- ⚠️ 已弃用老Key app-UaTWYdBSwN6VktKQlbh5YN5H(老线上应用,禁止使用)
"""
def __init__(self):
@@ -39,18 +48,24 @@ class AIService:
做什么:从配置读取 Dify API 地址和认证信息
为什么:集中管理 API 配置,便于切换测试/生产环境
"""
# Dify 兼容 OpenAI 格式的 API 端点
# Dify 兼容 OpenAI 格式的 API 端点(代理)
self.api_url = settings.dify_api_url
# Dify API Key(格式:base_url|app_id|app_name
self.api_key = settings.dify_api_key
# 请求超时(秒)
self.timeout = settings.dify_timeout
# httpx 异步客户端(复用连接池
# ★ v2.1: Dify 原生 API 配置(绕过代理
self.native_base_url = settings.dify_native_base_url
self.native_api_key = settings.dify_native_api_key
# httpx 异步客户端(复用连接池)— 代理用
self._client: Optional[httpx.AsyncClient] = None
# ★ v2.1: 原生 API 专用客户端(不同 auth header
self._native_client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 httpx 异步客户端。
"""获取或创建 httpx 异步客户端(代理用)
做什么:懒加载 httpx.AsyncClient,复用连接池
为什么:避免每次请求都创建新连接,提升性能
@@ -65,16 +80,37 @@ class AIService:
)
return self._client
async def _get_native_client(self) -> httpx.AsyncClient:
"""获取或创建 Dify 原生 API 专用客户端(v2.1 新增)。
做什么:懒加载 httpx.AsyncClient,使用 Dify 原生 auth 格式
为什么:原生 API 使用 `Bearer app-xxx` 认证(非管道分隔格式),
需要独立客户端避免 auth header 冲突
"""
if self._native_client is None or self._native_client.is_closed:
self._native_client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
headers={
"Authorization": f"Bearer {self.native_api_key}",
"Content-Type": "application/json",
}
)
return self._native_client
async def close(self):
"""关闭 httpx 客户端。
做什么:释放连接池资源
做什么:释放连接池资源(代理 + 原生)
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
logger.debug("AIService httpx client closed")
if self._native_client and not self._native_client.is_closed:
await self._native_client.aclose()
self._native_client = None
logger.debug("AIService native client closed")
# --------------------------------------------------------------------------
# 非流式调用:一次性获取 AI 完整回复
@@ -290,6 +326,392 @@ class AIService:
"hit": False,
}
# --------------------------------------------------------------------------
# 结构化调用:blocking 模式,返回解析后的 JSON {text, action, options}
# --------------------------------------------------------------------------
async def _call_dify_native(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""直连 Dify 原生 APIv2.1 新增,绕过 dify2openai 代理)。
做什么:
1. POST {base_url}/v1/chat-messagesblocking 模式)
2. 解析返回的 answer 字段(直接包含 AI 回复内容)
3. 返回 raw_content 供上层 JSON 解析
为什么:
- dify2openai 代理存在 [object Object] 序列化 bug
- 原生 API 响应格式更简单:{"answer": "...", "conversation_id": "..."}
- 不经过 OpenAI 兼容层转换,避免格式损失
Args:
message: 员工发送的消息内容
conversation_id: Dify 会话ID(用于多轮对话上下文)
user_id: 员工企微 UserID
Returns:
Dict 或 None
- 成功:{"raw_content": str, "conversation_id": str, "response_time_ms": float}
- 失败:None(调用方应 fallback 到代理路径)
"""
if not self.native_base_url or not self.native_api_key:
return None # 未配置原生 API,调用方走代理路径
url = f"{self.native_base_url}/v1/chat-messages"
payload = {
"inputs": {},
"query": message,
"response_mode": "blocking", # 阻塞模式,等待完整回复
"user": user_id or "unknown",
}
# 传入 Dify 会话ID,保持多轮对话上下文
if conversation_id:
payload["conversation_id"] = conversation_id
try:
client = await self._get_native_client()
start_time = time.perf_counter()
logger.info(f"调用 Dify 原生 API: message={message[:50]}...")
response = await client.post(url, json=payload)
response.raise_for_status()
response_time_ms = (time.perf_counter() - start_time) * 1000
data = response.json()
# 原生 API 返回格式:{"answer": "...", "conversation_id": "..."}
raw_content = data.get("answer", "")
dify_conv_id = data.get("conversation_id", conversation_id or "")
if not raw_content:
logger.warning("Dify 原生 API 返回空 answer")
return None
logger.info(
f"Dify 原生 API 返回: content_len={len(raw_content)}, "
f"response_time={response_time_ms:.0f}ms, "
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
)
return {
"raw_content": raw_content,
"conversation_id": dify_conv_id,
"response_time_ms": response_time_ms,
}
except httpx.TimeoutException:
logger.warning("Dify 原生 API 超时,将回退到代理路径")
return None
except httpx.HTTPStatusError as e:
logger.warning(f"Dify 原生 API HTTP 错误: status={e.response.status_code},将回退到代理路径")
return None
except Exception as e:
logger.warning(f"Dify 原生 API 调用失败: {e},将回退到代理路径")
return None
async def get_structured_reply(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""调用 Dify API 获取结构化 AI 回复(blocking 模式,JSON 输出)。
改造后的 Dify 主对话应用输出 JSON 格式:
{"text": "...", "action": {...}|null, "options": [...]|null}
本方法负责:
1. 以 blocking 模式调用 Difystream=False
2. 尝试解析返回内容为 JSON
3. 解析失败时降级为纯文本(向后兼容旧 Prompt)
4. 返回统一结构
Args:
message: 员工发送的消息内容(可能包含图片描述前缀)
conversation_id: Dify 会话ID(用于多轮对话上下文)
user_id: 员工企微 UserID
Returns:
Dict: {
"text": str, # 回复文字(始终有值)
"action": dict|None, # 操作卡片数据(审批/入口推荐)
"options": list|None, # 选项按钮列表
"hit": bool, # 是否命中知识库
"conversation_id": str, # Dify 会话ID
"raw_content": str, # 原始返回内容(调试用)
"is_structured": bool, # 是否成功解析为 JSON
}
"""
# ★ v2.1: 优先尝试 Dify 原生 API(绕过 dify2openai 代理)
# 为什么:代理存在 [object Object] 序列化 bug,原生 API 直接返回 answer 字段
# 降级:原生 API 未配置或调用失败时,自动回退到下方代理路径
native_result = await self._call_dify_native(message, conversation_id, user_id)
if native_result is not None:
raw_content = native_result["raw_content"]
dify_conv_id = native_result["conversation_id"]
response_time_ms = native_result["response_time_ms"]
# 尝试 JSON 解析(复用同一解析逻辑)
parsed = self._parse_structured_response(raw_content)
if parsed:
# JSON 解析成功
text = parsed.get("text", "")
action = parsed.get("action")
options = parsed.get("options")
diagnosis_stage = parsed.get("diagnosis_stage")
hit = self._check_knowledge_hit(text) if text else False
logger.info(
f"Dify 原生 structured 返回: hit={hit}, "
f"text_len={len(text)}, "
f"has_action={action is not None}, "
f"has_options={options is not None}, "
f"diagnosis_stage={diagnosis_stage}, "
f"response_time={response_time_ms:.0f}ms"
)
if response_time_ms > 10000:
logger.warning(f"Dify 原生慢响应告警: {response_time_ms:.0f}ms")
return {
"text": text,
"action": action,
"options": options,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": True,
"diagnosis_stage": diagnosis_stage,
"response_time_ms": round(response_time_ms, 1),
}
else:
# JSON 解析失败,降级为纯文本
logger.warning(
"Dify 原生返回非 JSON 格式,降级为纯文本。"
f"content={raw_content[:100]}..."
)
hit = self._check_knowledge_hit(raw_content) if raw_content else False
return {
"text": raw_content,
"action": None,
"options": None,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(response_time_ms, 1),
}
# === 代理路径(fallback===
# 原生 API 不可用或调用失败时,走 dify2openai 代理(原有逻辑)
logger.info("使用 dify2openai 代理路径(原生 API 不可用或失败)")
payload = {
"model": "Chat",
"messages": [{"role": "user", "content": message}],
"stream": False, # blocking 模式
"temperature": 0.3, # 略高于流式,给 JSON 结构化留一点灵活性
}
if conversation_id:
payload["conversation_id"] = conversation_id
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
# Phase 6B: 记录 Dify 调用开始时间(性能监控)
start_time = time.perf_counter()
logger.info(f"调用 Dify API (structured): message={message[:50]}...")
response = await client.post(self.api_url, json=payload)
response.raise_for_status()
# Phase 6B: 计算响应耗时
response_time_ms = (time.perf_counter() - start_time) * 1000
data = response.json()
# 解析 OpenAI 兼容格式返回
choices = data.get("choices", [])
if not choices:
logger.warning("Dify API 返回空 choices (structured)")
return {
"text": "",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(response_time_ms, 1),
}
raw_content = choices[0]["message"]["content"]
dify_conv_id = data.get("conversation_id", conversation_id or "")
# 尝试解析 JSON
parsed = self._parse_structured_response(raw_content)
if parsed:
# JSON 解析成功
text = parsed.get("text", "")
action = parsed.get("action")
options = parsed.get("options")
# Phase 6A: 提取诊断阶段(diagnosis_stage
diagnosis_stage = parsed.get("diagnosis_stage")
hit = self._check_knowledge_hit(text) if text else False
logger.info(
f"Dify structured 返回: hit={hit}, "
f"text_len={len(text)}, "
f"has_action={action is not None}, "
f"has_options={options is not None}, "
f"diagnosis_stage={diagnosis_stage}, "
f"response_time={response_time_ms:.0f}ms, "
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
)
# Phase 6B: 慢响应告警(>10秒)
if response_time_ms > 10000:
logger.warning(
f"Dify 慢响应告警: {response_time_ms:.0f}ms "
f"(conv={dify_conv_id[:20] if dify_conv_id else 'new'})"
)
return {
"text": text,
"action": action,
"options": options,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": True,
"diagnosis_stage": diagnosis_stage,
"response_time_ms": round(response_time_ms, 1),
}
else:
# JSON 解析失败,降级为纯文本(向后兼容旧 Prompt)
logger.warning(
"Dify 返回非 JSON 格式,降级为纯文本。"
f"content={raw_content[:100]}..."
)
hit = self._check_knowledge_hit(raw_content) if raw_content else False
return {
"text": raw_content,
"action": None,
"options": None,
"hit": hit,
"conversation_id": dify_conv_id,
"raw_content": raw_content,
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(response_time_ms, 1),
}
except httpx.TimeoutException:
elapsed = (time.perf_counter() - start_time) * 1000
logger.error(f"Dify API 超时 (structured): {elapsed:.0f}ms")
return {
"text": "AI 服务响应超时,请稍后再试或转人工坐席。",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(elapsed, 1),
}
except httpx.HTTPStatusError as e:
elapsed = (time.perf_counter() - start_time) * 1000
logger.error(f"Dify API HTTP 错误 (structured): status={e.response.status_code}, {elapsed:.0f}ms")
return {
"text": "AI 服务暂时不可用,请转人工坐席。",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(elapsed, 1),
}
except Exception as e:
elapsed = (time.perf_counter() - start_time) * 1000
logger.error(f"Dify API 调用失败 (structured): {e}, {elapsed:.0f}ms")
return {
"text": "AI 服务异常,请转人工坐席或稍后重试。",
"action": None,
"options": None,
"hit": False,
"conversation_id": conversation_id or "",
"raw_content": "",
"is_structured": False,
"diagnosis_stage": None,
"response_time_ms": round(elapsed, 1),
}
def _parse_structured_response(self, content: str) -> Optional[Dict[str, Any]]:
"""尝试将 Dify 返回内容解析为结构化 JSON。
支持以下格式:
1. 纯 JSON: {"text": "...", "action": null, "options": null}
2. 带 markdown 代码块: ```json\n{...}\n```
3. 前后有多余文本的 JSON(提取第一个 { 到最后一个 }
Args:
content: Dify 返回的原始内容
Returns:
解析后的 dict,或 None(解析失败)
"""
if not content or not content.strip():
return None
text = content.strip()
# 尝试 1: 直接解析
try:
result = json.loads(text)
if isinstance(result, dict) and "text" in result:
return result
except json.JSONDecodeError:
pass
# 尝试 2: 去除 markdown 代码块
if text.startswith("```"):
# 去除 ```json 或 ``` 开头和结尾的 ```
lines = text.split("\n")
# 去掉第一行(```json 或 ```
if lines[0].strip().startswith("```"):
lines = lines[1:]
# 去掉最后一行(```
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines).strip()
try:
result = json.loads(text)
if isinstance(result, dict) and "text" in result:
return result
except json.JSONDecodeError:
pass
# 尝试 3: 提取第一个 { 到最后一个 }
first_brace = content.find("{")
last_brace = content.rfind("}")
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
json_str = content[first_brace:last_brace + 1]
try:
result = json.loads(json_str)
if isinstance(result, dict) and "text" in result:
return result
except json.JSONDecodeError:
pass
return None
# --------------------------------------------------------------------------
# 判断是否命中知识库
# --------------------------------------------------------------------------