Files
next-tjwater-frontend/src/features/agent/components/agent-persona.tsx
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

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>
);
}