Bots can offer an exact tool call as a card. Nothing runs until the user taps Accept; the call then runs as its own approved run of the bot it was meant for, and the card shows the outcome. Open suggestions also wait in the inbox and expire after a week. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
218 lines
6.5 KiB
TypeScript
218 lines
6.5 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(),
|
|
});
|
|
|
|
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>;
|