from __future__ import annotations import asyncio import base64 import json import secrets from collections.abc import AsyncIterator from typing import Any import httpx from app.models.contracts import ChatMessage from app.services.engine_service import build_revision, load_engine from app.services.library import CdslLibrary from app.services.sse import event from app.services.storage import WorkspaceStore from app.settings import ProviderConfig, ProviderModel, Settings TOOL_SCHEMAS: list[dict[str, Any]] = [ { "type": "function", "function": { "name": "search_cdsl_library", "description": "Search the official local CDSL library for similar geometry and feature sequences.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 8}}, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "read_cdsl_reference", "description": "Read one official CDSL sample by part_id. Use this before creating geometry based on a reference.", "parameters": {"type": "object", "properties": {"part_id": {"type": "string"}}, "required": ["part_id"]}, }, }, { "type": "function", "function": { "name": "read_current_cdsl", "description": "Read the current task's latest CDSL before making a natural-language revision.", "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, }, }, { "type": "function", "function": { "name": "generate_cdsl_model", "description": "Validate and execute a complete parameterized CDSL model. Use only for explicit CAD generation or revision.", "parameters": { "type": "object", "properties": { "cdsl": {"type": "object", "description": "Complete cad.cdsl.llm.v1 JSON object."}, "summary": {"type": "string"}, "assumptions": {"type": "array", "items": {"type": "string"}}, }, "required": ["cdsl", "summary"], }, }, }, ] def text_from_message(message: ChatMessage) -> str: return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip() def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] for message in messages[-20:]: text = text_from_message(message) if text: result.append({"role": message.role, "content": text}) return result def system_prompt(settings: Settings) -> str: skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md" skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else "" readme_path = settings.engine_root / "README.md" engine_readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else "" supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS)) return f"""You are the CDSL CAD Agent for CDSL CAD Studio. You generate parameterized CDSL, never raw CAD source code. For new CAD requests: 1. Search the local official CDSL library. 2. Read at least one relevant reference when a match exists. 3. Call generate_cdsl_model only when the request is sufficiently specified. 4. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact. For a revision, call read_current_cdsl first and preserve unrelated features. Do not output compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles supported by the engine. Ask a concise clarification question when essential dimensions or intent are missing. Ordinary explanations must not create CAD. Local skill: {skill} Local engine guide: {engine_readme} Supported named profile types: {supported_profiles} """ class AgentService: def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None: self.settings = settings self.store = store self.library = library async def stream( self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, ) -> AsyncIterator[bytes]: latest_user = next((message for message in reversed(messages) if message.role == "user"), None) if latest_user is None: yield event("cad_error", {"stage": "request", "message": "A user message is required."}) yield event("done", {}) return user_text = text_from_message(latest_user) conversation = self.store.ensure_conversation(conversation_id, selected_task_id) self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), selected_task_id) task_id = selected_task_id or conversation.get("current_task_id") or "" assistant_parts: list[dict[str, Any]] = [] assistant_id = f"assistant_{secrets.token_hex(8)}" successful_result: dict[str, Any] | None = None error_payload: dict[str, Any] | None = None try: provider, model = self.settings.resolve_model(provider_id, model_id) except ValueError as error: provider = None model = None configuration_error = str(error) else: configuration_error = "" if not self.settings.llm_configured or provider is None or model is None: message = "Agent 尚未配置模型。请设置 CDSL_LLM_BASE_URL、CDSL_LLM_API_KEY 和 CDSL_LLM_MODEL。" if configuration_error: message = configuration_error error_payload = {"stage": "configuration", "message": message} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) yield event("done", {}) return try: attachment_message = self._attachment_message(conversation, model) except ValueError as error: error_payload = {"stage": "attachment", "message": str(error)} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) yield event("done", {}) return yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] model_messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt(self.settings)}] model_messages.extend(messages_for_model(messages)) if attachment_message: model_messages.append({"role": "user", "content": attachment_message}) tools = TOOL_SCHEMAS try: for iteration in range(8): response = await self._complete(model_messages, tools, provider, model) choice = response["choices"][0]["message"] tool_calls = choice.get("tool_calls") or [] content = str(choice.get("content") or "") if content: assistant_parts.append({"type": "text", "text": content}) for chunk in self._chunks(content): yield event("text_delta", {"text": chunk}) if not tool_calls: break model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") arguments = json.loads(call.get("function", {}).get("arguments") or "{}") yield event("progress", { "step": name, "label": self._tool_label(name), "status": "running", "message": "Agent 正在调用本地 CAD 工具。", }) result, generated = await self._run_tool(name, arguments, task_id, user_text, references) if generated: task_id = generated["task_id"] model_messages.append({ "role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False), }) yield event("progress", { "step": name, "label": self._tool_label(name), "status": "success" if result.get("ok", True) else "error", "message": result.get("message") or result.get("summary") or "", }) if generated: result_payload = { "taskId": generated["task_id"], "revisionId": generated["revision_id"], "cdslPath": generated["cdsl_path"], "stepPath": generated["step_path"], "glbPath": generated["glb_path"], "reportPath": generated["report_path"], "parametersPath": generated.get("parameters_path"), "selectorPath": generated.get("selector_path"), "edgesPath": generated.get("edges_path"), "summary": generated["summary"], "referenceIds": generated["reference_ids"], "engine": generated["engine"], } successful_result = result_payload assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) if iteration == 7: error_payload = {"stage": "agent", "message": "Agent tool loop reached its safety limit."} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) except Exception as error: error_payload = {"stage": "agent", "message": str(error)} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) if task_id: self.store.ensure_conversation(conversation["conversation_id"], task_id) if not assistant_parts: assistant_parts.append({ "type": "text", "text": "我暂时没有生成可执行的 CAD 结果。请补充尺寸、形状或修改目标。", }) if successful_result and not any(part.get("type") == "text" for part in assistant_parts): assistant_parts.insert(0, {"type": "text", "text": f"已生成:{successful_result['summary']}。"}) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) yield event("progress", {"step": "agent_stream", "label": "调用模型和工具", "status": "success", "message": "Agent 请求已完成。"}) yield event("done", {}) def _persist_assistant( self, conversation_id: str, assistant_id: str, parts: list[dict[str, Any]], task_id: str, ) -> None: self.store.append_conversation_message( conversation_id, { "id": assistant_id or f"assistant_{conversation_id}_{len(parts)}", "role": "assistant", "parts": parts, }, task_id or None, ) async def _complete( self, messages: list[dict[str, Any]], tools: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, ) -> dict[str, Any]: url = f"{provider.base_url}/chat/completions" headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} payload = {"model": model.id, "messages": messages, "tools": tools, "tool_choice": "auto", "temperature": 0.1} async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: response = await client.post(url, headers=headers, json=payload) if response.status_code >= 400: raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}") return response.json() async def _run_tool(self, name: str, arguments: dict[str, Any], task_id: str, request: str, references: list[str]) -> tuple[dict[str, Any], dict[str, Any] | None]: if name == "search_cdsl_library": results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5)) return {"ok": True, "results": results}, None if name == "read_cdsl_reference": part_id = str(arguments.get("part_id") or "") sample = self.library.read_sample(part_id) if part_id not in references: references.append(part_id) return {"ok": True, "part_id": part_id, "cdsl": sample}, None if name == "read_current_cdsl": if not task_id: return {"ok": False, "message": "No current task exists. This is a new model request."}, None path = self.store.current_cdsl_path(task_id) if path is None: return {"ok": False, "message": "The current task has no successful CDSL revision."}, None return {"ok": True, "task_id": task_id, "cdsl": json.loads(path.read_text(encoding="utf-8"))}, None if name == "generate_cdsl_model": cdsl = arguments.get("cdsl") if isinstance(cdsl, str): cdsl = json.loads(cdsl) if not isinstance(cdsl, dict): raise ValueError("generate_cdsl_model requires a CDSL JSON object") summary = str(arguments.get("summary") or "CDSL CAD model") yieldable = await asyncio.to_thread( build_revision, settings=self.settings, store=self.store, task_id=task_id or None, request=request, cdsl=cdsl, reference_ids=list(references), summary=summary, ) return {"ok": True, "summary": summary, "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"]}, yieldable raise ValueError(f"Unknown agent tool: {name}") @staticmethod def _chunks(text: str) -> list[str]: return [text[index:index + 96] for index in range(0, len(text), 96)] @staticmethod def _tool_label(name: str) -> str: return { "search_cdsl_library": "检索 CDSL 模型库", "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", "generate_cdsl_model": "生成 CDSL CAD 模型", }.get(name, "调用 CAD 工具") def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str: attachments = conversation.get("attachments") or [] if not attachments: return "" content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request."}] for attachment in attachments: if not isinstance(attachment, dict): continue kind = str(attachment.get("kind") or "") task_id = str(attachment.get("task_id") or "") relative = str(attachment.get("path") or "") if not task_id or not relative: continue path = self.store.artifact_path(task_id, relative) if kind == "image": if not model.vision: raise ValueError("The selected model does not support images. Choose a vision-capable OpenAI or Kimi model.") mime = str(attachment.get("mime") or "image/png") encoded = base64.b64encode(path.read_bytes()).decode("ascii") content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}}) elif kind == "document": extracted = str(attachment.get("extracted_path") or "") if extracted: text_path = self.store.artifact_path(task_id, extracted) text = text_path.read_text(encoding="utf-8")[:30_000] content.append({"type": "text", "text": f"Document {attachment.get('name')}:\n{text}"}) return content