feat: add project-aware agent workspace
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"path": "/agent/obsidian-water-v1.riv",
|
||||
"sha256": "ea556c57d3fd966710fc1c31af09dc706c72f6f2570a82a7e4201cde3c332a00",
|
||||
"byteLength": 9879
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import asset from "./persona-resource-manifest.json";
|
||||
|
||||
const bytes = Uint8Array.from(readFileSync(`public${asset.path}`)).buffer;
|
||||
const source = `${asset.path}?v=${asset.sha256}`;
|
||||
const absolute = (path: string) => new URL(path, window.location.href).href;
|
||||
|
||||
function mockStorage() {
|
||||
const entries = new Map<string, Response>();
|
||||
const cache = {
|
||||
match: vi.fn(async (key: string) => entries.get(absolute(key))?.clone()),
|
||||
put: vi.fn(async (key: string, response: Response) => { entries.set(absolute(key), response.clone()); }),
|
||||
keys: vi.fn(async () => Array.from(entries.keys(), (key) => new Request(key))),
|
||||
delete: vi.fn(async (request: Request) => entries.delete(request.url))
|
||||
};
|
||||
vi.stubGlobal("caches", { open: vi.fn().mockResolvedValue(cache) });
|
||||
return { entries, cache };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("crypto", webcrypto);
|
||||
vi.stubGlobal("caches", undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Obsidian resource", () => {
|
||||
it("shares one download across simultaneous and later renderer mounts", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const { loadPersonaResource } = await import("./persona-resource");
|
||||
const [first, second] = await Promise.all([loadPersonaResource(), loadPersonaResource()]);
|
||||
expect(new Uint8Array(first)).toEqual(new Uint8Array(bytes));
|
||||
expect(second).toBe(first);
|
||||
expect(await loadPersonaResource()).toBe(first);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
source,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a new mount to retry a failed download", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
||||
.mockResolvedValueOnce(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const { loadPersonaResource } = await import("./persona-resource");
|
||||
await expect(loadPersonaResource()).rejects.toThrow("503");
|
||||
expect(new Uint8Array(await loadPersonaResource())).toEqual(new Uint8Array(bytes));
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("loads from persistent storage after module memory is gone and the network is unavailable", async () => {
|
||||
const { entries } = mockStorage();
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(entries.has(absolute(source))).toBe(true);
|
||||
vi.resetModules();
|
||||
fetch.mockRejectedValue(new TypeError("offline"));
|
||||
const restored = await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(new Uint8Array(restored)).toEqual(new Uint8Array(bytes));
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches a new content version and removes only old entries for this animation", async () => {
|
||||
const { entries } = mockStorage();
|
||||
entries.set(absolute(`${asset.path}?v=old`), new Response("old animation"));
|
||||
entries.set(absolute("/unrelated.riv"), new Response("keep"));
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(Array.from(entries.keys()).sort()).toEqual([absolute(source), absolute("/unrelated.riv")].sort());
|
||||
});
|
||||
|
||||
it("replaces corrupted cached bytes even when their size and RIVE header still match", async () => {
|
||||
const { entries } = mockStorage();
|
||||
const corrupted = new Uint8Array(bytes.slice(0));
|
||||
corrupted[100] ^= 1;
|
||||
entries.set(absolute(source), new Response(corrupted));
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(bytes));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await (await import("./persona-resource")).loadPersonaResource();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(new Uint8Array(await entries.get(absolute(source))!.arrayBuffer())).toEqual(new Uint8Array(bytes));
|
||||
});
|
||||
|
||||
it.each(["unavailable", "denied", "full"])("keeps playing when storage is %s", async (failure) => {
|
||||
if (failure === "denied") {
|
||||
vi.stubGlobal("caches", { open: vi.fn().mockRejectedValue(new Error("Storage denied")) });
|
||||
} else if (failure === "full") {
|
||||
mockStorage().cache.put.mockRejectedValue(new Error("Quota exceeded"));
|
||||
}
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(bytes)));
|
||||
expect(new Uint8Array(await (await import("./persona-resource")).loadPersonaResource())).toEqual(new Uint8Array(bytes));
|
||||
});
|
||||
|
||||
it("does not persist an HTML fallback or the wrong resource version and can retry", async () => {
|
||||
const { cache } = mockStorage();
|
||||
const wrongVersion = new Uint8Array(bytes.slice(0));
|
||||
wrongVersion[100] ^= 1;
|
||||
vi.stubGlobal("fetch", vi.fn()
|
||||
.mockResolvedValueOnce(new Response("<!doctype html>"))
|
||||
.mockResolvedValueOnce(new Response(wrongVersion))
|
||||
.mockResolvedValueOnce(new Response(bytes)));
|
||||
const { loadPersonaResource } = await import("./persona-resource");
|
||||
await expect(loadPersonaResource()).rejects.toThrow("invalid animation data");
|
||||
await expect(loadPersonaResource()).rejects.toThrow("version mismatch");
|
||||
expect(cache.put).not.toHaveBeenCalled();
|
||||
expect(new Uint8Array(await loadPersonaResource())).toEqual(new Uint8Array(bytes));
|
||||
expect(cache.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import asset from "./persona-resource-manifest.json";
|
||||
|
||||
const source = `${asset.path}?v=${asset.sha256}`;
|
||||
const cacheName = "tjwater-agent-persona-v1";
|
||||
|
||||
let pending: Promise<ArrayBuffer> | undefined;
|
||||
|
||||
async function readResource(response: Response): Promise<ArrayBuffer> {
|
||||
if (!response.ok) throw new Error(`Obsidian resource: ${response.status}`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const header = new Uint8Array(bytes, 0, Math.min(bytes.byteLength, 4));
|
||||
if (bytes.byteLength !== asset.byteLength || String.fromCharCode(...header) !== "RIVE") {
|
||||
throw new Error("Obsidian resource: invalid animation data");
|
||||
}
|
||||
if (globalThis.crypto?.subtle) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
if (hash !== asset.sha256) throw new Error("Obsidian resource: version mismatch");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function loadBytes(): Promise<ArrayBuffer> {
|
||||
let cache: Cache | undefined;
|
||||
try {
|
||||
cache = await globalThis.caches?.open(cacheName);
|
||||
const cached = await cache?.match(source);
|
||||
if (cached) return await readResource(cached);
|
||||
} catch {
|
||||
// Storage can be denied or contain incomplete data. Retry through the network.
|
||||
}
|
||||
|
||||
const response = await fetch(source, { signal: AbortSignal.timeout(15_000) });
|
||||
const bytes = await readResource(response);
|
||||
if (cache) {
|
||||
try {
|
||||
await cache.put(source, new Response(bytes, {
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
}));
|
||||
const current = new URL(source, window.location.href);
|
||||
for (const request of await cache.keys()) {
|
||||
const url = new URL(request.url);
|
||||
if (url.origin === current.origin && url.pathname === asset.path && url.href !== current.href) {
|
||||
await cache.delete(request);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Quota or storage failures must not prevent the downloaded animation playing.
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Share the immutable bytes, never a live renderer or View Model instance.
|
||||
export function loadPersonaResource() {
|
||||
pending ??= loadBytes().catch((error: unknown) => {
|
||||
pending = undefined;
|
||||
throw error;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
@@ -1,36 +1,104 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const riveMocks = vi.hoisted(() => {
|
||||
const bindViewModelInstance = vi.fn();
|
||||
return {
|
||||
bytes: new ArrayBuffer(8),
|
||||
canvas: null as HTMLCanvasElement | null,
|
||||
load: vi.fn(),
|
||||
inputs: Object.fromEntries(
|
||||
["listening", "thinking", "speaking", "asleep", "hover"].map((name) => [
|
||||
name,
|
||||
{ value: false }
|
||||
])
|
||||
),
|
||||
bindViewModelInstance,
|
||||
useRive: vi.fn(() => ({
|
||||
rive: { bindViewModelInstance },
|
||||
RiveComponent: () => <canvas aria-label="Rive Persona" />
|
||||
canvas: riveMocks.canvas,
|
||||
RiveComponent: ({ className }: { className?: string }) => (
|
||||
<canvas className={className} aria-label="Rive Persona" />
|
||||
)
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./persona-resource", () => ({ loadPersonaResource: riveMocks.load }));
|
||||
vi.mock("@rive-app/react-webgl2", () => ({
|
||||
EventType: { LoadError: "loaderror" },
|
||||
useRive: riveMocks.useRive,
|
||||
useStateMachineInput: () => null
|
||||
useStateMachineInput: (_rive: unknown, _machine: string, name: string) => riveMocks.inputs[name]
|
||||
}));
|
||||
|
||||
import { Persona } from "./persona";
|
||||
|
||||
describe("Persona", () => {
|
||||
it("loads Obsidian without binding the Rive View Model", async () => {
|
||||
render(<Persona />);
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
riveMocks.load.mockResolvedValue(riveMocks.bytes);
|
||||
riveMocks.canvas = document.createElement("canvas");
|
||||
Object.values(riveMocks.inputs).forEach((input) => {
|
||||
input.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Persona", () => {
|
||||
it("loads the original bytes after deferred initialization without a View Model or color filter", async () => {
|
||||
const { getByLabelText } = render(<Persona />);
|
||||
expect(riveMocks.useRive).toHaveBeenCalledWith(null);
|
||||
await waitFor(() =>
|
||||
expect(riveMocks.useRive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
src: "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
|
||||
stateMachines: "default"
|
||||
buffer: riveMocks.bytes,
|
||||
stateMachines: "default",
|
||||
isTouchScrollEnabled: true
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(riveMocks.bindViewModelInstance).not.toHaveBeenCalled();
|
||||
expect(getByLabelText("Rive Persona").style.filter).toBe("");
|
||||
});
|
||||
|
||||
it("switches the original state inputs without leaving the previous state enabled", async () => {
|
||||
const { rerender } = render(<Persona state="thinking" />);
|
||||
await waitFor(() => expect(riveMocks.inputs.thinking.value).toBe(true));
|
||||
rerender(<Persona state="speaking" />);
|
||||
expect(riveMocks.inputs.thinking.value).toBe(false);
|
||||
expect(riveMocks.inputs.speaking.value).toBe(true);
|
||||
rerender(<Persona state="idle" />);
|
||||
expect(Object.values(riveMocks.inputs).every((input) => !input.value)).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards mouse coordinates and exit to Rive without turning touch into hover", async () => {
|
||||
const { getByLabelText } = render(<Persona />);
|
||||
await waitFor(() => expect(riveMocks.load).toHaveBeenCalled());
|
||||
const wrapper = getByLabelText("Rive Persona").parentElement!;
|
||||
const move = vi.fn();
|
||||
const exit = vi.fn();
|
||||
riveMocks.canvas!.addEventListener("mousemove", move);
|
||||
riveMocks.canvas!.addEventListener("mouseout", exit);
|
||||
const pointer = (type: string, pointerType: string, x: number) => {
|
||||
const event = new Event(type, { bubbles: true });
|
||||
Object.assign(event, { pointerType, isPrimary: true, clientX: x, clientY: 48 });
|
||||
fireEvent(wrapper, event);
|
||||
};
|
||||
pointer("pointermove", "mouse", 16);
|
||||
pointer("pointermove", "mouse", 48);
|
||||
expect(move).toHaveBeenNthCalledWith(1, expect.objectContaining({ clientX: 16, clientY: 48 }));
|
||||
expect(move).toHaveBeenNthCalledWith(2, expect.objectContaining({ clientX: 48, clientY: 48 }));
|
||||
pointer("pointermove", "touch", 32);
|
||||
pointer("pointerdown", "touch", 32);
|
||||
expect(move).toHaveBeenCalledTimes(2);
|
||||
pointer("pointercancel", "mouse", 48);
|
||||
expect(exit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a resource failure so the caller can retain the original still", async () => {
|
||||
riveMocks.load.mockRejectedValueOnce(new Error("offline"));
|
||||
const onLoadError = vi.fn();
|
||||
render(<Persona onLoadError={onLoadError} />);
|
||||
await waitFor(() =>
|
||||
expect(onLoadError).toHaveBeenCalledWith({ type: "loaderror", data: "Error: offline" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||
import { useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
||||
import { EventType, useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { loadPersonaResource } from "./persona-resource";
|
||||
|
||||
const RIVE_SOURCE = "https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv";
|
||||
const STATE_MACHINE = "default";
|
||||
|
||||
function useDeferredRiveInit() {
|
||||
@@ -21,24 +22,45 @@ export type PersonaState = "idle" | "listening" | "thinking" | "speaking" | "asl
|
||||
|
||||
export type PersonaProps = {
|
||||
className?: string;
|
||||
onLoad?: RiveParameters["onLoad"];
|
||||
onLoadError?: RiveParameters["onLoadError"];
|
||||
state?: PersonaState;
|
||||
};
|
||||
|
||||
export const Persona = memo(function Persona({
|
||||
className,
|
||||
onLoad,
|
||||
onLoadError,
|
||||
state = "idle"
|
||||
}: PersonaProps) {
|
||||
const ready = useDeferredRiveInit();
|
||||
const [buffer, setBuffer] = useState<ArrayBuffer>();
|
||||
|
||||
const { rive, RiveComponent } = useRive(
|
||||
ready
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
void loadPersonaResource().then(
|
||||
(bytes) => {
|
||||
if (!cancelled) setBuffer(bytes);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!cancelled) onLoadError?.({ type: EventType.LoadError, data: String(error) });
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ready, onLoadError]);
|
||||
|
||||
const { rive, canvas, RiveComponent } = useRive(
|
||||
ready && buffer
|
||||
? {
|
||||
autoplay: true,
|
||||
onLoad,
|
||||
onLoadError,
|
||||
src: RIVE_SOURCE,
|
||||
stateMachines: STATE_MACHINE
|
||||
buffer,
|
||||
stateMachines: STATE_MACHINE,
|
||||
isTouchScrollEnabled: true
|
||||
}
|
||||
: null
|
||||
);
|
||||
@@ -63,10 +85,26 @@ export const Persona = memo(function Persona({
|
||||
}
|
||||
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
||||
|
||||
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "mouseover" | "mousemove" | "mouseout") => {
|
||||
if (!event.isPrimary || event.pointerType === "touch") return;
|
||||
// Let Rive transform CSS coordinates into artboard coordinates and update
|
||||
// its original pointer constraint. Only forward hover events, never presses
|
||||
// or touch-generated mouse events, so mobile taps retain their button action.
|
||||
canvas?.dispatchEvent(new MouseEvent(type, {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<RiveComponent
|
||||
className={cn("size-16 shrink-0", className)}
|
||||
style={{ filter: "brightness(0.45) contrast(1.35)" }}
|
||||
/>
|
||||
<span
|
||||
className={cn("relative block size-16 shrink-0", className)}
|
||||
onPointerEnter={(event) => forwardPointer(event, "mouseover")}
|
||||
onPointerMove={(event) => forwardPointer(event, "mousemove")}
|
||||
onPointerLeave={(event) => forwardPointer(event, "mouseout")}
|
||||
onPointerCancel={(event) => forwardPointer(event, "mouseout")}
|
||||
>
|
||||
<RiveComponent className="pointer-events-none size-full" />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user