feat(avatars): bots offer a new Oo look from chat (#145)

update_bot takes a look (oo1: value), validated and normalized in core.
A suggested look renders as a "New look" card with the avatar preview on
web and mobile; accepting applies it to the bot.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 04:03:10 +02:00 committed by GitHub
parent 460eede226
commit c69f00e891
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 188 additions and 28 deletions

View File

@ -13,6 +13,7 @@ import { useState } from "react";
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
import { mobileTokens } from "../lib/appearance";
import { dateLocaleForUi, useI18n } from "../lib/i18n";
import { BotAvatar } from "./bot-avatar";
import { CookingMode, useNow } from "./cooking-mode";
import { NativeSymbol } from "./native-symbol";
@ -116,18 +117,21 @@ export function ThreadCard({
{block.sections?.map((part, index) => {
if (part.type === "header") {
return (
<View key={index} style={{ gap: 2 }}>
<Text style={[styles.title, { color: tokens.foreground }]}>{part.title}</Text>
{part.subtitle ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{part.subtitle}
</Text>
) : null}
{part.meta?.length ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{part.meta.join(" · ")}
</Text>
) : null}
<View key={index} style={styles.header}>
{part.avatar ? <BotAvatar color={part.avatar} size={56} /> : null}
<View style={styles.headerText}>
<Text style={[styles.title, { color: tokens.foreground }]}>{part.title}</Text>
{part.subtitle ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{part.subtitle}
</Text>
) : null}
{part.meta?.length ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{part.meta.join(" · ")}
</Text>
) : null}
</View>
</View>
);
}
@ -394,6 +398,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 14,
},
header: { flexDirection: "row", alignItems: "center", gap: 12 },
headerText: { flex: 1, gap: 2 },
title: { fontSize: 16, fontWeight: "600" },
body: { fontSize: 15.5, lineHeight: 22 },
small: { fontSize: 13.5 },

View File

@ -0,0 +1,43 @@
import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
const LOOK = "oo1:c=F4A7B9;sp=cat;la=soft;ck=pink;ew=round;hd=chef";
async function useAppearance(page: Page, appearance: "light" | "dark") {
const settings = await openUserSettings(page);
await settings.getByTestId(`ui-appearance-${appearance}`).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", appearance);
await settings.getByRole("button", { name: "Close user settings" }).click();
await expect(settings).toBeHidden();
}
test("a bot offers a new look and wears it once accepted", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `look-suggestion-${stamp}@rakazo.test`, "password12", "Look Suggestion");
await completeOnboarding(page);
await page.getByPlaceholder(/Message/).fill("Show me a new look.");
await page.keyboard.press("Enter");
const card = page.getByTestId("thread-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card).toContainText("New look");
await expect(card).toContainText("Ready for recipe nights.");
await expect(card.locator(".rakazo-bot-avatar")).toHaveCount(1);
const looks = async () =>
(await rpc<Array<{ color: string }>>(page, "bots/list", {})).map((bot) => bot.color);
expect(await looks()).not.toContain(LOOK);
await useAppearance(page, "light");
await card.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "look-suggestion-light");
await useAppearance(page, "dark");
await card.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "look-suggestion-dark");
await card.getByRole("button", { name: "Accept" }).click();
await expect(card.getByTestId("card-suggestion-status")).toContainText("Done", {
timeout: 60_000,
});
await expect.poll(looks, { timeout: 30_000 }).toContain(LOOK);
});

View File

