import type { OoMoment } from "@rakazo/core"; import { ACTIVE_RUN_STATUSES, DEFAULT_OO_AVATAR, decodeOoAvatar, OO_CLASSIC_SHAPE_COUNT, OO_SUCCESS_SECONDS, ooIdentityShapeIndex, ooMomentAfterStatus, ooMomentForStatus, resolveOoColor, } from "@rakazo/core"; import { memo, useEffect, useId, useMemo, useRef, useState } from "react"; import { normalizeEvooPose } from "./evoo-character/pose-map.js"; import type { EvooPose } from "./evoo-character/types.js"; import { cn } from "./lib/utils.js"; import { OoFigure } from "./oo-figure.js"; import "./styles.css"; export function parseBotAvatar( rawColor: string, _identity?: string, ): { color: string; shapeIndex?: number; isImage: boolean; imageUrl?: string; } { if (!rawColor) return { color: "#F97316", isImage: false }; // Only data: image URLs are rendered. Arbitrary http(s)/blob values in `color` // must not become (SSRF / tracking when other members view the bot). if (rawColor.startsWith("data:image/")) { return { color: "#F97316", isImage: true, imageUrl: rawColor }; } const look = decodeOoAvatar(rawColor); if (!look) return { color: "#F97316", isImage: false }; return { color: look.color || "#F97316", shapeIndex: look.shape ?? undefined, isImage: false, }; } /** Mascot poses that have their own Oo moment under another name. */ const POSE_MOMENT: Partial> = { analyzing: "reading", skeptical: "thinking", curious: "idle", delighted: "success", error: "concerned", collaborating: "working", }; /** Mascot pose → Oo moment; every canonical pose has one. */ export function ooMomentForPose(pose: EvooPose): OoMoment { const canonical = normalizeEvooPose(pose); return POSE_MOMENT[canonical] ?? (canonical as OoMoment); } /** A short moment after a status change, such as joy when a run finishes. */ export function useOoStatusMoment(status?: string): OoMoment | null { const previous = useRef(status); const [moment, setMoment] = useState(null); useEffect(() => { const next = ooMomentAfterStatus(previous.current, status); previous.current = status; setMoment(next); if (!next) return; const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000); return () => clearTimeout(timer); }, [status]); return moment; } export interface BotAvatarProps { color: string; size?: number; status?: string; identity?: string; /** Explicit mascot pose; when omitted, derived from `status`. */ pose?: EvooPose; /** Explicit moment; wins over `pose` and `status`. */ moment?: OoMoment; /** Draw one still frame. */ still?: boolean; /** Eyes follow the pointer and a tap boops the Oo. */ interactive?: boolean; className?: string; } function WorkingRing({ size, color }: { size: number; color: string }) { const id = useId().replace(/[^a-zA-Z0-9-_]/g, ""); return ( ); } export const BotAvatar = memo(function BotAvatar({ color, size = 36, status, identity = "", pose, moment, still, interactive, className, }: BotAvatarProps) { const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status); const parsed = useMemo(() => parseBotAvatar(color, identity), [color, identity]); const look = useMemo( () => (parsed.isImage ? null : (decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR)), [color, parsed.isImage], ); const effectiveId = identity || parsed.color || "agent"; const colorDef = useMemo( () => resolveOoColor(effectiveId, parsed.color), [effectiveId, parsed.color], ); const statusMoment = useOoStatusMoment(status); const activeMoment = moment ?? statusMoment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status)); if (parsed.isImage && parsed.imageUrl) { return (
{isWorking ? : null}
); } return (
{isWorking ? : null}
); }); /** A classic silhouette in the picker, drawn as the Oo it would become. */ export function OoShapePreview({ shapeIndex, color, selected, onClick, }: { shapeIndex: number; color: string; selected?: boolean; onClick?: () => void; }) { const colorDef = resolveOoColor("preview", color); return ( ); }