Files
kefu/wechat_rpa/ai_chat.py
T
2026-07-29 09:34:02 +08:00

586 lines
26 KiB
Python
Raw 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 聊天模块
===========
负责与 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
_CHAT_HEADER_RE = re.compile(
r"^.+?\s+(?:(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+)?"
r"\d{1,2}:\d{2}(?::\d{2})?$"
)
_CASUAL_RE = re.compile(
r"(?:好困|困死|想睡|好累|累死|无聊|好烦|烦死|好饿|饿死|"
r"在干嘛|干什么呢|多大了|几岁|哪里人|叫什么|吃饭了吗|"
r"早上好|中午好|晚上好|晚安|想你了|哈哈|嘿嘿)"
)
_EXPLICIT_HEALTH_RE = re.compile(
r"(?:血糖|糖尿病|胰岛素|降糖药|头晕|胸痛|心慌|恶心|呕吐|"
r"呼吸困难|昏迷|伤口|感染|疼|痛|麻|肿|低血糖|高血糖)"
)
_MEDICAL_DRIFT_RE = re.compile(
r"(?:血糖|糖尿病|胰岛素|降糖药|医院|医生|就医|挂号|面诊|"
r"调药|调整方案|治疗方案)"
)
_CLARIFY_RE = re.compile(r"^(?:什么|啥|什么意思|没懂|没看懂|没明白)[?。!!]*$")
def latest_customer_message(chat_text: str) -> str:
"""从企微复制文本中提取最后一位说话人的消息正文。"""
lines = [line.strip() for line in str(chat_text or "").splitlines() if line.strip()]
if not lines:
return ""
last_header = -1
for index, line in enumerate(lines):
if _CHAT_HEADER_RE.match(line):
last_header = index
if 0 <= last_header < len(lines) - 1:
return "\n".join(lines[last_header + 1:]).strip()
# 没有说话人/时间头时,调用方通常传入的就是本次新消息。
return "\n".join(lines).strip()
def _conversation_mode_instruction(latest: str) -> str:
text = str(latest or "").strip()
if _CLARIFY_RE.fullmatch(text):
return (
"【本轮是追问澄清】客户是在说没听懂你上一句。"
"请把客服上一句换成更简单的一句话说明;不要说自己没听清,也不要让客户再说一遍。"
)
if _CASUAL_RE.search(text) and not _EXPLICIT_HEALTH_RE.search(text):
return (
"【本轮是日常闲聊】直接顺着对方的话自然接一句。"
"绝对不要主动转到血糖、疾病、医院、挂号或健康管理,也不要教育对方。"
)
return (
"【本轮按原问题回答】先准确回答客户最后一句,不要因为历史里谈过健康,"
"就把当前无关问题强行拉回健康或业务。"
)
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)
messages = []
for item in history[-max_rounds * 2:]:
role = item.get("role")
content = str(item.get("content") or "").strip()
if role == "user":
# 旧档案中的 user 内容可能是一整屏聊天,里面混有我方历史回复。
# 只留下最后一位说话人的正文,避免把客服自己的话再次当成客户诉求。
content = latest_customer_message(content)
if role in {"user", "assistant"} and content:
messages.append({"role": role, "content": content})
return messages
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-messagesblocking)。
鉴权用应用 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 "甄养堂互联网医院"
agent = getattr(ai_config, "AI_AGENT_NAME", None) or "客服"
except Exception:
hosp = "甄养堂互联网医院"
agent = "客服"
latest = latest_customer_message(chat_text)
mode_instruction = _conversation_mode_instruction(latest)
rules = (
"【事实铁律|必须遵守】\n"
"1. 严禁编造快递单号、物流状态、签收时间、订单号、库存等业务数据。\n"
"2. 查快递/订单时:没有真实系统查询结果,只能追问单号/姓名/手机,或说去后台核实后回;"
"不准说「查到了」「已签收」并随口编一个单号。\n"
"3. 历史里出现的可疑示例单号(如 SF1234567890)一律视为无效,不要继续沿用。\n"
f"4. 凡建议就医/挂号/面诊,或回复里提到医院,【只能】写「{hosp}」,"
"严禁「正规医院」「当地医院」「三甲医院」「内分泌科」等说法(急救拨120除外)。"
f"本院是糖尿病中医专科医院,没有内分泌科。\n"
"5. 客户只是咨询(如血糖不稳定怎么办)时:先给专业建议,"
"可轻提一句需要可来本院挂号;【禁止】直接说已帮您预约。\n"
f"6. 仅当客户明确说要挂号/预约/面诊/帮我约 时,才说「已帮您预约了,稍后预约上了再联系您」,"
f"医院是{hosp}。客户说不需要/挂啥号时绝不能预约。\n"
"7. 只回复客户最后一条需要处理的问题。语气像干了十几年的老客服:口语、沉稳、"
"不急不躁,一次只说一件事,最多顺带问一个问题,不要复述对方原话,不要一次抛一大段方案。\n"
"8. 默认只写1~2句、20~60个汉字;先用一句自然的话接住对方的担心或不舒服,再回答重点。"
"不要标题、列表、客套收尾,不说「希望能帮到您」「请您放心」等套话。\n"
"9. 只有急救风险可以写到3句;一条消息最多一个问号,只输出能直接发给客户的正文。\n"
"10. 先判断客户是在闲聊、问业务还是问健康。闲聊就闲聊,普通问题就直接回答;"
"严禁每句话都扯到血糖、身体、医院、医生、挂号或面诊。\n"
f"11. 你的称呼是「{agent}」,年龄设定是四十来岁。客户问年龄时自然回答「四十来岁」,"
"不要回避、说教,也不要借机转移到健康话题。\n"
"12. 示例:客户说「好困啊」,可回「困了就先眯一会儿,别硬撑着」;"
"客户问「你多大了」,可回「四十来岁啦,怎么突然问这个?」。"
"示例只说明说话方式,不要机械重复。\n"
)
hist = _history_messages(history)
if hist:
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{mode_instruction}\n"
+ f"【客户最后一句|唯一回答对象】\n{latest}\n\n"
+ f"【本次原始聊天片段|仅用于理解上下文】\n{chat_text}\n\n请生成回复:"
)
else:
body = (
f"{mode_instruction}\n"
f"【客户最后一句|唯一回答对象】\n{latest}\n\n"
f"【本次原始聊天片段|仅用于理解上下文】\n{chat_text}\n\n请生成回复:"
)
return rules + "\n" + body
def _strip_thinking(text: str) -> str:
"""
去除 Qwen3 / DeepSeek-R1 等模型输出的 <think>...</think> 推理块,
只保留最终回复内容。
"""
if not text:
return ""
cleaned = re.sub(r'<think>.*?</think>', '', 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,}[,]?|哈哈[,]?")
_MARKDOWN_PREFIX_RE = re.compile(r"^\s*(?:#{1,6}\s*|[-*•]\s+|\d+[.、)]\s*)")
_CANNED_RE = re.compile(
r"(?:希望(?:以上|这些)?(?:建议|内容)?能帮到您|请您放心|感谢您的理解|"
r"感谢您的耐心等待|祝您(?:生活愉快|身体健康|早日康复)|"
r"如有(?:其他|任何)?问题[,]?欢迎随时(?:咨询|联系)(?:我|我们)?|"
r"如果还有(?:其他|任何)?问题[,]?(?:可以|请)随时(?:咨询|联系)(?:我|我们)?)"
r"[。!!]?"
)
_URGENT_RE = re.compile(
r"(?:120|急诊|急救|昏迷|意识不清|胸痛|呼吸困难|抽搐|酮症|严重低血糖|"
r"立即就医|尽快就医|马上就医)"
)
def _humanize(text: str) -> str:
"""把模型输出收成适合企微发送的短句,去掉机器味、套话和 Markdown。"""
if not text:
return text
lines = []
for raw_line in str(text).replace("\r", "\n").split("\n"):
line = _MARKDOWN_PREFIX_RE.sub("", raw_line).strip()
line = re.sub(r"^(?:回复|答复|客服回复)\s*[:]\s*", "", line)
line = line.replace("**", "").replace("__", "").replace("`", "")
if line in {"回复", "答复", "客服回复", "建议", "参考回复"}:
continue
if line:
lines.append(line)
text = "".join(lines)
# 用逗号占位替换,避免前后半句直接粘连;随后统一收拢多余标点
cleaned = _SELF_ID_RE.sub("", text)
cleaned = _LAUGH_RE.sub("", cleaned)
cleaned = _CANNED_RE.sub("", cleaned)
cleaned = re.sub(r"^(?:您好|尊敬的客户|亲爱的)[,!。\s]*", "", cleaned)
cleaned = re.sub(r"(?:首先|其次|另外|总之|综上)[,:\s]*", "", 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)
cleaned = cleaned.strip(" ")
# 默认只保留两句;出现急救提示时允许三句,避免安全信息被压掉。
sentence_limit = 3 if _URGENT_RE.search(cleaned) else 2
char_limit = 180 if sentence_limit == 3 else 100
sentences = re.findall(r"[^。!?;]+[。!?;]?", cleaned)
cleaned = "".join(sentences[:sentence_limit]).strip(" ")
if len(cleaned) > char_limit:
shortened = cleaned[:char_limit]
cut = max(shortened.rfind(mark) for mark in ",、;")
if cut >= int(char_limit * 0.55):
shortened = shortened[:cut]
cleaned = shortened.rstrip(",、;: ") + "。"
# 同一条最多问一个问题,避免像问卷审讯。
question_positions = [m.start() for m in re.finditer(r"[?]", cleaned)]
if len(question_positions) > 1:
last = question_positions[-1]
cleaned = "".join(
("" if char in "?" and i != last else char)
for i, char in enumerate(cleaned)
)
return cleaned.strip()
def _repair_obvious_mismatch(reply: str, chat_text: str, history: list = None) -> str:
"""对少量可明确判断的闲聊答非所问做最终兜底,不改写业务和医疗回答。"""
cleaned = str(reply or "").strip()
latest = latest_customer_message(chat_text)
if not latest or not cleaned:
return cleaned
if _CLARIFY_RE.fullmatch(latest):
previous = ""
for item in reversed(_history_messages(history or [])):
if item.get("role") == "assistant":
previous = _humanize(item.get("content") or "")
break
if previous:
first_sentence = re.match(r"[^。!?;]+[。!?;]?", previous)
concise = (first_sentence.group(0) if first_sentence else previous).strip()
if concise.startswith(("我是说", "就是说")):
return concise
return _humanize(f"我是说,{concise}")
if re.search(r"(?:你(?:今年)?多大|你几岁|你多大年纪)", latest):
answered_age = bool(re.search(r"(?:[二三四五六七八九]\s*十|\d{2})\s*(?:来)?岁", cleaned))
if not answered_age or _MEDICAL_DRIFT_RE.search(cleaned):
return "四十来岁啦,怎么突然问这个?"
if re.search(r"(?:好困|困死|想睡)", latest):
if len(cleaned) > 60 or _MEDICAL_DRIFT_RE.search(cleaned):
return "困了就先眯一会儿,别硬撑着。"
if re.search(r"(?:你)?在干嘛|干什么呢", latest):
if _MEDICAL_DRIFT_RE.search(cleaned):
return "刚忙完,正好看到你消息。你呢?"
if re.search(r"你(?:叫)?什么(?:名字)?|怎么称呼你", latest):
agent = str(getattr(ai_config, "AI_AGENT_NAME", "客服") or "客服").strip()
if agent not in cleaned or _MEDICAL_DRIFT_RE.search(cleaned):
return f"我叫{agent},叫我{agent}就行。"
return cleaned
def _finalize_reply(text: str, chat_text: str, history: list = None) -> str:
cleaned = _humanize(_strip_thinking(text))
return _repair_obvious_mismatch(cleaned, chat_text, history)
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:
latest = latest_customer_message(chat_text)
mode_instruction = _conversation_mode_instruction(latest)
return {
"role": "user",
"content": (
f"{mode_instruction}\n"
f"【客户最后一句|唯一回答对象】\n{latest}\n\n"
f"【原始聊天片段|仅用于理解上下文】\n{chat_text}\n\n"
"像真人微信聊天;普通闲聊直接接话,只有对方明确担心或难受时才先关心。默认1~2句、20~60字,"
"最多问一个问题,不要列表、标题和客套收尾。"
),
}
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 _finalize_reply(
_call_dify(_dify_query_from_chat(chat_text, history)),
chat_text,
history,
)
if getattr(ai_config, "AI_MCP_ENABLED", False):
try:
from mcp_bridge import run_coro
return _finalize_reply(
run_coro(_call_ai_text_with_mcp(chat_text, history)),
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 _finalize_reply(msg.get("content") or "", chat_text, history)
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": (
"这是一个聊天对话窗口的截图。"
"左边的灰色气泡是对方(客户)发的消息,右边的蓝色气泡是我方之前的回复。"
"请只关注对方(客户)发的最后一条消息,针对那条消息直接回复。"
"像真人微信聊天,先关心一句,再说重点;默认1~2句、20~60字,最多问一个问题。"
"只输出回复内容,不要描述图片,不要解释,不要加引号、标题或列表。"
),
},
{
"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 ""