This commit is contained in:
2026-08-31 14:16:08 +08:00
parent b1b226593a
commit 7533b298f1
30 changed files with 6976 additions and 253 deletions
+213 -11
View File
@@ -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: