feat(canvas): add Tripo3D generation workspace and provider config
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-02-28 21:57:51 +08:00
parent b578676251
commit d272bd5ee8
23 changed files with 2235 additions and 11 deletions

View File

@ -38,3 +38,8 @@ GTP5_API_KEY=your-gtp5-api-key
# 模型名称(例如:gpt-5-nano) # 模型名称(例如:gpt-5-nano)
GTP5_MODEL=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

View File

@ -123,7 +123,7 @@ type ToolConfig = {
type AiProvider = { type AiProvider = {
id: string; id: string;
name: 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; baseUrl: string;
apiKey: string; apiKey: string;
isEnabled: boolean; isEnabled: boolean;
@ -394,6 +394,7 @@ const PROVIDER_TYPES = [
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑 { value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
{ value: "ruxa-image", label: "RUXA 生图模型" }, { value: "ruxa-image", label: "RUXA 生图模型" },
{ value: "fal-image", label: "FAL 生图模型" }, { value: "fal-image", label: "FAL 生图模型" },
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
{ value: "zhipu", label: "智普官方 4.7" }, { value: "zhipu", label: "智普官方 4.7" },
{ value: "volcano-multimodal", label: "火山引擎多模态" }, { value: "volcano-multimodal", label: "火山引擎多模态" },
{ value: "openai", label: "OpenAI 兼容" }, { value: "openai", label: "OpenAI 兼容" },
@ -415,6 +416,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
"ruxa-image": "https://api.ruxa.ai", "ruxa-image": "https://api.ruxa.ai",
"fal-image": "https://fal.run", "fal-image": "https://fal.run",
"fal-video": "https://fal.run", "fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
zhipu: "https://open.bigmodel.cn/api/paas/v4", zhipu: "https://open.bigmodel.cn/api/paas/v4",
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3", "volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
openai: "https://api.openai.com/v1", openai: "https://api.openai.com/v1",
@ -1089,7 +1091,7 @@ export function AdminSettingsClient() {
const [providerForm, setProviderForm] = useState({ const [providerForm, setProviderForm] = useState({
id: "", id: "",
name: "", 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: "", baseUrl: "",
apiKey: "", apiKey: "",
isEnabled: true, isEnabled: true,
@ -3349,6 +3351,7 @@ export function AdminSettingsClient() {
"alibaba-image": "bg-amber-500", "alibaba-image": "bg-amber-500",
"ruxa-image": "bg-emerald-500", "ruxa-image": "bg-emerald-500",
"fal-image": "bg-sky-500", "fal-image": "bg-sky-500",
"tripo-3d": "bg-teal-500",
"openai": "bg-green-500", "openai": "bg-green-500",
"zhipu": "bg-lime-500", "zhipu": "bg-lime-500",
"volcano-multimodal": "bg-fuchsia-500", "volcano-multimodal": "bg-fuchsia-500",

View File

@ -25,6 +25,7 @@ const providerTypes = [
"fal-image", "fal-image",
"fal-video", "fal-video",
"zhipu", "zhipu",
"tripo-3d",
"volcano-multimodal", "volcano-multimodal",
] as const; ] as const;
type ProviderType = (typeof providerTypes)[number]; 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 ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; 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 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( function normalizeProviderTypeForStorage(
type?: ProviderType type?: ProviderType
@ -156,6 +158,8 @@ export async function POST(request: Request) {
? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL ? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL
: data.type === "doubao-video" : data.type === "doubao-video"
? data.baseUrl || DOUBAO_VIDEO_BASE_URL ? data.baseUrl || DOUBAO_VIDEO_BASE_URL
: data.type === "tripo-3d"
? data.baseUrl || TRIPO_3D_BASE_URL
: data.baseUrl, : data.baseUrl,
updatedBy: session.user.id, updatedBy: session.user.id,
}); });

View File

@ -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<Record<string, unknown>>(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 }
);
}
}

View File

@ -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<TripoTaskData>(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<string, unknown>);
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
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 }
);
}
}

View File

@ -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<string, unknown>;
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<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(data, key);
}
function assignMapped(
payload: Record<string, unknown>,
source: Record<string, unknown>,
map: Record<string, string>
) {
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<string, unknown> = {
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<string, unknown>, {
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<Record<string, unknown>>(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 }
);
}
}

View File

@ -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<string, unknown>;
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<Record<string, unknown>>(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 }
);
}
}

View File

