921 lines
36 KiB
Python
921 lines
36 KiB
Python
# -*- 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)
|