From d6dd2bef5e80a0f67fd0aff90f86a9782f68a351 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:56:34 +0200 Subject: [PATCH] feat(avatar): add the oo1 avatar value for customizable Oo looks (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bot's look is stored in its `color` string, which today only holds a colour, an optional classic shape index or an uploaded picture. The customizable avatars (species, eye styling, accessories, temperament) need a richer value that still travels everywhere `color` already goes: bot rows, group members, mentions and the inbox. - `@rakazo/core` gains `oo-avatar`: an `oo1:` key=value codec that writes only non-default fields, reads every legacy value unchanged, rejects malformed values, and exposes `ooAvatarColor` for plain CSS consumers. - The rig's eye anchors keep the Oo trademark (big eye = small eye × φ), and lenses scale with their eye so glasses stay asymmetric; the shared asymmetry ratio now comes from core. - The API rejects malformed `oo1:` values on bot create and update. - `parseBotAvatar` reads through the codec, so an `oo1:` value already renders with its colour and shape before the new renderer lands. - The agent cursor and the mobile avatar used the raw value as a CSS colour and broke on `#hex::shape_N`; they now use the decoded colour. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude --- apps/api/src/router.ts | 9 + apps/mobile/components/bot-avatar.tsx | 13 +- .../src/components/computer/AgentCursor.tsx | 7 +- packages/core/src/index.ts | 1 + packages/core/src/oo-avatar.test.ts | 118 ++++++++ packages/core/src/oo-avatar.ts | 257 ++++++++++++++++++ packages/ui-web/src/bot-avatar.tsx | 21 +- .../ui-web/src/evoo-character/brand-lock.ts | 4 +- 8 files changed, 410 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/oo-avatar.test.ts create mode 100644 packages/core/src/oo-avatar.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 4a5154a..92225e6 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -112,6 +112,7 @@ import { expandSkillReferencesInPrompt, hasMixedOneShotSchedule, isOneShotRoutineCrons, + isValidAvatarValue, nextCronDateAcrossStrict, } from "@rakazo/core"; import type { PrismaClient, ThreadEvents } from "@rakazo/db"; @@ -1199,6 +1200,7 @@ export function createRouter(deps: RouterDeps) { }), create: authed.bots.create.handler(async ({ context, input }) => { assertBuiltinToolNames(input.allowedBuiltinTools); + assertAvatarValue(input.color); try { return await repos.createBot(context.actor, input); } catch (error) { @@ -1353,6 +1355,7 @@ export function createRouter(deps: RouterDeps) { update: authed.bots.update.handler(async ({ context, input }) => { const existing = await repos.getBot(context.actor, input.botId); assertBuiltinToolNames(input.allowedBuiltinTools); + assertAvatarValue(input.color); if (input.sectionId) { const section = await deps.prisma.botSection.findFirst({ where: { @@ -5830,6 +5833,12 @@ function assertBuiltinToolNames(names: readonly string[] | undefined) { } } +function assertAvatarValue(value: string | undefined) { + if (value !== undefined && !isValidAvatarValue(value)) { + throw new ORPCError("BAD_REQUEST", { message: "Invalid avatar" }); + } +} + function duplicateBotName(name: string) { return `${name.slice(0, 75)} copy`; } diff --git a/apps/mobile/components/bot-avatar.tsx b/apps/mobile/components/bot-avatar.tsx index e8d82ce..a6c1ca7 100644 --- a/apps/mobile/components/bot-avatar.tsx +++ b/apps/mobile/components/bot-avatar.tsx @@ -1,5 +1,11 @@ import type { AvatarStyle } from "@rakazo/contracts"; -import { ACTIVE_RUN_STATUSES, avatarIdentitySeed, organicAvatarPath } from "@rakazo/core"; +import { + ACTIVE_RUN_STATUSES, + avatarIdentitySeed, + DEFAULT_OO_AVATAR, + ooAvatarColor, + organicAvatarPath, +} from "@rakazo/core"; import { memo, useEffect } from "react"; import { View } from "react-native"; import Animated, { @@ -38,6 +44,7 @@ export const BotAvatar = memo(function BotAvatar({ const { t } = useI18n(); const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status); const { avatarStyle } = useAvatarStyle(); + const fill = ooAvatarColor(color) || DEFAULT_OO_AVATAR.color; const visorW = Math.round(size * 0.68); const visorH = Math.round(size * 0.44); const eyeW = Math.max(3, Math.round(size * 0.11)); @@ -46,14 +53,14 @@ export const BotAvatar = memo(function BotAvatar({ return ( {(variant ?? avatarStyle) === "organic" ? ( - + ) : ( (null); const rippleRef = useRef(null); const [typing, setTyping] = useState(false); + const tint = ooAvatarColor(color) || undefined; useEffect(() => { const layer = layerRef.current; @@ -135,7 +136,7 @@ export function AgentCursor({ ref={cursorRef} data-testid="agent-cursor" className="absolute top-0 left-0 opacity-0 transition-opacity duration-300 will-change-transform" - style={{ color }} + style={{ color: tint }} > - + {name} {typing ? : null} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2807620..1a78747 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,7 @@ export * from "./messaging-prompts.js"; export * from "./model-oauth.js"; export * from "./model-probe.js"; export * from "./model-providers.js"; +export * from "./oo-avatar.js"; export * from "./quiet-hours.js"; export * from "./response-bytes.js"; export * from "./run-state.js"; diff --git a/packages/core/src/oo-avatar.test.ts b/packages/core/src/oo-avatar.test.ts new file mode 100644 index 0000000..a3d71e5 --- /dev/null +++ b/packages/core/src/oo-avatar.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_OO_AVATAR, + decodeOoAvatar, + encodeOoAvatar, + isValidAvatarValue, + OO_AVATAR_MAX_LENGTH, + OO_EYE_ASYMMETRY_MIN_RATIO, + OO_RIG_EYES, + type OoAvatar, + ooAvatarColor, + ooLensRadius, +} from "./oo-avatar.js"; + +const styled: OoAvatar = { + species: "cat", + color: "#FF781C", + shape: null, + arms: "paw", + lashes: "full", + liner: "wing", + eyeshadow: "rose", + iris: "green", + sparkle: "heart", + brows: "fine", + cheeks: "freckles", + eyewear: "round", + head: "chef", + neck: "bell", + energy: 1.3, + playful: 0.8, +}; + +describe("oo avatar value", () => { + it("round-trips a fully styled look", () => { + const value = encodeOoAvatar(styled); + expect(value).toBe( + "oo1:c=FF781C;sp=cat;ar=paw;la=full;li=wing;es=rose;ir=green;sk=heart;br=fine;ck=freckles;ew=round;hd=chef;nk=bell;en=1.30;pl=0.80", + ); + expect(decodeOoAvatar(value)).toEqual(styled); + }); + + it("writes only the colour for the default look", () => { + expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "#a97efe" })).toBe("oo1:c=A97EFE"); + expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, shape: 3 })).toBe("oo1:c=8B5CF6;sh=3"); + }); + + it("clamps temperament and falls back to the default colour when encoding", () => { + const value = encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "violet", energy: 9, playful: -1 }); + expect(value).toBe("oo1:c=8B5CF6;en=1.60;pl=0.00"); + expect(decodeOoAvatar(value)).toMatchObject({ energy: 1.6, playful: 0 }); + }); + + it("reads legacy colour and shape values unchanged", () => { + expect(decodeOoAvatar("#8b5cf6::shape_3")).toEqual({ + ...DEFAULT_OO_AVATAR, + color: "#8B5CF6", + shape: 3, + }); + expect(decodeOoAvatar("#10B981::shape_11")?.shape).toBe(3); + expect(decodeOoAvatar("#10B981::shape_x")?.shape).toBe(0); + expect(decodeOoAvatar("#fff")).toMatchObject({ color: "#FFFFFF", shape: null }); + expect(decodeOoAvatar("violet")).toMatchObject({ color: "violet", shape: null }); + }); + + it("treats uploaded pictures as no look", () => { + expect(decodeOoAvatar("data:image/webp;base64,AAAA")).toBeNull(); + expect(ooAvatarColor("data:image/webp;base64,AAAA")).toBe(""); + }); + + it("rejects malformed oo1 values", () => { + for (const value of [ + "oo1:", + "oo1:sp=cat", + "oo1:c=XYZXYZ", + "oo1:c=FF781C;sp=dragon", + "oo1:c=FF781C;sh=8", + "oo1:c=FF781C;en=3", + "oo1:c=FF781C;pl=1.5", + "oo1:c=FF781C;broken", + `oo1:c=FF781C;${"x=1;".repeat(OO_AVATAR_MAX_LENGTH)}`, + ]) { + expect(decodeOoAvatar(value), value).toBeNull(); + expect(isValidAvatarValue(value), value).toBe(false); + } + }); + + it("ignores keys from newer clients", () => { + expect(decodeOoAvatar("oo1:c=FF781C;sp=dog;zz=new")).toMatchObject({ + species: "dog", + color: "#FF781C", + }); + }); + + it("accepts every legacy value for persistence", () => { + expect(isValidAvatarValue("#8B5CF6::shape_0")).toBe(true); + expect(isValidAvatarValue("data:image/png;base64,AAAA")).toBe(true); + }); + + it("exposes a plain colour for CSS consumers", () => { + expect(ooAvatarColor("oo1:c=FF781C;sp=cat")).toBe("#FF781C"); + expect(ooAvatarColor("#8B5CF6::shape_2")).toBe("#8B5CF6"); + }); +}); + +describe("oo rig brand lock", () => { + it("keeps the big eye φ times the small eye", () => { + const ratio = OO_RIG_EYES.big.r / OO_RIG_EYES.small.r; + expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO); + expect(ratio).toBeCloseTo(1.618, 3); + }); + + it("keeps glasses as asymmetric as the eyes", () => { + const ratio = ooLensRadius("big") / ooLensRadius("small"); + expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO); + expect(ooLensRadius("big")).toBeGreaterThan(OO_RIG_EYES.big.r); + }); +}); diff --git a/packages/core/src/oo-avatar.ts b/packages/core/src/oo-avatar.ts new file mode 100644 index 0000000..d3377a3 --- /dev/null +++ b/packages/core/src/oo-avatar.ts @@ -0,0 +1,257 @@ +/** + * The Oo avatar value: one string that describes how a bot looks. + * + * It travels everywhere a bot's `color` already travels (bot rows, group members, + * mentions, inbox), so every surface can draw the full look without new fields. + * + * Formats, newest first: + * - `oo1:c=A97EFE;sp=cat;ew=round` — the full look; only non-default fields are written. + * - `#8B5CF6::shape_3` — legacy colour + classic shape index. + * - `#8B5CF6` or a palette id such as `violet` — legacy colour; shape follows the identity. + * - `data:image/...` — an uploaded picture, not an Oo look. + */ + +export const OO_SPECIES = [ + "classic", + "cat", + "dog", + "bunny", + "bear", + "robot", + "ghost", + "frog", + "penguin", +] as const; +export const OO_ARMS = ["glass", "stub", "paw", "robot", "none"] as const; +export const OO_LASHES = ["none", "soft", "full", "flick"] as const; +export const OO_LINERS = ["none", "wing"] as const; +export const OO_EYESHADOWS = ["none", "rose", "lilac", "gold", "mint"] as const; +export const OO_IRISES = ["classic", "brown", "blue", "green", "violet"] as const; +export const OO_SPARKLES = ["dot", "star", "heart"] as const; +export const OO_BROWS = ["auto", "fine", "bushy"] as const; +export const OO_CHEEKS = ["none", "pink", "peach", "freckles"] as const; +export const OO_EYEWEAR = ["none", "round", "nerd", "sun", "heart", "monocle", "reading"] as const; +export const OO_HEADWEAR = [ + "none", + "chef", + "crown", + "headphones", + "bow", + "beanie", + "flower", + "party", +] as const; +export const OO_NECKWEAR = ["none", "bowtie", "scarf", "bell"] as const; + +/** Classic silhouettes, in the order of the legacy `::shape_N` index. */ +export const OO_CLASSIC_SHAPE_COUNT = 8; +export const OO_ENERGY_RANGE = { min: 0.5, max: 1.6 } as const; + +export type OoSpecies = (typeof OO_SPECIES)[number]; +export type OoArms = (typeof OO_ARMS)[number]; +export type OoLashes = (typeof OO_LASHES)[number]; +export type OoLiner = (typeof OO_LINERS)[number]; +export type OoEyeshadow = (typeof OO_EYESHADOWS)[number]; +export type OoIris = (typeof OO_IRISES)[number]; +export type OoSparkle = (typeof OO_SPARKLES)[number]; +export type OoBrows = (typeof OO_BROWS)[number]; +export type OoCheeks = (typeof OO_CHEEKS)[number]; +export type OoEyewear = (typeof OO_EYEWEAR)[number]; +export type OoHeadwear = (typeof OO_HEADWEAR)[number]; +export type OoNeckwear = (typeof OO_NECKWEAR)[number]; + +export interface OoAvatar { + species: OoSpecies; + /** `#RRGGBB` for `oo1:` values; legacy values keep whatever colour they stored. */ + color: string; + /** Classic silhouette index; `null` lets the identity pick one. */ + shape: number | null; + arms: OoArms; + lashes: OoLashes; + liner: OoLiner; + eyeshadow: OoEyeshadow; + iris: OoIris; + sparkle: OoSparkle; + brows: OoBrows; + cheeks: OoCheeks; + eyewear: OoEyewear; + head: OoHeadwear; + neck: OoNeckwear; + /** Tempo and bounce of every animation. */ + energy: number; + /** 0–1: how often playful extras (ear twitch, tongue, tail wag) happen. */ + playful: number; +} + +export const DEFAULT_OO_AVATAR: OoAvatar = { + species: "classic", + color: "#8B5CF6", + shape: null, + arms: "glass", + lashes: "none", + liner: "none", + eyeshadow: "none", + iris: "classic", + sparkle: "dot", + brows: "auto", + cheeks: "none", + eyewear: "none", + head: "none", + neck: "none", + energy: 1, + playful: 0.5, +}; + +export const OO_AVATAR_PREFIX = "oo1:"; +/** Upper bound for an encoded `oo1:` value; every field written stays far below it. */ +export const OO_AVATAR_MAX_LENGTH = 256; + +type EnumField = Exclude; + +const ENUM_FIELDS: readonly { field: EnumField; key: string; values: readonly string[] }[] = [ + { field: "species", key: "sp", values: OO_SPECIES }, + { field: "arms", key: "ar", values: OO_ARMS }, + { field: "lashes", key: "la", values: OO_LASHES }, + { field: "liner", key: "li", values: OO_LINERS }, + { field: "eyeshadow", key: "es", values: OO_EYESHADOWS }, + { field: "iris", key: "ir", values: OO_IRISES }, + { field: "sparkle", key: "sk", values: OO_SPARKLES }, + { field: "brows", key: "br", values: OO_BROWS }, + { field: "cheeks", key: "ck", values: OO_CHEEKS }, + { field: "eyewear", key: "ew", values: OO_EYEWEAR }, + { field: "head", key: "hd", values: OO_HEADWEAR }, + { field: "neck", key: "nk", values: OO_NECKWEAR }, +]; + +const HEX = /^#?([0-9a-f]{6})$/i; +const SHORT_HEX = /^#([0-9a-f]{3})$/i; + +function normalizeHex(value: string): string | null { + const short = SHORT_HEX.exec(value); + if (short?.[1]) { + const [r, g, b] = short[1]; + return `#${r}${r}${g}${g}${b}${b}`.toUpperCase(); + } + const full = HEX.exec(value); + return full?.[1] ? `#${full[1].toUpperCase()}` : null; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function roundTo(value: number, step: number): number { + return Math.round(value / step) * step; +} + +/** Writes a look as an `oo1:` value. Colours that are not hex fall back to the default. */ +export function encodeOoAvatar(avatar: OoAvatar): string { + const color = normalizeHex(avatar.color) ?? DEFAULT_OO_AVATAR.color; + const parts = [`c=${color.slice(1)}`]; + for (const { field, key } of ENUM_FIELDS) { + if (avatar[field] !== DEFAULT_OO_AVATAR[field]) parts.push(`${key}=${avatar[field]}`); + } + if (avatar.shape !== null) { + parts.push(`sh=${clamp(Math.trunc(avatar.shape), 0, OO_CLASSIC_SHAPE_COUNT - 1)}`); + } + const energy = roundTo(clamp(avatar.energy, OO_ENERGY_RANGE.min, OO_ENERGY_RANGE.max), 0.05); + if (energy !== DEFAULT_OO_AVATAR.energy) parts.push(`en=${energy.toFixed(2)}`); + const playful = roundTo(clamp(avatar.playful, 0, 1), 0.05); + if (playful !== DEFAULT_OO_AVATAR.playful) parts.push(`pl=${playful.toFixed(2)}`); + return OO_AVATAR_PREFIX + parts.join(";"); +} + +function decodeV1(body: string): OoAvatar | null { + const avatar: OoAvatar = { ...DEFAULT_OO_AVATAR }; + let hasColor = false; + for (const pair of body.split(";")) { + if (!pair) continue; + const eq = pair.indexOf("="); + if (eq <= 0) return null; + const key = pair.slice(0, eq); + const value = pair.slice(eq + 1); + if (key === "c") { + const color = normalizeHex(value); + if (!color) return null; + avatar.color = color; + hasColor = true; + continue; + } + if (key === "sh") { + if (!/^\d$/.test(value) || Number(value) >= OO_CLASSIC_SHAPE_COUNT) return null; + avatar.shape = Number(value); + continue; + } + if (key === "en" || key === "pl") { + if (!/^\d(?:\.\d{1,2})?$/.test(value)) return null; + const number = Number(value); + if (key === "en") { + if (number < OO_ENERGY_RANGE.min || number > OO_ENERGY_RANGE.max) return null; + avatar.energy = number; + } else { + if (number > 1) return null; + avatar.playful = number; + } + continue; + } + const spec = ENUM_FIELDS.find((entry) => entry.key === key); + // Unknown keys come from newer clients; keep the rest of the look. + if (!spec) continue; + if (!spec.values.includes(value)) return null; + (avatar as unknown as Record)[spec.field] = value; + } + return hasColor ? avatar : null; +} + +/** + * Reads any stored avatar value as a look. Returns `null` for uploaded pictures and + * for malformed `oo1:` values. + */ +export function decodeOoAvatar(value: string): OoAvatar | null { + if (value.startsWith("data:")) return null; + if (value.startsWith(OO_AVATAR_PREFIX)) { + if (value.length > OO_AVATAR_MAX_LENGTH) return null; + return decodeV1(value.slice(OO_AVATAR_PREFIX.length)); + } + const [color = "", shapePart] = value.split("::shape_"); + let shape: number | null = null; + if (shapePart !== undefined) { + shape = /^\d+$/.test(shapePart) ? Number(shapePart) % OO_CLASSIC_SHAPE_COUNT : 0; + if (!Number.isSafeInteger(shape)) shape = 0; + } + return { ...DEFAULT_OO_AVATAR, color: normalizeHex(color) ?? color, shape }; +} + +/** True when a stored avatar value is acceptable to persist. */ +export function isValidAvatarValue(value: string): boolean { + if (!value.startsWith(OO_AVATAR_PREFIX)) return true; + return decodeOoAvatar(value) !== null; +} + +/** + * The colour part of an avatar value, for places that need a plain CSS colour + * (cursors, rings, chips). Empty for pictures and unreadable values. + */ +export function ooAvatarColor(value: string): string { + return decodeOoAvatar(value)?.color ?? ""; +} + +/** + * Oo eye anchors in the 200×200 avatar rig. Every species draws the eyes here, so the + * pair never moves when the body changes. The small eye is the big one divided by φ. + */ +export const OO_GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; +export const OO_RIG_EYES = { + big: { cx: 80, cy: 108, r: 21 }, + small: { cx: 126, cy: 111, r: 21 / OO_GOLDEN_RATIO }, +} as const; + +/** Minimum big÷small ratio for eyes, lenses and closed-eye arcs (Oo trademark). */ +export const OO_EYE_ASYMMETRY_MIN_RATIO = 1.45; + +/** Lenses scale with the eye they sit on, so glasses keep the Oo asymmetry. */ +export const OO_LENS_SCALE = 1.24; + +export function ooLensRadius(eye: keyof typeof OO_RIG_EYES): number { + return OO_RIG_EYES[eye].r * OO_LENS_SCALE; +} diff --git a/packages/ui-web/src/bot-avatar.tsx b/packages/ui-web/src/bot-avatar.tsx index d98a38a..f3bccd1 100644 --- a/packages/ui-web/src/bot-avatar.tsx +++ b/packages/ui-web/src/bot-avatar.tsx @@ -1,4 +1,4 @@ -import { ACTIVE_RUN_STATUSES } from "@rakazo/core"; +import { ACTIVE_RUN_STATUSES, decodeOoAvatar } from "@rakazo/core"; import { memo, useId, useMemo } from "react"; import { EvooMascot } from "./evoo-brand.js"; import { resolveEvooPose } from "./evoo-character/pose-map.js"; @@ -251,18 +251,13 @@ export function parseBotAvatar( if (rawColor.startsWith("data:image/")) { return { color: "#F97316", isImage: true, imageUrl: rawColor }; } - if (rawColor.includes("::shape_")) { - const parts = rawColor.split("::shape_"); - const rawShapeIdx = parts[1] ?? "0"; - const parsedShapeIdx = /^\d+$/.test(rawShapeIdx) ? Number(rawShapeIdx) : 0; - const shapeIdx = Number.isSafeInteger(parsedShapeIdx) ? parsedShapeIdx : 0; - return { - color: parts[0] || "#F97316", - shapeIndex: shapeIdx % SHIPPED_SHAPE_KEYS.length, - isImage: false, - }; - } - return { color: rawColor, isImage: false }; + const look = decodeOoAvatar(rawColor); + if (!look) return { color: "#F97316", isImage: false }; + return { + color: look.color || "#F97316", + shapeIndex: look.shape ?? undefined, + isImage: false, + }; } export interface BotAvatarProps { diff --git a/packages/ui-web/src/evoo-character/brand-lock.ts b/packages/ui-web/src/evoo-character/brand-lock.ts index c13fd9d..5afb17b 100644 --- a/packages/ui-web/src/evoo-character/brand-lock.ts +++ b/packages/ui-web/src/evoo-character/brand-lock.ts @@ -1,3 +1,5 @@ +import { OO_EYE_ASYMMETRY_MIN_RATIO } from "@rakazo/core"; + /** * evOo brand lock — non-negotiable (Ingo). * @@ -25,7 +27,7 @@ export const EVOO_PUPIL_BIG_R = 14.2; export const EVOO_PUPIL_SMALL_R = EVOO_PUPIL_BIG_R * 0.618034; /** Minimum big÷small ratio — catches accidental symmetrization in tests/CI. */ -export const EVOO_EYE_ASYMMETRY_MIN_RATIO = 1.45; +export const EVOO_EYE_ASYMMETRY_MIN_RATIO = OO_EYE_ASYMMETRY_MIN_RATIO; export const EVOO_BRAND_LOCK_RULES = [ "Two eyes only: one big O, one small o — never equal radii.",