Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

715 lines
21 KiB
TypeScript

import {
Component,
createRef,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent
} from "react";
import type { AgentGUIProvider } from "../../types";
import type { AgentGUIAgentAvatarPresentation } from "./model/agentGuiAgentAvatarPresentation";
import { AgentGuiHeroCarouselScene } from "./agentGuiHeroCarouselScene";
import { AgentGUIVinylPlayer } from "./AgentGUIVinylPlayer";
import styles from "./AgentGUINode.styles";
import { AgentGuiHeroCarouselImageLoad } from "./agentGuiHeroCarouselImageLoad";
export interface AgentGUIHeroCarouselSelectInput {
provider: AgentGUIProvider;
agentTargetId?: string | null;
}
interface AgentGUIHeroAgentCarouselProps {
activeAgentTargetId?: string | null;
isActive?: boolean;
isVisible?: boolean;
items: readonly AgentGUIAgentAvatarPresentation[];
onProviderSelect?: (input: AgentGUIHeroCarouselSelectInput) => void;
providerSelectLabel?: string;
}
interface AgentGUIHeroAgentCarouselState {
centerIndex: number;
badgeImages: readonly (HTMLImageElement | null)[];
coverImages: readonly (HTMLImageElement | null)[];
iconKey: string;
images: readonly (HTMLImageElement | null)[];
imagesReady: boolean;
}
interface AgentGUIHeroCarouselTaskScheduler {
postTask(
callback: () => void,
options: {
delay: number;
priority: "background";
signal: AbortSignal;
}
): Promise<unknown>;
}
const CAROUSEL_WHEEL_STEP_THRESHOLD = 42;
const CAROUSEL_WHEEL_STEP_COOLDOWN_MS = 110;
const CAROUSEL_DRAG_STEP_PX = 52;
const CAROUSEL_SCENE_IDLE_TIMEOUT_MS = 1_000;
const CAROUSEL_REACTIVATION_SCENE_DELAY_MS = 3_000;
function activeAgentIndex(props: AgentGUIHeroAgentCarouselProps): number {
if (!props.activeAgentTargetId) {
return -1;
}
return props.items.findIndex(
(item) => item.agentTargetId === props.activeAgentTargetId
);
}
function carouselIconKey(
items: readonly AgentGUIAgentAvatarPresentation[]
): string {
return items
.map(
(item) =>
`${item.agentTargetId}:${item.provider}:${item.iconUrl}:${item.badge?.iconUrl ?? ""}`
)
.join("|");
}
function emptyPreloadedCarouselImages(
length: number
): (HTMLImageElement | null)[] {
return Array.from({ length }).map((): HTMLImageElement | null => null);
}
// Three.js, ResizeObserver, image decoding, and a non-passive wheel listener
// form one imperative resource lifetime. A class component keeps that lifetime
// explicit instead of rebuilding it from several coordinating React effects.
export class AgentGUIHeroAgentCarousel extends Component<
AgentGUIHeroAgentCarouselProps,
AgentGUIHeroAgentCarouselState
> {
private readonly stageRef = createRef<HTMLDivElement>();
private readonly canvasRef = createRef<HTMLCanvasElement>();
private scene: AgentGuiHeroCarouselScene | null = null;
private resizeObserver: ResizeObserver | null = null;
private firstPaintFrameId: number | null = null;
private firstPaintReady = false;
private sceneMountDelayController: AbortController | null = null;
private sceneMountEligibleAt = 0;
private sceneMountIdleId: number | null = null;
private imagePreloadGeneration = 0;
private imageLoad: AgentGuiHeroCarouselImageLoad | null = null;
private wheelListenerAttached = false;
private wheelAccumulated = 0;
private wheelLastStepAt = 0;
private dragState: { pointerId: number; anchorX: number } | null = null;
private suppressClick = false;
private pointerActivatedIndex: number | null = null;
state: AgentGUIHeroAgentCarouselState = {
badgeImages: [],
centerIndex: Math.max(activeAgentIndex(this.props), 0),
coverImages: [],
iconKey: carouselIconKey(this.props.items),
images: [],
imagesReady: this.props.items.length === 0
};
componentDidMount(): void {
if (this.ownsImperativeResources()) {
this.activateImperativeResources();
}
this.syncWheelListener();
}
componentDidUpdate(previousProps: AgentGUIHeroAgentCarouselProps): void {
const iconKey = carouselIconKey(this.props.items);
const ownedResources = this.props.isVisible !== false;
const previouslyOwnedResources = previousProps.isVisible !== false;
if (!ownedResources) {
if (previouslyOwnedResources) {
this.deactivateImperativeResources(iconKey);
} else if (iconKey !== this.state.iconKey) {
this.resetPreloadedImages(iconKey);
}
this.syncWheelListener();
return;
}
if (iconKey !== this.state.iconKey) {
this.cancelImagePreload();
this.disposeScene();
this.setState(
{
centerIndex: Math.max(activeAgentIndex(this.props), 0),
badgeImages: [],
coverImages: [],
iconKey,
images: [],
imagesReady: this.props.items.length === 0
},
() => this.activateImperativeResources()
);
return;
}
if (!previouslyOwnedResources) {
this.activateImperativeResources(true);
}
if (
previousProps.activeAgentTargetId !== this.props.activeAgentTargetId ||
previousProps.items !== this.props.items
) {
const activeIndex = activeAgentIndex(this.props);
if (activeIndex >= 0 && activeIndex !== this.state.centerIndex) {
this.setState({ centerIndex: activeIndex });
this.scene?.moveTo(activeIndex);
}
}
this.syncWheelListener();
}
componentWillUnmount(): void {
this.cancelImagePreload();
if (this.firstPaintFrameId !== null) {
window.cancelAnimationFrame(this.firstPaintFrameId);
this.firstPaintFrameId = null;
}
this.removeWheelListener();
this.disposeScene();
}
private interactive(): boolean {
return this.props.onProviderSelect != null && this.props.items.length > 0;
}
private ownsImperativeResources(): boolean {
return this.props.isVisible !== false;
}
private activateImperativeResources(deferScene = false): void {
if (!this.ownsImperativeResources()) {
return;
}
this.sceneMountEligibleAt = deferScene
? performance.now() + CAROUSEL_REACTIVATION_SCENE_DELAY_MS
: 0;
this.waitForFirstPaint();
if (!this.state.imagesReady) {
this.preloadImages();
} else {
this.scheduleSceneMount();
}
}
private deactivateImperativeResources(iconKey: string): void {
this.cancelImagePreload();
if (this.firstPaintFrameId !== null) {
window.cancelAnimationFrame(this.firstPaintFrameId);
this.firstPaintFrameId = null;
}
this.removeWheelListener();
this.disposeScene();
this.resetPreloadedImages(iconKey);
}
private resetPreloadedImages(iconKey: string): void {
this.setState({
badgeImages: [],
coverImages: [],
iconKey,
images: [],
imagesReady: this.props.items.length === 0
});
}
private cancelImagePreload(): void {
this.imagePreloadGeneration += 1;
this.imageLoad?.cancel();
this.imageLoad = null;
}
private waitForFirstPaint(): void {
if (this.firstPaintReady || this.firstPaintFrameId !== null) {
return;
}
this.firstPaintFrameId = window.requestAnimationFrame(() => {
this.firstPaintFrameId = window.requestAnimationFrame(() => {
this.firstPaintFrameId = null;
this.firstPaintReady = true;
this.scheduleSceneMount();
});
});
}
private preloadImages(): void {
this.imageLoad?.cancel();
this.imageLoad = null;
const generation = ++this.imagePreloadGeneration;
const items = this.props.items;
if (items.length === 0) {
this.setState({
badgeImages: [],
coverImages: [],
images: [],
imagesReady: true
});
return;
}
if (typeof Image !== "function") {
this.setState(
{
coverImages: emptyPreloadedCarouselImages(items.length),
badgeImages: emptyPreloadedCarouselImages(items.length),
images: emptyPreloadedCarouselImages(items.length),
imagesReady: true
},
() => this.scheduleSceneMount()
);
return;
}
this.setState({
badgeImages: emptyPreloadedCarouselImages(items.length),
coverImages: emptyPreloadedCarouselImages(items.length),
images: emptyPreloadedCarouselImages(items.length),
imagesReady: false
});
const imageLoad = new AgentGuiHeroCarouselImageLoad(items);
this.imageLoad = imageLoad;
void imageLoad.result.then((preloaded) => {
if (
generation !== this.imagePreloadGeneration ||
carouselIconKey(this.props.items) !== this.state.iconKey ||
!this.ownsImperativeResources()
) {
return;
}
this.setState(
{
badgeImages: preloaded.badges,
coverImages: preloaded.covers,
images: preloaded.icons,
imagesReady: true
},
() => this.scheduleSceneMount()
);
});
}
private scheduleSceneMount(): void {
const sceneMountDelay = this.sceneMountEligibleAt - performance.now();
if (sceneMountDelay > 0) {
if (this.sceneMountDelayController !== null) {
return;
}
const scheduler = (
window as Window & {
scheduler?: AgentGUIHeroCarouselTaskScheduler;
}
).scheduler;
if (scheduler) {
const controller = new AbortController();
this.sceneMountDelayController = controller;
void scheduler
.postTask(
() => {
this.sceneMountDelayController = null;
this.sceneMountEligibleAt = 0;
this.scheduleSceneMount();
},
{
delay: sceneMountDelay,
priority: "background",
signal: controller.signal
}
)
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
if (this.sceneMountDelayController === controller) {
this.sceneMountDelayController = null;
this.sceneMountEligibleAt = 0;
this.scheduleSceneMount();
}
});
return;
}
this.sceneMountEligibleAt = 0;
}
if (
this.scene ||
this.sceneMountIdleId !== null ||
!this.ownsImperativeResources() ||
!this.canvasRef.current ||
!this.stageRef.current ||
!this.firstPaintReady ||
!this.state.imagesReady ||
this.props.items.length === 0
) {
return;
}
if (typeof window.requestIdleCallback === "function") {
this.sceneMountIdleId = window.requestIdleCallback(
() => {
this.sceneMountIdleId = null;
this.mountScene();
},
{ timeout: CAROUSEL_SCENE_IDLE_TIMEOUT_MS }
);
return;
}
this.mountScene();
}
private cancelScheduledSceneMount(): void {
this.sceneMountDelayController?.abort();
this.sceneMountDelayController = null;
this.sceneMountEligibleAt = 0;
if (this.sceneMountIdleId !== null) {
window.cancelIdleCallback(this.sceneMountIdleId);
this.sceneMountIdleId = null;
}
}
private mountScene(): void {
const canvas = this.canvasRef.current;
const stage = this.stageRef.current;
if (
this.scene ||
!canvas ||
!stage ||
!this.ownsImperativeResources() ||
!this.firstPaintReady ||
!this.state.imagesReady ||
this.props.items.length === 0
) {
return;
}
const scene = AgentGuiHeroCarouselScene.create({
canvas,
items: this.props.items,
loadedCoverImages: this.state.coverImages,
loadedBadgeImages: this.state.badgeImages,
loadedImages: this.state.images,
onSettle: this.handleSceneSettle
});
this.scene = scene;
if (!scene) {
return;
}
scene.moveTo(this.state.centerIndex, false);
const resize = (): void => {
// Use layout dimensions rather than the transformed bounding rect. The
// carousel layer is scaled, so getBoundingClientRect() can return
// fractional dimensions that no longer match the canvas CSS box after
// WebGL floors its drawing buffer size.
scene.setSize(stage.clientWidth, stage.clientHeight);
};
resize();
this.resizeObserver =
typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
this.resizeObserver?.observe(stage);
}
private disposeScene(): void {
this.cancelScheduledSceneMount();
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.scene?.dispose();
this.scene = null;
}
private syncWheelListener(): void {
const stage = this.stageRef.current;
const shouldAttach = Boolean(
stage && this.ownsImperativeResources() && this.interactive()
);
if (shouldAttach && !this.wheelListenerAttached) {
stage!.addEventListener("wheel", this.handleWheel, { passive: false });
this.wheelListenerAttached = true;
return;
}
if (!shouldAttach) {
this.removeWheelListener();
}
}
private removeWheelListener(): void {
if (!this.wheelListenerAttached) {
return;
}
this.stageRef.current?.removeEventListener("wheel", this.handleWheel);
this.wheelListenerAttached = false;
this.wheelAccumulated = 0;
this.wheelLastStepAt = 0;
}
private readonly handleSceneSettle = (index: number): void => {
this.setState({ centerIndex: index });
if (index !== activeAgentIndex(this.props)) {
this.selectIndex(index);
}
};
private selectIndex(index: number): void {
const item = this.props.items[index];
if (!item || !this.props.onProviderSelect) {
return;
}
this.props.onProviderSelect({
provider: item.provider,
agentTargetId: item.targetId
});
}
private stepBy(direction: 1 | -1): void {
if (!this.scene || this.props.items.length <= 1) {
return;
}
const centerIndex = this.scene.stepBy(direction);
this.setState({ centerIndex });
}
private readonly handleWheel = (event: WheelEvent): void => {
const delta =
Math.abs(event.deltaX) >= Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (delta === 0) {
return;
}
event.preventDefault();
if (Math.sign(delta) !== Math.sign(this.wheelAccumulated)) {
this.wheelAccumulated = 0;
}
this.wheelAccumulated += delta;
const now = performance.now();
if (
Math.abs(this.wheelAccumulated) < CAROUSEL_WHEEL_STEP_THRESHOLD ||
now - this.wheelLastStepAt < CAROUSEL_WHEEL_STEP_COOLDOWN_MS
) {
return;
}
this.stepBy(this.wheelAccumulated > 0 ? 1 : -1);
this.wheelAccumulated = 0;
this.wheelLastStepAt = now;
};
private readonly handlePointerDown = (
event: ReactPointerEvent<HTMLDivElement>
): void => {
if (!this.interactive() || event.button !== 0) {
return;
}
this.dragState = { pointerId: event.pointerId, anchorX: event.clientX };
this.suppressClick = false;
};
private readonly handlePointerMove = (
event: ReactPointerEvent<HTMLDivElement>
): void => {
if (!this.dragState || this.dragState.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - this.dragState.anchorX;
if (Math.abs(deltaX) < CAROUSEL_DRAG_STEP_PX) {
return;
}
this.dragState.anchorX = event.clientX;
this.suppressClick = true;
this.pointerActivatedIndex = null;
this.stepBy(deltaX < 0 ? 1 : -1);
};
private readonly handlePointerEnd = (
event: ReactPointerEvent<HTMLDivElement>
): void => {
if (this.dragState?.pointerId === event.pointerId) {
this.dragState = null;
}
};
private readonly handleClickCapture = (event: ReactMouseEvent): void => {
if (!this.suppressClick) {
return;
}
this.suppressClick = false;
this.pointerActivatedIndex = null;
event.preventDefault();
event.stopPropagation();
};
private readonly handleKeyDown = (
event: ReactKeyboardEvent<HTMLDivElement>
): void => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
event.preventDefault();
this.stepBy(event.key === "ArrowRight" ? 1 : -1);
};
private handleItemClick(index: number): void {
this.setState({ centerIndex: index });
this.scene?.moveTo(index);
this.selectIndex(index);
}
private pickAt(
event:
| ReactMouseEvent<HTMLCanvasElement>
| ReactPointerEvent<HTMLCanvasElement>
): number | null {
const canvas = this.canvasRef.current;
if (!this.scene || !canvas || !this.interactive()) {
return null;
}
const rect = canvas.getBoundingClientRect();
return this.scene.pick(
event.clientX - rect.left,
event.clientY - rect.top,
rect.width,
rect.height
);
}
private activateOnPointerDown(index: number, event: ReactPointerEvent): void {
if (event.button !== 0) {
return;
}
this.pointerActivatedIndex = index;
this.handleItemClick(index);
}
private activateOnClick(index: number): void {
if (this.pointerActivatedIndex === index) {
this.pointerActivatedIndex = null;
return;
}
this.pointerActivatedIndex = null;
this.handleItemClick(index);
}
private readonly handleCanvasPointerDown = (
event: ReactPointerEvent<HTMLCanvasElement>
): void => {
const index = this.pickAt(event);
if (index !== null) {
this.activateOnPointerDown(index, event);
}
};
private readonly handleCanvasClick = (
event: ReactMouseEvent<HTMLCanvasElement>
): void => {
const index = this.pickAt(event);
if (index !== null) {
this.activateOnClick(index);
}
};
private readonly handleCanvasHover = (
event: ReactPointerEvent<HTMLCanvasElement>
): void => {
const canvas = this.canvasRef.current;
if (!this.scene || !canvas) {
return;
}
const rect = canvas.getBoundingClientRect();
const hoveredIndex = this.scene.hover(
event.clientX - rect.left,
event.clientY - rect.top,
rect.width,
rect.height
);
canvas.style.cursor = hoveredIndex !== null ? "pointer" : "";
};
private readonly handleCanvasLeave = (): void => {
if (this.canvasRef.current) {
this.canvasRef.current.style.cursor = "";
}
};
render(): React.JSX.Element {
const interactive = this.interactive();
return (
<div
ref={this.stageRef}
aria-hidden={interactive ? undefined : "true"}
aria-label={interactive ? this.props.providerSelectLabel : undefined}
role={interactive ? "group" : undefined}
className={styles.emptyHeroCarousel}
data-icons-ready={this.state.imagesReady}
onKeyDown={interactive ? this.handleKeyDown : undefined}
onPointerDown={interactive ? this.handlePointerDown : undefined}
onPointerMove={interactive ? this.handlePointerMove : undefined}
onPointerUp={interactive ? this.handlePointerEnd : undefined}
onPointerCancel={interactive ? this.handlePointerEnd : undefined}
onClickCapture={interactive ? this.handleClickCapture : undefined}
>
<AgentGUIVinylPlayer
selectedAgent={
this.props.items[this.state.centerIndex] ??
this.props.items[0] ??
null
}
isPlaying={
this.props.isActive !== false && this.props.isVisible !== false
}
/>
<canvas
ref={this.canvasRef}
aria-hidden="true"
className={styles.emptyHeroCarouselCanvas}
onClick={interactive ? this.handleCanvasClick : undefined}
onPointerDown={interactive ? this.handleCanvasPointerDown : undefined}
onPointerMove={interactive ? this.handleCanvasHover : undefined}
onPointerLeave={interactive ? this.handleCanvasLeave : undefined}
/>
{this.props.items.map((item, index) => {
const isCenter = index === this.state.centerIndex;
const key = `${item.agentTargetId}:${item.iconUrl}`;
if (this.props.onProviderSelect) {
const itemLabel = item.badge?.label
? `${item.label}, ${item.badge.label}`
: item.label;
const label = this.props.providerSelectLabel
? `${this.props.providerSelectLabel}: ${itemLabel}`
: itemLabel;
return (
<button
key={key}
type="button"
className={styles.emptyHeroCarouselItem}
data-agent-target-id={item.agentTargetId}
data-provider={item.provider}
data-provider-active={isCenter}
aria-label={label}
aria-pressed={isCenter}
title={item.label}
onPointerDown={(event) =>
this.activateOnPointerDown(index, event)
}
onClick={() => this.activateOnClick(index)}
>
{item.label}
</button>
);
}
return (
<span
key={key}
className={styles.emptyHeroCarouselItem}
data-provider={item.provider}
data-provider-active={isCenter}
/>
);
})}
</div>
);
}
}