Merge remote-tracking branch 'github/main' into codex/admin-settings-ui-refactor

# Conflicts:
#	app/(admin)/settings/admin-settings-client.tsx
#	lib/db/migrations/meta/_journal.json
#	pnpm-lock.yaml
This commit is contained in:
Your Name 2026-05-27 13:40:19 +08:00
commit a223d931a6
23 changed files with 1359 additions and 281 deletions

View File

@ -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<string, unknown>,
// 🆕 图片生成模型特性配置
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 识图与分析使用同一模型)
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.excludeFromFailover || false}
onCheckedChange={(checked) =>
setModelForm({
...modelForm,
excludeFromFailover: checked,
})
}
/>
<Label>
禁止参与故障迁移(不作为其他模型的自动备用模型)
</Label>
<div className="space-y-3 rounded-md border p-3">
<div className="text-sm font-medium">
故障保护策略
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.excludeFromFailover || false}
onCheckedChange={(checked) =>
setModelForm({
...modelForm,
excludeFromFailover: checked,
})
}
/>
<Label>
不参与故障迁移候选池
</Label>
</div>
<p className="text-xs text-muted-foreground">
开启后,该模型不会作为其他模型请求失败时的自动备用模型。
</p>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.excludeFromHealthBan || false}
onCheckedChange={(checked) =>
setModelForm({
...modelForm,
excludeFromHealthBan: checked,
})
}
/>
<Label>
不参与故障封禁池
</Label>
</div>
<p className="text-xs text-muted-foreground">
开启后,健康检查仍会记录故障日志,但不会因为连续失败自动禁用该模型。
</p>
</div>
{/* 🆕 图片生成模型特性配置 */}
@ -16282,6 +16310,11 @@ export function AdminSettingsClient() {
不参与故障迁移
</span>
)}
{(model as any).excludeFromHealthBan && (
<span className="rounded bg-stone-100 px-2 py-0.5 text-stone-700 text-xs">
不参与封禁池
</span>
)}
{healthStatus === "auto_disabled" && (
<span className="rounded bg-red-100 px-2 py-0.5 text-red-700 text-xs">
系统自动禁用
@ -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 ===

View File

@ -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<typeof createS3Client>;
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<typeof RequestSchema>;
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 }
);
}
}

View File

