feat: add AI-CAD workspace and KittyCAD integration
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-06 14:46:51 +08:00
parent 276da822a6
commit 413b5ffac1
26 changed files with 8900 additions and 246 deletions

View File

@ -43,3 +43,8 @@ GTP5_MODEL=gpt-5-nano
# 优先使用系统设置-本地AI里的 `tripo-3d` 提供商;若未配置则回退到环境变量
TRIPO_API_KEY=your-tripo-api-key
TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi
# KittyCAD / Zoo Text-to-CAD(可选)
# 优先使用系统设置-本地AI里的 `kittycad-cad` 提供商;若未配置则回退到环境变量
KITTYCAD_API_TOKEN=your-kittycad-api-token
KITTYCAD_BASE_URL=https://api.zoo.dev

View File

@ -331,6 +331,7 @@ type AiProvider = {
| "zhipu"
| "tripo-3d"
| "worldlabs-3d"
| "kittycad-cad"
| "runway"
| "volcano-multimodal";
baseUrl: string;
@ -655,6 +656,7 @@ const PROVIDER_TYPES = [
{ value: "midjourney", label: "Midjourney" },
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
{ value: "worldlabs-3d", label: "World Labs 3D 世界生成" },
{ value: "kittycad-cad", label: "KittyCAD / Zoo CAD" },
{ value: "runway", label: "Runway API" },
{ value: "zhipu", label: "智普官方 4.7" },
{ value: "volcano-multimodal", label: "火山引擎多模态" },
@ -683,6 +685,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai",
"kittycad-cad": "https://api.zoo.dev",
runway: "https://api.dev.runwayml.com",
zhipu: "https://open.bigmodel.cn/api/paas/v4",
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
@ -1371,6 +1374,14 @@ export function AdminSettingsClient() {
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
string | null
>(null);
const [kittycadCadConfig, setKittycadCadConfig] = useState({
providerId: "",
name: "KittyCAD CAD",
baseUrl: getProviderDefaultBaseUrl("kittycad-cad"),
apiKey: "",
isEnabled: true,
});
const [isTestingKittycadCad, setIsTestingKittycadCad] = useState(false);
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
providerId: "",
modelEntryId: "",
@ -1902,6 +1913,32 @@ export function AdminSettingsClient() {
[worldlabsProviders]
);
useEffect(() => {
const kittycadProvider = (providersData?.providers || []).find(
(provider) => provider.type === "kittycad-cad"
);
if (kittycadProvider) {
setKittycadCadConfig({
providerId: kittycadProvider.id,
name: kittycadProvider.name || "KittyCAD CAD",
baseUrl:
kittycadProvider.baseUrl ||
getProviderDefaultBaseUrl("kittycad-cad"),
apiKey: "",
isEnabled: kittycadProvider.isEnabled,
});
return;
}
setKittycadCadConfig({
providerId: "",
name: "KittyCAD CAD",
baseUrl: getProviderDefaultBaseUrl("kittycad-cad"),
apiKey: "",
isEnabled: true,
});
}, [providersData?.providers]);
// 导航链接状态
const [navLinkForm, setNavLinkForm] = useState({
id: "",
@ -2217,6 +2254,7 @@ export function AdminSettingsClient() {
| "zhipu"
| "tripo-3d"
| "worldlabs-3d"
| "kittycad-cad"
| "runway"
| "volcano-multimodal",
baseUrl: "",
@ -4248,6 +4286,98 @@ export function AdminSettingsClient() {
}
};
const handleSaveKittycadCadConfig = async () => {
try {
const trimmedName = kittycadCadConfig.name.trim() || "KittyCAD CAD";
const trimmedBaseUrl =
kittycadCadConfig.baseUrl.trim() ||
getProviderDefaultBaseUrl("kittycad-cad");
const hasProvider = Boolean(kittycadCadConfig.providerId);
if (!hasProvider && !kittycadCadConfig.apiKey.trim()) {
toast.error("请填写 KittyCAD API Token");
return;
}
const requestData: Record<string, any> = hasProvider
? {
id: kittycadCadConfig.providerId,
name: trimmedName,
type: "kittycad-cad",
baseUrl: trimmedBaseUrl,
isEnabled: kittycadCadConfig.isEnabled,
}
: {
name: trimmedName,
type: "kittycad-cad",
baseUrl: trimmedBaseUrl,
apiKey: kittycadCadConfig.apiKey.trim(),
isEnabled: kittycadCadConfig.isEnabled,
};
if (hasProvider && kittycadCadConfig.apiKey.trim()) {
requestData.apiKey = kittycadCadConfig.apiKey.trim();
}
const response = await fetch("/api/admin/ai-providers", {
method: hasProvider ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("KittyCAD CAD 配置保存成功");
setKittycadCadConfig((prev) => ({ ...prev, apiKey: "" }));
mutateProviders();
} catch (error) {
console.error("保存 KittyCAD CAD 配置失败:", error);
toast.error("保存 KittyCAD CAD 配置失败");
}
};
const handleTestKittycadCadConfig = async () => {
try {
setIsTestingKittycadCad(true);
const response = await fetch("/api/admin/kittycad/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: {
providerId: kittycadCadConfig.providerId || undefined,
name: kittycadCadConfig.name.trim() || "KittyCAD CAD",
baseUrl:
kittycadCadConfig.baseUrl.trim() ||
getProviderDefaultBaseUrl("kittycad-cad"),
apiKey: kittycadCadConfig.apiKey.trim() || undefined,
},
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
payload?.cause || payload?.message || payload?.error || "连接测试失败"
);
}
const accountLabel =
payload?.userName || payload?.userEmail || payload?.userId || "当前账号";
toast.success(
`连接成功:${accountLabel}(${payload?.latencyMs || 0}ms)`
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "KittyCAD 连接测试失败"
);
} finally {
setIsTestingKittycadCad(false);
}
};
const handleSaveMoonshotDetailAgentConfig = async () => {
try {
const trimmedBaseUrl =
@ -6430,6 +6560,7 @@ export function AdminSettingsClient() {
midjourney: "bg-fuchsia-600",
"tripo-3d": "bg-teal-500",
"worldlabs-3d": "bg-sky-600",
"kittycad-cad": "bg-orange-500",
openai: "bg-green-500",
zhipu: "bg-lime-500",
"volcano-multimodal": "bg-fuchsia-500",
@ -12716,6 +12847,131 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="text-xl">
KittyCAD CAD API 配置
</CardTitle>
<CardDescription>
配置 AI-CAD 页面后续接入的 KittyCAD / Zoo Text-to-CAD
服务,保存后写入数据库 AI 提供商配置。
</CardDescription>
</div>
<LocalAiSectionToggleButton
expanded={isLocalAiSectionExpanded("kittycad-cad")}
onClick={() => toggleLocalAiSection("kittycad-cad")}
/>
</CardHeader>
<CardContent
className={cn(
"local-ai-config-card-content space-y-6 pt-6",
!isLocalAiSectionExpanded("kittycad-cad") && "hidden"
)}
>
<div className="space-y-2">
<Label htmlFor="kittycadCadName">提供商名称</Label>
<Input
id="kittycadCadName"
value={kittycadCadConfig.name}
onChange={(e) =>
setKittycadCadConfig({
...kittycadCadConfig,
name: e.target.value,
})
}
placeholder="KittyCAD CAD"
/>
</div>
<div className="space-y-2">
<Label htmlFor="kittycadCadBaseUrl">API 地址</Label>
<Input
id="kittycadCadBaseUrl"
value={kittycadCadConfig.baseUrl}
onChange={(e) =>
setKittycadCadConfig({
...kittycadCadConfig,
baseUrl: e.target.value,
})
}
placeholder={getProviderDefaultBaseUrl("kittycad-cad")}
/>
<div className="text-xs text-muted-foreground">
默认:{getProviderDefaultBaseUrl("kittycad-cad")}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="kittycadCadApiKey">
API Token {kittycadCadConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="kittycadCadApiKey"
type="password"
value={kittycadCadConfig.apiKey}
onChange={(e) =>
setKittycadCadConfig({
...kittycadCadConfig,
apiKey: e.target.value,
})
}
placeholder={
kittycadCadConfig.providerId
? "留空则不修改当前 Token"
: "请输入 KittyCAD / Zoo API Token"
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用 KittyCAD CAD</Label>
<div className="text-xs text-muted-foreground">
关闭后 AI-CAD 页面将无法切换到外部 CAD 服务
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={kittycadCadConfig.isEnabled}
onCheckedChange={(checked) =>
setKittycadCadConfig({
...kittycadCadConfig,
isEnabled: checked,
})
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{kittycadCadConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="space-y-1 rounded-lg border border-orange-200 bg-orange-50 p-4 text-orange-800 text-xs">
<p>• 默认接入地址为 `https://api.zoo.dev`。</p>
<p>
• 建议填写 Zoo 官方 API Token;如果你有自建兼容服务,也可以改成自定义地址。
</p>
<p>
• 测试连接只会校验当前账号和 Token,不会创建 CAD 任务,也不会消耗生成额度。
</p>
</div>
<div className="flex flex-wrap justify-end gap-3">
<Button
variant="outline"
onClick={handleTestKittycadCadConfig}
disabled={isTestingKittycadCad}
>
{isTestingKittycadCad ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Database className="mr-2 h-4 w-4" />
)}
测试连接
</Button>
<Button onClick={handleSaveKittycadCadConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 KittyCAD CAD 配置
</Button>
</div>
</CardContent>
</Card>
{/* 抠图API配置 */}
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">

View File

@ -0,0 +1,5 @@
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
export default function AiCadPage() {
return <AiCadWorkbench />;
}

View File

@ -31,6 +31,7 @@ const providerTypes = [
"zhipu",
"tripo-3d",
"worldlabs-3d",
"kittycad-cad",
"runway",
"volcano-multimodal",
] as const;
@ -64,6 +65,7 @@ const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/;
@ -212,6 +214,8 @@ export async function POST(request: Request) {
? data.baseUrl || TRIPO_3D_BASE_URL
: data.type === "worldlabs-3d"
? data.baseUrl || WORLDLABS_3D_BASE_URL
: data.type === "kittycad-cad"
? data.baseUrl || KITTYCAD_CAD_BASE_URL
: data.type === "runway"
? data.baseUrl || RUNWAY_BASE_URL
: data.baseUrl,

View File

@ -0,0 +1,148 @@
import { z } from "zod";
import { getAiProviderById } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
import { requireAdmin } from "@/lib/middleware/admin";
export const runtime = "nodejs";
const requestSchema = z.object({
config: z
.object({
providerId: z.string().uuid().optional(),
name: z.string().trim().optional(),
baseUrl: z.string().trim().optional(),
apiKey: z.string().trim().optional(),
})
.optional(),
});
function pickDisplayName(user: Record<string, unknown>) {
const candidates = [
user.name,
user.full_name,
user.display_name,
user.username,
user.first_name,
];
for (const value of candidates) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function pickEmail(user: Record<string, unknown>) {
const candidates = [user.email, user.primary_email];
for (const value of candidates) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
async function resolveKittyCadConfig(
input?: {
providerId?: string;
name?: string;
baseUrl?: string;
apiKey?: string;
} | null
) {
const providerId = String(input?.providerId || "").trim();
const inputApiKey = String(input?.apiKey || "").trim();
const inputBaseUrl = String(input?.baseUrl || "").trim();
const inputName = String(input?.name || "").trim();
if (providerId) {
const savedProvider = await getAiProviderById({ id: providerId });
if (!savedProvider) {
throw new Error("未找到对应的 KittyCAD CAD 提供商");
}
return {
apiKey: inputApiKey || String(savedProvider.apiKey || "").trim(),
baseUrl: inputBaseUrl || String(savedProvider.baseUrl || "").trim(),
providerId: savedProvider.id,
providerName: inputName || savedProvider.name || "KittyCAD CAD",
source: "provider" as const,
};
}
if (inputApiKey) {
return {
apiKey: inputApiKey,
baseUrl: inputBaseUrl || "https://api.zoo.dev",
providerName: inputName || "KittyCAD CAD",
source: "provider" as const,
};
}
return await getKittyCadProviderConfig();
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = requestSchema.parse(await request.json().catch(() => ({})));
const config = await resolveKittyCadConfig(body.config);
if (!config.apiKey.trim()) {
throw new Error("请填写 KittyCAD API Token");
}
const startedAt = Date.now();
const user = await kittyCadRequest<Record<string, unknown>>(config, {
method: "GET",
path: "/user",
});
const latencyMs = Date.now() - startedAt;
return Response.json({
success: true,
providerName: config.providerName || "KittyCAD CAD",
baseUrl: config.baseUrl,
latencyMs,
userId:
typeof user.id === "string" && user.id.trim() ? user.id.trim() : null,
userName: pickDisplayName(user),
userEmail: pickEmail(user),
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof KittyCadApiError) {
return Response.json(
{
code: "bad_request:api",
message: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 400 }
);
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
return Response.json(
{
code: "bad_request:api",
message:
error instanceof Error ? error.message : "KittyCAD 连接测试失败",
},
{ status: 400 }
);
}
}

View File

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { findKittyCadProviderConfig } from "@/lib/kittycad/server";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const config = await findKittyCadProviderConfig();
return NextResponse.json({
success: true,
data: {
configured: Boolean(config),
source: config?.source || "none",
providerName: config?.providerName || null,
baseUrl: config?.baseUrl || null,
},
});
}

View File

@ -0,0 +1,391 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 60;
type KittyCadTaskResponse = {
id?: string;
status?: string;
outputs?: Record<string, string> | null;
error?: string | null;
code?: string | null;
prompt?: string | null;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
type ArchivedFile = {
sourceHash: string;
archivedUrl: string;
};
type RouteParams = {
params: Promise<{ taskId: string }>;
};
function getArchiveCache() {
const globalState = globalThis as Record<string, unknown>;
if (!globalState.__kittyCadAssetArchiveCache) {
globalState.__kittyCadAssetArchiveCache = new Map<string, ArchivedFile>();
}
return globalState.__kittyCadAssetArchiveCache as Map<string, ArchivedFile>;
}
function getArchiveInflight() {
const globalState = globalThis as Record<string, unknown>;
if (!globalState.__kittyCadAssetArchiveInflight) {
globalState.__kittyCadAssetArchiveInflight = new Map<
string,
Promise<string | null>
>();
}
return globalState.__kittyCadAssetArchiveInflight as Map<
string,
Promise<string | null>
>;
}
function mapKittyCadStatus(status: unknown) {
const normalized = String(status || "")
.trim()
.toLowerCase();
if (!normalized) {
return "queued" as const;
}
if (
normalized === "queued" ||
normalized === "pending" ||
normalized === "created" ||
normalized === "submitted"
) {
return "queued" as const;
}
if (
normalized === "running" ||
normalized === "processing" ||
normalized === "in_progress" ||
normalized === "working" ||
normalized === "generating"
) {
return "running" as const;
}
if (
normalized === "completed" ||
normalized === "success" ||
normalized === "succeeded" ||
normalized === "done" ||
normalized === "finished"
) {
return "success" as const;
}
if (normalized === "cancelled" || normalized === "canceled") {
return "cancelled" as const;
}
if (normalized === "expired" || normalized === "timed_out") {
return "expired" as const;
}
if (normalized === "banned" || normalized === "blocked") {
return "banned" as const;
}
if (
normalized === "failed" ||
normalized === "error" ||
normalized === "errored"
) {
return "failed" as const;
}
return "unknown" as const;
}
function getFileExtension(filename: string) {
const normalized = String(filename || "")
.trim()
.toLowerCase();
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
const segments = cleanName.split(".");
return segments.length > 1 ? segments.at(-1) || "" : "";
}
function isImageExtension(extension: string) {
return ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
}
function pickPreferredModelFile(files: Array<{ name: string; url: string }>) {
const preferredOrder = ["glb", "gltf", "obj", "stl", "step", "stp"];
for (const extension of preferredOrder) {
const matched = files.find(
(item) => getFileExtension(item.name) === extension
);
if (matched) {
return matched;
}
}
return null;
}
function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
return (
files.find((item) => isImageExtension(getFileExtension(item.name))) || null
);
}
async function archiveOutputIfNeeded(params: {
taskId: string;
filename: string;
base64: string;
}) {
const normalizedFilename = String(params.filename || "").trim();
const normalizedBase64 = String(params.base64 || "").trim();
if (!normalizedFilename || !normalizedBase64) {
return null;
}
const cacheKey = `${params.taskId}:${normalizedFilename}`;
const cache = getArchiveCache();
const cached = cache.get(cacheKey);
if (cached && cached.sourceHash === normalizedBase64) {
return cached.archivedUrl;
}
const inflight = getArchiveInflight();
const existingTask = inflight.get(cacheKey);
if (existingTask) {
return (await existingTask) || null;
}
const archiveTask = (async () => {
try {
const binary = Buffer.from(normalizedBase64, "base64");
if (binary.byteLength <= 0) {
throw new Error("KittyCAD 输出文件为空");
}
const arrayBuffer = Uint8Array.from(binary).buffer;
const safeFilename =
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.bin";
const uploaded = await uploadFile(
`kittycad-${params.taskId}-${safeFilename}`,
arrayBuffer
);
cache.set(cacheKey, {
sourceHash: normalizedBase64,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[KittyCADArchive] failed", {
taskId: params.taskId,
filename: normalizedFilename,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(cacheKey);
}
})();
inflight.set(cacheKey, archiveTask);
return await archiveTask;
}
async function archiveTextOutputIfNeeded(params: {
taskId: string;
filename: string;
content: string;
}) {
const normalizedFilename = String(params.filename || "").trim();
const normalizedContent = String(params.content || "");
if (!normalizedFilename || !normalizedContent.trim()) {
return null;
}
const cacheKey = `${params.taskId}:${normalizedFilename}`;
const cache = getArchiveCache();
const cached = cache.get(cacheKey);
if (cached && cached.sourceHash === normalizedContent) {
return cached.archivedUrl;
}
const inflight = getArchiveInflight();
const existingTask = inflight.get(cacheKey);
if (existingTask) {
return (await existingTask) || null;
}
const archiveTask = (async () => {
try {
const arrayBuffer = Uint8Array.from(
new TextEncoder().encode(normalizedContent)
).buffer;
const safeFilename =
normalizedFilename.replace(/[^\w.-]+/g, "-") || "kittycad-output.kcl";
const uploaded = await uploadFile(
`kittycad-${params.taskId}-${safeFilename}`,
arrayBuffer
);
cache.set(cacheKey, {
sourceHash: normalizedContent,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[KittyCADArchive:text] failed", {
taskId: params.taskId,
filename: normalizedFilename,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(cacheKey);
}
})();
inflight.set(cacheKey, archiveTask);
return await archiveTask;
}
export async function GET(_request: Request, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { taskId } = await params;
const normalizedTaskId = String(taskId || "").trim();
if (!normalizedTaskId) {
return NextResponse.json(
{ success: false, error: "缺少 taskId" },
{ status: 400 }
);
}
const config = await getKittyCadProviderConfig();
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
method: "GET",
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
});
const status = mapKittyCadStatus(result.status);
if (status !== "success") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "cad",
status,
progress: status === "running" ? 65 : null,
errorMessage: result.error || null,
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat: result.output_format || null,
output: result,
},
});
}
const outputEntries = Object.entries(result.outputs || {}).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
const archivedEntries = await Promise.all(
outputEntries.map(async ([name, base64]) => {
const archivedUrl = await archiveOutputIfNeeded({
taskId: normalizedTaskId,
filename: name,
base64,
});
return archivedUrl ? { name, url: archivedUrl } : null;
})
);
const archivedFiles = archivedEntries.filter(
(item): item is { name: string; url: string } =>
Boolean(item?.name && item?.url)
);
const hasArchivedKclFile = archivedFiles.some(
(item) => getFileExtension(item.name) === "kcl"
);
if (
!hasArchivedKclFile &&
typeof result.code === "string" &&
result.code.trim()
) {
const archivedCodeUrl = await archiveTextOutputIfNeeded({
taskId: normalizedTaskId,
filename: "main.kcl",
content: result.code,
});
if (archivedCodeUrl) {
archivedFiles.push({
name: "main.kcl",
url: archivedCodeUrl,
});
}
}
const modelFile = pickPreferredModelFile(archivedFiles);
const previewImageFile = pickPreviewImageFile(archivedFiles);
const downloadUrls = Object.fromEntries(
archivedFiles.map((item) => [item.name, item.url])
);
const codeFile =
archivedFiles.find((item) => getFileExtension(item.name) === "kcl") ||
null;
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "cad",
status: "success",
progress: 100,
modelUrl:
modelFile &&
["glb", "gltf"].includes(getFileExtension(modelFile.name))
? modelFile.url
: null,
previewImageUrl: previewImageFile?.url || null,
errorMessage: result.error || null,
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat: result.output_format || null,
codeUrl: codeFile?.url || null,
downloadUrls,
output: result,
},
});
} catch (error) {
if (error instanceof KittyCadApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询 AI-CAD 任务失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,309 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { KittyCadApiError, kittyCadRequest } from "@/lib/kittycad/client";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const runtime = "nodejs";
export const maxDuration = 60;
type CreateAiCadTaskRequest = {
prompt?: string;
outputFormat?: string;
projectName?: string;
iterationSourceTaskId?: string;
};
type KittyCadCreateResponse = {
id?: string;
status?: string;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
type KittyCadSourceTaskResponse = {
code?: string | null;
outputs?: Record<string, string> | null;
};
type KittyCadIterationResponse = {
id?: string;
status?: string;
conversation_id?: string | null;
model_version?: string | null;
output_format?: string | null;
};
function getFileExtension(filename: string) {
const normalized = String(filename || "")
.trim()
.toLowerCase();
const cleanName = normalized.split("?")[0]?.split("#")[0] || normalized;
const segments = cleanName.split(".");
return segments.length > 1 ? segments.at(-1) || "" : "";
}
function decodeBase64Text(value: unknown) {
const normalized = String(value || "").trim();
if (!normalized) {
return null;
}
try {
const decoded = Buffer.from(normalized, "base64").toString("utf8").trim();
return decoded || null;
} catch (_error) {
return null;
}
}
function extractSourceCodeFromTask(task: KittyCadSourceTaskResponse) {
const directCode = String(task.code || "").trim();
if (directCode) {
return directCode;
}
const kclOutputEntry = Object.entries(task.outputs || {}).find(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
getFileExtension(entry[0]) === "kcl" &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
if (!kclOutputEntry) {
return null;
}
return decodeBase64Text(kclOutputEntry[1]);
}
function mapKittyCadStatus(status: unknown) {
const normalized = String(status || "")
.trim()
.toLowerCase();
if (!normalized) {
return "queued" as const;
}
if (
normalized === "queued" ||
normalized === "pending" ||
normalized === "created" ||
normalized === "submitted"
) {
return "queued" as const;
}
if (
normalized === "running" ||
normalized === "processing" ||
normalized === "in_progress" ||
normalized === "working" ||
normalized === "generating"
) {
return "running" as const;
}
if (
normalized === "completed" ||
normalized === "success" ||
normalized === "succeeded" ||
normalized === "done" ||
normalized === "finished"
) {
return "success" as const;
}
if (normalized === "cancelled" || normalized === "canceled") {
return "cancelled" as const;
}
if (normalized === "expired" || normalized === "timed_out") {
return "expired" as const;
}
if (normalized === "banned" || normalized === "blocked") {
return "banned" as const;
}
if (
normalized === "failed" ||
normalized === "error" ||
normalized === "errored"
) {
return "failed" as const;
}
return "unknown" as const;
}
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 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))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
: 0;
if (
pointsConfig.enabled &&
unitCost > 0 &&
pointsConfig.blockOnInsufficient !== false
) {
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
const currentBalance = Math.max(
0,
Number(userData?.pointsBalance || 0)
);
if (currentBalance < unitCost) {
return NextResponse.json(
{
success: false,
error: "积分不足",
code: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
balance: currentBalance,
expectedCost: unitCost,
unitCost,
},
{ status: 402 }
);
}
}
}
const outputFormat =
String(body.outputFormat || "glb")
.trim()
.toLowerCase() || "glb";
const normalizedProjectName = String(body.projectName || "").trim();
const iterationSourceTaskId = String(
body.iterationSourceTaskId || ""
).trim();
const config = await getKittyCadProviderConfig();
const generationMode = iterationSourceTaskId ? "iterate" : "create";
const result = iterationSourceTaskId
? await (async () => {
const sourceTask = await kittyCadRequest<KittyCadSourceTaskResponse>(
config,
{
method: "GET",
path: `/user/text-to-cad/${encodeURIComponent(iterationSourceTaskId)}`,
}
);
const originalSourceCode = extractSourceCodeFromTask(sourceTask);
if (!originalSourceCode) {
throw new KittyCadApiError({
message:
"当前参考版本缺少可继续编辑的 KCL 源码,请先等待该版本完成,或重新选择一个 KittyCAD 版本。",
httpStatus: 409,
});
}
return kittyCadRequest<KittyCadIterationResponse>(config, {
method: "POST",
path: "/ml/text-to-cad/iteration",
json: {
original_source_code: originalSourceCode,
prompt,
source_ranges: [],
...(normalizedProjectName
? { project_name: normalizedProjectName }
: {}),
},
});
})()
: await kittyCadRequest<KittyCadCreateResponse>(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) {
return NextResponse.json(
{
success: false,
error: "KittyCAD 返回缺少任务 ID",
raw: result,
},
{ status: 502 }
);
}
if (pointsConfig.enabled && unitCost > 0) {
try {
await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "model3d_generation",
modelId: "kittycad-cad",
note:
generationMode === "iterate"
? "AI-CAD 连续迭代消耗积分"
: "AI-CAD 生成消耗积分",
});
} catch (error) {
console.error("[Points] Failed to consume AI-CAD points:", error);
}
}
return NextResponse.json({
success: true,
data: {
taskId,
status: mapKittyCadStatus(result.status),
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat:
generationMode === "iterate"
? result.output_format || null
: result.output_format || outputFormat,
generationMode,
sourceTaskId: iterationSourceTaskId || null,
raw: result,
},
});
} catch (error) {
if (error instanceof KittyCadApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建 AI-CAD 任务失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,187 @@
import type { AiCadWorkspace } from "@/lib/db/schema";
type CadGenerationProvider = "kittycad-cad" | "tripo-3d";
const FINAL_TASK_STATUS = new Set([
"success",
"failed",
"banned",
"expired",
"cancelled",
"unknown",
]);
const DEFAULT_VIEWER_SETTINGS = {
envPreset: "studio",
lightIntensity: 1.1,
autoRotate: true,
showGrid: true,
wireframe: false,
} as const;
type WorkspaceVersionLike = {
status?: unknown;
prompt?: unknown;
};
export type UpdateAiCadWorkspaceRequest = {
name?: string;
prompt?: string;
generationProvider?: string;
modelVersion?: string;
viewerSettings?: unknown;
messages?: unknown;
versions?: unknown;
activeVersionId?: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getWorkspaceVersions(value: unknown): WorkspaceVersionLike[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is WorkspaceVersionLike => isRecord(item));
}
export function normalizeViewerSettings(
value: unknown
): Record<string, unknown> {
if (!isRecord(value)) {
return { ...DEFAULT_VIEWER_SETTINGS };
}
return {
envPreset:
typeof value.envPreset === "string" && value.envPreset.trim()
? value.envPreset
: DEFAULT_VIEWER_SETTINGS.envPreset,
lightIntensity:
typeof value.lightIntensity === "number" &&
Number.isFinite(value.lightIntensity)
? value.lightIntensity
: DEFAULT_VIEWER_SETTINGS.lightIntensity,
autoRotate:
typeof value.autoRotate === "boolean"
? value.autoRotate
: DEFAULT_VIEWER_SETTINGS.autoRotate,
showGrid:
typeof value.showGrid === "boolean"
? value.showGrid
: DEFAULT_VIEWER_SETTINGS.showGrid,
wireframe:
typeof value.wireframe === "boolean"
? value.wireframe
: DEFAULT_VIEWER_SETTINGS.wireframe,
};
}
export function normalizeJsonRecordArray(
value: unknown
): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is Record<string, unknown> =>
isRecord(item)
);
}
export function normalizeWorkspaceName(
value: unknown,
fallbackName = "AI-CAD 工作台"
) {
return typeof value === "string" && value.trim()
? value.trim()
: fallbackName;
}
export function normalizeWorkspacePayload(body: UpdateAiCadWorkspaceRequest): {
name?: string;
prompt: string;
generationProvider: CadGenerationProvider;
modelVersion: string;
viewerSettings: Record<string, unknown>;
messages: Record<string, unknown>[];
versions: Record<string, unknown>[];
activeVersionId: string | null;
} {
return {
...(typeof body.name === "string" && body.name.trim()
? { name: body.name.trim() }
: {}),
prompt: String(body.prompt || ""),
generationProvider:
body.generationProvider === "tripo-3d" ? "tripo-3d" : "kittycad-cad",
modelVersion:
typeof body.modelVersion === "string" && body.modelVersion.trim()
? body.modelVersion
: "Turbo-v1.0-20250506",
viewerSettings: normalizeViewerSettings(body.viewerSettings),
messages: normalizeJsonRecordArray(body.messages),
versions: normalizeJsonRecordArray(body.versions),
activeVersionId:
typeof body.activeVersionId === "string" && body.activeVersionId.trim()
? body.activeVersionId
: null,
};
}
export function buildDefaultWorkspaceName(index: number) {
return index > 1 ? `AI-CAD 工作台 ${index}` : "AI-CAD 工作台";
}
export function createWorkspaceSummary(workspace: AiCadWorkspace) {
const versions = getWorkspaceVersions(workspace.versions);
const versionCount = versions.length;
const completedCount = versions.filter(
(version) => version.status === "success"
).length;
const hasPendingTasks = versions.some(
(version) =>
typeof version.status !== "string" ||
!FINAL_TASK_STATUS.has(version.status)
);
const latestPrompt =
typeof workspace.prompt === "string" && workspace.prompt.trim()
? workspace.prompt.trim()
: versions.find(
(version) =>
typeof version.prompt === "string" && version.prompt.trim()
)?.prompt || null;
return {
id: workspace.id,
name: workspace.name,
generationProvider: workspace.generationProvider,
versionCount,
completedCount,
hasPendingTasks,
latestPrompt:
typeof latestPrompt === "string" && latestPrompt.trim()
? latestPrompt.trim()
: null,
createdAt: workspace.createdAt?.toISOString() || null,
updatedAt: workspace.updatedAt?.toISOString() || null,
};
}
export function createWorkspaceDocument(workspace: AiCadWorkspace) {
return {
id: workspace.id,
name: workspace.name,
prompt: workspace.prompt,
generationProvider: workspace.generationProvider,
modelVersion: workspace.modelVersion,
viewerSettings: workspace.viewerSettings,
messages: workspace.messages,
versions: workspace.versions,
activeVersionId: workspace.activeVersionId,
createdAt: workspace.createdAt?.toISOString() || null,
updatedAt: workspace.updatedAt?.toISOString() || null,
};
}

View File

@ -0,0 +1,89 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createWorkspaceDocument,
normalizeWorkspacePayload,
type UpdateAiCadWorkspaceRequest,
} from "@/app/api/ai-cad/workspace-utils";
import {
createAiCadWorkspace,
listAiCadWorkspacesByUserId,
updateAiCadWorkspace,
} from "@/lib/db/queries";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const workspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const workspace = workspaces[0] ?? null;
if (!workspace) {
return NextResponse.json({
success: true,
data: null,
});
}
return NextResponse.json({
success: true,
data: createWorkspaceDocument(workspace),
});
}
export async function PUT(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
const existingWorkspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const targetWorkspace =
existingWorkspaces[0] ??
(await createAiCadWorkspace({
userId: session.user.id,
name: "AI-CAD 工作台",
}));
if (!targetWorkspace) {
return NextResponse.json(
{ success: false, error: "初始化 AI-CAD 工作台失败" },
{ status: 500 }
);
}
const savedWorkspace = await updateAiCadWorkspace({
workspaceId: targetWorkspace.id,
userId: session.user.id,
...normalizeWorkspacePayload(body),
});
if (!savedWorkspace) {
return NextResponse.json(
{ success: false, error: "保存 AI-CAD 工作台失败" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
data: {
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
},
});
}

View File

@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createWorkspaceDocument,
createWorkspaceSummary,
normalizeWorkspacePayload,
type UpdateAiCadWorkspaceRequest,
} from "@/app/api/ai-cad/workspace-utils";
import {
deleteAiCadWorkspaceById,
getAiCadWorkspaceById,
updateAiCadWorkspace,
} from "@/lib/db/queries";
export const runtime = "nodejs";
type RouteContext = {
params: Promise<{
workspaceId: string;
}>;
};
export async function GET(_request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const workspace = await getAiCadWorkspaceById({
userId: session.user.id,
workspaceId,
});
if (!workspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: createWorkspaceDocument(workspace),
});
}
export async function PUT(request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const body = (await request.json()) as UpdateAiCadWorkspaceRequest;
const savedWorkspace = await updateAiCadWorkspace({
workspaceId,
userId: session.user.id,
...normalizeWorkspacePayload(body),
});
if (!savedWorkspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在或保存失败" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
id: savedWorkspace.id,
name: savedWorkspace.name,
updatedAt: savedWorkspace.updatedAt?.toISOString() || null,
summary: createWorkspaceSummary(savedWorkspace),
},
});
}
export async function DELETE(_request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const { workspaceId } = await context.params;
const deletedWorkspace = await deleteAiCadWorkspaceById({
workspaceId,
userId: session.user.id,
});
if (!deletedWorkspace) {
return NextResponse.json(
{ success: false, error: "AI-CAD 工作台不存在" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: {
id: deletedWorkspace.id,
},
});
}

View File

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
buildDefaultWorkspaceName,
createWorkspaceSummary,
normalizeWorkspaceName,
} from "@/app/api/ai-cad/workspace-utils";
import {
createAiCadWorkspace,
listAiCadWorkspacesByUserId,
} from "@/lib/db/queries";
export const runtime = "nodejs";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const workspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
return NextResponse.json({
success: true,
data: workspaces.map(createWorkspaceSummary),
});
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
const existingWorkspaces = await listAiCadWorkspacesByUserId({
userId: session.user.id,
});
const workspace = await createAiCadWorkspace({
userId: session.user.id,
name: normalizeWorkspaceName(
body.name,
buildDefaultWorkspaceName(existingWorkspaces.length + 1)
),
});
if (!workspace) {
return NextResponse.json(
{ success: false, error: "创建 AI-CAD 工作台失败" },
{ status: 500 }
);
}
return NextResponse.json(
{
success: true,
data: createWorkspaceSummary(workspace),
},
{ status: 201 }
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,39 @@
"use client";
import {
Award,
BarChart3,
Box,
Briefcase,
Building2,
Clapperboard,
Crown,
Edit,
ExternalLink,
Images,
LayoutGrid,
Medal,
PlayCircle,
Settings,
Sparkles,
Trophy,
Wand2,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X, BarChart3, Clapperboard } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { ImageAnnotator } from "@/components/image-annotator";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ImageAnnotator } from "@/components/image-annotator";
import { VideoPlayer } from "@/components/video-player";
import { toast } from "sonner";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
type RankingItem = {
@ -80,38 +98,35 @@ export function TopNavbar({
}: TopNavbarProps) {
const router = useRouter();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [ranking, setRanking] = useState<RankingItem[]>([]);
const [rankingOpen, setRankingOpen] = useState(false);
const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false);
const [showTutorialModal, setShowTutorialModal] = useState(false);
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(null);
const [currentTutorialVideo, setCurrentTutorialVideo] =
useState<TutorialVideo | null>(null);
const [showImageEditor, setShowImageEditor] = useState(false);
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
// 移除 fileInputRef,不再需要外部上传
useEffect(() => {
setMounted(true);
}, []);
// 获取精选榜单数据
const fetchRanking = async () => {
try {
const res = await fetch("/api/featured/ranking");
const data = await res.json();
if (data.ranking) {
setRanking(data.ranking);
}
} catch (error) {
console.error("Failed to fetch ranking:", error);
}
};
// 当下拉菜单打开时获取数据
useEffect(() => {
if (rankingOpen) {
fetchRanking();
if (!rankingOpen) {
return;
}
const loadRanking = async () => {
try {
const res = await fetch("/api/featured/ranking");
const data = await res.json();
if (data.ranking) {
setRanking(data.ranking);
}
} catch (error) {
console.error("Failed to fetch ranking:", error);
}
};
loadRanking();
}, [rankingOpen]);
// 获取排名图标
@ -124,36 +139,75 @@ export function TopNavbar({
case 3:
return <Award className="h-4 w-4 text-amber-600" />;
default:
return <span className="w-4 h-4 text-center text-xs font-bold text-gray-500">{rank}</span>;
return (
<span className="h-4 w-4 text-center font-bold text-gray-500 text-xs">
{rank}
</span>
);
}
};
// 决定显示哪个 LOGO(浅色用普通,深色用暗色)
const displayLogo = resolvedTheme === "dark"
? (siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo)
: (siteLogo && siteLogo.trim() !== "" ? siteLogo : siteLogoDark);
const displayLogo =
resolvedTheme === "dark"
? siteLogoDark && siteLogoDark.trim() !== ""
? siteLogoDark
: siteLogo
: siteLogo && siteLogo.trim() !== ""
? siteLogo
: siteLogoDark;
const getShortLabel = (label: string) => {
const trimmed = label.trim();
if (!trimmed) return "";
if (trimmed === "AI精选") return "精选";
if (trimmed === "功能教程") return "教程";
if (trimmed === "精选榜") return "榜单";
if (trimmed === "图片编辑") return "编辑";
if (trimmed === "智能体") return "智能";
if (trimmed === "视频画布") return "视频";
if (trimmed === "无限画布") return "画布";
if (trimmed === "我的图册") return "图册";
if (trimmed === "商品策划") return "策划";
if (trimmed === "系统设置") return "设置";
if (trimmed === "工具箱") return "工具";
if (trimmed === "工具箱2") return "工具";
if (!trimmed) {
return "";
}
if (trimmed === "AI精选") {
return "精选";
}
if (trimmed === "功能教程") {
return "教程";
}
if (trimmed === "精选榜") {
return "榜单";
}
if (trimmed === "图片编辑") {
return "编辑";
}
if (trimmed === "智能体") {
return "智能";
}
if (trimmed === "视频画布") {
return "视频";
}
if (trimmed === "无限画布") {
return "画布";
}
if (trimmed === "我的图册") {
return "图册";
}
if (trimmed === "商品策划") {
return "策划";
}
if (trimmed === "AI-CAD") {
return "CAD";
}
if (trimmed === "系统设置") {
return "设置";
}
if (trimmed === "工具箱") {
return "工具";
}
if (trimmed === "工具箱2") {
return "工具";
}
return Array.from(trimmed).slice(0, 2).join("");
};
const navItemBaseClass =
"flex h-12 w-12 flex-col items-center justify-center gap-1 rounded-xl text-gray-800 hover:bg-black/5 transition-colors dark:text-white dark:hover:bg-white/10";
const navLabelClass = "text-[10px] leading-none font-medium text-gray-700 dark:text-white/90";
const navLabelClass =
"text-[10px] leading-none font-medium text-gray-700 dark:text-white/90";
// 打开图片编辑器 - 直接打开,不触发上传
const handleOpenImageEditor = () => {
@ -186,7 +240,7 @@ export function TopNavbar({
return;
}
}
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
try {
sessionStorage.setItem("pending-edited-image", finalUrl);
@ -210,11 +264,11 @@ export function TopNavbar({
};
const navContent = (
<nav className="fixed left-0 top-0 bottom-0 z-[100] flex w-16 flex-col items-center bg-white/90 backdrop-blur-sm text-gray-900 border-r border-gray-200 shadow-sm dark:bg-black/90 dark:text-white dark:border-white/10">
<nav className="fixed top-0 bottom-0 left-0 z-[100] flex w-16 flex-col items-center border-gray-200 border-r bg-white/90 text-gray-900 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-black/90 dark:text-white">
{/* 顶部:LOGO + 网站名称 */}
<a
href="/"
className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80"
href="/"
onClick={(e) => {
e.preventDefault();
window.location.href = "/";
@ -222,101 +276,129 @@ export function TopNavbar({
>
{displayLogo && (
<img
src={displayLogo}
alt={siteName}
className="h-7 w-7 object-contain"
src={displayLogo}
/>
)}
<span className="max-w-[4.5rem] text-center text-[10px] leading-tight line-clamp-2">{siteName}</span>
<span className="line-clamp-2 max-w-[4.5rem] text-center text-[10px] leading-tight">
{siteName}
</span>
</a>
{/* 侧边功能区 */}
<div className="mt-12 flex w-full flex-1 flex-col items-center gap-3 overflow-y-auto px-2 pb-4">
{/* 工具箱1 (如果启用且有链接) */}
{toolbox1Enabled && customLinks.filter(l => (l.toolboxGroup || 1) === 1).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`${navItemBaseClass} p-0`}
title={toolbox1Name}
type="button"
{toolbox1Enabled &&
customLinks.filter((l) => (l.toolboxGroup || 1) === 1).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={`${navItemBaseClass} p-0`}
size="sm"
title={toolbox1Name}
type="button"
variant="ghost"
>
<Briefcase className="h-5 w-5" />
<span className={navLabelClass}>
{getShortLabel(toolbox1Name)}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-[320px] p-2"
side="right"
>
<Briefcase className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel(toolbox1Name)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
<div className="grid grid-cols-2 gap-2">
{customLinks
.filter(l => (l.toolboxGroup || 1) === 1)
.sort((a, b) => a.order - b.order)
.map((link) => (
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
<div className="grid grid-cols-2 gap-2">
{customLinks
.filter((l) => (l.toolboxGroup || 1) === 1)
.sort((a, b) => a.order - b.order)
.map((link) => (
<DropdownMenuItem
asChild
className="cursor-pointer"
key={link.id}
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<a
className="flex h-20 flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 text-center transition-colors hover:bg-accent hover:text-accent-foreground"
href={link.url}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="line-clamp-2 font-medium text-xs">
{link.name}
</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 工具箱2 (如果启用且有链接) */}
{toolbox2Enabled && customLinks.filter(l => l.toolboxGroup === 2).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`${navItemBaseClass} p-0`}
title={toolbox2Name}
type="button"
{toolbox2Enabled &&
customLinks.filter((l) => l.toolboxGroup === 2).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={`${navItemBaseClass} p-0`}
size="sm"
title={toolbox2Name}
type="button"
variant="ghost"
>
<Briefcase className="h-5 w-5" />
<span className={navLabelClass}>
{getShortLabel(toolbox2Name)}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-[320px] p-2"
side="right"
>
<Briefcase className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel(toolbox2Name)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
<div className="grid grid-cols-2 gap-2">
{customLinks
.filter(l => l.toolboxGroup === 2)
.sort((a, b) => a.order - b.order)
.map((link) => (
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
<div className="grid grid-cols-2 gap-2">
{customLinks
.filter((l) => l.toolboxGroup === 2)
.sort((a, b) => a.order - b.order)
.map((link) => (
<DropdownMenuItem
asChild
className="cursor-pointer"
key={link.id}
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<a
className="flex h-20 flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 text-center transition-colors hover:bg-accent hover:text-accent-foreground"
href={link.url}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="line-clamp-2 font-medium text-xs">
{link.name}
</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* AI智能精选 */}
<a
href="/featured"
className={navItemBaseClass}
title="AI精选"
href="/featured"
onClick={(e) => {
e.preventDefault();
window.location.href = "/featured";
}}
title="AI精选"
>
<Sparkles className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("AI精选")}</span>
@ -324,68 +406,94 @@ export function TopNavbar({
{productPlanningEnabled && (
<a
href="/product-planning"
className={navItemBaseClass}
title="商品策划"
href="/product-planning"
onClick={(e) => {
e.preventDefault();
window.location.href = "/product-planning";
}}
title="商品策划"
>
<BarChart3 className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("商品策划")}</span>
</a>
)}
<a
className={navItemBaseClass}
href="/ai-cad"
onClick={(e) => {
e.preventDefault();
window.location.href = "/ai-cad";
}}
title="AI-CAD"
>
<Box className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("AI-CAD")}</span>
</a>
{/* 功能教程 */}
{tutorialEnabled && tutorialVideos.length > 0 && (
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>
<DropdownMenu
onOpenChange={setTutorialMenuOpen}
open={tutorialMenuOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`${navItemBaseClass} p-0`}
type="button"
size="sm"
title="功能教程"
type="button"
variant="ghost"
>
<PlayCircle className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("功能教程")}</span>
<span className={navLabelClass}>
{getShortLabel("功能教程")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="center" className="w-[320px] p-2">
<div className="space-y-1 max-h-[400px] overflow-y-auto">
<DropdownMenuContent
align="center"
className="w-[320px] p-2"
side="right"
>
<div className="max-h-[400px] space-y-1 overflow-y-auto">
{tutorialVideos
.sort((a, b) => a.order - b.order)
.map((video) => (
<DropdownMenuItem
key={video.id}
className="cursor-pointer p-0"
key={video.id}
onClick={() => {
setCurrentTutorialVideo(video);
setShowTutorialModal(true);
setTutorialMenuOpen(false);
}}
>
<div className="flex items-center gap-3 p-2 w-full hover:bg-accent rounded-md">
<div className="flex w-full items-center gap-3 rounded-md p-2 hover:bg-accent">
{/* 封面 */}
<div className="w-16 h-10 bg-muted rounded overflow-hidden flex-shrink-0">
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
{video.coverUrl ? (
<img
src={video.coverUrl}
alt={video.title}
className="w-full h-full object-cover"
<img
alt={video.title}
className="h-full w-full object-cover"
src={video.coverUrl}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-green-500/20 to-blue-500/20">
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-green-500/20 to-blue-500/20">
<PlayCircle className="h-5 w-5 text-green-500" />
</div>
)}
</div>
{/* 标题和描述 */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{video.title}</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">
{video.title}
</div>
{video.description && (
<div className="text-xs text-muted-foreground truncate">{video.description}</div>
<div className="truncate text-muted-foreground text-xs">
{video.description}
</div>
)}
</div>
</div>
@ -398,53 +506,66 @@ export function TopNavbar({
{/* 精选榜单 */}
{rankingEnabled && (
<DropdownMenu open={rankingOpen} onOpenChange={setRankingOpen}>
<DropdownMenu onOpenChange={setRankingOpen} open={rankingOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`${navItemBaseClass} p-0`}
type="button"
size="sm"
title="精选榜单"
type="button"
variant="ghost"
>
<Trophy className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("精选榜")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="center" className="w-[280px] p-3">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<DropdownMenuContent
align="center"
className="w-[280px] p-3"
side="right"
>
<div className="mb-3 flex items-center gap-2 border-b pb-2">
<Trophy className="h-5 w-5 text-orange-500" />
<span className="font-semibold">精选榜单 TOP5</span>
</div>
{ranking.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-sm">
<div className="py-4 text-center text-muted-foreground text-sm">
暂无精选数据
</div>
) : (
<div className="space-y-2">
{ranking.map((item) => (
<div
key={item.userId}
className={`flex items-center gap-3 p-2 rounded-lg transition-colors ${
item.rank === 1 ? "bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20" :
item.rank === 2 ? "bg-gray-50 dark:bg-gray-800/50" :
item.rank === 3 ? "bg-amber-50 dark:bg-amber-900/20" :
"hover:bg-muted"
className={`flex items-center gap-3 rounded-lg p-2 transition-colors ${
item.rank === 1
? "bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20"
: item.rank === 2
? "bg-gray-50 dark:bg-gray-800/50"
: item.rank === 3
? "bg-amber-50 dark:bg-amber-900/20"
: "hover:bg-muted"
}`}
key={item.userId}
>
<div className="flex items-center justify-center w-6">
<div className="flex w-6 items-center justify-center">
{getRankIcon(item.rank)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.userName}</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 truncate">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">
{item.userName}
</p>
<p className="flex items-center gap-1 truncate text-muted-foreground text-xs">
<Building2 className="h-3 w-3" />
{item.department}
</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-orange-500">{item.featuredCount}</span>
<span className="text-xs text-muted-foreground ml-1">精选</span>
<span className="font-bold text-lg text-orange-500">
{item.featuredCount}
</span>
<span className="ml-1 text-muted-foreground text-xs">
精选
</span>
</div>
</div>
))}
@ -456,12 +577,12 @@ export function TopNavbar({
{/* 图片编辑 */}
<Button
variant="ghost"
size="sm"
className={`${navItemBaseClass} p-0`}
onClick={handleOpenImageEditor}
type="button"
size="sm"
title="图片编辑"
type="button"
variant="ghost"
>
<Edit className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("图片编辑")}</span>
@ -474,8 +595,8 @@ export function TopNavbar({
onClick={() => {
window.location.href = "/canvas?mode=agent";
}}
type="button"
title="智能体"
type="button"
>
<Wand2 className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("智能体")}</span>
@ -487,8 +608,8 @@ export function TopNavbar({
onClick={() => {
window.location.href = "/video-canvas";
}}
type="button"
title="视频画布"
type="button"
>
<Clapperboard className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
@ -501,8 +622,8 @@ export function TopNavbar({
const newId = crypto.randomUUID();
window.location.href = `/canvas/${newId}`;
}}
type="button"
title="无限画布"
type="button"
>
<LayoutGrid className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("无限画布")}</span>
@ -510,13 +631,13 @@ export function TopNavbar({
{/* 我的图册 */}
<a
href="/gallery"
className={navItemBaseClass}
title="我的图册"
href="/gallery"
onClick={(e) => {
e.preventDefault();
window.location.href = "/gallery";
}}
title="我的图册"
>
<Images className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("我的图册")}</span>
@ -527,13 +648,13 @@ export function TopNavbar({
{(isAdmin || settingsHref) && (
<div className="pb-4">
<a
href={settingsHref || "/settings"}
className={navItemBaseClass}
title="系统设置"
href={settingsHref || "/settings"}
onClick={(e) => {
e.preventDefault();
window.location.href = settingsHref || "/settings";
}}
title="系统设置"
>
<Settings className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("系统设置")}</span>
@ -545,94 +666,102 @@ export function TopNavbar({
{showImageEditor && (
<ImageAnnotator
imageUrl={editorImageUrl}
onCancel={handleImageEditorCancel}
onConfirm={handleImageEditorConfirm}
submitMode="publish-new-canvas"
onCancel={handleImageEditorCancel}
/>
)}
</nav>
);
// 使用 Portal 将视频弹窗渲染到 body,确保它在最顶层且不受 nav 的限制
const videoModal = showTutorialModal && currentTutorialVideo && typeof document !== 'undefined' ? createPortal(
<div
className="fixed inset-0 z-[99999] bg-black/95"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: 0,
padding: '20px',
}}
onClick={() => {
setShowTutorialModal(false);
setCurrentTutorialVideo(null);
}}
>
<div
className="relative w-full max-w-[1200px]"
style={{ maxHeight: 'calc(100vh - 80px)' }}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 - 包含标题和关闭按钮 */}
<div className="flex items-center justify-between mb-3 px-2">
<div className="w-8" /> {/* 占位符保持标题居中 */}
<h3 className="text-white text-lg font-medium truncate flex-1 text-center">{currentTutorialVideo.title}</h3>
<button
const videoModal =
showTutorialModal && currentTutorialVideo && typeof document !== "undefined"
? createPortal(
<div
className="fixed inset-0 z-[99999] bg-black/95"
onClick={() => {
setShowTutorialModal(false);
setCurrentTutorialVideo(null);
}}
className="text-white hover:text-red-400 transition-colors z-10 p-1"
title="关闭 (ESC)"
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: 0,
padding: "20px",
}}
>
<X className="h-8 w-8" />
</button>
</div>
{/* 视频播放器 */}
{currentTutorialVideo.videoUrl.includes('youtube.com') || currentTutorialVideo.videoUrl.includes('youtu.be') ? (
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
<iframe
src={`${currentTutorialVideo.videoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}?autoplay=1&rel=0&modestbranding=1`}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
loading="eager"
/>
</div>
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
<iframe
src={`${currentTutorialVideo.videoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}&autoplay=1&high_quality=1`}
className="w-full h-full"
allowFullScreen
loading="eager"
/>
</div>
) : (
<VideoPlayer
src={currentTutorialVideo.videoUrl}
poster={currentTutorialVideo.coverUrl || undefined}
title={currentTutorialVideo.title}
autoPlay={true}
className="shadow-2xl"
/>
)}
<div
className="relative w-full max-w-[1200px]"
onClick={(e) => e.stopPropagation()}
style={{ maxHeight: "calc(100vh - 80px)" }}
>
{/* 标题栏 - 包含标题和关闭按钮 */}
<div className="mb-3 flex items-center justify-between px-2">
<div className="w-8" /> {/* 占位符保持标题居中 */}
<h3 className="flex-1 truncate text-center font-medium text-lg text-white">
{currentTutorialVideo.title}
</h3>
<button
className="z-10 p-1 text-white transition-colors hover:text-red-400"
onClick={() => {
setShowTutorialModal(false);
setCurrentTutorialVideo(null);
}}
title="关闭 (ESC)"
>
<X className="h-8 w-8" />
</button>
</div>
{/* 视频描述 */}
{currentTutorialVideo.description && (
<p className="text-gray-300 text-sm mt-3 text-center">{currentTutorialVideo.description}</p>
)}
</div>
</div>,
document.body
) : null;
{/* 视频播放器 */}
{currentTutorialVideo.videoUrl.includes("youtube.com") ||
currentTutorialVideo.videoUrl.includes("youtu.be") ? (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black shadow-2xl">
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="h-full w-full"
loading="eager"
src={`${currentTutorialVideo.videoUrl.replace("watch?v=", "embed/").replace("youtu.be/", "youtube.com/embed/")}?autoplay=1&rel=0&modestbranding=1`}
/>
</div>
) : currentTutorialVideo.videoUrl.includes("bilibili.com") ? (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black shadow-2xl">
<iframe
allowFullScreen
className="h-full w-full"
loading="eager"
src={`${currentTutorialVideo.videoUrl.replace("www.bilibili.com/video/", "player.bilibili.com/player.html?bvid=")}&autoplay=1&high_quality=1`}
/>
</div>
) : (
<VideoPlayer
autoPlay={true}
className="shadow-2xl"
poster={currentTutorialVideo.coverUrl || undefined}
src={currentTutorialVideo.videoUrl}
title={currentTutorialVideo.title}
/>
)}
{/* 视频描述 */}
{currentTutorialVideo.description && (
<p className="mt-3 text-center text-gray-300 text-sm">
{currentTutorialVideo.description}
</p>
)}
</div>
</div>,
document.body
)
: null;
return (
<>

View File

@ -19,6 +19,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai",
"kittycad-cad": "https://api.zoo.dev",
runway: "https://api.dev.runwayml.com",
};

View File

@ -34,6 +34,7 @@ const providerBaseUrlDefaults = [
{ type: "fal-video", baseUrl: "https://fal.run" },
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
{ type: "worldlabs-3d", baseUrl: "https://api.worldlabs.ai" },
{ type: "kittycad-cad", baseUrl: "https://api.zoo.dev" },
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
] as const;
@ -94,7 +95,7 @@ const runMigrate = async () => {
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'runway'
'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway'
));
`);
await db.execute(sql`

View File

@ -0,0 +1,32 @@
ALTER TABLE "AiProvider"
DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
ALTER TABLE "AiProvider"
ADD CONSTRAINT "AiProvider_type_check"
CHECK (
"type" IN (
'alibaba',
'openai',
'custom',
'local',
'gemini',
'huanke-gemini',
'qianwen-video',
'doubao-video',
'ruxa-video',
'volcano-vision',
'volcano-video',
'alibaba-image',
'bfl-image',
'ruxa-image',
'fal-image',
'openai-image',
'midjourney',
'fal-video',
'zhipu',
'tripo-3d',
'worldlabs-3d',
'kittycad-cad',
'runway'
)
);

View File

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS "AiCadWorkspace" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL,
"prompt" text DEFAULT '' NOT NULL,
"generationProvider" varchar(32) DEFAULT 'kittycad-cad' NOT NULL,
"modelVersion" varchar(128) DEFAULT 'Turbo-v1.0-20250506' NOT NULL,
"viewerSettings" jsonb DEFAULT '{"envPreset":"studio","lightIntensity":1.1,"autoRotate":true,"showGrid":true,"wireframe":false}'::jsonb NOT NULL,
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
"versions" jsonb DEFAULT '[]'::jsonb NOT NULL,
"activeVersionId" varchar(64),
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "AiCadWorkspace_userId_unique" UNIQUE("userId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AiCadWorkspace" ADD CONSTRAINT "AiCadWorkspace_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AiCadWorkspace_user_updated_idx"
ON "AiCadWorkspace"("userId", "updatedAt");

View File

@ -0,0 +1,5 @@
ALTER TABLE "AiCadWorkspace"
DROP CONSTRAINT IF EXISTS "AiCadWorkspace_userId_unique";
ALTER TABLE "AiCadWorkspace"
ADD COLUMN IF NOT EXISTS "name" varchar(128) NOT NULL DEFAULT 'AI-CAD 工作台';

View File

@ -358,6 +358,27 @@
"when": 1775342400000,
"tag": "0064_add_model3d_asset_share",
"breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1775376000000,
"tag": "0065_add_kittycad_provider_type",
"breakpoints": true
},
{
"idx": 52,
"version": "7",
"when": 1775419200000,
"tag": "0066_add_ai_cad_workspace",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1775462400000,
"tag": "0067_add_ai_cad_multi_workspace",
"breakpoints": true
}
]
}

View File

@ -44,6 +44,8 @@ import {
type User,
user,
vote,
aiCadWorkspace,
type AiCadWorkspace,
aiProvider,
type AiProvider,
aiModel,
@ -742,6 +744,156 @@ export async function getConversationWorkingStateByChatId({
}
}
export async function listAiCadWorkspacesByUserId({
userId,
}: {
userId: string;
}): Promise<AiCadWorkspace[]> {
try {
return await db
.select()
.from(aiCadWorkspace)
.where(eq(aiCadWorkspace.userId, userId))
.orderBy(desc(aiCadWorkspace.updatedAt));
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to list AI-CAD workspaces"
);
}
}
export async function getAiCadWorkspaceById(params: {
userId: string;
workspaceId: string;
}): Promise<AiCadWorkspace | null> {
try {
const [result] = await db
.select()
.from(aiCadWorkspace)
.where(
and(
eq(aiCadWorkspace.userId, params.userId),
eq(aiCadWorkspace.id, params.workspaceId)
)
)
.limit(1);
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to get AI-CAD workspace by id"
);
}
}
export async function createAiCadWorkspace(params: {
userId: string;
name: string;
}): Promise<AiCadWorkspace | null> {
try {
const now = new Date();
const [result] = await db
.insert(aiCadWorkspace)
.values({
userId: params.userId,
name: params.name,
prompt: "",
generationProvider: "kittycad-cad",
modelVersion: "Turbo-v1.0-20250506",
viewerSettings: {
envPreset: "studio",
lightIntensity: 1.1,
autoRotate: true,
showGrid: true,
wireframe: false,
},
messages: [],
versions: [],
activeVersionId: null,
createdAt: now,
updatedAt: now,
})
.returning();
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to create AI-CAD workspace"
);
}
}
export async function updateAiCadWorkspace(params: {
workspaceId: string;
userId: string;
name?: string;
prompt: string;
generationProvider: "kittycad-cad" | "tripo-3d";
modelVersion: string;
viewerSettings: Record<string, unknown>;
messages: Record<string, unknown>[];
versions: Record<string, unknown>[];
activeVersionId?: string | null;
}): Promise<AiCadWorkspace | null> {
try {
const now = new Date();
const [result] = await db
.update(aiCadWorkspace)
.set({
...(params.name ? { name: params.name } : {}),
prompt: params.prompt,
generationProvider: params.generationProvider,
modelVersion: params.modelVersion,
viewerSettings: params.viewerSettings,
messages: params.messages,
versions: params.versions,
activeVersionId: params.activeVersionId ?? null,
updatedAt: now,
})
.where(
and(
eq(aiCadWorkspace.id, params.workspaceId),
eq(aiCadWorkspace.userId, params.userId)
)
)
.returning();
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to update AI-CAD workspace"
);
}
}
export async function deleteAiCadWorkspaceById(params: {
workspaceId: string;
userId: string;
}): Promise<AiCadWorkspace | null> {
try {
const [result] = await db
.delete(aiCadWorkspace)
.where(
and(
eq(aiCadWorkspace.id, params.workspaceId),
eq(aiCadWorkspace.userId, params.userId)
)
)
.returning();
return result ?? null;
} catch (_error) {
throw new ChatSDKError(
"bad_request:database",
"Failed to delete AI-CAD workspace"
);
}
}
export async function upsertConversationWorkingState(params: {
userId: string;
chatId: string;

View File

@ -264,7 +264,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "runway", "bfl-image"] })
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "bfl-image"] })
.notNull()
.default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL
@ -907,6 +907,32 @@ export const conversationWorkingState = pgTable("ConversationWorkingState", {
export type ConversationWorkingState = InferSelectModel<typeof conversationWorkingState>;
export const aiCadWorkspace = pgTable("AiCadWorkspace", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: varchar("name", { length: 128 }).notNull().default("AI-CAD 工作台"),
prompt: text("prompt").notNull().default(""),
generationProvider: varchar("generationProvider", {
length: 32,
enum: ["kittycad-cad", "tripo-3d"],
})
.notNull()
.default("kittycad-cad"),
modelVersion: varchar("modelVersion", { length: 128 })
.notNull()
.default("Turbo-v1.0-20250506"),
viewerSettings: jsonb("viewerSettings").notNull(),
messages: jsonb("messages").$type<Record<string, unknown>[]>().notNull().default([]),
versions: jsonb("versions").$type<Record<string, unknown>[]>().notNull().default([]),
activeVersionId: varchar("activeVersionId", { length: 64 }),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type AiCadWorkspace = InferSelectModel<typeof aiCadWorkspace>;
// 每轮消息的目标资产/图片定位审计表
export const messageTargetResolution = pgTable("MessageTargetResolution", {
id: uuid("id").primaryKey().notNull().defaultRandom(),

134
lib/kittycad/client.ts Normal file
View File

@ -0,0 +1,134 @@
import "server-only";
import type { KittyCadProviderConfig } from "./server";
type KittyCadRequestOptions = {
method?: "GET" | "POST";
path: string;
json?: Record<string, unknown>;
headers?: Record<string, string>;
};
const DEFAULT_KITTYCAD_BASE_URL = "https://api.zoo.dev";
const TRAILING_SLASH_RE = /\/+$/;
export class KittyCadApiError extends Error {
httpStatus: number;
payload: unknown;
constructor(params: {
message: string;
httpStatus: number;
payload?: unknown;
}) {
super(params.message);
this.name = "KittyCadApiError";
this.httpStatus = params.httpStatus;
this.payload = params.payload;
}
}
function normalizeBaseUrl(baseUrl?: string) {
const resolved = String(baseUrl || "").trim() || DEFAULT_KITTYCAD_BASE_URL;
return resolved.replace(TRAILING_SLASH_RE, "");
}
function sanitizeApiKey(value: string): string {
let result = "";
for (const char of String(value || "")) {
const code = char.charCodeAt(0);
if (code === 0x20_28 || code === 0x20_29) {
continue;
}
if ((code >= 0 && code <= 0x1f) || code === 0x7f) {
continue;
}
result += char;
}
return result.trim();
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return response.json().catch(() => null);
}
const text = await response.text().catch(() => "");
return text || null;
}
function extractErrorMessage(payload: unknown, fallback: string) {
if (!payload) {
return fallback;
}
if (typeof payload === "string") {
return payload || fallback;
}
if (typeof payload !== "object") {
return fallback;
}
const record = payload as Record<string, unknown>;
if (typeof record.error === "string" && record.error.trim()) {
return record.error.trim();
}
if (typeof record.message === "string" && record.message.trim()) {
return record.message.trim();
}
if (typeof record.detail === "string" && record.detail.trim()) {
return record.detail.trim();
}
return fallback;
}
export async function kittyCadRequest<T = unknown>(
config: KittyCadProviderConfig,
options: KittyCadRequestOptions
): Promise<T> {
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
const headers = new Headers(options.headers || {});
const normalizedApiKey = sanitizeApiKey(String(config.apiKey || "")).replace(
/\s+/g,
""
);
if (!normalizedApiKey) {
throw new KittyCadApiError({
message: "KittyCAD API Token 为空,请在本地AI设置中重新保存",
httpStatus: 400,
});
}
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
headers.set("Accept", "application/json");
let body: BodyInit | undefined;
if (options.json) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json);
}
const response = await fetch(url, {
method: options.method || "GET",
headers,
body,
cache: "no-store",
});
const parsed = await parseResponseBody(response);
if (!response.ok) {
throw new KittyCadApiError({
message: extractErrorMessage(
parsed,
`KittyCAD 请求失败(HTTP ${response.status})`
),
httpStatus: response.status,
payload: parsed,
});
}
return parsed as T;
}

94
lib/kittycad/server.ts Normal file
View File

@ -0,0 +1,94 @@
import "server-only";
import { getEnabledAiProviders } from "@/lib/db/queries";
export type KittyCadProviderSource = "provider" | "env";
export type KittyCadProviderConfig = {
apiKey: string;
baseUrl: string;
providerId?: string;
providerName?: string;
source: KittyCadProviderSource;
};
const DEFAULT_KITTYCAD_BASE_URL = "https://api.zoo.dev";
function stripUnsafeText(value: string): string {
let result = "";
for (const char of String(value || "")) {
const code = char.charCodeAt(0);
if (code === 0x20_28 || code === 0x20_29) {
continue;
}
if ((code >= 0 && code <= 0x1f) || code === 0x7f) {
continue;
}
result += char;
}
return result;
}
function sanitizeKittyCadToken(value: string): string {
return stripUnsafeText(value).trim();
}
function sanitizeKittyCadBaseUrl(value: string): string {
return stripUnsafeText(value).trim();
}
export async function findKittyCadProviderConfig(): Promise<KittyCadProviderConfig | null> {
const providers = await getEnabledAiProviders();
const kittyCadProvider = providers.find(
(item) => item.type === "kittycad-cad"
);
const providerApiKey = sanitizeKittyCadToken(
String(kittyCadProvider?.apiKey || "")
);
if (kittyCadProvider && providerApiKey) {
return {
apiKey: providerApiKey,
baseUrl:
sanitizeKittyCadBaseUrl(String(kittyCadProvider.baseUrl || "")) ||
DEFAULT_KITTYCAD_BASE_URL,
providerId: kittyCadProvider.id,
providerName: kittyCadProvider.name,
source: "provider",
};
}
const envApiKey = sanitizeKittyCadToken(
String(
process.env.KITTYCAD_API_TOKEN ||
process.env.KITTYCAD_API_KEY ||
process.env.ZOO_API_TOKEN ||
""
)
);
if (!envApiKey) {
return null;
}
return {
apiKey: envApiKey,
baseUrl:
sanitizeKittyCadBaseUrl(
String(process.env.KITTYCAD_BASE_URL || process.env.ZOO_BASE_URL || "")
) || DEFAULT_KITTYCAD_BASE_URL,
source: "env",
};
}
export async function getKittyCadProviderConfig(): Promise<KittyCadProviderConfig> {
const config = await findKittyCadProviderConfig();
if (!config) {
throw new Error(
"未配置 KittyCAD CAD API,请在系统设置-本地AI新增 KittyCAD CAD 提供商"
);
}
return config;
}

193
services/ai-cad/index.ts Normal file
View File

@ -0,0 +1,193 @@
import type { Model3DTaskStatus } from "@/services/canvas";
type ApiResponse<T = any> = {
success: boolean;
data?: T;
error?: string;
};
export type AiCadProviderStatus = {
configured: boolean;
source: "provider" | "env" | "none";
providerName?: string | null;
baseUrl?: string | null;
};
export type AiCadWorkspaceSummary = {
id: string;
name: string;
generationProvider: "kittycad-cad" | "tripo-3d";
versionCount: number;
completedCount: number;
hasPendingTasks: boolean;
latestPrompt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
};
export type AiCadWorkspaceDocument = {
id: string;
name: string;
prompt: string;
generationProvider: "kittycad-cad" | "tripo-3d";
modelVersion: string;
viewerSettings: Record<string, unknown>;
messages: Record<string, unknown>[];
versions: Record<string, unknown>[];
activeVersionId?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
};
export const aiCadGenerationService = {
async listWorkspaces(): Promise<ApiResponse<AiCadWorkspaceSummary[]>> {
try {
const response = await fetch("/api/ai-cad/workspaces");
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "读取 AI-CAD 工作台列表失败" };
}
},
async createWorkspace(params?: {
name?: string;
}): Promise<ApiResponse<AiCadWorkspaceSummary>> {
try {
const response = await fetch("/api/ai-cad/workspaces", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params || {}),
});
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "创建 AI-CAD 工作台失败" };
}
},
async getWorkspace(
workspaceId: string
): Promise<ApiResponse<AiCadWorkspaceDocument | null>> {
try {
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`);
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "读取 AI-CAD 工作台失败" };
}
},
async saveWorkspace(
workspaceId: string,
params: {
name?: string;
prompt: string;
generationProvider: "kittycad-cad" | "tripo-3d";
modelVersion: string;
viewerSettings: Record<string, unknown>;
messages: Record<string, unknown>[];
versions: Record<string, unknown>[];
activeVersionId?: string | null;
}
): Promise<
ApiResponse<{
id: string;
name: string;
updatedAt?: string | null;
summary?: AiCadWorkspaceSummary;
}>
> {
try {
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "保存 AI-CAD 工作台失败" };
}
},
async deleteWorkspace(
workspaceId: string
): Promise<ApiResponse<{ id: string }>> {
try {
const response = await fetch(`/api/ai-cad/workspaces/${workspaceId}`, {
method: "DELETE",
});
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "删除 AI-CAD 工作台失败" };
}
},
async getProviderStatus(): Promise<ApiResponse<AiCadProviderStatus>> {
try {
const response = await fetch("/api/ai-cad/provider");
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "读取 AI-CAD 提供商状态失败" };
}
},
async createTask(params: {
prompt: string;
outputFormat?: string;
projectName?: string;
iterationSourceTaskId?: string;
}): Promise<
ApiResponse<{
taskId: string;
status: Model3DTaskStatus;
conversationId?: string | null;
modelVersion?: string | null;
outputFormat?: string | null;
generationMode?: "create" | "iterate";
sourceTaskId?: string | null;
raw?: any;
}>
> {
try {
const response = await fetch("/api/ai-cad/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "创建 AI-CAD 任务失败" };
}
},
async getTask(taskId: string): Promise<
ApiResponse<{
taskId: string;
type: string;
status: Model3DTaskStatus;
progress?: number | null;
output?: Record<string, any>;
modelUrl?: string | null;
previewImageUrl?: string | null;
errorMessage?: string | null;
conversationId?: string | null;
modelVersion?: string | null;
outputFormat?: string | null;
codeUrl?: string | null;
downloadUrls?: Record<string, string>;
}>
> {
try {
const response = await fetch(`/api/ai-cad/tasks/${taskId}`);
const data = await response.json();
return data;
} catch (_error) {
return { success: false, error: "查询 AI-CAD 任务失败" };
}
},
};

File diff suppressed because one or more lines are too long