@ -10,7 +10,7 @@ import {
formatCardTimerLength,
suggestionStatus,
} from "@rakazo/core";
import { Button, Checkbox, cn } from "@rakazo/ui-web";
import { BotAvatar, Button, Checkbox, cn } from "@rakazo/ui-web";
import { Minus, Plus, Timer } from "lucide-react";
import { useId, useState } from "react";
import { Shimmer, SuccessPop } from "./ai/primitives";
@ -107,14 +107,19 @@ export function ThreadCard({
{block.sections?.map((part, index) => {
if (part.type === "header") {
return (
<div key={index} className="flex flex-col gap-0.5">
<div className="text-ui-body font-medium text-foreground">{part.title}</div>
{part.subtitle ? (
<div className="text-ui-body-sm text-muted-foreground">{part.subtitle}</div>
) : null}
{part.meta?.length ? (
<div className="text-ui-body-sm text-muted-foreground">{part.meta.join(" · ")}</div>
) : null}
<div key={index} className="flex items-center gap-3">
{part.avatar ? <BotAvatar color={part.avatar} size={56} /> : null}
<div className="flex min-w-0 flex-col gap-0.5">
<div className="text-ui-body font-medium text-foreground">{part.title}</div>
{part.subtitle ? (
<div className="text-ui-body-sm text-muted-foreground">{part.subtitle}</div>
) : null}
{part.meta?.length ? (
<div className="text-ui-body-sm text-muted-foreground">
{part.meta.join(" · ")}
</div>
) : null}
</div>
</div>
);
}

View File

@ -1158,6 +1158,7 @@ export function ShellPage() {
void refreshBots(true).catch(() => undefined);
} else if (
event.type === "bot.spawned" ||
event.type === "bot.updated" ||
event.type === "bot.deleted" ||
event.type === "run.started" ||
isRunTerminalEvent(event) ||

View File

@ -13,6 +13,7 @@ import {
TranscribeInputSchema,
USER_FACT_CATEGORIES,
} from "@rakazo/contracts";
import { ooLookFormat } from "@rakazo/core";
import { z } from "zod";
import { coachTools } from "./coach-tools.js";
import { mediaToolInputSchema } from "./media-tools.js";
@ -1127,7 +1128,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{
name: "update_bot",
description:
"Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), description, speaking voice, and whether replies are read aloud. Call this when the user asks you to rename yourself, change your title/description, pick a different voice (e.g. Eva, Bernd), or turn speaking on/off. Do not claim you updated the profile without calling this tool.",
"Update this bot's own profile fields that the user sees in chat: name (header and list label), title (short role line), description, Oo look, speaking voice, and whether replies are read aloud. Call this when the user asks you to rename yourself, change your title/description or look, pick a different voice (e.g. Eva, Bernd), or turn speaking on/off. Do not claim you updated the profile without calling this tool.",
inputSchema: {
type: "object",
properties: {
@ -1143,6 +1144,10 @@ export const builtinAgentTools: ConnectorTool[] = [
type: "string",
description: "Longer blurb describing what this bot does.",
},
look: {
type: "string",
description: `Oo avatar look. ${ooLookFormat()} To offer a look instead of switching on your own, wrap this call in suggest so the user sees a preview and accepts it.`,
},
voice_id: {
type: "string",
description:

View File

@ -66,6 +66,8 @@ import {
messagingDmSurfaceNote,
nextCronDateAcross,
nextFence,
normalizeOoLook,
ooLookFormat,
planActionGate,
promptInvokesSkill,
redactSecrets,
@ -3463,6 +3465,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
name?: string;
title?: string;
description?: string;
color?: string;
voiceId?: string | null;
autoSpeak?: boolean;
} = {};
@ -3474,9 +3477,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
patch.voiceId = nextVoiceId.length > 0 ? nextVoiceId : null;
}
if (args.auto_speak !== undefined) patch.autoSpeak = Boolean(args.auto_speak);
if (args.look !== undefined) {
const look = normalizeOoLook(args.look);
if (!look) return finish({ error: `look must be ${ooLookFormat()}` });
patch.color = look;
}
if (Object.keys(patch).length === 0) {
return finish({
error: "Provide at least one of name, title, description, voice_id, or auto_speak.",
error:
"Provide at least one of name, title, description, look, voice_id, or auto_speak.",
});
}
if (patch.name !== undefined) {
@ -3522,6 +3531,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
name: true,
title: true,
description: true,
color: true,
voiceId: true,
autoSpeak: true,
},
@ -3549,6 +3559,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
name: updated.name,
title: updated.title,
description: updated.description,
look: updated.color,
voiceId: updated.voiceId,
autoSpeak: updated.autoSpeak,
});
@ -4043,7 +4054,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
? "spawn_bot creates a lasting regular bot (own chat, computer, memory) that appears in the user's bot list. If the user asked to create a bot, call spawn_bot once and stop. Do not run_subagent to demo it."
: undefined,
exposedToolNames.has("update_bot")
? "update_bot updates this bot's own name (chat header / list label), title, description, voice_id, and auto_speak. When the user asks you to rename yourself, change your title or description, pick a voice, or turn speaking on/off, call update_bot — do not claim you changed them without the tool."
? "update_bot updates this bot's own name (chat header / list label), title, description, Oo look, voice_id, and auto_speak. When the user asks you to rename yourself, change your title, description or look, pick a voice, or turn speaking on/off, call update_bot — do not claim you changed them without the tool. When a new look would suit you but the user did not ask for it, offer it with suggest wrapping update_bot so they see it first."
: undefined,
exposedToolNames.has("run_subagent")
? "run_subagent is a short helper inside this turn only. It is not a bot, has no thread, and does not show in the list. Use it for parallel work you will summarize here."

View File

@ -27,7 +27,7 @@ export function formatProductSelfModificationInstruction(
"You run inside evOo. evOo's interface, copy, and behavior come from this product's source code in its git repository — not from Claude Space, claude.ai, or any third-party chat host.",
"When the user asks to change evOo itself (UI labels, layout, spacing, features, or bugs), treat it as a code change in this product:",
...codePaths.map((line) => `- ${line}.`),
"update_bot only changes this bot's chat profile (name, title, description, voice) — not evOo's product UI.",
"update_bot only changes this bot's chat profile (name, title, description, look, voice) — not evOo's product UI.",
"Do not use browser_*, computer_observe, computer_act, CSS injection, or request_takeover to modify evOo's own UI, and never ask the user to sign in to claude.ai or other third-party services for product changes.",
"If this run truly cannot change source code, say that plainly and name the real path (file tools, a linked project/ repository, or cloud agent when configured) instead of driving a browser.",
].join(" ");

View File

@ -344,6 +344,25 @@ export function inferScript(
},
];
}
// A look is offered, not applied: the card shows it and the user decides.
if (lower.includes("show me a new look")) {
return [
{
assistant: "",
toolCalls: [
{
name: "suggest",
args: {
tool: "update_bot",
args: { look: "oo1:c=F4A7B9;sp=cat;la=soft;ck=pink;ew=round;hd=chef" },
why: "Ready for recipe nights.",
},
},
],
complete: true,
},
];
}
// Everyday answers come as a card the user taps, not a list in prose.
if (lower.includes("what should we cook tonight")) {
return [

View File

@ -3,6 +3,8 @@ import type { CardBlock } from "@rakazo/contracts";
import {
buildSuggestionCard,
describeSuggestion,
normalizeOoLook,
ooLookFormat,
UNSUGGESTABLE_TOOLS,
withSuggestionStatus,
} from "@rakazo/core";
@ -72,6 +74,9 @@ export async function createSuggestionFromTool(
const raw = input.args.args;
const args =
raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
if (tool === "update_bot" && args.look !== undefined && !normalizeOoLook(args.look)) {
return { error: `look must be ${ooLookFormat()}` };
}
const owner = await resolveScheduleOwner(
deps.prisma,
{

View File

@ -31,6 +31,8 @@ export const CardHeaderSchema = z.object({
subtitle: z.string().trim().max(CARD_LIMITS.title).optional(),
/** Short facts beside the title, such as "30 min" or "4 servings". */
meta: z.array(Label).max(CARD_LIMITS.meta).optional(),
/** An Oo look (`oo1:…`) drawn beside the title, so a proposed look is seen, not described. */
avatar: z.string().startsWith("oo1:").max(256).optional(),
});
export const CardFactsSchema = z.object({

View File

@ -4,12 +4,14 @@ import {
decodeOoAvatar,
encodeOoAvatar,
isValidAvatarValue,
normalizeOoLook,
OO_AVATAR_MAX_LENGTH,
OO_EYE_ASYMMETRY_MIN_RATIO,
OO_RIG_EYES,
type OoAvatar,
ooAvatarColor,
ooLensRadius,
ooLookFormat,
} from "./oo-avatar.js";
const styled: OoAvatar = {
@ -101,6 +103,20 @@ describe("oo avatar value", () => {
expect(ooAvatarColor("oo1:c=FF781C;sp=cat")).toBe("#FF781C");
expect(ooAvatarColor("#8B5CF6::shape_2")).toBe("#8B5CF6");
});
it("takes a model-written look only as a valid oo1 value", () => {
expect(normalizeOoLook(" oo1:c=#f4a7b9;sp=cat;ew=round ")).toBe("oo1:c=F4A7B9;sp=cat;ew=round");
expect(normalizeOoLook("oo1:sp=cat")).toBeNull();
expect(normalizeOoLook("oo1:c=F4A7B9;sp=dragon")).toBeNull();
expect(normalizeOoLook("#8B5CF6")).toBeNull();
expect(normalizeOoLook(42)).toBeNull();
});
it("documents the format with the example it gives", () => {
const example = /e\.g\. (oo1:\S+)\./.exec(ooLookFormat())?.[1];
expect(example && normalizeOoLook(example)).toBe(example);
expect(ooLookFormat()).toContain("penguin");
});
});
describe("oo rig brand lock", () => {

View File

@ -161,6 +161,14 @@ export function encodeOoAvatar(avatar: OoAvatar): string {
return OO_AVATAR_PREFIX + parts.join(";");
}
/** How an `oo1:` value is written, for models that compose a look as text. */
export function ooLookFormat(): string {
const fields = ENUM_FIELDS.map(
({ field, key, values }) => `${key} ${field} (${values.join("|")})`,
);
return `oo1: followed by key=value pairs joined by ";", e.g. oo1:c=F4A7B9;sp=cat;ew=round;hd=bow. c is the body colour as hex (required). Other keys: ${fields.join(", ")}. Omitted keys keep their defaults.`;
}
function decodeV1(body: string): OoAvatar | null {
const avatar: OoAvatar = { ...DEFAULT_OO_AVATAR };
let hasColor = false;
@ -222,6 +230,14 @@ export function decodeOoAvatar(value: string): OoAvatar | null {
return { ...DEFAULT_OO_AVATAR, color: normalizeHex(color) ?? color, shape };
}
/** A look written by a model, as the canonical `oo1:` value, or `null` when it is not one. */
export function normalizeOoLook(value: unknown): string | null {
if (typeof value !== "string") return null;
const raw = value.trim();
const look = raw.startsWith(OO_AVATAR_PREFIX) ? decodeOoAvatar(raw) : null;
return look ? encodeOoAvatar(look) : null;
}
/** True when a stored avatar value is acceptable to persist. */
export function isValidAvatarValue(value: string): boolean {
if (!value.startsWith(OO_AVATAR_PREFIX)) return true;

View File

@ -1,4 +1,4 @@
import { SUGGESTION_TTL_MS } from "@rakazo/contracts";
import { CardBlockSchema, SUGGESTION_TTL_MS } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { applyCardAct, cardHistoryText } from "./cards.js";
import {
@ -25,6 +25,27 @@ describe("suggestions", () => {
expect(describeSuggestion("gmail_send_email", {}).title).toBe("Gmail send email");
});
it("shows a suggested look instead of describing it", () => {
const card = buildSuggestionCard({
id: "s2",
effectId: "s2",
tool: "update_bot",
args: { look: "oo1:c=f4a7b9;sp=cat" },
why: "Cosy for recipe nights.",
});
expect(card.sections?.[0]).toEqual({
type: "header",
title: "New look",
subtitle: "Cosy for recipe nights.",
avatar: "oo1:c=F4A7B9;sp=cat",
});
expect(CardBlockSchema.safeParse(card).success).toBe(true);
expect(describeSuggestion("update_bot", { look: "nope", name: "Mia" })).toEqual({
title: "Update profile",
details: ["Mia"],
});
});
it("takes one tap: accept runs it, then nothing else applies", () => {
const card = buildSuggestionCard(walk);
const accepted = applyCardAct(card, { type: "action", actionId: "accept" });

View File

@ -1,6 +1,7 @@
import type { CardBlock, SuggestionStatus } from "@rakazo/contracts";
import { CARD_LIMITS, SUGGESTION_TTL_MS } from "@rakazo/contracts";
import { formatCron } from "./cron.js";
import { normalizeOoLook } from "./oo-avatar.js";
/** Tools a suggestion may never wrap: they talk to the user or steer the run itself. */
export const UNSUGGESTABLE_TOOLS: ReadonlySet<string> = new Set([
@ -22,6 +23,7 @@ const TOOL_TITLES: Record<string, string> = {
web_search: "Search the web",
delegate_task: "Hand this to a specialist",
spawn_bot: "Create a bot",
update_bot: "Update profile",
create_project: "Create a project",
update_goal: "Set a goal",
update_habit: "Track a habit",
@ -60,7 +62,7 @@ function humanizeTool(tool: string): string {
export function describeSuggestion(
tool: string,
args: Record<string, unknown>,
): { title: string; details: string[] } {
): { title: string; details: string[]; avatar?: string } {
const details: string[] = [];
for (const key of SUBJECT_KEYS) {
const value = args[key];
@ -70,9 +72,16 @@ export function describeSuggestion(
if (tool === "schedule_create" && typeof args.cron === "string" && details.length < 3) {
details.push(clip(formatCron(args.cron), CARD_LIMITS.label));
}
const avatar = suggestedLook(tool, args);
if (avatar) return { title: "New look", details, avatar };
return { title: TOOL_TITLES[tool] ?? humanizeTool(tool), details };
}
/** The look a suggested `update_bot` call would apply, so the card can show it. */
function suggestedLook(tool: string, args: Record<string, unknown>): string | undefined {
return tool === "update_bot" ? (normalizeOoLook(args.look) ?? undefined) : undefined;
}
/** A suggestion card: what the call does, why, and Accept or Dismiss. */
export function buildSuggestionCard(input: {
id: string;
@ -81,7 +90,7 @@ export function buildSuggestionCard(input: {
args: Record<string, unknown>;
why?: string;
}): CardBlock {
const { title, details } = describeSuggestion(input.tool, input.args);
const { title, details, avatar } = describeSuggestion(input.tool, input.args);
const why = input.why?.trim();
return {
kind: "card",
@ -92,6 +101,7 @@ export function buildSuggestionCard(input: {
title,
...(why ? { subtitle: clip(why, CARD_LIMITS.title) } : {}),
...(details.length ? { meta: details } : {}),
...(avatar ? { avatar } : {}),
},
{
type: "actions",