优化agent

This commit is contained in:
2026-08-25 11:01:59 +08:00
parent 9f8a55ff07
commit 51575161dc
34 changed files with 2687 additions and 456 deletions
+10 -7
View File
@@ -1,19 +1,22 @@
# Default provider. Only providers with an API key are exposed to the UI. # Default provider. Only providers with an API key are exposed to the UI.
CDSL_DEFAULT_PROVIDER=deepseek CDSL_DEFAULT_PROVIDER=deepseek
CDSL_DEFAULT_MODEL=deepseek-chat CDSL_DEFAULT_MODEL=deepseek-v4-flash-vision-exp
# CDSL_DEFAULT_PROVIDER=openai
# CDSL_DEFAULT_MODEL=gpt-5.5
# DeepSeek. Fill in your own API key below. # DeepSeek. Fill in your own API key below.
CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 CDSL_LLM_BASE_URL=https://api.deepseek.com/v1
CDSL_LLM_API_KEY=sk-d3f8fe84bf9a4100a6563559e5d2fefd CDSL_LLM_API_KEY=sk-d3f8fe84bf9a4100a6563559e5d2fefd
CDSL_LLM_MODEL=deepseek-chat CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp
CDSL_LLM_TIMEOUT_S=90 CDSL_LLM_TIMEOUT_S=90
CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp
# Optional OpenAI provider. Comma-separate enabled models; list vision models # Optional OpenAI provider. Comma-separate enabled models; list vision models
# separately so image attachments can be routed safely. # separately so image attachments can be routed safely.
CDSL_OPENAI_BASE_URL=https://api.openai.com/v1 CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1
CDSL_OPENAI_API_KEY= CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229
CDSL_OPENAI_MODELS=gpt-4.1,gpt-4.1-mini CDSL_OPENAI_MODELS=gpt-5.5
CDSL_OPENAI_VISION_MODELS=gpt-4.1,gpt-4.1-mini CDSL_OPENAI_VISION_MODELS=gpt-5.5
# Optional Kimi provider. # Optional Kimi provider.
CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1 CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1
@@ -24,7 +27,7 @@ CDSL_KIMI_VISION_MODELS=
# OpenAI-compatible function schemas. This affects generate_cdsl_model's JSON # OpenAI-compatible function schemas. This affects generate_cdsl_model's JSON
# arguments only, never ordinary assistant chat text. # arguments only, never ordinary assistant chat text.
CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false
CDSL_OPENAI_STRICT_TOOL_SCHEMA=true CDSL_OPENAI_STRICT_TOOL_SCHEMA=false
CDSL_KIMI_STRICT_TOOL_SCHEMA=true CDSL_KIMI_STRICT_TOOL_SCHEMA=true
# To enable individual models instead of every model from a provider, keep the # To enable individual models instead of every model from a provider, keep the
# provider-wide switch false and list exact IDs, for example: # provider-wide switch false and list exact IDs, for example:
+9 -7
View File
@@ -13,15 +13,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine.
1. Use any injected CDSL part-skill bridge to establish the part structure, 1. Use any injected CDSL part-skill bridge to establish the part structure,
parameter roles, and feature order. It is planning guidance only. parameter roles, and feature order. It is planning guidance only.
2. Submit a complete DesignIntent before reading CDSL references or generating 2. Record a concise natural-language design brief before reading CDSL references
CDSL. It contains semantic structures and mappings, never sketch coordinates or generating CDSL. The brief captures the intended structures, dependency
or raw CAD code. The backend records canonical selected part skills. order, key dimensions, and explicit assumptions. It is reference context
only, never an executable CAD contract.
3. Read the engine README, `profile_schema.json`, and `cdsl_schema.json`, then 3. Read the engine README, `profile_schema.json`, and `cdsl_schema.json`, then
search the official CDSL library for schema-valid profiles and feature search the official CDSL library for schema-valid profiles and feature
expressions. `cdsl_schema.json` is the executable input contract. expressions. `cdsl_schema.json` is the executable input contract.
4. Produce parameterized CDSL in feature dependency order, using the accepted 4. Produce parameterized CDSL in feature dependency order. CDSL is the sole
DesignIntent's feature IDs and mappings plus the part authoritative and executable model: choose its feature IDs, profiles,
plan for intent and CDSL references for concrete schema expressions. selectors, and schema-valid expressions directly from the CDSL contract.
5. Call the generation tool only when every chosen feature is executable by 5. Call the generation tool only when every chosen feature is executable by
the current schema and runtime. Validate through the `cdsl_only` path, the current schema and runtime. Validate through the `cdsl_only` path,
then generate STEP and GLB artifacts. then generate STEP and GLB artifacts.
@@ -29,7 +30,8 @@ Help an AI agent generate CAD models through the repository's CDSL engine.
## Part-skill boundary ## Part-skill boundary
- User requirements and the CDSL schema/runtime override bridge guidance; - User requirements and the CDSL schema/runtime override bridge guidance;
CDSL examples are lower-priority expression references. CDSL examples are lower-priority expression references. The textual design
brief is planning context and is never validated against the CDSL.
- Do not expose upstream source skills directly or treat them as code. Use - Do not expose upstream source skills directly or treat them as code. Use
only the injected CDSL bridge content. only the injected CDSL bridge content.
- Never emit build123d source or invent an atomic, profile, selector, or - Never emit build123d source or invent an atomic, profile, selector, or
@@ -1,40 +1,35 @@
# DesignIntent Planning Recipe # CDSL Planning Recipe
## Requirement analysis ## Requirement analysis
1. Treat the backend-injected part-family skill as the selected family before 1. Treat the backend-injected part-family skill as planning guidance only.
identifying structures. It provides planning guidance only. 2. Write a concise natural-language brief describing the requested structures,
2. Decompose the requested part into structures with roles: `base`, dependency order, key dimensions, and explicit assumptions.
`reference`, `additive`, `subtractive`, `dressup`, or `pattern`. 3. Ask the user one concise clarification question when an essential dimension
3. Every structure must state a purpose, structural dependencies, parameter or requested outcome is unknown. Do not silently fabricate it.
roles, selector roles, an executable CDSL atomic ID, and a named profile 4. Keep the brief free of feature IDs, selector bindings, profile bindings,
type when its atomic needs a sketch. sketch coordinates, raw CDSL, and Build123d source. Those implementation
4. Put every structure exactly once in `feature_order`, after its dependencies. choices belong only in the final CDSL document.
Its `cdsl_feature_id` is the immutable ID that the later CDSL feature must use. 5. The brief is not stored or validated as a CAD artifact. It is reference
5. Do not put coordinates, raw CDSL, Build123d, or low-level sketch data in a context for the model while it writes the one authoritative CDSL document.
DesignIntent.
6. Missing essential dimensions are blocking `open_questions`. Unsupported
geometry is a `capability_gaps` item. A blocking item requires
`status: needs_clarification`; do not approximate silently.
## Part family boundary ## Part family boundary
- Use only the backend-injected primary and support skills. Never submit - Use only the backend-injected primary and support skills. The backend records
`part_skill_ids`; the backend records the canonical selection for audit. the canonical selection for audit.
- Explicit user requirements override part skills. The executable CDSL - Explicit user requirements override part skills. The executable CDSL
schema/runtime overrides both part skills and library examples. schema/runtime overrides both part skills and library examples.
- The current primary family is inherited during revisions unless the user - The current primary family is inherited during revisions unless the user
explicitly asks to replace the whole part. A family conflict needs a concise explicitly asks to replace the whole part. A family conflict needs a concise
clarification before a ready plan can be submitted. clarification before a ready plan can be submitted.
- CDSL library examples show only expression patterns. They never add or remove - CDSL library examples show expression patterns only. They never override the
a requested DesignIntent structure. user's request or the CDSL schema/runtime.
## CDSL generation ## CDSL generation
- Submit `propose_design_intent` before any library or CDSL-generation call. - Submit `describe_design_intent` before any library or CDSL-generation call.
- After its ready result includes `intent_id`, inspect references and generate - After the text brief is returned, inspect references and generate complete,
complete CDSL. Use every planned `cdsl_feature_id` once, in `feature_order`. schema-valid CDSL. CDSL controls its own feature IDs, atomics, profiles, and
- Do not add undeclared CDSL features, delete planned features, change planned selectors; it is not checked for agreement with the brief.
atomics/profiles, or omit declared selector evidence.
- Keep `cdsl_only` as the only build path. Never use `compiler_context` or a - Keep `cdsl_only` as the only build path. Never use `compiler_context` or a
legacy fallback. legacy fallback.
+12 -16
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from typing import Any from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate
@@ -88,29 +88,32 @@ async def create_conversation() -> JSONResponse:
@app.patch("/v1/conversations/{conversation_id}") @app.patch("/v1/conversations/{conversation_id}")
async def patch_conversation(conversation_id: str, payload: ConversationPatch) -> JSONResponse: async def patch_conversation(conversation_id: str, payload: ConversationPatch) -> JSONResponse:
try: try:
record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id, payload.attachments) record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id)
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
return JSONResponse(record) return JSONResponse(record)
@app.post("/v1/uploads") @app.post("/v1/conversations/{conversation_id}/attachments")
async def upload_attachment( async def upload_conversation_attachment(
conversation_id: str,
file: UploadFile = File(...), file: UploadFile = File(...),
task_id: str | None = Form(default=None),
) -> JSONResponse: ) -> JSONResponse:
data = await file.read() data = await file.read()
filename = file.filename or "attachment" filename = file.filename or "attachment"
try: try:
conversation = safe_conversation_id(conversation_id)
if store.read_conversation(conversation) is None:
raise HTTPException(status_code=404, detail="Conversation not found")
kind = classify_upload(filename, file.content_type or "", len(data)) kind = classify_upload(filename, file.content_type or "", len(data))
task = store.ensure_task(safe_task_id(task_id) if task_id else None, f"Attachment: {filename}") relative_path, _ = store.write_conversation_upload(conversation, filename, data)
relative_path, _ = store.write_upload(task["task_id"], filename, data)
extracted_path = "" extracted_path = ""
if kind == "document": if kind == "document":
extracted_path = relative_path + ".txt" extracted_path = relative_path + ".txt"
extracted = extract_document_text(data) extracted = extract_document_text(data)
store.artifact_path(task["task_id"], extracted_path).write_text(extracted, encoding="utf-8") store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8")
record = attachment_record(task["task_id"], filename, file.content_type or "", relative_path, data, kind, extracted_path) record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path)
store.add_conversation_attachment(conversation, record)
return JSONResponse(record) return JSONResponse(record)
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
@@ -189,10 +192,6 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
safe_id = safe_task_id(task_id) safe_id = safe_task_id(task_id)
task = store.read_task(safe_id) task = store.read_task(safe_id)
current_revision_id = str((task or {}).get("current_revision") or "") current_revision_id = str((task or {}).get("current_revision") or "")
current_revision = next(
(item for item in (task or {}).get("revisions", []) if item.get("revision_id") == current_revision_id),
{},
)
current_path = store.current_cdsl_path(safe_id) current_path = store.current_cdsl_path(safe_id)
if not task or not current_path or not current_revision_id: if not task or not current_path or not current_revision_id:
raise ValueError("Task has no successful CDSL revision") raise ValueError("Task has no successful CDSL revision")
@@ -209,9 +208,6 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo
operation={"type": "parameter_update", "values": payload.values}, operation={"type": "parameter_update", "values": payload.values},
part_skills=None, part_skills=None,
generation_assumptions=[], generation_assumptions=[],
design_intent_id=str(current_revision.get("design_intent_id") or ""),
design_intent_path=str(current_revision.get("design_intent_path") or ""),
design_intent_status="accepted" if current_revision.get("design_intent_id") else "",
) )
return JSONResponse(result) return JSONResponse(result)
except ValueError as error: except ValueError as error:
-3
View File
@@ -28,7 +28,6 @@ class ChatRequest(BaseModel):
class ConversationPatch(BaseModel): class ConversationPatch(BaseModel):
current_task_id: str | None = None current_task_id: str | None = None
attachments: list[dict[str, Any]] | None = None
class ParameterUpdate(BaseModel): class ParameterUpdate(BaseModel):
@@ -57,8 +56,6 @@ class CadResult(BaseModel):
parameters_path: str | None = None parameters_path: str | None = None
selector_path: str | None = None selector_path: str | None = None
edges_path: str | None = None edges_path: str | None = None
design_intent_id: str | None = None
design_intent_path: str | None = None
summary: str summary: str
reference_ids: list[str] = Field(default_factory=list) reference_ids: list[str] = Field(default_factory=list)
engine: str = "cdsl_only" engine: str = "cdsl_only"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -34,10 +34,10 @@ def extract_document_text(data: bytes) -> str:
return text[:MAX_EXTRACTED_CHARS] return text[:MAX_EXTRACTED_CHARS]
def attachment_record(task_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]: def attachment_record(conversation_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]:
return { return {
"id": Path(relative_path).stem, "id": Path(relative_path).stem,
"task_id": task_id, "conversation_id": conversation_id,
"name": filename, "name": filename,
"kind": kind, "kind": kind,
"path": relative_path, "path": relative_path,
-7
View File
@@ -147,10 +147,6 @@ def apply_direct_edit(
revision_id = str((task or {}).get("current_revision") or "") revision_id = str((task or {}).get("current_revision") or "")
if source is None or not revision_id: if source is None or not revision_id:
raise ValueError("Task has no successful CDSL revision") raise ValueError("Task has no successful CDSL revision")
current_revision = next(
(item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id),
{},
)
frame = _selection_frame(selection) frame = _selection_frame(selection)
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8"))) cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
features = cdsl.setdefault("features", []) features = cdsl.setdefault("features", [])
@@ -190,7 +186,4 @@ def apply_direct_edit(
operation={"type": operation, "selection": selection, "parameters": parameters}, operation={"type": operation, "selection": selection, "parameters": parameters},
part_skills=None, part_skills=None,
generation_assumptions=[], generation_assumptions=[],
design_intent_id=str(current_revision.get("design_intent_id") or ""),
design_intent_path=str(current_revision.get("design_intent_path") or ""),
design_intent_status="accepted" if current_revision.get("design_intent_id") else "",
) )
+72 -2
View File
@@ -75,6 +75,76 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
raise ValueError(f"CDSL schema violation at {location}: {error.message}") raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None:
if isinstance(offset, bool) or not isinstance(offset, (int, float)):
return None
distance = float(offset)
definitions = {
"XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
"XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
"YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
}
definition = definitions.get(plane.upper())
if definition is None:
return None
origin, x_dir, normal = definition
return {"origin_mm": origin, "x_dir": x_dir, "normal": normal}
def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""Convert unambiguous legacy LLM aliases into the current CDSL dialect.
This intentionally does not infer dimensions, selectors, or feature
dependencies. Any non-mechanical error remains visible to the validator.
"""
normalized = copy.deepcopy(cdsl)
repairs: list[str] = []
geometry = normalized.get("geometry")
sketches = geometry.get("sketches") if isinstance(geometry, dict) else None
if isinstance(sketches, list):
for index, sketch in enumerate(sketches):
if not isinstance(sketch, dict):
continue
if "id" not in sketch and isinstance(sketch.get("sketch_id"), str):
sketch["id"] = sketch.pop("sketch_id")
repairs.append(f"geometry.sketches[{index}]: sketch_id -> id")
legacy_plane: Any = sketch.get("plane")
legacy_offset: Any = sketch.get("offset_mm", 0)
workplane_value = sketch.get("workplane")
if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value:
legacy_plane = workplane_value.get("plane")
legacy_offset = workplane_value.get("offset_mm", 0)
elif "workplane" in sketch:
continue
workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None
if workplane is None:
continue
sketch["workplane"] = workplane
sketch.pop("plane", None)
sketch.pop("offset_mm", None)
repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane")
features = normalized.get("features")
if isinstance(features, list):
for index, feature in enumerate(features):
if not isinstance(feature, dict):
continue
if "sketch_id" not in feature and isinstance(feature.get("sketch"), str):
feature["sketch_id"] = feature.pop("sketch")
repairs.append(f"features[{index}]: sketch -> sketch_id")
if "depends_on" not in feature:
feature["depends_on"] = []
repairs.append(f"features[{index}]: added empty depends_on")
params = feature.get("params")
axis = params.get("axis") if isinstance(params, dict) else None
if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis:
axis["origin_mm"] = axis.pop("point_mm")
repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm")
return normalized, repairs
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not isinstance(cdsl, dict): if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object") raise ValueError("CDSL must be a JSON object")
@@ -392,7 +462,7 @@ def build_revision(
summary: str, summary: str,
parent_revision_id: str | None = None, parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None, operation: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None, input_attachments: list[dict[str, Any]] | None = None,
part_skills: dict[str, Any] | None = None, part_skills: dict[str, Any] | None = None,
generation_assumptions: list[str] | None = None, generation_assumptions: list[str] | None = None,
design_intent: dict[str, Any] | None = None, design_intent: dict[str, Any] | None = None,
@@ -455,7 +525,7 @@ def build_revision(
"summary": summary, "summary": summary,
"parent_revision_id": parent_revision_id or "", "parent_revision_id": parent_revision_id or "",
"operation": operation or {}, "operation": operation or {},
"attachments": attachments or [], "input_attachments": input_attachments or [],
} }
if status == "success": if status == "success":
record.update({ record.update({
@@ -0,0 +1,273 @@
from __future__ import annotations
import copy
import math
import re
from typing import Any
TEMPLATE_ID = "flange_sleeve_v1"
# These defaults describe the pictured family, but the model may override every
# dimension that affects the visible form. The backend owns all CDSL plumbing.
DEFAULT_PLAN: dict[str, float | str] = {
"name": "Parameterized flange sleeve",
"flange_width_mm": 120.0,
"flange_height_mm": 120.0,
"flange_thickness_mm": 14.0,
"corner_chamfer_mm": 10.0,
"tube_outer_diameter_mm": 70.0,
"tube_straight_length_mm": 95.0,
"tip_outer_diameter_mm": 62.0,
"tip_length_mm": 28.0,
"bore_diameter_mm": 46.0,
"boss_outer_diameter_mm": 82.0,
"boss_height_mm": 5.0,
"mount_hole_diameter_mm": 12.0,
"mount_counterbore_diameter_mm": 24.0,
"mount_counterbore_depth_mm": 5.0,
"mount_hole_u_mm": 42.0,
"mount_hole_v_mm": 42.0,
}
_DIMENSION_FIELDS = tuple(key for key in DEFAULT_PLAN if key != "name")
_PART_ID = re.compile(r"^[A-Za-z0-9_-]{3,80}$")
def flange_sleeve_plan_schema() -> dict[str, Any]:
"""Return the compact semantic plan accepted by the flange-sleeve tool."""
properties: dict[str, Any] = {
"template": {"const": TEMPLATE_ID},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
"part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"},
}
for field in _DIMENSION_FIELDS:
minimum = 0 if field == "corner_chamfer_mm" else 0.001
properties[field] = {"type": "number", "exclusiveMinimum": minimum} if minimum else {
"type": "number", "minimum": 0,
}
return {
"type": "object",
"properties": properties,
"required": ["template"],
"additionalProperties": False,
}
def normalize_flange_sleeve_plan(plan: Any) -> dict[str, float | str]:
"""Validate only semantic dimensions; do not accept CDSL implementation data."""
if not isinstance(plan, dict):
raise ValueError("flange sleeve plan must be a JSON object")
if plan.get("template") != TEMPLATE_ID:
raise ValueError(f"flange sleeve plan template must be {TEMPLATE_ID}")
allowed = {"template", "part_id", *DEFAULT_PLAN}
unknown = sorted(str(key) for key in plan if key not in allowed)
if unknown:
raise ValueError(f"flange sleeve plan has unsupported fields: {', '.join(unknown)}")
normalized = copy.deepcopy(DEFAULT_PLAN)
for field in _DIMENSION_FIELDS:
if field not in plan:
continue
value = plan[field]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError(f"flange sleeve plan {field} must be a finite number")
number = float(value)
if number < 0 if field == "corner_chamfer_mm" else number <= 0:
comparator = "non-negative" if field == "corner_chamfer_mm" else "greater than zero"
raise ValueError(f"flange sleeve plan {field} must be {comparator}")
normalized[field] = number
name = str(plan.get("name") or normalized["name"]).strip()
if not name:
raise ValueError("flange sleeve plan name must not be empty")
normalized["name"] = name[:120]
part_id = str(plan.get("part_id") or "flange_sleeve_template")
if not _PART_ID.fullmatch(part_id):
raise ValueError("flange sleeve plan part_id must use letters, numbers, underscores, or hyphens")
normalized["part_id"] = part_id
_validate_dimensions(normalized)
return normalized
def _validate_dimensions(plan: dict[str, float | str]) -> None:
number = lambda field: float(plan[field])
width, height = number("flange_width_mm"), number("flange_height_mm")
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
tube_od, tip_od, bore = number("tube_outer_diameter_mm"), number("tip_outer_diameter_mm"), number("bore_diameter_mm")
boss_od = number("boss_outer_diameter_mm")
hole_od, counterbore_od = number("mount_hole_diameter_mm"), number("mount_counterbore_diameter_mm")
counterbore_depth = number("mount_counterbore_depth_mm")
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
if chamfer * 2 >= min(width, height):
raise ValueError("flange sleeve plan corner_chamfer_mm must be less than half the flange side")
if not bore < min(tube_od, tip_od):
raise ValueError("flange sleeve plan bore_diameter_mm must be smaller than both tube diameters")
if not tube_od <= boss_od <= min(width, height):
raise ValueError("flange sleeve plan boss_outer_diameter_mm must be between tube diameter and flange side")
if counterbore_od < hole_od:
raise ValueError("flange sleeve plan mount_counterbore_diameter_mm must not be smaller than mount_hole_diameter_mm")
if counterbore_depth > thickness:
raise ValueError("flange sleeve plan mount_counterbore_depth_mm must not exceed flange_thickness_mm")
radius = counterbore_od / 2
if abs(hole_u) + radius >= width / 2 or abs(hole_v) + radius >= height / 2:
raise ValueError("flange sleeve plan mounting counterbores must remain inside the flange boundary")
if chamfer and abs(hole_u) > width / 2 - chamfer and abs(hole_v) > height / 2 - chamfer:
edge_clearance = (width / 2 - abs(hole_u)) + (height / 2 - abs(hole_v))
if edge_clearance < chamfer + radius * math.sqrt(2):
raise ValueError("flange sleeve plan mounting counterbores intersect the corner chamfers")
def _x_plane(offset_mm: float, normal_x: float = 1.0) -> dict[str, list[float]]:
return {
"origin_mm": [offset_mm, 0.0, 0.0],
"x_dir": [0.0, 1.0, 0.0],
"normal": [normal_x, 0.0, 0.0],
}
def _flange_profile(width: float, height: float, chamfer: float) -> dict[str, Any]:
if chamfer == 0:
return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}
half_width, half_height = width / 2, height / 2
return {
"type": "polygon",
"vertices": [
[-half_width + chamfer, -half_height],
[half_width - chamfer, -half_height],
[half_width, -half_height + chamfer],
[half_width, half_height - chamfer],
[half_width - chamfer, half_height],
[-half_width + chamfer, half_height],
[-half_width, half_height - chamfer],
[-half_width, -half_height + chamfer],
],
}
def build_flange_sleeve_cdsl(plan: Any) -> tuple[dict[str, Any], dict[str, float | str]]:
"""Build a schema-valid CDSL flange sleeve from semantic, editable parameters."""
values = normalize_flange_sleeve_plan(plan)
number = lambda field: float(values[field])
width, height = number("flange_width_mm"), number("flange_height_mm")
thickness, chamfer = number("flange_thickness_mm"), number("corner_chamfer_mm")
tube_radius, tip_radius, bore_radius = number("tube_outer_diameter_mm") / 2, number("tip_outer_diameter_mm") / 2, number("bore_diameter_mm") / 2
straight, tip_length = number("tube_straight_length_mm"), number("tip_length_mm")
boss_radius, boss_height = number("boss_outer_diameter_mm") / 2, number("boss_height_mm")
hole_radius, counterbore_radius = number("mount_hole_diameter_mm") / 2, number("mount_counterbore_diameter_mm") / 2
hole_u, hole_v = number("mount_hole_u_mm"), number("mount_hole_v_mm")
hole_centers = [[u, v] for u in (-hole_u, hole_u) for v in (-hole_v, hole_v)]
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.3.0",
"part_id": str(values["part_id"]),
"kind": "part",
"meta": {"name": str(values["name"]), "units": "mm"},
"geometry": {"sketches": [
{
"id": "flange_outline",
"workplane": _x_plane(-thickness),
"profile": _flange_profile(width, height, chamfer),
},
{
"id": "sleeve_profile",
"workplane": {
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
},
"profile": {
"type": "polygon",
"vertices": [
[0.0, bore_radius],
[0.0, tube_radius],
[straight, tube_radius],
[straight + tip_length, tip_radius],
[straight + tip_length, bore_radius],
],
},
},
{
"id": "front_boss_ring",
"workplane": _x_plane(0.0),
"profile": {
"type": "annulus",
"inner_radius_mm": bore_radius,
"outer_radius_mm": boss_radius,
"center": [0.0, 0.0],
},
},
{
"id": "center_bore",
"workplane": _x_plane(-thickness),
"profile": {"type": "circle", "radius_mm": bore_radius, "center": [0.0, 0.0]},
},
{
"id": "mount_holes",
"workplane": _x_plane(-thickness),
"profile": {
"type": "circles",
"items": [{"radius_mm": hole_radius, "center": center} for center in hole_centers],
},
},
{
"id": "mount_counterbores",
"workplane": _x_plane(0.0, -1.0),
"profile": {
"type": "circles",
"items": [{"radius_mm": counterbore_radius, "center": center} for center in hole_centers],
},
},
]},
"features": [
{
"id": "flange_plate",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"sketch_id": "flange_outline",
"params": {"distance_mm": thickness},
},
{
"id": "sleeve_body",
"atomic_id": "revolve_add",
"depends_on": ["flange_plate"],
"sketch_id": "sleeve_profile",
"params": {
"angle_deg": 360.0,
"axis": {"origin_mm": [0.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]},
},
},
{
"id": "front_boss",
"atomic_id": "extrude_add_blind",
"depends_on": ["flange_plate", "sleeve_body"],
"sketch_id": "front_boss_ring",
"params": {"distance_mm": boss_height},
},
{
"id": "center_bore_cut",
"atomic_id": "extrude_cut_blind",
"depends_on": ["flange_plate", "sleeve_body", "front_boss"],
"sketch_id": "center_bore",
"params": {"distance_mm": thickness + straight + tip_length + boss_height + 1.0},
},
{
"id": "mount_hole_cuts",
"atomic_id": "extrude_cut_blind",
"depends_on": ["center_bore_cut"],
"sketch_id": "mount_holes",
"params": {"distance_mm": thickness + 1.0},
},
{
"id": "mount_counterbore_cuts",
"atomic_id": "extrude_cut_blind",
"depends_on": ["mount_hole_cuts"],
"sketch_id": "mount_counterbores",
"params": {"distance_mm": number("mount_counterbore_depth_mm")},
},
],
}
return cdsl, values
+2 -2
View File
@@ -207,8 +207,8 @@ class PartSkillLibrary:
revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id] revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
if revision_ids: if revision_ids:
return revision_ids return revision_ids
# A valid DesignIntent creates a task before the first runtime build. # Legacy tasks can contain a DesignIntent created before their first
# Its canonical backend selection must therefore be inheritable too. # runtime build. Retain this fallback only for historical artifacts.
intent_id = str((task or {}).get("current_design_intent_id") or "") intent_id = str((task or {}).get("current_design_intent_id") or "")
intent = next( intent = next(
(item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id), (item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id),
+48 -8
View File
@@ -88,11 +88,26 @@ class WorkspaceStore:
write_json(path, payload) write_json(path, payload)
return (Path(conversation) / relative).as_posix() return (Path(conversation) / relative).as_posix()
def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str:
"""Retain a parsed model candidate before validation or execution."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, cdsl)
return (Path(conversation) / relative).as_posix()
def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
"""Persist the reason a retained CDSL candidate was rejected."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def ensure_conversation( def ensure_conversation(
self, self,
conversation_id: str | None, conversation_id: str | None,
current_task_id: str | None = None, current_task_id: str | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv") cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv")
path = self.conversation_path(cid) path = self.conversation_path(cid)
@@ -102,21 +117,18 @@ class WorkspaceStore:
if current_task_id: if current_task_id:
current["current_task_id"] = safe_task_id(current_task_id) current["current_task_id"] = safe_task_id(current_task_id)
changed = True changed = True
if attachments is not None:
current["attachments"] = attachments
changed = True
if changed: if changed:
current["updated_at"] = now_iso() current["updated_at"] = now_iso()
write_json(path, current) write_json(path, current)
return current return current
record = { record = {
"schema_version": "1.1", "schema_version": "1.2",
"conversation_id": cid, "conversation_id": cid,
"created_at": now_iso(), "created_at": now_iso(),
"updated_at": now_iso(), "updated_at": now_iso(),
"current_task_id": safe_task_id(current_task_id) if current_task_id else "", "current_task_id": safe_task_id(current_task_id) if current_task_id else "",
"messages": [], "messages": [],
"attachments": attachments or [], "attachments": [],
} }
write_json(path, record) write_json(path, record)
return record return record
@@ -135,14 +147,34 @@ class WorkspaceStore:
write_json(self.conversation_path(record["conversation_id"]), record) write_json(self.conversation_path(record["conversation_id"]), record)
return record return record
def write_upload(self, task_id: str, filename: str, data: bytes) -> tuple[str, Path]: def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]:
conversation = safe_conversation_id(conversation_id)
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment" safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}" relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}"
target = self.artifact_path(task_id, relative.as_posix()) target = self.conversation_attachment_path(conversation, relative.as_posix())
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data) target.write_bytes(data)
return relative.as_posix(), target return relative.as_posix(), target
def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]:
conversation = safe_conversation_id(conversation_id)
record = self.read_conversation(conversation)
if record is None:
raise ValueError("Conversation not found")
if str(attachment.get("conversation_id") or "") != conversation:
raise ValueError("Attachment does not belong to this conversation")
attachment_id = str(attachment.get("id") or "")
if not attachment_id:
raise ValueError("Attachment id is required")
self.conversation_attachment_path(conversation, str(attachment.get("path") or ""))
attachments = record.setdefault("attachments", [])
if any(str(item.get("id") or "") == attachment_id for item in attachments if isinstance(item, dict)):
raise ValueError("Attachment already exists")
attachments.append(attachment)
record["updated_at"] = now_iso()
write_json(self.conversation_path(conversation), record)
return record
def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]: def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]:
tid = safe_task_id(task_id) if task_id else new_id("cad") tid = safe_task_id(task_id) if task_id else new_id("cad")
path = self.task_path(tid) path = self.task_path(tid)
@@ -274,3 +306,11 @@ class WorkspaceStore:
if root != target and root not in target.parents: if root != target and root not in target.parents:
raise ValueError("Artifact path escapes task directory") raise ValueError("Artifact path escapes task directory")
return target return target
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.conversation_dir(conversation_id).resolve()
target = (root / safe).resolve()
if root != target and root not in target.parents:
raise ValueError("Attachment path escapes conversation directory")
return target
+2 -2
View File
@@ -99,8 +99,8 @@ def validate_design_intent(
raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id") raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id")
if current_revision_id and base_revision_id != current_revision_id: if current_revision_id and base_revision_id != current_revision_id:
raise DesignIntentError( raise DesignIntentError(
"INVALID_DESIGN_INTENT", "DESIGN_INTENT_BASE_REVISION_MISMATCH",
"revise DesignIntent base_revision_id must be the current successful revision", f"revise DesignIntent base_revision_id must be {current_revision_id}, the current successful revision",
) )
structures = normalized["structures"] structures = normalized["structures"]
@@ -0,0 +1,212 @@
"""Deterministic GenerationSpec -> CDSL compilers.
The LLM supplies semantic parameters and feature intent. This module owns
the CDSL details so tool calls do not need to contain sketch IDs, selectors, or
runtime-specific parameter wrappers.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any, Protocol
from .generation_spec import GenerationSpecError, validate_generation_spec
def _workplane(z: float = 0.0, normal: list[float] | None = None) -> dict[str, list[float]]:
return {
"origin_mm": [0.0, 0.0, float(z)],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": list(normal or [0.0, 0.0, 1.0]),
}
def _value(spec: dict[str, Any], name: str, default: float | int | str) -> Any:
parameter = (spec.get("parameters") or {}).get(name)
if isinstance(parameter, dict) and "value" in parameter:
return parameter["value"]
return default
def _number(spec: dict[str, Any], name: str, default: float) -> float:
value = _value(spec, name, default)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise GenerationSpecError(f"Parameter {name} must be numeric", f"$.parameters.{name}.value")
number = float(value)
if not number == number or number in {float("inf"), float("-inf")}:
raise GenerationSpecError(f"Parameter {name} must be finite", f"$.parameters.{name}.value")
return number
def _integer(spec: dict[str, Any], name: str, default: int) -> int:
value = _number(spec, name, default)
if int(value) != value:
raise GenerationSpecError(f"Parameter {name} must be an integer", f"$.parameters.{name}.value")
return int(value)
def _positive(number: float, name: str) -> float:
if number <= 0:
raise GenerationSpecError(f"Parameter {name} must be greater than zero", f"$.parameters.{name}.value")
return number
def _acceptance(spec: dict[str, Any], generated: list[dict[str, Any]]) -> list[dict[str, Any]]:
existing = copy.deepcopy(spec.get("acceptance") or [])
known = {str(item.get("id")) for item in existing}
for item in generated:
if item["id"] not in known:
existing.append(item)
known.add(item["id"])
return existing
@dataclass(frozen=True)
class CompiledGeneration:
cdsl: dict[str, Any]
acceptance: list[dict[str, Any]]
provenance: dict[str, Any]
assumptions: list[str]
approximations: list[dict[str, Any]]
class Compiler(Protocol):
family: str
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
...
class MountingPlateCompiler:
family = "mounting_plate"
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
width = _positive(_number(spec, "width", 100.0), "width")
height = _positive(_number(spec, "height", 60.0), "height")
thickness = _positive(_number(spec, "thickness", 6.0), "thickness")
hole_diameter = _positive(_number(spec, "hole_diameter", 4.5), "hole_diameter")
hole_offset = _positive(_number(spec, "hole_edge_offset", 10.0), "hole_edge_offset")
hole_count = _integer(spec, "hole_count", 4)
counterbore_diameter = _positive(_number(spec, "counterbore_diameter", hole_diameter * 2), "counterbore_diameter")
counterbore_depth = _positive(_number(spec, "counterbore_depth", min(thickness / 2, 3.0)), "counterbore_depth")
slot_length = _positive(_number(spec, "slot_length", 20.0), "slot_length")
slot_width = _positive(_number(spec, "slot_width", 12.0), "slot_width")
if hole_count != 4:
raise GenerationSpecError("mounting_plate compiler currently requires four corner holes", "$.parameters.hole_count.value")
if counterbore_diameter <= hole_diameter:
raise GenerationSpecError("counterbore_diameter must exceed hole_diameter", "$.parameters.counterbore_diameter.value")
if counterbore_depth > thickness:
raise GenerationSpecError("counterbore_depth must not exceed thickness", "$.parameters.counterbore_depth.value")
if hole_offset * 2 >= min(width, height):
raise GenerationSpecError("hole_edge_offset leaves no usable plate area", "$.parameters.hole_edge_offset.value")
if slot_length < slot_width:
raise GenerationSpecError("slot_length must be at least slot_width", "$.parameters.slot_length.value")
centers = [
[-width / 2 + hole_offset, -height / 2 + hole_offset],
[width / 2 - hole_offset, -height / 2 + hole_offset],
[width / 2 - hole_offset, height / 2 - hole_offset],
[-width / 2 + hole_offset, height / 2 - hole_offset],
]
frame = _workplane(thickness)
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "generation-spec-preflight",
"meta": {"unit": "mm", "name": str(spec["part"]["name"])},
"geometry": {"sketches": [
{"id": "base_plate", "workplane": _workplane(), "profile": {
"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height,
}},
{"id": "mounting_holes", "workplane": frame, "profile": {
"type": "circles", "items": [{"center": center, "radius_mm": hole_diameter / 2} for center in centers],
}},
{"id": "center_slot", "workplane": frame, "profile": {
"type": "obround", "center": [0.0, 0.0], "length_mm": slot_length, "width_mm": slot_width,
}},
]},
"features": [
{"id": "base_plate", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_plate", "params": {"distance_mm": thickness}},
{"id": "mounting_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_plate"], "sketch_id": "mounting_holes", "params": {"distance_mm": thickness, "reverse": True}},
{"id": "mounting_counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mounting_holes"], "sketch_id": "mounting_holes", "params": {
"diameter_mm": hole_diameter, "depth_mm": thickness, "counterbore_diameter_mm": counterbore_diameter,
"counterbore_depth_mm": counterbore_depth, "positions": [{"mm": [x, y, 0.0]} for x, y in centers], "host_face": {"frame": frame},
}},
{"id": "center_slot", "atomic_id": "extrude_cut_blind", "depends_on": ["mounting_counterbores"], "sketch_id": "center_slot", "params": {"distance_mm": thickness, "reverse": True}},
],
}
assumptions = [str(item) for item in spec.get("assumptions") or []]
if "hole_diameter" not in (spec.get("parameters") or {}):
assumptions.append("M4 clearance hole defaults to 4.5 mm")
if "counterbore_depth" not in (spec.get("parameters") or {}):
assumptions.append("Counterbore depth defaults to the lesser of 3 mm and half the plate thickness")
acceptance = _acceptance(spec, [
{"id": "overall_bbox", "type": "bbox", "expected": [width, height, thickness], "tolerance": 0.1, "severity": "blocking"},
{"id": "mounting_hole_count", "type": "feature_count", "feature": "mounting_holes", "expected": 4, "tolerance": 0, "severity": "blocking"},
{"id": "mounting_hole_diameter", "type": "hole_diameter", "feature": "mounting_holes", "expected": hole_diameter, "tolerance": 0.05, "severity": "blocking"},
{"id": "center_slot_size", "type": "overall_length", "feature": "center_slot", "expected": slot_length, "tolerance": 0.1, "severity": "blocking"},
])
provenance = {
"compiler": self.family,
"compiler_version": "1.0",
"references": references,
"feature_sources": {item["id"]: item["id"] for item in spec.get("features") or []},
"parameter_paths": {name: f"parameters.{name}.value" for name in (spec.get("parameters") or {})},
}
return CompiledGeneration(cdsl, acceptance, provenance, list(dict.fromkeys(assumptions)), list(spec.get("approximations") or []))
class FlangeSleeveCompiler:
family = "flange_sleeve"
def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration:
from app.services.flange_sleeve_template import build_flange_sleeve_cdsl, TEMPLATE_ID
aliases = {
"width": "flange_width_mm", "height": "flange_height_mm", "thickness": "flange_thickness_mm",
"tube_outer_diameter": "tube_outer_diameter_mm", "bore_diameter": "bore_diameter_mm",
"hole_diameter": "mount_hole_diameter_mm", "counterbore_diameter": "mount_counterbore_diameter_mm",
"counterbore_depth": "mount_counterbore_depth_mm", "hole_offset_x": "mount_hole_u_mm", "hole_offset_y": "mount_hole_v_mm",
}
plan: dict[str, Any] = {"template": TEMPLATE_ID, "name": spec["part"]["name"]}
for source, target in aliases.items():
if source in (spec.get("parameters") or {}):
plan[target] = _value(spec, source, 0)
cdsl, values = build_flange_sleeve_cdsl(plan)
acceptance = _acceptance(spec, [
{"id": "flange_bbox", "type": "bbox", "expected": [float(values["flange_width_mm"]), float(values["flange_height_mm"]), float(values["flange_thickness_mm"])], "tolerance": 0.1, "severity": "blocking"},
])
provenance = {"compiler": self.family, "compiler_version": "1.0", "template_id": TEMPLATE_ID, "references": references}
assumptions = list(dict.fromkeys([*map(str, spec.get("assumptions") or []), "Flange sleeve dimensions use the verified parameterized template defaults for omitted fields."]))
return CompiledGeneration(cdsl, acceptance, provenance, assumptions, list(spec.get("approximations") or []))
COMPILERS: dict[str, Compiler] = {
"mounting_plate": MountingPlateCompiler(),
"flange_sleeve": FlangeSleeveCompiler(),
}
def compiler_for(family: str) -> Compiler:
try:
return COMPILERS[family]
except KeyError as error:
raise GenerationSpecError(f"No GenerationSpec compiler registered for part family: {family}", "$.part.family") from error
def compile_generation_spec(spec: dict[str, Any], references: list[dict[str, Any]] | None = None) -> dict[str, Any]:
normalized = validate_generation_spec(spec)
compiler = compiler_for(str(normalized["part"]["family"]))
compiled = compiler.compile(normalized, references or [])
return {
"spec": normalized,
"cdsl": compiled.cdsl,
"acceptance": compiled.acceptance,
"provenance": compiled.provenance,
"assumptions": compiled.assumptions,
"approximations": compiled.approximations,
}
@@ -0,0 +1,177 @@
"""Validation and normalization for the semantic GenerationSpec contract."""
from __future__ import annotations
import copy
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
KNOWN_PART_FAMILIES = frozenset({
"mounting_plate",
"flange",
"flange_sleeve",
"simple_shaft",
"bearing_housing",
"mounting_bracket",
"hex_nut",
"slotted_plate",
})
KNOWN_FEATURE_KINDS = frozenset({
"base_extrusion",
"base_revolve",
"boss",
"through_hole",
"blind_hole",
"counterbored_hole",
"countersunk_hole",
"hole_pattern",
"counterbored_hole_pattern",
"obround_cut",
"pocket",
"revolve_profile",
"coaxial_bore",
"fillet",
"chamfer",
})
class GenerationSpecError(ValueError):
"""A stable, user-repairable GenerationSpec validation error."""
def __init__(self, message: str, path: str = "$") -> None:
super().__init__(message)
self.path = path
@lru_cache(maxsize=1)
def generation_spec_schema() -> dict[str, Any]:
path = Path(__file__).with_name("generation_spec_schema.json")
schema = json.loads(path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
return schema
def _schema_error(spec: dict[str, Any]) -> GenerationSpecError | None:
errors = sorted(
Draft202012Validator(generation_spec_schema()).iter_errors(spec),
key=lambda error: (list(error.absolute_path), error.message),
)
if not errors:
return None
error = errors[0]
location = "$" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
return GenerationSpecError(error.message, location)
def _parameter_value(spec: dict[str, Any], name: str) -> Any:
value = (spec.get("parameters") or {}).get(name)
if not isinstance(value, dict):
raise GenerationSpecError(f"Unknown parameter: {name}", f"$.parameters.{name}")
return value.get("value")
def _numeric(value: Any, path: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise GenerationSpecError("Expected a finite numeric value", path)
number = float(value)
if number != number or number in {float("inf"), float("-inf")}:
raise GenerationSpecError("Expected a finite numeric value", path)
return number
def _check_constraints(spec: dict[str, Any]) -> None:
for index, constraint in enumerate(spec.get("constraints") or []):
path = f"$.constraints[{index}]"
kind = constraint.get("type")
if kind in {"less_than", "less_equal", "greater_than", "greater_equal", "equal"}:
left = _numeric(_parameter_value(spec, str(constraint.get("left") or "")), f"{path}.left")
right_name = constraint.get("right")
right = _numeric(_parameter_value(spec, str(right_name)), f"{path}.right") if right_name else _numeric(constraint.get("value"), f"{path}.value")
ok = {
"less_than": left < right,
"less_equal": left <= right,
"greater_than": left > right,
"greater_equal": left >= right,
"equal": abs(left - right) <= 1e-9,
}[kind]
if not ok:
raise GenerationSpecError(constraint.get("message") or f"Constraint {kind} failed", path)
def _check_graph(spec: dict[str, Any]) -> None:
features = spec.get("features") or []
ids = [str(item.get("id")) for item in features]
if len(ids) != len(set(ids)):
raise GenerationSpecError("Feature ids must be unique", "$.features")
known: set[str] = set()
for index, feature in enumerate(features):
feature_id = str(feature.get("id"))
kind = str(feature.get("kind"))
if kind not in KNOWN_FEATURE_KINDS:
raise GenerationSpecError(f"Unsupported feature kind: {kind}", f"$.features[{index}].kind")
for dependency in feature.get("depends_on") or []:
if dependency not in known:
raise GenerationSpecError(
f"Feature {feature_id} has a forward or missing dependency: {dependency}",
f"$.features[{index}].depends_on",
)
known.add(feature_id)
acceptance_ids = {str(item.get("id")) for item in spec.get("acceptance") or []}
if len(acceptance_ids) != len(spec.get("acceptance") or []):
raise GenerationSpecError("Acceptance ids must be unique", "$.acceptance")
for index, item in enumerate(spec.get("acceptance") or []):
feature = item.get("feature")
if feature and feature not in known:
raise GenerationSpecError(f"Acceptance refers to missing feature: {feature}", f"$.acceptance[{index}].feature")
def normalize_generation_spec(spec: dict[str, Any], *, request: str = "") -> dict[str, Any]:
if not isinstance(spec, dict):
raise GenerationSpecError("GenerationSpec must be a JSON object")
normalized = copy.deepcopy(spec)
normalized.setdefault("schema", "cad.generation-spec.v1")
normalized.setdefault("schema_version", "1.0")
normalized.setdefault("mode", "create")
normalized.setdefault("base_revision_id", "")
normalized.setdefault("parameters", {})
normalized.setdefault("features", [])
normalized.setdefault("constraints", [])
normalized.setdefault("acceptance", [])
normalized.setdefault("assumptions", [])
normalized.setdefault("approximations", [])
normalized.setdefault("references", [])
normalized.setdefault("patch_intent", request)
return normalized
def validate_generation_spec(
spec: dict[str, Any],
*,
known_families: set[str] | frozenset[str] = KNOWN_PART_FAMILIES,
) -> dict[str, Any]:
normalized = normalize_generation_spec(spec)
error = _schema_error(normalized)
if error:
raise error
family = str(normalized["part"]["family"])
if family not in known_families:
raise GenerationSpecError(f"Unsupported part family: {family}", "$.part.family")
for name, parameter in normalized["parameters"].items():
source = parameter["source"]
if source == "user" and not parameter["locked"]:
raise GenerationSpecError("User parameters must be locked", f"$.parameters.{name}.locked")
if source == "image_estimate" and not parameter.get("assumption"):
raise GenerationSpecError("Image estimates require an assumption", f"$.parameters.{name}.assumption")
_check_graph(normalized)
_check_constraints(normalized)
return normalized
@@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cdsl.local/schema/cad.generation-spec.v1",
"title": "CDSL GenerationSpec",
"type": "object",
"additionalProperties": false,
"properties": {
"schema": {"const": "cad.generation-spec.v1"},
"schema_version": {"const": "1.0"},
"mode": {"enum": ["create", "revise"]},
"base_revision_id": {"type": "string"},
"patch_intent": {"type": "string"},
"part": {"$ref": "#/$defs/part"},
"parameters": {"type": "object", "additionalProperties": {"$ref": "#/$defs/parameter"}},
"features": {"type": "array", "items": {"$ref": "#/$defs/feature"}},
"constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}},
"acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}},
"assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}},
"approximations": {"type": "array", "items": {"$ref": "#/$defs/approximation"}},
"references": {"type": "array", "items": {"type": "object"}}
},
"required": ["schema", "schema_version", "mode", "base_revision_id", "part", "parameters", "features", "constraints", "acceptance", "assumptions", "approximations", "references"],
"$defs": {
"part": {
"type": "object",
"additionalProperties": false,
"properties": {
"family": {"type": "string", "minLength": 1},
"name": {"type": "string", "minLength": 1},
"units": {"const": "mm"},
"coordinate_system": {"type": "string", "minLength": 1}
},
"required": ["family", "name", "units", "coordinate_system"]
},
"parameter": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {},
"unit": {"enum": ["mm", "deg", "rad", "count", ""]},
"source": {"enum": ["user", "technical_drawing", "image_estimate", "template_default", "derived_standard", "assumption"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"locked": {"type": "boolean"},
"assumption": {"type": "string"}
},
"required": ["value", "unit", "source", "confidence", "locked", "assumption"]
},
"feature": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"kind": {"type": "string", "minLength": 1},
"depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"parameters": {"type": "object"},
"semantic_role": {"type": "string"}
},
"required": ["id", "kind", "depends_on", "parameters"]
},
"constraint": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {"enum": ["less_than", "less_equal", "greater_than", "greater_equal", "equal", "inside_boundary", "symmetric", "same_axis", "distance", "count"]},
"left": {"type": "string"},
"right": {"type": "string"},
"value": {},
"feature": {"type": "string"},
"margin": {"type": "number"},
"ratio": {"type": "number"},
"message": {"type": "string"}
},
"required": ["type"]
},
"acceptance": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"type": {"enum": ["bbox", "solid_count", "feature_count", "hole_diameter", "hole_center", "hole_spacing", "wall_thickness", "overall_length", "overall_diameter", "coaxiality", "symmetry", "through_condition", "blind_depth"]},
"feature": {"type": "string"},
"expected": {},
"tolerance": {"type": "number", "minimum": 0},
"severity": {"enum": ["blocking", "warning", "informational"]}
},
"required": ["id", "type", "expected", "tolerance", "severity"]
},
"approximation": {
"type": "object",
"additionalProperties": false,
"properties": {
"request": {"type": "string", "minLength": 1},
"status": {"enum": ["approximated", "expanded", "omitted", "blocked"]},
"translation": {"type": "string", "minLength": 1},
"reason": {"type": "string", "minLength": 1}
},
"required": ["request", "status", "translation", "reason"]
}
}
}
+493 -4
View File
@@ -5,14 +5,59 @@ import json
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from app.models.contracts import ChatMessage, MessagePart from app.models.contracts import ChatMessage, MessagePart
from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, parse_tool_arguments, response_language_instruction, tools_for_model, user_visible_error_message from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, normalize_image_analysis, parse_tool_arguments, response_language_instruction, tools_for_model, use_flange_sleeve_template, user_visible_error_message
from app.services.library import CdslLibrary from app.services.library import CdslLibrary
from app.services.storage import WorkspaceStore from app.services.storage import WorkspaceStore
from app.settings import ProviderConfig, ProviderModel, Settings from app.settings import ProviderConfig, ProviderModel, Settings
class ToolChoiceCompatibilityTests(unittest.TestCase):
def test_retries_without_tool_choice_when_thinking_mode_rejects_it(self) -> None:
class FakeResponse:
def __init__(self, status_code: int, text: str, body: dict[str, object]) -> None:
self.status_code = status_code
self.text = text
self._body = body
def json(self) -> dict[str, object]:
return self._body
class FakeClient:
def __init__(self) -> None:
self.requests: list[dict[str, object]] = []
self.responses = [
FakeResponse(400, '{"error":{"message":"Thinking mode does not support this tool_choice"}}', {}),
FakeResponse(200, "", {"choices": [{"message": {"role": "assistant", "content": "ok"}}]}),
]
async def __aenter__(self) -> "FakeClient":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def post(self, _url: str, *, headers: dict[str, str], json: dict[str, object]) -> FakeResponse:
self.requests.append(dict(json))
return self.responses.pop(0)
agent = object.__new__(AgentService)
agent.settings = SimpleNamespace(llm_timeout_s=1)
client = FakeClient()
provider = ProviderConfig("deepseek", "DeepSeek", "https://example.invalid/v1", "test-key", (ProviderModel("deepseek-v4-flash-vision-exp", vision=True),))
model = provider.models[0]
with patch("app.services.agent_service.httpx.AsyncClient", return_value=client):
response = asyncio.run(agent._complete([], [], provider, model, "analyze_image_reference"))
self.assertEqual(response["choices"][0]["message"]["content"], "ok")
self.assertEqual(client.requests[0]["tool_choice"], {"type": "function", "function": {"name": "analyze_image_reference"}})
self.assertNotIn("tool_choice", client.requests[1])
class ParseToolArgumentsTests(unittest.TestCase): class ParseToolArgumentsTests(unittest.TestCase):
def test_accepts_one_json_object(self) -> None: def test_accepts_one_json_object(self) -> None:
payload = parse_tool_arguments(' {"summary":"water cup","cdsl":{"parts":[]}} ') payload = parse_tool_arguments(' {"summary":"water cup","cdsl":{"parts":[]}} ')
@@ -62,11 +107,13 @@ class ParseToolArgumentsTests(unittest.TestCase):
self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"]) self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"])
self.assertEqual(cdsl, CDSL_TOOL_SCHEMA) self.assertEqual(cdsl, CDSL_TOOL_SCHEMA)
def test_strict_tool_schema_covers_design_intent_and_cdsl_generation_arguments(self) -> None: def test_strict_tool_schema_covers_generation_arguments(self) -> None:
tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True)) tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True))
strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")] strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")]
self.assertEqual(strict_tools, ["propose_design_intent", "generate_cdsl_model"]) self.assertEqual(strict_tools, ["generate_flange_sleeve_model", "generate_cdsl_model"])
template_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_flange_sleeve_model")
self.assertEqual(template_tool["function"]["parameters"]["properties"]["plan"]["required"], ["template"])
generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model") generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model")
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1}) self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1})
self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA) self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA)
@@ -77,6 +124,38 @@ class ParseToolArgumentsTests(unittest.TestCase):
self.assertFalse(any(tool["function"].get("strict") for tool in tools)) self.assertFalse(any(tool["function"].get("strict") for tool in tools))
def test_flange_sleeve_image_route_exposes_only_the_compact_plan_generator(self) -> None:
analysis = {
"partType": "带方形法兰的圆筒管座",
"visibleFeatures": ["同轴中空管", "四个安装孔"],
}
self.assertTrue(use_flange_sleeve_template(analysis, ""))
self.assertFalse(use_flange_sleeve_template(analysis, "cad_existing"))
tools = tools_for_model(ProviderModel("default-model"), flange_sleeve_only=True)
names = [tool["function"]["name"] for tool in tools]
self.assertIn("generate_flange_sleeve_model", names)
self.assertNotIn("search_cdsl_library", names)
self.assertNotIn("read_cdsl_reference", names)
self.assertNotIn("read_current_cdsl", names)
self.assertNotIn("generate_cdsl_model", names)
def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None:
tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False)
self.assertNotIn("analyze_image_reference", [tool["function"]["name"] for tool in tools])
def test_image_analysis_allows_no_dimension_candidates(self) -> None:
analysis_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "analyze_image_reference")
self.assertNotIn("dimension_candidates", analysis_tool["function"]["parameters"]["required"])
result = normalize_image_analysis({
"part_type": "压铸外壳",
"visible_features": ["圆角矩形外轮廓"],
"uncertain_features": [],
})
self.assertEqual(result["dimension_candidates"], [])
def test_strict_schema_rejection_is_localized_for_chinese_requests(self) -> None: def test_strict_schema_rejection_is_localized_for_chinese_requests(self) -> None:
message = user_visible_error_message( message = user_visible_error_message(
StrictToolSchemaError("provider rejected strict schema"), StrictToolSchemaError("provider rejected strict schema"),
@@ -243,6 +322,332 @@ class ToolArgumentsRetryTests(unittest.TestCase):
self.assertTrue(any("已修正工具参数" in str(event.get("text", "")) for event in events)) self.assertTrue(any("已修正工具参数" in str(event.get("text", "")) for event in events))
self.assertEqual(list(settings.task_root.glob("cad_*")), []) self.assertEqual(list(settings.task_root.glob("cad_*")), [])
def test_persists_every_cdsl_attempt_and_validation_failure(self) -> None:
class InvalidCdslAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
plan_call = {
"id": "design_brief",
"type": "function",
"function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "建立一个法兰。", "assumptions": []}),
},
}
invalid_call = {
"id": "invalid_cdsl",
"type": "function",
"function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "无效法兰", "assumptions": []}),
},
}
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [plan_call]}}]},
*[
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [invalid_call]}}]}
for _ in range(7)
],
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
return self.responses.pop(0)
backend_root = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_root = Path(temporary_directory)
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="test-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
store = WorkspaceStore(settings)
agent = InvalidCdslAgent(settings, store, CdslLibrary(settings))
conversation_id = "conv_000000000004"
message = ChatMessage(id="user_invalid_cdsl", role="user", parts=[MessagePart(type="text", text="生成一个法兰")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
attempts = sorted(diagnostics.glob("cdsl_attempt_*.json"))
failures = sorted(diagnostics.glob("cdsl_validation_*.json"))
self.assertEqual(len(attempts), 7)
self.assertEqual(len(failures), 7)
self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts))
records = sorted(
(json.loads(path.read_text(encoding="utf-8")) for path in failures),
key=lambda record: int(record["iteration"]),
)
self.assertEqual([record["iteration"] for record in records], list(range(2, 9)))
self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records))
self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records))
self.assertTrue(all(record["validation_error"] for record in records))
self.assertEqual(
{Path(record["cdsl_attempt_path"]).name for record in records},
{path.name for path in attempts},
)
self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events))
self.assertEqual(agent.responses, [])
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
class ImageReferenceIntakeTests(unittest.TestCase):
@staticmethod
def _settings(temporary_root: Path, *, vision: bool = True) -> Settings:
backend_root = Path(__file__).resolve().parents[1]
provider = ProviderConfig(
"test",
"Test",
"https://example.invalid/v1",
"test-key",
(ProviderModel("vision-model", vision=vision),),
)
return Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="vision-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
@staticmethod
def _add_image_attachment(store: WorkspaceStore, conversation_id: str) -> str:
store.ensure_conversation(conversation_id)
relative_path, _ = store.write_conversation_upload(conversation_id, "flange.png", b"image-bytes")
store.add_conversation_attachment(conversation_id, {
"id": "upload_flange",
"conversation_id": conversation_id,
"name": "flange.png",
"kind": "image",
"path": relative_path,
"mime": "image/png",
})
return "upload_flange"
@staticmethod
def _analysis_arguments() -> dict[str, object]:
return {
"part_type": "四孔法兰套筒",
"visible_features": ["中空圆筒", "四孔法兰", "螺栓孔"],
"uncertain_features": ["法兰背面可能有沉孔"],
"dimension_candidates": [
{"id": "bore_diameter", "label": "中心孔直径", "reason": "图片没有标注内径"},
{"id": "bolt_circle", "label": "螺栓孔中心距", "reason": "透视图无法确定孔距"},
],
}
def test_image_request_keeps_structured_analysis_when_model_asks_a_question(self) -> None:
class ImageIntakeAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.required_tools: list[str | None] = []
self.responses = [
{"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "image_analysis",
"type": "function",
"function": {
"name": "analyze_image_reference",
"arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()),
},
}],
}}]},
{"choices": [{"message": {
"role": "assistant",
"content": "中心孔直径会显著影响零件用途,请确认这个尺寸。",
"tool_calls": [],
}}]},
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
self.required_tools.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None)
return self.responses.pop(0)
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_root = Path(temporary_directory)
settings = self._settings(temporary_root)
store = WorkspaceStore(settings)
conversation_id = "conv_000000000001"
self._add_image_attachment(store, conversation_id)
agent = ImageIntakeAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_image", role="user", parts=[MessagePart(type="text", text="生成图片中的模型")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
conversation = store.read_conversation(conversation_id)
assistant_parts = conversation["messages"][-1]["parts"]
self.assertEqual(agent.required_tools, ["analyze_image_reference", None])
self.assertEqual(agent.responses, [])
self.assertTrue(any(event.get("partType") == "四孔法兰套筒" for event in events))
self.assertTrue(any("中心孔直径" in str(event.get("text", "")) for event in events))
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"])
self.assertEqual(assistant_parts[0]["data"]["attachmentIds"], ["upload_flange"])
self.assertEqual(list(settings.task_root.glob("cad_*")), [])
def test_model_can_continue_to_generation_after_initial_analysis(self) -> None:
class EstimateAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.tool_sets: list[list[str]] = []
self.tool_calls: list[str] = []
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "image_analysis", "type": "function", "function": {
"name": "analyze_image_reference",
"arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "design_brief", "type": "function", "function": {
"name": "describe_design_intent",
"arguments": json.dumps({
"plan": "按图片比例建立法兰套筒。",
"assumptions": ["所有未标注尺寸按图片比例估算,单位为 mm。"],
}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "build_cdsl", "type": "function", "function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "已按图片比例估算尺寸并生成模型。", "tool_calls": []}}]},
]
async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
self.tool_sets.append([str(tool["function"]["name"]) for tool in tools])
return self.responses.pop(0)
async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
self.tool_calls.append(name)
if name == "generate_cdsl_model":
return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None
return await super()._run_tool(name, arguments, *args, **kwargs)
with tempfile.TemporaryDirectory() as temporary_directory:
settings = self._settings(Path(temporary_directory))
store = WorkspaceStore(settings)
conversation_id = "conv_000000000002"
self._add_image_attachment(store, conversation_id)
agent = EstimateAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_estimate", role="user", parts=[MessagePart(type="text", text="根据图片直接推进建模,比例上的不确定性按合理工程判断处理。")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"]
self.assertEqual(agent.tool_calls, ["analyze_image_reference", "describe_design_intent", "generate_cdsl_model"])
self.assertIn("analyze_image_reference", agent.tool_sets[0])
self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets[1:]))
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"])
self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events))
def test_recorded_analysis_reuses_context_without_reanalyzing(self) -> None:
class RecordedEstimateAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.tool_sets: list[list[str]] = []
self.tool_calls: list[str] = []
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "design_brief", "type": "function", "function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "按既有识别结果建立法兰套筒。", "assumptions": ["尺寸按图片比例估算"]}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "build_cdsl", "type": "function", "function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "已按已有识别结果继续生成模型。", "tool_calls": []}}]},
]
async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
self.tool_sets.append([str(tool["function"]["name"]) for tool in tools])
return self.responses.pop(0)
async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
self.tool_calls.append(name)
if name == "generate_cdsl_model":
return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None
return await super()._run_tool(name, arguments, *args, **kwargs)
with tempfile.TemporaryDirectory() as temporary_directory:
settings = self._settings(Path(temporary_directory))
store = WorkspaceStore(settings)
conversation_id = "conv_000000000003"
attachment_id = self._add_image_attachment(store, conversation_id)
analysis = self._analysis_arguments()
store.append_conversation_message(conversation_id, {
"id": "assistant_previous_analysis",
"role": "assistant",
"parts": [{"type": "data-cad-image-analysis", "data": {
"attachmentIds": [attachment_id],
"partType": analysis["part_type"],
"visibleFeatures": analysis["visible_features"],
"uncertainFeatures": analysis["uncertain_features"],
"dimensionCandidates": analysis["dimension_candidates"],
}}],
})
agent = RecordedEstimateAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_estimate_again", role="user", parts=[MessagePart(type="text", text="请继续,未标注处按你的工程判断处理。")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], conversation_id, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
conversation = store.read_conversation(conversation_id)
analysis_parts = [
part
for item in conversation["messages"]
for part in item["parts"]
if part["type"] == "data-cad-image-analysis"
]
self.assertEqual(agent.tool_calls, ["describe_design_intent", "generate_cdsl_model"])
self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets))
self.assertEqual(len(analysis_parts), 1)
self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events))
def test_direct_cdsl_generation_is_rejected_without_creating_a_task(self) -> None: def test_direct_cdsl_generation_is_rejected_without_creating_a_task(self) -> None:
class RetryAgent(AgentService): class RetryAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None: def __init__(self, *args: object, **kwargs: object) -> None:
@@ -328,10 +733,94 @@ class ToolArgumentsRetryTests(unittest.TestCase):
tool_result = agent.seen_messages[1][-1] tool_result = agent.seen_messages[1][-1]
self.assertEqual(tool_result["role"], "tool") self.assertEqual(tool_result["role"], "tool")
self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_INTENT_REQUIRED") self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_BRIEF_REQUIRED")
self.assertEqual(agent.required_tools, [None, None, None]) self.assertEqual(agent.required_tools, [None, None, None])
self.assertEqual(list(settings.task_root.glob("cad_*")), []) self.assertEqual(list(settings.task_root.glob("cad_*")), [])
class StructuredResultResponseTests(unittest.TestCase):
def test_structured_result_does_not_add_a_duplicate_success_message(self) -> None:
class StructuredResultAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.responses = [
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "design_brief",
"type": "function",
"function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "建立带中心孔的法兰。", "assumptions": []}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{
"id": "build_cdsl",
"type": "function",
"function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({"cdsl": {}, "summary": "带中心孔的法兰", "assumptions": []}),
},
}]}}]},
{"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": []}}]},
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
return self.responses.pop(0)
async def _run_tool(self, name: str, *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]:
if name == "describe_design_intent":
return {"ok": True, "summary": "设计说明已记录"}, None
if name == "generate_cdsl_model":
return {"ok": True, "summary": "带中心孔的法兰"}, {
"task_id": "cad_000000000001",
"revision_id": "rev_001",
"cdsl_path": "model.cdsl.json",
"step_path": "model.step",
"glb_path": "model.glb",
"report_path": "report.json",
"summary": "带中心孔的法兰",
"reference_ids": [],
"engine": "cdsl_only",
}
raise AssertionError(f"unexpected tool: {name}")
backend_root = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as temporary_directory:
temporary_root = Path(temporary_directory)
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
settings = Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="test-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
store = WorkspaceStore(settings)
agent = StructuredResultAgent(settings, store, CdslLibrary(settings))
message = ChatMessage(id="user_result", role="user", parts=[MessagePart(type="text", text="生成一个带中心孔的法兰")])
async def collect_events() -> list[dict[str, object]]:
events: list[dict[str, object]] = []
async for chunk in agent.stream([message], None, None):
events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1]))
return events
events = asyncio.run(collect_events())
conversation_id = store.read_conversation(next(settings.conversation_root.iterdir()).name)["conversation_id"]
assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"]
diagnostics = settings.conversation_root / conversation_id / "diagnostics"
attempts = list(diagnostics.glob("cdsl_attempt_*.json"))
self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-result"])
self.assertTrue(any(event.get("taskId") == "cad_000000000001" for event in events))
self.assertFalse(any("已生成:" in str(event.get("text", "")) for event in events))
self.assertEqual(len(attempts), 1)
self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {})
self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), [])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,56 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from app.services.attachments import attachment_record
from app.services.storage import WorkspaceStore
from app.settings import ProviderConfig, ProviderModel, Settings
class ConversationAttachmentStorageTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary_directory = tempfile.TemporaryDirectory()
temporary_root = Path(self.temporary_directory.name)
backend_root = Path(__file__).resolve().parents[1]
provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),))
self.settings = Settings(
task_root=temporary_root / "tasks",
conversation_root=temporary_root / "conversations",
library_root=backend_root / "cdsl_library",
engine_root=backend_root / "engine" / "cdsl_engine",
llm_base_url=provider.base_url,
llm_api_key=provider.api_key,
llm_model="test-model",
llm_timeout_s=1,
default_provider_id="test",
providers=(provider,),
)
self.store = WorkspaceStore(self.settings)
self.conversation_id = "conv_000000000001"
self.store.ensure_conversation(self.conversation_id)
def tearDown(self) -> None:
self.temporary_directory.cleanup()
def test_upload_is_owned_by_conversation_without_creating_a_task(self) -> None:
relative_path, target = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes")
attachment = attachment_record(self.conversation_id, "reference.png", "image/png", relative_path, b"reference-bytes", "image")
conversation = self.store.add_conversation_attachment(self.conversation_id, attachment)
self.assertEqual(target, (self.settings.conversation_root / self.conversation_id / relative_path).resolve())
self.assertTrue(target.is_file())
self.assertEqual(conversation["attachments"], [attachment])
self.assertEqual(list(self.settings.task_root.glob("cad_*")), [])
def test_attachment_cannot_be_linked_to_another_conversation(self) -> None:
relative_path, _ = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes")
attachment = attachment_record("conv_000000000002", "reference.png", "image/png", relative_path, b"reference-bytes", "image")
with self.assertRaisesRegex(ValueError, "does not belong"):
self.store.add_conversation_attachment(self.conversation_id, attachment)
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -132,7 +132,8 @@ class DesignIntentValidationTests(unittest.TestCase):
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001") self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001")
with self.assertRaises(self.engine.DesignIntentError) as context: with self.assertRaises(self.engine.DesignIntentError) as context:
self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002") self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002")
self.assertEqual(context.exception.code, "INVALID_DESIGN_INTENT") self.assertEqual(context.exception.code, "DESIGN_INTENT_BASE_REVISION_MISMATCH")
self.assertIn("rev_002", str(context.exception))
def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None: def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None:
duplicate = mounting_plate_intent() duplicate = mounting_plate_intent()
+257 -65
View File
@@ -6,18 +6,21 @@ import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend")) sys.path.insert(0, str(ROOT / "backend"))
from app.models.contracts import ChatMessage, MessagePart # noqa: E402
from app.services.agent_service import AgentService # noqa: E402 from app.services.agent_service import AgentService # noqa: E402
from app.services.engine_service import load_engine # noqa: E402 from app.services.engine_service import load_engine # noqa: E402
from app.services.flange_sleeve_template import TEMPLATE_ID # noqa: E402
from app.services.library import CdslLibrary # noqa: E402 from app.services.library import CdslLibrary # noqa: E402
from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402
from app.services.storage import WorkspaceStore # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402
from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402
from tests.test_design_intent import mounting_plate_cdsl, mounting_plate_intent # noqa: E402 from tests.test_design_intent import mounting_plate_cdsl # noqa: E402
BACKEND = ROOT / "backend" BACKEND = ROOT / "backend"
@@ -40,85 +43,274 @@ class DesignIntentFlowTests(unittest.TestCase):
providers=(provider,), providers=(provider,),
) )
def test_design_intent_is_required_before_library_or_cdsl(self) -> None: def test_design_brief_is_required_before_library_or_cdsl(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory)) settings = self.settings(Path(directory))
store = WorkspaceStore(settings) store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], intent_state=state)) searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state))
generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"design_intent_id": "intent_aaaaaaaaaaaa", "cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], intent_state=state)) generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state))
self.assertEqual(searched["code"], "DESIGN_INTENT_REQUIRED") self.assertEqual(searched["code"], "DESIGN_BRIEF_REQUIRED")
self.assertEqual(generated["code"], "DESIGN_INTENT_REQUIRED") self.assertEqual(generated["code"], "DESIGN_BRIEF_REQUIRED")
self.assertEqual(list(settings.task_root.glob("cad_*")), []) self.assertEqual(list(settings.task_root.glob("cad_*")), [])
def test_ready_plan_persists_before_build_and_links_success_revision(self) -> None: def test_generation_normalizes_legacy_llm_cdsl_before_building(self) -> None:
legacy_cdsl = {
"schema": "cad.cdsl.llm.v1",
"part_id": "legacy-flange-base",
"geometry": {"sketches": [{
"id": "base_sketch",
"plane": "XY",
"offset_mm": 12,
"profile": {"type": "circle", "radius_mm": 20},
}]},
"features": [{
"id": "base_add",
"atomic_id": "extrude_add_blind",
"sketch": "base_sketch",
"params": {"distance_mm": 8},
}],
}
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange base."}
captured_build: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured_build.update(kwargs)
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"generate_cdsl_model",
{"cdsl": legacy_cdsl, "summary": "flange base", "assumptions": []},
"", "Create a flange base", [], planning_state=state,
))
built_cdsl = captured_build["cdsl"]
self.assertTrue(result["ok"])
self.assertEqual(built_cdsl["features"][0]["sketch_id"], "base_sketch")
self.assertEqual(built_cdsl["features"][0]["depends_on"], [])
self.assertNotIn("sketch", built_cdsl["features"][0])
self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0])
self.assertEqual(len(result["normalization_repairs"]), 3)
def test_flange_sleeve_tool_builds_cdsl_from_a_compact_semantic_plan(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "PLAN_RECORDED", "design_brief": "Create a flange sleeve."}
captured_build: dict[str, object] = {}
def fake_build_revision(**kwargs: object) -> dict[str, object]:
captured_build.update(kwargs)
return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
result, _ = asyncio.run(agent._run_tool(
"generate_flange_sleeve_model",
{
"plan": {
"template": TEMPLATE_ID,
"name": "Image flange sleeve",
"flange_width_mm": 140,
"flange_height_mm": 120,
"mount_hole_u_mm": 48,
"mount_hole_v_mm": 38,
},
"summary": "flange sleeve",
"assumptions": ["Dimensions are approximate."],
},
"", "Create an image flange sleeve", [], planning_state=state,
))
built_cdsl = captured_build["cdsl"]
self.assertTrue(result["ok"])
self.assertEqual(result["template_id"], TEMPLATE_ID)
self.assertEqual(captured_build["operation"]["template_id"], TEMPLATE_ID)
self.assertEqual(built_cdsl["meta"]["name"], "Image flange sleeve")
self.assertEqual(built_cdsl["geometry"]["sketches"][0]["workplane"]["normal"], [1.0, 0.0, 0.0])
self.assertEqual(built_cdsl["features"][-1]["depends_on"], ["mount_hole_cuts"])
self.assertNotIn("cdsl", result["template_plan"])
def test_successful_generation_ends_the_agent_tool_loop(self) -> None:
class CaptureAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.responses = [
{
"choices": [{"message": {
"role": "assistant", "content": "", "tool_calls": [{
"id": "brief", "type": "function", "function": {
"name": "describe_design_intent",
"arguments": json.dumps({"plan": "Create a flange sleeve.", "assumptions": []}),
},
}],
}}],
},
{
"choices": [{"message": {
"role": "assistant", "content": "", "tool_calls": [{
"id": "template", "type": "function", "function": {
"name": "generate_flange_sleeve_model",
"arguments": json.dumps({
"plan": {"template": TEMPLATE_ID},
"summary": "flange sleeve",
"assumptions": [],
}),
},
}],
}}],
},
]
async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]:
return self.responses.pop(0)
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = CaptureAgent(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
def fake_build_revision(**kwargs: object) -> dict[str, object]:
return {
"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001",
"cdsl_path": "revisions/rev_001/model.cdsl.json",
"step_path": "revisions/rev_001/model.step",
"glb_path": "revisions/rev_001/model.glb",
"report_path": "revisions/rev_001/rebuild-report.json",
"summary": str(kwargs["summary"]), "reference_ids": [], "engine": "cdsl_only",
}
with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
async def consume() -> None:
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a flange sleeve")])
async for _ in agent.stream([message], None, None):
pass
asyncio.run(consume())
saved = store.read_conversation(next(item.name for item in settings.conversation_root.iterdir()))
parts = saved["messages"][-1]["parts"]
self.assertEqual(agent.responses, [])
self.assertTrue(any(part["type"] == "data-cad-result" for part in parts))
self.assertFalse(any(part["type"] == "data-cad-error" for part in parts))
def test_text_brief_is_returned_to_model_and_cdsl_is_the_only_contract(self) -> None:
class CaptureAgent(AgentService):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.seen_messages: list[list[dict[str, object]]] = []
self.responses = [
{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "brief",
"type": "function",
"function": {
"name": "describe_design_intent",
"arguments": json.dumps({
"plan": "Create a rectangular mounting plate, then cut four mounting holes and a center slot.",
"assumptions": ["Use millimetres."],
}),
},
}],
}}],
},
{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "generate",
"type": "function",
"function": {
"name": "generate_cdsl_model",
"arguments": json.dumps({
"cdsl": mounting_plate_cdsl(),
"summary": "mounting plate",
"assumptions": ["Use millimetres."],
}),
},
}],
}}],
},
{"choices": [{"message": {"role": "assistant", "content": "已生成。", "tool_calls": []}}]},
]
async def _complete(self, messages: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]:
self.seen_messages.append([dict(message) for message in messages])
return self.responses.pop(0)
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory)) settings = self.settings(Path(directory))
store = WorkspaceStore(settings) store = WorkspaceStore(settings)
skills = PartSkillLibrary(PART_SKILL_ROOT) skills = PartSkillLibrary(PART_SKILL_ROOT)
agent = AgentService(settings, store, CdslLibrary(settings), skills) agent = CaptureAgent(settings, store, CdslLibrary(settings), skills)
request = "Create a mounting plate with four holes and a center slot" captured_build: dict[str, object] = {}
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
planned, _ = asyncio.run(agent._run_tool( def fake_build_revision(**kwargs: object) -> dict[str, object]:
"propose_design_intent", captured_build.update(kwargs)
{"intent": mounting_plate_intent(), "summary": "plate plan", "assumptions": []}, return {
"", request, [], part_skill_selection=skills.select(request), intent_state=state, "task_id": "cad_aaaaaaaaaaaa",
"revision_id": "rev_001",
"cdsl_path": "revisions/rev_001/model.cdsl.json",
"step_path": "revisions/rev_001/model.step",
"glb_path": "revisions/rev_001/model.glb",
"report_path": "revisions/rev_001/rebuild-report.json",
"summary": str(kwargs["summary"]),
"reference_ids": list(kwargs["reference_ids"]),
"engine": "cdsl_only",
}
engine = load_engine(settings)
with patch.object(engine, "validate_intent_cdsl", side_effect=AssertionError("legacy intent validation must not run")) as legacy_validation, patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
async def consume() -> None:
message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")])
async for _ in agent.stream([message], None, None):
pass
asyncio.run(consume())
brief_result = json.loads(str(agent.seen_messages[1][-1]["content"]))
self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.")
self.assertNotIn("design_intent_id", brief_result)
self.assertNotIn("design_intent_id", captured_build)
self.assertNotIn("design_intent", captured_build)
self.assertEqual(captured_build["parent_revision_id"], "")
legacy_validation.assert_not_called()
def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT))
state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
asyncio.run(agent._run_tool(
"describe_design_intent",
{"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []},
"cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state,
)) ))
store.read_task = lambda _task_id: {"current_revision": "rev_007"} # type: ignore[method-assign]
captured_build: dict[str, object] = {}
self.assertTrue(planned["ok"]) def fake_build_revision(**kwargs: object) -> dict[str, object]:
task = store.read_task(planned["task_id"]) captured_build.update(kwargs)
self.assertEqual(task["current_revision"], "") return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_008"}
planning_path = store.artifact_path(planned["task_id"], planned["design_intent_path"])
self.assertTrue(planning_path.is_file())
saved = json.loads(planning_path.read_text(encoding="utf-8"))
self.assertEqual(saved["part_skill_ids"], skills.select(request)["skill_ids"])
searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "plate"}, planned["task_id"], request, [], intent_state=state)) with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision):
self.assertTrue(searched["ok"]) result, _ = asyncio.run(agent._run_tool(
result, built = asyncio.run(agent._run_tool(
"generate_cdsl_model", "generate_cdsl_model",
{"design_intent_id": planned["design_intent_id"], "cdsl": mounting_plate_cdsl(), "summary": "plate", "assumptions": []}, {"cdsl": mounting_plate_cdsl(), "summary": "revised plate", "assumptions": []},
planned["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state, "cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state,
)) ))
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
self.assertIsNotNone(built) self.assertEqual(captured_build["parent_revision_id"], "rev_007")
revision = store.read_task(planned["task_id"])["revisions"][-1]
self.assertEqual(revision["design_intent_id"], planned["design_intent_id"])
self.assertEqual(revision["design_intent_path"], planned["design_intent_path"])
audit = json.loads(store.artifact_path(planned["task_id"], revision["part_skills_path"]).read_text(encoding="utf-8"))
self.assertEqual(audit["design_intent_id"], planned["design_intent_id"])
self.assertEqual(audit["design_intent_assumptions"], [])
def test_blocked_plan_is_persisted_without_a_revision_and_new_plan_supersedes_it(self) -> None:
with tempfile.TemporaryDirectory() as directory:
settings = self.settings(Path(directory))
store = WorkspaceStore(settings)
skills = PartSkillLibrary(PART_SKILL_ROOT)
agent = AgentService(settings, store, CdslLibrary(settings), skills)
request = "Create a mounting plate"
state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
blocked_intent = mounting_plate_intent()
blocked_intent["open_questions"] = [{"id": "thickness", "question": "Thickness?", "blocking": True}]
blocked_intent["status"] = "needs_clarification"
blocked, _ = asyncio.run(agent._run_tool(
"propose_design_intent",
{"intent": blocked_intent, "summary": "blocked", "assumptions": []},
"", request, [], part_skill_selection=skills.select(request), intent_state=state,
))
self.assertEqual(blocked["code"], "DESIGN_INTENT_BLOCKED")
task = store.read_task(blocked["task_id"])
self.assertEqual(task["revisions"], [])
self.assertEqual(task["design_intents"][0]["status"], "pending")
ready, _ = asyncio.run(agent._run_tool(
"propose_design_intent",
{"intent": mounting_plate_intent(), "summary": "ready", "assumptions": []},
blocked["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state,
))
self.assertTrue(ready["ok"])
task = store.read_task(blocked["task_id"])
self.assertEqual(task["design_intents"][0]["status"], "superseded")
self.assertEqual(task["design_intents"][-1]["status"], "accepted")
+9 -11
View File
@@ -211,7 +211,7 @@ class AgentPartSkillTests(unittest.TestCase):
self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"])) self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"]))
self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"])) self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"]))
def test_prompt_contains_bridge_without_adding_tools(self) -> None: def test_prompt_contains_bridge_and_template_tool(self) -> None:
library = PartSkillLibrary(PART_SKILL_ROOT) library = PartSkillLibrary(PART_SKILL_ROOT)
selection = library.select("Create a flange with a bolt circle") selection = library.select("Create a flange with a bolt circle")
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -220,7 +220,7 @@ class AgentPartSkillTests(unittest.TestCase):
self.assertIn("[planning/flange]", prompt) self.assertIn("[planning/flange]", prompt)
self.assertIn("circular-pattern atomic", prompt) self.assertIn("circular-pattern atomic", prompt)
self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["search_cdsl_library", "read_cdsl_reference", "propose_design_intent", "read_current_cdsl", "generate_cdsl_model"]) self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_flange_sleeve_model", "generate_cdsl_model"])
def test_generation_persists_assumptions_and_selected_skills(self) -> None: def test_generation_persists_assumptions_and_selected_skills(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -230,27 +230,25 @@ class AgentPartSkillTests(unittest.TestCase):
agent = AgentService(settings, store, CdslLibrary(settings), library) agent = AgentService(settings, store, CdslLibrary(settings), library)
request = "M6 六角螺母" request = "M6 六角螺母"
selection = library.select(request) selection = library.select(request)
engine = load_engine(settings) planning_state = {"phase": "WAITING_FOR_PLAN", "design_brief": ""}
intent = engine.design_intent_from_cdsl(hex_nut_fixture(), request=request)
intent_state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""}
planned, _ = asyncio.run(agent._run_tool( planned, _ = asyncio.run(agent._run_tool(
"propose_design_intent", "describe_design_intent",
{"intent": intent, "summary": "M6 nut plan", "assumptions": []}, {"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []},
"", "",
request, request,
[], [],
part_skill_selection=selection, part_skill_selection=selection,
intent_state=intent_state, planning_state=planning_state,
)) ))
self.assertTrue(planned["ok"]) self.assertTrue(planned["ok"])
result, generated = asyncio.run(agent._run_tool( result, generated = asyncio.run(agent._run_tool(
"generate_cdsl_model", "generate_cdsl_model",
{"design_intent_id": planned["design_intent_id"], "cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]}, {"cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]},
planned["task_id"], "",
request, request,
["reference-fixture"], ["reference-fixture"],
part_skill_selection=selection, part_skill_selection=selection,
intent_state=intent_state, planning_state=planning_state,
)) ))
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
+81 -1
View File
@@ -5,7 +5,8 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from app.services.engine_service import load_engine, validate_cdsl from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl
from app.services.flange_sleeve_template import TEMPLATE_ID, build_flange_sleeve_cdsl
from app.settings import get_settings from app.settings import get_settings
@@ -88,6 +89,85 @@ class ProfileSchemaTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "positions\\[0\\].*not of type 'object'"): with self.assertRaisesRegex(ValueError, "positions\\[0\\].*not of type 'object'"):
validate_cdsl(cdsl, self.engine) validate_cdsl(cdsl, self.engine)
def test_normalizes_unambiguous_legacy_llm_field_names_before_validation(self) -> None:
legacy_cdsl = {
"schema": "cad.cdsl.llm.v1",
"part_id": "legacy-flange-base",
"geometry": {"sketches": [{
"id": "base_sketch",
"plane": "XY",
"offset_mm": 12,
"profile": {"type": "circle", "radius_mm": 20},
}]},
"features": [{
"id": "base_add",
"atomic_id": "extrude_add_blind",
"sketch": "base_sketch",
"params": {"distance_mm": 8},
}],
}
normalized, repairs = normalize_cdsl_for_engine(legacy_cdsl)
self.assertEqual(legacy_cdsl["geometry"]["sketches"][0]["plane"], "XY")
self.assertEqual(normalized["features"][0]["sketch_id"], "base_sketch")
self.assertNotIn("sketch", normalized["features"][0])
self.assertEqual(normalized["features"][0]["depends_on"], [])
self.assertEqual(normalized["geometry"]["sketches"][0]["workplane"], {
"origin_mm": [0.0, 0.0, 12.0],
"x_dir": [1.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
})
self.assertEqual(len(repairs), 3)
validate_cdsl(normalized, self.engine)
def test_normalizer_rewrites_the_legacy_revolve_axis_point_name(self) -> None:
normalized, repairs = normalize_cdsl_for_engine({
"features": [{
"id": "turn",
"atomic_id": "revolve_add",
"params": {"axis": {"point_mm": [0, 0, 0], "direction": [0, 0, 1]}},
}],
})
axis = normalized["features"][0]["params"]["axis"]
self.assertEqual(axis["origin_mm"], [0, 0, 0])
self.assertNotIn("point_mm", axis)
self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs)
def test_flange_sleeve_template_is_schema_valid_and_runtime_buildable(self) -> None:
cdsl, plan = build_flange_sleeve_cdsl({
"template": TEMPLATE_ID,
"name": "Template flange sleeve",
"flange_width_mm": 120,
"flange_height_mm": 120,
"flange_thickness_mm": 14,
"corner_chamfer_mm": 10,
"tube_outer_diameter_mm": 70,
"tube_straight_length_mm": 95,
"tip_outer_diameter_mm": 62,
"tip_length_mm": 28,
"bore_diameter_mm": 46,
"boss_outer_diameter_mm": 82,
"boss_height_mm": 5,
"mount_hole_diameter_mm": 12,
"mount_counterbore_diameter_mm": 24,
"mount_counterbore_depth_mm": 5,
"mount_hole_u_mm": 42,
"mount_hole_v_mm": 42,
})
self.assertEqual(plan["name"], "Template flange sleeve")
self.assertEqual(cdsl["features"][1]["depends_on"], ["flange_plate"])
self.assertEqual(cdsl["geometry"]["sketches"][0]["workplane"]["x_dir"], [0.0, 1.0, 0.0])
validate_cdsl(cdsl, self.engine)
with tempfile.TemporaryDirectory() as directory:
step_path = Path(directory) / "flange-sleeve.step"
result = self.engine.run_cdsl_only(cdsl, step_path)
self.assertEqual(result["engine"], "cdsl_only")
self.assertTrue(step_path.is_file())
self.assertGreater(step_path.stat().st_size, 0)
def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None: def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None:
cdsl = { cdsl = {
"schema": "cad.cdsl.llm.v1", "schema": "cad.cdsl.llm.v1",
@@ -3,9 +3,10 @@ import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) {
const { conversationId } = await context.params;
const body = await request.formData(); const body = await request.formData();
const response = await backendFetch("/v1/uploads", { method: "POST", body }); const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json()); return NextResponse.json(await response.json());
} }
@@ -18,7 +18,6 @@ export async function PATCH(request: NextRequest, context: { params: Promise<{ c
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
current_task_id: body.currentTaskId || null, current_task_id: body.currentTaskId || null,
attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
}), }),
}); });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
+49 -14
View File
@@ -75,7 +75,6 @@
--ui-loading-overlay: rgb(233 238 242 / 70%); --ui-loading-overlay: rgb(233 238 242 / 70%);
--ui-loading-overlay-strong: rgb(245 247 248 / 88%); --ui-loading-overlay-strong: rgb(245 247 248 / 88%);
--ui-drag-overlay: rgb(255 255 255 / 88%); --ui-drag-overlay: rgb(255 255 255 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(15 25 30 / 18%);
--ui-shadow-soft: 0 12px 30px rgb(16 24 32 / 12%); --ui-shadow-soft: 0 12px 30px rgb(16 24 32 / 12%);
--ui-shadow-panel: 0 22px 60px rgb(16 24 32 / 14%); --ui-shadow-panel: 0 22px 60px rgb(16 24 32 / 14%);
--ui-shadow-popover: 0 18px 46px rgb(16 24 32 / 16%); --ui-shadow-popover: 0 18px 46px rgb(16 24 32 / 16%);
@@ -161,7 +160,6 @@
--ui-loading-overlay: rgb(13 15 17 / 35%); --ui-loading-overlay: rgb(13 15 17 / 35%);
--ui-loading-overlay-strong: rgb(13 15 17 / 76%); --ui-loading-overlay-strong: rgb(13 15 17 / 76%);
--ui-drag-overlay: rgb(16 19 21 / 88%); --ui-drag-overlay: rgb(16 19 21 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(17 19 21 / 42%);
--ui-shadow-soft: 0 10px 24px rgb(0 0 0 / 20%); --ui-shadow-soft: 0 10px 24px rgb(0 0 0 / 20%);
--ui-shadow-panel: 0 24px 60px rgb(0 0 0 / 26%); --ui-shadow-panel: 0 24px 60px rgb(0 0 0 / 26%);
--ui-shadow-popover: 0 18px 48px rgb(0 0 0 / 28%); --ui-shadow-popover: 0 18px 48px rgb(0 0 0 / 28%);
@@ -375,6 +373,7 @@ button:disabled {
@keyframes generation-edge-soft-out { to { opacity: 0; } } @keyframes generation-edge-soft-out { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.spin { animation: none; }
.generation-edge-glow, .generation-edge-glow,
.generation-edge-glow *, .generation-edge-glow *,
.generation-edge-glow *::before { .generation-edge-glow *::before {
@@ -397,28 +396,64 @@ button:disabled {
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); } .agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } .preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } .agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-thread-shell { position: relative; }
.spin { animation: ui-spin 900ms linear infinite; }
@keyframes ui-spin { to { transform: rotate(360deg); } }
.agent-pane-title { display: flex; height: 40px; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-text-strong); font-size: 12px; font-weight: 700; padding: 0 12px; } .agent-pane-title { display: flex; height: 40px; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-text-strong); font-size: 12px; font-weight: 700; padding: 0 12px; }
.agent-pane-title svg { color: var(--ui-accent); } .agent-pane-title svg { color: var(--ui-accent); }
.thread-viewport { min-height: 0; flex: 1; overflow-y: auto; padding: 12px; } .thread-viewport { min-height: 0; flex: 1; overflow-y: auto; padding: 12px; }
.message-list { display: grid; } .message-list { display: grid; }
.message-row { display: flex; border-bottom: 1px solid var(--ui-border-muted); padding: 10px 2px; } .message-row { display: grid; gap: 6px; border-bottom: 1px solid var(--ui-border-muted); padding: 12px 2px; }
.user-row { justify-content: flex-end; } .user-row { padding-left: 32px; }
.assistant-row { justify-content: flex-start; } .message-role { display: flex; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; font-weight: 700; }
.message-bubble { max-width: 100%; min-width: 0; } .message-role svg { color: var(--ui-text-muted); }
.user-bubble { max-width: 90%; border-radius: 5px; background: var(--ui-accent); color: var(--ui-accent-contrast); padding: 8px 10px; } .assistant-row .message-role svg { color: var(--ui-accent); }
.assistant-bubble { width: 100%; color: var(--ui-text); } .message-content { min-width: 0; color: var(--ui-text); }
.message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; } .message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
.thread-empty { display: grid; gap: 7px; border: 1px dashed var(--ui-border-strong); border-radius: 5px; color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 14px; } .message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; }
.thread-empty strong { color: var(--ui-text-strong); } .message-request-error svg { flex: 0 0 auto; margin-top: 1px; }
.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding-bottom: 10px; } .thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; }
.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding: 0 1px 10px; }
.attachment-card { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; } .attachment-card { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; }
.attachment-card svg { flex: 0 0 auto; color: var(--ui-accent); }.attachment-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { margin-left: auto; color: var(--ui-text-subtle); font-size: 10px; white-space: nowrap; } .attachment-card svg { flex: 0 0 auto; color: var(--ui-accent); }.attachment-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { margin-left: auto; color: var(--ui-text-subtle); font-size: 10px; white-space: nowrap; }
.upload-error { display: flex; align-items: flex-start; gap: 7px; margin: 0 1px 10px; border-bottom: 1px solid var(--ui-error-border); color: var(--ui-error-text); font-size: 11px; line-height: 1.45; padding: 0 0 10px; }
.upload-error svg { flex: 0 0 auto; margin-top: 1px; }
.composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; } .composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; }
.composer-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.composer-root { display: grid; gap: 8px; } .composer-root { display: grid; gap: 8px; }
.composer-input { min-height: 96px; max-height: 176px; width: 100%; resize: none; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 12px; line-height: 1.55; outline: none; padding: 9px; } .composer-input { min-height: 96px; max-height: 176px; width: 100%; resize: none; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 12px; line-height: 1.55; outline: none; padding: 9px; }
.composer-input:focus { border-color: var(--ui-accent); }.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }.composer-footer > span, .composer-footer > div { display: flex; align-items: center; gap: 7px; }.composer-action, .composer-send { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); }.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); } .composer-input:focus-visible { border-color: var(--ui-accent); box-shadow: 0 0 0 3px var(--ui-focus-ring); }
.cad-card { display: flex; gap: 9px; margin: 6px 0; border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-panel-muted); padding: 9px; }.cad-card-icon { display: grid; flex: 0 0 auto; width: 25px; height: 25px; place-items: center; border-radius: 4px; background: var(--ui-accent-soft); color: var(--ui-accent); }.cad-card-body { min-width: 0; }.cad-card-title { color: var(--ui-text-strong); font-size: 12px; font-weight: 700; }.cad-card-copy, .cad-result-meta { color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.download-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }.download-link { border-bottom: 1px solid currentColor; color: var(--ui-link); font-size: 11px; text-decoration: none; }.cad-error-card { border-color: var(--ui-error-border); background: var(--ui-error-bg); }.cad-error-card .cad-card-icon { background: transparent; color: var(--ui-error-text); } .composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }
.composer-footer > span, .composer-actions { display: flex; align-items: center; gap: 7px; }
.composer-actions { flex: 0 0 auto; }
.composer-command { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); font-size: 11px; font-weight: 600; padding: 0 9px; }
.composer-command:hover:not(:disabled) { background: var(--ui-control-hover); color: var(--ui-text); }
.composer-command:focus-visible, .download-link:focus-visible { outline: 2px solid var(--ui-focus-ring); outline-offset: 2px; }
.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); }
.composer-send:hover:not(:disabled) { background: var(--ui-accent-hover); color: var(--ui-accent-contrast); }
.composer-cancel { border-color: var(--ui-accent-border); background: var(--ui-accent-soft); color: var(--ui-accent-text); }
.file-drop-overlay { position: absolute; z-index: 20; inset: 8px; display: grid; place-content: center; justify-items: center; gap: 8px; border: 1px dashed var(--ui-accent); border-radius: 5px; background: var(--ui-drag-overlay); color: var(--ui-accent); font-size: 12px; font-weight: 700; pointer-events: none; }
.cad-message { display: grid; gap: 3px; margin: 9px 0 0; min-width: 0; padding-left: 2px; }
.cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; }
.cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); }
.cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; }
.cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); }
.cad-result { margin-top: 12px; }
.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; }
.cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; }
.download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; }
.download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; }
.download-link:hover { color: var(--ui-link-hover); text-decoration: underline; }
.cad-image-part-type { margin-left: 21px; color: var(--ui-text); font-size: 11px; font-weight: 600; line-height: 1.45; }
.image-analysis-section { display: grid; gap: 2px; margin: 4px 0 0 21px; }
.image-analysis-section > span { color: var(--ui-text-subtle); font-size: 10px; font-weight: 700; }
.image-analysis-section p { margin: 0; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.image-dimension-list { display: grid; gap: 5px; margin: 1px 0 0; padding: 0; list-style: none; }
.image-dimension-list li { display: grid; gap: 1px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.4; }
.image-dimension-list strong { color: var(--ui-text); font-weight: 600; }
.image-dimension-list span { overflow-wrap: anywhere; }
.viewer-state { display: flex; height: 100%; min-height: 42vh; align-items: center; justify-content: center; gap: 9px; color: var(--ui-text-muted); font-size: 12px; }.viewer-state svg { color: var(--ui-accent); }.viewer-state-error { color: var(--ui-error-text); }.viewer-state-error svg { color: var(--ui-error-text); } .viewer-state { display: flex; height: 100%; min-height: 42vh; align-items: center; justify-content: center; gap: 9px; color: var(--ui-text-muted); font-size: 12px; }.viewer-state svg { color: var(--ui-accent); }.viewer-state-error { color: var(--ui-error-text); }.viewer-state-error svg { color: var(--ui-error-text); }
.viewer-loading { position: absolute; z-index: 40; left: 50%; top: 50%; display: flex; align-items: center; gap: 8px; transform: translate(-50%, -50%); border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-glass-popover); color: var(--ui-text-muted); font-size: 12px; padding: 9px 12px; box-shadow: var(--ui-shadow-soft); } .viewer-loading { position: absolute; z-index: 40; left: 50%; top: 50%; display: flex; align-items: center; gap: 8px; transform: translate(-50%, -50%); border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-glass-popover); color: var(--ui-text-muted); font-size: 12px; padding: 9px 12px; box-shadow: var(--ui-shadow-soft); }
.cad-viewer-dark { background: var(--ui-viewer-bg); } .cad-viewer-dark { background: var(--ui-viewer-bg); }
@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { order: -1; min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-top: 1px solid var(--ui-border); border-right: 0; }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } } @media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-right: 0; border-bottom: 1px solid var(--ui-border); }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } }
+15 -24
View File
@@ -34,6 +34,7 @@ export function AgentStudio() {
const [lastError, setLastError] = useState(""); const [lastError, setLastError] = useState("");
const [attachments, setAttachments] = useState<CadAttachment[]>([]); const [attachments, setAttachments] = useState<CadAttachment[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [providerId, setProviderId] = useState(""); const [providerId, setProviderId] = useState("");
const [modelId, setModelId] = useState(""); const [modelId, setModelId] = useState("");
const [theme, setTheme] = useState<"light" | "dark">("light"); const [theme, setTheme] = useState<"light" | "dark">("light");
@@ -141,43 +142,30 @@ export function AgentStudio() {
}, []); }, []);
const handleUpload = useCallback(async (files: FileList | null) => { const handleUpload = useCallback(async (files: FileList | null) => {
if (!files?.length) return; const selectedFiles = Array.from(files || []);
const selectedModel = config?.providers if (!selectedFiles.length) return;
.find((provider) => provider.id === providerId) setUploadError("");
?.models.find((model) => model.id === modelId);
const includesImage = Array.from(files).some((file) => file.type.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(file.name));
if (includesImage && !selectedModel?.vision) {
setLastError("当前模型不支持图片。请选择标记为 Vision 的 OpenAI 或 Kimi 模型后再上传图片。");
return;
}
setUploading(true); setUploading(true);
try { try {
const uploaded: CadAttachment[] = []; const uploaded: CadAttachment[] = [];
for (const file of Array.from(files)) { for (const file of selectedFiles) {
const form = new FormData(); const form = new FormData();
form.set("file", file); form.set("file", file);
if (selectedTaskId) form.set("task_id", selectedTaskId); const response = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body: form });
const response = await fetch("/api/uploads", { method: "POST", body: form });
const payload = await response.json() as CadAttachment & { error?: string }; const payload = await response.json() as CadAttachment & { error?: string };
if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`); if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`);
uploaded.push(payload); uploaded.push(payload);
if (!selectedTaskId) setSelectedTaskId(payload.task_id);
} }
setAttachments((current) => { setAttachments((current) => [...current, ...uploaded]);
const next = [...current, ...uploaded];
if (conversationId) void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentTaskId: selectedTaskId || uploaded[0]?.task_id || null, attachments: next }),
});
return next;
});
setLastError(""); setLastError("");
} catch (error) { } catch (error) {
setLastError(error instanceof Error ? error.message : "附件上传失败"); const message = error instanceof Error ? error.message : "附件上传失败";
setUploadError(message);
setLastError(message);
} finally { } finally {
setUploading(false); setUploading(false);
} }
}, [config, conversationId, modelId, providerId, selectedTaskId]); }, [conversationId]);
if (loadState === "loading") { if (loadState === "loading") {
return <StudioLoading />; return <StudioLoading />;
@@ -204,6 +192,7 @@ export function AgentStudio() {
lastError={lastError} lastError={lastError}
attachments={attachments} attachments={attachments}
uploading={uploading} uploading={uploading}
uploadError={uploadError}
onUpload={handleUpload} onUpload={handleUpload}
theme={theme} theme={theme}
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
@@ -425,6 +414,7 @@ function StudioShell({
lastError, lastError,
attachments, attachments,
uploading, uploading,
uploadError,
onUpload, onUpload,
theme, theme,
onToggleTheme, onToggleTheme,
@@ -441,6 +431,7 @@ function StudioShell({
lastError: string; lastError: string;
attachments: CadAttachment[]; attachments: CadAttachment[];
uploading: boolean; uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void; onUpload: (files: FileList | null) => void;
theme: "light" | "dark"; theme: "light" | "dark";
onToggleTheme: () => void; onToggleTheme: () => void;
@@ -473,7 +464,7 @@ function StudioShell({
</header> </header>
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null} {!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null}
<div className="studio-main"> <div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} onUpload={onUpload} /></aside> <aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} onUpload={onUpload} /></aside>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section> <section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} /></section>
</div> </div>
</main> </main>
+84 -11
View File
@@ -1,59 +1,132 @@
"use client"; "use client";
import { Check, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Square } from "lucide-react"; import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Square, Upload } from "lucide-react";
import { useRef } from "react"; import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react"; import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types"; import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, onUpload }: { export function AgentThread({ attachments, uploading, uploadError, onUpload }: {
attachments: CadAttachment[]; attachments: CadAttachment[];
uploading: boolean; uploading: boolean;
uploadError: string;
onUpload: (files: FileList | null) => void; onUpload: (files: FileList | null) => void;
}) { }) {
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const dragDepth = useRef(0);
const running = useAuiState((state) => state.thread.isRunning);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const canUpload = !uploading && !running;
const onDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current += 1;
if (canUpload && isFileDrag(event)) setIsDraggingFiles(true);
};
const onDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = canUpload ? "copy" : "none";
};
const onDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setIsDraggingFiles(false);
};
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepth.current = 0;
setIsDraggingFiles(false);
if (canUpload && event.dataTransfer.files.length) onUpload(event.dataTransfer.files);
};
return ( return (
<div className="agent-thread-shell"> <div
className="agent-thread-shell"
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="agent-pane-title"><MessageSquare size={16} /><span>Agent</span></div> <div className="agent-pane-title"><MessageSquare size={16} /><span>Agent</span></div>
<ThreadPrimitive.Root className="thread-root"> <ThreadPrimitive.Root className="thread-root">
<ThreadPrimitive.Viewport className="thread-viewport scrollbar-thin" autoScroll> <ThreadPrimitive.Viewport className="thread-viewport scrollbar-thin" autoScroll>
{attachments.length ? <div className="attachment-list">{attachments.map((attachment) => <AttachmentCard key={attachment.id} attachment={attachment} />)}</div> : null} {attachments.length ? <div className="attachment-list">{attachments.map((attachment) => <AttachmentCard key={attachment.id} attachment={attachment} />)}</div> : null}
{uploadError ? <div className="upload-error" role="alert"><CircleAlert size={14} aria-hidden="true" />{uploadError}</div> : null}
<ThreadPrimitive.Empty> <ThreadPrimitive.Empty>
<div className="thread-empty"><strong>描述要生成或修改的 CAD 模型</strong><span>Agent 会检索本地 CDSL 样本并生成可编辑的 CDSL 模型。</span></div> <div className="thread-empty">描述需要生成或修改的 CAD 模型。</div>
</ThreadPrimitive.Empty> </ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div> <div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport> </ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} /> <Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} />
</ThreadPrimitive.Root> </ThreadPrimitive.Root>
{isDraggingFiles ? <div className="file-drop-overlay" role="status" aria-live="polite"><Upload size={24} /><span>拖放文件上传</span></div> : null}
</div> </div>
); );
} }
function isFileDrag(event: DragEvent<HTMLDivElement>) {
return event.dataTransfer.files.length > 0 || Array.from(event.dataTransfer.types).includes("Files");
}
function AttachmentCard({ attachment }: { attachment: CadAttachment }) { function AttachmentCard({ attachment }: { attachment: CadAttachment }) {
const Icon = attachment.kind === "image" ? FileImage : FileText; const Icon = attachment.kind === "image" ? FileImage : FileText;
return <div className="attachment-card"><Icon size={14} /><span>{attachment.name}</span><small>{attachment.kind === "image" ? "视觉参考" : "文本参考"}</small></div>; return <div className="attachment-card"><Icon size={14} /><span>{attachment.name}</span><small>{attachment.kind === "image" ? "视觉参考" : "文本参考"}</small></div>;
} }
function UserMessage() { function UserMessage() {
return <MessagePrimitive.Root className="message-row user-row"><div className="message-bubble user-bubble"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div></MessagePrimitive.Root>; return (
<MessagePrimitive.Root className="message-row user-row">
<div className="message-role"><Sparkles size={14} aria-hidden="true" /><span>You</span></div>
<div className="message-content"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div>
</MessagePrimitive.Root>
);
} }
function AssistantMessage() { function AssistantMessage() {
return ( return (
<MessagePrimitive.Root className="message-row assistant-row"> <MessagePrimitive.Root className="message-row assistant-row">
<div className="message-bubble assistant-bubble"><MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart } } }} /></div> <div className="message-role"><Bot size={14} aria-hidden="true" /><span>Agent</span></div>
<div className="message-content">
<MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart, "cad-image-analysis": CadImageAnalysisPart } } }} />
<MessagePrimitive.Error>
<div className="message-request-error" role="alert"><CircleAlert size={14} aria-hidden="true" /> Agent 请求失败,请检查服务端日志和模型配置。</div>
</MessagePrimitive.Error>
</div>
</MessagePrimitive.Root> </MessagePrimitive.Root>
); );
} }
function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) { function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning); const running = useAuiState((state) => state.thread.isRunning);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) onUpload(event.currentTarget.files);
event.currentTarget.value = "";
};
return ( return (
<div className="composer-shell"> <div className="composer-shell">
<input ref={fileInput} className="hidden" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple onChange={(event) => onUpload(event.target.files)} /> <input ref={fileInput} className="composer-file-input" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple tabIndex={-1} aria-hidden="true" onChange={handleFileChange} />
<ComposerPrimitive.Root className="composer-root"> <ComposerPrimitive.Root className="composer-root">
<ComposerPrimitive.Input className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} /> <ComposerPrimitive.Input aria-label="CAD 请求" className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<div className="composer-footer"><span><Check size={14} /> Enter 发送,Shift + Enter 换行</span><div>{running ? <ComposerPrimitive.Cancel className="composer-action" title="停止生成"><Square size={15} /></ComposerPrimitive.Cancel> : <button type="button" className="composer-action" title="上传图片或文档" disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={15} /> : <Paperclip size={15} />}</button>}<ComposerPrimitive.Send className="composer-send" title="发送"><Send size={16} /></ComposerPrimitive.Send></div></div> <div className="composer-footer">
<span><Check size={14} aria-hidden="true" /> Enter 发送,Shift + Enter 换行</span>
<div className="composer-actions">
{running ? (
<ComposerPrimitive.Cancel className="composer-command composer-cancel" title="停止生成" aria-label="停止生成"><Square size={14} fill="currentColor" aria-hidden="true" />停止</ComposerPrimitive.Cancel>
) : (
<>
<button type="button" className="composer-command composer-upload" title="上传图片或文档" aria-label="上传图片或文档" aria-busy={uploading || undefined} disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Paperclip size={14} aria-hidden="true" />}上传</button>
<ComposerPrimitive.Send className="composer-command composer-send" title="发送" aria-label="发送"><Send size={14} aria-hidden="true" />发送</ComposerPrimitive.Send>
</>
)}
</div>
</div>
</ComposerPrimitive.Root> </ComposerPrimitive.Root>
</div> </div>
); );
+59 -29
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react"; import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts"; import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types";
export function TextPart({ text }: { text: string }) { export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null; if (!text.trim()) return null;
@@ -10,16 +10,19 @@ export function TextPart({ text }: { text: string }) {
} }
export function CadProgressPart({ data }: { data: CadProgress }) { export function CadProgressPart({ data }: { data: CadProgress }) {
const running = data.status === "running"; if (data.step === "agent_stream") return null;
const status = String(data.status || "").toLowerCase();
const isRunning = status === "running";
const isError = status === "error";
const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status;
return ( return (
<div className="cad-card cad-progress-card"> <div className={`cad-message cad-progress${isError ? " is-error" : ""}`} role="status" aria-live="polite">
<div className="cad-card-icon" data-status={data.status}> <div className="cad-message-heading">
{running ? <Loader2 className="spin" size={16} /> : <CheckCircle2 size={16} />} {isRunning ? <Loader2 className="spin" size={14} aria-hidden="true" /> : isError ? <AlertTriangle size={14} aria-hidden="true" /> : <Check size={14} aria-hidden="true" />}
</div> <span>{data.label || data.step}</span>
<div className="cad-card-body"> <span className="cad-status-label">{statusLabel}</span>
<div className="cad-card-title">{data.label || data.step}</div>
{data.message ? <div className="cad-card-copy">{data.message}</div> : null}
</div> </div>
{data.message ? <div className="cad-message-copy">{data.message}</div> : null}
</div> </div>
); );
} }
@@ -29,44 +32,71 @@ export function CadResultPart({ data }: { data: CadResult }) {
["STEP", data.stepPath], ["STEP", data.stepPath],
["CDSL", data.cdslPath], ["CDSL", data.cdslPath],
["GLB", data.glbPath], ["GLB", data.glbPath],
["REPORT", data.reportPath], ["报告", data.reportPath],
]; ];
if (data.designIntentPath) downloads.push(["DESIGN INTENT", data.designIntentPath]);
return ( return (
<div className="cad-card cad-result-card"> <section className="cad-message cad-result" aria-label="CAD 结果">
<div className="cad-card-icon success"> <div className="cad-message-heading">
<CheckCircle2 size={16} /> <Box size={14} aria-hidden="true" />
<span>CAD 结果</span>
</div> </div>
<div className="cad-card-body"> <div className="cad-result-title">{data.summary || "生成完成"}</div>
<div className="cad-card-title">{data.summary || "生成完成"}</div>
<div className="cad-result-meta"> <div className="cad-result-meta">
<span>{data.engine}</span> <span>{data.engine}</span>
<span>{data.revisionId}</span> <span>{data.revisionId}</span>
<span>{data.referenceIds.length} references</span> <span>{data.referenceIds.length} 个参考</span>
</div> </div>
<div className="download-row"> <div className="download-row">
{downloads.map(([label, path]) => ( {downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download> <a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
<Download size={14} /> <Download size={13} aria-hidden="true" />
{label} {label}
</a> </a>
))} ))}
</div> </div>
</section>
);
}
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
return (
<section className="cad-message cad-image-analysis" aria-label="图像分析">
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span>图像分析</span></div>
<div className="cad-image-part-type">{data.partType}</div>
<div className="image-analysis-section">
<span>可见特征</span>
<p>{data.visibleFeatures.join(";") || "未识别到可确认特征"}</p>
</div> </div>
</div> {data.uncertainFeatures.length ? <div className="image-analysis-section">
<span>待确认特征</span>
<p>{data.uncertainFeatures.join(";")}</p>
</div> : null}
{dimensionCandidates.length ? <div className="image-analysis-section">
<span>可能需要确认的尺寸</span>
<ul className="image-dimension-list">
{dimensionCandidates.map((dimension) => <li key={dimension.id}>
<strong>{dimension.label}</strong>
<span>{dimension.reason}</span>
</li>)}
</ul>
</div> : null}
</section>
); );
} }
export function CadErrorPart({ data }: { data: CadError }) { export function CadErrorPart({ data }: { data: CadError }) {
const stageLabels: Record<string, string> = {
agent: "Agent",
attachment: "附件",
chat: "对话",
viewer: "预览",
};
const stage = stageLabels[data.stage] || data.stage || "CAD 操作";
return ( return (
<div className="cad-card cad-error-card"> <div className="cad-message cad-error" role="alert">
<div className="cad-card-icon error"> <div className="cad-message-heading"><AlertTriangle size={14} aria-hidden="true" /><span>{stage}失败</span></div>
<AlertTriangle size={16} /> <div className="cad-message-copy">{data.message}</div>
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.stage || "生成失败"}</div>
<div className="cad-card-copy">{data.message}</div>
</div>
</div> </div>
); );
} }
@@ -51,8 +51,6 @@ function resultFromBackend(payload: Record<string, unknown>): CadResult {
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined, parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined, selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined, edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
designIntentId: typeof payload.design_intent_id === "string" ? payload.design_intent_id : undefined,
designIntentPath: typeof payload.design_intent_path === "string" ? payload.design_intent_path : undefined,
summary: String(payload.summary || "Updated CDSL model"), summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [], referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"), engine: String(payload.engine || "cdsl_only"),
-2
View File
@@ -31,8 +31,6 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul
parametersPath: current.parameters_path, parametersPath: current.parameters_path,
selectorPath: current.selector_path, selectorPath: current.selector_path,
edgesPath: current.edges_path, edgesPath: current.edges_path,
designIntentId: current.design_intent_id,
designIntentPath: current.design_intent_path,
summary: current.summary || "CDSL CAD model", summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [], referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only", engine: current.engine || "cdsl_only",
+4 -1
View File
@@ -1,4 +1,4 @@
import type { CadError, CadProgress, CadResult, CadUIMessage } from "./cad-types"; import type { CadError, CadImageAnalysis, CadProgress, CadResult, CadUIMessage } from "./cad-types";
type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown }; type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown };
type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown }; type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown };
@@ -24,6 +24,9 @@ export function normalizeCadMessages(input: unknown): CadUIMessage[] {
if (item.type === "data-cad-error") { if (item.type === "data-cad-error") {
return [{ type: "data-cad-error", id: stringId(item.id), data: item.data as CadError }]; return [{ type: "data-cad-error", id: stringId(item.id), data: item.data as CadError }];
} }
if (item.type === "data-cad-image-analysis") {
return [{ type: "data-cad-image-analysis", id: stringId(item.id), data: item.data as CadImageAnalysis }];
}
return []; return [];
}); });
return [{ return [{
+14
View File
@@ -14,6 +14,20 @@ test("maps backend cad_result SSE into an AI SDK data part", () => {
assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" }); assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" });
}); });
test("maps structured image analysis SSE into an AI SDK data part", () => {
const data = {
attachmentIds: ["upload_flange"],
partType: "四孔法兰套筒",
visibleFeatures: ["中空圆筒"],
uncertainFeatures: [],
dimensionCandidates: [{ id: "bore_diameter", label: "中心孔直径", reason: "图片未标注" }],
};
const chunk = backendEventToUiChunk({ event: "image_analysis", data }, "text_1");
assert.equal(chunk?.type, "data-cad-image-analysis");
assert.deepEqual("data" in chunk! ? chunk.data : null, data);
});
test("restores the latest successful task revision for the viewer", () => { test("restores the latest successful task revision for the viewer", () => {
const result = latestSuccessfulResult({ const result = latestSuccessfulResult({
task_id: "cad_abc", task_id: "cad_abc",
+7
View File
@@ -33,5 +33,12 @@ export function backendEventToUiChunk(
data: item.data, data: item.data,
}; };
} }
if (item.event === "image_analysis") {
return {
type: "data-cad-image-analysis",
id: `image_analysis_${Date.now()}`,
data: item.data,
};
}
return null; return null;
} }
+18 -6
View File
@@ -17,8 +17,6 @@ export type CadResult = {
parametersPath?: string; parametersPath?: string;
selectorPath?: string; selectorPath?: string;
edgesPath?: string; edgesPath?: string;
designIntentId?: string;
designIntentPath?: string;
summary: string; summary: string;
referenceIds: string[]; referenceIds: string[];
engine: string; engine: string;
@@ -29,10 +27,27 @@ export type CadError = {
message: string; message: string;
}; };
export type CadImageDimension = {
id: string;
label: string;
reason: string;
};
export type CadImageAnalysis = {
attachmentIds: string[];
partType: string;
visibleFeatures: string[];
uncertainFeatures: string[];
dimensionCandidates?: CadImageDimension[];
// Legacy conversations stored this field before dimensions became optional.
requiredDimensions?: CadImageDimension[];
};
export type CadDataParts = { export type CadDataParts = {
"cad-progress": CadProgress; "cad-progress": CadProgress;
"cad-result": CadResult; "cad-result": CadResult;
"cad-error": CadError; "cad-error": CadError;
"cad-image-analysis": CadImageAnalysis;
}; };
export type CadUIMessage = UIMessage<unknown, CadDataParts>; export type CadUIMessage = UIMessage<unknown, CadDataParts>;
@@ -46,7 +61,7 @@ export type ConversationRecord = {
export type CadAttachment = { export type CadAttachment = {
id: string; id: string;
task_id: string; conversation_id: string;
name: string; name: string;
kind: "image" | "document"; kind: "image" | "document";
path: string; path: string;
@@ -66,8 +81,6 @@ export type TaskRevision = {
parameters_path?: string; parameters_path?: string;
selector_path?: string; selector_path?: string;
edges_path?: string; edges_path?: string;
design_intent_id?: string;
design_intent_path?: string;
summary?: string; summary?: string;
reference_ids?: string[]; reference_ids?: string[];
engine?: string; engine?: string;
@@ -77,7 +90,6 @@ export type TaskRevision = {
export type TaskRecord = { export type TaskRecord = {
task_id: string; task_id: string;
current_revision: string; current_revision: string;
current_design_intent_id?: string;
revisions: TaskRevision[]; revisions: TaskRevision[];
}; };