Files
kefu/wechat_rpa/grok_direct_chat.py
2026-07-28 09:46:53 +08:00

921 lines
36 KiB
Python
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.
# -*- coding: utf-8 -*-
"""Fast, tool-free chat path for the backend-configured Grok Agent model.
The Grok Build runtime is intentionally skipped for ordinary language turns.
Only requests classified as requiring live data, tools, or side effects should
be sent through the Agent path.
"""
from __future__ import annotations
import json
import re
import threading
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Callable, Mapping, Sequence
from grok_build_bridge import GrokBuildManager
class DirectChatError(RuntimeError):
"""The configured custom model could not complete a direct chat turn."""
class DirectChatCancelled(DirectChatError):
"""The caller cancelled an in-flight direct chat stream."""
class DirectChatCancellation:
"""Thread-safe cancellation handle that also closes the active HTTP stream."""
def __init__(self) -> None:
self._event = threading.Event()
self._lock = threading.Lock()
self._response: object | None = None
self._cancel_callback: Callable[[], None] | None = None
self._cancel_callback_started = False
@property
def cancelled(self) -> bool:
return self._event.is_set()
def cancel(self) -> None:
self._event.set()
with self._lock:
response = self._response
callback = self._take_cancel_callback_locked()
if response is not None:
try:
response.close()
except (AttributeError, OSError, ValueError):
pass
if callback is not None:
threading.Thread(target=callback, daemon=True).start()
def _take_cancel_callback_locked(self) -> Callable[[], None] | None:
if self._cancel_callback_started or self._cancel_callback is None:
return None
self._cancel_callback_started = True
return self._cancel_callback
def set_cancel_callback(self, callback: Callable[[], None]) -> None:
with self._lock:
self._cancel_callback = callback
pending = self._event.is_set()
selected = self._take_cancel_callback_locked() if pending else None
if selected is not None:
threading.Thread(target=selected, daemon=True).start()
def attach(self, response: object) -> None:
with self._lock:
if self._event.is_set():
try:
response.close()
except (AttributeError, OSError, ValueError):
pass
raise DirectChatCancelled("普通对话已停止")
self._response = response
def detach(self, response: object) -> None:
with self._lock:
if self._response is response:
self._response = None
def raise_if_cancelled(self) -> None:
if self._event.is_set():
raise DirectChatCancelled("普通对话已停止")
@dataclass(frozen=True)
class DirectChatResult:
text: str
model: str
protocol: str
conversation_id: str = ""
_MAX_HISTORY_MESSAGES = 12
_MAX_HISTORY_CHARS = 24_000
_MAX_ANSWER_CHARS = 200_000
_MAX_HTTP_BODY_BYTES = 32 * 1024 * 1024
_MAX_SSE_LINE_BYTES = 1024 * 1024
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_HTTP_OPENER = urllib.request.build_opener(_NoRedirectHandler())
_EXPLICIT_AGENT_RE = re.compile(
r"(?:使用|调用|启动|交给|让)\s*(?:Grok\s*Build\s*)?(?:Agent|智能体|代理|MCP|工具)",
re.IGNORECASE,
)
_LIVE_DATA_RE = re.compile(
r"(天气|气温|降雨|空气质量|新闻|热搜|股价|股票行情|汇率|航班|火车票|"
r"物流|快递|订单状态|库存|实时数据|最新数据|今天几号|现在几点)"
)
_LIVE_INTENT_RE = re.compile(
r"(查(?:一下)?|查询|查找|搜索|检索|获取|看看|看一下|告诉我|怎么样|多少|是否)"
)
_SIDE_EFFECT_RE = re.compile(
r"(打开|运行|执行|测试|安装|卸载|更新|下载|上传|发送|发布|部署|创建|新建|"
r"删除|移除|保存|读取|查看|写入|改动|修改|编辑|修复|重命名|复制到|移动到)"
)
_TOOL_OBJECT_RE = re.compile(
r"(文件|目录|文件夹|项目|代码库|仓库|终端|命令|脚本|程序|浏览器|网页|"
r"网站|网址|链接|GitHub|数据库|日志|截图|图片|Excel|表格|文档|PDF)",
re.IGNORECASE,
)
_WECOM_OBJECT_RE = re.compile(
r"(企业微信|企微|通讯录|联系人|群聊|聊天记录|消息|日程|会议|待办)"
)
_WECOM_ACTION_RE = re.compile(
r"(查|看|获取|发送|创建|安排|预约|取消|更新|删除|添加|移除|回复)"
)
_URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
_URL_ACTION_RE = re.compile(r"(打开|访问|读取|总结|分析|查询|下载|抓取|搜索)")
_IDENTITY_RE = re.compile(
r"(你是谁|你是(?:什么|哪个|哪一个|哪种)?模型|"
r"(?:什么|哪个|哪一个|哪种)模型|模型(?:名称|版本|型号)|底层模型)"
)
def classify_chat_route(text: str, *, last_route: str = "") -> str:
"""Return ``direct`` for language chat or ``agent`` for executable work."""
value = " ".join(str(text or "").strip().split())
if not value:
return "direct"
if (
last_route == "agent"
and len(value) <= 12
and re.fullmatch(
r"(?:[?]|继续|然后呢|还有呢|明天呢|后天呢|再查一下|再看看|详细点)[??]?",
value,
)
):
return "agent"
if re.match(r"^(?:@|/)\s*(?:agent|智能体)\b", value, re.IGNORECASE):
return "agent"
if _EXPLICIT_AGENT_RE.search(value):
return "agent"
if _LIVE_DATA_RE.search(value) and (
_LIVE_INTENT_RE.search(value)
or any(token in value for token in ("今天", "现在", "最新", "实时", "帮我"))
):
return "agent"
if _SIDE_EFFECT_RE.search(value) and _TOOL_OBJECT_RE.search(value):
return "agent"
if _WECOM_OBJECT_RE.search(value) and _WECOM_ACTION_RE.search(value):
return "agent"
if _URL_RE.search(value) and _URL_ACTION_RE.search(value):
return "agent"
return "direct"
def configured_identity_reply(text: str, model: str) -> str:
"""Return a truthful instant model-identity answer when applicable."""
if not _IDENTITY_RE.search(str(text or "")):
return ""
configured = str(model or "").strip() or "后台配置的自有模型"
return (
f"当前使用的是后台配置的自有模型:{configured}。"
"普通对话直接由该模型回答;只有需要查询或执行工具时才启动 Grok Build Agent。"
)
def _bounded_history(history: Sequence[Mapping[str, object]] | None) -> list[dict[str, str]]:
if not history:
return []
selected: list[dict[str, str]] = []
total = 0
for item in reversed(list(history)[-_MAX_HISTORY_MESSAGES:]):
role = str(item.get("role") or "").strip()
content = str(item.get("content") or "").strip()
if role not in {"user", "assistant"} or not content:
continue
remaining = _MAX_HISTORY_CHARS - total
if remaining <= 0:
break
content = content[-remaining:]
selected.append({"role": role, "content": content})
total += len(content)
selected.reverse()
return selected
def _headers(api_key: str, auth_scheme: str, *, anthropic: bool = False) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "ZhenYangTang-RPA-Direct-Chat/1.0",
}
if auth_scheme == "x_api_key":
headers["x-api-key"] = api_key
else:
headers["Authorization"] = f"Bearer {api_key}"
if anthropic:
headers["anthropic-version"] = "2023-06-01"
return headers
def _system_prompt(model: str) -> str:
return (
"你是当前软件内的 AI 客服对话助手。请直接、自然、简洁地回答用户。"
"你的推理由后台配置的自有模型完成,不得声称自己是 xAI、Grok、"
"OpenAI、Claude 或其他未配置的厂商模型。"
f"后台当前配置的模型名称是 {model}。"
"本次是无工具普通对话:不要声称已经查询实时信息、访问网页、读取文件、"
"执行命令或完成外部操作。若用户确实要求这些操作,请说明需要切换到 Agent。"
)
@dataclass(frozen=True)
class _PreparedChat:
prompt: str
model: str
protocol: str
url: str
headers: Mapping[str, str]
payload: Mapping[str, object]
timeout: int
conversation_id: str
identity: str = ""
def _prepare_chat(
message: str,
*,
history: Sequence[Mapping[str, object]] | None,
user_id: str,
conversation_id: str,
manager: GrokBuildManager | None,
streaming: bool,
) -> _PreparedChat:
prompt = str(message or "").strip()
if not prompt:
raise DirectChatError("消息不能为空")
runtime = manager or GrokBuildManager()
settings = runtime.load_ai_settings()
if not bool(settings.get("GROK_MODEL_ENABLED", False)):
raise DirectChatError("后台尚未启用 Agent 自有模型")
profile = runtime.model_profile(settings)
if not profile.compatible:
raise DirectChatError(profile.reason or "后台自有模型配置不可用")
api_key = str(settings.get("GROK_API_KEY") or "").strip()
if not api_key:
raise DirectChatError("后台自有模型缺少 API Key")
try:
configured_timeout = int(settings.get("GROK_CUSTOMER_SERVICE_TIMEOUT", 180))
except (TypeError, ValueError):
configured_timeout = 180
timeout = min(90, max(10, configured_timeout))
bounded = _bounded_history(history)
identity = configured_identity_reply(prompt, profile.model)
system = _system_prompt(profile.model)
protocol = profile.api_backend
url = profile.base_url.rstrip("/")
headers = _headers(api_key, profile.auth_scheme)
if protocol == "dify":
query_parts = [system]
if bounded and not conversation_id:
query_parts.append(
"以下是最近对话:\n"
+ "\n".join(
f"{'用户' if item['role'] == 'user' else '助手'}{item['content']}"
for item in bounded
)
)
query_parts.append(f"用户当前消息:{prompt}")
payload: dict[str, object] = {
"inputs": (
settings.get("GROK_DIFY_INPUTS")
if isinstance(settings.get("GROK_DIFY_INPUTS"), Mapping)
else {}
),
"query": "\n\n".join(query_parts),
"response_mode": "streaming" if streaming else "blocking",
"user": re.sub(r"[^A-Za-z0-9_-]+", "-", user_id)[:64]
or "wechat-rpa-chat",
}
if conversation_id:
payload["conversation_id"] = conversation_id
url = f"{url}/chat-messages"
elif protocol == "chat_completions":
payload = {
"model": profile.model,
"messages": [
{"role": "system", "content": system},
*bounded,
{"role": "user", "content": prompt},
],
"temperature": profile.temperature,
"max_tokens": profile.max_completion_tokens,
"stream": streaming,
}
url = f"{url}/chat/completions"
elif protocol == "responses":
payload = {
"model": profile.model,
"instructions": system,
"input": [*bounded, {"role": "user", "content": prompt}],
"temperature": profile.temperature,
"max_output_tokens": profile.max_completion_tokens,
"stream": streaming,
}
url = f"{url}/responses"
elif protocol == "messages":
payload = {
"model": profile.model,
"system": system,
"messages": [*bounded, {"role": "user", "content": prompt}],
"temperature": profile.temperature,
"max_tokens": profile.max_completion_tokens,
"stream": streaming,
}
url = f"{url}/messages"
headers = _headers(api_key, profile.auth_scheme, anthropic=True)
else:
raise DirectChatError(f"普通对话暂不支持接口协议:{protocol}")
if streaming:
headers = {**headers, "Accept": "text/event-stream"}
return _PreparedChat(
prompt=prompt,
model=profile.model,
protocol=protocol,
url=url,
headers=headers,
payload=payload,
timeout=timeout,
conversation_id=conversation_id,
identity=identity,
)
def _post_json(
url: str,
*,
headers: Mapping[str, str],
payload: Mapping[str, object],
timeout: int,
) -> Mapping[str, object]:
request = urllib.request.Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=dict(headers),
method="POST",
)
try:
with _HTTP_OPENER.open(request, timeout=timeout) as response:
status = int(getattr(response, "status", 200) or 200)
body = response.read(_MAX_HTTP_BODY_BYTES + 1)
except urllib.error.HTTPError as exc:
try:
detail = exc.read().decode("utf-8", errors="replace")[:500]
except OSError:
detail = str(exc)
raise DirectChatError(
f"后台自有模型请求失败(HTTP {exc.code}):{detail}"
) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise DirectChatError(f"无法连接后台自有模型:{exc}") from exc
if not 200 <= status < 300:
detail = body.decode("utf-8", errors="replace")[:500]
raise DirectChatError(
f"后台自有模型请求失败(HTTP {status}):{detail}"
)
if len(body) > _MAX_HTTP_BODY_BYTES:
raise DirectChatError("后台自有模型响应过大,已停止读取")
try:
data = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DirectChatError("后台自有模型返回了无效 JSON") from exc
if not isinstance(data, Mapping):
raise DirectChatError("后台自有模型响应结构无效")
return data
def _answer_from_responses(data: Mapping[str, object]) -> str:
output_text = data.get("output_text")
if isinstance(output_text, str) and output_text.strip():
return output_text.strip()
chunks: list[str] = []
output = data.get("output")
if isinstance(output, list):
for item in output:
if not isinstance(item, Mapping):
continue
content = item.get("content")
if not isinstance(content, list):
continue
for part in content:
if not isinstance(part, Mapping):
continue
text = part.get("text")
if isinstance(text, str):
chunks.append(text)
return "".join(chunks).strip()
def _answer_from_messages(data: Mapping[str, object]) -> str:
chunks: list[str] = []
content = data.get("content")
if isinstance(content, list):
for part in content:
if isinstance(part, Mapping) and part.get("type") == "text":
text = part.get("text")
if isinstance(text, str):
chunks.append(text)
return "".join(chunks).strip()
def _error_message(data: Mapping[str, object], fallback: str) -> str:
error = data.get("error")
if isinstance(error, Mapping):
return str(error.get("message") or error.get("type") or fallback)
return str(data.get("message") or error or fallback)
def _result_from_json(
prepared: _PreparedChat,
data: Mapping[str, object],
) -> DirectChatResult:
next_conversation_id = prepared.conversation_id
if prepared.protocol == "dify":
answer = data.get("answer")
if not isinstance(answer, str):
nested = data.get("data")
answer = nested.get("answer") if isinstance(nested, Mapping) else ""
next_conversation_id = str(
data.get("conversation_id") or prepared.conversation_id
)
elif prepared.protocol == "chat_completions":
choices = data.get("choices")
answer = ""
if isinstance(choices, list) and choices and isinstance(choices[0], Mapping):
message_data = choices[0].get("message")
if isinstance(message_data, Mapping):
answer = message_data.get("content")
elif prepared.protocol == "responses":
answer = _answer_from_responses(data)
else:
answer = _answer_from_messages(data)
text = str(answer or "").strip()
if not text:
raise DirectChatError("后台自有模型没有返回有效文本")
if len(text) > _MAX_ANSWER_CHARS:
raise DirectChatError("后台自有模型回复过长,已拒绝显示")
return DirectChatResult(
text=text,
model=prepared.model,
protocol=prepared.protocol,
conversation_id=next_conversation_id,
)
def _open_stream_response(prepared: _PreparedChat):
request = urllib.request.Request(
prepared.url,
data=json.dumps(prepared.payload, ensure_ascii=False).encode("utf-8"),
headers=dict(prepared.headers),
method="POST",
)
try:
return _HTTP_OPENER.open(request, timeout=prepared.timeout)
except urllib.error.HTTPError as exc:
try:
detail = exc.read(501).decode("utf-8", errors="replace")[:500]
except OSError:
detail = str(exc)
raise DirectChatError(
f"后台自有模型请求失败(HTTP {exc.code}):{detail}"
) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise DirectChatError(f"无法连接后台自有模型:{exc}") from exc
def _stop_dify_task(prepared: _PreparedChat, task_id: str, user_id: str) -> None:
safe_task_id = urllib.parse.quote(task_id, safe="")
stop_url = f"{prepared.url.rstrip('/')}/{safe_task_id}/stop"
request = urllib.request.Request(
stop_url,
data=json.dumps({"user": user_id}, ensure_ascii=False).encode("utf-8"),
headers=dict(prepared.headers),
method="POST",
)
try:
with _HTTP_OPENER.open(request, timeout=5) as response:
response.read(1024)
except Exception:
pass
def _read_json_response(response: object) -> Mapping[str, object]:
body = response.read(_MAX_HTTP_BODY_BYTES + 1)
if len(body) > _MAX_HTTP_BODY_BYTES:
raise DirectChatError("后台自有模型响应过大,已停止读取")
try:
data = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DirectChatError("后台自有模型返回了无效 JSON") from exc
if not isinstance(data, Mapping):
raise DirectChatError("后台自有模型响应结构无效")
return data
def _iter_sse_events(
response: object,
cancellation: DirectChatCancellation,
):
event_name = ""
data_lines: list[str] = []
total_bytes = 0
while True:
cancellation.raise_if_cancelled()
raw = response.readline(_MAX_SSE_LINE_BYTES + 1)
if not raw:
break
total_bytes += len(raw)
if total_bytes > _MAX_HTTP_BODY_BYTES:
raise DirectChatError("后台自有模型流式响应过大,已停止读取")
if len(raw) > _MAX_SSE_LINE_BYTES:
raise DirectChatError("后台自有模型 SSE 单行数据过大")
try:
line = raw.decode("utf-8").rstrip("\r\n")
except UnicodeDecodeError as exc:
raise DirectChatError("后台自有模型 SSE 不是有效 UTF-8") from exc
if line.startswith("\ufeff"):
line = line.lstrip("\ufeff")
if not line:
if event_name or data_lines:
yield event_name, "\n".join(data_lines)
event_name = ""
data_lines = []
continue
if line.startswith(":"):
continue
field, separator, value = line.partition(":")
if separator and value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value
elif field == "data":
data_lines.append(value)
if event_name or data_lines:
yield event_name, "\n".join(data_lines)
def stream_direct_chat(
message: str,
*,
on_update: Callable[[str, bool], None],
history: Sequence[Mapping[str, object]] | None = None,
user_id: str = "wechat-rpa-chat",
conversation_id: str = "",
manager: GrokBuildManager | None = None,
cancellation: DirectChatCancellation | None = None,
) -> DirectChatResult:
"""Stream a tool-free model turn and return only after a valid terminal event.
``on_update(text, replace)`` receives text deltas. ``replace=True`` is
used by Dify's ``message_replace`` snapshot event.
"""
prepared = _prepare_chat(
message,
history=history,
user_id=user_id,
conversation_id=conversation_id,
manager=manager,
streaming=True,
)
cancel = cancellation or DirectChatCancellation()
cancel.raise_if_cancelled()
if prepared.identity:
on_update(prepared.identity, False)
return DirectChatResult(
text=prepared.identity,
model=prepared.model,
protocol=prepared.protocol,
conversation_id="",
)
response = _open_stream_response(prepared)
cancel.attach(response)
chunks: list[str] = []
current_chars = 0
finished = False
next_conversation_id = prepared.conversation_id
workflow_started = False
workflow_finished = False
seen_agent_message = False
completion_finish_reason = ""
dify_task_id = ""
anthropic_started = False
anthropic_message_delta = False
anthropic_stop_reason = ""
anthropic_open_blocks: set[int] = set()
def emit(text: object, *, replace: bool = False) -> None:
nonlocal chunks, current_chars
cancel.raise_if_cancelled()
value = str(text or "")
if not value:
return
new_size = len(value) if replace else current_chars + len(value)
if new_size > _MAX_ANSWER_CHARS:
raise DirectChatError("后台自有模型回复过长,已停止生成")
if replace:
chunks = [value]
current_chars = len(value)
else:
chunks.append(value)
current_chars = new_size
try:
on_update(value, replace)
except Exception as exc:
raise DirectChatError("界面无法接收流式内容") from exc
try:
status = int(getattr(response, "status", 200) or 200)
if not 200 <= status < 300:
detail = response.read(501).decode("utf-8", errors="replace")[:500]
raise DirectChatError(
f"后台自有模型请求失败(HTTP {status}):{detail}"
)
headers = getattr(response, "headers", {})
content_type = str(headers.get("Content-Type", "") or "").lower()
if "application/json" in content_type:
result = _result_from_json(prepared, _read_json_response(response))
emit(result.text)
return result
try:
events = _iter_sse_events(response, cancel)
for sse_name, raw_data in events:
cancel.raise_if_cancelled()
value = raw_data.strip()
if not value:
continue
if value == "[DONE]":
if prepared.protocol == "chat_completions":
finished = True
elif prepared.protocol == "responses":
finished = True
break
try:
event = json.loads(value)
except json.JSONDecodeError as exc:
raise DirectChatError(
"后台自有模型 SSE data 不是合法 JSON"
) from exc
if not isinstance(event, Mapping):
continue
event_type = str(event.get("event") or event.get("type") or sse_name)
if prepared.protocol == "dify":
task_id = str(event.get("task_id") or "").strip()
if task_id and not dify_task_id:
dify_task_id = task_id
dify_user = str(
prepared.payload.get("user") or "wechat-rpa-chat"
)
cancel.set_cancel_callback(
lambda current_task=task_id, current_user=dify_user: _stop_dify_task(
prepared,
current_task,
current_user,
)
)
next_conversation_id = str(
event.get("conversation_id") or next_conversation_id
)
if event_type == "error":
raise DirectChatError(
_error_message(event, "Dify 流式响应返回错误")
)
if event_type == "workflow_started":
workflow_started = True
elif event_type in {"message", "agent_message"}:
answer = str(event.get("answer") or "")
if event_type == "agent_message":
seen_agent_message = True
emit(answer)
elif seen_agent_message:
emit(answer, replace=True)
else:
emit(answer)
elif event_type == "message_replace":
emit(event.get("answer"), replace=True)
elif event_type == "text_chunk":
data = event.get("data")
if isinstance(data, Mapping):
emit(data.get("text"))
elif event_type == "message_end":
finished = True
elif event_type in {"workflow_finished", "node_finished"}:
data = event.get("data")
status_value = (
str(data.get("status") or "").lower()
if isinstance(data, Mapping)
else ""
)
if status_value in {"failed", "error", "stopped"}:
raise DirectChatError(
str(
data.get("error")
or data.get("message")
or f"Dify {event_type} 失败"
)
)
if event_type == "workflow_finished":
workflow_finished = True
if finished and (not workflow_started or workflow_finished):
break
elif prepared.protocol == "chat_completions":
if event_type == "error" or "error" in event:
raise DirectChatError(
_error_message(event, "Chat Completions 流式响应返回错误")
)
choices = event.get("choices")
if not isinstance(choices, list):
continue
for choice in choices:
if not isinstance(choice, Mapping):
continue
delta = choice.get("delta")
if isinstance(delta, Mapping):
content = delta.get("content")
if isinstance(content, str):
emit(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, Mapping):
emit(part.get("text"))
if delta.get("tool_calls") or delta.get("function_call"):
raise DirectChatError(
"普通对话模型返回了工具调用,已拒绝执行"
)
reason = choice.get("finish_reason")
if reason is not None:
completion_finish_reason = str(reason)
elif prepared.protocol == "responses":
if event_type in {"error", "response.failed", "response.incomplete", "response.cancelled"}:
raise DirectChatError(
_error_message(event, f"Responses 流式响应未完成:{event_type}")
)
if event_type in {"response.output_text.delta", "response.refusal.delta"}:
emit(event.get("delta"))
elif event_type == "response.output_text.done" and not chunks:
emit(event.get("text"))
elif event_type == "response.refusal.done" and not chunks:
emit(event.get("refusal"))
elif event_type in {"response.completed", "response.done"}:
response_data = event.get("response")
if isinstance(response_data, Mapping):
response_status = str(response_data.get("status") or "completed")
if response_status != "completed":
raise DirectChatError(
f"Responses 流式响应状态异常:{response_status}"
)
canonical = _answer_from_responses(response_data)
if canonical and canonical != "".join(chunks).strip():
emit(canonical, replace=True)
finished = True
break
else:
declared_type = str(event.get("type") or "")
if sse_name and declared_type and sse_name != declared_type:
raise DirectChatError("Anthropic SSE 事件名称与数据类型不一致")
if event_type == "error":
raise DirectChatError(
_error_message(event, "Anthropic 流式响应返回错误")
)
if event_type == "message_start":
anthropic_started = True
elif event_type == "content_block_start":
if not anthropic_started:
raise DirectChatError("Anthropic 内容块早于 message_start")
index = int(event.get("index", -1))
if index < 0 or index in anthropic_open_blocks:
raise DirectChatError("Anthropic 内容块索引无效")
anthropic_open_blocks.add(index)
block = event.get("content_block")
if isinstance(block, Mapping) and block.get("type") == "text":
emit(block.get("text"))
elif event_type == "content_block_delta":
index = int(event.get("index", -1))
if index not in anthropic_open_blocks:
raise DirectChatError("Anthropic 内容增量没有对应的开始事件")
delta = event.get("delta")
if isinstance(delta, Mapping) and delta.get("type") == "text_delta":
emit(delta.get("text"))
elif event_type == "content_block_stop":
index = int(event.get("index", -1))
if index not in anthropic_open_blocks:
raise DirectChatError("Anthropic 内容块结束事件无效")
anthropic_open_blocks.remove(index)
elif event_type == "message_delta":
delta = event.get("delta")
stop_reason = (
str(delta.get("stop_reason") or "")
if isinstance(delta, Mapping)
else ""
)
anthropic_message_delta = True
anthropic_stop_reason = stop_reason
if stop_reason in {"tool_use", "max_tokens"}:
raise DirectChatError(
f"Anthropic 普通对话未完整结束:{stop_reason}"
)
elif event_type == "message_stop":
if (
not anthropic_started
or anthropic_open_blocks
or not anthropic_message_delta
or not anthropic_stop_reason
):
raise DirectChatError("Anthropic 流式响应结束序列不完整")
if anthropic_stop_reason not in {
"end_turn",
"stop_sequence",
"refusal",
}:
raise DirectChatError(
f"Anthropic 普通对话结束原因异常:{anthropic_stop_reason}"
)
finished = True
break
except (OSError, TimeoutError, ValueError) as exc:
cancel.raise_if_cancelled()
raise DirectChatError(f"读取后台自有模型流失败:{exc}") from exc
cancel.raise_if_cancelled()
if not finished:
raise DirectChatError("后台自有模型流式响应未正常结束")
if prepared.protocol == "dify" and workflow_started and not workflow_finished:
raise DirectChatError("Dify Chatflow 流式响应缺少 workflow_finished")
if prepared.protocol == "chat_completions" and completion_finish_reason != "stop":
raise DirectChatError(
"Chat Completions 普通对话未完整结束:"
f"{completion_finish_reason or '缺少 finish_reason'}"
)
text = "".join(chunks).strip()
if not text:
raise DirectChatError("后台自有模型流式响应没有有效文本")
return DirectChatResult(
text=text,
model=prepared.model,
protocol=prepared.protocol,
conversation_id=next_conversation_id,
)
finally:
cancel.detach(response)
try:
response.close()
except (AttributeError, OSError, ValueError):
pass
def direct_chat(
message: str,
*,
history: Sequence[Mapping[str, object]] | None = None,
user_id: str = "wechat-rpa-chat",
conversation_id: str = "",
manager: GrokBuildManager | None = None,
) -> DirectChatResult:
"""Call the configured custom model without starting Grok Build."""
prepared = _prepare_chat(
message,
history=history,
user_id=user_id,
conversation_id=conversation_id,
manager=manager,
streaming=False,
)
if prepared.identity:
return DirectChatResult(
text=prepared.identity,
model=prepared.model,
protocol=prepared.protocol,
conversation_id="",
)
data = _post_json(
prepared.url,
headers=prepared.headers,
payload=prepared.payload,
timeout=prepared.timeout,
)
return _result_from_json(prepared, data)