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:
commit
a223d931a6
@ -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 ===
|
||||
|
||||
354
app/(chat)/api/files/direct-upload/route.ts
Normal file
354
app/(chat)/api/files/direct-upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "AiModel"
|
||||
ADD COLUMN IF NOT EXISTS "excludeFromHealthBan" boolean NOT NULL DEFAULT false;
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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"],
|
||||
})
|
||||
|
||||
@ -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}`);
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
120
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
6
proxy.ts
6
proxy.ts
@ -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
|
||||
|
||||
222
scripts/storage/configure-direct-upload-cors.ts
Normal file
222
scripts/storage/configure-direct-upload-cors.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user