From 6ab68e8ac9d4c9a0635302769cc33d2f51586b43 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 2 Sep 2026 19:12:58 +0800 Subject: [PATCH] fix(canvas): show Bloub in chat and image placeholders --- components/canvas/CanvasChatPanel.tsx | 19 +- components/generation-dot-placeholder.tsx | 473 ++++++---------------- 2 files changed, 134 insertions(+), 358 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index aecfa2b97..ca46bb4b5 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -16382,8 +16382,20 @@ export function CanvasChatPanel({ ) : ( - <> -
+ {isClawAssistantMessage ? ( + + ) : null} +
+
) : null} +
- +
)} diff --git a/components/generation-dot-placeholder.tsx b/components/generation-dot-placeholder.tsx index ad6dacad6..c5c416488 100644 --- a/components/generation-dot-placeholder.tsx +++ b/components/generation-dot-placeholder.tsx @@ -1,17 +1,25 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { BloubAvatar } from "@/components/bloub-avatar"; +import type { StateId } from "@/lib/bloub-official/states"; import { cn } from "@/lib/utils"; -type GenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed" | "completed"; +type GenerationPhase = + | "idle" + | "queued" + | "processing" + | "finalizing" + | "failed" + | "completed"; interface GenerationDotPlaceholderProps { className?: string; - title?: string; + compact?: boolean; phase?: GenerationPhase; progress?: number | null; - compact?: boolean; showProgress?: boolean; + title?: string; viewportScale?: number; } @@ -101,6 +109,7 @@ const GENERATION_STATUS_TITLES = [ "正在完成本次生成", "正在即将展示结果", ]; + const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000; const LONG_WAIT_STATUS_TITLES = [ "服务繁忙,排队处理中", @@ -108,7 +117,6 @@ const LONG_WAIT_STATUS_TITLES = [ "仍在保持连接,等待模型返回", "结果返回后会自动展示", ]; - const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => { const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400]; return rhythm[index % rhythm.length]; @@ -116,147 +124,13 @@ const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => { const TYPEWRITER_START_DELAY_MS = 120; const TYPEWRITER_BASE_STEP_MS = 46; -function getStatusTitleState(elapsedMs: number, initialTitle?: string) { - const titles = initialTitle - ? [initialTitle, ...GENERATION_STATUS_TITLES.filter((item) => item !== initialTitle)] - : GENERATION_STATUS_TITLES; - const durations = initialTitle - ? [4200, ...STATUS_TITLE_DURATIONS_MS] - : STATUS_TITLE_DURATIONS_MS; - - let cursor = Math.max(0, elapsedMs); - for (let index = 0; index < titles.length; index += 1) { - const duration = durations[index] || 4600; - if (cursor < duration) { - return { - index, - title: titles[index] || titles[titles.length - 1] || PHASE_TITLES.processing, - elapsedInTitle: cursor, - }; - } - cursor -= duration; - } - - return { - index: titles.length - 1, - title: titles[titles.length - 1] || PHASE_TITLES.processing, - elapsedInTitle: durations[durations.length - 1] || 4600, - }; -} - -function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) { - const titles = initialTitle - ? [initialTitle, ...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle)] - : LONG_WAIT_STATUS_TITLES; - const elapsedAfterThreshold = Math.max(0, elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS); - const duration = 7600; - const index = Math.floor(elapsedAfterThreshold / duration) % titles.length; - - return { - index, - title: titles[index] || titles[0] || PHASE_TITLES.processing, - elapsedInTitle: elapsedAfterThreshold % duration, - }; -} - -function getTypewriterText(text: string, elapsedInTitle: number, index: number) { - const chars = Array.from(text); - if (chars.length === 0) return ""; - const step = TYPEWRITER_BASE_STEP_MS + (index % 5) * 8; - const visibleCount = Math.max( - 1, - Math.min( - chars.length, - Math.floor(Math.max(0, elapsedInTitle - TYPEWRITER_START_DELAY_MS) / step) + 1 - ) - ); - return chars.slice(0, visibleCount).join(""); -} - -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 edge = 1.0 - smoothstep(0.42, 0.5, dist); - float core = 0.92 + (1.0 - smoothstep(0.0, 0.34, dist)) * 0.08; - float shimmer = 0.78 + sin(vDistance * 0.33 + uTime) * 0.18; - gl_FragColor = vec4(vec3(0.11, 0.12, 0.13), edge * core * shimmer * uOpacity); - } - `; - function clampProgress(value: number) { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, Math.round(value))); } -function normalizeViewportScale(value: number | undefined) { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return 1; - } - return Math.max(0.1, Math.min(4, value)); -} - -function getParticlePointSize(compact: boolean, viewportScale: number) { - return (compact ? 76 : 94) * viewportScale; -} - function estimateProgress(phase: GenerationPhase, elapsedMs: number) { - if (phase === "completed") return 100; - if (phase === "failed") return 100; + if (phase === "completed" || phase === "failed") return 100; if (phase === "finalizing") return 96; if (phase === "queued") { return clampProgress(6 + Math.min(12, elapsedMs / 3500)); @@ -268,165 +142,93 @@ function estimateProgress(phase: GenerationPhase, elapsedMs: number) { return clampProgress(Math.min(8, elapsedMs / 1000)); } -function ScatterParticleLayer({ - compact, - opacity, - viewportScale, -}: { - compact: boolean; - opacity: number; - viewportScale: number; -}) { - const containerRef = useRef(null); - const pointSizeUniformRef = useRef<{ value: number } | null>(null); - const viewportScaleRef = useRef(viewportScale); +function getStatusTitleState(elapsedMs: number, initialTitle?: string) { + const titles = initialTitle + ? [ + initialTitle, + ...GENERATION_STATUS_TITLES.filter((item) => item !== initialTitle), + ] + : GENERATION_STATUS_TITLES; + const durations = initialTitle + ? [4200, ...STATUS_TITLE_DURATIONS_MS] + : STATUS_TITLE_DURATIONS_MS; - useEffect(() => { - viewportScaleRef.current = viewportScale; - if (pointSizeUniformRef.current) { - pointSizeUniformRef.current.value = getParticlePointSize( - compact, - viewportScale - ); - } - }, [compact, viewportScale]); - - 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 ? 48 : 44); - 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, 2.5)); - renderer.domElement.style.display = "block"; - renderer.domElement.style.height = "100%"; - renderer.domElement.style.width = "100%"; - 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: getParticlePointSize(compact, viewportScaleRef.current), - }, - uEffectIntensity: { value: 1 }, - uOpacity: { value: opacity }, - }, - }); - pointSizeUniformRef.current = material.uniforms.uPointSize as { - value: number; - }; - - const points = new THREE.Points(geometry, material); - points.rotation.x = -0.06; - const baseX = 0; - const baseY = 0; - 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.04; - points.position.y = baseY + Math.sin(time * 0.22) * 0.04; - 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(); - pointSizeUniformRef.current = null; + let cursor = Math.max(0, elapsedMs); + for (let index = 0; index < titles.length; index += 1) { + const duration = durations[index] || 4600; + if (cursor < duration) { + return { + elapsedInTitle: cursor, + index, + title: + titles[index] || + titles[titles.length - 1] || + PHASE_TITLES.processing, }; } + cursor -= duration; + } - setup(); - return () => { - cancelled = true; - cleanup(); - }; - }, [compact, opacity]); + return { + elapsedInTitle: durations[durations.length - 1] || 4600, + index: titles.length - 1, + title: titles[titles.length - 1] || PHASE_TITLES.processing, + }; +} - return
; +function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) { + const titles = initialTitle + ? [ + initialTitle, + ...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle), + ] + : LONG_WAIT_STATUS_TITLES; + const elapsedAfterThreshold = Math.max( + 0, + elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS + ); + const duration = 7600; + const index = Math.floor(elapsedAfterThreshold / duration) % titles.length; + + return { + elapsedInTitle: elapsedAfterThreshold % duration, + index, + title: titles[index] || titles[0] || PHASE_TITLES.processing, + }; +} + +function getTypewriterText(text: string, elapsedInTitle: number, index: number) { + const chars = Array.from(text); + if (chars.length === 0) return ""; + const step = TYPEWRITER_BASE_STEP_MS + (index % 5) * 8; + const visibleCount = Math.max( + 1, + Math.min( + chars.length, + Math.floor( + Math.max(0, elapsedInTitle - TYPEWRITER_START_DELAY_MS) / step + ) + 1 + ) + ); + return chars.slice(0, visibleCount).join(""); +} + +function getBloubState(phase: GenerationPhase): StateId { + if (phase === "failed") return "alert"; + if (phase === "completed") return "wink"; + if (phase === "queued") return "thinking"; + return "idle"; } export function GenerationDotPlaceholder({ className, - title, + compact = false, phase = "processing", progress, - compact = false, showProgress = true, - viewportScale, + title, }: GenerationDotPlaceholderProps) { const [elapsedMs, setElapsedMs] = useState(0); - const resolvedViewportScale = normalizeViewportScale(viewportScale); useEffect(() => { const startedAt = Date.now(); @@ -451,9 +253,10 @@ export function GenerationDotPlaceholder({ statusTitleState.index ); const resolvedTitle = - phase === "processing" || phase === "finalizing" ? rotatingTitle : title || PHASE_TITLES[phase]; - const dotOpacity = phase === "failed" ? 0.18 : 0.56; - + phase === "processing" || phase === "finalizing" + ? rotatingTitle + : title || PHASE_TITLES[phase]; + const playSequence = phase === "processing" || phase === "finalizing"; const progressStyle = useMemo( () => ({ width: `${resolvedProgress}%` }), [resolvedProgress] @@ -462,60 +265,51 @@ export function GenerationDotPlaceholder({ return (
-
+
+ {resolvedTitle}
-
+ +
+ +
+ {showProgress ? (
-
+
{phase === "failed" ? "已停止" : "生成进度"} {resolvedProgress}%
-
+
@@ -523,11 +317,7 @@ export function GenerationDotPlaceholder({
) : null}
- +
);