feat: add knowledge-driven joint design studio

This commit is contained in:
Jerry
2026-08-14 13:53:51 +08:00
parent 7d12459a32
commit 281afdfd9e
57 changed files with 269659 additions and 1152 deletions
+291 -13
View File
@@ -1,6 +1,7 @@
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamText, type ModelMessage } from "ai";
import { generateText, Output, streamText, type ModelMessage } from "ai";
import { z } from "zod";
import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "./config";
type ChatMessage = {
@@ -8,6 +9,11 @@ type ChatMessage = {
content: string;
};
export type CadOptions = {
kind?: "joint_module" | "reducer";
mode?: "auto" | "requirement";
};
function messageContent(value: unknown) {
if (!value || typeof value !== "object") return "";
const record = value as Record<string, unknown>;
@@ -122,14 +128,15 @@ function compactJson(value: unknown, maxLength: number) {
function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[]) {
return [
"You are the CAD Agent Studio assistant inside Orbit Joint Studio.",
"You are the intelligent mechanical design assistant inside 灵心造物.",
"Follow the CAD Agent Studio Agent Rules from cadSet.",
"You may answer ordinary questions, clarify requirements, and explain engineering tradeoffs.",
"Strict honesty rule: never claim that a CAD file, model, STEP, URDF, or preview artifact was generated unless a real server-side CAD generation tool has succeeded and returned artifact URLs.",
"This Orbit integration exposes real server-side CAD generation, task storage, artifact download, and upload endpoints. The UI has a separate explicit Generate action; ordinary chat must remain ordinary chat.",
"This product exposes real server-side design generation, task storage, artifact download, and upload endpoints. The UI has a separate explicit Generate action; ordinary chat must remain ordinary chat.",
"White-label rule: in user-facing replies, do not mention model providers, third-party product names, or internal file-format names unless the user explicitly asks for technical export details. Use 灵心造物, 设计模型, 零件, 拼装, and 验证 as the product vocabulary.",
"If the user asks you to generate or modify CAD inside chat, clarify the requirements and tell them to use the explicit Generate action when they are ready. Do not invent files, task ids, generated variants, or viewer state.",
"Do not rely on hardcoded templates, canned examples, mock data, fake filenames, or imaginary viewer state.",
"普通聊天、确认、说明、提问不得自动生成 CAD。只有真实 CAD 工具接通并成功写出 artifact 后,才允许说已经生成。",
"普通聊天、确认、说明、提问不得自动生成设计模型。只有真实设计工具成功生成并保存模型文件后,才允许说已经生成。",
`Viewer context count: ${viewerContext.length}`,
viewerContext.length ? compactJson(viewerContext, 10000) : "",
`Attachment count: ${attachments.length}`,
@@ -152,7 +159,7 @@ function messageText(message: Record<string, unknown> | undefined) {
}
function hasCadIntent(text: string) {
return /(生成|创建|设计|建模|重建|导出|修改|调整|改成).*(减速器|行星|关节|模组|cad|step|urdf|mjcf|模数|传动比|行星轮|齿宽|压力角)|\b(generate|create|design|reconstruct|export|modify|edit)\b.*\b(cad|step|urdf|mjcf|reducer|joint|planetary|module|ratio)\b/i.test(text);
return /(生成|创建|设计|建模|重建|导出|修改|调整|改成).*(减速器|行星|关节|模组|cad|step|urdf|mjcf|模数|传动比|行星轮|齿宽|压力角)|(?:减速器|行星|关节|模组).*(?:扭矩|传动比|减速比|尺寸|外径|紧凑|噪音|模数|齿宽|压力角)|\b(generate|create|design|reconstruct|export|modify|edit)\b.*\b(cad|step|urdf|mjcf|reducer|joint|planetary|module|ratio)\b/i.test(text);
}
export type RequirementOverrides = {
@@ -169,6 +176,41 @@ export type RequirementOverrides = {
max_outer_diameter_mm?: number;
};
const cadCandidateSchema = z.object({
title: z.string().min(1).max(80),
rationale: z.string().min(1).max(240),
topology_family: z.enum(["simple_2k_h", "simple_2k_h_cascade", "ferguson_wolfrom"]),
target_ratio: z.number().positive(),
tooth_form: z.enum(["spur", "helical"]),
module_mm: z.number().positive().optional(),
planet_count: z.number().int().min(3).max(4).optional(),
helix_angle_deg: z.number().min(0).max(35).optional(),
pressure_angle_deg: z.number().min(14.5).max(25).optional(),
face_width_mm: z.number().positive().max(30).optional(),
backlash_mm: z.number().min(0).max(1).optional(),
ring_rim_thickness_mm: z.number().positive().max(20).optional(),
max_outer_diameter_mm: z.number().positive().optional(),
reuse_design_id: z.string().nullable().optional(),
});
const cadDesignPlanSchema = z.object({
interpreted_requirement: z.string().min(1).max(400),
assumptions: z.array(z.string().max(200)).max(6),
candidates: z.array(cadCandidateSchema).min(1).max(3),
});
type CadCandidate = z.infer<typeof cadCandidateSchema>;
type CatalogDesign = {
id: string;
kind: "joint_module" | "reducer";
topology: CadCandidate["topology_family"];
ratio: number;
parameters?: Record<string, unknown>;
eligible: boolean;
catalogClass?: string;
};
function firstNumber(text: string, patterns: RegExp[], integer = false) {
for (const pattern of patterns) {
const match = text.match(pattern);
@@ -242,12 +284,219 @@ async function backendJson(pathname: string, init: RequestInit = {}) {
return payload;
}
function candidateOverrides(candidate: CadCandidate): RequirementOverrides {
return Object.fromEntries(Object.entries({
topology_family: candidate.topology_family,
target_ratio: candidate.target_ratio,
tooth_form: candidate.tooth_form,
module_mm: candidate.module_mm,
planet_count: candidate.planet_count,
helix_angle_deg: candidate.tooth_form === "helical" ? candidate.helix_angle_deg || 15 : 0,
pressure_angle_deg: candidate.pressure_angle_deg,
face_width_mm: candidate.face_width_mm,
backlash_mm: candidate.backlash_mm,
ring_rim_thickness_mm: candidate.ring_rim_thickness_mm,
max_outer_diameter_mm: candidate.max_outer_diameter_mm,
}).filter(([, value]) => value !== undefined)) as RequirementOverrides;
}
function closeEnough(actual: unknown, expected: unknown, tolerance = 0.001) {
const actualNumber = Number(actual);
const expectedNumber = Number(expected);
return Number.isFinite(actualNumber)
&& Number.isFinite(expectedNumber)
&& Math.abs(actualNumber - expectedNumber) <= tolerance;
}
export function findReusableDesign(
candidate: CadCandidate,
catalog: CatalogDesign[],
kind: "joint_module" | "reducer",
usedIds = new Set<string>(),
) {
const compatible = catalog.filter((design) => {
if (!design.eligible || design.catalogClass === "test_artifact" || usedIds.has(design.id)) return false;
if (design.kind !== kind || design.topology !== candidate.topology_family) return false;
const ratioTolerance = Math.max(0.02, candidate.target_ratio * 0.002);
if (!closeEnough(design.ratio, candidate.target_ratio, ratioTolerance)) return false;
const parameters = design.parameters || {};
if (String(parameters.tooth_form || "spur") !== candidate.tooth_form) return false;
if (candidate.module_mm !== undefined && !closeEnough(parameters.module_mm, candidate.module_mm)) return false;
if (candidate.planet_count !== undefined && !closeEnough(parameters.planet_count, candidate.planet_count)) return false;
if (candidate.face_width_mm !== undefined && !closeEnough(parameters.face_width_mm, candidate.face_width_mm)) return false;
return true;
});
if (candidate.reuse_design_id) {
const requested = compatible.find((design) => design.id === candidate.reuse_design_id);
if (requested) return requested;
}
return compatible[0] || null;
}
async function planCadCandidates({
text,
kind,
provider,
model,
catalog,
}: {
text: string;
kind: "joint_module" | "reducer";
provider?: string;
model?: string;
catalog: CatalogDesign[];
}) {
const config = loadLlmConfig();
const selected = selectedModelId(provider, model, config);
const apiKey = resolveProviderApiKey(selected.providerConfig);
if (!apiKey) throw new Error("智能设计模型尚未配置访问密钥。");
const languageModel = buildLanguageModel({
apiKey,
provider: selected.provider,
providerConfig: selected.providerConfig,
model: selected.model,
});
const supportedTopologies = kind === "joint_module"
? ["simple_2k_h", "simple_2k_h_cascade"]
: ["simple_2k_h", "simple_2k_h_cascade", "ferguson_wolfrom"];
const verifiedCatalog = catalog
.filter((design) => design.eligible && design.catalogClass !== "test_artifact" && design.kind === kind)
.map((design) => ({
id: design.id,
topology: design.topology,
ratio: design.ratio,
parameters: design.parameters,
}));
const result = await generateText({
model: languageModel,
output: Output.object({
schema: cadDesignPlanSchema,
name: "mechanical_design_plan",
description: "A constrained plan that can be executed by the mechanical CAD toolchain.",
}),
system: [
"You are the mechanical design planner inside 灵心造物.",
"Convert the user's request into executable candidates for the real CAD solver; never invent unsupported mechanisms or claim validation.",
`The selected product kind is ${kind}. Supported topology families: ${supportedTopologies.join(", ")}.`,
"Return up to three meaningfully different candidates. Use only module values 0.25, 0.3, or 0.5 mm and 3 or 4 planets.",
"For helical gears use a helix angle from 12 to 20 degrees. Pressure angle should normally be 20 degrees.",
"A verified existing design may be reused only when its kind, topology, ratio, tooth form, module, planet count and explicit user constraints match.",
"Set reuse_design_id only to an id from the verified catalog. Otherwise leave it null so the CAD program generates a new design.",
"Do not output prose outside the structured result.",
].join("\n"),
prompt: [
`User requirement: ${text}`,
`Verified generated-design catalog: ${compactJson(verifiedCatalog, 12000)}`,
"Produce the strongest feasible candidate set for this request.",
].join("\n\n"),
temperature: 0.2,
maxOutputTokens: 1800,
});
const explicit = parseRequirementOverrides(text);
const allowed = new Set(supportedTopologies);
const candidates = result.output.candidates.map((candidate) => {
const merged = { ...candidate, ...explicit } as CadCandidate;
if (!allowed.has(merged.topology_family)) {
throw new Error(`当前${kind === "joint_module" ? "关节模组" : "减速器"}不支持 ${merged.topology_family}。`);
}
return merged;
});
return {
plan: { ...result.output, candidates },
provider: selected.provider,
model: selected.model,
};
}
async function runCadDesignBatch({
text,
cadOptions,
provider,
model,
}: {
text: string;
cadOptions?: CadOptions;
provider?: string;
model?: string;
}) {
const kind = cadOptions?.kind || (/(关节|模组|joint|actuator)/i.test(text) ? "joint_module" : "reducer");
const catalogPayload = await backendJson("/api/designs");
const catalog = (Array.isArray(catalogPayload?.designs) ? catalogPayload.designs : []) as CatalogDesign[];
const planned = await planCadCandidates({ text, kind, provider, model, catalog });
const planId = `plan_${Date.now().toString(36)}`;
const designIds: string[] = [];
const usedIds = new Set<string>();
const failures: string[] = [];
let generatedCount = 0;
let reusedCount = 0;
for (const [index, candidate] of planned.plan.candidates.entries()) {
const reusable = findReusableDesign(candidate, catalog, kind, usedIds);
if (reusable) {
designIds.push(reusable.id);
usedIds.add(reusable.id);
reusedCount += 1;
continue;
}
try {
const generated = await backendJson("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind,
request: `${text} · ${candidate.title}`,
mode: cadOptions?.mode || "auto",
requirementOverrides: candidateOverrides(candidate),
planner: {
planId,
provider: planned.provider,
model: planned.model,
candidateIndex: index,
candidate,
},
}),
});
if (generated?.status !== "success" || !generated?.taskId) {
failures.push(`${candidate.title}:生成或验证未通过`);
continue;
}
const exported = await backendJson(`/api/tasks/${encodeURIComponent(generated.taskId)}/exports/urdf`, {
method: "POST",
});
if (exported?.status !== "success") {
failures.push(`${candidate.title}:装配模型导出失败`);
continue;
}
designIds.push(String(generated.taskId));
usedIds.add(String(generated.taskId));
generatedCount += 1;
} catch (error) {
failures.push(`${candidate.title}:${error instanceof Error ? error.message : "执行失败"}`);
}
}
if (!designIds.length) {
throw new Error(`没有方案通过真实生成与验证。${failures.length ? ` ${failures.join(";")}` : ""}`);
}
return {
planId,
provider: planned.provider,
model: planned.model,
interpretedRequirement: planned.plan.interpreted_requirement,
designIds,
generatedCount,
reusedCount,
failures,
};
}
async function runExplicitCadAction({
text,
selectedTaskId,
cadOptions,
}: {
text: string;
selectedTaskId?: string;
cadOptions?: CadOptions;
}) {
if (/(重建|reconstruct).*(step|stp|上传)/i.test(text) || /(step|stp|上传).*(重建|reconstruct)/i.test(text)) {
if (!selectedTaskId) throw new Error("请先选择包含 STEP 上传文件的任务。");
@@ -282,25 +531,32 @@ async function runExplicitCadAction({
if (!selectedTaskId) throw new Error("请先在右侧选择一个已生成任务,再导出 URDF。");
return backendJson(`/api/tasks/${encodeURIComponent(selectedTaskId)}/exports/urdf`, { method: "POST" });
}
const kind = /(关节|模组|joint|actuator)/i.test(text) ? "joint_module" : "reducer";
return backendJson("/api/generate", {
const kind = cadOptions?.kind || (/(关节|模组|joint|actuator)/i.test(text) ? "joint_module" : "reducer");
const generated = await backendJson("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind,
request: text,
mode: "auto",
mode: cadOptions?.mode || "auto",
requirementOverrides: parseRequirementOverrides(text),
}),
});
if (generated?.status !== "success" || !generated?.taskId) {
return generated;
}
return backendJson(`/api/tasks/${encodeURIComponent(generated.taskId)}/exports/urdf`, {
method: "POST",
});
}
function textResponse(text: string) {
function textResponse(text: string, extraHeaders: Record<string, string> = {}) {
return new Response(text, {
status: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
@@ -312,6 +568,7 @@ export async function streamAgentText({
provider,
model,
selectedTaskId,
cadOptions,
}: {
messages: unknown[];
attachments: unknown[];
@@ -319,13 +576,34 @@ export async function streamAgentText({
provider?: string;
model?: string;
selectedTaskId?: string;
cadOptions?: CadOptions;
}) {
const userText = messageText(lastUserText(messages));
if (hasCadIntent(userText)) {
const result = await runExplicitCadAction({ text: userText, selectedTaskId });
const artifact = result?.robotExport?.packageUrl || result?.artifacts?.find((item: Record<string, unknown>) => item.role === "primary")?.url;
const suffix = artifact ? `\n产物:${artifact}` : "";
return textResponse(`${result?.summary || "CAD 工具链执行完成。"}\n任务 ID:${result?.taskId || selectedTaskId || "-"}${suffix}`);
const isExistingTaskAction = Boolean(selectedTaskId) && /(重建|修改|调整|改成|导出|reconstruct|modify|edit|export)/i.test(userText);
if (isExistingTaskAction) {
const result = await runExplicitCadAction({ text: userText, selectedTaskId, cadOptions });
const artifact = result?.robotExport?.packageUrl || result?.artifacts?.find((item: Record<string, unknown>) => item.role === "primary")?.url;
const suffix = artifact ? `\n产物:${artifact}` : "";
return textResponse(`${result?.summary || "设计工具链执行完成。"}\n任务 ID:${result?.taskId || selectedTaskId || "-"}${suffix}`);
}
const batch = await runCadDesignBatch({
text: userText,
cadOptions,
provider,
model,
});
const failureText = batch.failures.length ? `,另有 ${batch.failures.length} 个候选未通过工程验证` : "";
return textResponse(
`已理解目标:${batch.interpretedRequirement}\n已得到 ${batch.designIds.length} 个真实可查看方案:本次新生成 ${batch.generatedCount} 个,复用已验证结果 ${batch.reusedCount} 个${failureText}。`,
{
"X-Lingxin-Design-Ids": batch.designIds.join(","),
"X-Lingxin-Plan-Id": batch.planId,
"X-Lingxin-Generated-Count": String(batch.generatedCount),
"X-Lingxin-Reused-Count": String(batch.reusedCount),
"X-Lingxin-Planner": batch.provider,
},
);
}
const config = loadLlmConfig();
const selected = selectedModelId(provider, model, config);
+10
View File
@@ -103,6 +103,15 @@ const server = http.createServer(async (request, response) => {
try {
const body = await readJson(request);
const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
const cadOptionsRecord = record.cadOptions && typeof record.cadOptions === "object"
? record.cadOptions as Record<string, unknown>
: {};
const cadKind = cadOptionsRecord.kind === "joint_module" || cadOptionsRecord.kind === "reducer"
? cadOptionsRecord.kind
: undefined;
const cadMode = cadOptionsRecord.mode === "auto" || cadOptionsRecord.mode === "requirement"
? cadOptionsRecord.mode
: undefined;
const streamResponse = await streamAgentText({
messages: Array.isArray(record.messages) ? record.messages : [],
attachments: Array.isArray(record.attachments) ? record.attachments : [],
@@ -110,6 +119,7 @@ const server = http.createServer(async (request, response) => {
provider: typeof record.provider === "string" ? record.provider : undefined,
model: typeof record.model === "string" ? record.model : undefined,
selectedTaskId: typeof record.selectedTaskId === "string" ? record.selectedTaskId : undefined,
cadOptions: { kind: cadKind, mode: cadMode },
});
await writeWebResponse(response, streamResponse);
} catch (error) {