feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
+80 -29
View File
@@ -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>
);
}