diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 2db3a498f..3cf97aeab 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -898,6 +898,7 @@ type AiModel = { fallbackModelId1?: string; fallbackModelId2?: string; excludeFromFailover?: boolean; + excludeFromHealthBan?: boolean; healthStatus?: "healthy" | "degraded" | "auto_disabled"; healthConsecutiveFailures?: number; healthLastEventType?: string | null; @@ -3657,6 +3658,7 @@ export function AdminSettingsClient() { fallbackModelId1: "", // 备用模型 ID 1 fallbackModelId2: "", // 备用模型 ID 2 excludeFromFailover: false, + excludeFromHealthBan: false, capabilities: {} as Record, // 🆕 图片生成模型特性配置 imageGenConfig: createDefaultImageGenConfig(), @@ -6995,6 +6997,7 @@ export function AdminSettingsClient() { fallbackModelId1: "", fallbackModelId2: "", excludeFromFailover: false, + excludeFromHealthBan: false, capabilities: {}, imageGenConfig: createDefaultImageGenConfig(), }); @@ -15300,19 +15303,44 @@ export function AdminSettingsClient() { 多模态模型(Agent 识图与分析使用同一模型) -
- - setModelForm({ - ...modelForm, - excludeFromFailover: checked, - }) - } - /> - +
+
+ 故障保护策略 +
+
+ + setModelForm({ + ...modelForm, + excludeFromFailover: checked, + }) + } + /> + +
+

+ 开启后,该模型不会作为其他模型请求失败时的自动备用模型。 +

+
+ + setModelForm({ + ...modelForm, + excludeFromHealthBan: checked, + }) + } + /> + +
+

+ 开启后,健康检查仍会记录故障日志,但不会因为连续失败自动禁用该模型。 +

