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(), }); 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; 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; /** 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; /** 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; /** 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;