修改agent流程,分步构建cdsl
This commit is contained in:
@@ -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());
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 +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 [{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user