""" AI 聊天模块 =========== 负责与 AI API 通信,支持文本模式、视觉模式,以及 MCP 工具增强回复。 使用标准 requests 库,无需安装 openai SDK。 """ import requests import base64 import json import re from urllib.parse import urlparse import ai_config # ⚠ 本模块所有配置一律通过 ai_config.XXX 动态读取(而非 from-import 快照), # 这样在 GUI「AI 高级配置」中修改保存后,下一次请求立即生效,无需重启。 def _completions_url() -> str: """ 组装实际请求地址: - AI_API_BASE 形如 .../v1/chat-messages(/v1/ 后已有路径)→ 原样使用,不再拼 /chat/completions - AI_API_BASE 形如 https://api.deepseek.com 或 .../v1 → 追加 /chat/completions """ base = (ai_config.AI_API_BASE or "").rstrip("/") path = urlparse(base).path or "" if re.match(r"^/v1/.+", path): return base return f"{base}/chat/completions" def _is_dify_endpoint() -> bool: """AI_API_BASE 指向 Dify 的 chat-messages / completion-messages 时走 Dify 协议。""" path = (urlparse((ai_config.AI_API_BASE or "").rstrip("/")).path or "").lower() return "chat-messages" in path or "completion-messages" in path def _system_prompt() -> str: """ 动态构建系统提示词:基础人设(用当前昵称/医院名实时渲染) + 按开关追加的可选模块(对骂模式 / MCP 工具说明)。 """ prompt = ai_config.build_system_prompt() if getattr(ai_config, 'AI_COUNTER_INSULT_ENABLED', False): prompt += "\n" + getattr(ai_config, 'AI_COUNTER_INSULT_PROMPT', '') if getattr(ai_config, 'AI_MCP_ENABLED', False): prompt += "\n" + getattr(ai_config, 'AI_MCP_PROMPT', '') return prompt def _history_messages(history: list) -> list: """ 将会话历史裁剪为最近 N 轮,作为多轮上下文消息插入到请求中。 history 为会话档案中的消息列表(可能带 ts 等额外字段,发 API 前只保留 role/content)。 运行时动态读取 ai_config(GUI 可在启动时修改开关)。 """ if not getattr(ai_config, 'AI_CONTEXT_ENABLED', False) or not history: return [] max_rounds = getattr(ai_config, 'AI_CONTEXT_MAX_ROUNDS', 5) return [ {"role": m["role"], "content": m["content"]} for m in history[-max_rounds * 2:] ] def _headers(): key = (ai_config.AI_API_KEY or "").strip() return { "Authorization": f"Bearer {key}", "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Connection": "keep-alive", } def _call_dify(query: str, user: str = "wechat-rpa", conversation_id: str = "") -> str: """ 调用 Dify /v1/chat-messages(blocking)。 鉴权用应用 API Key(通常以 app- 开头),与 OpenAI 兼容接口不同。 """ payload = { "inputs": {}, "query": query, "response_mode": "blocking", "user": user or "wechat-rpa", } if conversation_id: payload["conversation_id"] = conversation_id url = _completions_url() resp = requests.post( url, headers=_headers(), json=payload, timeout=ai_config.AI_TIMEOUT ) if resp.status_code == 401: detail = "" try: detail = resp.json().get("message") or resp.text[:200] except Exception: detail = resp.text[:200] raise RuntimeError( f"Dify 鉴权失败(401): {detail}。" f"请到 Dify 应用 →「访问 API」复制 API Key(一般以 app- 开头)填到 AI_API_KEY," f"当前 Key 看起来不像有效应用密钥。" ) if not resp.ok: detail = (resp.text or "")[:400] raise RuntimeError(f"Dify 请求失败 {resp.status_code}: {detail}") data = resp.json() answer = data.get("answer") if answer is None: # 少数部署可能包一层 data answer = (data.get("data") or {}).get("answer") if not answer: raise RuntimeError(f"Dify 未返回 answer: {json.dumps(data, ensure_ascii=False)[:300]}") return str(answer) def _dify_query_from_chat(chat_text: str, history: list = None) -> str: """ 把本地会话档案拼进 Dify query,并注入事实铁律 + 挂号医院铁律。 """ try: hosp = getattr(ai_config, "AI_HOSPITAL_NAME", None) or "甄养堂互联网医院" except Exception: hosp = "甄养堂互联网医院" rules = ( "【事实铁律|必须遵守】\n" "1. 严禁编造快递单号、物流状态、签收时间、订单号、库存等业务数据。\n" "2. 查快递/订单时:没有真实系统查询结果,只能追问单号/姓名/手机,或说去后台核实后回;" "不准说「查到了」「已签收」并随口编一个单号。\n" "3. 历史里出现的可疑示例单号(如 SF1234567890)一律视为无效,不要继续沿用。\n" f"4. 凡建议就医/挂号/面诊,或回复里提到医院,【只能】写「{hosp}」," "严禁「正规医院」「当地医院」「三甲医院」「内分泌科」等说法(急救拨120除外)。" f"本院是糖尿病中医专科医院,没有内分泌科。\n" "5. 客户只是咨询(如血糖不稳定怎么办)时:先给专业建议," "可轻提一句需要可来本院挂号;【禁止】直接说已帮您预约。\n" f"6. 仅当客户明确说要挂号/预约/面诊/帮我约 时,才说「已帮您预约了,稍后预约上了再联系您」," f"医院是{hosp}。客户说不需要/挂啥号时绝不能预约。\n" "7. 只回复客户最后一条需要处理的问题。语气像干了十几年的老客服:口语、沉稳、" "不急不躁,一次只说一件事,最多顺带问一个问题,不要复述对方原话,不要一次抛一大段方案。\n" ) hist = _history_messages(history) if not hist: body = f"以下是最近的聊天记录:\n\n{chat_text}\n\n请生成回复:" else: lines = [] for m in hist: role = "客户" if m.get("role") == "user" else "客服" lines.append(f"{role}: {m.get('content', '')}") body = ( "【近期对话|仅供参考,其中客服所述业务数据可能不实】\n" + "\n".join(lines) + f"\n\n【最新消息】\n{chat_text}\n\n请生成回复:" ) return rules + "\n" + body def _strip_thinking(text: str) -> str: """ 去除 Qwen3 / DeepSeek-R1 等模型输出的 ... 推理块, 只保留最终回复内容。 """ if not text: return "" cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL) return cleaned.strip() # 上游模型(尤其是 Dify 应用侧自带的固定人设/挡话话术)有时不听系统提示词, # 这里做最后一道兜底净化:去掉暴露机器身份的自称,以及被禁止的轻浮语气词。 _SELF_ID_RE = re.compile( r"[,,]?\s*(我是|作为)(您|你)的?(专属)?" r"(健康(顾问|助理|管家|专家)|智能(客服|助手)|AI(客服|助手)?|人工智能|" r"虚拟(客服|助手)|机器人客服|机器人|自动回复(助手)?|系统助手|助手)" r"[,,。!]?" ) _LAUGH_RE = re.compile(r"哈{2,}[,,]?|哈哈[,,]?") def _humanize(text: str) -> str: """去除自称机器人/助手的句子,以及禁止的"哈哈"语气词,收拾多余标点。""" if not text: return text # 用逗号占位替换,避免前后半句直接粘连;随后统一收拢多余标点 cleaned = _SELF_ID_RE.sub(",", text) cleaned = _LAUGH_RE.sub("", cleaned) cleaned = re.sub(r"[,,]{2,}", ",", cleaned) cleaned = re.sub(r"[,,]\s*([。!?])", r"\1", cleaned) cleaned = re.sub(r"([。!?])\s*[,,]+", r"\1", cleaned) cleaned = re.sub(r"[。!?]{2,}", "。", cleaned) cleaned = re.sub(r"^[,,、\s]+", "", cleaned) cleaned = re.sub(r"\s{2,}", " ", cleaned) return cleaned.strip() def _chat_completion(messages: list, tools: list = None) -> dict: """ 调用 chat/completions,返回 message 对象(含 content / tool_calls)。 """ if _is_dify_endpoint(): # 兜底:误入 OpenAI 路径时改走 Dify(避免 400 Arg user must be provided) last_user = "" for m in reversed(messages): if m.get("role") == "user": c = m.get("content") last_user = c if isinstance(c, str) else str(c) break answer = _call_dify(last_user or "请回复") return {"role": "assistant", "content": answer} payload = { "model": ai_config.AI_MODEL, "messages": messages, "max_tokens": ai_config.AI_MAX_TOKENS, "temperature": ai_config.AI_TEMPERATURE, } if tools: payload["tools"] = tools payload["tool_choice"] = "auto" url = _completions_url() resp = requests.post( url, headers=_headers(), json=payload, timeout=ai_config.AI_TIMEOUT ) if not resp.ok: detail = (resp.text or "")[:400] raise RuntimeError(f"AI 请求失败 {resp.status_code}: {detail}") return resp.json()["choices"][0]["message"] def _user_turn(chat_text: str) -> dict: return { "role": "user", "content": f"以下是最近的聊天记录:\n\n{chat_text}\n\n请生成回复:", } def call_ai_text(chat_text: str, history: list = None) -> str: """ 文本模式:将聊天记录文字发给文本 AI,返回回复。 若开启 AI_MCP_ENABLED,会连接外部 MCP Server,让模型按需调用工具后再回复。 若 AI_API_BASE 为 Dify chat-messages,走 Dify 协议(不支持 OpenAI tools)。 """ if _is_dify_endpoint(): print(" [AI] 检测到 Dify 接口,使用 chat-messages 协议") return _humanize(_strip_thinking(_call_dify(_dify_query_from_chat(chat_text, history)))) if getattr(ai_config, "AI_MCP_ENABLED", False): try: from mcp_bridge import run_coro return _humanize(run_coro(_call_ai_text_with_mcp(chat_text, history))) except Exception as e: print(f" [MCP] ⚠ 工具增强失败,回退普通回复: {e}") messages = [{"role": "system", "content": _system_prompt()}] messages += _history_messages(history) messages.append(_user_turn(chat_text)) msg = _chat_completion(messages) return _humanize(_strip_thinking(msg.get("content") or "")) async def _call_ai_text_with_mcp(chat_text: str, history: list = None) -> str: """带 MCP 工具调用的多轮回复。""" from mcp_bridge import McpHub async with McpHub() as hub: tools = hub.openai_tools() if not tools: print(" [MCP] 未加载到任何工具,使用普通文本回复") messages = [{"role": "system", "content": _system_prompt()}] messages += _history_messages(history) messages.append(_user_turn(chat_text)) msg = _chat_completion(messages) return _strip_thinking(msg.get("content") or "") print(f" [MCP] 已加载 {hub.tool_count} 个工具" f"(服务器: {', '.join(hub.server_names) or '-'})") messages = [{"role": "system", "content": _system_prompt()}] messages += _history_messages(history) messages.append(_user_turn(chat_text)) max_rounds = int(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5) or 5) for i in range(max_rounds): msg = _chat_completion(messages, tools=tools) tool_calls = msg.get("tool_calls") or [] content = msg.get("content") if not tool_calls: return _strip_thinking(content or "") # 保留 assistant 消息(含 tool_calls) messages.append({ "role": "assistant", "content": content, "tool_calls": tool_calls, }) for tc in tool_calls: fn = tc.get("function") or {} name = fn.get("name") or "" raw_args = fn.get("arguments") or "{}" try: args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {}) except json.JSONDecodeError: args = {} print(f" [MCP] 调用工具 {name} args={json.dumps(args, ensure_ascii=False)[:120]}") result = await hub.call_tool(name, args) preview = (result or "").replace("\n", " ")[:120] print(f" [MCP] 工具结果: {preview}{'…' if len(result or '') > 120 else ''}") messages.append({ "role": "tool", "tool_call_id": tc.get("id") or name, "content": result or "", }) # 超过轮数:强制再要一次纯文本回复 print(" [MCP] 已达最大工具轮数,请求最终回复") msg = _chat_completion(messages) return _strip_thinking(msg.get("content") or "") def call_ai_vision(image_bytes: bytes, history: list = None) -> str: """ 视觉模式:将聊天区域截图发给多模态 AI,让 AI 直接阅读并回复。 image_bytes 为 PNG 图片的 bytes。 history 为该会话的历史上下文(多轮记忆),可为 None。 """ b64 = base64.b64encode(image_bytes).decode("utf-8") simple_headers = { "Authorization": f"Bearer {ai_config.AI_API_KEY}", "Content-Type": "application/json", } messages = [{"role": "system", "content": _system_prompt()}] messages += _history_messages(history) payload = { "model": ai_config.AI_MODEL, "messages": messages + [ {"role": "user", "content": [ { "type": "text", "text": ( "这是一个聊天对话窗口的截图。" "左边的灰色气泡是对方(客户)发的消息,右边的蓝色气泡是我方之前的回复。" "请只关注对方(客户)发的最后一条消息,针对那条消息直接回复。" "只输出回复内容,不要描述图片,不要解释,不要加引号。" ), }, { "type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}, }, ]}, ], "max_tokens": ai_config.AI_MAX_TOKENS, "temperature": ai_config.AI_TEMPERATURE, } url = _completions_url() resp = requests.post(url, headers=simple_headers, json=payload, timeout=ai_config.AI_TIMEOUT) resp.raise_for_status() content = resp.json()["choices"][0]["message"]["content"] return _humanize(_strip_thinking(content)) def get_ai_reply(chat_text: str = None, image_bytes: bytes = None, history: list = None) -> str: """ 统一入口:根据 AI_USE_VISION 配置自动选择模式。 history 为该会话的历史上下文(多轮记忆),可为 None。 返回 AI 生成的回复文本。 """ try: if ai_config.AI_USE_VISION and image_bytes: print(" [AI] 使用视觉模式分析聊天截图...") return call_ai_vision(image_bytes, history=history) elif chat_text: if getattr(ai_config, "AI_MCP_ENABLED", False): print(" [AI] 文本模式 + MCP 工具增强...") else: print(" [AI] 使用文本模式分析聊天记录...") return call_ai_text(chat_text, history=history) else: return "" except requests.exceptions.Timeout: print(" [AI] ⚠ API 请求超时") return "" except requests.exceptions.RequestException as e: print(f" [AI] ⚠ API 请求失败: {e}") return "" except (KeyError, IndexError, json.JSONDecodeError) as e: print(f" [AI] ⚠ 解析响应失败: {e}") return ""