fix grsai image request and generation preview
This commit is contained in:
parent
edae2f83c2
commit
016d588559
39
app/particle-preview/page.tsx
Normal file
39
app/particle-preview/page.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
||||
|
||||
export default function ParticlePreviewPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f7f7f7] px-10 py-10 text-[#202124]">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-8">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">生成占位粒子预览</h1>
|
||||
<p className="mt-2 text-[14px] text-[#7d838b]">
|
||||
单独预览旋转粒子、3D 视差和进度条状态。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[420px_1fr]">
|
||||
<section>
|
||||
<div className="mb-3 text-[14px] font-medium text-[#5f6670]">对话框尺寸</div>
|
||||
<GenerationDotPlaceholder
|
||||
className="h-[420px] w-[420px]"
|
||||
phase="processing"
|
||||
progress={41}
|
||||
title="正在创建图片"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-3 text-[14px] font-medium text-[#5f6670]">画布节点尺寸</div>
|
||||
<GenerationDotPlaceholder
|
||||
className="h-[360px] w-full"
|
||||
compact
|
||||
phase="processing"
|
||||
progress={67}
|
||||
title="正在润饰细节"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
@ -25,6 +25,70 @@ const PHASE_TITLES: Record<GenerationPhase, string> = {
|
||||
|
||||
const ROTATING_TITLES = ["正在创建图片", "生成初稿中", "正在润饰细节"];
|
||||
|
||||
const scatterVertexShader = `
|
||||
uniform float uTime;
|
||||
uniform float uPointSize;
|
||||
uniform float uEffectIntensity;
|
||||
attribute vec3 randomOffset;
|
||||
varying float vDistance;
|
||||
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
i = mod289(i);
|
||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
||||
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
|
||||
m = m * m;
|
||||
m = m * m;
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 pos = position;
|
||||
vec3 scatterDir = normalize(pos + randomOffset * 0.5);
|
||||
float scatterDist = length(pos) * 0.5 + randomOffset.x * 3.0;
|
||||
vec3 scattered = pos + scatterDir * scatterDist * uEffectIntensity * 1.85;
|
||||
float turb = snoise(pos.xy * 0.34 + uTime * 0.45);
|
||||
scattered += vec3(turb, turb * 0.55, turb * 0.35) * uEffectIntensity * 1.2;
|
||||
pos = mix(pos, scattered, 0.72);
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
||||
float dist = length(pos);
|
||||
vDistance = dist;
|
||||
gl_PointSize = (uPointSize / -mvPosition.z) * (1.0 + sin(uTime * 3.0 + dist * 0.16) * 0.42);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const scatterFragmentShader = `
|
||||
uniform float uTime;
|
||||
uniform float uOpacity;
|
||||
varying float vDistance;
|
||||
|
||||
void main() {
|
||||
float dist = distance(gl_PointCoord, vec2(0.5));
|
||||
if (dist > 0.5) discard;
|
||||
float strength = pow(1.0 - dist * 2.0, 1.55);
|
||||
float shimmer = 0.72 + sin(vDistance * 0.33 + uTime) * 0.22;
|
||||
gl_FragColor = vec4(vec3(0.12, 0.13, 0.14), strength * shimmer * uOpacity);
|
||||
}
|
||||
`;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
@ -44,6 +108,131 @@ function estimateProgress(phase: GenerationPhase, elapsedMs: number) {
|
||||
return clampProgress(Math.min(8, elapsedMs / 1000));
|
||||
}
|
||||
|
||||
function ScatterParticleLayer({
|
||||
compact,
|
||||
opacity,
|
||||
}: {
|
||||
compact: boolean;
|
||||
opacity: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cleanup = () => {};
|
||||
|
||||
async function setup() {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const THREE = await import("three");
|
||||
if (cancelled || !containerRef.current) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 1000);
|
||||
camera.position.set(0, 0, compact ? 46 : 42);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.setClearColor(0xffffff, 0);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const particleCount = compact ? 7200 : 12800;
|
||||
const positions = new Float32Array(particleCount * 3);
|
||||
const randomOffsets = new Float32Array(particleCount * 3);
|
||||
|
||||
for (let i = 0; i < particleCount; i += 1) {
|
||||
const i3 = i * 3;
|
||||
const t = (Math.random() - 0.5) * 4.7;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const radiusBase = 0.35 + Math.pow(Math.abs(t), 2.18);
|
||||
const radius = radiusBase * (0.74 + Math.random() * 0.58);
|
||||
positions[i3] = Math.cos(angle) * radius * 2.25;
|
||||
positions[i3 + 1] = t * (compact ? 5.2 : 6.8);
|
||||
positions[i3 + 2] = Math.sin(angle) * radius * 2.25;
|
||||
randomOffsets[i3] = (Math.random() - 0.5) * 2;
|
||||
randomOffsets[i3 + 1] = (Math.random() - 0.5) * 2;
|
||||
randomOffsets[i3 + 2] = (Math.random() - 0.5) * 2;
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("randomOffset", new THREE.BufferAttribute(randomOffsets, 3));
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader: scatterVertexShader,
|
||||
fragmentShader: scatterFragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.NormalBlending,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uPointSize: { value: compact ? 88 : 108 },
|
||||
uEffectIntensity: { value: 1 },
|
||||
uOpacity: { value: opacity },
|
||||
},
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geometry, material);
|
||||
points.rotation.x = -0.06;
|
||||
const baseX = compact ? -5.2 : -5.8;
|
||||
const baseY = compact ? 0.15 : -0.05;
|
||||
points.position.set(baseX, baseY, 0);
|
||||
scene.add(points);
|
||||
|
||||
const resize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
const height = Math.max(1, Math.floor(rect.height));
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
resize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
let frameId = 0;
|
||||
let time = 0;
|
||||
const animate = () => {
|
||||
time += 0.008;
|
||||
points.rotation.y += 0.0048;
|
||||
points.rotation.z += 0.0015;
|
||||
points.rotation.x = -0.08 + Math.sin(time * 0.17) * 0.18;
|
||||
points.position.x = baseX + Math.sin(time * 0.16) * 0.18;
|
||||
points.position.y = baseY + Math.sin(time * 0.22) * 0.16;
|
||||
material.uniforms.uTime.value = time;
|
||||
renderer.render(scene, camera);
|
||||
frameId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
|
||||
cleanup = () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
resizeObserver.disconnect();
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
}
|
||||
|
||||
setup();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [compact, opacity]);
|
||||
|
||||
return <div ref={containerRef} className="absolute inset-0" />;
|
||||
}
|
||||
|
||||
export function GenerationDotPlaceholder({
|
||||
className,
|
||||
title,
|
||||
@ -67,7 +256,7 @@ export function GenerationDotPlaceholder({
|
||||
);
|
||||
const rotatingTitle = ROTATING_TITLES[Math.floor(elapsedMs / 3200) % ROTATING_TITLES.length];
|
||||
const resolvedTitle = title || (phase === "processing" ? rotatingTitle : PHASE_TITLES[phase]);
|
||||
const dotOpacity = phase === "failed" ? 0.22 : 0.34;
|
||||
const dotOpacity = phase === "failed" ? 0.18 : 0.42;
|
||||
|
||||
const progressStyle = useMemo(
|
||||
() => ({ width: `${resolvedProgress}%` }),
|
||||
@ -105,48 +294,7 @@ export function GenerationDotPlaceholder({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="generation-dot-field generation-dot-field-a absolute inset-0"
|
||||
style={{ opacity: dotOpacity }}
|
||||
/>
|
||||
<div
|
||||
className="generation-dot-field generation-dot-field-b absolute inset-0"
|
||||
style={{ opacity: dotOpacity * 0.9 }}
|
||||
/>
|
||||
<style jsx>{`
|
||||
.generation-dot-field {
|
||||
background-image: radial-gradient(circle, rgba(40, 44, 50, 0.42) 1.35px, transparent 1.55px);
|
||||
background-size: ${compact ? "14px 14px" : "18px 18px"};
|
||||
animation: generation-dot-drift 7.5s ease-in-out infinite alternate;
|
||||
mask-image:
|
||||
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
|
||||
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
|
||||
-webkit-mask-image:
|
||||
radial-gradient(circle at 32% 69%, black 0 19%, rgba(0, 0, 0, 0.72) 27%, transparent 48%),
|
||||
radial-gradient(circle at 68% 36%, black 0 15%, rgba(0, 0, 0, 0.58) 25%, transparent 45%);
|
||||
mask-composite: add;
|
||||
-webkit-mask-composite: source-over;
|
||||
}
|
||||
|
||||
.generation-dot-field-b {
|
||||
background-size: ${compact ? "18px 18px" : "23px 23px"};
|
||||
animation-duration: 9s;
|
||||
animation-direction: alternate-reverse;
|
||||
filter: blur(0.1px);
|
||||
transform: translate3d(6px, -4px, 0);
|
||||
}
|
||||
|
||||
@keyframes generation-dot-drift {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
transform: translate3d(-3px, 2px, 0) scale(0.985);
|
||||
}
|
||||
100% {
|
||||
background-position: 26px 18px;
|
||||
transform: translate3d(5px, -4px, 0) scale(1.015);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<ScatterParticleLayer compact={compact} opacity={dotOpacity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1869,52 +1869,52 @@ function PureModelSelectorCompact({
|
||||
</Button>
|
||||
)}
|
||||
</Trigger>
|
||||
<PromptInputModelSelectContent className="!w-[266px] max-h-[372px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5]/90 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
|
||||
<div className="max-h-[338px] space-y-0.5 overflow-y-auto pr-1">
|
||||
<PromptInputModelSelectContent className="!w-[344px] max-h-[426px] overflow-hidden rounded-[22px] border border-[#bfc6cf] bg-[#f2f3f5]/90 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.12)]">
|
||||
<div className="max-h-[392px] space-y-1 overflow-y-auto pr-1">
|
||||
{displayModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
disabled={model.selectable === false}
|
||||
className={cn(
|
||||
"h-11 rounded-[14px] px-3 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
|
||||
"h-12 rounded-[14px] px-4 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
|
||||
"focus:bg-[#e7ebf0] focus:text-[#1f2937]",
|
||||
"data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]",
|
||||
model.isSubModel ? "pl-8" : "",
|
||||
model.isSubModel ? "pl-9" : "",
|
||||
model.selectable === false && "opacity-55"
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center text-[#2f3640]">
|
||||
<span className="flex w-full items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-[#2f3640]">
|
||||
{model.avatarUrl ? (
|
||||
<img
|
||||
src={model.avatarUrl}
|
||||
alt={model.displayName}
|
||||
className="h-5 w-5 rounded-full object-cover"
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center">
|
||||
<span className="flex h-6 w-6 items-center justify-center">
|
||||
{(() => {
|
||||
if (isVideoProviderType(model)) {
|
||||
return <Video className="h-4 w-4" strokeWidth={2.2} />;
|
||||
return <Video className="h-[19px] w-[19px]" strokeWidth={2.2} />;
|
||||
}
|
||||
if (isImageGenerationModel(model)) {
|
||||
return <ImageIcon className="h-4 w-4" strokeWidth={2.2} />;
|
||||
return <ImageIcon className="h-[19px] w-[19px]" strokeWidth={2.2} />;
|
||||
}
|
||||
if ((model.providerType || "") === "system") {
|
||||
return <Sparkles className="h-4 w-4" strokeWidth={2.2} />;
|
||||
return <Sparkles className="h-[19px] w-[19px]" strokeWidth={2.2} />;
|
||||
}
|
||||
return <Bot className="h-4 w-4" strokeWidth={2.2} />;
|
||||
return <Bot className="h-[19px] w-[19px]" strokeWidth={2.2} />;
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[13px] font-medium leading-none">
|
||||
<span className="min-w-0 whitespace-nowrap text-[17px] font-medium leading-none tracking-[-0.01em]">
|
||||
{model.isSubModel ? `└ ${model.displayName}` : model.displayName}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{getImageModelCreditDiscountLabel(model.imageGenConfig) ? (
|
||||
<span className="rounded-[10px] border border-[#c7ccd6] bg-[#eef1f5] px-2 py-0.5 text-[11px] font-semibold leading-none text-[#5b6472] shadow-[inset_0_1px_0_rgba(255,255,255,0.85)]">
|
||||
{getImageModelCreditDiscountLabel(model.imageGenConfig)}
|
||||
|
||||
@ -373,6 +373,73 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
|
||||
logger.debug("[Gemini Image] Extracting images from response:", JSON.stringify(response).substring(0, 500)); // 🔍 调试日志
|
||||
const images: Array<{ thumbnail: string; original: string }> = [];
|
||||
const uploadPromises: Promise<void>[] = [];
|
||||
const queuedUrls = new Set<string>();
|
||||
|
||||
const queueRemoteImageUrl = (imageUrl: unknown, source: string) => {
|
||||
const normalized = String(imageUrl || "").trim();
|
||||
if (!normalized || queuedUrls.has(normalized)) return;
|
||||
queuedUrls.add(normalized);
|
||||
|
||||
if (normalized.startsWith("data:")) {
|
||||
const imageData = extractImageDataFromText(normalized)[0];
|
||||
if (imageData?.isBase64 && imageData.base64Data) {
|
||||
const uploadPromise = uploadBase64ToS3(imageData.base64Data, imageData.mimeType)
|
||||
.then((urls) => {
|
||||
images.push(urls);
|
||||
});
|
||||
uploadPromises.push(uploadPromise);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[Gemini Image] Found ${source} image URL: ${normalized.substring(0, 100)}...`
|
||||
);
|
||||
const uploadPromise = downloadAndUploadToS3(normalized)
|
||||
.then((urls) => {
|
||||
images.push(urls);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(`[Gemini Image] Failed to download/upload ${source} URL after retries:`, error);
|
||||
throw new Error(
|
||||
`无法下载并上传图片到国内中转: ${error instanceof Error ? error.message : "未知错误"}`
|
||||
);
|
||||
});
|
||||
uploadPromises.push(uploadPromise);
|
||||
};
|
||||
|
||||
const queueImageValue = (value: unknown, source: string) => {
|
||||
if (!value) return;
|
||||
if (typeof value === "string") {
|
||||
queueRemoteImageUrl(value, source);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const item = value as Record<string, any>;
|
||||
queueRemoteImageUrl(
|
||||
item.url || item.imageUrl || item.image_url || item.original || item.thumbnail,
|
||||
source
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
queueImageValue(response?.url, "direct");
|
||||
queueImageValue(response?.imageUrl, "direct");
|
||||
queueImageValue(response?.image_url, "direct");
|
||||
queueImageValue(response?.data?.url, "data");
|
||||
queueImageValue(response?.data?.imageUrl, "data");
|
||||
queueImageValue(response?.data?.image_url, "data");
|
||||
|
||||
const directArrays = [
|
||||
{ value: response?.images, source: "images" },
|
||||
{ value: response?.data?.images, source: "data.images" },
|
||||
{ value: response?.data?.results, source: "data.results" },
|
||||
];
|
||||
for (const item of directArrays) {
|
||||
if (Array.isArray(item.value)) {
|
||||
item.value.forEach((entry) => queueImageValue(entry, item.source));
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 尝试 OpenAI Images API 格式 (data[].b64_json / data[].url)
|
||||
if (Array.isArray(response.data)) {
|
||||
@ -388,16 +455,7 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
|
||||
});
|
||||
uploadPromises.push(uploadPromise);
|
||||
} else if (item?.url && typeof item.url === "string") {
|
||||
logger.debug("[Gemini Image] Found OpenAI Images URL, will download and upload to S3:", item.url.substring(0, 100) + "...");
|
||||
const uploadPromise = downloadAndUploadToS3(item.url)
|
||||
.then((urls) => {
|
||||
images.push(urls);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("[Gemini Image] Failed to download/upload OpenAI Images URL after retries:", error);
|
||||
throw new Error(`无法下载并上传图片到国内中转: ${error instanceof Error ? error.message : "未知错误"}`);
|
||||
});
|
||||
uploadPromises.push(uploadPromise);
|
||||
queueRemoteImageUrl(item.url, "OpenAI Images");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -439,20 +497,7 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
|
||||
logger.debug("[Gemini Image] Parsing Nano Banana format, results count:", response.results.length);
|
||||
for (const result of response.results) {
|
||||
if (result.url && typeof result.url === "string") {
|
||||
logger.debug("[Gemini Image] Found Nano Banana image URL:", result.url.substring(0, 100) + "...");
|
||||
// Nano Banana 返回的是外部URL,必须下载并上传到 S3(国内中转)
|
||||
// 如果下载失败,抛出错误,不要使用原始URL(官方地址可能无法访问)
|
||||
const uploadPromise = downloadAndUploadToS3(result.url)
|
||||
.then(urls => {
|
||||
logger.debug("[Gemini Image] Successfully downloaded and uploaded Nano Banana image to S3");
|
||||
images.push(urls);
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error("[Gemini Image] Failed to download/upload Nano Banana URL after retries:", error);
|
||||
// 不再 fallback 到原始 URL,而是抛出错误
|
||||
throw new Error(`无法下载并上传图片到国内中转: ${error instanceof Error ? error.message : "未知错误"}`);
|
||||
});
|
||||
uploadPromises.push(uploadPromise);
|
||||
queueRemoteImageUrl(result.url, "Nano Banana");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -558,72 +603,48 @@ async function sendGrsaiGptImageRequest(params: {
|
||||
apiLogPrefix: string;
|
||||
}): Promise<any> {
|
||||
const { baseURL, apiKey, modelId, prompt, size, imageUrls, timeout, apiLogPrefix } = params;
|
||||
const hasReferenceImage = imageUrls.length > 0;
|
||||
const endpoint = joinOpenAICompatibleBaseUrl(
|
||||
baseURL,
|
||||
hasReferenceImage ? "/v1/images/edits" : "/v1/images/generations"
|
||||
);
|
||||
const endpoint = `${baseURL.replace(/\/+$/, "")}/v1/draw/completions`;
|
||||
const requestBody: Record<string, any> = {
|
||||
model: modelId,
|
||||
prompt,
|
||||
size,
|
||||
aspectRatio: size,
|
||||
shutProgress: true,
|
||||
};
|
||||
|
||||
if (imageUrls.length > 0) {
|
||||
requestBody.urls = imageUrls;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
logger.info(`${apiLogPrefix} Native submit`, {
|
||||
endpoint,
|
||||
modelId,
|
||||
size,
|
||||
referenceImageCount: imageUrls.length,
|
||||
});
|
||||
|
||||
if (hasReferenceImage) {
|
||||
const formData = new FormData();
|
||||
formData.append("model", modelId);
|
||||
formData.append("prompt", prompt);
|
||||
formData.append("size", size);
|
||||
|
||||
const { blob, filename } = await loadImageAsBlob(imageUrls[0]);
|
||||
formData.append("image", blob, filename);
|
||||
|
||||
logger.debug(
|
||||
`${apiLogPrefix} Sending OpenAI-compatible edit request: endpoint=${endpoint}, size=${size}, referenceImageCount=${imageUrls.length}`
|
||||
);
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} else {
|
||||
const requestBody = {
|
||||
model: modelId,
|
||||
prompt,
|
||||
size,
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`${apiLogPrefix} Sending OpenAI-compatible generation request: endpoint=${endpoint}, size=${size}`
|
||||
);
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const responseText = await response.text();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = responseText ? JSON.parse(responseText) : {};
|
||||
} catch {
|
||||
data = { raw: responseText };
|
||||
}
|
||||
const data = parseGrsaiJsonOrStreamResponse(responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`${apiLogPrefix} OpenAI-compatible request failed:`,
|
||||
`${apiLogPrefix} Native request failed:`,
|
||||
response.status,
|
||||
responseText.substring(0, 1000)
|
||||
);
|
||||
@ -636,6 +657,13 @@ async function sendGrsaiGptImageRequest(params: {
|
||||
throw new Error(`图片生成失败: ${errorMessage}`);
|
||||
}
|
||||
|
||||
logger.info(`${apiLogPrefix} Native response received`, {
|
||||
status: response.status,
|
||||
hasImages: hasGrsaiImages(data),
|
||||
progress: getGrsaiProgress(data),
|
||||
responseKeys: isRecord(data) ? Object.keys(data).slice(0, 12) : [],
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
@ -644,10 +672,54 @@ async function sendGrsaiGptImageRequest(params: {
|
||||
logger.error(`${apiLogPrefix} Request timeout after ${timeoutSeconds} seconds`);
|
||||
throw new Error(`Grsai GPT Image 请求超时(${timeoutSeconds}秒),将自动切换备用模型`);
|
||||
}
|
||||
logger.error(`${apiLogPrefix} Native request error:`, {
|
||||
name: error instanceof Error ? error.name : "UnknownError",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause:
|
||||
error instanceof Error && "cause" in error
|
||||
? error.cause instanceof Error
|
||||
? error.cause.message
|
||||
: String(error.cause)
|
||||
: undefined,
|
||||
endpoint,
|
||||
modelId,
|
||||
size,
|
||||
referenceImageCount: imageUrls.length,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGrsaiJsonOrStreamResponse(responseText: string): any {
|
||||
if (!responseText.trim()) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
} catch {
|
||||
// Grsai draw/completions may stream progress as SSE-like "data: {...}" lines.
|
||||
const payloads = responseText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.replace(/^data:\s*/, "").trim())
|
||||
.filter((line) => line && line !== "[DONE]")
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (payloads.length > 0) {
|
||||
return payloads[payloads.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
return { raw: responseText };
|
||||
}
|
||||
|
||||
function getGrsaiTaskId(payload: any): string | null {
|
||||
const normalized = normalizeGrsaiPayload(payload);
|
||||
const candidates = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user