新增功能
This commit is contained in:
@@ -0,0 +1,920 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user