@ -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<Model3DTaskStatus>([
"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<string, any> | 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<GenerationMode>("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<File | null>(null);
const [referencePreviewUrl, setReferencePreviewUrl] = useState<string | null>(null);
const [currentTask, setCurrentTask] = useState<TaskRecord | null>(null);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
const [balanceLabel, setBalanceLabel] = useState<string | null>(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<string, any> | 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<string, any> = {
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 (
<div className="flex h-dvh w-full flex-col bg-background p-4">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={() => router.push(`/canvas/${id}`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold">3D 生成工作台</h1>
<p className="text-xs text-muted-foreground">
Tripo3D 文生模型 / 图生模型 · 一键导入无限画布
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="rounded-md border bg-card px-3 py-1.5 text-xs text-muted-foreground">
额度: {balanceLabel || "--"}
</div>
<Button variant="outline" onClick={() => router.push(`/canvas/${id}`)}>
返回画布
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-[320px_minmax(0,1fr)_300px]">
<Card className="min-h-0">
<CardHeader className="pb-3">
<CardTitle className="text-base">生成参数</CardTitle>
<CardDescription>参考图布局,使用站内 UI 规范</CardDescription>
</CardHeader>
<CardContent className="space-y-4 overflow-y-auto">
<div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/30 p-1">
<Button
variant={mode === "text" ? "default" : "ghost"}
className="h-8"
onClick={() => setMode("text")}
>
文生3D
</Button>
<Button
variant={mode === "image" ? "default" : "ghost"}
className="h-8"
onClick={() => setMode("image")}
>
图生3D
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="model3d-prompt">提示词</Label>
<Textarea
id="model3d-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="例如:一个Q版机器人手办,白色机身,PBR材质"
className="min-h-24 resize-none"
/>
</div>
{mode === "image" && (
<div className="space-y-2">
<Label htmlFor="model3d-image">参考图</Label>
<label
htmlFor="model3d-image"
className={cn(
"flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed px-3 py-6 text-sm transition-colors",
"hover:bg-muted/50"
)}
>
<Upload className="h-4 w-4" />
<span>{referenceImage ? referenceImage.name : "点击上传 PNG/JPG/WEBP"}</span>
</label>
<Input
id="model3d-image"
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => setReferenceImage(event.target.files?.[0] || null)}
/>
{referencePreviewUrl && (
<img
src={referencePreviewUrl}
alt="参考图预览"
className="h-28 w-full rounded-md border object-cover"
/>
)}
</div>
)}
<div className="space-y-2">
<Label>模型版本</Label>
<Select value={modelVersion} onValueChange={(value) => setModelVersion(value as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_VERSION_OPTIONS.map((item) => (
<SelectItem value={item} key={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="face-limit">面数上限</Label>
<Input
id="face-limit"
type="number"
min={5000}
max={500000}
value={faceLimit}
onChange={(event) => setFaceLimit(Number(event.target.value || 0))}
/>
</div>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
<div className="flex items-center justify-between text-sm">
<span>生成贴图</span>
<Switch checked={texture} onCheckedChange={setTexture} />
</div>
<div className="flex items-center justify-between text-sm">
<span>PBR 材质</span>
<Switch checked={pbr} onCheckedChange={setPbr} />
</div>
<div className="flex items-center justify-between text-sm">
<span>自动尺寸</span>
<Switch checked={autoSize} onCheckedChange={setAutoSize} />
</div>
</div>
<Button className="w-full gap-2" onClick={handleGenerate} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{uploadingImage ? "上传参考图..." : "提交任务中..."}
</>
) : (
<>
<Sparkles className="h-4 w-4" />
开始生成
</>
)}
</Button>
</CardContent>
</Card>
<Card className="min-h-0">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Box className="h-4 w-4" />
3D 预览
</CardTitle>
<CardDescription>
{statusLabel}
{typeof currentTask?.progress === "number" ? ` · ${Math.round(currentTask.progress)}%` : ""}
</CardDescription>
</CardHeader>
<CardContent className="flex h-[calc(100%-4.5rem)] flex-col gap-3">
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border">
<Model3DViewer modelUrl={currentModelUrl} previewImageUrl={currentPreviewImage} />
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<Button variant="default" className="gap-2" onClick={handleImportToCanvas} disabled={importDisabled}>
<PlusCircle className="h-4 w-4" />
导入画布
</Button>
<Button
variant="secondary"
className="gap-2"
onClick={handleDownloadCurrent}
disabled={!currentModelUrl}
>
<Download className="h-4 w-4" />
下载模型
</Button>
<Button variant="outline" className="gap-2" onClick={handleGenerate} disabled={isSubmitting}>
<RefreshCw className="h-4 w-4" />
重新生成
</Button>
</div>
</CardContent>
</Card>
<Card className="min-h-0">
<CardHeader className="pb-2">
<CardTitle className="text-base">任务列表</CardTitle>
<CardDescription>最近 20 条任务记录</CardDescription>
</CardHeader>
<CardContent className="h-[calc(100%-4.5rem)] p-0">
<ScrollArea className="h-full px-4 pb-4">
<div className="space-y-2 pt-1">
{taskHistory.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
暂无任务记录
</div>
) : (
taskHistory.map((task) => (
<button
key={task.taskId}
type="button"
onClick={() => setCurrentTask(task)}
className={cn(
"w-full rounded-lg border p-3 text-left text-xs transition-colors",
currentTask?.taskId === task.taskId
? "border-primary bg-primary/5"
: "hover:bg-muted/40"
)}
>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{task.mode === "text" ? "文生3D" : "图生3D"}</span>
<span className="text-muted-foreground">{normalizeTaskStatus(task.status)}</span>
</div>
<p className="truncate text-muted-foreground">{task.prompt || "图生3D任务"}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">ID: {task.taskId}</p>
</button>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -24,6 +24,7 @@ import {
Grid3X3, Grid3X3,
Settings, Settings,
PenTool, PenTool,
Box,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -771,6 +772,20 @@ export default function CanvasEditorPage({ params }: PageProps) {
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/model3d`)}
className="rounded-xl h-9 w-9"
>
<Box className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">3D生成</TooltipContent>
</Tooltip>
<Separator orientation="horizontal" className="w-5 my-0.5" /> <Separator orientation="horizontal" className="w-5 my-0.5" />
<Tooltip> <Tooltip>

View File

@ -6,6 +6,7 @@ import { motion } from "framer-motion";
import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import type { CanvasNode, Position } from "@/types/canvas"; import type { CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode"; import { ImageNode } from "./nodes/ImageNode";
import { Model3DNode } from "./nodes/Model3DNode";
import { TextNode } from "./nodes/TextNode"; import { TextNode } from "./nodes/TextNode";
import { VideoNode } from "./nodes/VideoNode"; import { VideoNode } from "./nodes/VideoNode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -982,6 +983,9 @@ export function CanvasWorkspace() {
{node.type === 'video' && ( {node.type === 'video' && (
<VideoNode node={node} isSelected={isSelected} /> <VideoNode node={node} isSelected={isSelected} />
)} )}
{node.type === 'model3d' && (
<Model3DNode node={node} isSelected={isSelected} />
)}
{node.type === 'message' && ( {node.type === 'message' && (
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border"> <div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
<span className="text-sm">{(node.data as any).content}</span> <span className="text-sm">{(node.data as any).content}</span>

View File

@ -1,4 +1,5 @@
export { CanvasWorkspace } from './CanvasWorkspace'; export { CanvasWorkspace } from './CanvasWorkspace';
export { ImageNode } from './nodes/ImageNode'; export { ImageNode } from './nodes/ImageNode';
export { Model3DNode } from './nodes/Model3DNode';
export { TextNode } from './nodes/TextNode'; export { TextNode } from './nodes/TextNode';
export { VideoNode } from './nodes/VideoNode'; export { VideoNode } from './nodes/VideoNode';

View File

@ -0,0 +1,131 @@
"use client";
import React, { Suspense, useMemo } from "react";
import { Canvas } from "@react-three/fiber";
import { Environment, OrbitControls, useGLTF } from "@react-three/drei";
import { AlertCircle, Box, ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Model3DViewerProps {
modelUrl?: string | null;
previewImageUrl?: string | null;
}
class ViewerErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidUpdate(prevProps: { children: React.ReactNode }) {
if (prevProps.children !== this.props.children && this.state.hasError) {
this.setState({ hasError: false });
}
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
function ModelScene({ url }: { url: string }) {
const model = useGLTF(url);
const scene = useMemo(() => model.scene.clone(), [model.scene]);
return <primitive object={scene} scale={1} />;
}
function isGlbOrGltf(url?: string | null) {
const value = String(url || "").toLowerCase();
return /\.(gltf|glb)(\?|#|$)/.test(value);
}
function EmptyState({ previewImageUrl }: { previewImageUrl?: string | null }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/20 p-4">
{previewImageUrl ? (
<img
src={previewImageUrl}
alt="3D 预览"
className="max-h-[65%] max-w-full rounded-lg border object-contain"
/>
) : (
<Box className="h-14 w-14 text-muted-foreground/60" />
)}
<p className="text-sm text-muted-foreground">生成完成后可在此区域预览 3D 资产</p>
</div>
);
}
export function Model3DViewer({ modelUrl, previewImageUrl }: Model3DViewerProps) {
if (!modelUrl) {
return <EmptyState previewImageUrl={previewImageUrl} />;
}
if (!isGlbOrGltf(modelUrl)) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/20 p-4">
<AlertCircle className="h-8 w-8 text-amber-500" />
<p className="text-sm text-muted-foreground">
当前文件格式不支持内嵌预览,请在新窗口打开
</p>
<Button asChild variant="secondary" className="gap-2">
<a href={modelUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
打开3D文件
</a>
</Button>
</div>
);
}
return (
<ViewerErrorBoundary
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/20 p-4">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-muted-foreground">3D 预览加载失败,请尝试在新窗口打开</p>
<Button asChild variant="secondary" className="gap-2">
<a href={modelUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
打开3D文件
</a>
</Button>
</div>
}
>
<Canvas camera={{ position: [2, 1.5, 2], fov: 45 }}>
<ambientLight intensity={0.8} />
<directionalLight position={[5, 8, 5]} intensity={1.2} />
<Suspense
fallback={
<mesh>
<boxGeometry args={[0.6, 0.6, 0.6]} />
<meshStandardMaterial color="#94a3b8" wireframe />
</mesh>
}
>
<ModelScene url={modelUrl} />
<Environment preset="city" />
</Suspense>
<OrbitControls enablePan enableZoom enableRotate />
</Canvas>
<div className="pointer-events-none absolute left-3 top-3 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Loader2 className="h-3 w-3" />
拖拽旋转 / 滚轮缩放
</span>
</div>
</ViewerErrorBoundary>
);
}

View File

@ -0,0 +1,184 @@
"use client";
import React, { useState } from "react";
import {
AlertCircle,
Box,
Copy,
Download,
ExternalLink,
Loader2,
MoreVertical,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
interface Model3DNodeProps {
node: CanvasNode;
isSelected: boolean;
}
function inferModelFileName(data: Model3DNodeData, nodeId: string) {
const raw = String(data.src || "").trim();
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
try {
const url = new URL(raw);
const filename = url.pathname.split("/").filter(Boolean).pop();
if (filename) return decodeURIComponent(filename);
} catch {}
return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`;
}
function inferModelFormat(data: Model3DNodeData) {
if (data.format) return data.format.toUpperCase();
const src = String(data.src || "").toLowerCase();
if (src.endsWith(".gltf")) return "GLTF";
if (src.endsWith(".glb")) return "GLB";
if (src.endsWith(".usdz")) return "USDZ";
if (src.endsWith(".fbx")) return "FBX";
if (src.endsWith(".obj")) return "OBJ";
if (src.endsWith(".stl")) return "STL";
if (src.endsWith(".3mf")) return "3MF";
return "3D";
}
export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
const data = node.data as Model3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const handleOpen = () => {
if (!data.src) {
toast.error("暂无可打开的 3D 地址");
return;
}
window.open(data.src, "_blank", "noopener,noreferrer");
};
const handleDownload = () => {
if (!data.src) {
toast.error("暂无可下载的 3D 文件");
return;
}
const link = document.createElement("a");
link.href = data.src;
link.download = inferModelFileName(data, node.id);
link.click();
};
const handleCopy = async () => {
if (!data.src) {
toast.error("暂无可复制的地址");
return;
}
try {
await navigator.clipboard.writeText(data.src);
toast.success("已复制 3D 链接");
} catch {
toast.error("复制失败");
}
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm",
isSelected && "border-primary"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{data.status === "generating" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/40">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">3D 生成中...</span>
</div>
) : data.status === "failed" ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-destructive/10">
<AlertCircle className="h-7 w-7 text-destructive" />
<span className="text-xs text-destructive">3D 生成失败</span>
</div>
) : (
<div className="flex h-full w-full flex-col">
<div className="relative flex-1 overflow-hidden bg-muted/30">
{data.previewImage ? (
<img
src={data.previewImage}
alt={data.prompt || "3D 预览"}
className="h-full w-full object-cover"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Box className="h-12 w-12 text-muted-foreground/60" />
</div>
)}
<div className="absolute left-2 top-2 rounded-md bg-background/85 px-2 py-0.5 text-[10px] font-medium">
{inferModelFormat(data)}
</div>
</div>
<div className="space-y-1 border-t px-3 py-2">
<p className="truncate text-xs font-medium">
{data.prompt || inferModelFileName(data, node.id)}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{data.modelVersion ? `模型: ${data.modelVersion}` : "Tripo3D 结果"}
</p>
</div>
</div>
)}
{isHovered && data.status === "completed" && (
<div className="absolute right-2 top-2 flex items-center gap-1">
<Button
variant="secondary"
size="icon"
className="h-7 w-7"
onClick={handleOpen}
title="打开3D"
>
<ExternalLink className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="h-7 w-7">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleOpen}>
<ExternalLink className="mr-2 h-4 w-4" />
打开 3D
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
下载
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
复制链接
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,505 @@
# 无限画布 3D 生成(Tripo3D)开发文档
## 0. 文档信息
- 文档版本:v1.0(开发方案)
- 编写日期:2026-02-28
- 适用项目:`ai-chatbot-main`(Next.js + 无限画布)
- 目标:在无限画布左侧工具栏新增「3D生成」入口,并新增独立 3D 生成页面,接入 Tripo3D OpenAPI,实现文生3D/图生3D并回流到画布节点
---
## 1. 背景与目标
当前无限画布已支持图片、视频、文本、实时画布等工作流,但缺少 3D 资产生产能力。新增 3D 生成能力后,用户可在同一工作空间内完成:
1. 文案到 3D(Text to 3D)
2. 图片到 3D(Image to 3D)
3. 3D 资产预览、格式转换、导入画布、下载复用
本次先完成「文档先行」:明确 API、架构、页面、数据流、错误处理、安全与验收标准,为后续编码提供统一基线。
---
## 2. Tripo3D API 研究结论(用于接入实现)
## 2.1 基础信息
- Base URL:`https://api.tripo3d.ai/v2/openapi`
- 鉴权:`Authorization: Bearer <TRIPO_API_KEY>`
- 通用响应结构:
- 成功:`{ code: 0, data: ... }`
- 失败:`{ code: 非0, message, suggestion }`
- 响应头可包含追踪字段:`X-Tripo-Trace-ID`
## 2.2 本项目相关接口
| 能力 | 方法 | 路径 | 用途 |
|---|---|---|---|
| 创建任务 | POST | `/task` | 发起 text_to_model / image_to_model / convert_model 等任务 |
| 查询任务 | GET | `/task/{task_id}` | 轮询任务状态与输出 |
| 上传图片(STS) | POST | `/upload/sts` | 图生3D前上传参考图并获取 token |
| STS Token | POST | `/upload/sts/token` | 进阶上传流程(S3 直传) |
| 查询余额 | GET | `/user/balance` | 展示可用额度 |
## 2.3 任务类型(本期聚焦)
- `text_to_model`(MVP 必做)
- `image_to_model`(MVP 必做)
- `convert_model`(MVP 建议做:统一为 GLB/GLTF)
- `refine_model`(V1.1)
- `multiview_to_model`(V1.1)
## 2.4 任务状态
- 进行中:`queued`、`running`
- 结束态:`success`、`failed`、`banned`、`expired`、`cancelled`、`unknown`
建议前端状态映射:
- `queued/running` -> `generating`
- `success` -> `completed`
- 其他结束态 -> `failed`
## 2.5 输出字段(按可用性优先级)
任务成功后输出可能在以下字段出现:
1. `pbr_model`
2. `model`
3. `base_model`
4. `generated_image`(预览图)
5. `rendered_image`(预览图)
建议导入画布时优先选择:`pbr_model > model > base_model`。
## 2.6 已确认可选模型版本(写入产品文案与参数面板)
可用版本示例(官方文档中出现):
- `Turbo-v1.0-20250506`(建议默认)
- `v3.1-20260211`
- `v3.0-20250812`
- `v2.5-20250123`
- `v2.0-20240919`
- `v1.4-20240625`(部分可能已废弃)
说明:模型版本可能变更,运行时应允许后台动态配置默认值与可选列表。
## 2.7 关键约束
- 速率与并发(文档值):
- Refine 并发上限:5
- 其他任务并发上限:10
- 图片上传:10 QPS
- 图片输入:
- 格式:`webp | jpeg | png`
- 尺寸:20~6000px(建议 >256px)
- 输出 URL 可能短时有效(文档提到默认较短有效期),不能只在前端缓存,建议尽快回存到自有存储
## 2.8 典型错误码(需做前端友好映射)
- `1002`:鉴权失败
- `1004`:参数错误
- `1007`:触发限流
- `2000`:超出限制
- `2001`:任务不存在
- `2002`:任务类型不支持
- `2004`:文件类型不支持
- `2008`:内容策略拦截
- `2010`:余额不足
- `2015`:模型版本废弃
- `2017`:模型版本无效
---
## 3. 业务范围定义
## 3.1 MVP(本期上线)
1. 无限画布左侧工具栏新增 `3D生成` 按钮
2. 新增页面:`/canvas/[id]/model3d`
3. 支持文生3D、图生3D
4. 支持任务轮询、结果预览、一键导入画布
5. 支持下载 3D 文件(至少 GLB/GLTF)
6. 错误态、超时态、重试机制可用
## 3.2 V1.1(增强)
1. refine_model 二次精修
2. 批量任务队列与历史检索
3. 多格式导出(USDZ/FBX/OBJ/STL/3MF)
4. 3D 资产库与复用
---
## 4. 现有系统接入点(基于当前代码)
已确认关键文件:
- 画布主页面:`/Users/lanmu/Desktop/ai-chatbot-main/app/canvas/[id]/page.tsx`
- 画布工作区:`/Users/lanmu/Desktop/ai-chatbot-main/components/canvas/CanvasWorkspace.tsx`
- 画布状态:`/Users/lanmu/Desktop/ai-chatbot-main/store/canvas/index.ts`
- 节点类型定义:`/Users/lanmu/Desktop/ai-chatbot-main/types/canvas/index.ts`
- 现有独立功能页范式(实时画布):`/Users/lanmu/Desktop/ai-chatbot-main/app/canvas/[id]/realtime/page.tsx`
- 全局设计 Token:`/Users/lanmu/Desktop/ai-chatbot-main/app/globals.css`
当前节点类型暂不含 3D,需扩展:
- 现状:`message | image | video | text | scene-fusion`
- 新增:`model3d`
项目已具备 3D 渲染依赖(无需新增核心依赖):
- `three`
- `@react-three/fiber`
- `@react-three/drei`
---
## 5. 页面与交互设计(参考图布局 + 站内 UI 规范)
## 5.1 页面路由
- 新页面:`/canvas/[id]/model3d`
- 从画布侧栏按钮进入;关闭后返回 `/canvas/[id]`
## 5.2 结构分区(桌面端)
- 顶部:轻量标题栏(返回、项目名、余额、帮助)
- 左侧参数区(约 320px):
- 生成模式切换:文生3D / 图生3D
- Prompt 输入
- 参考图上传(图生3D)
- 高级参数折叠(模型版本、面数、贴图、PBR、seed)
- 主按钮:开始生成
- 中央预览区(主视觉):
- 3D Viewer(OrbitControls)
- 状态覆盖层(排队中/生成中/失败)
- 结果操作(导入画布、下载、重新生成)
- 右侧任务区(约 300px):
- 当前任务状态卡
- 最近任务历史(可重开结果)
- 输出资源信息(格式、大小、创建时间)
## 5.3 视觉规范(必须遵循站内风格)
使用现有 Token,不复制参考图配色:
- 背景:`bg-background`
- 卡片:`bg-card border`
- 主色:`bg-primary text-primary-foreground`
- 辅助文本:`text-muted-foreground`
- 圆角:与现有画布一致(`rounded-xl/2xl`)
- 毛玻璃与阴影:沿用 `backdrop-blur` + `shadow-sm/2xl`
## 5.4 移动端策略
- 左右栏折叠为抽屉(参数抽屉 + 任务抽屉)
- 中央 Viewer 全宽
- 关键操作固定底栏(生成 / 导入画布)
---
## 6. 系统架构设计
```mermaid
flowchart LR
A["Canvas Sidebar: 3D生成"] --> B["/canvas/[id]/model3d 页面"]
B --> C["Next.js API: /api/canvas/3d/*"]
C --> D["Tripo OpenAPI"]
D --> C
C --> E["任务状态返回前端"]
E --> B
B --> F["导入画布 model3d 节点"]
F --> G["CanvasStore + CanvasProject.canvasData"]
```
## 6.1 前端层
- 新建页面组件:`app/canvas/[id]/model3d/page.tsx`
- 新建 3D 业务组件(建议):
- `components/canvas/Model3DStudio.tsx`
- `components/canvas/model3d/Model3DViewer.tsx`
- `components/canvas/model3d/TaskHistoryPanel.tsx`
- 在 `app/canvas/[id]/page.tsx` 左侧工具栏新增入口按钮(图标建议 `Box`)
## 6.2 后端 API 代理层
新增接口(同域代理,隐藏 Tripo Key):
1. `POST /api/canvas/3d/upload-image`
2. `POST /api/canvas/3d/tasks`
3. `GET /api/canvas/3d/tasks/[taskId]`
4. `GET /api/canvas/3d/balance`
5. `POST /api/canvas/3d/import`(可选,做导入封装)
## 6.3 Tripo 适配层
新增服务层(建议):
- `lib/tripo/client.ts`:底层请求、鉴权头、trace-id 提取
- `lib/tripo/types.ts`:请求/响应类型
- `lib/tripo/mappers.ts`:错误码与状态映射
---
## 7. 详细数据流
## 7.1 文生3D流程
1. 前端提交 `prompt + modelVersion + params`
2. 后端 `POST /task`(`type=text_to_model`)
3. 返回 `taskId`
4. 前端轮询 `GET /api/canvas/3d/tasks/[taskId]`
5. 成功后拿到模型 URL(优先 `pbr_model`)
6. 预览通过后点击「导入画布」
7. 生成 `model3d` 节点并持久化画布
## 7.2 图生3D流程
1. 上传参考图到后端
2. 后端调用 Tripo `POST /upload/sts`,拿到 image token
3. 后端 `POST /task`(`type=image_to_model`)
4. 后续轮询与导入同上
## 7.3 轮询策略
- 初始间隔:2s
- 单任务最大时长:8~10 分钟(可配置)
- 超时处理:标记失败并提示可重试
- 页面离开后自动停止轮询
---
## 8. 内部 API 合同(建议定稿)
## 8.1 `POST /api/canvas/3d/upload-image`
请求:`multipart/form-data`(file)
响应:
```json
{
"success": true,
"data": {
"imageToken": "xxxx",
"source": "tripo-sts"
}
}
```
## 8.2 `POST /api/canvas/3d/tasks`
请求(文生):
```json
{
"taskType": "text_to_model",
"prompt": "a cute toy robot",
"modelVersion": "Turbo-v1.0-20250506",
"faceLimit": 120000,
"texture": true,
"pbr": true
}
```
请求(图生):
```json
{
"taskType": "image_to_model",
"imageToken": "xxxx",
"modelVersion": "Turbo-v1.0-20250506",
"faceLimit": 120000,
"texture": true,
"pbr": true
}
```
响应:
```json
{
"success": true,
"data": {
"taskId": "task_xxx",
"status": "queued"
}
}
```
## 8.3 `GET /api/canvas/3d/tasks/[taskId]`
响应(成功):
```json
{
"success": true,
"data": {
"taskId": "task_xxx",
"status": "success",
"outputs": {
"modelUrl": "https://...",
"previewImageUrl": "https://..."
},
"raw": {}
}
}
```
响应(失败):
```json
{
"success": false,
"error": "余额不足",
"code": 2010,
"retryable": false
}
```
## 8.4 `GET /api/canvas/3d/balance`
```json
{
"success": true,
"data": {
"balance": 123.45,
"unit": "credits"
}
}
```
---
## 9. 无限画布节点扩展方案
## 9.1 类型定义
更新 `types/canvas/index.ts`:
- `NodeType` 新增 `model3d`
- 新增 `Model3DNodeData`:
- `src`(模型地址)
- `previewImage?`
- `format`(glb/gltf/usdz/fbx/obj...)
- `prompt?`
- `modelVersion?`
- `status`
- `metadata`(faceLimit、texture、seed、taskId、traceId 等)
## 9.2 组件渲染
- 新增:`components/canvas/nodes/Model3DNode.tsx`
- 更新:`components/canvas/CanvasWorkspace.tsx` 渲染分支
- 节点交互:
- 预览缩略图
- 下载
- 复制链接
- 删除
- 在新窗口查看 3D
## 9.3 数据持久化
MVP 可不加新表,直接利用现有 `CanvasProject.canvasData` JSON 存储节点。
V1.1 如需任务历史和统计,可新增 `Canvas3DTask` 表。
---
## 10. 后端安全与配置
## 10.1 鉴权与密钥
- Tripo API Key 仅保留在服务端(`TRIPO_API_KEY`)
- 前端严禁直连 Tripo API
- 所有 `/api/canvas/3d/*` 必须校验登录态
## 10.2 输入约束
- 上传文件白名单:`image/png`, `image/jpeg`, `image/webp`
- 文件大小上限建议:10MB(可配置)
- prompt 长度限制(例如 500)
## 10.3 审计日志
每次调用记录:
- userId
- taskId
- taskType
- modelVersion
- tripo code / message
- `X-Tripo-Trace-ID`
---
## 11. 错误处理与降级策略
| 场景 | 检测 | 前端提示 | 系统动作 |
|---|---|---|---|
| API Key 无效 | code=1002 | 服务配置异常 | 终止任务并告警 |
| 速率限制 | code=1007 | 请求过于频繁,请稍后 | 指数退避重试 |
| 余额不足 | code=2010 | 余额不足,请充值 | 终止任务 |
| 模型版本失效 | code=2015/2017 | 当前模型不可用,已切换默认版本 | 自动回退默认模型 |
| 内容审核拦截 | code=2008 | 内容不符合规范 | 终止任务 |
| 任务超时 | 本地计时超阈值 | 生成超时,请重试 | 标记失败,可重提 |
---
## 12. 性能与可观测性
## 12.1 性能
- 限制单用户并发任务数(建议 2~3)
- 轮询仅对活跃任务执行
- 3D 预览默认启用轻量灯光与压缩纹理,避免页面卡顿
## 12.2 可观测性
- API 耗时埋点:upload/create/poll/import
- 失败率指标:按任务类型、模型版本、错误码聚合
- 成功耗时 P50/P90/P99
---
## 13. 研发排期(建议)
## 阶段 A(1~2 天):后端通路
1. Tripo client + API 代理路由
2. 上传、创建任务、轮询、余额接口
3. 错误映射和日志
## 阶段 B(1~2 天):前端页面与交互
1. 新建 `/canvas/[id]/model3d`
2. 三栏式页面 + 3D Viewer
3. 任务状态与结果操作
## 阶段 C(1 天):画布集成与验收
1. 左侧工具栏新增 3D 入口
2. `model3d` 节点落盘与展示
3. 全链路联调 + 回归测试
---
## 14. 验收标准(Definition of Done)
1. 用户可从无限画布一键进入 3D 生成页
2. 文生3D与图生3D至少各成功 10 次,成功率 >= 95%(测试环境)
3. 生成成功后 1 次点击可导入画布,并在刷新后仍保留节点
4. 失败场景提示清晰,不出现无响应状态
5. 页面风格符合站内 UI 规范(Token、按钮、间距、圆角、暗黑模式)
---
## 15. 参考文档
- Tripo 文档入口:<https://platform.tripo3d.ai/docs/introduction>
- Tripo OpenAPI Base:<https://api.tripo3d.ai/v2/openapi>
> 备注:Tripo 文档为动态站点,模型版本与限制可能变化;实现时应将默认版本、可选版本与限额做成后台可配置项,而不是硬编码。

View File

@ -13,9 +13,9 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
"ruxa-image": "https://api.ruxa.ai", "ruxa-image": "https://api.ruxa.ai",
"fal-image": "https://fal.run", "fal-image": "https://fal.run",
"fal-video": "https://fal.run", "fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
}; };
export function getProviderDefaultBaseUrl(type: AiProvider["type"]): string | undefined { export function getProviderDefaultBaseUrl(type: AiProvider["type"]): string | undefined {
return PROVIDER_DEFAULT_BASE_URL[type]; return PROVIDER_DEFAULT_BASE_URL[type];
} }

View File

@ -28,6 +28,7 @@ const providerBaseUrlDefaults = [
{ type: "ruxa-image", baseUrl: "https://api.ruxa.ai" }, { type: "ruxa-image", baseUrl: "https://api.ruxa.ai" },
{ type: "fal-image", baseUrl: "https://fal.run" }, { type: "fal-image", baseUrl: "https://fal.run" },
{ type: "fal-video", baseUrl: "https://fal.run" }, { type: "fal-video", baseUrl: "https://fal.run" },
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
] as const; ] as const;
function assertMigrationJournalIsConsistent() { function assertMigrationJournalIsConsistent() {
@ -87,7 +88,7 @@ const runMigrate = async () => {
ADD CONSTRAINT "AiProvider_type_check" ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN ( CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini', 'alibaba', 'openai', 'custom', 'local', 'gemini',
'huanke-gemini', 'qianwen-video', 'doubao-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'fal-video', 'zhipu' 'huanke-gemini', 'qianwen-video', 'doubao-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'ruxa-image', 'fal-image', 'fal-video', 'zhipu', 'tripo-3d'
)); ));
`); `);
for (const item of providerBaseUrlDefaults) { for (const item of providerBaseUrlDefaults) {

View File

@ -1250,7 +1250,7 @@ export async function createAiProvider({
updatedBy, updatedBy,
}: { }: {
name: 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"; 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";
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
isEnabled?: boolean; isEnabled?: boolean;
@ -1288,7 +1288,7 @@ export async function updateAiProvider({
}: { }: {
id: string; id: string;
name?: 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"; 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";
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
isEnabled?: boolean; isEnabled?: boolean;

View File

@ -259,7 +259,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
export const aiProvider = pgTable("AiProvider", { export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问" name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "fal-video", "zhipu"] }) type: varchar("type", { enum: ["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"] })
.notNull() .notNull()
.default("custom"), // 提供商类型 .default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL baseUrl: text("baseUrl").notNull(), // API 基础 URL
@ -551,7 +551,7 @@ export const canvasNode = pgTable("CanvasNode", {
projectId: uuid("projectId") projectId: uuid("projectId")
.notNull() .notNull()
.references(() => canvasProject.id, { onDelete: "cascade" }), .references(() => canvasProject.id, { onDelete: "cascade" }),
type: varchar("type", { length: 32 }).notNull(), // 'message' | 'image' | 'video' | 'text' | 'scene-fusion' type: varchar("type", { length: 32 }).notNull(), // 'message' | 'image' | 'video' | 'text' | 'scene-fusion' | 'model3d'
position: jsonb("position").$type<{ x: number; y: number }>(), position: jsonb("position").$type<{ x: number; y: number }>(),
size: jsonb("size").$type<{ width: number; height: number }>(), size: jsonb("size").$type<{ width: number; height: number }>(),
data: jsonb("data"), // 节点具体数据 data: jsonb("data"), // 节点具体数据

146
lib/tripo/client.ts Normal file
View File

@ -0,0 +1,146 @@
import "server-only";
import type { TripoApiEnvelope, TripoProviderConfig } from "./types";
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
type TripoRequestOptions = {
method?: "GET" | "POST";
path: string;
json?: Record<string, unknown>;
formData?: FormData;
headers?: Record<string, string>;
};
export class TripoApiError extends Error {
code: number | null;
suggestion: string | null;
httpStatus: number;
traceId: string | null;
payload: unknown;
constructor(params: {
message: string;
code?: number | null;
suggestion?: string | null;
httpStatus: number;
traceId?: string | null;
payload?: unknown;
}) {
super(params.message);
this.name = "TripoApiError";
this.code = params.code ?? null;
this.suggestion = params.suggestion ?? null;
this.httpStatus = params.httpStatus;
this.traceId = params.traceId ?? null;
this.payload = params.payload;
}
}
function normalizeBaseUrl(baseUrl?: string) {
const resolved = String(baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL;
return resolved.replace(/\/+$/, "");
}
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;
}
export async function tripoRequest<T = unknown>(
config: TripoProviderConfig,
options: TripoRequestOptions
): Promise<{ data: T; traceId: string | null; raw: TripoApiEnvelope<T> | unknown }> {
const method = options.method || "GET";
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
const headers = new Headers(options.headers || {});
headers.set("Authorization", `Bearer ${config.apiKey}`);
headers.set("Accept", "application/json");
let body: BodyInit | undefined;
if (options.formData) {
body = options.formData;
} else if (options.json) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json);
}
const response = await fetch(url, {
method,
headers,
body,
});
const traceId = response.headers.get("x-tripo-trace-id");
const parsed = await parseResponseBody(response);
const envelope =
parsed && typeof parsed === "object" && "code" in (parsed as Record<string, unknown>)
? (parsed as TripoApiEnvelope<T>)
: null;
if (!response.ok) {
const message =
envelope?.message ||
(typeof parsed === "string" ? parsed : `Tripo 请求失败(HTTP ${response.status})`);
throw new TripoApiError({
message,
code: envelope?.code ?? null,
suggestion: envelope?.suggestion ?? null,
httpStatus: response.status,
traceId,
payload: parsed,
});
}
if (!envelope) {
throw new TripoApiError({
message: "Tripo 响应格式异常",
httpStatus: response.status,
traceId,
payload: parsed,
});
}
if (envelope.code !== 0) {
throw new TripoApiError({
message: envelope.message || "Tripo 请求失败",
code: envelope.code,
suggestion: envelope.suggestion || null,
httpStatus: response.status,
traceId,
payload: envelope,
});
}
return {
data: envelope.data,
traceId,
raw: envelope,
};
}
export function pickTaskModelUrl(output?: Record<string, unknown> | null): string | null {
if (!output) return null;
const candidates = [output.pbr_model, output.model, output.base_model];
for (const item of candidates) {
const value = typeof item === "string" ? item.trim() : "";
if (value) return value;
}
return null;
}
export function pickTaskPreviewImageUrl(output?: Record<string, unknown> | null): string | null {
if (!output) return null;
const candidates = [output.generated_image, output.rendered_image];
for (const item of candidates) {
const value = typeof item === "string" ? item.trim() : "";
if (value) return value;
}
return null;
}

31
lib/tripo/server.ts Normal file
View File

@ -0,0 +1,31 @@
import "server-only";
import { getEnabledAiProviders } from "@/lib/db/queries";
import type { TripoProviderConfig } from "./types";
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
export async function getTripoProviderConfig(): Promise<TripoProviderConfig> {
const providers = await getEnabledAiProviders();
const tripoProvider = providers.find((item) => item.type === "tripo-3d");
if (tripoProvider?.apiKey?.trim()) {
return {
apiKey: tripoProvider.apiKey.trim(),
baseUrl: String(tripoProvider.baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL,
providerId: tripoProvider.id,
providerName: tripoProvider.name,
};
}
const envApiKey = String(process.env.TRIPO_API_KEY || "").trim();
if (!envApiKey) {
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
}
return {
apiKey: envApiKey,
baseUrl: String(process.env.TRIPO_BASE_URL || "").trim() || DEFAULT_TRIPO_BASE_URL,
};
}

46
lib/tripo/types.ts Normal file
View File

@ -0,0 +1,46 @@
export type TripoTaskType = "text_to_model" | "image_to_model" | "convert_model";
export type TripoTaskStatus =
| "queued"
| "running"
| "success"
| "failed"
| "banned"
| "expired"
| "cancelled"
| "unknown";
export interface TripoTaskOutput {
model?: string;
base_model?: string;
pbr_model?: string;
generated_image?: string;
rendered_image?: string;
[key: string]: unknown;
}
export interface TripoTaskData {
task_id: string;
type: TripoTaskType | string;
status: TripoTaskStatus | string;
progress?: number;
output?: TripoTaskOutput;
error_code?: number;
error_msg?: string;
[key: string]: unknown;
}
export interface TripoProviderConfig {
apiKey: string;
baseUrl: string;
providerId?: string;
providerName?: string;
}
export interface TripoApiEnvelope<T> {
code: number;
data: T;
message?: string;
suggestion?: string;
}

View File

@ -418,8 +418,80 @@ export const videoGenerationService = {
}, },
}; };
export type Model3DTaskStatus =
| 'queued'
| 'running'
| 'success'
| 'failed'
| 'banned'
| 'expired'
| 'cancelled'
| 'unknown';
export const model3DGenerationService = {
async uploadImage(file: File): Promise<ApiResponse<{ imageToken?: string; file?: Record<string, any> }>> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/canvas/3d/upload-image', {
method: 'POST',
body: formData,
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '上传参考图失败' };
}
},
async createTask(params: Record<string, any>): Promise<ApiResponse<{ taskId: string; status: Model3DTaskStatus; raw?: any }>> {
try {
const response = await fetch('/api/canvas/3d/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: '创建 3D 任务失败' };
}
},
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;
errorCode?: number | null;
errorMessage?: string | null;
}>> {
try {
const response = await fetch(`/api/canvas/3d/tasks/${taskId}`);
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '查询 3D 任务失败' };
}
},
async getBalance(): Promise<ApiResponse<Record<string, any>>> {
try {
const response = await fetch('/api/canvas/3d/balance');
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '查询余额失败' };
}
},
};
export default { export default {
canvas: canvasService, canvas: canvasService,
image: imageGenerationService, image: imageGenerationService,
video: videoGenerationService, video: videoGenerationService,
model3d: model3DGenerationService,
}; };

View File

@ -1,6 +1,6 @@
// 画布节点类型定义 // 画布节点类型定义
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion'; export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion' | 'model3d';
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed'; export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
export type ToolType = 'select' | 'pan' | 'draw' | 'text'; export type ToolType = 'select' | 'pan' | 'draw' | 'text';
@ -88,13 +88,30 @@ export interface SceneFusionNodeData {
status: NodeStatus; status: NodeStatus;
} }
// 3D 模型节点数据
export interface Model3DNodeData {
src: string;
previewImage?: string;
prompt?: string;
modelVersion?: string;
format?: string;
status: NodeStatus;
metadata?: Record<string, any>;
}
// 画布节点 // 画布节点
export interface CanvasNode { export interface CanvasNode {
id: string; id: string;
type: NodeType; type: NodeType;
position: Position; position: Position;
size: Size; size: Size;
data: MessageNodeData | ImageNodeData | VideoNodeData | TextNodeData | SceneFusionNodeData; data:
| MessageNodeData
| ImageNodeData
| VideoNodeData
| TextNodeData
| SceneFusionNodeData
| Model3DNodeData;
connections: string[]; // 连接的节点 ID connections: string[]; // 连接的节点 ID
parentId?: string; // 父节点 ID(用于对话上下文) parentId?: string; // 父节点 ID(用于对话上下文)
createdAt: Date; createdAt: Date;