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:
parent
460eede226
commit
c69f00e891
@ -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,7 +117,9 @@ export function ThreadCard({
|
||||
{block.sections?.map((part, index) => {
|
||||
if (part.type === "header") {
|
||||
return (
|
||||
<View key={index} style={{ gap: 2 }}>
|
||||
<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 }]}>
|
||||
@ -129,6 +132,7 @@ export function ThreadCard({
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (part.type === "facts") {
|
||||
@ -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 },
|
||||
|
||||
43
apps/web/e2e/look-suggestion.spec.ts
Normal file
43
apps/web/e2e/look-suggestion.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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,15 +107,20 @@ export function ThreadCard({
|
||||
{block.sections?.map((part, index) => {
|
||||
if (part.type === "header") {
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-0.5">
|
||||
<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>
|
||||
<div className="text-ui-body-sm text-muted-foreground">
|
||||
{part.meta.join(" · ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (part.type === "facts") {
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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(" ");
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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,
|
||||
{
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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" });
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user