feat: add project-aware agent workspace
This commit is contained in:
@@ -1,18 +1,37 @@
|
||||
import { Bot } from "lucide-react";
|
||||
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 LazyPersona = lazy(async () => {
|
||||
const module = await import("@/shared/ai-elements/persona");
|
||||
return { default: module.Persona };
|
||||
});
|
||||
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);
|
||||
@@ -20,60 +39,92 @@ function useElementVisibility() {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let intersectsViewport = false;
|
||||
const updateVisibility = () => {
|
||||
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 PersonaFallback() {
|
||||
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="grid h-full w-full place-items-center rounded-full border border-[var(--surface-border)]/70 bg-[var(--surface-control)] text-[var(--ink-muted)]">
|
||||
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
||||
<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 [failed, setFailed] = useState(false);
|
||||
const handleLoadError = useCallback(() => setFailed(true), []);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const live = visible && !reducedMotion;
|
||||
|
||||
const shouldRenderAnimation = !failed && visible;
|
||||
useEffect(() => {
|
||||
if (!live) setHovered(false);
|
||||
}, [live]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
aria-label="Agent 状态"
|
||||
className={cn("relative block shrink-0", className)}
|
||||
className={cn("relative block h-16 w-16 shrink-0", className)}
|
||||
>
|
||||
{shouldRenderAnimation ? (
|
||||
<Suspense fallback={<PersonaFallback />}>
|
||||
<LazyPersona
|
||||
className="h-full w-full scale-110"
|
||||
onLoadError={handleLoadError}
|
||||
state={state}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PersonaFallback />
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user