{/* 🆕 图片生成模型特性配置 */} @@ -16282,6 +16310,11 @@ export function AdminSettingsClient() { 不参与故障迁移 )} + {(model as any).excludeFromHealthBan && ( + + 不参与封禁池 + + )} {healthStatus === "auto_disabled" && ( 系统自动禁用 @@ -16409,6 +16442,8 @@ export function AdminSettingsClient() { (model as any).fallbackModelId2 || "", excludeFromFailover: (model as any).excludeFromFailover || false, + excludeFromHealthBan: + (model as any).excludeFromHealthBan || false, capabilities: (model as any).capabilities && typeof (model as any).capabilities === diff --git a/app/(chat)/api/files/direct-upload/route.ts b/app/(chat)/api/files/direct-upload/route.ts new file mode 100644 index 000000000..b83c19a10 --- /dev/null +++ b/app/(chat)/api/files/direct-upload/route.ts @@ -0,0 +1,354 @@ +import { + AbortMultipartUploadCommand, + CompleteMultipartUploadCommand, + CreateMultipartUploadCommand, + HeadObjectCommand, + PutObjectCommand, + UploadPartCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/app/(auth)/auth"; +import { upsertMediaAsset } from "@/lib/db/queries"; +import { + buildS3PublicUrl, + buildStorageObjectKey, + createS3Client, + getContentType, + maybeEnqueueStorageMigrationTask, + resolveS3UploadTarget, +} from "@/lib/storage/service"; + +const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; +const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; +const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024; +const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60; +const DIRECT_UPLOAD_PREFIX = "uploads/direct"; + +const allowedDirectUploadTypes = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", +]); + +const InitiateSchema = z.object({ + action: z.literal("initiate"), + filename: z.string().min(1).max(256), + contentType: z.string().min(1).max(128), + size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES), + purpose: z.string().max(64).optional(), +}); + +const CompleteSchema = z.object({ + action: z.literal("complete"), + key: z.string().min(1).max(512), + uploadId: z.string().min(1).max(512).optional(), + filename: z.string().min(1).max(256), + contentType: z.string().min(1).max(128), + size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES), + parts: z + .array( + z.object({ + ETag: z.string().min(1), + PartNumber: z.number().int().positive(), + }) + ) + .optional(), + purpose: z.string().max(64).optional(), +}); + +const AbortSchema = z.object({ + action: z.literal("abort"), + key: z.string().min(1).max(512), + uploadId: z.string().min(1).max(512), +}); + +const RequestSchema = z.discriminatedUnion("action", [ + InitiateSchema, + CompleteSchema, + AbortSchema, +]); + +function normalizeContentType(contentType: string, filename: string) { + const trimmed = contentType.trim().toLowerCase(); + return trimmed || getContentType(filename); +} + +function sanitizeDisplayName(filename: string) { + return ( + filename + .trim() + .replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120) || `upload-${Date.now()}` + ); +} + +function userUploadPrefix(userId: string) { + return `${DIRECT_UPLOAD_PREFIX}/${userId}`; +} + +function assertUserOwnedKey(key: string, userId: string) { + const normalized = String(key || "").replace(/^\/+/, ""); + const prefix = `${userUploadPrefix(userId)}/`; + if (!normalized.startsWith(prefix)) { + throw new Error("Invalid upload object key"); + } + return normalized; +} + +function isAllowedDirectUpload(contentType: string) { + return allowedDirectUploadTypes.has(contentType); +} + +function computePartSize(size: number) { + if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES; + if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024; + return 16 * 1024 * 1024; +} + +async function createPartUrls(params: { + client: ReturnType; + bucket: string; + key: string; + uploadId: string; + partCount: number; +}) { + const urls = []; + for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) { + const command = new UploadPartCommand({ + Bucket: params.bucket, + Key: params.key, + UploadId: params.uploadId, + PartNumber: partNumber, + }); + urls.push({ + partNumber, + url: await getSignedUrl(params.client, command, { + expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, + }), + }); + } + return urls; +} + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let payload: z.infer; + try { + payload = RequestSchema.parse(await request.json()); + } catch (error) { + return NextResponse.json( + { + error: "Invalid direct upload request", + details: error instanceof Error ? error.message : String(error), + }, + { status: 400 } + ); + } + + const target = await resolveS3UploadTarget().catch((error) => { + console.error("[DirectUpload] Failed to resolve storage target:", error); + return null; + }); + + if (!target) { + return NextResponse.json( + { error: "Direct upload is not available for current storage backend" }, + { status: 409 } + ); + } + + const client = createS3Client(target.config); + const bucket = target.config.bucket; + + try { + if (payload.action === "initiate") { + const contentType = normalizeContentType( + payload.contentType, + payload.filename + ); + + if (!isAllowedDirectUpload(contentType)) { + return NextResponse.json( + { error: "Direct upload only supports JPG, PNG and WebP images" }, + { status: 400 } + ); + } + + const displayName = sanitizeDisplayName(payload.filename); + const key = buildStorageObjectKey( + displayName, + userUploadPrefix(session.user.id) + ); + + if (payload.size < MULTIPART_THRESHOLD_BYTES) { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType, + ACL: "public-read", + }); + const url = await getSignedUrl(client, command, { + expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, + }); + + return NextResponse.json({ + mode: "single", + key, + url, + headers: { + "Content-Type": contentType, + "x-amz-acl": "public-read", + }, + expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, + }); + } + + const createResult = await client.send( + new CreateMultipartUploadCommand({ + Bucket: bucket, + Key: key, + ContentType: contentType, + ACL: "public-read", + }) + ); + + const uploadId = createResult.UploadId; + if (!uploadId) { + throw new Error("Storage provider did not return uploadId"); + } + + const partSize = computePartSize(payload.size); + const partCount = Math.ceil(payload.size / partSize); + const parts = await createPartUrls({ + client, + bucket, + key, + uploadId, + partCount, + }); + + return NextResponse.json({ + mode: "multipart", + key, + uploadId, + partSize, + partCount, + parts, + expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, + }); + } + + if (payload.action === "abort") { + const key = assertUserOwnedKey(payload.key, session.user.id); + await client.send( + new AbortMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId: payload.uploadId, + }) + ); + return NextResponse.json({ ok: true }); + } + + const key = assertUserOwnedKey(payload.key, session.user.id); + const contentType = normalizeContentType(payload.contentType, payload.filename); + if (!isAllowedDirectUpload(contentType)) { + return NextResponse.json( + { error: "Direct upload only supports JPG, PNG and WebP images" }, + { status: 400 } + ); + } + + if (payload.uploadId) { + const parts = [...(payload.parts || [])].sort( + (a, b) => a.PartNumber - b.PartNumber + ); + if (parts.length === 0) { + return NextResponse.json( + { error: "Missing multipart upload parts" }, + { status: 400 } + ); + } + + await client.send( + new CompleteMultipartUploadCommand({ + Bucket: bucket, + Key: key, + UploadId: payload.uploadId, + MultipartUpload: { + Parts: parts, + }, + }) + ); + } + + const head = await client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key, + }) + ); + + const actualSize = + typeof head.ContentLength === "number" ? head.ContentLength : null; + if (actualSize !== null && actualSize <= 0) { + throw new Error("Uploaded object is empty"); + } + + const url = buildS3PublicUrl(target.config, key); + const resolvedContentType = + head.ContentType || contentType || getContentType(payload.filename); + const asset = await upsertMediaAsset({ + userId: session.user.id, + mediaType: "image", + sourceType: "upload", + status: "ready", + originalUrl: url, + previewUrl: url, + thumbnailUrl: url, + mimeType: resolvedContentType, + displayName: payload.filename, + sourceTable: "direct_upload", + metadata: { + pathname: key, + size: actualSize ?? payload.size, + expectedSize: payload.size, + uploadMode: payload.uploadId ? "multipart" : "single", + purpose: payload.purpose || null, + }, + }); + + await maybeEnqueueStorageMigrationTask({ + uploaded: { url, pathname: key }, + storageConfig: target.storageConfig, + migrationConfig: target.migrationConfig, + }).catch((error) => { + console.error("[DirectUpload] Failed to enqueue OSS migration task:", error); + }); + + return NextResponse.json({ + url, + pathname: key, + contentType: resolvedContentType, + assetId: asset?.id ?? null, + }); + } catch (error) { + console.error("[DirectUpload] Request failed:", error); + return NextResponse.json( + { + error: "Direct upload failed", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index 6706e3ba1..33c58c429 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -82,6 +82,7 @@ const createModelSchema = z.object({ fallbackModelId1: z.string().max(128).optional(), fallbackModelId2: z.string().max(128).optional(), excludeFromFailover: z.boolean().optional(), + excludeFromHealthBan: z.boolean().optional(), isAgentModel: z.boolean().optional(), agentCodingModelId: z.string().max(128).optional(), isMultimodal: z.boolean().optional(), @@ -106,6 +107,7 @@ const updateModelSchema = z.object({ fallbackModelId1: z.string().max(128).optional(), fallbackModelId2: z.string().max(128).optional(), excludeFromFailover: z.boolean().optional(), + excludeFromHealthBan: z.boolean().optional(), isAgentModel: z.boolean().optional(), agentCodingModelId: z.string().max(128).optional(), isMultimodal: z.boolean().optional(), diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index bcdedaf94..8812c76f8 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -4833,6 +4833,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); @@ -4897,6 +4898,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); @@ -5047,6 +5049,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); @@ -7369,6 +7372,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append('file', file); + formData.append('purpose', 'canvas-upload'); const response = await fetchUploadWithRetry(formData); @@ -7620,6 +7624,7 @@ export function CanvasChatPanel({ try { const formData = new FormData(); formData.append('file', file); + formData.append('purpose', 'canvas-upload'); const response = await fetchUploadWithRetry(formData); diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index d2a82042c..437da9080 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -485,6 +485,7 @@ async function uploadChatAttachmentBlob(blob: Blob, displayName: string) { const formData = new FormData(); const contentType = uploadBlob.type || "image/jpeg"; formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType)); + formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); let payload: any = null; @@ -5630,6 +5631,7 @@ export function ImageNode({ const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `annotated-${Date.now()}.png`); + formData.append("purpose", "canvas-upload"); const uploadResponse = await fetchUploadWithRetry(formData); if (uploadResponse.ok) { const result = await uploadResponse.json(); @@ -5666,6 +5668,7 @@ export function ImageNode({ const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `canvas-resize-${Date.now()}.png`); + formData.append("purpose", "canvas-upload"); const uploadResponse = await fetchUploadWithRetry(formData); diff --git a/components/canvas/nodes/TextNode.tsx b/components/canvas/nodes/TextNode.tsx index f403d14a1..ad690cf4e 100644 --- a/components/canvas/nodes/TextNode.tsx +++ b/components/canvas/nodes/TextNode.tsx @@ -138,6 +138,7 @@ export function TextNode({ const dimensions = await getPastedImageDimensions(file); const formData = new FormData(); formData.append("file", file); + formData.append("purpose", "canvas-upload"); try { const response = await fetchUploadWithRetry(formData); diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 2a4eefb2e..0d2b9b82a 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -6,15 +6,12 @@ import { Box, Camera, Copy, - Crosshair, - Cuboid, Download, ImageIcon, Loader2, MoreVertical, Sparkles, Trash2, - Waypoints, type LucideIcon, } from "lucide-react"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; @@ -51,7 +48,7 @@ import { worldLabsGenerationService, } from "@/services/canvas"; import { useCanvasStore } from "@/store/canvas"; -import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas"; +import type { CanvasNode, World3DNodeData, World3DViewMode } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; interface World3DNodeProps { @@ -68,7 +65,6 @@ const VIEW_META: Array<{ }> = [ { id: "image", label: "I", icon: ImageIcon, toolbarKey: "world3dViewImage" }, { id: "splat", label: "3D", icon: Box, toolbarKey: "world3dViewSplat" }, - { id: "mesh", label: "M", icon: Cuboid, toolbarKey: "world3dViewMesh" }, ]; const WORLD_ASSET_HYDRATION_INTERVAL_MS = 5000; @@ -188,6 +184,7 @@ function resolveActiveView(data: World3DNodeData) { const hasSplat = availableViews.includes("splat"); if ( data.activeView && + data.activeView !== "mesh" && availableViews.includes(data.activeView) ) { return data.activeView; @@ -255,7 +252,6 @@ export function World3DNode({ }: World3DNodeProps) { const data = node.data as World3DNodeData; const [isHovered, setIsHovered] = useState(false); - const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate"); const [viewerCapture, setViewerCapture] = useState<(() => Promise) | null>( null ); @@ -272,14 +268,6 @@ export function World3DNode({ const projectId = useCanvasStore((state) => state.projectId); const getCanvasState = useCanvasStore((state) => state.getCanvasState); const viewportZoom = useCanvasStore((state) => state.viewport.zoom); - const world3dFirstPersonItem = getCanvasToolbarItem( - canvasToolbarConfig, - "world3dFirstPerson" - ); - const world3dHotspotItem = getCanvasToolbarItem( - canvasToolbarConfig, - "world3dHotspot" - ); const world3dMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "world3dMore"); const world3dOpenMeshItem = getCanvasToolbarItem( canvasToolbarConfig, @@ -354,7 +342,7 @@ export function World3DNode({ ); const primaryUrl = resolvePrimaryUrl(data, activeView); const modelDownloadTarget = inferModelDownloadTarget(data); - const hotspotCount = data.hotspots?.length || 0; + const interactionMode = "navigate" as const; const placeholderSizeScale = useMemo( () => getNodeContentScale(node.size, { width: 420, height: 320 }, 0.76), [node.size.height, node.size.width] @@ -618,7 +606,7 @@ export function World3DNode({ modelVersion, status: "completed", activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image", - firstPerson: data.firstPerson ?? false, + firstPerson: data.firstPerson ?? true, hotspots: data.hotspots || [], semanticsMetadata: assetPayload.semanticsMetadata && @@ -770,7 +758,7 @@ export function World3DNode({ : imageUrl ? "image" : data.activeView, - firstPerson: data.firstPerson ?? false, + firstPerson: data.firstPerson ?? true, semanticsMetadata: assetPayload.semanticsMetadata && typeof assetPayload.semanticsMetadata === "object" ? (assetPayload.semanticsMetadata as Record) @@ -820,55 +808,6 @@ export function World3DNode({ }); }; - const handleToggleFirstPerson = () => { - const nextFirstPerson = !data.firstPerson; - updateNode(node.id, { - data: { - ...data, - firstPerson: nextFirstPerson, - } as World3DNodeData, - }); - if (nextFirstPerson) { - setInteractionMode("navigate"); - } - }; - - const handleToggleHotspotMode = () => { - const nextMode = interactionMode === "hotspot" ? "navigate" : "hotspot"; - setInteractionMode(nextMode); - if (nextMode === "hotspot" && data.firstPerson) { - updateNode(node.id, { - data: { - ...data, - firstPerson: false, - } as World3DNodeData, - }); - } - }; - - const handlePick = (pick: { point: [number, number, number] }) => { - if (interactionMode !== "hotspot") return; - const nextHotspot: World3DHotspot = { - id: crypto.randomUUID(), - label: `热点 ${hotspotCount + 1}`, - position: { - x: pick.point[0], - y: pick.point[1], - z: pick.point[2], - }, - createdAt: new Date().toISOString(), - }; - - updateNode(node.id, { - data: { - ...data, - hotspots: [...(data.hotspots || []), nextHotspot], - } as World3DNodeData, - }); - setInteractionMode("navigate"); - toast.success("已添加世界热点"); - }; - const handleCopy = async () => { if (!primaryUrl) { toast.error("暂无可复制的世界地址"); @@ -1144,14 +1083,13 @@ export function World3DNode({ > - {activeView === "splat" ? ( -
- - -
- ) : null} - {captureEnabled ? (