优化
This commit is contained in:
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
import json
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
@@ -17,7 +17,7 @@ from app.services.autonomous_cdsl_generation import AutonomousCdslGenerationRunn
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.review_renderer import renderer_status
|
||||
from app.services.sse import event
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.services.storage import WorkspaceStore, now_iso
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
@@ -25,6 +25,68 @@ def text_from_message(message: ChatMessage) -> str:
|
||||
return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip()
|
||||
|
||||
|
||||
def _response_language(text: str) -> str:
|
||||
cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff")
|
||||
latin = sum(1 for char in text if char.isascii() and char.isalpha())
|
||||
return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English"
|
||||
|
||||
|
||||
_EVENT_LABELS = {
|
||||
"requirements_document": "冻结需求", "completion_checklist": "完成清单", "completion_audit": "完成审计",
|
||||
"modeling_plan": "建模计划", "modeling_plan_review": "计划独立复核",
|
||||
"tool_call": "建模工具", "candidate_result": "候选构建", "candidate_review": "候选独立复核",
|
||||
"geometry_diagnostic": "几何诊断", "geometry_conclusion": "几何结论", "step_review": "步骤审查",
|
||||
"checkpoint": "构建检查点", "rollback": "回滚检查点", "final_review": "最终视觉复核", "task_terminal": "生成任务",
|
||||
}
|
||||
|
||||
|
||||
def _event_status(name: str, payload: dict[str, Any]) -> str:
|
||||
if name == "task_terminal":
|
||||
lifecycle = str(payload.get("lifecycle") or "")
|
||||
if lifecycle == "failed":
|
||||
return "error"
|
||||
if lifecycle == "cancelled":
|
||||
return "cancelled"
|
||||
return "success"
|
||||
review = payload.get("review") if isinstance(payload.get("review"), dict) else {}
|
||||
if (name == "modeling_plan_review" and str(review.get("verdict") or "") == "revise") or (
|
||||
name == "candidate_review" and str(review.get("verdict") or "") == "reject"
|
||||
) or (
|
||||
name == "final_review" and str(review.get("verdict") or "") == "repair"
|
||||
):
|
||||
return "error"
|
||||
return str(payload.get("status") or "running")
|
||||
|
||||
|
||||
def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(payload)
|
||||
data.update({"step": name, "label": _EVENT_LABELS.get(name, name), "status": _event_status(name, payload)})
|
||||
return data
|
||||
|
||||
|
||||
def _append_text_part(parts: list[dict[str, Any]], text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
if parts and parts[-1].get("type") == "text":
|
||||
parts[-1]["text"] = str(parts[-1].get("text") or "") + text
|
||||
else:
|
||||
parts.append({"type": "text", "text": text})
|
||||
|
||||
|
||||
def _upsert_data_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None:
|
||||
part_id = str(part.get("id") or "")
|
||||
if part_id:
|
||||
for index, existing in enumerate(parts):
|
||||
if str(existing.get("id") or "") == part_id:
|
||||
parts[index] = part
|
||||
return
|
||||
parts.append(part)
|
||||
|
||||
|
||||
class _StreamingUnsupported(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def conversation_user_context(conversation: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"""Return durable user intent from the whole conversation.
|
||||
|
||||
@@ -131,6 +193,19 @@ class AgentService:
|
||||
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"resume-autonomous-cdsl-{task_id}")
|
||||
|
||||
async def cancel(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.store.read_task(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
running = self._autonomous_runs.pop(task_id, None)
|
||||
if running and not running.done():
|
||||
running.cancel()
|
||||
if str(task.get("lifecycle") or "") == "running":
|
||||
task = self.store.finish_generation(task_id, lifecycle="cancelled", failure={
|
||||
"schema_version": "cad.autonomous-cancelled.v1", "stage": "cancelled", "message": "CAD generation was cancelled by the user.",
|
||||
})
|
||||
return task
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
@@ -214,34 +289,67 @@ class AgentService:
|
||||
if isinstance(attachment, dict) and str(attachment.get("id") or "")
|
||||
]
|
||||
self.store.start_generation(task_id, request=user_text)
|
||||
yield event("progress", {
|
||||
"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running",
|
||||
"message": "CAD 任务已启动。" if _response_language(user_text) == "Chinese" else "CAD task started.",
|
||||
})
|
||||
queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
|
||||
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete)
|
||||
assistant_parts: list[dict[str, Any]] = []
|
||||
sequence = 0
|
||||
|
||||
async def on_text_delta(text: str) -> None:
|
||||
# Text is persisted and streamed through the same ordered queue as
|
||||
# tool events, so a refresh produces the exact same transcript.
|
||||
nonlocal sequence
|
||||
sequence += 1
|
||||
_append_text_part(assistant_parts, text)
|
||||
await queue.put(("text_delta", {
|
||||
"text": text, "taskId": task_id, "eventId": f"{task_id}_{sequence}_text_delta",
|
||||
"sequence": sequence, "timestamp": now_iso(),
|
||||
}))
|
||||
|
||||
runner = AutonomousCdslGenerationRunner(self.settings, self.store, self._complete, on_text_delta=on_text_delta)
|
||||
assistant_id = f"assistant_{secrets.token_hex(8)}"
|
||||
|
||||
async def consume() -> None:
|
||||
assistant_parts: list[dict[str, Any]] = []
|
||||
nonlocal sequence
|
||||
terminal = ""
|
||||
try:
|
||||
async for name, payload in runner.run(
|
||||
task_id=task_id, request=user_text, conversation_id=conversation["conversation_id"], provider=provider,
|
||||
model=model, initial_messages=initial_messages, frozen_attachment_ids=frozen_attachment_ids, already_started=True,
|
||||
):
|
||||
sequence += 1
|
||||
decorated = dict(payload)
|
||||
decorated.setdefault("taskId", task_id)
|
||||
decorated.setdefault("eventId", f"{task_id}_{sequence}_{name}")
|
||||
decorated.update({
|
||||
"sequence": sequence,
|
||||
"timestamp": now_iso(),
|
||||
})
|
||||
if name == "cad_result":
|
||||
assistant_parts.append({"type": "data-cad-result", "data": payload})
|
||||
_upsert_data_part(assistant_parts, {"type": "data-cad-result", "id": decorated["eventId"], "data": decorated})
|
||||
elif name == "agent_thinking":
|
||||
visible = str(payload.get("message") or "")
|
||||
if visible:
|
||||
assistant_parts.append({"type": "text", "text": visible})
|
||||
# Streaming completions already delivered this
|
||||
# content through on_text_delta; only persist the
|
||||
# full response for the compatibility path.
|
||||
if not assistant_parts or assistant_parts[-1].get("type") != "text" or visible not in str(assistant_parts[-1].get("text") or ""):
|
||||
_append_text_part(assistant_parts, visible + "\n\n")
|
||||
elif name == "task_terminal":
|
||||
terminal = str(payload.get("lifecycle") or "")
|
||||
if terminal == "failed":
|
||||
assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
|
||||
await queue.put((name, payload))
|
||||
_upsert_data_part(assistant_parts, {"type": "data-cad-error", "id": decorated["eventId"], "data": {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")}})
|
||||
elif name != "text_delta":
|
||||
_upsert_data_part(assistant_parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)})
|
||||
await queue.put((name, decorated))
|
||||
except Exception as error:
|
||||
self.store.finish_generation(task_id, lifecycle="failed", failure={"schema_version": "cad.autonomous-failure.v1", "stage": "worker", "message": str(error)})
|
||||
terminal = "failed"
|
||||
payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
|
||||
assistant_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}})
|
||||
error_id = f"{task_id}_{sequence + 1}_cad_error"
|
||||
assistant_parts.append({"type": "data-cad-error", "id": error_id, "data": {"stage": "generation", "message": str(error)}})
|
||||
await queue.put(("task_terminal", payload))
|
||||
finally:
|
||||
if terminal == "completed" and not assistant_parts:
|
||||
@@ -252,14 +360,20 @@ class AgentService:
|
||||
|
||||
self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cdsl-{task_id}")
|
||||
while True:
|
||||
item = await queue.get()
|
||||
try:
|
||||
item = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
except asyncio.TimeoutError:
|
||||
yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()})
|
||||
continue
|
||||
if item is None:
|
||||
break
|
||||
name, payload = item
|
||||
if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed":
|
||||
yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD 自主生成失败。")})
|
||||
elif name == "agent_thinking":
|
||||
yield event("text_delta", {"text": str(payload.get("message") or "")})
|
||||
message = str(payload.get("message") or "")
|
||||
if message:
|
||||
yield event("text_delta", {"text": message + "\n\n"})
|
||||
else:
|
||||
yield event(name, payload)
|
||||
yield event("done", {})
|
||||
@@ -278,6 +392,94 @@ class AgentService:
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
required_tool_name: str | None = None,
|
||||
*,
|
||||
on_text_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if on_text_delta is None:
|
||||
return await self._complete_once(messages, tools, provider, model, required_tool_name)
|
||||
try:
|
||||
return await self._complete_stream(messages, tools, provider, model, required_tool_name, on_text_delta)
|
||||
except _StreamingUnsupported:
|
||||
await on_text_delta("当前模型不支持工具流式,已切换为兼容模式。\n\n")
|
||||
response = await self._complete_once(messages, tools, provider, model, required_tool_name)
|
||||
content = str((((response.get("choices") or [{}])[0] or {}).get("message") or {}).get("content") or "")
|
||||
if content:
|
||||
await on_text_delta(content + "\n\n")
|
||||
# Prevent the runner from emitting this same full response again.
|
||||
choice = ((response.get("choices") or [{}])[0] or {}).get("message") or {}
|
||||
return {"choices": [{"message": {**choice, "content": ""}}]}
|
||||
|
||||
async def _complete_stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
required_tool_name: str | None,
|
||||
on_text_delta: Callable[[str], Awaitable[None]],
|
||||
) -> dict[str, Any]:
|
||||
tool_choice: str | dict[str, Any] = "auto"
|
||||
if required_tool_name:
|
||||
tool_choice = {"type": "function", "function": {"name": required_tool_name}}
|
||||
payload: dict[str, Any] = {
|
||||
"model": model.id, "messages": messages, "tools": tools,
|
||||
"tool_choice": tool_choice, "temperature": 0.1, "stream": True,
|
||||
}
|
||||
payload.update(provider.chat_completion_options)
|
||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
|
||||
async with client.stream("POST", f"{provider.base_url}/chat/completions", headers=headers, json=payload) as response:
|
||||
if response.status_code in {400, 404, 405, 415, 422}:
|
||||
detail = (await response.aread()).decode(errors="replace")[:400]
|
||||
raise _StreamingUnsupported(detail)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"LLM request failed ({response.status_code}): {(await response.aread()).decode(errors='replace')[:800]}")
|
||||
if "text/event-stream" not in str(response.headers.get("content-type") or ""):
|
||||
raise _StreamingUnsupported("provider returned a non-stream response")
|
||||
tool_calls: dict[int, dict[str, Any]] = {}
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[5:].strip()
|
||||
if raw == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = chunk.get("choices") if isinstance(chunk, dict) else None
|
||||
delta = choices[0].get("delta") if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
||||
if not isinstance(delta, dict):
|
||||
continue
|
||||
text = str(delta.get("content") or delta.get("reasoning_content") or "")
|
||||
if text:
|
||||
await on_text_delta(text)
|
||||
for call in delta.get("tool_calls") or []:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
index = int(call.get("index") or 0)
|
||||
current = tool_calls.setdefault(index, {"id": str(call.get("id") or f"call_{index}"), "type": "function", "function": {"name": "", "arguments": ""}})
|
||||
if call.get("id"):
|
||||
current["id"] = str(call["id"])
|
||||
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
||||
if function.get("name"):
|
||||
current["function"]["name"] += str(function["name"])
|
||||
if function.get("arguments"):
|
||||
current["function"]["arguments"] += str(function["arguments"])
|
||||
except _StreamingUnsupported:
|
||||
raise
|
||||
except (httpx.HTTPError, asyncio.TimeoutError) as error:
|
||||
raise RuntimeError(f"LLM streaming connection failed: {error}") from error
|
||||
return {"choices": [{"message": {"content": "", "tool_calls": [tool_calls[key] for key in sorted(tool_calls)]}}]}
|
||||
|
||||
async def _complete_once(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
required_tool_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tool_choice: str | dict[str, Any] = "auto"
|
||||
if required_tool_name:
|
||||
|
||||
Reference in New Issue
Block a user