From 016d5885598c463add5d29a98162927826dc1884 Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 12 Jun 2026 14:06:00 +0800 Subject: [PATCH] fix grsai image request and generation preview --- app/particle-preview/page.tsx | 39 ++++ components/generation-dot-placeholder.tsx | 236 ++++++++++++++++++---- components/multimodal-input.tsx | 30 +-- lib/ai/providers/gemini-image.ts | 226 ++++++++++++++------- proxy.ts | 1 + 5 files changed, 396 insertions(+), 136 deletions(-) create mode 100644 app/particle-preview/page.tsx diff --git a/app/particle-preview/page.tsx b/app/particle-preview/page.tsx new file mode 100644 index 000000000..b4bec5b13 --- /dev/null +++ b/app/particle-preview/page.tsx @@ -0,0 +1,39 @@ +import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder"; + +export default function ParticlePreviewPage() { + return ( +
+
+
+

生成占位粒子预览

+

+ 单独预览旋转粒子、3D 视差和进度条状态。 +

+
+ +
+
+
对话框尺寸
+ +
+ +
+
画布节点尺寸
+ +
+
+
+
+ ); +} diff --git a/components/generation-dot-placeholder.tsx b/components/generation-dot-placeholder.tsx index 9d3455830..61b6584fe 100644 --- a/components/generation-dot-placeholder.tsx +++ b/components/generation-dot-placeholder.tsx @@ -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 = { 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(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
; +} + 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({
) : null} -
-
- +
); } diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 40eefbce9..50467e8c0 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -1869,52 +1869,52 @@ function PureModelSelectorCompact({ )} - -
+ +
{displayModels.map((model) => ( 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" )} > - - - + + + {model.avatarUrl ? ( {model.displayName} ) : ( - + {(() => { if (isVideoProviderType(model)) { - return )} - + {model.isSubModel ? `└ ${model.displayName}` : model.displayName} - + {getImageModelCreditDiscountLabel(model.imageGenConfig) ? ( {getImageModelCreditDiscountLabel(model.imageGenConfig)} diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 205e96945..1419accff 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -373,6 +373,73 @@ async function extractAndUploadImages(response: any): Promise = []; const uploadPromises: Promise[] = []; + const queuedUrls = new Set(); + + 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; + 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 { - 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 { - 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 { 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 = { + 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 = [ diff --git a/proxy.ts b/proxy.ts index e0c9f5f72..8d952f0a9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -314,6 +314,7 @@ export async function proxy(request: NextRequest) { pathname === "/" || pathname === "/login" || pathname === "/register" || + pathname === "/particle-preview" || pathname === "/riji" || pathname.startsWith("/riji/") || pathname.startsWith("/worlds/share/");