131 lines
4.1 KiB
TypeScript
131 lines
4.1 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
|
import asset from "@/shared/ai-elements/persona-resource-manifest.json";
|
|
import { cn } from "@/shared/ui/cn";
|
|
|
|
const Persona = lazy(() =>
|
|
import("@/shared/ai-elements/persona").then((module) => ({ default: module.Persona }))
|
|
);
|
|
const stillSources = {
|
|
idle: "/agent/obsidian-water-idle.png",
|
|
asleep: "/agent/obsidian-water-offline.png"
|
|
};
|
|
|
|
type AgentPersonaProps = {
|
|
className?: string;
|
|
state?: PersonaState;
|
|
};
|
|
|
|
function usePrefersReducedMotion() {
|
|
const [reducedMotion, setReducedMotion] = useState(
|
|
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
);
|
|
|
|
useEffect(() => {
|
|
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const update = () => setReducedMotion(query.matches);
|
|
update();
|
|
query.addEventListener("change", update);
|
|
return () => query.removeEventListener("change", update);
|
|
}, []);
|
|
|
|
return reducedMotion;
|
|
}
|
|
|
|
function useElementVisibility() {
|
|
const containerRef = useRef<HTMLSpanElement>(null);
|
|
const [visible, setVisible] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
let intersectsViewport = false;
|
|
const updateVisibility = () =>
|
|
setVisible(intersectsViewport && document.visibilityState === "visible");
|
|
const observer = new IntersectionObserver(([entry]) => {
|
|
intersectsViewport = Boolean(entry?.isIntersecting);
|
|
updateVisibility();
|
|
});
|
|
observer.observe(container);
|
|
document.addEventListener("visibilitychange", updateVisibility);
|
|
return () => {
|
|
observer.disconnect();
|
|
document.removeEventListener("visibilitychange", updateVisibility);
|
|
};
|
|
}, []);
|
|
return { containerRef, visible };
|
|
}
|
|
|
|
function PersonaStill({ state }: { state: PersonaState }) {
|
|
const source = stillSources[state === "asleep" ? "asleep" : "idle"];
|
|
return <img className="agent-orb-still" src={`${source}?v=${asset.sha256}`} alt="" draggable={false} />;
|
|
}
|
|
|
|
function LivePersona({ state }: { state: PersonaState }) {
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [ready, setReady] = useState(false);
|
|
const [failed, setFailed] = useState(false);
|
|
const onLoad = useCallback(() => setLoaded(true), []);
|
|
const onLoadError = useCallback(() => setFailed(true), []);
|
|
|
|
useEffect(() => {
|
|
if (!loaded) return;
|
|
// Keep the matching still visible while Rive completes its entrance animation.
|
|
const timer = window.setTimeout(() => setReady(true), 600);
|
|
return () => window.clearTimeout(timer);
|
|
}, [loaded]);
|
|
|
|
return (
|
|
<span className="agent-orb-live" data-ready={ready && !failed} data-failed={failed}>
|
|
<PersonaStill state={state} />
|
|
{!failed ? (
|
|
<Suspense fallback={null}>
|
|
<Persona
|
|
className="agent-orb-canvas"
|
|
state={state}
|
|
onLoad={onLoad}
|
|
onLoadError={onLoadError}
|
|
/>
|
|
</Suspense>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
|
|
const { containerRef, visible } = useElementVisibility();
|
|
const reducedMotion = usePrefersReducedMotion();
|
|
const [hovered, setHovered] = useState(false);
|
|
const live = visible && !reducedMotion;
|
|
|
|
useEffect(() => {
|
|
if (!live) setHovered(false);
|
|
}, [live]);
|
|
|
|
return (
|
|
<span
|
|
ref={containerRef}
|
|
aria-label="Agent 状态"
|
|
className={cn("relative block h-16 w-16 shrink-0", className)}
|
|
>
|
|
<span
|
|
className="agent-orb"
|
|
data-agent-orb={state}
|
|
data-animated={live}
|
|
data-hovered={hovered && live}
|
|
aria-hidden="true"
|
|
onPointerEnter={(event) => {
|
|
if (event.isPrimary && event.pointerType !== "touch") setHovered(true);
|
|
}}
|
|
onPointerLeave={() => setHovered(false)}
|
|
onPointerCancel={() => setHovered(false)}
|
|
>
|
|
<span className="agent-orb-response">
|
|
{live ? <LivePersona state={state} /> : <PersonaStill state={state} />}
|
|
<span className="agent-orb-contrast" />
|
|
</span>
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|