cynarAI c69f00e891
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>
2026-10-05 04:03:10 +02:00

220 lines
6.6 KiB
TypeScript

import * as z from "zod";
/**
* Interactive cards: a fixed catalog of sections a bot fills with content. The model never
* chooses layout or markup; the backend validates every card and applies every tap.
*/
export const CARD_LIMITS = {
sections: 5,
facts: 8,
checklistItems: 30,
steps: 20,
stepText: 300,
/** Longer lists show this many entries until the user opens the rest. */
collapsedEntries: 8,
stepperMax: 99,
timerSeconds: 24 * 60 * 60,
selectOptions: 6,
actions: 3,
label: 80,
title: 120,
meta: 3,
} as const;
const Label = z.string().trim().min(1).max(CARD_LIMITS.label);
const SectionId = z.string().min(1).max(40);
export const CardHeaderSchema = z.object({
type: z.literal("header"),
title: z.string().trim().min(1).max(CARD_LIMITS.title),
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({
type: z.literal("facts"),
rows: z
.array(z.object({ label: Label, value: z.string().trim().min(1).max(200) }))
.min(1)
.max(CARD_LIMITS.facts),
});
/** "200 g" of something, scaled by the card's stepper; `plural` names more than one ("Eggs"). */
export const CardQuantitySchema = z.object({
amount: z.number().positive().max(100_000),
unit: z.string().trim().max(20).optional(),
plural: Label.optional(),
});
export type CardQuantity = z.infer<typeof CardQuantitySchema>;
export const CardChecklistSchema = z.object({
type: z.literal("checklist"),
id: SectionId,
items: z
.array(
z.object({
id: SectionId,
label: Label,
quantity: CardQuantitySchema.optional(),
checked: z.boolean().optional(),
}),
)
.min(1)
.max(CARD_LIMITS.checklistItems),
});
/** A number the user turns up or down, such as servings; quantities scale from `base`. */
export const CardStepperSchema = z.object({
type: z.literal("stepper"),
id: SectionId,
label: Label,
base: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
value: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
});
/** Numbered steps, optionally timed; the card remembers the current step and running timers. */
export const CardStepsSchema = z.object({
type: z.literal("steps"),
id: SectionId,
steps: z
.array(
z.object({
id: SectionId,
text: z.string().trim().min(1).max(CARD_LIMITS.stepText),
timerSeconds: z.number().int().min(1).max(CARD_LIMITS.timerSeconds).optional(),
/** When a started timer runs out; stored so it survives a reload or another device. */
timerEndsAt: z.string().datetime().optional(),
}),
)
.min(1)
.max(CARD_LIMITS.steps),
current: z
.number()
.int()
.min(0)
.max(CARD_LIMITS.steps - 1)
.optional(),
});
export const CardSelectSchema = z.object({
type: z.literal("select"),
id: SectionId,
/** Several options before sending; otherwise one tap answers. */
multiple: z.boolean().optional(),
options: z
.array(z.object({ id: SectionId, label: Label }))
.min(2)
.max(CARD_LIMITS.selectOptions),
selected: z.array(SectionId).optional(),
});
/**
* "reply" sends the label, with any selection, to the bot as the user's answer. "steps" opens the
* card's steps one at a time in full screen (cooking mode) and never reaches the bot. "accept"
* and "dismiss" settle a suggestion card: accepting runs the prepared tool call, with no model.
*/
export const CardActionSchema = z.object({
id: SectionId,
label: Label,
kind: z.enum(["reply", "steps", "accept", "dismiss"]),
});
export const CardActionsSchema = z.object({
type: z.literal("actions"),
actions: z.array(CardActionSchema).min(1).max(CARD_LIMITS.actions),
});
export const CardSectionSchema = z.discriminatedUnion("type", [
CardHeaderSchema,
CardFactsSchema,
CardStepperSchema,
CardChecklistSchema,
CardStepsSchema,
CardSelectSchema,
CardActionsSchema,
]);
export type CardSection = z.infer<typeof CardSectionSchema>;
/** How long a suggestion can be accepted before it is stale. */
export const SUGGESTION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export const SuggestionStatusSchema = z.enum([
"proposed",
"running",
"done",
"failed",
"dismissed",
"expired",
]);
export type SuggestionStatus = z.infer<typeof SuggestionStatusSchema>;
/** A prepared tool call the user starts with one tap; `effectId` holds the exact call. */
export const CardSuggestionSchema = z.object({
effectId: SectionId,
/** The tool the suggestion runs, so clients can name it in the user's language. */
tool: z.string().min(1).max(120),
status: SuggestionStatusSchema,
/** Why it failed, in the tool's words. */
error: z.string().max(300).optional(),
});
/** Set once the user answered the card; the card then stays as it was answered. */
export const CardAnswerSchema = z.object({ text: z.string(), actionId: SectionId.optional() });
/**
* One card per block. `lines` is the older read-only shape, kept so stored messages still parse;
* new cards carry `id` and `sections`.
*/
export const CardBlockSchema = z.object({
kind: z.literal("card"),
id: SectionId.optional(),
lines: z.array(z.object({ k: z.string(), v: z.string() })).optional(),
sections: z.array(CardSectionSchema).max(CARD_LIMITS.sections).optional(),
answer: CardAnswerSchema.optional(),
suggestion: CardSuggestionSchema.optional(),
});
export type CardBlock = z.infer<typeof CardBlockSchema>;
/** What a tap on a card asks the backend to do. Toggles carry the new state, so a retry is a no-op. */
export const CardActSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("toggle"),
sectionId: SectionId,
itemId: SectionId,
checked: z.boolean(),
}),
z.object({
type: z.literal("select"),
sectionId: SectionId,
optionId: SectionId,
/** Multiple choice only: whether the option is now chosen. */
selected: z.boolean().optional(),
}),
z.object({ type: z.literal("action"), actionId: SectionId }),
z.object({
type: z.literal("stepper"),
sectionId: SectionId,
value: z.number().int().min(1).max(CARD_LIMITS.stepperMax),
}),
z.object({
type: z.literal("step"),
sectionId: SectionId,
index: z
.number()
.int()
.min(0)
.max(CARD_LIMITS.steps - 1),
}),
z.object({
type: z.literal("timer"),
sectionId: SectionId,
stepId: SectionId,
running: z.boolean(),
}),
]);
export type CardAct = z.infer<typeof CardActSchema>;