diff --git a/.env.example b/.env.example index 9a03f8096..e630e0a3f 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,8 @@ GTP5_API_KEY=your-gtp5-api-key # 模型名称(例如:gpt-5-nano) GTP5_MODEL=gpt-5-nano + +# Tripo3D(可选) +# 优先使用系统设置-本地AI里的 `tripo-3d` 提供商;若未配置则回退到环境变量 +TRIPO_API_KEY=your-tripo-api-key +TRIPO_BASE_URL=https://api.tripo3d.ai/v2/openapi diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 50aad92b9..021182cb2 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -123,7 +123,7 @@ type ToolConfig = { type AiProvider = { id: string; name: string; - type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal"; + type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal"; baseUrl: string; apiKey: string; isEnabled: boolean; @@ -394,6 +394,7 @@ const PROVIDER_TYPES = [ { value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑 { value: "ruxa-image", label: "RUXA 生图模型" }, { value: "fal-image", label: "FAL 生图模型" }, + { value: "tripo-3d", label: "Tripo3D 模型生成" }, { value: "zhipu", label: "智普官方 4.7" }, { value: "volcano-multimodal", label: "火山引擎多模态" }, { value: "openai", label: "OpenAI 兼容" }, @@ -415,6 +416,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = { "ruxa-image": "https://api.ruxa.ai", "fal-image": "https://fal.run", "fal-video": "https://fal.run", + "tripo-3d": "https://api.tripo3d.ai/v2/openapi", zhipu: "https://open.bigmodel.cn/api/paas/v4", "volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3", openai: "https://api.openai.com/v1", @@ -1089,7 +1091,7 @@ export function AdminSettingsClient() { const [providerForm, setProviderForm] = useState({ id: "", name: "", - type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal", + type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "tripo-3d" | "volcano-multimodal", baseUrl: "", apiKey: "", isEnabled: true, @@ -3349,6 +3351,7 @@ export function AdminSettingsClient() { "alibaba-image": "bg-amber-500", "ruxa-image": "bg-emerald-500", "fal-image": "bg-sky-500", + "tripo-3d": "bg-teal-500", "openai": "bg-green-500", "zhipu": "bg-lime-500", "volcano-multimodal": "bg-fuchsia-500", diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts index 6d23ee89c..9c9172b25 100644 --- a/app/api/admin/ai-providers/route.ts +++ b/app/api/admin/ai-providers/route.ts @@ -25,6 +25,7 @@ const providerTypes = [ "fal-image", "fal-video", "zhipu", + "tripo-3d", "volcano-multimodal", ] as const; type ProviderType = (typeof providerTypes)[number]; @@ -50,6 +51,7 @@ const updateProviderSchema = z.object({ const ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; +const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; function normalizeProviderTypeForStorage( type?: ProviderType @@ -156,7 +158,9 @@ export async function POST(request: Request) { ? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL : data.type === "doubao-video" ? data.baseUrl || DOUBAO_VIDEO_BASE_URL - : data.baseUrl, + : data.type === "tripo-3d" + ? data.baseUrl || TRIPO_3D_BASE_URL + : data.baseUrl, updatedBy: session.user.id, }); diff --git a/app/api/canvas/3d/balance/route.ts b/app/api/canvas/3d/balance/route.ts new file mode 100644 index 000000000..d17af12e0 --- /dev/null +++ b/app/api/canvas/3d/balance/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getTripoProviderConfig } from "@/lib/tripo/server"; +import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const config = await getTripoProviderConfig(); + const result = await tripoRequest>(config, { + method: "GET", + path: "/user/balance", + }); + + return NextResponse.json({ + success: true, + data: result.data, + traceId: result.traceId, + }); + } catch (error) { + if (error instanceof TripoApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + code: error.code, + suggestion: error.suggestion, + traceId: error.traceId, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "查询余额失败", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/canvas/3d/tasks/[taskId]/route.ts b/app/api/canvas/3d/tasks/[taskId]/route.ts new file mode 100644 index 000000000..4b16f1d8d --- /dev/null +++ b/app/api/canvas/3d/tasks/[taskId]/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { + TripoApiError, + pickTaskModelUrl, + pickTaskPreviewImageUrl, + tripoRequest, +} from "@/lib/tripo/client"; +import { getTripoProviderConfig } from "@/lib/tripo/server"; +import type { TripoTaskData } from "@/lib/tripo/types"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +interface RouteParams { + params: Promise<{ taskId: string }>; +} + +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 getTripoProviderConfig(); + const result = await tripoRequest(config, { + method: "GET", + path: `/task/${encodeURIComponent(normalizedTaskId)}`, + }); + + const output = + result.data?.output && typeof result.data.output === "object" ? result.data.output : {}; + const modelUrl = pickTaskModelUrl(output as Record); + const previewImageUrl = pickTaskPreviewImageUrl(output as Record); + + return NextResponse.json({ + success: true, + data: { + taskId: result.data.task_id, + type: result.data.type, + status: result.data.status, + progress: result.data.progress ?? null, + output, + modelUrl, + previewImageUrl, + errorCode: result.data.error_code ?? null, + errorMessage: result.data.error_msg ?? null, + }, + traceId: result.traceId, + }); + } catch (error) { + if (error instanceof TripoApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + code: error.code, + suggestion: error.suggestion, + traceId: error.traceId, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "查询任务失败", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/canvas/3d/tasks/route.ts b/app/api/canvas/3d/tasks/route.ts new file mode 100644 index 000000000..6a72c99ca --- /dev/null +++ b/app/api/canvas/3d/tasks/route.ts @@ -0,0 +1,223 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getTripoProviderConfig } from "@/lib/tripo/server"; +import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type CreateTaskRequest = { + taskType: "text_to_model" | "image_to_model" | "convert_model"; + prompt?: string; + imageToken?: string; + imageUrl?: string; + imageType?: string; + file?: Record; + originalModelTaskId?: string; + format?: "GLTF" | "USDZ" | "FBX" | "OBJ" | "STL" | "3MF"; + modelVersion?: string; + negativePrompt?: string; + faceLimit?: number; + texture?: boolean; + pbr?: boolean; + imageSeed?: number; + modelSeed?: number; + textureSeed?: number; + textureQuality?: "standard" | "detailed"; + geometryQuality?: "standard" | "detailed"; + textureAlignment?: "original_image" | "geometry"; + autoSize?: boolean; + orientation?: "default" | "align_image"; + quad?: boolean; + compress?: boolean; + generateParts?: boolean; + smartLowPoly?: boolean; + forceSymmetry?: boolean; + flattenBottom?: boolean; + flattenBottomThreshold?: number; + textureSize?: number; + textureFormat?: string; + scaleFactor?: number; + pivotToCenterBottom?: boolean; + withAnimation?: boolean; + packUv?: boolean; + bake?: boolean; + exportVertexColors?: boolean; + fbxPreset?: "blender" | "mixamo" | "3dsmax"; + exportOrientation?: "+x" | "+y" | "-x" | "-y"; + animateInPlace?: boolean; +}; + +function hasOwn(data: Record, key: string) { + return Object.prototype.hasOwnProperty.call(data, key); +} + +function assignMapped( + payload: Record, + source: Record, + map: Record +) { + for (const [sourceKey, targetKey] of Object.entries(map)) { + if (!hasOwn(source, sourceKey)) continue; + const value = source[sourceKey]; + if (value === undefined || value === null) continue; + if (typeof value === "string" && !value.trim()) continue; + payload[targetKey] = value; + } +} + +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 CreateTaskRequest; + const taskType = body.taskType; + if (!taskType) { + return NextResponse.json({ success: false, error: "缺少 taskType" }, { status: 400 }); + } + + const payload: Record = { + type: taskType, + }; + + if (taskType === "text_to_model") { + const prompt = String(body.prompt || "").trim(); + if (!prompt) { + return NextResponse.json({ success: false, error: "请输入提示词" }, { status: 400 }); + } + payload.prompt = prompt; + } + + if (taskType === "image_to_model") { + const imageToken = String(body.imageToken || "").trim(); + const imageUrl = String(body.imageUrl || "").trim(); + const inputFile = + imageToken + ? { + type: String(body.imageType || "jpg"), + file_token: imageToken, + } + : imageUrl + ? { + type: String(body.imageType || "jpg"), + url: imageUrl, + } + : body.file; + + if (!inputFile || typeof inputFile !== "object") { + return NextResponse.json( + { success: false, error: "图生3D缺少图片输入(imageToken 或 imageUrl)" }, + { status: 400 } + ); + } + payload.file = inputFile; + } + + if (taskType === "convert_model") { + const originalModelTaskId = String(body.originalModelTaskId || "").trim(); + if (!originalModelTaskId) { + return NextResponse.json( + { success: false, error: "缺少 originalModelTaskId" }, + { status: 400 } + ); + } + if (!body.format) { + return NextResponse.json({ success: false, error: "缺少 format" }, { status: 400 }); + } + payload.original_model_task_id = originalModelTaskId; + payload.format = body.format; + } + + assignMapped(payload, body as Record, { + modelVersion: "model_version", + negativePrompt: "negative_prompt", + faceLimit: "face_limit", + texture: "texture", + pbr: "pbr", + imageSeed: "image_seed", + modelSeed: "model_seed", + textureSeed: "texture_seed", + textureQuality: "texture_quality", + geometryQuality: "geometry_quality", + textureAlignment: "texture_alignment", + autoSize: "auto_size", + orientation: "orientation", + quad: "quad", + generateParts: "generate_parts", + smartLowPoly: "smart_low_poly", + forceSymmetry: "force_symmetry", + flattenBottom: "flatten_bottom", + flattenBottomThreshold: "flatten_bottom_threshold", + textureSize: "texture_size", + textureFormat: "texture_format", + scaleFactor: "scale_factor", + pivotToCenterBottom: "pivot_to_center_bottom", + withAnimation: "with_animation", + packUv: "pack_uv", + bake: "bake", + exportVertexColors: "export_vertex_colors", + fbxPreset: "fbx_preset", + exportOrientation: "export_orientation", + animateInPlace: "animate_in_place", + }); + + if (body.compress === true) { + payload.compress = "geometry"; + } + + const config = await getTripoProviderConfig(); + const result = await tripoRequest>(config, { + method: "POST", + path: "/task", + json: payload, + }); + + const taskId = String(result.data?.task_id || result.data?.id || "").trim(); + if (!taskId) { + return NextResponse.json( + { + success: false, + error: "Tripo 返回缺少 task_id", + traceId: result.traceId, + raw: result.data, + }, + { status: 502 } + ); + } + + return NextResponse.json({ + success: true, + data: { + taskId, + status: String(result.data?.status || "queued"), + raw: result.data, + }, + traceId: result.traceId, + }); + } catch (error) { + if (error instanceof TripoApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + code: error.code, + suggestion: error.suggestion, + traceId: error.traceId, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "创建 3D 任务失败", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/canvas/3d/upload-image/route.ts b/app/api/canvas/3d/upload-image/route.ts new file mode 100644 index 000000000..575bb5dcd --- /dev/null +++ b/app/api/canvas/3d/upload-image/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getTripoProviderConfig } from "@/lib/tripo/server"; +import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); +const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; + +function extractImageToken(data: unknown): string | null { + if (!data) return null; + if (typeof data === "string") return data.trim() || null; + if (typeof data !== "object") return null; + const record = data as Record; + const tokenCandidates = [record.image_token, record.file_token, record.token]; + for (const candidate of tokenCandidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return null; +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ success: false, error: "请上传图片文件" }, { status: 400 }); + } + + if (!ALLOWED_IMAGE_TYPES.has(file.type)) { + return NextResponse.json( + { success: false, error: "仅支持 PNG/JPEG/WEBP 图片" }, + { status: 400 } + ); + } + + if (file.size <= 0 || file.size > MAX_UPLOAD_SIZE) { + return NextResponse.json( + { success: false, error: "图片大小需在 1B~10MB 之间" }, + { status: 400 } + ); + } + + const config = await getTripoProviderConfig(); + + const uploadPaths = ["/upload/sts", "/upload"]; + let lastError: unknown = null; + + for (const path of uploadPaths) { + try { + const submitData = new FormData(); + submitData.set("file", file, file.name || "upload.png"); + const result = await tripoRequest>(config, { + method: "POST", + path, + formData: submitData, + }); + const imageToken = extractImageToken(result.data); + return NextResponse.json({ + success: true, + data: { + imageToken, + file: result.data, + }, + traceId: result.traceId, + }); + } catch (error) { + lastError = error; + if (error instanceof TripoApiError && error.httpStatus === 404) { + continue; + } + throw error; + } + } + + throw lastError instanceof Error ? lastError : new Error("图片上传失败"); + } catch (error) { + if (error instanceof TripoApiError) { + return NextResponse.json( + { + success: false, + error: error.message, + code: error.code, + suggestion: error.suggestion, + traceId: error.traceId, + }, + { status: error.httpStatus || 502 } + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "图片上传失败", + }, + { status: 500 } + ); + } +} diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx new file mode 100644 index 000000000..1dc79ae5e --- /dev/null +++ b/app/canvas/[id]/model3d/page.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + ArrowLeft, + Box, + Download, + Loader2, + PlusCircle, + RefreshCw, + Sparkles, + Upload, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Model3DViewer } from "@/components/canvas/model3d/Model3DViewer"; +import { canvasSelectors, useCanvasStore } from "@/store/canvas"; +import { canvasService, model3DGenerationService, type Model3DTaskStatus } from "@/services/canvas"; +import { cn } from "@/lib/utils"; + +type GenerationMode = "text" | "image"; + +type TaskRecord = { + taskId: string; + status: Model3DTaskStatus; + prompt: string; + mode: GenerationMode; + modelVersion: string; + createdAt: number; + progress?: number | null; + modelUrl?: string | null; + previewImageUrl?: string | null; + error?: string | null; +}; + +const MODEL_VERSION_OPTIONS = [ + "Turbo-v1.0-20250506", + "v3.1-20260211", + "v3.0-20250812", + "v2.5-20250123", + "v2.0-20240919", + "v1.4-20240625", +] as const; + +const FINAL_TASK_STATUS = new Set([ + "success", + "failed", + "banned", + "expired", + "cancelled", + "unknown", +]); + +function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus { + switch (status) { + case "queued": + case "running": + case "success": + case "failed": + case "banned": + case "expired": + case "cancelled": + case "unknown": + return status; + default: + return "unknown"; + } +} + +function formatBalance(data: Record | undefined): string | null { + if (!data || typeof data !== "object") return null; + const candidates = [data.balance, data.available_balance, data.credits, data.amount]; + for (const value of candidates) { + if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function extractModelFormat(modelUrl?: string | null): string { + const value = String(modelUrl || "").toLowerCase(); + if (!value) return "3D"; + if (value.includes(".gltf")) return "gltf"; + if (value.includes(".glb")) return "glb"; + if (value.includes(".usdz")) return "usdz"; + if (value.includes(".fbx")) return "fbx"; + if (value.includes(".obj")) return "obj"; + if (value.includes(".stl")) return "stl"; + if (value.includes(".3mf")) return "3mf"; + return "3d"; +} + +export default function CanvasModel3DPage() { + const router = useRouter(); + const routeParams = useParams(); + const id = String(routeParams?.id || ""); + + const viewport = useCanvasStore(canvasSelectors.viewport); + const addNode = useCanvasStore((state) => state.addNode); + const getCanvasState = useCanvasStore((state) => state.getCanvasState); + + const [mode, setMode] = useState("text"); + const [prompt, setPrompt] = useState(""); + const [modelVersion, setModelVersion] = useState<(typeof MODEL_VERSION_OPTIONS)[number]>( + "Turbo-v1.0-20250506" + ); + const [faceLimit, setFaceLimit] = useState(120000); + const [texture, setTexture] = useState(true); + const [pbr, setPbr] = useState(true); + const [autoSize, setAutoSize] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [uploadingImage, setUploadingImage] = useState(false); + const [referenceImage, setReferenceImage] = useState(null); + const [referencePreviewUrl, setReferencePreviewUrl] = useState(null); + const [currentTask, setCurrentTask] = useState(null); + const [taskHistory, setTaskHistory] = useState([]); + const [balanceLabel, setBalanceLabel] = useState(null); + + useEffect(() => { + const loadBalance = async () => { + const result = await model3DGenerationService.getBalance(); + if (!result.success) return; + const label = formatBalance(result.data); + if (label) setBalanceLabel(label); + }; + void loadBalance(); + }, []); + + const upsertTaskHistory = useCallback((next: TaskRecord) => { + setTaskHistory((prev) => { + const index = prev.findIndex((item) => item.taskId === next.taskId); + if (index >= 0) { + const updated = [...prev]; + updated[index] = { ...updated[index], ...next }; + return updated; + } + return [next, ...prev].slice(0, 20); + }); + }, []); + + useEffect(() => { + if (!referenceImage) { + setReferencePreviewUrl(null); + return; + } + const objectUrl = URL.createObjectURL(referenceImage); + setReferencePreviewUrl(objectUrl); + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [referenceImage]); + + useEffect(() => { + if (!currentTask || FINAL_TASK_STATUS.has(currentTask.status)) { + return; + } + + let cancelled = false; + let attempts = 0; + + const runPoll = async () => { + if (cancelled) return; + attempts += 1; + const result = await model3DGenerationService.getTask(currentTask.taskId); + if (!result.success || !result.data) { + const failMessage = result.error || "任务查询失败"; + const failedRecord: TaskRecord = { + ...currentTask, + status: "failed", + error: failMessage, + }; + setCurrentTask(failedRecord); + upsertTaskHistory(failedRecord); + toast.error(failMessage); + return; + } + + const status = normalizeTaskStatus(result.data.status); + const nextRecord: TaskRecord = { + ...currentTask, + status, + progress: result.data.progress ?? null, + modelUrl: result.data.modelUrl ?? currentTask.modelUrl ?? null, + previewImageUrl: result.data.previewImageUrl ?? currentTask.previewImageUrl ?? null, + error: status === "failed" ? result.data.errorMessage || "任务失败" : null, + }; + setCurrentTask(nextRecord); + upsertTaskHistory(nextRecord); + + if (status === "success") { + toast.success("3D 模型生成完成"); + } + + if (attempts >= 300 && !FINAL_TASK_STATUS.has(status)) { + const timeoutRecord: TaskRecord = { + ...nextRecord, + status: "failed", + error: "任务轮询超时,请稍后重试", + }; + setCurrentTask(timeoutRecord); + upsertTaskHistory(timeoutRecord); + toast.error("任务轮询超时,请稍后重试"); + } + }; + + void runPoll(); + const timer = window.setInterval(() => { + void runPoll(); + }, 2000); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [currentTask, upsertTaskHistory]); + + const handleGenerate = async () => { + const normalizedPrompt = prompt.trim(); + if (mode === "text" && !normalizedPrompt) { + toast.error("请输入提示词"); + return; + } + if (mode === "image" && !referenceImage) { + toast.error("图生3D请先上传参考图"); + return; + } + + setIsSubmitting(true); + try { + let imageToken: string | undefined; + let filePayload: Record | undefined; + + if (mode === "image" && referenceImage) { + setUploadingImage(true); + const uploadResult = await model3DGenerationService.uploadImage(referenceImage); + setUploadingImage(false); + if (!uploadResult.success || !uploadResult.data) { + throw new Error(uploadResult.error || "上传参考图失败"); + } + imageToken = uploadResult.data.imageToken; + filePayload = uploadResult.data.file; + } + + const createPayload: Record = { + taskType: mode === "text" ? "text_to_model" : "image_to_model", + prompt: normalizedPrompt, + modelVersion, + faceLimit, + texture, + pbr, + autoSize, + }; + + if (mode === "image") { + if (imageToken) { + createPayload.imageToken = imageToken; + } else if (filePayload) { + createPayload.file = filePayload; + } else { + throw new Error("参考图上传未返回可用 token"); + } + } + + const createResult = await model3DGenerationService.createTask(createPayload); + if (!createResult.success || !createResult.data?.taskId) { + throw new Error(createResult.error || "创建任务失败"); + } + + const newTask: TaskRecord = { + taskId: createResult.data.taskId, + status: normalizeTaskStatus(createResult.data.status), + prompt: normalizedPrompt, + mode, + modelVersion, + createdAt: Date.now(), + }; + setCurrentTask(newTask); + upsertTaskHistory(newTask); + toast.success("已提交 3D 生成任务"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "创建任务失败"); + } finally { + setUploadingImage(false); + setIsSubmitting(false); + } + }; + + const currentModelUrl = currentTask?.modelUrl || null; + const currentPreviewImage = currentTask?.previewImageUrl || referencePreviewUrl || null; + + const importDisabled = !currentTask || currentTask.status !== "success" || !currentModelUrl; + + const handleImportToCanvas = async () => { + if (!currentModelUrl || !currentTask) { + toast.error("暂无可导入的 3D 模型"); + return; + } + + const viewportX = viewport.x || 0; + const viewportY = viewport.y || 0; + const viewportZoom = viewport.zoom || 1; + const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280; + const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720; + const targetWidth = 360; + const targetHeight = 300; + const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2; + const y = (stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2; + + addNode({ + type: "model3d", + position: { x, y }, + size: { width: targetWidth, height: targetHeight }, + data: { + src: currentModelUrl, + previewImage: currentTask.previewImageUrl || undefined, + prompt: currentTask.prompt, + modelVersion: currentTask.modelVersion, + format: extractModelFormat(currentModelUrl), + status: "completed", + metadata: { + taskId: currentTask.taskId, + mode: currentTask.mode, + progress: currentTask.progress, + }, + }, + connections: [], + }); + + try { + await canvasService.saveCanvasState(id, getCanvasState()); + } catch (error) { + console.error("[Model3DPage] 保存画布失败:", error); + } + toast.success("3D 模型已导入画布"); + }; + + const handleDownloadCurrent = () => { + if (!currentModelUrl) { + toast.error("暂无可下载的 3D 模型"); + return; + } + const link = document.createElement("a"); + link.href = currentModelUrl; + link.download = `tripo-model-${currentTask?.taskId || "result"}.${extractModelFormat( + currentModelUrl + )}`; + link.click(); + }; + + const statusLabel = useMemo(() => { + if (!currentTask) return "未开始"; + if (currentTask.status === "queued") return "排队中"; + if (currentTask.status === "running") return "生成中"; + if (currentTask.status === "success") return "生成成功"; + if (currentTask.status === "failed") return "生成失败"; + if (currentTask.status === "cancelled") return "已取消"; + if (currentTask.status === "expired") return "已过期"; + if (currentTask.status === "banned") return "审核拦截"; + return "状态未知"; + }, [currentTask]); + + return ( +
+
+
+ +
+

3D 生成工作台

+

+ Tripo3D 文生模型 / 图生模型 · 一键导入无限画布 +

+
+
+
+
+ 额度: {balanceLabel || "--"} +
+ +
+
+ +
+ + + 生成参数 + 参考图布局,使用站内 UI 规范 + + +
+ + +
+ +
+ +