@ -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(),

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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<Blob | null>) | 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<string, any>)
@ -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({
>
<World3DViewer
className="h-full w-full"
firstPerson={Boolean(data.firstPerson) && activeView === "splat"}
firstPerson={activeView === "splat"}
hotspots={data.hotspots}
imageUrl={data.imageUrl}
interactionMode={interactionMode}
meshUrl={renderableMeshUrl}
minimalFrame
mode={activeView}
onPick={handlePick}
previewImageUrl={data.previewImage}
semanticsMetadata={data.semanticsMetadata}
splatUrl={renderableSplatUrl}
@ -1197,41 +1135,6 @@ export function World3DNode({
})}
</div>
{activeView === "splat" ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleFirstPerson}
size="sm"
title={world3dFirstPersonItem.label}
type="button"
variant={data.firstPerson ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={!data.firstPerson}
className="h-2.5 w-2.5"
fallback={Waypoints}
item={world3dFirstPersonItem}
/>
</Button>
<Button
className="h-5 rounded-full px-1.5"
onClick={handleToggleHotspotMode}
size="sm"
title={world3dHotspotItem.label}
type="button"
variant={interactionMode === "hotspot" ? "secondary" : "ghost"}
>
<CanvasToolbarIcon
active={interactionMode !== "hotspot"}
className="h-2.5 w-2.5"
fallback={Crosshair}
item={world3dHotspotItem}
/>
</Button>
</div>
) : null}
{captureEnabled ? (
<div className="flex rounded-full border border-white/10 bg-black/55 p-0.5 backdrop-blur-sm">
<Button

View File

@ -49,6 +49,14 @@ type PrefetchedSplatAsset = {
fileName: string;
url: string;
};
type SplatSampleSource = {
getNumSplats?: () => number;
getSplat: (index: number) => {
center: THREE.Vector3;
opacity: number;
};
numSplats?: number;
};
type SparkRevealProfile = {
blurAmount: number;
preBlurAmount: number;
@ -132,11 +140,11 @@ const SPLAT_PARTICLE_PROFILE: SparkRevealProfile = {
};
const SPLAT_NORMAL_PROFILE: SparkRevealProfile = {
blurAmount: 0.12,
preBlurAmount: 0.02,
blurAmount: 0.045,
preBlurAmount: 0,
minAlpha: 0.015,
maxStdDev: Math.sqrt(6.5),
focalAdjustment: 1.18,
maxStdDev: Math.sqrt(4.2),
focalAdjustment: 1.36,
falloff: 1,
opacity: 1,
};
@ -186,8 +194,8 @@ const SPARK_MINIMAL_FRAME_LOD_SPLAT_SCALE = 0.82;
const SPARK_MINIMAL_FRAME_LOD_RENDER_SCALE = 1.6;
const SPARK_MIN_SORT_INTERVAL_MS = 24;
const SPARK_MINIMAL_FRAME_SORT_INTERVAL_MS = 40;
const SPARK_SPLAT_FETCH_TIMEOUT_MS = 18_000;
const SPARK_SPLAT_LOAD_TIMEOUT_MS = 8_000;
const SPARK_SPLAT_FETCH_TIMEOUT_MS = 150_000;
const SPARK_SPLAT_LOAD_TIMEOUT_MS = 45_000;
const KEY_TO_MOVEMENT: Partial<Record<string, MovementCommand>> = {
KeyW: "forward",
@ -313,11 +321,15 @@ function getPercentileValue(values: number[], percentile: number) {
}
function getSplatWorldBounds(splatMesh: SparkSplatMesh) {
splatMesh.updateMatrixWorld(true);
return splatMesh
.getBoundingBox(true)
.clone()
.applyMatrix4(splatMesh.matrixWorld);
try {
splatMesh.updateMatrixWorld(true);
return splatMesh
.getBoundingBox(true)
.clone()
.applyMatrix4(splatMesh.matrixWorld);
} catch {
return new THREE.Box3();
}
}
function getWorldLabsDisplayGroundPlaneOffset(value: unknown) {
@ -343,12 +355,24 @@ function getLoadedSplatCount(splatMesh: SparkSplatMesh) {
}
}
function getSplatSampleSource(splatMesh: SparkSplatMesh): SplatSampleSource | null {
return splatMesh.packedSplats || splatMesh.extSplats || null;
}
function getSplatSampleSourceCount(source: SplatSampleSource) {
try {
return Math.max(0, source.getNumSplats?.() ?? source.numSplats ?? 0);
} catch {
return 0;
}
}
function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
const packedSplats = splatMesh.packedSplats;
if (!packedSplats) {
const splats = getSplatSampleSource(splatMesh);
if (!splats) {
return getSplatWorldBounds(splatMesh);
}
const totalSplats = Math.max(0, packedSplats?.numSplats || 0);
const totalSplats = getSplatSampleSourceCount(splats);
if (totalSplats < 32) {
return getSplatWorldBounds(splatMesh);
}
@ -362,7 +386,7 @@ function getRobustSplatWorldBounds(splatMesh: SparkSplatMesh) {
const zs: number[] = [];
for (let index = 0; index < totalSplats; index += sampleStep) {
const splat = packedSplats.getSplat(index);
const splat = splats.getSplat(index);
if (splat.opacity < 0.025) continue;
point.copy(splat.center).applyMatrix4(splatMesh.matrixWorld);
@ -1374,20 +1398,23 @@ function WorldSplatScene({
const markReady = () => {
if (cancelled || hasAppliedInitialViewRef.current) return;
if (!isSplatRenderable()) return;
clearReadyTimers();
let nextCameraPose: WorldCameraPose | null = null;
try {
lastCameraPoseRef.current = positionCameraInsideWorld({
nextCameraPose = positionCameraInsideWorld({
camera,
controls: orbitControlsRef.current,
firstPerson,
groundPlaneOffset,
splatMesh,
});
} catch (error) {
console.warn("[WorldSplatScene] Failed to position camera for SPZ", error);
lastCameraPoseRef.current = null;
} catch {
nextCameraPose = null;
}
if (!nextCameraPose) return;
clearReadyTimers();
lastCameraPoseRef.current = nextCameraPose;
hasAppliedInitialViewRef.current = true;
onLoadStateChange?.("ready");
@ -1593,8 +1620,6 @@ function WorldSplatScene({
return (
<>
<color args={["#020617"]} attach="background" />
<fog attach="fog" args={["#020617", 10, 45]} />
<ambientLight intensity={1.6} />
<directionalLight intensity={1.25} position={[6, 10, 4]} />
{groundPlaneOffset !== null ? (
@ -2006,13 +2031,23 @@ function WorldSplatViewer({
}
return (
<div className="relative h-full w-full">
<div className="relative h-full w-full overflow-hidden bg-zinc-100">
{previewImageUrl ? (
<img
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-35 blur-md saturate-90"
decoding="async"
src={previewImageUrl}
/>
) : null}
<Canvas
camera={{ position: [0, 1.6, 4], fov: 55 }}
className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.95),_rgba(1,1,1,1)_68%)]"
className="relative h-full w-full bg-transparent"
dpr={minimalFrame ? [1, 1.5] : [1, 2]}
frameloop="always"
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: Boolean(onCaptureReady),

View File

@ -604,6 +604,7 @@ function getFlowBoardImageDimensions(file: File) {
async function uploadFlowBoardImageFile(file: File) {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {

View File

@ -211,6 +211,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
const conditions = [
eq(aiModel.isEnabled, false),
eq(aiModel.excludeFromFailover, false),
eq(aiModel.excludeFromHealthBan, false),
];
if (params?.modelId) {
@ -392,6 +393,7 @@ export async function recordAiModelHealthFailure(params: {
displayName: aiModel.displayName,
isEnabled: aiModel.isEnabled,
excludeFromFailover: aiModel.excludeFromFailover,
excludeFromHealthBan: aiModel.excludeFromHealthBan,
healthStatus: aiModel.healthStatus,
healthConsecutiveFailures: aiModel.healthConsecutiveFailures,
healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak,
@ -414,6 +416,7 @@ export async function recordAiModelHealthFailure(params: {
const shouldAutoDisable =
classification.counted &&
!existing.excludeFromFailover &&
!existing.excludeFromHealthBan &&
((isObservation && existing.isEnabled) ||
(existing.healthStatus !== "auto_disabled" &&
nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD));

View File

@ -1,8 +1,110 @@
const DEFAULT_MAX_RETRIES = 3;
const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3;
const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024;
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
const RETRYABLE_UPLOAD_STATUSES = new Set([
408,
425,
429,
500,
502,
503,
504,
]);
const DIRECT_UPLOAD_IMAGE_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
]);
let activeUploadCount = 0;
const uploadQueue: Array<() => void> = [];
let directUploadSuppressedUntil = 0;
type UploadFormFile = {
blob: Blob;
filename: string;
contentType: string;
purpose: string;
};
type DirectUploadPart = {
partNumber: number;
url: string;
};
type DirectUploadInitiateResponse =
| {
mode: "single";
key: string;
url: string;
headers?: Record<string, string>;
}
| {
mode: "multipart";
key: string;
uploadId: string;
partSize: number;
partCount: number;
parts: DirectUploadPart[];
};
function createAbortError() {
const error = new Error("Upload aborted");
error.name = "AbortError";
return error;
}
function sleep(ms: number, signal?: AbortSignal) {
if (signal?.aborted) {
return Promise.reject(createAbortError());
}
return new Promise<void>((resolve, reject) => {
const timeout = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
const onAbort = () => {
globalThis.clearTimeout(timeout);
reject(createAbortError());
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
function acquireUploadSlot() {
return new Promise<void>((resolve) => {
const start = () => {
activeUploadCount += 1;
resolve();
};
if (activeUploadCount < MAX_CONCURRENT_UPLOADS) {
start();
} else {
uploadQueue.push(start);
}
});
}
async function withUploadSlot<T>(task: () => Promise<T>): Promise<T> {
await acquireUploadSlot();
try {
return await task();
} finally {
activeUploadCount = Math.max(0, activeUploadCount - 1);
uploadQueue.shift()?.();
}
}
function parseRetryAfterMs(value: string | null): number | null {
@ -23,6 +125,298 @@ function computeBackoffMs(attempt: number, retryAfterMs: number | null) {
return exp + jitter;
}
function isRetryableUploadStatus(status: number) {
return RETRYABLE_UPLOAD_STATUSES.has(status);
}
function isAbortError(error: unknown, signal?: AbortSignal) {
return (
signal?.aborted ||
(error instanceof Error && error.name === "AbortError")
);
}
function cloneFormData(formData: FormData) {
const copy = new FormData();
formData.forEach((value, key) => {
if (typeof value === "string") {
copy.append(key, value);
return;
}
const filename =
typeof File !== "undefined" && value instanceof File
? value.name
: undefined;
if (filename) {
copy.append(key, value, filename);
} else {
copy.append(key, value);
}
});
return copy;
}
function getUploadFormFile(formData: FormData): UploadFormFile | null {
const value = formData.get("file");
if (!(value instanceof Blob)) {
return null;
}
const filename =
typeof File !== "undefined" && value instanceof File && value.name
? value.name
: `upload-${Date.now()}`;
const contentType = value.type || "application/octet-stream";
const purposeValue = formData.get("purpose");
return {
blob: value,
filename,
contentType,
purpose: typeof purposeValue === "string" ? purposeValue : "",
};
}
function shouldTryDirectUpload(file: UploadFormFile) {
if (Date.now() < directUploadSuppressedUntil) return false;
if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) {
return false;
}
if (!DIRECT_UPLOAD_IMAGE_TYPES.has(file.contentType.toLowerCase())) {
return false;
}
if (file.purpose === "agent-workflow-preview") {
return false;
}
return (
file.purpose === "canvas-upload" ||
file.blob.size >= DIRECT_UPLOAD_MIN_BYTES
);
}
function suppressDirectUploadTemporarily() {
directUploadSuppressedUntil = Date.now() + DIRECT_UPLOAD_SUPPRESS_MS;
}
async function readErrorPayload(response: Response) {
try {
const payload = await response.clone().json();
return payload?.error || payload?.details || response.statusText;
} catch {
return response.statusText;
}
}
async function postDirectUploadAction<T>(
payload: Record<string, unknown>,
signal?: AbortSignal
): Promise<T> {
const response = await fetch("/api/files/direct-upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal,
});
if (!response.ok) {
throw new Error(String(await readErrorPayload(response)));
}
return (await response.json()) as T;
}
async function fetchDirectPartWithRetry(
url: string,
init: RequestInit,
maxRetries: number,
signal?: AbortSignal
) {
for (let attempt = 0; ; attempt += 1) {
try {
const response = await fetch(url, {
...init,
signal,
});
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
}
await sleep(computeBackoffMs(attempt, null), signal);
} catch (error) {
if (isAbortError(error, signal) || attempt >= maxRetries) {
throw error;
}
await sleep(computeBackoffMs(attempt, null), signal);
}
}
}
async function uploadMultipartParts(params: {
file: UploadFormFile;
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>;
maxRetries: number;
signal?: AbortSignal;
}) {
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
let nextIndex = 0;
async function worker() {
for (;;) {
const part = params.upload.parts[nextIndex];
nextIndex += 1;
if (!part) return;
const start = (part.partNumber - 1) * params.upload.partSize;
const end = Math.min(start + params.upload.partSize, params.file.blob.size);
const body = params.file.blob.slice(start, end);
const response = await fetchDirectPartWithRetry(
part.url,
{
method: "PUT",
body,
},
params.maxRetries,
params.signal
);
if (!response.ok) {
throw new Error(`Direct multipart upload failed: ${response.status}`);
}
const etag = response.headers.get("ETag") || response.headers.get("etag");
if (!etag) {
throw new Error("Storage did not expose ETag; please allow ETag in bucket CORS");
}
uploadedParts.push({
ETag: etag,
PartNumber: part.partNumber,
});
}
}
await Promise.all(
Array.from(
{ length: Math.min(DIRECT_UPLOAD_PART_CONCURRENCY, params.upload.parts.length) },
() => worker()
)
);
return uploadedParts.sort((a, b) => a.PartNumber - b.PartNumber);
}
async function fetchDirectUpload(
formData: FormData,
maxRetries: number,
signal?: AbortSignal
): Promise<Response | null> {
const file = getUploadFormFile(formData);
if (!file || !shouldTryDirectUpload(file)) {
return null;
}
let multipartUpload:
| Extract<DirectUploadInitiateResponse, { mode: "multipart" }>
| null = null;
try {
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
{
action: "initiate",
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
purpose: file.purpose,
},
signal
);
if (upload.mode === "single") {
const response = await fetchDirectPartWithRetry(
upload.url,
{
method: "PUT",
headers: upload.headers || {
"Content-Type": file.contentType,
},
body: file.blob,
},
maxRetries,
signal
);
if (!response.ok) {
throw new Error(`Direct upload failed: ${response.status}`);
}
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
action: "complete",
key: upload.key,
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
purpose: file.purpose,
},
signal
);
return Response.json(payload);
}
multipartUpload = upload;
const parts = await uploadMultipartParts({
file,
upload,
maxRetries,
signal,
});
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
action: "complete",
key: upload.key,
uploadId: upload.uploadId,
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
parts,
purpose: file.purpose,
},
signal
);
return Response.json(payload);
} catch (error) {
if (multipartUpload && !isAbortError(error, signal)) {
postDirectUploadAction(
{
action: "abort",
key: multipartUpload.key,
uploadId: multipartUpload.uploadId,
},
signal
).catch(() => {});
}
if (!isAbortError(error, signal)) {
console.warn("[Upload] Direct upload failed; falling back to server upload", error);
suppressDirectUploadTemporarily();
return null;
}
throw error;
}
}
export async function fetchUploadWithRetry(
formData: FormData,
options?: {
@ -32,29 +426,49 @@ export async function fetchUploadWithRetry(
) {
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
for (let attempt = 0; ; attempt++) {
const response = await fetch("/api/files/upload", {
method: "POST",
body: formData,
signal: options?.signal,
});
if (response.status !== 429 || attempt >= maxRetries) {
return response;
return withUploadSlot(async () => {
const directResponse = await fetchDirectUpload(
formData,
maxRetries,
options?.signal
);
if (directResponse) {
return directResponse;
}
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
for (let attempt = 0; ; attempt++) {
try {
const payload = await response.clone().json();
const retryAfterSec = Number(payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = Math.round(retryAfterSec * 1000);
}
} catch {}
}
const response = await fetch("/api/files/upload", {
method: "POST",
body: cloneFormData(formData),
signal: options?.signal,
});
const delayMs = computeBackoffMs(attempt, retryAfterMs);
await sleep(delayMs);
}
if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
}
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
try {
const payload = await response.clone().json();
const retryAfterSec = Number(payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = Math.round(retryAfterSec * 1000);
}
} catch {}
}
const delayMs = computeBackoffMs(attempt, retryAfterMs);
await sleep(delayMs, options?.signal);
} catch (error) {
if (isAbortError(error, options?.signal) || attempt >= maxRetries) {
throw error;
}
const delayMs = computeBackoffMs(attempt, null);
await sleep(delayMs, options?.signal);
}
}
});
}

View File

@ -106,6 +106,10 @@ const runMigrate = async () => {
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromFailover" boolean NOT NULL DEFAULT false;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromHealthBan" boolean NOT NULL DEFAULT false;
`);
await db.execute(sql`
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "isDefaultTextEditModel" boolean NOT NULL DEFAULT false;

View File

@ -0,0 +1,2 @@
ALTER TABLE "AiModel"
ADD COLUMN IF NOT EXISTS "excludeFromHealthBan" boolean NOT NULL DEFAULT false;

View File

@ -488,8 +488,15 @@
{
"idx": 69,
"version": "7",
"when": 1779849000000,
"tag": "0083_add_ai_model_health_ban_exclusion",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1779797137842,
"tag": "0083_optimize_featured_home_query",
"tag": "0084_optimize_featured_home_query",
"breakpoints": true
}
]

View File

@ -4067,6 +4067,7 @@ export async function createAiModel({
fallbackModelId1,
fallbackModelId2,
excludeFromFailover,
excludeFromHealthBan,
imageGenConfig,
capabilities,
isAgentModel,
@ -4088,6 +4089,7 @@ export async function createAiModel({
fallbackModelId1?: string;
fallbackModelId2?: string;
excludeFromFailover?: boolean;
excludeFromHealthBan?: boolean;
imageGenConfig?: ImageGenConfig;
capabilities?: Record<string, unknown> | null;
isAgentModel?: boolean;
@ -4138,6 +4140,7 @@ export async function createAiModel({
fallbackModelId1: fallbackModelId1 || null,
fallbackModelId2: fallbackModelId2 || null,
excludeFromFailover: excludeFromFailover ?? false,
excludeFromHealthBan: excludeFromHealthBan ?? false,
imageGenConfig: imageGenConfig || null,
defaultParams,
capabilities:
@ -4171,6 +4174,7 @@ export async function updateAiModel({
fallbackModelId1,
fallbackModelId2,
excludeFromFailover,
excludeFromHealthBan,
imageGenConfig,
capabilities,
isAgentModel,
@ -4193,6 +4197,7 @@ export async function updateAiModel({
fallbackModelId1?: string;
fallbackModelId2?: string;
excludeFromFailover?: boolean;
excludeFromHealthBan?: boolean;
imageGenConfig?: ImageGenConfig;
capabilities?: Record<string, unknown> | null;
isAgentModel?: boolean;
@ -4260,6 +4265,21 @@ export async function updateAiModel({
if (fallbackModelId1 !== undefined) updateData.fallbackModelId1 = fallbackModelId1 || null;
if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null;
if (excludeFromFailover !== undefined) updateData.excludeFromFailover = excludeFromFailover;
if (excludeFromHealthBan !== undefined) {
updateData.excludeFromHealthBan = excludeFromHealthBan;
if (excludeFromHealthBan) {
updateData.healthStatus = "healthy";
updateData.healthConsecutiveFailures = 0;
updateData.healthLastEventType = null;
updateData.healthLastError = null;
updateData.healthLastFailureAt = null;
updateData.healthAutoDisabledAt = null;
updateData.healthAutoDisabledReason = null;
updateData.healthRecoverySuccessStreak = 0;
updateData.healthObservationStartedAt = null;
updateData.healthUpdatedAt = new Date();
}
}
// 🆕 图片生成配置
if (imageGenConfig !== undefined) updateData.imageGenConfig = imageGenConfig || null;
if (

View File

@ -335,6 +335,7 @@ export const aiModel = pgTable("AiModel", {
fallbackModelId1: varchar("fallbackModelId1", { length: 128 }), // 备用模型 ID 1
fallbackModelId2: varchar("fallbackModelId2", { length: 128 }), // 备用模型 ID 2
excludeFromFailover: boolean("excludeFromFailover").notNull().default(false), // 不参与故障迁移
excludeFromHealthBan: boolean("excludeFromHealthBan").notNull().default(false), // 不参与故障自动封禁池
healthStatus: varchar("healthStatus", {
enum: ["healthy", "degraded", "auto_disabled"],
})

View File

@ -65,7 +65,7 @@ function normalizePublicUrl(publicUrl?: string): string | undefined {
}
// 获取文件的 Content-Type
function getContentType(filename: string): string {
export function getContentType(filename: string): string {
const ext = filename.toLowerCase().split(".").pop();
const types: Record<string, string> = {
jpg: "image/jpeg",
@ -102,7 +102,7 @@ function getContentType(filename: string): string {
return types[ext ?? ""] ?? "application/octet-stream";
}
function createS3Client(config: S3UploadConfig): S3Client {
export function createS3Client(config: S3UploadConfig): S3Client {
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
const lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 =
@ -120,6 +120,75 @@ function createS3Client(config: S3UploadConfig): S3Client {
});
}
export function buildStorageObjectKey(filename: string, prefix = "uploads"): string {
const timestamp = Date.now();
const randomString = Math.random().toString(36).slice(2, 8);
const cleanExt = String(filename.split(".").pop() || "bin")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "")
.slice(0, 16) || "bin";
const cleanPrefix = prefix.replace(/^\/+|\/+$/g, "") || "uploads";
return `${cleanPrefix}/${timestamp}-${randomString}.${cleanExt}`;
}
export function buildS3PublicUrl(config: S3UploadConfig, key: string): string {
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
const normalizedPublicUrl = normalizePublicUrl(config.publicUrl);
const cleanKey = key.replace(/^\/+/, "");
if (normalizedPublicUrl) {
return `${normalizedPublicUrl}/${cleanKey}`;
}
return `${normalizedEndpoint}/${config.bucket}/${cleanKey}`;
}
export async function resolveS3UploadTarget(): Promise<
| {
config: S3UploadConfig;
storageConfig?: StorageConfig;
migrationConfig: OssImageMigrationConfig;
}
| null
> {
let migrationConfig = defaultOssImageMigrationConfig;
const migrationSetting = await getSystemSetting({
key: OSS_MIGRATION_CONFIG_KEY,
}).catch(() => undefined);
migrationConfig = normalizeOssImageMigrationConfig(migrationSetting?.value);
const selectedStorageConfig = await resolvePreferredUploadStorageConfig(
migrationConfig
);
const dbS3Config = toDbS3UploadConfig(selectedStorageConfig);
if (dbS3Config) {
return {
config: dbS3Config,
storageConfig: selectedStorageConfig,
migrationConfig,
};
}
const envS3Config = buildEnvS3Config();
if (envS3Config) {
return {
config: envS3Config,
migrationConfig,
};
}
return null;
}
export async function maybeEnqueueStorageMigrationTask(params: {
uploaded: { url: string; pathname: string };
storageConfig?: StorageConfig;
migrationConfig: OssImageMigrationConfig;
}) {
await maybeEnqueueAliyunMigrationTask(params);
}
// S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3(
filename: string,
@ -136,10 +205,7 @@ async function uploadToS3(
const normalizedPublicUrl = normalizePublicUrl(publicUrl);
// 生成唯一的文件名
const timestamp = Date.now();
const randomString = Math.random().toString(36).slice(2, 8);
const ext = filename.split(".").pop();
const key = `uploads/${timestamp}-${randomString}.${ext}`;
const key = buildStorageObjectKey(filename);
const contentType = getContentType(filename);
@ -162,14 +228,10 @@ async function uploadToS3(
// 返回文件的公开 URL
// MinIO 的公开 URL 格式通常是: http://endpoint:port/bucket/key
let fileUrl: string;
if (normalizedPublicUrl) {
// 如果用户配置了自定义公开 URL,使用它
fileUrl = `${normalizedPublicUrl}/${key}`;
} else {
// 否则使用 endpoint + bucket + key
fileUrl = `${normalizedEndpoint}/${bucket}/${key}`;
}
const fileUrl = buildS3PublicUrl(
{ ...config, endpoint: normalizedEndpoint, publicUrl: normalizedPublicUrl },
key
);
logger.debug(`[S3 Upload] File URL: ${fileUrl}`);

View File

@ -59,74 +59,13 @@ export function pickBestWorldLabsSplatUrl(
return pickBestWorldLabsSplatEntry(spzUrls)?.[1] || null;
}
// World Labs exports multiple SPZ tiers (for example 100k / 500k / full).
// For embedded previews we bias hard toward the lightweight tier so users see
// the world quickly, then can switch to imagery / mesh for detail if needed.
const INLINE_WORLDLABS_SPLAT_TARGET_SCORE = 100_000;
function pickClosestResolutionEntry(
entries: Array<[string, string]>,
targetScore: number
) {
if (!entries.length) return null;
const scoredEntries = entries
.map((entry) => ({
entry,
score: parseResolutionScore(entry[0]),
}))
.filter(
(item) =>
Number.isFinite(item.score) &&
item.score > 0 &&
item.score !== Number.MAX_SAFE_INTEGER
);
if (!scoredEntries.length) {
return entries[0] || null;
}
return scoredEntries
.slice()
.sort((left, right) => {
const distanceDelta =
Math.abs(left.score - targetScore) - Math.abs(right.score - targetScore);
if (distanceDelta !== 0) return distanceDelta;
return left.score - right.score;
})[0]?.entry || null;
}
function getResolutionDistance(entry: [string, string], targetScore: number) {
const score = parseResolutionScore(entry[0]);
if (
!Number.isFinite(score) ||
score <= 0 ||
score === Number.MAX_SAFE_INTEGER
) {
return Number.MAX_SAFE_INTEGER;
}
return Math.abs(score - targetScore);
}
function compareInlineSplatEntries(
left: [string, string],
right: [string, string]
) {
const leftDistance = getResolutionDistance(
left,
INLINE_WORLDLABS_SPLAT_TARGET_SCORE
);
const rightDistance = getResolutionDistance(
right,
INLINE_WORLDLABS_SPLAT_TARGET_SCORE
);
if (leftDistance !== rightDistance) return leftDistance - rightDistance;
const leftScore = parseResolutionScore(left[0]);
const rightScore = parseResolutionScore(right[0]);
if (leftScore !== rightScore) return leftScore - rightScore;
return left[0].localeCompare(right[0], "en");
// World Labs exposes multiple SPZ tiers; the canvas should show the highest
// quality available first and keep lower tiers only as load fallbacks.
return compareSplatEntries(left, right);
}
const KNOWN_ARCHIVED_ASSET_HOSTS = [
@ -204,17 +143,11 @@ export function pickPreferredInlineWorldLabsSplatEntry(
spzUrls?: Record<string, string> | null
) {
const archivedEntries = getArchivedWorldLabsSplatEntries(spzUrls);
if (archivedEntries.length > 0) {
return pickClosestResolutionEntry(
archivedEntries,
INLINE_WORLDLABS_SPLAT_TARGET_SCORE
);
}
return pickClosestResolutionEntry(
getSortedWorldLabsSplatEntries(spzUrls),
INLINE_WORLDLABS_SPLAT_TARGET_SCORE
);
const sourceEntries =
archivedEntries.length > 0
? archivedEntries
: getSortedWorldLabsSplatEntries(spzUrls);
return sourceEntries.slice().sort(compareInlineSplatEntries)[0] || null;
}
export function pickPreferredInlineWorldLabsSplatUrl(

View File

@ -50,6 +50,7 @@
"@ai-sdk/react": "2.0.26",
"@ai-sdk/xai": "2.0.13",
"@aws-sdk/client-s3": "^3.679.0",
"@aws-sdk/s3-request-presigner": "^3.958.0",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/state": "^6.5.0",

120
pnpm-lock.yaml generated
View File

@ -40,6 +40,9 @@ importers:
'@aws-sdk/client-s3':
specifier: ^3.679.0
version: 3.958.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.958.0
version: 3.958.0
'@codemirror/lang-javascript':
specifier: ^6.2.2
version: 6.2.3
@ -661,6 +664,10 @@ packages:
resolution: {integrity: sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==}
engines: {node: '>=18.0.0'}
'@aws-sdk/s3-request-presigner@3.958.0':
resolution: {integrity: sha512-bFKsofead/fl3lyhdES+aNo+MZ+qv1ixSPSsF8O1oj6/KgGE0t1UH9AHw2vPq6iSQMTeEuyV0F5pC+Ns40kBgA==}
engines: {node: '>=18.0.0'}
'@aws-sdk/signature-v4-multi-region@3.957.0':
resolution: {integrity: sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==}
engines: {node: '>=18.0.0'}
@ -673,6 +680,10 @@ packages:
resolution: {integrity: sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==}
engines: {node: '>=18.0.0'}
'@aws-sdk/types@3.973.9':
resolution: {integrity: sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-arn-parser@3.957.0':
resolution: {integrity: sha512-Aj6m+AyrhWyg8YQ4LDPg2/gIfGHCEcoQdBt5DeSFogN5k9mmJPOJ+IAmNSWmWRjpOxEy6eY813RNDI6qS97M0g==}
engines: {node: '>=18.0.0'}
@ -681,6 +692,10 @@ packages:
resolution: {integrity: sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==}
engines: {node: '>=18.0.0'}
'@aws-sdk/util-format-url@3.957.0':
resolution: {integrity: sha512-Yyo/tlc0iGFGTPPkuxub1uRAv6XrnVnvSNjslZh5jIYA8GZoeEFPgJa3Qdu0GUS/YwoK8GOLnnaL9h/eH5LDJQ==}
engines: {node: '>=18.0.0'}
'@aws-sdk/util-locate-window@3.957.0':
resolution: {integrity: sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==}
engines: {node: '>=18.0.0'}
@ -2961,6 +2976,10 @@ packages:
resolution: {integrity: sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.24.4':
resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.2.7':
resolution: {integrity: sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==}
engines: {node: '>=18.0.0'}
@ -3073,6 +3092,10 @@ packages:
resolution: {integrity: sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.4.4':
resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==}
engines: {node: '>=18.0.0'}
'@smithy/smithy-client@4.10.2':
resolution: {integrity: sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==}
engines: {node: '>=18.0.0'}
@ -3081,6 +3104,10 @@ packages:
resolution: {integrity: sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.14.2':
resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==}
engines: {node: '>=18.0.0'}
'@smithy/url-parser@4.2.7':
resolution: {integrity: sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==}
engines: {node: '>=18.0.0'}
@ -6786,13 +6813,13 @@ snapshots:
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.957.0
'@aws-sdk/types': 3.973.9
tslib: 2.8.1
'@aws-crypto/crc32c@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.957.0
'@aws-sdk/types': 3.973.9
tslib: 2.8.1
'@aws-crypto/sha1-browser@5.2.0':
@ -6826,7 +6853,7 @@ snapshots:
'@aws-crypto/util@5.2.0':
dependencies:
'@aws-sdk/types': 3.957.0
'@aws-sdk/types': 3.973.9
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
@ -6918,7 +6945,7 @@ snapshots:
'@smithy/node-http-handler': 4.4.7
'@smithy/protocol-http': 5.3.7
'@smithy/smithy-client': 4.10.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/url-parser': 4.2.7
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
@ -6951,7 +6978,7 @@ snapshots:
'@aws-sdk/crc64-nvme@3.957.0':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.957.0':
@ -6959,7 +6986,7 @@ snapshots:
'@aws-sdk/core': 3.957.0
'@aws-sdk/types': 3.957.0
'@smithy/property-provider': 4.2.7
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.957.0':
@ -6971,7 +6998,7 @@ snapshots:
'@smithy/property-provider': 4.2.7
'@smithy/protocol-http': 5.3.7
'@smithy/smithy-client': 4.10.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/util-stream': 4.5.8
tslib: 2.8.1
@ -6989,7 +7016,7 @@ snapshots:
'@smithy/credential-provider-imds': 4.2.7
'@smithy/property-provider': 4.2.7
'@smithy/shared-ini-file-loader': 4.4.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
@ -7002,7 +7029,7 @@ snapshots:
'@smithy/property-provider': 4.2.7
'@smithy/protocol-http': 5.3.7
'@smithy/shared-ini-file-loader': 4.4.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
@ -7030,7 +7057,7 @@ snapshots:
'@aws-sdk/types': 3.957.0
'@smithy/property-provider': 4.2.7
'@smithy/shared-ini-file-loader': 4.4.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.958.0':
@ -7041,7 +7068,7 @@ snapshots:
'@aws-sdk/types': 3.957.0
'@smithy/property-provider': 4.2.7
'@smithy/shared-ini-file-loader': 4.4.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
@ -7053,7 +7080,7 @@ snapshots:
'@aws-sdk/types': 3.957.0
'@smithy/property-provider': 4.2.7
'@smithy/shared-ini-file-loader': 4.4.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
@ -7180,7 +7207,7 @@ snapshots:
'@smithy/node-http-handler': 4.4.7
'@smithy/protocol-http': 5.3.7
'@smithy/smithy-client': 4.10.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/url-parser': 4.2.7
'@smithy/util-base64': 4.3.0
'@smithy/util-body-length-browser': 4.2.0
@ -7203,13 +7230,24 @@ snapshots:
'@smithy/types': 4.11.0
tslib: 2.8.1
'@aws-sdk/s3-request-presigner@3.958.0':
dependencies:
'@aws-sdk/signature-v4-multi-region': 3.957.0
'@aws-sdk/types': 3.957.0
'@aws-sdk/util-format-url': 3.957.0
'@smithy/middleware-endpoint': 4.4.1
'@smithy/protocol-http': 5.3.7
'@smithy/smithy-client': 4.10.2
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.957.0':
dependencies:
'@aws-sdk/middleware-sdk-s3': 3.957.0
'@aws-sdk/types': 3.957.0
'@smithy/protocol-http': 5.3.7
'@smithy/signature-v4': 5.3.7
'@smithy/types': 4.11.0
'@smithy/signature-v4': 5.4.4
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/token-providers@3.958.0':
@ -7219,14 +7257,19 @@ snapshots:
'@aws-sdk/types': 3.957.0
'@smithy/property-provider': 4.2.7
'@smithy/shared-ini-file-loader': 4.4.2
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/types@3.957.0':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/types@3.973.9':
dependencies:
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/util-arn-parser@3.957.0':
@ -7241,6 +7284,13 @@ snapshots:
'@smithy/util-endpoints': 3.2.7
tslib: 2.8.1
'@aws-sdk/util-format-url@3.957.0':
dependencies:
'@aws-sdk/types': 3.957.0
'@smithy/querystring-builder': 4.2.7
'@smithy/types': 4.14.2
tslib: 2.8.1
'@aws-sdk/util-locate-window@3.957.0':
dependencies:
tslib: 2.8.1
@ -9310,7 +9360,7 @@ snapshots:
'@smithy/abort-controller@4.2.7':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@smithy/chunked-blob-reader-native@4.2.1':
@ -9344,18 +9394,24 @@ snapshots:
'@smithy/uuid': 1.1.0
tslib: 2.8.1
'@smithy/core@3.24.4':
dependencies:
'@aws-crypto/crc32': 5.2.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@smithy/credential-provider-imds@4.2.7':
dependencies:
'@smithy/node-config-provider': 4.3.7
'@smithy/property-provider': 4.2.7
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/url-parser': 4.2.7
tslib: 2.8.1
'@smithy/eventstream-codec@4.2.7':
dependencies:
'@aws-crypto/crc32': 5.2.0
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/util-hex-encoding': 4.2.0
tslib: 2.8.1
@ -9379,7 +9435,7 @@ snapshots:
'@smithy/eventstream-serde-universal@4.2.7':
dependencies:
'@smithy/eventstream-codec': 4.2.7
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@smithy/fetch-http-handler@5.3.8':
@ -9486,7 +9542,7 @@ snapshots:
'@smithy/property-provider@4.2.7':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@smithy/protocol-http@5.3.7':
@ -9496,35 +9552,41 @@ snapshots:
'@smithy/querystring-builder@4.2.7':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/util-uri-escape': 4.2.0
tslib: 2.8.1
'@smithy/querystring-parser@4.2.7':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@smithy/service-error-classification@4.2.7':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/shared-ini-file-loader@4.4.2':
dependencies:
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
tslib: 2.8.1
'@smithy/signature-v4@5.3.7':
dependencies:
'@smithy/is-array-buffer': 4.2.0
'@smithy/protocol-http': 5.3.7
'@smithy/types': 4.11.0
'@smithy/types': 4.14.2
'@smithy/util-hex-encoding': 4.2.0
'@smithy/util-middleware': 4.2.7
'@smithy/util-uri-escape': 4.2.0
'@smithy/util-utf8': 4.2.0
tslib: 2.8.1
'@smithy/signature-v4@5.4.4':
dependencies:
'@smithy/core': 3.24.4
'@smithy/types': 4.14.2
tslib: 2.8.1
'@smithy/smithy-client@4.10.2':
dependencies:
'@smithy/core': 3.20.0
@ -9539,6 +9601,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@smithy/types@4.14.2':
dependencies:
tslib: 2.8.1
'@smithy/url-parser@4.2.7':
dependencies:
'@smithy/querystring-parser': 4.2.7

View File

@ -107,7 +107,11 @@ export async function proxy(request: NextRequest) {
{ status: 429, headers: { "Retry-After": String(retryAfter) } }
);
}
} else if (pathname === "/api/files/upload" && request.method === "POST") {
} else if (
(pathname === "/api/files/upload" ||
pathname === "/api/files/direct-upload") &&
request.method === "POST"
) {
const token = await resolveToken();
const userId =
typeof token?.sub === "string" && token.sub.length > 0

View File

@ -0,0 +1,222 @@
import { config as loadEnv } from "dotenv";
import {
GetBucketCorsCommand,
PutBucketCorsCommand,
S3Client,
type CORSRule,
} from "@aws-sdk/client-s3";
import { and, eq } from "drizzle-orm";
loadEnv({ path: ".env.local" });
loadEnv();
type S3UploadConfig = {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
};
const DEFAULT_ORIGINS = [
"https://ai.yydd.ai",
"https://yydd.ai",
"https://bbyy.ai",
"https://goyingup.chat",
"http://localhost:3000",
"http://localhost:3001",
];
function normalizeEndpoint(endpoint: string): string {
const trimmed = endpoint.trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (/aliyuncs\.com/i.test(trimmed)) {
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "");
return `https://${withoutProtocol}`;
}
return trimmed;
}
function createS3Client(config: S3UploadConfig) {
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
const lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 =
lowerEndpoint.includes("aliyuncs.com") ||
lowerEndpoint.includes("amazonaws.com");
return new S3Client({
endpoint: normalizedEndpoint,
region: config.region || "auto",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: !isCloudS3,
});
}
function toS3Config(value: unknown): S3UploadConfig | null {
const raw = value as Record<string, unknown> | null | undefined;
const endpoint = raw?.endpoint ? normalizeEndpoint(String(raw.endpoint)) : "";
const bucket = raw?.bucket ? String(raw.bucket).trim() : "";
const accessKeyId = raw?.accessKeyId ? String(raw.accessKeyId).trim() : "";
const secretAccessKey = raw?.secretAccessKey
? String(raw.secretAccessKey).trim()
: "";
if (
!raw ||
raw.type !== "s3" ||
raw.isEnabled !== true ||
!endpoint ||
!bucket ||
!accessKeyId ||
!secretAccessKey
) {
return null;
}
return {
endpoint,
region: String(raw.region || "auto").trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
};
}
function readEnvS3Config(): S3UploadConfig | null {
const endpoint = process.env.S3_ENDPOINT
? normalizeEndpoint(process.env.S3_ENDPOINT)
: "";
const bucket = process.env.S3_BUCKET?.trim() || "";
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() || "";
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() || "";
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
return null;
}
return {
endpoint,
region: process.env.S3_REGION?.trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
};
}
async function resolveS3Config() {
const [{ db }, { storageConfig }] = await Promise.all([
import("@/lib/db"),
import("@/lib/db/schema"),
]);
const [defaultConfig] = await db
.select()
.from(storageConfig)
.where(
and(
eq(storageConfig.type, "s3"),
eq(storageConfig.isDefault, true),
eq(storageConfig.isEnabled, true)
)
)
.limit(1);
return toS3Config(defaultConfig) || readEnvS3Config();
}
function readOrigins() {
const raw = process.env.DIRECT_UPLOAD_CORS_ORIGINS || "";
const origins = raw
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
return origins.length > 0 ? origins : DEFAULT_ORIGINS;
}
function normalizeRule(rule: CORSRule): CORSRule {
return {
AllowedHeaders: Array.from(new Set(rule.AllowedHeaders || [])),
AllowedMethods: Array.from(new Set(rule.AllowedMethods || [])),
AllowedOrigins: Array.from(new Set(rule.AllowedOrigins || [])),
ExposeHeaders: Array.from(new Set(rule.ExposeHeaders || [])),
MaxAgeSeconds: rule.MaxAgeSeconds,
};
}
function isDirectUploadRule(rule: CORSRule, origins: string[]) {
const methods = new Set(rule.AllowedMethods || []);
const allowedOrigins = new Set(rule.AllowedOrigins || []);
return (
methods.has("PUT") &&
methods.has("POST") &&
origins.some((origin) => allowedOrigins.has(origin))
);
}
async function main() {
const config = await resolveS3Config();
if (!config) {
console.log("[direct-upload-cors] skip: no S3-compatible upload target");
return;
}
const origins = readOrigins();
const client = createS3Client(config);
const directRule: CORSRule = {
AllowedOrigins: origins,
AllowedMethods: ["GET", "PUT", "POST", "HEAD"],
AllowedHeaders: ["*"],
ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"],
MaxAgeSeconds: 3600,
};
let existingRules: CORSRule[] = [];
try {
const current = await client.send(
new GetBucketCorsCommand({ Bucket: config.bucket })
);
existingRules = current.CORSRules || [];
} catch (error) {
console.warn(
"[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:",
error instanceof Error ? error.message : String(error)
);
}
const mergedRules = [
directRule,
...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)),
]
.slice(0, 100)
.map(normalizeRule);
await client.send(
new PutBucketCorsCommand({
Bucket: config.bucket,
CORSConfiguration: {
CORSRules: mergedRules,
},
})
);
console.log("[direct-upload-cors] configured", {
bucket: config.bucket,
origins,
ruleCount: mergedRules.length,
});
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error("[direct-upload-cors] failed:", error);
process.exit(1);
});