修改agent流程,分步构建cdsl

This commit is contained in:
2026-08-27 18:59:33 +08:00
parent 7fc1dfa84d
commit b26db86d78
54 changed files with 6835 additions and 9984 deletions
@@ -1,15 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function POST(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/modify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(await request.json()),
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -1,22 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/parameters`);
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/parameters`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(await request.json()),
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -1,11 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/quality`);
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
+3
View File
@@ -403,6 +403,9 @@ button:disabled {
.generation-status li[data-status="completed"] small { color: var(--ui-success); }
.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); }
.generation-status li[data-status="failed"] small { color: var(--ui-error); }
.generation-status details { margin: 8px 0; border-top: 1px solid var(--ui-border-muted); padding-top: 6px; }
.generation-status summary { cursor: pointer; color: var(--ui-text-strong); }
.generation-status pre { margin: 6px 0 0; max-height: 132px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ui-text-muted); }
.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; }
+15 -12
View File
@@ -156,7 +156,7 @@ export function AgentStudio() {
});
}
}
if (progress.step === "generation_plan" && progress.status === "running") setTaskRunning(true);
if (progress.step === "requirements_document" && progress.status === "running") setTaskRunning(true);
if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running");
}, [conversationId, syncUrl]);
@@ -208,7 +208,7 @@ export function AgentStudio() {
if (preview) setCadResult(preview);
} else {
const restored = latestSuccessfulResult(next);
if (restored) setCadResult(restored);
setCadResult(restored);
}
} catch {
// Keep the persisted lock until a later poll can prove a terminal state.
@@ -519,7 +519,7 @@ function StudioShell({
<header className="app-header">
<div className="app-brand"><Box size={16} /><strong>CDSL CAD Studio</strong>{cadResult ? <span className="task-badge">{cadResult.taskId}</span> : null}</div>
<div className="app-controls">
<select aria-label="模型提供商" value={providerId} disabled={running} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
<select aria-label="模型提供商" value={providerId} disabled={running} onChange={(event) => { const id = event.target.value; const models = config?.providers.find((item) => item.id === id)?.models || []; onProviderChange(id); onModelChange(models[0]?.id || ""); }}>
{config?.providers.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
<select aria-label="模型" value={modelId} disabled={running} onChange={(event) => onModelChange(event.target.value)}>
@@ -529,12 +529,12 @@ function StudioShell({
</div>
</header>
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null}
{config?.incremental_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>视觉复核未配置,增量任务会拒绝启动:{config.review_error || "请配置独立视觉模型与 Chromium。"}</span></div> : null}
{config?.autonomous_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} /></aside>
<section className="preview-pane">
<GenerationStatus task={taskRecord} />
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={handleViewerError} onSelectionChange={onSelectionChange} />
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
</section>
</div>
</main>
@@ -542,16 +542,19 @@ function StudioShell({
}
function GenerationStatus({ task }: { task: TaskRecord | null }) {
const plan = task?.generation_plan;
const nodes = Array.isArray(plan?.nodes) ? plan.nodes.filter((node): node is Record<string, unknown> => Boolean(node && typeof node === "object")) : [];
if (task?.lifecycle !== "running") return null;
const agent = task.agent_state;
const events = agent?.recent_events || [];
return (
<aside className="generation-status" aria-live="polite">
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span>生成检查点</span></div>
<div className="generation-status-active">{task.active_node_id || "正在生成计划"}</div>
{nodes.length ? <ul>{nodes.map((node) => <li key={String(node.id || "node")} data-status={String(node.status || "planned")}>
<span>{String(node.id || "node")}</span><small>{String(node.status || "planned")}</small>
</li>)}</ul> : null}
<div className="generation-status-heading"><Loader2 className="spin" size={14} /><span>自主建模</span></div>
<div className="generation-status-active">{task.active_candidate_id ? `候选 ${task.active_candidate_id}` : task.active_revision || "正在编写冻结需求"}</div>
{task.requirements_markdown ? <details open><summary>需求文档</summary><pre>{task.requirements_markdown}</pre></details> : null}
{task.completion_checklist_markdown ? <details open><summary>完成清单</summary><pre>{task.completion_checklist_markdown}</pre></details> : null}
{agent?.completion_ledger?.items?.length ? <ul className="completion-ledger">{agent.completion_ledger.items.map((item, index) => <li key={`${item.item || "item"}-${index}`}><span>{item.status === "complete" ? "完成" : item.status === "uncertain" ? "待确认" : "缺失"}</span><small>{item.item}{item.evidence ? `:${item.evidence}` : ""}</small></li>)}</ul> : null}
{agent?.last_review ? <div className="generation-status-active">步骤审查:{agent.last_review.decision || "已记录"}</div> : null}
{agent?.last_diagnostic ? <div className="generation-status-active">{agent.last_diagnostic}</div> : null}
{events.length ? <ul>{events.slice(-6).map((item, index) => <li key={`${item.at || "event"}-${index}`}><span>{item.kind || item.tool || "工具"}</span><small>{item.message || "已更新"}</small></li>)}</ul> : null}
</aside>
);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, P
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: {
attachments: CadAttachment[];
@@ -95,7 +95,7 @@ function AssistantMessage() {
<MessagePrimitive.Root className="message-row assistant-row">
<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.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart } } }} />
<MessagePrimitive.Error>
<div className="message-request-error" role="alert"><CircleAlert size={14} aria-hidden="true" /> Agent 请求失败,请检查服务端日志和模型配置。</div>
</MessagePrimitive.Error>
+2 -116
View File
@@ -1,9 +1,8 @@
"use client";
import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react";
import { useEffect, useState } from "react";
import { AlertTriangle, Box, Check, Download, Loader2 } from "lucide-react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null;
@@ -35,16 +34,6 @@ export function CadResultPart({ data }: { data: CadResult }) {
["GLB", data.glbPath],
["报告", data.reportPath],
];
if (!data.checkpoint && data.qualityPath) downloads.push(["质量报告", data.qualityPath]);
if (!data.checkpoint && data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]);
const qualityLabels: Record<string, string> = {
accepted: "验收通过",
built_with_warnings: "构建完成,有警告",
needs_repair: "需要修复",
blocked: "已阻塞",
failed: "失败",
};
const quality = String(data.qualityStatus || "");
return (
<section className="cad-message cad-result" aria-label="CAD 结果">
<div className="cad-message-heading">
@@ -56,12 +45,8 @@ export function CadResultPart({ data }: { data: CadResult }) {
<span>{data.engine}</span>
<span>{data.revisionId}</span>
<span>{data.referenceIds.length} 个参考</span>
{quality ? <span className={`cad-quality-status quality-${quality}`}>{qualityLabels[quality] || quality}</span> : null}
</div>
{data.assumptions?.length ? <div className="cad-result-notes"><strong>假设</strong><span>{data.assumptions.join(";")}</span></div> : null}
{data.referenceIds.length ? <div className="cad-result-notes"><strong>参考</strong><span>{data.referenceIds.join(";")}</span></div> : null}
{!data.checkpoint ? <QualitySummary data={data} /> : null}
{data.snapshotStatus && data.snapshotStatus !== "unavailable" ? <div className="cad-result-notes"><strong>快照</strong><span>{data.snapshotStatus}</span></div> : null}
{downloads.length ? <div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download aria-label={`下载 ${label}`}>
@@ -74,105 +59,6 @@ export function CadResultPart({ data }: { data: CadResult }) {
);
}
type QualityResult = { id?: string; status?: string; severity?: string; expected?: unknown; actual?: unknown };
type QualityPayload = { quality?: { results?: QualityResult[] }; quality_status?: string };
function QualitySummary({ data }: { data: CadResult }) {
const [quality, setQuality] = useState<QualityPayload | null>(null);
useEffect(() => {
if (!data.qualityPath) {
setQuality(null);
return;
}
const controller = new AbortController();
void fetch(`/api/tasks/${encodeURIComponent(data.taskId)}/quality`, { signal: controller.signal })
.then((response) => response.ok ? response.json() as Promise<QualityPayload> : null)
.then((payload) => { if (!controller.signal.aborted) setQuality(payload); })
.catch(() => { if (!controller.signal.aborted) setQuality(null); });
return () => controller.abort();
}, [data.qualityPath, data.revisionId, data.taskId]);
const results = quality?.quality?.results || [];
if (!results.length) return null;
const label: Record<string, string> = { passed: "通过", failed: "未通过", unavailable: "不可用" };
return (
<div className="cad-quality-results" aria-label="通用验证结果">
<strong>验证</strong>
<ul>
{results.map((result, index) => (
<li key={`${result.id || "rule"}-${index}`} className={`quality-rule-${result.status || "unavailable"}`}>
<span>{result.id || "rule"}</span>
<span>{label[result.status || ""] || result.status || "不可用"}</span>
{result.severity && result.severity !== "blocking" ? <span>{result.severity}</span> : null}
</li>
))}
</ul>
</div>
);
}
export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) {
const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? [];
const profileCount = data.profiles?.length || 0;
const holeCount = data.holes?.length || 0;
const viewCount = data.views?.length || data.attachmentIds.length;
return (
<section className="cad-message cad-image-analysis" aria-label="图像分析">
<div className="cad-message-heading"><Ruler size={14} aria-hidden="true" /><span>{data.observationStage === "survey" ? "图片勘测" : data.observationStage === "sketch" ? "草图候选" : "图像分析"}</span></div>
<div className="cad-image-part-type">{data.partType}</div>
<div className="image-analysis-section">
<span>可见特征</span>
<p>{data.visibleFeatures.join(";") || "未识别到可确认特征"}</p>
</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}
{(data.views?.length || profileCount || holeCount || data.measurements?.length || data.assumptions?.length) ? (
<details className="image-analysis-details">
<summary>勘测详情</summary>
<div className="image-analysis-section">
<span>视角与几何</span>
<p>{viewCount} 个视角,{profileCount} 个轮廓,{holeCount} 个孔/切除特征</p>
</div>
{data.profiles?.length ? <div className="image-analysis-section">
<span>草图候选</span>
<ul className="image-dimension-list">
{data.profiles.slice(0, 12).map((profile) => <li key={profile.id}>
<strong>{profile.id}</strong>
<span>{profile.segments?.length || 0} 段,{profile.closed ? "闭合" : "未确认闭合"}{profile.confidence == null ? "" : `,置信度 ${Math.round(profile.confidence * 100)}%`}</span>
</li>)}
</ul>
</div> : null}
{data.measurements?.length ? <div className="image-analysis-section">
<span>测量记录</span>
<ul className="image-dimension-list">
{data.measurements.slice(0, 16).map((measurement, index) => <li key={`${measurement.name}-${index}`}>
<strong>{measurement.name}</strong>
<span>{measurement.value_mm == null ? "待确认" : `${measurement.value_mm} mm`} · {measurement.source || "image"}</span>
</li>)}
</ul>
</div> : null}
{data.uncertainties?.length ? <div className="image-analysis-section">
<span>勘测不确定项</span>
<p>{data.uncertainties.slice(0, 16).join(";")}</p>
</div> : null}
</details>
) : null}
</section>
);
}
export function CadErrorPart({ data }: { data: CadError }) {
const stageLabels: Record<string, string> = {
agent: "Agent",
+13 -483
View File
@@ -1,36 +1,27 @@
"use client";
import { AlertTriangle, Box, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import CadViewer from "@/viewer-port/components/CadViewer";
import { RENDER_FORMAT } from "@/viewer-port/workbench/constants";
import { loadRenderGlb, loadRenderJson } from "@/viewer-runtime/lib/renderAssetClient";
import { loadRenderGlb } from "@/viewer-runtime/lib/renderAssetClient";
import { cloneThemePresetSettings } from "@/viewer-runtime/lib/themeSettings";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import { buildCdslSelectorRuntime } from "@/lib/cdsl-selector-runtime";
import { cadEditToolForOperation, cadEditToolNextPickKind, cadEditToolPickComplete, defaultCadEditParameters, type AiSelectionMode } from "@/lib/cad-edit-tools";
import type { CadResult } from "@/lib/cad-types";
import { buildViewerSelectionContext, type ViewerSelectionContext } from "@/lib/viewer-selection";
import { EmbeddedCadEditToolbar, EmbeddedCadViewToolbar } from "./embedded-cad-toolbar";
import type { ViewerSelectionContext } from "@/lib/viewer-selection";
import { EmbeddedCadViewToolbar } from "./embedded-cad-toolbar";
import { GenerationEdgeGlow } from "./generation-edge-glow";
import { ParameterPanel } from "./parameter-panel";
type Props = {
result: CadResult | null;
isGenerating: boolean;
lastError?: string;
theme: "light" | "dark";
onResult: (result: CadResult) => void;
onError: (message: string) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
};
type SelectorRuntime = ReturnType<typeof buildCdslSelectorRuntime>;
type LoadState = { kind: "empty" | "loading" | "error" } | { kind: "ready"; meshData: unknown; selectorRuntime: SelectorRuntime | null };
type AiSelectionDraft = {
active?: boolean;
points?: Array<{ x: number; y: number }>;
} | null;
type LoadState = { kind: "empty" | "loading" | "error" } | { kind: "ready"; meshData: unknown };
const VIEWER_THEME = (() => {
const base = cloneThemePresetSettings("workbench") as Record<string, unknown>;
@@ -43,181 +34,13 @@ const VIEWER_THEME = (() => {
};
})();
function resultFromBackend(payload: Record<string, unknown>): CadResult {
return {
taskId: String(payload.task_id), revisionId: String(payload.revision_id),
cdslPath: String(payload.cdsl_path), stepPath: String(payload.step_path),
glbPath: String(payload.glb_path), reportPath: String(payload.report_path),
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
topologyPath: typeof payload.topology_path === "string" ? payload.topology_path : undefined,
summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"),
qualityStatus: String(payload.quality_status || ""),
qualityPath: typeof payload.quality_path === "string" ? payload.quality_path : undefined,
assumptions: Array.isArray(payload.generation_assumptions) ? payload.generation_assumptions.map(String) : [],
warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : [],
repairAttempts: Number(payload.repair_attempts || 0),
snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [],
snapshotStatus: String(payload.snapshot_status || "unavailable"),
checkpoint: Boolean(payload.checkpoint),
lifecycle: typeof payload.lifecycle === "string" ? payload.lifecycle : undefined,
};
}
function finiteClientPoint(pick: Record<string, unknown> | null) {
const x = Number(pick?.clientX);
const y = Number(pick?.clientY);
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}
function editToolPickMarkerLabel(activeToolId: string, index: number) {
if (activeToolId === "add_slot") return index === 0 ? "起点" : "终点";
if (activeToolId === "add_hole_pattern") return "中心";
return "位置";
}
function editParameter(parameters: Record<string, string | number>, name: string, fallback: number) {
const value = Number(parameters[name]);
return Number.isFinite(value) ? value : fallback;
}
function previewPixels(valueMm: number, scale = 5, min = 12, max = 180) {
const value = Math.abs(valueMm);
return Number.isFinite(value) && value > 0 ? Math.min(Math.max(value * scale, min), max) : min;
}
function AiSelectionOverlay({ draft }: { draft: AiSelectionDraft }) {
const points = draft?.active && Array.isArray(draft.points) ? draft.points : [];
const polylinePoints = points.map((point) => `${Number(point.x)},${Number(point.y)}`).join(" ");
if (!polylinePoints) return null;
return (
<div className="pointer-events-none fixed inset-0 z-[26]" aria-hidden="true">
<svg className="fixed inset-0 size-full overflow-visible">
<polyline
points={polylinePoints}
fill="none"
className="stroke-[var(--ui-accent)] opacity-[0.85]"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function EditToolPickOverlay({
activeToolId,
picks,
hoverPick,
parameters,
}: {
activeToolId: string;
picks: Record<string, unknown>[];
hoverPick: Record<string, unknown> | null;
parameters: Record<string, string | number>;
}) {
if (!activeToolId) return null;
const selected = picks.flatMap((pick, index) => {
const point = finiteClientPoint(pick);
return point ? [{ pick, index, point, hover: false }] : [];
});
const hoverPoint = finiteClientPoint(hoverPick);
const hover = hoverPoint && !selected.some((entry) => Math.hypot(entry.point.x - hoverPoint.x, entry.point.y - hoverPoint.y) < 1)
? { pick: hoverPick || {}, index: selected.length, point: hoverPoint, hover: true }
: null;
const points = hover ? [...selected, hover] : selected;
if (!points.length) return null;
const slot = activeToolId === "add_slot" && points.length >= 2
? { start: points[0].point, end: points[1].point, preview: points[1].hover }
: null;
const anchor = activeToolId === "add_slot" ? null : (selected.at(-1)?.point || hover?.point || null);
const tool = activeToolId === "add_counterbore" ? "double" : activeToolId;
const circle = anchor && ["add_hole", "double", "add_countersink", "add_circular_pocket"].includes(tool)
? {
point: anchor,
inner: previewPixels(editParameter(parameters, tool === "add_circular_pocket" ? "diameter" : "holeDiameter", tool === "add_circular_pocket" ? 8 : 3)),
outer: tool === "double"
? previewPixels(editParameter(parameters, "counterboreDiameter", 6))
: tool === "add_countersink"
? previewPixels(editParameter(parameters, "countersinkDiameter", 6))
: 0,
dashed: tool === "add_countersink",
}
: null;
const pocket = anchor && activeToolId === "add_pocket"
? { point: anchor, width: previewPixels(editParameter(parameters, "width", 10), 4, 24, 240), height: previewPixels(editParameter(parameters, "height", 6), 4, 18, 180) }
: null;
const pattern = anchor && activeToolId === "add_hole_pattern"
? {
point: anchor,
rows: Math.min(Math.max(Math.round(editParameter(parameters, "rows", 2)), 1), 8),
columns: Math.min(Math.max(Math.round(editParameter(parameters, "columns", 2)), 1), 8),
pitchX: previewPixels(editParameter(parameters, "pitchX", 8), 3, 18, 90),
pitchY: previewPixels(editParameter(parameters, "pitchY", 8), 3, 18, 90),
diameter: previewPixels(editParameter(parameters, "holeDiameter", 2), 4, 8, 48),
}
: null;
const slotWidth = slot ? previewPixels(editParameter(parameters, "slotWidth", 2), 5, 12, 80) : 0;
return (
<div className="pointer-events-none fixed inset-0 z-[25]" aria-hidden="true">
{(slot || circle || pocket || pattern) ? (
<svg className="fixed inset-0 size-full overflow-visible">
{slot ? <>
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className="stroke-[var(--ui-accent-soft)]" strokeWidth={slotWidth} strokeLinecap="round" />
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className={slot.preview ? "stroke-[var(--ui-accent)] opacity-65" : "stroke-[var(--ui-accent)] opacity-90"} strokeWidth="2.5" strokeLinecap="round" strokeDasharray={slot.preview ? "7 5" : "none"} />
</> : null}
{circle ? <>
{circle.outer ? <circle cx={circle.point.x} cy={circle.point.y} r={circle.outer / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.dashed ? "7 4" : "none"} /> : null}
<circle cx={circle.point.x} cy={circle.point.y} r={circle.inner / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.outer ? "4 4" : "5 4"} />
</> : null}
{pocket ? <rect x={pocket.point.x - pocket.width / 2} y={pocket.point.y - pocket.height / 2} width={pocket.width} height={pocket.height} rx="6" className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray="6 4" /> : null}
{pattern ? Array.from({ length: pattern.rows * pattern.columns }, (_, index) => {
const row = Math.floor(index / pattern.columns);
const column = index % pattern.columns;
return <circle key={index} cx={pattern.point.x + (column - (pattern.columns - 1) / 2) * pattern.pitchX} cy={pattern.point.y + (row - (pattern.rows - 1) / 2) * pattern.pitchY} r={pattern.diameter / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="1.5" strokeDasharray="4 3" />;
}) : null}
</svg>
) : null}
{points.map(({ index, point, hover: isHover }) => (
<div key={`${index}:${point.x}:${point.y}`} className="fixed" style={{ left: `${point.x}px`, top: `${point.y}px`, transform: "translate(-50%, -50%)" }}>
<span className={`absolute left-1/2 top-1/2 h-12 w-px -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
<span className={`absolute left-1/2 top-1/2 h-px w-12 -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
<span className={`absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-[var(--ui-accent-soft)] shadow-[0_0_22px_var(--ui-accent-muted)] ${isHover ? "border-[var(--ui-accent-border)] border-dashed opacity-75" : "border-[var(--ui-accent-border)]"}`} />
<span className={`relative block rounded-full border border-[var(--ui-text-inverse)] bg-[var(--ui-accent)] shadow-[var(--ui-shadow-soft)] ${isHover ? "size-2 opacity-70" : "size-3"}`} />
<span className="absolute left-4 top-3 whitespace-nowrap rounded-full border border-[var(--ui-accent-border)] bg-[var(--ui-glass-popover)] px-2 py-0.5 text-[10px] font-semibold text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)]">{isHover ? "预选" : editToolPickMarkerLabel(activeToolId, index)}</span>
</div>
))}
</div>
);
}
export function CadViewerPreview({ result, isGenerating, lastError, theme, onResult, onError, onSelectionChange }: Props) {
export function CadViewerPreview({ result, isGenerating, lastError, theme, onError, onSelectionChange }: Props) {
const viewerRef = useRef<{ captureScreenshot?: (options?: unknown) => Promise<void>; zoomToFit?: () => void } | null>(null);
// A task restored from the URL should appear quietly after a page refresh.
// Subsequent revisions in this mounted workspace still receive completion feedback.
const suppressInitialReveal = useRef(Boolean(result));
const [loadState, setLoadState] = useState<LoadState>({ kind: "empty" });
const [activeTool, setActiveTool] = useState("");
const [editPicks, setEditPicks] = useState<Record<string, unknown>[]>([]);
const [editParameters, setEditParameters] = useState<Record<string, string | number>>({});
const [editHoverPick, setEditHoverPick] = useState<Record<string, unknown> | null>(null);
const [editSelectionReady, setEditSelectionReady] = useState(false);
const [aiSelectionDraft, setAiSelectionDraft] = useState<AiSelectionDraft>(null);
const [hoveredReferenceId, setHoveredReferenceId] = useState("");
const [selectedReferenceIds, setSelectedReferenceIds] = useState<string[]>([]);
const [selectionMode, setSelectionMode] = useState<AiSelectionMode>("point");
const [editPending, setEditPending] = useState(false);
const [reveal, setReveal] = useState(0);
const [showParameters, setShowParameters] = useState(false);
const [parameters, setParameters] = useState<Record<string, unknown>[]>([]);
const [parameterPending, setParameterPending] = useState("");
const [parameterError, setParameterError] = useState("");
useEffect(() => {
if (!result) {
@@ -227,42 +50,11 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
const controller = new AbortController();
setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" });
const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath);
const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : "";
const topologyUrl = result.topologyPath ? encodeArtifactUrl(result.taskId, result.topologyPath) : "";
void Promise.all([
loadRenderGlb(glbUrl),
selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null),
topologyUrl ? loadRenderJson(topologyUrl).catch(() => null) : Promise.resolve(null),
])
.then(([meshData, selectorSidecar, topologySnapshot]) => {
void loadRenderGlb(glbUrl)
.then((meshData) => {
if (controller.signal.aborted) return;
const topologyMatchesRevision = topologySnapshot && typeof topologySnapshot === "object"
&& String((topologySnapshot as Record<string, unknown>).task_id || "") === result.taskId
&& String((topologySnapshot as Record<string, unknown>).revision_id || "") === result.revisionId;
const rawTopologyRecords = topologyMatchesRevision ? (topologySnapshot as Record<string, unknown>).records : null;
const topologyRecords = Array.isArray(rawTopologyRecords)
? rawTopologyRecords.filter((record): record is Record<string, unknown> => Boolean(record && typeof record === "object"))
: [];
const selectorPayload = selectorSidecar && typeof selectorSidecar === "object"
? {
...(selectorSidecar as Record<string, unknown>),
edges: Array.isArray((selectorSidecar as Record<string, unknown>).edges)
&& ((selectorSidecar as Record<string, unknown>).edges as unknown[]).length
? (selectorSidecar as Record<string, unknown>).edges
: topologyRecords.filter((record) => record.kind === "edge" && record.executable !== false),
}
: null;
const selectorRuntime = selectorPayload
? buildCdslSelectorRuntime(selectorPayload as Parameters<typeof buildCdslSelectorRuntime>[0], meshData)
: null;
setLoadState({ kind: "ready", meshData, selectorRuntime });
setHoveredReferenceId("");
setSelectedReferenceIds([]);
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
setAiSelectionDraft(null);
onSelectionChange(null);
setLoadState({ kind: "ready", meshData });
if (suppressInitialReveal.current) {
suppressInitialReveal.current = false;
} else {
@@ -275,7 +67,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed");
});
return () => controller.abort();
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId, result?.topologyPath]);
}, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.taskId]);
useEffect(() => {
if (!reveal) return;
@@ -283,152 +75,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
return () => window.clearTimeout(timer);
}, [reveal]);
useEffect(() => {
if (!result || result.checkpoint || isGenerating) {
setParameters([]);
setShowParameters(false);
return;
}
const controller = new AbortController();
setParameterError("");
void fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, { signal: controller.signal })
.then(async (response) => {
if (response.status === 404) return [];
if (!response.ok) throw new Error(await response.text());
const payload = await response.json() as { parameters?: Record<string, unknown>[] };
return Array.isArray(payload.parameters) ? payload.parameters : [];
})
.then((next) => { if (!controller.signal.aborted) setParameters(next); })
.catch((error: unknown) => {
if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数");
});
return () => controller.abort();
}, [isGenerating, result?.checkpoint, result?.revisionId, result?.taskId]);
const submitEdit = useCallback(async (operation: string, picks: Record<string, unknown>[]) => {
if (!result || !operation || !picks.length) return;
setEditPending(true);
try {
const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/modify`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ operation, selection: { pick: picks[0], picks }, parameters: editParameters }),
});
const payload = await response.json() as Record<string, unknown> & { error?: string };
if (!response.ok) throw new Error(payload.error || "Direct CAD edit failed");
onResult(resultFromBackend(payload));
setActiveTool("");
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
} catch (error) {
onError(error instanceof Error ? error.message : "Direct CAD edit failed");
} finally {
setEditPending(false);
}
}, [editParameters, onError, onResult, result]);
const onEditPick = useCallback((pick: Record<string, unknown> | null) => {
if (!activeTool || editPending) return;
if (!pick) {
onError("未命中可编辑平面,请在模型实体表面点击。");
return;
}
setEditPicks((current) => {
if (cadEditToolPickComplete(activeTool, current)) return current;
const next = [...current, pick];
const referenceId = typeof pick.referenceId === "string" ? pick.referenceId : "";
if (referenceId) setSelectedReferenceIds([referenceId]);
setEditHoverPick(null);
setEditSelectionReady(cadEditToolPickComplete(activeTool, next));
return next;
});
}, [activeTool, editPending, onError]);
const handleAiSelectionDraftChange = useCallback((draft: AiSelectionDraft) => {
setAiSelectionDraft((current) => {
const currentPoints = current?.points || [];
const nextPoints = draft?.points || [];
if (current?.active === draft?.active && currentPoints.length === nextPoints.length && currentPoints.every((point, index) => point.x === nextPoints[index]?.x && point.y === nextPoints[index]?.y)) {
return current;
}
return draft;
});
}, []);
const cancelEdit = useCallback(() => {
setActiveTool("");
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
setSelectedReferenceIds([]);
setSelectionMode("point");
}, []);
const onAiSelectionComplete = useCallback((selection: Record<string, unknown> | null) => {
const referenceIds = Array.isArray(selection?.referenceIds)
? selection.referenceIds.filter((value): value is string => typeof value === "string" && value.length > 0)
: [];
setSelectedReferenceIds(referenceIds);
if (!selection || !referenceIds.length || loadState.kind !== "ready" || !result) {
onSelectionChange(null);
return;
}
onSelectionChange(buildViewerSelectionContext({
selection,
references: loadState.selectorRuntime?.references || [],
taskId: result.taskId,
revisionId: result.revisionId,
}));
}, [loadState, onSelectionChange, result]);
const handleActivateReference = useCallback((referenceId: string) => {
const normalized = String(referenceId || "").trim();
setSelectedReferenceIds(normalized ? [normalized] : []);
if (!normalized || loadState.kind !== "ready" || !result) {
onSelectionChange(null);
return;
}
onSelectionChange(buildViewerSelectionContext({
selection: {
kind: "single_reference_activation",
selectionMode: "point",
referenceIds: [normalized],
},
references: loadState.selectorRuntime?.references || [],
taskId: result.taskId,
revisionId: result.revisionId,
}));
}, [loadState, onSelectionChange, result]);
const commitParameters = useCallback(async (values: Record<string, number>) => {
if (!result || result.checkpoint || isGenerating || !Object.keys(values).length) return;
const parameterId = Object.keys(values)[0];
setParameterPending(parameterId);
setParameterError("");
try {
const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values }),
});
const payload = await response.json() as Record<string, unknown> & { error?: string };
if (!response.ok) throw new Error(payload.error || "参数更新失败");
onResult(resultFromBackend(payload));
} catch (error) {
setParameterError(error instanceof Error ? error.message : "参数更新失败");
} finally {
setParameterPending("");
}
}, [isGenerating, onResult, result]);
const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]);
const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null;
const pickableFaces = useMemo(
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
[isGenerating, loadState, result?.checkpoint]
);
const pickableEdges = useMemo(
() => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.edges || [] : [],
[isGenerating, loadState, result?.checkpoint]
);
if (loadState.kind === "empty") return <ViewerState icon={<Box size={20} />} text="3D 预览等待模型" />;
if (loadState.kind === "loading") return <ViewerState icon={<Loader2 className="spin" size={20} />} text="加载 CAD Viewer 资产..." />;
if (loadState.kind === "error") return <ViewerState icon={<AlertTriangle size={20} />} text={lastError || "CAD Viewer 资产加载失败"} error />;
@@ -450,133 +97,16 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes
// The enclosing studio owns completion feedback. Enabling CadViewer's
// shader reveal here would play a second, separate animation.
generationIntroEnabled={false}
hoveredReferenceId={hoveredReferenceId}
selectedReferenceIds={selectedReferenceIds}
selectorRuntime={loadState.selectorRuntime}
pickableFaces={pickableFaces}
pickableEdges={pickableEdges}
onHoverReferenceChange={setHoveredReferenceId}
onActivateReference={handleActivateReference}
editPointPickEnabled={Boolean(activeTool) && !isGenerating && !result?.checkpoint}
activeEditToolId={activeTool}
editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)}
editToolPicks={editPicks}
editToolHoverPick={editHoverPick}
editToolParameters={editParameters}
onEditToolPick={onEditPick}
onEditToolHover={setEditHoverPick}
aiSelectionMode={selectionMode === "none" ? "" : selectionMode}
onAiSelectionDraftChange={handleAiSelectionDraftChange}
onAiSelectionComplete={onAiSelectionComplete}
/>
<AiSelectionOverlay draft={aiSelectionDraft} />
{!isGenerating && !result?.checkpoint ? <EditToolPickOverlay
activeToolId={activeTool}
picks={editPicks}
hoverPick={editHoverPick}
parameters={editParameters}
/> : null}
<EmbeddedCadEditToolbar
activeToolId={activeTool}
aiSelectionMode={selectionMode}
disabled={!result || editPending || isGenerating || Boolean(result?.checkpoint)}
unavailableToolIds={["add_chamfer", "add_fillet"]}
onSelectTool={(tool) => {
setActiveTool(tool);
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
setAiSelectionDraft(null);
setHoveredReferenceId("");
setSelectedReferenceIds([]);
setEditParameters(tool ? defaultCadEditParameters(tool) : {});
setSelectionMode(tool ? "none" : "point");
}}
onSelectionModeChange={(mode) => {
setSelectionMode(mode);
if (mode !== "lasso") setAiSelectionDraft(null);
}}
editPointPickEnabled={false}
activeEditToolId=""
/>
<EmbeddedCadViewToolbar
disabled={!result || isGenerating}
onResetView={() => viewerRef.current?.zoomToFit?.()}
onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })}
onParameters={() => setShowParameters(true)}
/>
{editPending || isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}</span></div> : null}
{activeToolDefinition && !isGenerating && !result?.checkpoint ? (
<div className="absolute bottom-3 left-3 z-30 w-[236px] border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)] backdrop-blur" data-viewer-interaction-overlay="true">
<div className="mb-2 text-xs font-semibold">{activeToolDefinition.label}</div>
<div className="grid gap-2">
{activeToolDefinition.parameterFields.map((field) => (
<label className="grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 text-[11px] text-[var(--ui-text-muted)]" key={field.name}>
<span>{field.label}</span>
{field.type === "select" ? (
<select
className="h-7 min-w-0 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
value={String(editParameters[field.name] ?? "")}
onChange={(event) => setEditParameters((current) => ({ ...current, [field.name]: event.target.value }))}
>
{(field.options || []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : (
<div className="flex min-w-0 items-center gap-1">
<input
className="h-7 min-w-0 flex-1 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
type={field.type === "number" ? "number" : "text"}
min={field.min}
step={field.step}
value={String(editParameters[field.name] ?? "")}
onChange={(event) => setEditParameters((current) => ({
...current,
[field.name]: field.type === "number" ? Number(event.target.value) : event.target.value,
}))}
/>
{field.unit ? <span className="w-5 text-[10px]">{field.unit}</span> : null}
</div>
)}
</label>
))}
</div>
<div className="mt-2 text-[10px] text-[var(--ui-text-subtle)]">{editPicks.length}/{activeToolDefinition.pickKinds.length} 个几何点已选择</div>
<div className="mt-3 flex justify-end gap-2 border-t border-[var(--ui-border)] pt-2">
<button
type="button"
className="h-7 border border-[var(--ui-border-strong)] px-2 text-[11px] text-[var(--ui-text-muted)] hover:bg-[var(--ui-control-hover)]"
onClick={cancelEdit}
>
取消
</button>
<button
type="button"
disabled={!editSelectionReady || editPending}
className="h-7 border border-[var(--ui-accent-border)] bg-[var(--ui-accent-soft)] px-2 text-[11px] text-[var(--ui-accent-text)] transition hover:bg-[var(--ui-accent-muted)] disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void submitEdit(activeTool, editPicks)}
>
应用
</button>
</div>
</div>
) : null}
{isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>正在生成 CDSL 模型...</span></div> : null}
{reveal > 0 ? <GenerationEdgeGlow /> : null}
{showParameters && result ? (
<div className="absolute inset-y-0 right-0 z-40 w-full max-w-[360px] shadow-[var(--ui-shadow-panel)]">
<ParameterPanel
parameters={parameters}
pendingParameter={parameterPending}
error={parameterError}
onClose={() => setShowParameters(false)}
onCommit={(id, value) => void commitParameters({ [id]: value })}
onReset={(values) => void commitParameters(values)}
downloads={result.checkpoint ? [] : [
{ label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) },
{ label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) },
{ label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) },
{ label: "REPORT", description: "Rebuild validation", url: encodeArtifactUrl(result.taskId, result.reportPath) },
]}
/>
</div>
) : null}
</div>
);
}
@@ -1,45 +1,15 @@
"use client";
import type { ReactNode } from "react";
import {
CircleDot,
CircleDotDashed,
CornerDownRight,
Disc,
Drill,
Focus,
Grid2X2Plus,
LassoSelect,
MousePointerClick,
Orbit,
Radius,
SlidersHorizontal,
SquareDashed,
SquareSplitHorizontal,
} from "lucide-react";
import { AiSelectionMode, CAD_EDIT_TOOLS } from "@/lib/cad-edit-tools";
const EDIT_TOOL_ICONS = {
add_hole: Drill,
add_counterbore: CircleDot,
add_countersink: CircleDotDashed,
add_slot: SquareSplitHorizontal,
add_pocket: SquareDashed,
add_circular_pocket: Disc,
add_hole_pattern: Grid2X2Plus,
add_chamfer: CornerDownRight,
add_fillet: Radius,
};
import { Focus, Orbit } from "lucide-react";
function ToolbarButton({
label,
active = false,
disabled = false,
children,
onClick,
}: {
label: string;
active?: boolean;
disabled?: boolean;
children: ReactNode;
onClick?: () => void;
@@ -48,15 +18,11 @@ function ToolbarButton({
<button
type="button"
aria-label={label}
aria-pressed={active}
title={label}
disabled={disabled}
onClick={onClick}
className={[
"grid size-7 place-items-center rounded-md border text-[var(--ui-text-muted)] transition",
active
? "border-[var(--ui-border-strong)] bg-[var(--ui-control-hover)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-inset)]"
: "border-transparent hover:border-[var(--ui-border-strong)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
"grid size-7 place-items-center rounded-md border border-transparent text-[var(--ui-text-muted)] transition hover:border-[var(--ui-border-strong)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
disabled ? "cursor-not-allowed opacity-40 hover:border-transparent hover:bg-transparent" : "",
].join(" ")}
>
@@ -65,114 +31,29 @@ function ToolbarButton({
);
}
function Divider() {
return <span className="mx-0.5 h-5 w-px shrink-0 bg-[var(--ui-border-strong)]" aria-hidden="true" />;
}
export function EmbeddedCadEditToolbar({
activeToolId,
aiSelectionMode,
disabled = false,
unavailableToolIds = [],
onSelectTool,
onSelectionModeChange,
}: {
activeToolId: string;
aiSelectionMode: AiSelectionMode;
disabled?: boolean;
unavailableToolIds?: string[];
onSelectTool: (toolId: string) => void;
onSelectionModeChange: (mode: AiSelectionMode) => void;
}) {
return (
<div
data-viewer-interaction-overlay="true"
className="pointer-events-auto absolute left-3 top-3 z-30 inline-flex h-10 max-w-[calc(100vw-1.5rem)] flex-nowrap items-center gap-1 overflow-x-auto rounded-md border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-1.5 text-[var(--ui-text-muted)] shadow-[var(--ui-shadow-soft)] backdrop-blur"
role="toolbar"
aria-label="CAD 编辑工具"
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarButton
label="点选几何"
active={aiSelectionMode === "point"}
disabled={disabled}
onClick={() => {
onSelectTool("");
onSelectionModeChange("point");
}}
>
<MousePointerClick className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
label="圈选几何"
active={aiSelectionMode === "lasso"}
disabled={disabled}
onClick={() => {
onSelectTool("");
onSelectionModeChange("lasso");
}}
>
<LassoSelect className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<Divider />
{CAD_EDIT_TOOLS.map((tool) => {
const Icon = EDIT_TOOL_ICONS[tool.id as keyof typeof EDIT_TOOL_ICONS] || Drill;
const active = activeToolId === tool.id;
const unavailable = unavailableToolIds.includes(tool.id);
return (
<ToolbarButton
key={tool.id}
label={unavailable ? `${tool.label}(当前 engine 尚不支持)` : tool.label}
active={active}
disabled={disabled || unavailable}
onClick={() => {
if (active) {
onSelectTool("");
onSelectionModeChange("point");
return;
}
onSelectTool(tool.id);
onSelectionModeChange("none");
}}
>
<Icon className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
);
})}
</div>
);
}
export function EmbeddedCadViewToolbar({
disabled = false,
onResetView,
onScreenshot,
onParameters,
}: {
disabled?: boolean;
onResetView: () => void;
onScreenshot?: () => void;
onParameters?: () => void;
}) {
return (
<div
data-viewer-interaction-overlay="true"
className="pointer-events-auto absolute right-3 top-3 z-30 inline-flex min-h-10 items-center gap-1 rounded-md border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-1.5 text-[var(--ui-text-muted)] shadow-[var(--ui-shadow-soft)] backdrop-blur"
role="toolbar"
aria-label="CAD 预览工具"
aria-label="CAD preview tools"
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarButton label="Orbit" disabled={disabled} onClick={onResetView}>
<ToolbarButton label="Reset view" disabled={disabled} onClick={onResetView}>
<Orbit className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton label="Copy screenshot" disabled={disabled || !onScreenshot} onClick={onScreenshot}>
<Focus className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton label="Parameters" disabled={disabled || !onParameters} onClick={onParameters}>
<SlidersHorizontal className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
</div>
);
}
-398
View File
@@ -1,398 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import * as Collapsible from "@radix-ui/react-collapsible";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDown, ChevronUp, Download, Loader2, RefreshCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CadamSlider } from "@/components/ui/cadam-slider";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type EditableParameter = Record<string, unknown>;
type NormalizedParameter = {
id: string;
name: string;
displayName: string;
group: string;
groupDisplayName: string;
value: number;
defaultValue: number;
min: number | null;
max: number | null;
step: number;
precision: number;
unit: string;
editable: boolean;
editState: string;
};
type DownloadFormat = {
label: string;
description: string;
url: string;
};
function numberValue(value: unknown) {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function parameterToView(parameter: EditableParameter): NormalizedParameter | null {
const id = String(parameter.id || parameter.name || "").trim();
const name = String(parameter.name || id).trim();
const value = numberValue(parameter.value);
const declaredRange = Array.isArray(parameter.range) ? parameter.range : [];
const min = numberValue(parameter.min ?? parameter.minimum ?? declaredRange[0]);
const max = numberValue(parameter.max ?? parameter.maximum ?? declaredRange[1]);
if (!name || value === null) return null;
return {
id,
name,
displayName: String(parameter.display_name || parameter.displayName || parameter.label || name),
group: String(parameter.group || "dimensions"),
groupDisplayName: String(parameter.group_display_name || parameter.groupDisplayName || "尺寸"),
value,
defaultValue: numberValue(parameter.default_value ?? parameter.defaultValue) ?? value,
min,
max,
step: numberValue(parameter.step) ?? 1,
precision: Math.max(0, numberValue(parameter.precision) ?? 2),
unit: String(parameter.unit || ""),
editable: parameter.editable === true || parameter.edit_state === "declared_unvalidated",
editState: String(parameter.edit_state || "unvalidated"),
};
}
function formatValue(value: number, precision: number) {
if (!Number.isFinite(value)) return "";
if (Number.isInteger(value) && precision === 0) return String(value);
return Number(value.toFixed(precision)).toString();
}
function clampValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function visualRange(parameter: NormalizedParameter) {
if (parameter.min !== null && parameter.max !== null && parameter.max > parameter.min) {
return { min: parameter.min, max: parameter.max };
}
const span = Math.max(Math.abs(parameter.value), 1);
return {
min: Math.max(0, parameter.value - span),
max: parameter.value + span,
};
}
function sectionDisplayName(group: { id: string; displayName: string }) {
return group.id === "dimensions" || group.displayName === "尺寸"
? "Dimensions"
: group.displayName;
}
export function ParameterPanel({
parameters,
downloads = [],
pendingParameter,
error,
onClose,
onCommit,
onReset,
}: {
parameters: EditableParameter[];
downloads?: DownloadFormat[];
pendingParameter?: string;
error?: string;
onClose: () => void;
onCommit: (parameter: string, value: number) => void;
onReset: (values: Record<string, number>) => void;
}) {
const normalizedParameters = useMemo(
() => parameters.map(parameterToView).filter((value): value is NormalizedParameter => Boolean(value)),
[parameters],
);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [selectedDownload, setSelectedDownload] = useState(downloads[0]?.label || "");
useEffect(() => {
setDrafts(Object.fromEntries(
normalizedParameters.map((parameter) => [
parameter.name,
formatValue(parameter.value, parameter.precision),
]),
));
setOpenGroups((current) => {
const next = { ...current };
for (const parameter of normalizedParameters) {
if (next[parameter.group] === undefined) next[parameter.group] = true;
}
return next;
});
}, [normalizedParameters]);
useEffect(() => {
if (!downloads.some((download) => download.label === selectedDownload)) {
setSelectedDownload(downloads[0]?.label || "");
}
}, [downloads, selectedDownload]);
const grouped = useMemo(() => {
const groups = new Map<string, { id: string; displayName: string; parameters: NormalizedParameter[] }>();
for (const parameter of normalizedParameters) {
const group = groups.get(parameter.group) || {
id: parameter.group,
displayName: parameter.groupDisplayName,
parameters: [],
};
group.parameters.push(parameter);
groups.set(parameter.group, group);
}
return Array.from(groups.values());
}, [normalizedParameters]);
const resetParameters = () => {
const values = Object.fromEntries(
normalizedParameters
.filter((parameter) => parameter.editable && parameter.value !== parameter.defaultValue)
.map((parameter) => [parameter.id, parameter.defaultValue]),
);
if (Object.keys(values).length) {
onReset(values);
return;
}
setDrafts(Object.fromEntries(
normalizedParameters.map((parameter) => [
parameter.name,
formatValue(parameter.defaultValue, parameter.precision),
]),
));
};
const selectedDownloadItem = downloads.find((download) => download.label === selectedDownload) || downloads[0] || null;
const commitValue = (parameter: NormalizedParameter, rawValue: string | number) => {
const numeric = numberValue(rawValue);
if (!parameter.editable) {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
return;
}
if (numeric === null) {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
return;
}
const clamped = parameter.min !== null && parameter.max !== null
? clampValue(numeric, parameter.min, parameter.max)
: numeric;
const formatted = formatValue(clamped, parameter.precision);
setDrafts((current) => ({ ...current, [parameter.name]: formatted }));
if (Number(formatted) !== parameter.value) {
onCommit(parameter.id, Number(formatted));
}
};
return (
<aside
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l border-[var(--ui-border)] bg-[var(--ui-panel)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-panel)]"
data-viewer-interaction-overlay="true"
role="dialog"
aria-label="可编辑参数"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--ui-border-strong)] bg-[var(--ui-panel)] px-6">
<div className="flex items-center gap-2">
<div className="text-lg font-semibold tracking-tight text-[var(--ui-text-strong)]">Parameters</div>
</div>
<div className="flex items-center gap-1">
<Button
aria-label="恢复全部默认参数"
size="icon-sm"
variant="ghost"
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
disabled={Boolean(pendingParameter) || !normalizedParameters.length}
onClick={resetParameters}
>
<RefreshCcw className="size-4" />
</Button>
<Button
aria-label="隐藏参数面板"
size="icon-sm"
variant="ghost"
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-6 py-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{error ? (
<div className="mb-3 rounded border border-[var(--ui-error-border)] bg-[var(--ui-error-bg)] px-3 py-2 text-[11px] leading-4 text-[var(--ui-error-text)]">
{error}
</div>
) : null}
{normalizedParameters.length ? (
<div className="flex flex-col gap-3">
{grouped.map((group) => (
<Collapsible.Root
key={group.id}
open={openGroups[group.id] ?? true}
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.id]: open }))}
>
<Collapsible.Trigger className="group flex w-full items-center justify-between gap-2 rounded-md py-1 text-left text-xs font-semibold text-[var(--ui-text-strong)] transition-colors focus:outline-none">
<span className="flex items-center gap-2">
{sectionDisplayName(group)}
<span className="text-[10px] text-[var(--ui-text-subtle)]">{group.parameters.length}</span>
</span>
<ChevronDown
className={cn(
"size-3.5 text-[var(--ui-text-subtle)] transition-all duration-200 group-hover:text-[var(--ui-text-strong)]",
openGroups[group.id] !== false && "rotate-180",
)}
/>
</Collapsible.Trigger>
<Collapsible.Content className="mt-3 flex flex-col gap-3">
{group.parameters.map((parameter) => {
const draft = drafts[parameter.name] ?? formatValue(parameter.value, parameter.precision);
const numericDraft = numberValue(draft) ?? parameter.value;
const disabled = Boolean(pendingParameter) || !parameter.editable;
const range = visualRange(parameter);
const pending = pendingParameter === parameter.id;
return (
<div
className="grid w-full grid-cols-[80px_minmax(0,1fr)] items-center gap-3"
key={parameter.name}
>
<label
className="min-w-0 overflow-hidden text-ellipsis text-xs font-normal leading-4 text-[var(--ui-text-muted)]"
htmlFor={`parameter-${parameter.name}`}
title={parameter.displayName}
>
<span className="block truncate">{parameter.displayName}</span>
</label>
<div className="flex w-full min-w-0 items-center gap-3">
<CadamSlider
id={`${parameter.name}-slider`}
name={parameter.name}
min={range.min}
max={range.max}
step={parameter.step}
value={[clampValue(numericDraft, range.min, range.max)]}
defaultValue={[clampValue(parameter.defaultValue, range.min, range.max)]}
disabled={disabled}
visualOnly={false}
defaultMarkerStyle="line"
onValueChange={([nextValue]) => {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(nextValue, parameter.precision),
}));
}}
onValueCommit={([nextValue]) => commitValue(parameter, nextValue)}
/>
<div className="flex shrink-0 items-center gap-2">
<div className="relative">
<Input
id={`parameter-${parameter.name}`}
autoComplete="off"
className="h-6 w-14 rounded-lg border-0 bg-[var(--ui-control-bg)] px-2 pr-2 text-left text-xs text-[var(--ui-text-strong)] transition-colors selection:bg-[var(--ui-selection-bg)] selection:text-[var(--ui-text-inverse)] focus-visible:ring-0 hover:bg-[var(--ui-control-hover)]"
disabled={disabled}
inputMode="decimal"
max={range.max}
min={range.min}
step={parameter.step}
type="number"
value={draft}
onBlur={() => commitValue(parameter, draft)}
onChange={(event) => setDrafts((current) => ({
...current,
[parameter.name]: event.target.value,
}))}
onFocus={(event) => event.target.select()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
}
}}
/>
{pending ? (
<Loader2 className="absolute right-1.5 top-1.5 size-3 animate-spin text-[var(--ui-accent)]" />
) : null}
</div>
<span className="ml-1 w-6 text-left text-xs text-[var(--ui-text-muted)]">
{parameter.unit}
</span>
</div>
</div>
</div>
);
})}
</Collapsible.Content>
</Collapsible.Root>
))}
</div>
) : (
<div className="rounded border border-[var(--ui-border-strong)] bg-[var(--ui-panel-raised)] px-3 py-3 text-xs leading-5 text-[var(--ui-text-muted)]">
当前模型没有可直接编辑的参数。
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-4 border-t border-[var(--ui-border-strong)] px-6 py-6">
<div className="flex">
<a
aria-disabled={!selectedDownloadItem}
className={cn(
"inline-flex h-12 flex-1 items-center justify-center rounded-l-lg rounded-r-none bg-[var(--ui-accent)] text-sm font-semibold text-[var(--ui-accent-contrast)] transition-colors hover:bg-[var(--ui-accent-hover)]",
!selectedDownloadItem && "pointer-events-none opacity-50",
)}
href={selectedDownloadItem?.url || "#"}
download
>
<Download className="mr-2 size-4" />
{selectedDownloadItem?.label || "STEP"}
</a>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
aria-label="选择下载格式"
className="h-12 w-12 rounded-l-none rounded-r-lg border-l border-[var(--ui-border-muted)] bg-[var(--ui-accent)] p-0 text-[var(--ui-accent-contrast)] hover:bg-[var(--ui-accent-hover)]"
disabled={!downloads.length}
>
<ChevronUp className="size-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
className="z-50 w-64 rounded-md border border-[var(--ui-border)] bg-[var(--ui-popover)] p-1 shadow-[var(--ui-shadow-popover)]"
>
{downloads.map((download) => (
<DropdownMenu.Item
key={download.label}
className="flex cursor-pointer items-center rounded px-3 py-2 text-[var(--ui-text-strong)] outline-none hover:bg-[var(--ui-control-hover)]"
onSelect={() => setSelectedDownload(download.label)}
>
<span className="text-sm">.{download.label}</span>
<span className="ml-3 text-xs text-[var(--ui-text-muted)]">{download.description}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</aside>
);
}
+3 -11
View File
@@ -25,20 +25,9 @@ function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boo
stepPath: current.step_path,
glbPath: current.glb_path,
reportPath: current.report_path,
parametersPath: current.parameters_path,
selectorPath: current.selector_path,
edgesPath: current.edges_path,
topologyPath: current.topology_path,
summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only",
qualityStatus: current.quality_status || "",
qualityPath: current.quality_path,
assumptions: current.generation_assumptions || current.assumptions || [],
warnings: current.warnings || [],
repairAttempts: current.repair_attempts || 0,
snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [],
snapshotStatus: current.snapshot_status || "unavailable",
checkpoint,
lifecycle: task.lifecycle || "completed",
};
@@ -53,6 +42,9 @@ export function activeCheckpointPreview(task: TaskRecord | null): CadResult | nu
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
if (!task) return null;
// Checkpoints are intentionally private after a failed run. Returning one
// here makes the restored viewer request an artifact the backend denies.
if (task.lifecycle === "failed" && !task.published_revision) return null;
const current =
task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ??
[...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint");
-218
View File
@@ -1,218 +0,0 @@
export type AiSelectionMode = "none" | "point" | "lasso";
export type CadEditTool = {
id: string;
label: string;
pickKinds: Array<"surface_point" | "edge">;
parameters: Record<string, string | number>;
parameterFields: Array<{
name: string;
label: string;
type: "number" | "select" | "string";
unit?: string;
min?: number;
step?: number;
options?: string[];
placeholder?: string;
}>;
};
export const CAD_EDIT_TOOLS: CadEditTool[] = [
{
id: "add_hole",
label: "打孔",
pickKinds: ["surface_point"],
parameters: { holeDiameter: 2, depth: "through", direction: "face_normal" },
parameterFields: [
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_counterbore",
label: "沉孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 3,
counterboreDiameter: 6,
counterboreDepth: 2,
depth: "through",
direction: "face_normal",
},
parameterFields: [
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "counterboreDiameter", label: "沉孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "counterboreDepth", label: "沉孔深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_countersink",
label: "锥沉孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 3,
countersinkDiameter: 6,
countersinkAngleDeg: 90,
depth: "through",
direction: "face_normal",
},
parameterFields: [
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "countersinkDiameter", label: "锥口直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "countersinkAngleDeg", label: "锥角", type: "number", unit: "deg", min: 1, step: 1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_slot",
label: "开槽",
pickKinds: ["surface_point", "surface_point"],
parameters: { slotWidth: 2, depth: "through", direction: "face_normal" },
parameterFields: [
{ name: "slotWidth", label: "槽宽", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_pocket",
label: "铣型腔",
pickKinds: ["surface_point"],
parameters: { shape: "rectangle", width: 10, height: 6, depth: 1, direction: "face_normal", orientation: "surface_xy" },
parameterFields: [
{ name: "width", label: "宽度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "height", label: "高度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_circular_pocket",
label: "圆形型腔",
pickKinds: ["surface_point"],
parameters: { diameter: 8, depth: 1, direction: "face_normal" },
parameterFields: [
{ name: "diameter", label: "直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_hole_pattern",
label: "阵列孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 2,
rows: 2,
columns: 2,
pitchX: 8,
pitchY: 8,
depth: "through",
direction: "face_normal",
orientation: "surface_xy",
},
parameterFields: [
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "rows", label: "行数", type: "number", min: 1, step: 1 },
{ name: "columns", label: "列数", type: "number", min: 1, step: 1 },
{ name: "pitchX", label: "X 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "pitchY", label: "Y 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_chamfer",
label: "倒角",
pickKinds: ["edge"],
parameters: { distance: 1 },
parameterFields: [
{ name: "distance", label: "倒角距离", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_fillet",
label: "圆角",
pickKinds: ["edge"],
parameters: { radius: 1 },
parameterFields: [
{ name: "radius", label: "圆角半径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
];
export function cadEditToolForOperation(operation: string) {
return CAD_EDIT_TOOLS.find((tool) => tool.id === operation) || CAD_EDIT_TOOLS[0];
}
export function cadEditToolPickKind(operation: string) {
return cadEditToolForOperation(operation).pickKinds[0] || "surface_point";
}
export function cadEditToolNextPickKind(operation: string, pickCount: number) {
const pickKinds = cadEditToolForOperation(operation).pickKinds;
return pickKinds[Math.min(Math.max(Math.floor(pickCount), 0), pickKinds.length - 1)] || "surface_point";
}
export function cadEditToolPickComplete(operation: string, picks: unknown[]) {
return picks.length >= cadEditToolForOperation(operation).pickKinds.length;
}
export function defaultCadEditParameters(operation: string) {
return { ...cadEditToolForOperation(operation).parameters };
}
export function buildEmbeddedCadEditIntent({
operation,
pick,
parameters,
artifactPath,
taskId,
}: {
operation: string;
pick: Record<string, unknown>;
parameters?: Record<string, unknown>;
artifactPath: string;
taskId: string;
}) {
const tool = cadEditToolForOperation(operation);
return {
schema: "cad-edit-intent.v1",
operation: tool.id,
source: {
entryFile: artifactPath,
stepPath: artifactPath,
sourcePath: "",
backend: "agent-studio",
units: "mm",
taskId,
},
selection: pick,
parameters: {
...defaultCadEditParameters(tool.id),
...(parameters || {}),
},
};
}
export function buildEmbeddedGeometrySelectionIntent({
selection,
artifactPath,
taskId,
}: {
selection: Record<string, unknown>;
artifactPath: string;
taskId: string;
}) {
return {
schema: "cad-ai-geometry-selection.v1",
intent: "modify_selected_geometry",
source: {
entryFile: artifactPath,
stepPath: artifactPath,
sourcePath: "",
backend: "agent-studio",
units: "mm",
taskId,
},
selection,
instruction: "用户通过 CAD Agent Studio 内置预览指定了要修改的几何区域。请结合 selection 中的屏幕坐标、近似模型坐标和当前视角理解位置,再修改可编辑源文件并重新生成 STEP 与预览资产。",
};
}
+1 -4
View File
@@ -1,4 +1,4 @@
import type { CadError, CadImageAnalysis, CadProgress, CadResult, CadUIMessage } from "./cad-types";
import type { CadError, CadProgress, CadResult, CadUIMessage } from "./cad-types";
type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown };
type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown };
@@ -24,9 +24,6 @@ export function normalizeCadMessages(input: unknown): CadUIMessage[] {
if (item.type === "data-cad-error") {
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 [{
+26 -16
View File
@@ -20,29 +20,26 @@ test("keeps progressive revisions as separate data parts", () => {
assert.notEqual(first?.id, second?.id);
});
test("maps visual repair review into a blocking progress state", () => {
test("maps final visual repair review into a blocking progress state", () => {
const chunk = backendEventToUiChunk({
event: "render_review",
data: { taskId: "cad_abc", nodeId: "round", review: { verdict: "repair", confidence: 0.92, evidence: ["missing round"] } },
event: "final_review",
data: { taskId: "cad_abc", review: { verdict: "repair", confidence: 0.92, evidence: ["missing round"] } },
}, "text_1");
assert.equal(chunk?.type, "data-cad-progress");
assert.deepEqual("data" in chunk! ? chunk.data : null, {
step: "render_review", label: "视觉复核", status: "error", message: "missing round", taskId: "cad_abc", nodeId: "round",
step: "final_review", label: "最终视觉复核", status: "error", message: "missing round", taskId: "cad_abc",
});
});
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("maps a rejected independent candidate review into a blocking progress state", () => {
const chunk = backendEventToUiChunk({
event: "candidate_review",
data: { taskId: "cad_abc", review: { verdict: "reject", evidence: ["base is disconnected"] } },
}, "text_1");
assert.equal(chunk?.type, "data-cad-progress");
assert.deepEqual("data" in chunk! ? chunk.data : null, {
step: "candidate_review", label: "候选独立复核", status: "error", message: "base is disconnected", taskId: "cad_abc",
});
});
test("restores the latest successful task revision for the viewer", () => {
@@ -74,6 +71,19 @@ test("prefers the published revision over an active checkpoint", () => {
assert.equal(result?.checkpoint, false);
});
test("does not restore a private checkpoint after a failed run", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
current_revision: "rev_002",
active_revision: "rev_002",
lifecycle: "failed",
revisions: [
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
],
});
assert.equal(result, null);
});
test("restores an active checkpoint only while the task is running", () => {
const result = activeCheckpointPreview({
task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running",
+4 -10
View File
@@ -19,20 +19,21 @@ export function backendEventToUiChunk(
data: item.data,
};
}
if (["generation_plan", "checkpoint", "render_review", "rollback", "task_terminal"].includes(item.event)) {
if (["requirements_document", "completion_checklist", "completion_audit", "agent_thinking", "tool_call", "candidate_result", "candidate_review", "geometry_diagnostic", "geometry_conclusion", "step_review", "checkpoint", "rollback", "final_review", "task_terminal"].includes(item.event)) {
const review = item.data.review && typeof item.data.review === "object"
? item.data.review as Record<string, unknown>
: null;
const status = item.event === "task_terminal"
? (String(item.data.lifecycle || "") === "failed" ? "error" : "success")
: item.event === "render_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85
: (item.event === "candidate_review" && String(review?.verdict || "") === "reject")
|| (item.event === "final_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85)
? "error"
: String(item.data.status || "running");
return {
type: "data-cad-progress",
id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`,
data: { step: item.event, label: ({
generation_plan: "生成计划", checkpoint: "构建检查点", render_review: "视觉复核", rollback: "回滚检查点", task_terminal: "生成任务",
requirements_document: "冻结需求", completion_checklist: "完成清单", completion_audit: "完成审计", agent_thinking: "建模判断", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", geometry_diagnostic: "几何诊断", geometry_conclusion: "几何结论", step_review: "步骤审查", checkpoint: "构建检查点", rollback: "回滚检查点", final_review: "最终视觉复核", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, message: String(
item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join(";") : ""),
),
@@ -60,12 +61,5 @@ export function backendEventToUiChunk(
data: item.data,
};
}
if (item.event === "image_analysis") {
return {
type: "data-cad-image-analysis",
id: `image_analysis_${Date.now()}`,
data: item.data,
};
}
return null;
}
+28 -82
View File
@@ -8,6 +8,10 @@ export type CadProgress = {
taskId?: string;
nodeId?: string;
lifecycle?: "running" | "completed" | "failed" | string;
attempt?: number;
maxAttempts?: number;
path?: string;
contractHash?: string;
};
export type CadResult = {
@@ -17,20 +21,9 @@ export type CadResult = {
stepPath: string;
glbPath: string;
reportPath: string;
parametersPath?: string;
selectorPath?: string;
edgesPath?: string;
topologyPath?: string;
summary: string;
referenceIds: string[];
engine: string;
qualityStatus?: "accepted" | "built_with_warnings" | "needs_repair" | "blocked" | "failed" | string;
qualityPath?: string;
assumptions?: string[];
warnings?: string[];
repairAttempts?: number;
snapshotPaths?: string[];
snapshotStatus?: string;
checkpoint?: boolean;
lifecycle?: "running" | "completed" | "failed" | string;
};
@@ -40,63 +33,10 @@ export type CadError = {
message: string;
};
export type CadImageDimension = {
id: string;
label: string;
reason: string;
};
export type CadImageSegment = {
type: "line" | "arc" | "circle" | "polyline" | "unknown_curve" | string;
start?: number[];
end?: number[];
center?: number[];
radius_mm?: number | null;
points?: number[][];
confidence?: number | null;
notes?: string;
};
export type CadImageProfile = {
id: string;
role?: string;
plane_hint?: string;
closed?: boolean;
segments?: CadImageSegment[];
source_images?: string[];
confidence?: number | null;
uncertain?: string[];
notes?: string;
};
export type CadImageAnalysis = {
observationStage?: "survey" | "sketch" | "complete" | string;
schemaVersion?: string;
attachmentIds: string[];
partType: string;
visibleFeatures: string[];
uncertainFeatures: string[];
dimensionCandidates?: CadImageDimension[];
// Legacy conversations stored this field before dimensions became optional.
requiredDimensions?: CadImageDimension[];
views?: Array<{ attachment_id?: string; view_role?: string; orientation?: string; quality?: string; confidence?: number | null }>;
overallGeometry?: Record<string, unknown>;
surfaces?: Array<Record<string, unknown>>;
profiles?: CadImageProfile[];
holes?: Array<Record<string, unknown>>;
bends?: Array<Record<string, unknown>>;
measurements?: Array<{ name: string; value_mm?: number | null; source?: string; confidence?: number | null; evidence?: string }>;
uncertainties?: string[];
assumptions?: string[];
cvHints?: Array<Record<string, unknown>>;
artifactPath?: string;
};
export type CadDataParts = {
"cad-progress": CadProgress;
"cad-result": CadResult;
"cad-error": CadError;
"cad-image-analysis": CadImageAnalysis;
};
export type CadUIMessage = UIMessage<unknown, CadDataParts>;
@@ -131,24 +71,15 @@ export type TaskRevision = {
step_path?: string;
glb_path?: string;
report_path?: string;
parameters_path?: string;
selector_path?: string;
edges_path?: string;
topology_path?: string;
summary?: string;
reference_ids?: string[];
engine?: string;
error?: string;
quality_status?: string;
quality_path?: string;
assumptions?: string[];
generation_assumptions?: string[];
warnings?: string[];
repair_attempts?: number;
snapshot_manifest_path?: string;
snapshot_status?: string;
visibility?: "checkpoint" | "final" | "superseded" | string;
node_id?: string;
parent_revision_id?: string;
branch_id?: string;
step_review_path?: string;
candidate_review_path?: string;
render_manifest_path?: string;
visual_review_path?: string;
};
@@ -159,9 +90,25 @@ export type TaskRecord = {
active_revision?: string;
published_revision?: string;
lifecycle?: "running" | "completed" | "failed" | string;
active_node_id?: string;
active_candidate_id?: string;
active_branch_id?: string;
requirements_path?: string;
requirements_markdown?: string | null;
completion_checklist_path?: string;
completion_checklist_markdown?: string | null;
agent_state?: {
no_progress?: number;
cycle_tool_calls?: number;
last_diagnostic?: string;
last_review?: { candidate_id?: string; path?: string; decision?: string; recorded_at?: string };
last_candidate_review?: { verdict?: "accept" | "reject" | string; batch_goal?: string; batch_goal_status?: string; evidence?: string[]; recorded_at?: string };
completion_ledger?: {
verified_revision?: string;
items?: Array<{ item?: string; status?: "complete" | "missing" | "uncertain" | string; evidence?: string }>;
};
recent_events?: Array<{ kind?: string; at?: string; message?: string; tool?: string }>;
} | null;
preview_revision?: string;
generation_plan?: Record<string, unknown> | null;
revisions: TaskRevision[];
};
@@ -171,13 +118,12 @@ export type BackendConfig = {
providers: Array<{
id: string;
label: string;
models: Array<{ id: string; vision: boolean; strict_tool_schema?: boolean }>;
models: Array<{ id: string; vision: boolean }>;
}>;
model: string;
configured: boolean;
library_samples: number;
max_repair_attempts?: number;
incremental_generation?: boolean;
autonomous_generation?: boolean;
review_configured?: boolean;
review_error?: string;
};