diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index 8eca253ba..1fdb87d78 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; import { ChatShell } from "@/components/chat-shell"; +import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading"; import { DataStreamProvider } from "@/components/data-stream-provider"; import { auth } from "../(auth)/auth"; import { @@ -13,7 +14,9 @@ export const dynamic = "force-dynamic"; export default function Layout({ children }: { children: React.ReactNode }) { return ( - }> + } + > {children} diff --git a/components/bloub-avatar.tsx b/components/bloub-avatar.tsx new file mode 100644 index 000000000..26e8ec7a7 --- /dev/null +++ b/components/bloub-avatar.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { defaultCycle } from "@/lib/bloub-official/cycles"; +import { NOTIF_BLUE } from "@/lib/bloub-official/decor"; +import { type BotFrame, BotEngine } from "@/lib/bloub-official/engine"; +import { + DEFAULT_EXPRESSION, + EXPRESSION_BY_ID, +} from "@/lib/bloub-official/expressions"; +import { DEMI_VIEWBOX, RAYON } from "@/lib/bloub-official/repere"; +import { SHAPE_BY_ID } from "@/lib/bloub-official/skins"; +import type { StateId } from "@/lib/bloub-official/states"; +import { cn } from "@/lib/utils"; + +export type BloubAvatarProps = { + animated?: boolean; + className?: string; + label?: string; + playSequence?: boolean; + size?: number; + state?: StateId; + surface?: "chat" | "page"; +}; + +export function BloubAvatar({ + animated = true, + className, + label = "AI 助手", + playSequence = false, + size = 32, + state = "idle", + surface = "chat", +}: BloubAvatarProps) { + const rawId = useId(); + const uid = rawId.replace(/[^a-zA-Z0-9_-]/g, ""); + const maskId = `bloub-mask-${uid}`; + const engineRef = useRef(null); + const clockRef = useRef(0); + + if (!engineRef.current) { + engineRef.current = new BotEngine( + RAYON, + state, + SHAPE_BY_ID.get("squircle")?.radii ?? null, + EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION) ?? null + ); + } + + const [frame, setFrame] = useState(() => + engineRef.current!.sample(0) + ); + + useEffect(() => { + if (playSequence) return; + const engine = engineRef.current; + if (!engine || engine.state === state) return; + engine.setState(state, clockRef.current); + setFrame(engine.sample(clockRef.current)); + }, [playSequence, state]); + + useEffect(() => { + const engine = engineRef.current; + if (!engine) return; + + const cycle = defaultCycle().blocks; + const firstState = playSequence + ? cycle[0]?.state ?? "idle" + : engine.state; + engine.reset(firstState, 0); + clockRef.current = 0; + setFrame(engine.sample(0)); + + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + if (!animated || reducedMotion) return; + + let animationFrame = 0; + let lastFrameAt = 0; + let blockIndex = 0; + let nextBlockAt = cycle[0]?.duration ?? Number.POSITIVE_INFINITY; + + const tick = (timestamp: number) => { + const delta = lastFrameAt + ? Math.min((timestamp - lastFrameAt) / 1000, 0.064) + : 0; + lastFrameAt = timestamp; + clockRef.current += delta; + + if (playSequence && cycle.length > 0) { + while (clockRef.current >= nextBlockAt) { + blockIndex = (blockIndex + 1) % cycle.length; + const nextBlock = cycle[blockIndex]!; + engine.setState(nextBlock.state, nextBlockAt); + nextBlockAt += nextBlock.duration; + } + } + + setFrame(engine.sample(clockRef.current)); + animationFrame = window.requestAnimationFrame(tick); + }; + + animationFrame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(animationFrame); + }, [animated, playSequence]); + + const renderDot = ( + dot: BotFrame["dots"][number], + index: number, + layer: "back" | "front" + ) => { + const fill = dot.color ?? "currentColor"; + const common = { + fill, + opacity: + dot.color || dot.depth === undefined + ? dot.opacity + : dot.opacity * dot.depth, + }; + const key = `${layer}-${index}`; + + if (dot.d) { + return ( + + ); + } + + return ( + + ); + }; + + return ( + + + + + {frame.eyes.map((eye, index) => ( + + ))} + {frame.notch ? ( + + ) : null} + + + {frame.arcs.map((arc) => ( + + {arc.grad.stops.map((color, index) => ( + + ))} + + ))} + + + + {frame.arcs.map((arc) => ( + + ))} + + + {frame.dotsBehind ? ( + {frame.dots.map((dot, index) => renderDot(dot, index, "back"))} + ) : null} + + + + + + + + + {!frame.dotsBehind ? ( + {frame.dots.map((dot, index) => renderDot(dot, index, "front"))} + ) : null} + + {frame.notif ? ( + + ) : null} + + + {frame.arcs.map((arc) => ( + + ))} + + + ); +} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 1e1901da1..aecfa2b97 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -153,6 +153,7 @@ import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-ic import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; +import { BloubAvatar } from "@/components/bloub-avatar"; import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import type { VideoToolCapabilityContract } from "@/types/video-tool-capability"; import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel"; @@ -2631,7 +2632,12 @@ function CanvasAssistantTypingPlaceholder({ aria-label={label} className="inline-flex h-8 items-center gap-2 rounded-[16px] bg-[#f4f5f7] px-3.5 text-[15px] font-medium text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400" > - + {label} {onCancel ? (