diff --git a/app/(chat)/ai-cad/page.tsx b/app/(chat)/ai-cad/page.tsx index 64c8a3f21..285bd14c7 100644 --- a/app/(chat)/ai-cad/page.tsx +++ b/app/(chat)/ai-cad/page.tsx @@ -1,5 +1,7 @@ import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench"; +export const dynamic = "force-dynamic"; + export default function AiCadPage() { return ; } diff --git a/app/api/ai-cad/modeling/command/route.ts b/app/api/ai-cad/modeling/command/route.ts new file mode 100644 index 000000000..1ae9fc69c --- /dev/null +++ b/app/api/ai-cad/modeling/command/route.ts @@ -0,0 +1,122 @@ +import { randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + getKittyCadModelingCommandError, + isKittyCadModelingCommandSuccessful, + sendKittyCadModelingCommand, + takeKittyCadModelingSnapshot, +} from "@/lib/kittycad/modeling-session"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type ModelingCommandRequest = { + captureSnapshot?: boolean; + cmd?: Record; + cmdId?: string; + sessionId?: string; + snapshotFormat?: "jpeg" | "png"; +}; + +function readOptionalRecord(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const body = (await request.json()) as ModelingCommandRequest; + const sessionId = String(body.sessionId || "").trim(); + if (!sessionId) { + return NextResponse.json( + { success: false, error: "缺少官方建模会话 ID" }, + { status: 400 } + ); + } + + const cmd = readOptionalRecord(body.cmd); + const cmdType = String(cmd?.type || "").trim(); + if (!cmd || !cmdType) { + return NextResponse.json( + { success: false, error: "缺少有效的官方建模命令" }, + { status: 400 } + ); + } + + const cmdId = String(body.cmdId || "").trim() || randomUUID(); + const response = await sendKittyCadModelingCommand({ + cmd: { + ...cmd, + type: cmdType, + }, + cmdId, + sessionId, + }); + + if (!isKittyCadModelingCommandSuccessful(response)) { + return NextResponse.json( + { + success: false, + error: getKittyCadModelingCommandError(response), + payload: response.errors || null, + }, + { status: 400 } + ); + } + + let snapshotUrl: string | null = null; + let snapshotError: string | null = null; + + const shouldCaptureSnapshot = + body.captureSnapshot === true && cmdType !== "take_snapshot"; + + if (cmdType === "take_snapshot") { + const contents = String(response.resp?.data?.contents || "").trim(); + const snapshotFormat = body.snapshotFormat || "png"; + snapshotUrl = contents + ? `data:${snapshotFormat === "jpeg" ? "image/jpeg" : "image/png"};base64,${contents}` + : null; + } else if (shouldCaptureSnapshot) { + try { + const snapshot = await takeKittyCadModelingSnapshot({ + format: body.snapshotFormat || "png", + sessionId, + }); + snapshotUrl = snapshot.snapshotUrl; + } catch (error) { + snapshotError = + error instanceof Error ? error.message : "获取官方建模快照失败"; + } + } + + return NextResponse.json({ + success: true, + data: { + raw: response, + requestId: response.request_id || cmdId, + responseData: response.resp?.data || null, + responseType: response.resp?.type || cmdType, + snapshotError, + snapshotUrl, + }, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "发送官方建模命令失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/ai-cad/modeling/session/[sessionId]/route.ts b/app/api/ai-cad/modeling/session/[sessionId]/route.ts new file mode 100644 index 000000000..d697e1264 --- /dev/null +++ b/app/api/ai-cad/modeling/session/[sessionId]/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { closeKittyCadModelingSession } from "@/lib/kittycad/modeling-session"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type RouteParams = { + params: Promise<{ + sessionId: string; + }>; +}; + +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const { sessionId } = await params; + const normalizedSessionId = String(sessionId || "").trim(); + if (!normalizedSessionId) { + return NextResponse.json( + { success: false, error: "缺少 sessionId" }, + { status: 400 } + ); + } + + const result = await closeKittyCadModelingSession(normalizedSessionId); + + return NextResponse.json({ + success: true, + data: result, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "关闭官方建模会话失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/ai-cad/modeling/session/route.ts b/app/api/ai-cad/modeling/session/route.ts new file mode 100644 index 000000000..63e7c5ce6 --- /dev/null +++ b/app/api/ai-cad/modeling/session/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { createKittyCadModelingSession } from "@/lib/kittycad/modeling-session"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function POST() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: "未登录" }, + { status: 401 } + ); + } + + const modelingSession = await createKittyCadModelingSession(); + + return NextResponse.json({ + success: true, + data: modelingSession, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "创建官方建模会话失败", + }, + { status: 500 } + ); + } +} diff --git a/app/api/ai-cad/tasks/route.ts b/app/api/ai-cad/tasks/route.ts index d9e2e3a60..86695395e 100644 --- a/app/api/ai-cad/tasks/route.ts +++ b/app/api/ai-cad/tasks/route.ts @@ -16,6 +16,8 @@ type CreateAiCadTaskRequest = { outputFormat?: string; projectName?: string; iterationSourceTaskId?: string; + sourceCode?: string; + sourceRanges?: Record[]; }; type KittyCadCreateResponse = { @@ -148,12 +150,6 @@ export async function POST(request: Request) { const body = (await request.json()) as CreateAiCadTaskRequest; const prompt = String(body.prompt || "").trim(); - if (!prompt) { - return NextResponse.json( - { success: false, error: "请输入 CAD 描述" }, - { status: 400 } - ); - } const pointsConfig = await getPointsSystemConfig(); const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel)) @@ -196,50 +192,76 @@ export async function POST(request: Request) { const iterationSourceTaskId = String( body.iterationSourceTaskId || "" ).trim(); + const sourceCode = String(body.sourceCode || ""); + const normalizedSourceCode = sourceCode.trim(); + const sourceRanges = Array.isArray(body.sourceRanges) + ? body.sourceRanges.filter( + (item): item is Record => + Boolean(item) && typeof item === "object" && !Array.isArray(item) + ) + : []; const config = await getKittyCadProviderConfig(); - const generationMode = iterationSourceTaskId ? "iterate" : "create"; - const result = iterationSourceTaskId - ? await (async () => { - const sourceTask = await kittyCadRequest( - config, - { - method: "GET", - path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`, + const generationMode = + iterationSourceTaskId || normalizedSourceCode ? "iterate" : "create"; + + if (generationMode === "create" && !prompt) { + return NextResponse.json( + { success: false, error: "请输入 CAD 描述" }, + { status: 400 } + ); + } + + const iterationPrompt = + prompt || "Apply the edited KCL source and rebuild the CAD model."; + const result = + generationMode === "iterate" + ? await (async () => { + const originalSourceCode = + normalizedSourceCode || + (await (async () => { + if (!iterationSourceTaskId) { + return null; + } + + const sourceTask = + await kittyCadRequest(config, { + method: "GET", + path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`, + }); + return extractSourceCodeFromTask(sourceTask); + })()); + + if (!originalSourceCode) { + throw new KittyCadApiError({ + message: + "当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。", + httpStatus: 409, + }); } - ); - const originalSourceCode = extractSourceCodeFromTask(sourceTask); - if (!originalSourceCode) { - throw new KittyCadApiError({ - message: - "当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。", - httpStatus: 409, + return kittyCadRequest(config, { + method: "POST", + path: "/ml/text-to-cad/iteration", + json: { + original_source_code: originalSourceCode, + prompt: iterationPrompt, + source_ranges: sourceRanges, + ...(normalizedProjectName + ? { project_name: normalizedProjectName } + : {}), + }, }); - } - - return kittyCadRequest(config, { + })() + : await kittyCadRequest(config, { method: "POST", - path: "/ml/text-to-cad/iteration", + path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`, json: { - original_source_code: originalSourceCode, prompt, - source_ranges: [], ...(normalizedProjectName ? { project_name: normalizedProjectName } : {}), }, }); - })() - : await kittyCadRequest(config, { - method: "POST", - path: `/ai/text-to-cad/${encodeURIComponent(outputFormat)}?kcl=true`, - json: { - prompt, - ...(normalizedProjectName - ? { project_name: normalizedProjectName } - : {}), - }, - }); const taskId = String(result.id || "").trim(); if (!taskId) { diff --git a/components/ai-cad/ai-cad-workbench.tsx b/components/ai-cad/ai-cad-workbench.tsx index 41f653e61..5a86f0780 100644 --- a/components/ai-cad/ai-cad-workbench.tsx +++ b/components/ai-cad/ai-cad-workbench.tsx @@ -5,7 +5,6 @@ import { Bot, Box, BrainCircuit, - Camera, CheckCircle2, ChevronDown, ChevronLeft, @@ -18,9 +17,7 @@ import { Grid3X3, History, Loader2, - MoreHorizontal, Orbit, - Paperclip, Plus, RotateCcw, Send, @@ -60,6 +57,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, @@ -74,6 +72,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { + type AiCadModelingCommandPayload, type AiCadProviderStatus, type AiCadWorkspaceSummary, aiCadGenerationService, @@ -136,6 +135,14 @@ type PersistedAiCadWorkbenchState = { savedAt?: number; }; +type ModelingConnectionState = + | "connected" + | "connecting" + | "disconnected" + | "error"; + +type ModelingCutType = "chamfer" | "fillet"; + type WorkspaceListItem = AiCadWorkspaceSummary; const MODEL_VERSION_OPTIONS = [ @@ -536,6 +543,46 @@ function readNullableText(value: unknown) { return typeof value === "string" && value.trim() ? value : null; } +function parsePositiveNumberInput(value: string, label: string) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${label} 需要是一个大于 0 的数字`); + } + return parsed; +} + +function parseEdgeIndexList(value: string) { + const items = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + + if (items.length === 0) { + throw new Error("请至少填写一个边索引,例如 0,1,2,3"); + } + + const parsed = items.map((item) => Number(item)); + if ( + parsed.some( + (item) => !Number.isInteger(item) || !Number.isFinite(item) || item < 0 + ) + ) { + throw new Error("边索引必须是 0 开始的整数,例如 0,1,2,3"); + } + + return [...new Set(parsed)]; +} + +function getModelingResponseData( + payload?: AiCadModelingCommandPayload | null +): Record | null { + return payload?.responseData && + typeof payload.responseData === "object" && + !Array.isArray(payload.responseData) + ? (payload.responseData as Record) + : null; +} + function getDownloadEntryLabel(name: string, url: string) { const extension = getFileExtension(name) || getFileExtension(url); switch (extension) { @@ -2262,6 +2309,33 @@ export function AiCadWorkbench() { Record >({}); const [codeActionError, setCodeActionError] = useState(null); + const [kclDraft, setKclDraft] = useState(""); + const [kclEditPrompt, setKclEditPrompt] = useState(""); + const [isApplyingKclEdit, setIsApplyingKclEdit] = useState(false); + const [modelingSessionId, setModelingSessionId] = useState( + null + ); + const [modelingConnectionState, setModelingConnectionState] = + useState("disconnected"); + const [modelingError, setModelingError] = useState(null); + const [modelingSnapshotUrl, setModelingSnapshotUrl] = useState( + null + ); + const [modelingPlaneId, setModelingPlaneId] = useState(null); + const [modelingSketchId, setModelingSketchId] = useState(null); + const [modelingSolidId, setModelingSolidId] = useState(null); + const [modelingSelectedEntityIds, setModelingSelectedEntityIds] = useState< + string[] + >([]); + const [modelingLogLines, setModelingLogLines] = useState([]); + const [modelingWidth, setModelingWidth] = useState("120"); + const [modelingHeight, setModelingHeight] = useState("60"); + const [modelingDepth, setModelingDepth] = useState("25"); + const [modelingCutSize, setModelingCutSize] = useState("2"); + const [modelingEdgeIndices, setModelingEdgeIndices] = useState("0,1,2,3"); + const [modelingCutType, setModelingCutType] = + useState("fillet"); + const [isModelingBusy, setIsModelingBusy] = useState(false); const [messages, setMessages] = useState( createWelcomeMessages ); @@ -2291,6 +2365,7 @@ export function AiCadWorkbench() { ); const versionsRef = useRef(versions); const activeWorkspaceIdRef = useRef(null); + const modelingSessionIdRef = useRef(null); const conversationPanelRef = useRef(null); const historyPanelRef = useRef(null); const resultPanelRef = useRef(null); @@ -2398,10 +2473,29 @@ export function AiCadWorkbench() { activeWorkspaceIdRef.current = activeWorkspaceId; }, [activeWorkspaceId]); + useEffect(() => { + modelingSessionIdRef.current = modelingSessionId; + }, [modelingSessionId]); + useEffect(() => { writePersistedActiveWorkspaceId(activeWorkspaceId); }, [activeWorkspaceId]); + useEffect( + () => () => { + if (!modelingSessionIdRef.current) { + return; + } + + swallowAsync( + aiCadGenerationService.closeModelingSession( + modelingSessionIdRef.current + ) + ); + }, + [] + ); + useEffect(() => { const hasActiveVersion = Boolean(activeVersionId || versions.length > 0); @@ -2577,6 +2671,12 @@ export function AiCadWorkbench() { const activeCodePreviewError = activeCodeUrl ? codePreviewErrorByUrl[activeCodeUrl] || null : null; + const canEditActiveKcl = Boolean( + activeVersion && + activeVersion.provider === "kittycad-cad" && + activeCodeUrl && + activeCodeContent + ); const activeExecutionSummary = activeVersion ? buildVersionExecutionSummary(activeVersion, { parentVersion: activeParentVersion, @@ -2717,12 +2817,18 @@ export function AiCadWorkbench() { !isWorkspaceActionDisabled && !isLoadingWorkspaceList; const isKittycadConfigured = kittycadProviderStatus?.configured !== false; + const isOfficialModelingAvailable = + generationProvider === "kittycad-cad" && isKittycadConfigured; const isSubmitDisabled = !activeWorkspaceId || isLoadingWorkspaceList || !prompt.trim() || isSubmitting || (generationProvider === "kittycad-cad" && !isKittycadConfigured); + const modelingPreviewImageUrl = + !activePreviewVersion?.modelUrl && modelingSnapshotUrl + ? modelingSnapshotUrl + : activePreviewVersion?.previewImageUrl || null; const serializedWorkbenchSnapshot = serializeWorkbenchState({ prompt, generationProvider, @@ -2773,6 +2879,44 @@ export function AiCadWorkbench() { } }, [compareVersion]); + useEffect(() => { + if (!canEditActiveKcl || !activeCodeContent) { + setKclDraft(""); + setKclEditPrompt(""); + return; + } + + setKclDraft(activeCodeContent); + setKclEditPrompt(""); + }, [activeCodeContent, canEditActiveKcl]); + + useEffect(() => { + if (!hasHydratedFromStorage || !activeWorkspaceId) { + return; + } + + setModelingError(null); + setModelingSnapshotUrl(null); + setModelingPlaneId(null); + setModelingSketchId(null); + setModelingSolidId(null); + setModelingSelectedEntityIds([]); + setModelingLogLines([]); + + if (!modelingSessionIdRef.current) { + setModelingConnectionState("disconnected"); + return; + } + + const previousSessionId = modelingSessionIdRef.current; + modelingSessionIdRef.current = null; + setModelingSessionId(null); + setModelingConnectionState("disconnected"); + swallowAsync( + aiCadGenerationService.closeModelingSession(previousSessionId) + ); + }, [activeWorkspaceId, hasHydratedFromStorage]); + useEffect(() => { const codeUrlsToFetch = Array.from( new Set( @@ -3268,6 +3412,443 @@ export function AiCadWorkbench() { ); } + function appendModelingLog(message: string) { + const timestamp = formatClockTime(Date.now()); + setModelingLogLines((current) => + [`${timestamp} · ${message}`, ...current].slice(0, 8) + ); + } + + async function ensureModelingSession() { + if (modelingSessionId) { + return modelingSessionId; + } + + setModelingConnectionState("connecting"); + setModelingError(null); + + const result = await aiCadGenerationService.createModelingSession(); + if (!result.success || !result.data?.sessionId) { + setModelingConnectionState("error"); + const message = result.error || "创建官方建模会话失败"; + setModelingError(message); + throw new Error(message); + } + + setModelingSessionId(result.data.sessionId); + setModelingConnectionState("connected"); + appendModelingLog("已连接官方 modeling 会话"); + return result.data.sessionId; + } + + async function handleDisconnectModelingSession() { + if (!modelingSessionId) { + return; + } + + const closingSessionId = modelingSessionId; + setModelingConnectionState("disconnected"); + setModelingSessionId(null); + setModelingPlaneId(null); + setModelingSketchId(null); + setModelingSolidId(null); + setModelingSelectedEntityIds([]); + setModelingSnapshotUrl(null); + + const result = + await aiCadGenerationService.closeModelingSession(closingSessionId); + if (!result.success) { + setModelingConnectionState("error"); + setModelingError(result.error || "关闭官方建模会话失败"); + return; + } + + appendModelingLog("已关闭官方 modeling 会话"); + } + + async function sendOfficialModelingCommand(params: { + captureSnapshot?: boolean; + cmd: Record; + cmdId?: string; + }) { + const sessionId = await ensureModelingSession(); + const result = await aiCadGenerationService.sendModelingCommand({ + captureSnapshot: params.captureSnapshot, + cmd: params.cmd, + cmdId: params.cmdId, + sessionId, + snapshotFormat: "png", + }); + + if (!result.success || !result.data) { + setModelingConnectionState("error"); + const message = result.error || "官方建模命令执行失败"; + setModelingError(message); + throw new Error(message); + } + + setModelingConnectionState("connected"); + if (result.data.snapshotUrl) { + setModelingSnapshotUrl(result.data.snapshotUrl); + } + if (result.data.snapshotError) { + setModelingError(result.data.snapshotError); + } + return result.data; + } + + async function runModelingAction(label: string, task: () => Promise) { + if (isModelingBusy || !isOfficialModelingAvailable) { + return; + } + + setIsModelingBusy(true); + setModelingError(null); + + try { + await task(); + appendModelingLog(label); + } catch (error) { + const message = + error instanceof Error ? error.message : "官方建模命令执行失败"; + setModelingConnectionState((current) => + current === "connecting" ? "error" : current + ); + setModelingError(message); + appendModelingLog(`${label}失败:${message}`); + } finally { + setIsModelingBusy(false); + } + } + + async function handleCreateRectangleSketch() { + await runModelingAction("已创建矩形草图", async () => { + const width = parsePositiveNumberInput(modelingWidth, "草图宽度"); + const height = parsePositiveNumberInput(modelingHeight, "草图高度"); + const planeId = createId(); + const sketchId = createId(); + + await sendOfficialModelingCommand({ + cmd: { + clobber: true, + origin: { x: 0, y: 0, z: 0 }, + size: Math.max(width, height) * 2, + type: "make_plane", + x_axis: { x: 1, y: 0, z: 0 }, + y_axis: { x: 0, y: 1, z: 0 }, + }, + cmdId: planeId, + }); + await sendOfficialModelingCommand({ + cmd: { + adjust_camera: true, + animated: false, + entity_id: planeId, + ortho: true, + type: "enable_sketch_mode", + }, + }); + await sendOfficialModelingCommand({ + cmd: { + tool: "sketch_line", + type: "set_tool", + }, + }); + await sendOfficialModelingCommand({ + cmd: { type: "start_path" }, + cmdId: sketchId, + }); + await sendOfficialModelingCommand({ + cmd: { + path: sketchId, + to: { x: -width / 2, y: -height / 2, z: 0 }, + type: "move_path_pen", + }, + }); + + const corners = [ + { x: width / 2, y: -height / 2, z: 0 }, + { x: width / 2, y: height / 2, z: 0 }, + { x: -width / 2, y: height / 2, z: 0 }, + ]; + + for (const corner of corners) { + await sendOfficialModelingCommand({ + cmd: { + path: sketchId, + segment: { + end: corner, + relative: false, + type: "line", + }, + type: "extend_path", + }, + }); + } + + await sendOfficialModelingCommand({ + cmd: { + path_id: sketchId, + type: "close_path", + }, + }); + await sendOfficialModelingCommand({ + captureSnapshot: true, + cmd: { + padding: 0.12, + type: "view_isometric", + }, + }); + + setModelingPlaneId(planeId); + setModelingSketchId(sketchId); + setModelingSolidId(null); + setModelingSelectedEntityIds([]); + }); + } + + async function handleExtrudeSketch() { + await runModelingAction("已拉伸当前草图", async () => { + if (!modelingSketchId) { + throw new Error("请先通过官方草图工具创建一个矩形草图"); + } + + const distance = parsePositiveNumberInput(modelingDepth, "拉伸深度"); + const solidId = createId(); + + await sendOfficialModelingCommand({ + cmd: { + distance, + target: modelingSketchId, + type: "extrude", + }, + cmdId: solidId, + }); + await sendOfficialModelingCommand({ + cmd: { + entities: [solidId], + type: "select_replace", + }, + }); + await sendOfficialModelingCommand({ + captureSnapshot: true, + cmd: { + padding: 0.08, + type: "view_isometric", + }, + }); + + setModelingSolidId(solidId); + setModelingSelectedEntityIds([solidId]); + }); + } + + async function handleSelectCurrentBody() { + await runModelingAction("已同步当前实体选择", async () => { + if (!modelingSolidId) { + throw new Error("当前还没有可选择的实体,请先拉伸出一个实体"); + } + + await sendOfficialModelingCommand({ + cmd: { + entities: [modelingSolidId], + type: "select_replace", + }, + }); + const result = await sendOfficialModelingCommand({ + cmd: { + type: "select_get", + }, + }); + const responseData = getModelingResponseData(result); + const entityIds = Array.isArray(responseData?.entity_ids) + ? responseData.entity_ids + .map((item) => String(item || "").trim()) + .filter(Boolean) + : []; + setModelingSelectedEntityIds(entityIds); + }); + } + + async function handleCutSelectedEdges() { + await runModelingAction( + modelingCutType === "fillet" ? "已执行圆角切边" : "已执行倒角切边", + async () => { + if (!modelingSolidId) { + throw new Error("请先拉伸出一个实体,再执行圆角或倒角"); + } + + const size = parsePositiveNumberInput( + modelingCutSize, + modelingCutType === "fillet" ? "圆角半径" : "倒角距离" + ); + const edgeIndices = parseEdgeIndexList(modelingEdgeIndices); + const edgeIds: string[] = []; + + for (const edgeIndex of edgeIndices) { + const response = await sendOfficialModelingCommand({ + cmd: { + edge_index: edgeIndex, + object_id: modelingSolidId, + type: "solid3d_get_edge_uuid", + }, + }); + const responseData = getModelingResponseData(response); + const edgeId = String(responseData?.edge_id || "").trim(); + if (!edgeId) { + throw new Error(`读取第 ${edgeIndex} 条边的 UUID 失败`); + } + edgeIds.push(edgeId); + } + + await sendOfficialModelingCommand({ + captureSnapshot: true, + cmd: { + cut_type: + modelingCutType === "fillet" + ? { fillet: { radius: size } } + : { chamfer: { distance: size, swap: false } }, + edge_ids: edgeIds, + extra_face_ids: + edgeIds.length > 1 ? edgeIds.slice(1).map(() => createId()) : [], + object_id: modelingSolidId, + strategy: "automatic", + tolerance: 0.01, + type: "solid3d_cut_edges", + }, + }); + } + ); + } + + async function handleApplyKclEdit() { + if ( + !activeWorkspaceId || + !activeVersion || + activeVersion.provider !== "kittycad-cad" || + !canEditActiveKcl || + isApplyingKclEdit || + isSubmitting + ) { + return; + } + + const normalizedSourceCode = kclDraft.trim(); + if (!normalizedSourceCode) { + setCodeActionError("KCL 代码不能为空"); + return; + } + + const editPrompt = + kclEditPrompt.trim() || + `应用手动编辑后的 KCL,并基于 V${activeVersion.versionNumber} 重新求解。`; + const nextVersionNumber = versions.length + 1; + const userMessageId = createId(); + const assistantMessageId = createId(); + + setCodeActionError(null); + setIsApplyingKclEdit(true); + setMessages((current) => [ + ...current, + { + content: editPrompt, + createdAt: Date.now(), + id: userMessageId, + role: "user", + }, + { + content: `收到,这轮会直接使用你编辑后的 KCL 代码提交官方 iteration,并把结果记为 V${nextVersionNumber}。`, + createdAt: Date.now(), + id: assistantMessageId, + role: "assistant", + state: "thinking", + }, + ]); + + try { + const result = await aiCadGenerationService.createTask({ + iterationSourceTaskId: activeVersion.taskId, + outputFormat: "glb", + prompt: editPrompt, + sourceCode: kclDraft, + sourceRanges: [], + }); + + if (!result.success || !result.data?.taskId) { + throw new Error(result.error || "提交 KCL iteration 失败"); + } + + const versionId = createId(); + const createdConversationId = + "conversationId" in result.data + ? readNullableText(result.data.conversationId) + : null; + const createdModelVersion = + "modelVersion" in result.data + ? readNullableText(result.data.modelVersion) + : null; + + const nextVersion: GenerationVersion = { + assistantMessageId, + conversationId: createdConversationId, + createdAt: Date.now(), + generationMode: "iterate", + id: versionId, + modelVersion: createdModelVersion || "KCL Iteration", + parentVersionId: activeVersion.id, + progress: null, + prompt: editPrompt, + provider: "kittycad-cad", + status: result.data.status || "queued", + taskId: result.data.taskId, + versionNumber: nextVersionNumber, + }; + + setVersions((current) => [nextVersion, ...current]); + setActiveVersionId(versionId); + setMessages((current) => + current.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: buildAssistantMessage( + nextVersionNumber, + nextVersion.status, + null, + null + ), + state: + nextVersion.status === "running" + ? "running" + : nextVersion.status === "success" + ? "success" + : "queued", + versionId, + } + : message + ) + ); + setKclEditPrompt(""); + } catch (error) { + const message = + error instanceof Error ? error.message : "提交 KCL iteration 失败"; + setCodeActionError(message); + setMessages((current) => + current.map((messageItem) => + messageItem.id === assistantMessageId + ? { + ...messageItem, + content: `KCL 重算失败了:${message}。你可以检查语法后重试,或者补一句更明确的修改意图。`, + state: "failed", + } + : messageItem + ) + ); + } finally { + setIsApplyingKclEdit(false); + } + } + async function handleSubmit() { const trimmedPrompt = prompt.trim(); if ( @@ -3511,6 +4092,25 @@ export function AiCadWorkbench() { setMessages(createWelcomeMessages()); setVersions([]); setActiveVersionId(null); + setKclDraft(""); + setKclEditPrompt(""); + setModelingError(null); + setModelingSnapshotUrl(null); + setModelingPlaneId(null); + setModelingSketchId(null); + setModelingSolidId(null); + setModelingSelectedEntityIds([]); + setModelingLogLines([]); + + if (modelingSessionIdRef.current) { + const previousSessionId = modelingSessionIdRef.current; + modelingSessionIdRef.current = null; + setModelingSessionId(null); + setModelingConnectionState("disconnected"); + swallowAsync( + aiCadGenerationService.closeModelingSession(previousSessionId) + ); + } } return ( @@ -3542,20 +4142,6 @@ export function AiCadWorkbench() { - -
AI
@@ -3677,18 +4263,84 @@ export function AiCadWorkbench() {
- {activeVersion ? `Focus V${activeVersion.versionNumber}` : "Ready for first pass"} + {generationProvider === "kittycad-cad" ? ( + + + + + + + + ) : null}
-
- - -
+
@@ -3983,6 +4627,234 @@ export function AiCadWorkbench() {
+ {generationProvider === "kittycad-cad" ? ( +
+
+
+
+ 官方建模工具 +
+

+ 这一组直接走官方{" "} + + ws/modeling/commands + + ,不是前端假按钮。 +

+
+ + {modelingConnectionState} + +
+ +
+ + setModelingWidth(event.target.value) + } + placeholder="宽度" + value={modelingWidth} + /> + + setModelingHeight(event.target.value) + } + placeholder="高度" + value={modelingHeight} + /> + + setModelingDepth(event.target.value) + } + placeholder="拉伸深度" + value={modelingDepth} + /> +
+ +
+ + setModelingEdgeIndices(event.target.value) + } + placeholder="边索引,例如 0,1,2,3" + value={modelingEdgeIndices} + /> + + + setModelingCutSize(event.target.value) + } + placeholder="半径 / 距离" + value={modelingCutSize} + /> +
+ +
+ + + + + +
+ +
+

+ 当前平面: + + {truncateIdentifier(modelingPlaneId, 16)} + +

+

+ 当前草图: + + {truncateIdentifier(modelingSketchId, 16)} + + ,当前实体: + + {truncateIdentifier(modelingSolidId, 16)} + +

+

+ 当前选择: + + {modelingSelectedEntityIds.length > 0 + ? modelingSelectedEntityIds + .map((item) => + truncateIdentifier(item, 10) + ) + .join(", ") + : "未选择"} + +

+
+ + {modelingError ? ( +
+ {modelingError} +
+ ) : null} + + {modelingLogLines.length > 0 ? ( +
+
+ session log +
+
+ {modelingLogLines.slice(0, 3).map((line) => ( +

{line}

+ ))} +
+
+ ) : null} +
+ ) : null} +