feat: let bots answer with cards the user taps (#137)

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

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 00:23:59 +02:00 committed by GitHub
parent 9f4991abe0
commit 47988602e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1341 additions and 8 deletions

View File

@ -234,6 +234,7 @@ import {
assertTeachingTakeoverAllowed, assertTeachingTakeoverAllowed,
createTaughtSkillsService, createTaughtSkillsService,
} from "./taught-skills.js"; } from "./taught-skills.js";
import { actOnThreadCard } from "./thread-cards.js";
import { import {
isPeerRun, isPeerRun,
loadAllMessages, loadAllMessages,
@ -1869,6 +1870,13 @@ export function createRouter(deps: RouterDeps) {
} }
return { ok: true as const }; return { ok: true as const };
}), }),
act: authed.threads.act.handler(async ({ context, input }) => {
if (input.act.type !== "toggle" && (await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
return actOnThreadCard(deps, context.actor, target, input);
}),
stop: authed.threads.stop.handler(async ({ context, input }) => { stop: authed.threads.stop.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input); const target = await resolveThreadTarget(deps.prisma, context.actor, input);
await stopThreadRuns(deps, context.actor, target); await stopThreadRuns(deps, context.actor, target);

View File

@ -0,0 +1,150 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import type { Actor, CardBlock, MessageBlock } from "@rakazo/contracts";
import { buildCardFromTool } from "@rakazo/core";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { actOnThreadCard } from "./thread-cards.js";
import type { ThreadTarget } from "./thread-target.js";
import { resolveThreadTarget } from "./thread-target.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("actOnThreadCard (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
let actor: Actor;
let target: ThreadTarget;
const notify = vi.fn(async () => undefined);
const enqueue = vi.fn(async () => undefined);
let deps: { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher };
let seq = 0;
let nonce = 0;
beforeAll(async () => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
identity = await provisionMessagingIdentity(
prisma,
{ provider: "sendblue", address: `+1556${Date.now().toString().slice(-7)}` },
{ signupsEnabled: undefined, signupAllowlist: undefined },
);
actor = {
userId: identity.userId,
spaceId: identity.spaceId,
email: "card@example.com",
isDeploymentOwner: false,
};
deps = {
prisma,
events: { notify } as unknown as ThreadEvents,
jobs: {
enqueue,
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
} as unknown as JobPublisher,
};
target = await resolveThreadTarget(prisma, actor, { botId: identity.botId });
seq =
(
await prisma.message.aggregate({
where: { threadId: identity.threadId },
_max: { seq: true },
})
)._max.seq ?? 0;
});
afterAll(async () => {
if (identity) {
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
await prisma.user.deleteMany({ where: { id: identity.userId } });
}
await close?.();
});
async function postCard(args: Parameters<typeof buildCardFromTool>[0]) {
const built = buildCardFromTool(args, `card${++seq}`);
if (!("ok" in built)) throw new Error(built.error);
const message = await prisma.message.create({
data: {
threadId: identity.threadId,
seq,
role: "bot",
botId: identity.botId,
blocks: [built.block],
},
});
return { messageId: message.id, cardId: built.block.id! };
}
async function card(messageId: string) {
const row = await prisma.message.findUniqueOrThrow({ where: { id: messageId } });
return (row.blocks as MessageBlock[])[0] as CardBlock;
}
const act = (input: Omit<Parameters<typeof actOnThreadCard>[3], "clientNonce">, again?: string) =>
actOnThreadCard(deps, actor, target, { ...input, clientNonce: again ?? `nonce-${++nonce}` });
it("ticks a checklist item without starting a run", async () => {
const posted = await postCard({ title: "Shopping", checklist: ["Milk", "Eggs"] });
await act({
...posted,
act: { type: "toggle", sectionId: "checklist", itemId: "i2", checked: true },
});
const updated = await card(posted.messageId);
expect(updated.sections?.[1]).toMatchObject({
items: [{ id: "i1" }, { id: "i2", checked: true }],
});
expect(await prisma.message.count({ where: { replyToMessageId: posted.messageId } })).toBe(0);
expect(
await prisma.event.count({
where: {
threadId: identity.threadId,
type: "thread.message.updated",
payload: { path: ["messageId"], equals: posted.messageId },
},
}),
).toBe(1);
});
it("answers a choice once and sends it to the bot as the user's reply", async () => {
const posted = await postCard({ title: "Dinner", options: ["Pasta", "Curry"] });
const pick = {
...posted,
act: { type: "select" as const, sectionId: "select", optionId: "o2" },
};
await act(pick, "pick-curry");
// A retried tap is a no-op rather than an error.
await act(pick, "pick-curry");
expect((await card(posted.messageId)).answer).toEqual({ text: "Curry" });
const replies = await prisma.message.findMany({
where: { replyToMessageId: posted.messageId },
select: { role: true, blocks: true, runId: true },
});
expect(replies).toHaveLength(1);
expect(replies[0]).toMatchObject({ role: "user", blocks: [{ kind: "text", text: "Curry" }] });
expect(replies[0]?.runId).toBeTruthy();
await expect(
act({ ...posted, act: { type: "select", sectionId: "select", optionId: "o1" } }),
).rejects.toThrow("already answered");
});
it("keeps other threads' cards out of reach", async () => {
const posted = await postCard({ title: "Elsewhere", checklist: ["One"] });
await expect(
act({
...posted,
cardId: "not-this-card",
act: { type: "toggle", sectionId: "checklist", itemId: "i1", checked: true },
}),
).rejects.toThrow();
});
});

View File

@ -0,0 +1,79 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import type { Actor, CardAct, CardBlock, MessageBlock } from "@rakazo/contracts";
import { applyCardAct } from "@rakazo/core";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { appendEventInTransaction, IsolationError } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { ThreadTarget } from "./thread-target.js";
import { groupDefaultBotId, sendThreadMessage } from "./thread-target.js";
type CardDeps = { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher };
function isCard(block: MessageBlock, cardId: string): block is CardBlock {
return block.kind === "card" && block.id === cardId;
}
/**
* Apply one tap on a bot's card. The card changes in place for everyone watching the thread; a
* choice or an action then goes to the card's bot as the user's reply, through the normal send.
*/
export async function actOnThreadCard(
deps: CardDeps,
actor: Actor,
target: ThreadTarget,
input: { messageId: string; cardId: string; act: CardAct; clientNonce: string },
): Promise<{ ok: true }> {
// A retried answer already landed as the user's reply; the card was answered with it.
const replayed = await deps.prisma.message.findUnique({
where: { threadId_clientNonce: { threadId: target.threadId, clientNonce: input.clientNonce } },
select: { id: true },
});
if (replayed) return { ok: true };
const applied = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const message = await tx.message.findFirst({
where: { id: input.messageId, threadId: target.threadId, role: "bot" },
select: { id: true, blocks: true, botId: true },
});
if (!message) throw new IsolationError();
const blocks = (Array.isArray(message.blocks) ? message.blocks : []) as MessageBlock[];
const index = blocks.findIndex((block) => isCard(block, input.cardId));
const card = blocks[index];
if (card?.kind !== "card") throw new IsolationError();
const result = applyCardAct(card, input.act);
if (!result.ok) throw new ORPCError("CONFLICT", { message: result.error });
if (!result.changed) return { reply: result.reply, botId: message.botId, seq: null };
const next = blocks.map((block, position) => (position === index ? result.block : block));
await tx.message.update({ where: { id: message.id }, data: { blocks: next } });
const botId =
message.botId ?? (target.kind === "bot" ? target.botId : groupDefaultBotId(target));
if (!botId) throw new IsolationError();
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId,
type: "thread.message.updated",
payload: { messageId: message.id, role: "bot", blocks: next },
});
return { reply: result.reply, botId: message.botId, seq: event.seq };
});
if (applied.seq !== null) {
await deps.events.notify(target.threadId, applied.seq).catch((error) => {
getLogger().error("thread card realtime notification", error);
});
}
if (applied.reply) {
await sendThreadMessage(deps, actor, target, {
text: applied.reply,
replyToMessageId: input.messageId,
clientNonce: input.clientNonce,
// In a group the answer goes to the bot that asked, not to whoever leads.
...(target.kind === "group" && applied.botId
? { mentions: [{ kind: "bot" as const, id: applied.botId }] }
: {}),
});
}
return { ok: true };
}

View File

@ -1,6 +1,8 @@
import { ChatMarkdown } from "@rakazo/chat-ui/native"; import { ChatMarkdown } from "@rakazo/chat-ui/native";
import type { import type {
AgentSkillCatalogEntry, AgentSkillCatalogEntry,
CardAct,
CardBlock,
Connection, Connection,
ConnectionCatalogItem, ConnectionCatalogItem,
MessageBlock, MessageBlock,
@ -69,6 +71,7 @@ import {
} from "../components/markdown-artifact-preview"; } from "../components/markdown-artifact-preview";
import { NativeSymbol } from "../components/native-symbol"; import { NativeSymbol } from "../components/native-symbol";
import { RoutineEventLine } from "../components/routine-event-line"; import { RoutineEventLine } from "../components/routine-event-line";
import { ThreadCard } from "../components/thread-card";
import type { import type {
MobileBot, MobileBot,
MobileGroup, MobileGroup,
@ -1227,6 +1230,23 @@ function Thread() {
[botId, groupId], [botId, groupId],
); );
const actOnCard = useCallback(
async (message: MobileMessage, cardId: string, act: CardAct) => {
const targetBotId = botId;
const targetGroupId = groupId;
if (!targetBotId && !targetGroupId) return;
await rpc("threads/act", {
...(targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }),
messageId: message.id,
cardId,
act,
clientNonce: newClientNonce(),
});
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
},
[botId, groupId],
);
const openBot = useCallback( const openBot = useCallback(
(id: string, botName: string) => (id: string, botName: string) =>
router.push({ pathname: "/thread", params: { botId: id, name: botName } }), router.push({ pathname: "/thread", params: { botId: id, name: botName } }),
@ -1476,6 +1496,7 @@ function Thread() {
} }
canAnswer={message.id === answerableAskMessageId} canAnswer={message.id === answerableAskMessageId}
onAnswer={answerMessage} onAnswer={answerMessage}
onCardAct={actOnCard}
onOpenBot={openBot} onOpenBot={openBot}
onPreviewMarkdown={setMarkdownPreview} onPreviewMarkdown={setMarkdownPreview}
actionProps={actionProps} actionProps={actionProps}
@ -2350,6 +2371,7 @@ const MessageBubble = memo(function MessageBubble({
replyPreview, replyPreview,
canAnswer, canAnswer,
onAnswer, onAnswer,
onCardAct,
onOpenBot, onOpenBot,
onPreviewMarkdown, onPreviewMarkdown,
actionProps, actionProps,
@ -2363,6 +2385,7 @@ const MessageBubble = memo(function MessageBubble({
replyPreview?: MobileMessage; replyPreview?: MobileMessage;
canAnswer: boolean; canAnswer: boolean;
onAnswer: (message: MobileMessage, answer: string) => Promise<void>; onAnswer: (message: MobileMessage, answer: string) => Promise<void>;
onCardAct: (message: MobileMessage, cardId: string, act: CardAct) => Promise<void>;
onOpenBot: (botId: string, name: string) => void; onOpenBot: (botId: string, name: string) => void;
onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void; onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void;
actionProps: MessageActionProps; actionProps: MessageActionProps;
@ -2402,6 +2425,25 @@ const MessageBubble = memo(function MessageBubble({
</View> </View>
); );
} }
const cards = message.blocks.filter(
(block): block is CardBlock => block.kind === "card" && Boolean(block.sections),
);
if (cards.length) {
return (
<View style={{ gap: 8, width: "100%" }}>
{cards.map((card, index) => {
const cardId = card.id;
return (
<ThreadCard
key={cardId ?? index}
block={card}
onAct={cardId ? (act) => onCardAct(message, cardId, act) : undefined}
/>
);
})}
</View>
);
}
if (isRoutineEventMessage(message.blocks)) { if (isRoutineEventMessage(message.blocks)) {
return ( return (
<View style={{ width: "100%" }}> <View style={{ width: "100%" }}>

View File

@ -0,0 +1,235 @@
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
import { useState } from "react";
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import { NativeSymbol } from "./native-symbol";
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
/** A bot's interactive card. Taps go to the server, which owns the card's state. */
export function ThreadCard({
block,
onAct,
}: {
block: CardBlock;
onAct?: (act: CardAct) => Promise<void>;
}) {
const tokens = mobileTokens();
const { t } = useI18n();
const [pending, setPending] = useState<CardAct | null>(null);
const answered = Boolean(block.answer);
const busy = !onAct || pending !== null;
async function act(next: CardAct) {
if (!onAct || pending) return;
setPending(next);
try {
await onAct(next);
} catch (error) {
Alert.alert(
t("Could not submit answer"),
error instanceof Error ? error.message : t("Please try again."),
);
} finally {
setPending(null);
}
}
const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) =>
pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId
? pending.checked
: stored;
const selectedNow = (select: Section<"select">, optionId: string) =>
pending?.type === "select" && pending.sectionId === select.id && pending.optionId === optionId
? (pending.selected ?? true)
: (select.selected?.includes(optionId) ?? false);
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
return (
<View
testID="thread-card"
style={[styles.card, { borderColor: tokens.border, backgroundColor: tokens.card }]}
>
{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>
);
}
if (part.type === "facts") {
return (
<View key={index} style={{ gap: 6 }}>
{part.rows.map((row) => (
<View key={row.label} style={styles.factRow}>
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{row.label}</Text>
<Text style={[styles.body, styles.factValue, { color: tokens.foreground }]}>
{row.value}
</Text>
</View>
))}
</View>
);
}
if (part.type === "checklist") {
return (
<View key={index}>
{part.items.map((item) => {
const checked = checkedNow(part, item.id, Boolean(item.checked));
return (
<Pressable
key={item.id}
accessibilityRole="checkbox"
accessibilityState={{ checked, disabled: busy }}
disabled={busy}
onPress={() =>
void act({
type: "toggle",
sectionId: part.id,
itemId: item.id,
checked: !checked,
})
}
style={styles.checkRow}
>
<NativeSymbol
ios={checked ? "checkmark.circle.fill" : "circle"}
android={checked ? "checkmark-circle" : "ellipse-outline"}
size={20}
color={checked ? tokens.primary : tokens.mutedForeground}
/>
<Text
style={[
styles.body,
{
flexShrink: 1,
color: checked ? tokens.mutedForeground : tokens.foreground,
},
checked && { textDecorationLine: "line-through" },
]}
>
{item.label}
</Text>
</Pressable>
);
})}
</View>
);
}
if (part.type === "select") {
return (
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
{part.options.map((option) => {
const chosen = selectedNow(part, option.id);
return (
<Pressable
key={option.id}
accessibilityRole="button"
accessibilityState={{ selected: chosen, disabled: busy || answered }}
disabled={busy || answered}
onPress={() =>
void act({
type: "select",
sectionId: part.id,
optionId: option.id,
...(part.multiple ? { selected: !chosen } : {}),
})
}
style={[
styles.option,
part.multiple ? null : { alignSelf: "stretch" },
{
borderColor: chosen ? tokens.primary : tokens.border,
backgroundColor: chosen ? tokens.primary : "transparent",
opacity: answered && !chosen ? 0.5 : 1,
},
]}
>
<Text
style={[
styles.body,
{ color: chosen ? tokens.primaryForeground : tokens.foreground },
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
);
}
if (answered) return null;
return (
<View key={index} style={styles.chips}>
{part.actions.map((action, position) => {
const primary = position === 0;
const disabled = busy || nothingChosen;
return (
<Pressable
key={action.id}
accessibilityRole="button"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={() => void act({ type: "action", actionId: action.id })}
style={[
styles.option,
{
borderColor: primary ? tokens.primary : tokens.border,
backgroundColor: primary ? tokens.primary : "transparent",
opacity: disabled ? 0.5 : 1,
},
]}
>
<Text
style={[
styles.body,
{
fontWeight: "600",
color: primary ? tokens.primaryForeground : tokens.foreground,
},
]}
>
{action.label}
</Text>
</Pressable>
);
})}
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
card: {
width: "90%",
gap: 14,
borderRadius: 18,
borderWidth: 1,
paddingHorizontal: 16,
paddingVertical: 14,
},
title: { fontSize: 16, fontWeight: "600" },
body: { fontSize: 15.5, lineHeight: 22 },
small: { fontSize: 13.5 },
factRow: { flexDirection: "row", gap: 12 },
factValue: { flex: 1 },
checkRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
option: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 14, paddingVertical: 10 },
});

View File

@ -902,6 +902,12 @@ export function blockText(message: MobileMessage) {
} }
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "event") return block.name; if (block.kind === "event") return block.name;
if (block.kind === "card") {
const header = block.sections?.find((part) => part.type === "header");
return header?.type === "header"
? header.title
: (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join("\n");
}
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") { if (block.kind === "file") {
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`; return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;

View File

@ -0,0 +1,54 @@
import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
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's card is ticked off and answered with a tap", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `thread-card-${stamp}@rakazo.test`, "password12", "Thread Card");
await completeOnboarding(page);
await page.getByPlaceholder(/Message/).fill("What should we cook tonight?");
await page.keyboard.press("Enter");
const card = page.getByTestId("thread-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card).toContainText("Dinner tonight");
await expect(card).toContainText("30 min · 2 servings");
await card.getByRole("checkbox", { name: "Tomatoes" }).click();
await expect(card.getByRole("checkbox", { name: "Tomatoes" })).toBeChecked();
// The tick is stored, not just drawn.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByTestId("thread-card").getByRole("checkbox", { name: "Tomatoes" }),
).toBeChecked({
timeout: 30_000,
});
await useAppearance(page, "light");
await card.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "thread-card-light");
await useAppearance(page, "dark");
await card.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, "thread-card-dark");
await card.getByRole("button", { name: "Curry" }).click();
await expect(card).toHaveAttribute("data-answered", "true", { timeout: 15_000 });
await expect(card.getByRole("button", { name: "Pasta" })).toBeDisabled();
// The choice reaches the bot as the user's reply.
await expect(
page.getByTestId("message-user-bubble").filter({ hasText: /^\s*Curry\s*$/ }),
).toHaveCount(1, {
timeout: 30_000,
});
// A checklist stays usable after the choice.
await card.getByRole("checkbox", { name: "Basil" }).click();
await expect(card.getByRole("checkbox", { name: "Basil" })).toBeChecked();
});

View File

@ -0,0 +1,182 @@
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
import { Button, Checkbox, cn } from "@rakazo/ui-web";
import { useId, useState } from "react";
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
/**
* A bot's interactive card. The server owns the state: every tap goes through `onAct` and the
* card re-renders from the updated message, so only the tap in flight is held locally.
*/
export function ThreadCard({
block,
onAct,
className,
}: {
block: CardBlock;
onAct?: (act: CardAct) => Promise<void>;
className?: string;
}) {
const idPrefix = useId();
const [pending, setPending] = useState<CardAct | null>(null);
const [error, setError] = useState<string | null>(null);
const answered = Boolean(block.answer);
async function act(next: CardAct) {
if (!onAct || pending) return;
setPending(next);
setError(null);
try {
await onAct(next);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPending(null);
}
}
const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) =>
pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId
? pending.checked
: stored;
const selectedNow = (select: Section<"select">, optionId: string) => {
const stored = select.selected?.includes(optionId) ?? false;
return pending?.type === "select" &&
pending.sectionId === select.id &&
pending.optionId === optionId
? (pending.selected ?? true)
: stored;
};
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
return (
<div
data-testid="thread-card"
data-answered={answered || undefined}
className={cn(
"flex w-full max-w-sm flex-col gap-3.5 rounded-2xl border border-border bg-card px-5 py-4",
className,
)}
>
{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>
);
}
if (part.type === "facts") {
return (
<dl key={index} className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-ui-body">
{part.rows.map((row) => (
<div key={row.label} className="contents">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="text-foreground">{row.value}</dd>
</div>
))}
</dl>
);
}
if (part.type === "checklist") {
return (
<ul key={index} className="flex flex-col gap-2">
{part.items.map((item) => {
const checked = checkedNow(part, item.id, Boolean(item.checked));
return (
<li key={item.id}>
<label
htmlFor={`${idPrefix}-${item.id}`}
className="flex cursor-pointer items-center gap-2.5 text-ui-body"
>
<Checkbox
id={`${idPrefix}-${item.id}`}
checked={checked}
disabled={!onAct || pending !== null}
onCheckedChange={(next) =>
void act({
type: "toggle",
sectionId: part.id,
itemId: item.id,
checked: Boolean(next),
})
}
/>
<span
className={cn(
"text-foreground",
checked && "text-muted-foreground line-through",
)}
>
{item.label}
</span>
</label>
</li>
);
})}
</ul>
);
}
if (part.type === "select") {
return (
<div
key={index}
className={cn("flex gap-1.5", part.multiple ? "flex-wrap" : "flex-col")}
>
{part.options.map((option) => {
const chosen = selectedNow(part, option.id);
return (
<Button
key={option.id}
variant={chosen ? "default" : "outline"}
aria-pressed={part.multiple ? chosen : undefined}
className={cn(
"h-auto whitespace-normal px-3.5 py-2.5 font-normal",
!part.multiple && "w-full justify-start text-start",
)}
disabled={!onAct || answered || pending !== null}
onClick={() =>
void act({
type: "select",
sectionId: part.id,
optionId: option.id,
...(part.multiple ? { selected: !chosen } : {}),
})
}
>
{option.label}
</Button>
);
})}
</div>
);
}
if (answered) return null;
return (
<div key={index} className="flex flex-wrap gap-2">
{part.actions.map((action, position) => (
<Button
key={action.id}
variant={position === 0 ? "default" : "outline"}
disabled={!onAct || pending !== null || nothingChosen}
onClick={() => void act({ type: "action", actionId: action.id })}
>
{action.label}
</Button>
))}
</div>
);
})}
{error ? <p className="text-ui-body-sm text-destructive">{error}</p> : null}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
import type { import type {
Bot, Bot,
BotSection, BotSection,
CardAct,
ComputerMode, ComputerMode,
ComputerReleaseReason, ComputerReleaseReason,
ComputerStatus, ComputerStatus,
@ -1961,6 +1962,23 @@ export function ShellPage() {
await refreshThreadRef.current(botId); await refreshThreadRef.current(botId);
} }
}, []); }, []);
const actOnCard = useCallback(async (message: ThreadMessage, cardId: string, act: CardAct) => {
const botId = activeBotId.current;
const groupId = activeGroupId.current;
if (!botId && !groupId) return;
await rpc.threads.act({
...(groupId ? { groupId } : { botId: botId! }),
messageId: message.id,
cardId,
act,
clientNonce: newClientNonce(),
});
if (groupId && activeGroupId.current === groupId) {
await refreshGroupThreadRef.current(groupId);
} else if (botId && activeBotId.current === botId) {
await refreshThreadRef.current(botId);
}
}, []);
const reactToMessage = useCallback( const reactToMessage = useCallback(
async (message: ThreadMessage, reaction: MessageReaction) => { async (message: ThreadMessage, reaction: MessageReaction) => {
const botId = activeBotId.current; const botId = activeBotId.current;
@ -3312,6 +3330,7 @@ export function ShellPage() {
onOpenBot={openBot} onOpenBot={openBot}
onOpenGroup={openGroup} onOpenGroup={openGroup}
onAnswer={answerMessage} onAnswer={answerMessage}
onCardAct={actOnCard}
onReply={(message) => { onReply={(message) => {
setReplyTarget(message); setReplyTarget(message);
setReplyQuote(null); setReplyQuote(null);

View File

@ -1,7 +1,7 @@
import { i18n } from "@lingui/core"; import { i18n } from "@lingui/core";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web"; import { ChatMarkdown } from "@rakazo/chat-ui/web";
import type { MessageReaction, ThreadMessage } from "@rakazo/contracts"; import type { CardAct, MessageReaction, ThreadMessage } from "@rakazo/contracts";
import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts"; import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
import type { WorkspaceArtifact } from "@rakazo/core"; import type { WorkspaceArtifact } from "@rakazo/core";
import { import {
@ -37,6 +37,7 @@ import { CloudAgentCard } from "../../components/CloudAgentCard";
import { MessageHoverMetadata } from "../../components/MessageHoverMetadata"; import { MessageHoverMetadata } from "../../components/MessageHoverMetadata";
import { MediaCard } from "../../components/media/MediaCard"; import { MediaCard } from "../../components/media/MediaCard";
import { MediaPlayer } from "../../components/media/MediaPlayer"; import { MediaPlayer } from "../../components/media/MediaPlayer";
import { ThreadCard } from "../../components/ThreadCard";
import { SkillDraftCard } from "../../components/teach/SkillDraftCard"; import { SkillDraftCard } from "../../components/teach/SkillDraftCard";
import type { ArtifactTarget } from "../../lib/artifact-open"; import type { ArtifactTarget } from "../../lib/artifact-open";
import { copyableMessageText } from "../../lib/message-text"; import { copyableMessageText } from "../../lib/message-text";
@ -69,6 +70,7 @@ export const Transcript = memo(function Transcript({
onOpenBot, onOpenBot,
onOpenGroup, onOpenGroup,
onAnswer, onAnswer,
onCardAct,
onReply, onReply,
onQuote, onQuote,
onReact, onReact,
@ -99,6 +101,7 @@ export const Transcript = memo(function Transcript({
onOpenBot: (botId: string) => void; onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void; onOpenGroup: (groupId: string) => void;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>; onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
onReply: (message: ThreadMessage) => void; onReply: (message: ThreadMessage) => void;
onQuote: (message: ThreadMessage, quote: string) => void; onQuote: (message: ThreadMessage, quote: string) => void;
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>; onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
@ -446,6 +449,7 @@ export const Transcript = memo(function Transcript({
onOpenGroup={onOpenGroup} onOpenGroup={onOpenGroup}
onOpenPeerMessages={onOpenPeerMessages} onOpenPeerMessages={onOpenPeerMessages}
onAnswer={onAnswer} onAnswer={onAnswer}
onCardAct={onCardAct}
encounterMode={encounterMode} encounterMode={encounterMode}
speakerName={ speakerName={
peerReceipt peerReceipt
@ -728,6 +732,7 @@ const MessageView = memo(function MessageView({
canAnswer, canAnswer,
message, message,
onAnswer, onAnswer,
onCardAct,
onOpenBot, onOpenBot,
onOpenGroup, onOpenGroup,
onOpenPeerMessages, onOpenPeerMessages,
@ -750,6 +755,7 @@ const MessageView = memo(function MessageView({
canAnswer: boolean; canAnswer: boolean;
message: ThreadMessage; message: ThreadMessage;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>; onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
onOpenBot: (botId: string) => void; onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void; onOpenGroup: (groupId: string) => void;
onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void; onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void;
@ -1243,11 +1249,22 @@ const MessageView = memo(function MessageView({
</div> </div>
); );
} }
if (block.kind === "card" && block.sections) {
const cardId = block.id;
return (
<div key={i} className="flex justify-start">
<ThreadCard
block={block}
onAct={onCardAct && cardId ? (act) => onCardAct(message, cardId, act) : undefined}
/>
</div>
);
}
if (block.kind === "card") { if (block.kind === "card") {
return ( return (
<div key={i} className="flex justify-start"> <div key={i} className="flex justify-start">
<div className="flex flex-col gap-2 rounded-[20px] bg-muted px-5 py-4"> <div className="flex flex-col gap-2 rounded-[20px] bg-muted px-5 py-4">
{block.lines.map((line) => ( {(block.lines ?? []).map((line) => (
<div key={line.k} className="flex items-baseline gap-2.5 text-ui-body"> <div key={line.k} className="flex items-baseline gap-2.5 text-ui-body">
<span className="text-success">✓</span> <span className="text-success">✓</span>
<span className="font-semibold text-foreground">{line.k}</span> <span className="font-semibold text-foreground">{line.k}</span>

View File

@ -244,6 +244,59 @@ export const builtinAgentTools: ConnectorTool[] = [
required: ["question", "options"], required: ["question", "options"],
}, },
}, },
{
name: "show_card",
description:
"Show the user a card instead of a list in prose: a title, short facts, a checklist they can tick off, options they tap, or up to three buttons. Use it for everyday things worth keeping in view, such as a shopping list, a plan for the day or a choice between options. A tap on an option or button arrives as the user's next message. Keep text short; the card is the answer, so do not repeat it in your reply.",
inputSchema: {
type: "object",
properties: {
title: { type: "string", maxLength: 120 },
subtitle: { type: "string", maxLength: 120 },
meta: {
type: "array",
description: 'Up to three short facts beside the title, such as "30 min".',
items: { type: "string", maxLength: 80 },
maxItems: 3,
},
facts: {
type: "array",
items: {
type: "object",
properties: {
label: { type: "string", maxLength: 80 },
value: { type: "string", maxLength: 200 },
},
required: ["label", "value"],
},
maxItems: 8,
},
checklist: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 80 },
maxItems: 30,
},
options: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 80 },
minItems: 2,
maxItems: 6,
uniqueItems: true,
},
multiple: {
type: "boolean",
description: "Let the user pick several options and send them with an action.",
},
actions: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 80 },
maxItems: 3,
uniqueItems: true,
},
},
required: ["title"],
},
},
{ {
name: "message_user", name: "message_user",
description: description:

View File

@ -135,7 +135,9 @@ export function selectBuiltinToolsForRun(options: {
(tool) => (tool) =>
!(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) && !(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) &&
(!options.messagingChannelRun || (!options.messagingChannelRun ||
(!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && (!["remember", "save_memory", "recall_memory", "forget_memory", "show_card"].includes(
tool.name,
) &&
!USER_MODEL_TOOLS.has(tool.name) && !USER_MODEL_TOOLS.has(tool.name) &&
!tool.name.startsWith("scratchpad_"))), !tool.name.startsWith("scratchpad_"))),
); );

View File

@ -38,6 +38,7 @@ import {
MessageBlock as MessageBlockSchema, MessageBlock as MessageBlockSchema,
OPENAI_COMPATIBLE_PROVIDER_ID, OPENAI_COMPATIBLE_PROVIDER_ID,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import type { ShowCardArgs } from "@rakazo/core";
import { import {
type ActionApprovalRule, type ActionApprovalRule,
appendTextSegment, appendTextSegment,
@ -45,6 +46,7 @@ import {
applyJudgeDecision, applyJudgeDecision,
assertTransition, assertTransition,
botMessageAllowsSilence, botMessageAllowsSilence,
buildCardFromTool,
computerScreenSize, computerScreenSize,
connectorKindFromToolName, connectorKindFromToolName,
containsSecret, containsSecret,
@ -3488,6 +3490,25 @@ export function createRunExecutor(deps: ExecutorDeps) {
autoSpeak: updated.autoSpeak, autoSpeak: updated.autoSpeak,
}); });
} }
if (name === "show_card") {
const built = buildCardFromTool(
JSON.parse(redactSecrets(JSON.stringify(args), runSecrets)) as ShowCardArgs,
randomUUID(),
);
if (!("ok" in built)) return finish(built);
await flushProgress();
await publishMidTurnNarration();
await publishMessage(
deps,
run,
"bot",
[built.block],
undefined,
`card:${run.id}:${executionId}`,
);
publishedMidTurnUserMessage = true;
return finish({ ok: true, cardId: built.block.id });
}
if (name === "message_user") { if (name === "message_user") {
const rawMessage = redactSecrets(String(args.message ?? ""), runSecrets); const rawMessage = redactSecrets(String(args.message ?? ""), runSecrets);
const text = clampUserProgressMessage(rawMessage); const text = clampUserProgressMessage(rawMessage);

View File

@ -275,6 +275,26 @@ export function inferScript(
}, },
]; ];
} }
// Everyday answers come as a card the user taps, not a list in prose.
if (lower.includes("what should we cook tonight")) {
return [
{
assistant: "",
toolCalls: [
{
name: "show_card",
args: {
title: "Dinner tonight",
meta: ["30 min", "2 servings"],
checklist: ["Spaghetti", "Tomatoes", "Basil"],
options: ["Pasta", "Curry", "Salad"],
},
},
],
complete: true,
},
];
}
if (lower.includes("delegate to the bot named")) { if (lower.includes("delegate to the bot named")) {
const name = namedBot(prompt) ?? "Researcher"; const name = namedBot(prompt) ?? "Researcher";
const brief = const brief =

View File

@ -0,0 +1,112 @@
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 = {
facts: 8,
checklistItems: 30,
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),
});
export const CardChecklistSchema = z.object({
type: z.literal("checklist"),
id: SectionId,
items: z
.array(z.object({ id: SectionId, label: Label, checked: z.boolean().optional() }))
.min(1)
.max(CARD_LIMITS.checklistItems),
});
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. */
export const CardActionSchema = z.object({
id: SectionId,
label: Label,
kind: z.literal("reply"),
});
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,
CardChecklistSchema,
CardSelectSchema,
CardActionsSchema,
]);
export type CardSection = z.infer<typeof CardSectionSchema>;
/** 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(5).optional(),
answer: CardAnswerSchema.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 }),
]);
export type CardAct = z.infer<typeof CardActSchema>;

View File

@ -1,5 +1,6 @@
import * as z from "zod"; import * as z from "zod";
import { BotSecretDestination } from "./bot-secrets.js"; import { BotSecretDestination } from "./bot-secrets.js";
import { CardBlockSchema } from "./cards.js";
import { Id } from "./ids.js"; import { Id } from "./ids.js";
import { McpTransportSchema } from "./mcp.js"; import { McpTransportSchema } from "./mcp.js";
import { AudioBlockSchema, MediaJobBlockSchema, VideoBlockSchema } from "./media.js"; import { AudioBlockSchema, MediaJobBlockSchema, VideoBlockSchema } from "./media.js";
@ -193,10 +194,7 @@ export type RoutineEventBlock = z.infer<typeof RoutineEventBlockSchema>;
export const MessageBlock = z.discriminatedUnion("kind", [ export const MessageBlock = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), text: z.string() }), z.object({ kind: z.literal("text"), text: z.string() }),
z.object({ CardBlockSchema,
kind: z.literal("card"),
lines: z.array(z.object({ k: z.string(), v: z.string() })),
}),
AskBlockSchema, AskBlockSchema,
ChoiceBlockSchema, ChoiceBlockSchema,
IntakeNoteBlockSchema, IntakeNoteBlockSchema,

View File

@ -1,6 +1,7 @@
export * from "./ai-consent.js"; export * from "./ai-consent.js";
export * from "./attachments.js"; export * from "./attachments.js";
export * from "./bot-secrets.js"; export * from "./bot-secrets.js";
export * from "./cards.js";
export * from "./clock-time.js"; export * from "./clock-time.js";
export * from "./coach.js"; export * from "./coach.js";
export * from "./computer-pointer.js"; export * from "./computer-pointer.js";

View File

@ -2,6 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
import * as z from "zod"; import * as z from "zod";
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
import { CardActSchema } from "./cards.js";
import { import {
CheckInSchema, CheckInSchema,
COACH_TITLE_MAX_LENGTH, COACH_TITLE_MAX_LENGTH,
@ -452,6 +453,17 @@ export const appContract = {
}), }),
) )
.output(z.object({ ok: z.literal(true) })), .output(z.object({ ok: z.literal(true) })),
/** A tap on an interactive card: the backend applies it and may answer the bot. */
act: oc
.input(
threadTarget.safeExtend({
messageId: Id,
cardId: z.string().min(1).max(40),
act: CardActSchema,
clientNonce: z.string().min(1).max(200),
}),
)
.output(z.object({ ok: z.literal(true) })),
markRead: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })), markRead: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
markUnread: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })), markUnread: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
}, },

View File

@ -6,6 +6,7 @@ import {
isAllowedAttachmentMimeType, isAllowedAttachmentMimeType,
isAttachmentImageMimeType, isAttachmentImageMimeType,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { cardHistoryText } from "./cards.js";
import { routineEventHistoryText } from "./routine-events.js"; import { routineEventHistoryText } from "./routine-events.js";
export class AttachmentValidationError extends Error { export class AttachmentValidationError extends Error {
@ -139,6 +140,7 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`; return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
} }
if (block.kind === "event") return routineEventHistoryText(block); if (block.kind === "event") return routineEventHistoryText(block);
if (block.kind === "card") return cardHistoryText(block);
if (block.kind === "handoff") { if (block.kind === "handoff") {
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
} }

View File

@ -0,0 +1,106 @@
import type { CardBlock } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { applyCardAct, buildCardFromTool, cardHistoryText } from "./cards.js";
function card(args: Parameters<typeof buildCardFromTool>[0]): CardBlock {
const built = buildCardFromTool(args, "c1");
if (!("ok" in built)) throw new Error(built.error);
return built.block;
}
describe("buildCardFromTool", () => {
it("builds sections in a fixed order with stable ids", () => {
const block = card({
actions: ["Send"],
options: ["Pasta", "Curry"],
checklist: ["Milk", "Eggs"],
facts: [{ label: "Time", value: "30 min" }],
title: "Dinner",
meta: ["4 servings"],
});
expect(block.sections?.map((part) => part.type)).toEqual([
"header",
"facts",
"checklist",
"select",
"actions",
]);
expect(block.sections?.[2]).toMatchObject({ items: [{ id: "i1" }, { id: "i2" }] });
expect(block.sections?.[3]).toMatchObject({ options: [{ id: "o1" }, { id: "o2" }] });
});
it("rejects cards that say nothing or cannot be answered", () => {
expect(buildCardFromTool({ title: "Only a title" }, "c1")).toHaveProperty("error");
expect(buildCardFromTool({ title: "", checklist: ["a"] }, "c1")).toHaveProperty("error");
expect(buildCardFromTool({ title: "One", options: ["a"] }, "c1")).toHaveProperty("error");
expect(
buildCardFromTool({ title: "Many", options: ["a", "b"], multiple: true }, "c1"),
).toHaveProperty("error");
expect(buildCardFromTool({ title: "Long", checklist: ["x".repeat(81)] }, "c1")).toHaveProperty(
"error",
);
});
});
describe("applyCardAct", () => {
it("sets checklist state idempotently, even after the card was answered", () => {
const block = { ...card({ title: "List", checklist: ["Milk"] }), answer: { text: "Done" } };
const first = applyCardAct(block, {
type: "toggle",
sectionId: "checklist",
itemId: "i1",
checked: true,
});
expect(first).toMatchObject({ ok: true, changed: true });
if (!first.ok) return;
const again = applyCardAct(first.block, {
type: "toggle",
sectionId: "checklist",
itemId: "i1",
checked: true,
});
expect(again).toMatchObject({ ok: true, changed: false });
expect(cardHistoryText(first.block)).toContain("checklist 1/1 done");
});
it("answers a single choice with the option's label, once", () => {
const block = card({ title: "Dinner", options: ["Pasta", "Curry"] });
const picked = applyCardAct(block, { type: "select", sectionId: "select", optionId: "o2" });
expect(picked).toMatchObject({
ok: true,
reply: "Curry",
block: { answer: { text: "Curry" } },
});
if (!picked.ok) return;
expect(
applyCardAct(picked.block, { type: "select", sectionId: "select", optionId: "o1" }),
).toMatchObject({ ok: false });
});
it("collects a multiple choice in card order and sends it with an action", () => {
let block = card({
title: "Toppings",
options: ["A", "B", "C"],
multiple: true,
actions: ["Send"],
});
expect(applyCardAct(block, { type: "action", actionId: "a1" })).toMatchObject({ ok: false });
for (const optionId of ["o3", "o1"]) {
const next = applyCardAct(block, {
type: "select",
sectionId: "select",
optionId,
selected: true,
});
if (!next.ok) throw new Error(next.error);
block = next.block;
}
const sent = applyCardAct(block, { type: "action", actionId: "a1" });
expect(sent).toMatchObject({ ok: true, reply: "Send: A, C" });
expect(cardHistoryText(sent.ok ? sent.block : block)).toContain("answered: Send: A, C");
});
it("reads legacy cards as their lines", () => {
expect(cardHistoryText({ kind: "card", lines: [{ k: "Mail", v: "sent" }] })).toBe("Mail: sent");
});
});

210
packages/core/src/cards.ts Normal file
View File

@ -0,0 +1,210 @@
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
import { CardBlockSchema } from "@rakazo/contracts";
/** What `show_card` accepts: plain content in a fixed order, never layout. */
export interface ShowCardArgs {
title?: unknown;
subtitle?: unknown;
meta?: unknown;
facts?: unknown;
checklist?: unknown;
options?: unknown;
multiple?: unknown;
actions?: unknown;
}
const strings = (value: unknown): string[] =>
Array.isArray(value) ? value.map((item) => String(item ?? "").trim()).filter(Boolean) : [];
const text = (value: unknown) => (typeof value === "string" ? value.trim() : "");
/** Build a validated card from tool arguments; the error is phrased for the model. */
export function buildCardFromTool(
args: ShowCardArgs,
id: string,
): { ok: true; block: CardBlock } | { error: string } {
const title = text(args.title);
if (!title) return { error: "title is required." };
const facts = Array.isArray(args.facts)
? args.facts
.map((row) => ({
label: text((row as { label?: unknown })?.label),
value: text((row as { value?: unknown })?.value),
}))
.filter((row) => row.label && row.value)
: [];
const checklist = strings(args.checklist);
const options = strings(args.options);
const actions = strings(args.actions);
const multiple = args.multiple === true;
if (!facts.length && !checklist.length && !options.length && !actions.length) {
return {
error: "Add facts, checklist, options or actions; a card with only a title says nothing.",
};
}
if (options.length === 1) return { error: "options needs at least two choices." };
if (multiple && options.length && !actions.length) {
return { error: 'A multiple choice needs an action to send it, such as "Send".' };
}
const subtitle = text(args.subtitle);
const meta = strings(args.meta);
const sections: CardSection[] = [
{
type: "header",
title,
...(subtitle ? { subtitle } : {}),
...(meta.length ? { meta } : {}),
},
];
if (facts.length) sections.push({ type: "facts", rows: facts });
if (checklist.length) {
sections.push({
type: "checklist",
id: "checklist",
items: checklist.map((label, index) => ({ id: `i${index + 1}`, label })),
});
}
if (options.length) {
sections.push({
type: "select",
id: "select",
...(multiple ? { multiple: true } : {}),
options: options.map((label, index) => ({ id: `o${index + 1}`, label })),
});
}
if (actions.length) {
sections.push({
type: "actions",
actions: actions.map((label, index) => ({ id: `a${index + 1}`, label, kind: "reply" })),
});
}
const parsed = CardBlockSchema.safeParse({ kind: "card", id, sections });
if (!parsed.success) {
const issue = parsed.error.issues[0];
return { error: `Card is not valid: ${issue?.path.join(".")} ${issue?.message}`.trim() };
}
return { ok: true, block: parsed.data };
}
export type CardActResult =
| { ok: true; block: CardBlock; changed: boolean; reply?: string }
| { ok: false; error: string };
function section<T extends CardSection["type"]>(
block: CardBlock,
type: T,
id?: string,
): Extract<CardSection, { type: T }> | undefined {
return block.sections?.find(
(candidate): candidate is Extract<CardSection, { type: T }> =>
candidate.type === type && (id === undefined || ("id" in candidate && candidate.id === id)),
);
}
function replaceSection(block: CardBlock, next: CardSection, index: number): CardBlock {
const sections = [...(block.sections ?? [])];
sections[index] = next;
return { ...block, sections };
}
/**
* Apply one tap. Checklists stay usable after the card is answered (a shopping list is still a
* shopping list); a choice or an action answers the card once.
*/
export function applyCardAct(block: CardBlock, act: CardAct): CardActResult {
const sections = block.sections ?? [];
if (act.type === "toggle") {
const index = sections.findIndex(
(candidate) => candidate.type === "checklist" && candidate.id === act.sectionId,
);
const list = sections[index];
if (!list || list.type !== "checklist") return { ok: false, error: "No such checklist." };
if (!list.items.some((item) => item.id === act.itemId)) {
return { ok: false, error: "No such item." };
}
const current = list.items.find((item) => item.id === act.itemId);
if (Boolean(current?.checked) === act.checked) return { ok: true, changed: false, block };
const items = list.items.map((item) =>
item.id === act.itemId ? { ...item, checked: act.checked } : item,
);
return { ok: true, changed: true, block: replaceSection(block, { ...list, items }, index) };
}
if (block.answer) return { ok: false, error: "This card was already answered." };
if (act.type === "select") {
const index = sections.findIndex(
(candidate) => candidate.type === "select" && candidate.id === act.sectionId,
);
const select = sections[index];
if (!select || select.type !== "select") return { ok: false, error: "No such choice." };
const option = select.options.find((candidate) => candidate.id === act.optionId);
if (!option) return { ok: false, error: "No such option." };
if (select.multiple) {
const current = select.selected ?? [];
const on = act.selected ?? !current.includes(option.id);
if (current.includes(option.id) === on) return { ok: true, changed: false, block };
// Keep the options' order so the reply reads the way the card does.
const selected = select.options
.map((candidate) => candidate.id)
.filter((id) => (id === option.id ? on : current.includes(id)));
return {
ok: true,
changed: true,
block: replaceSection(block, { ...select, selected }, index),
};
}
const next = replaceSection(block, { ...select, selected: [option.id] }, index);
return {
ok: true,
changed: true,
reply: option.label,
block: { ...next, answer: { text: option.label } },
};
}
const actions = section(block, "actions");
const action = actions?.actions.find((candidate) => candidate.id === act.actionId);
if (!action) return { ok: false, error: "No such action." };
const select = section(block, "select");
const chosen = select?.options
.filter((option) => select.selected?.includes(option.id))
.map((option) => option.label);
if (select?.multiple && !chosen?.length) {
return { ok: false, error: "Choose at least one option first." };
}
const reply = chosen?.length ? `${action.label}: ${chosen.join(", ")}` : action.label;
return {
ok: true,
changed: true,
reply,
block: { ...block, answer: { text: reply, actionId: action.id } },
};
}
/** The card as a later turn reads it: content plus what the user has done with it. */
export function cardHistoryText(block: CardBlock): string {
if (!block.sections) return (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join("\n");
const parts: string[] = [];
for (const part of block.sections) {
if (part.type === "header") {
parts.push([part.title, part.subtitle, ...(part.meta ?? [])].filter(Boolean).join(" · "));
} else if (part.type === "facts") {
parts.push(part.rows.map((row) => `${row.label}: ${row.value}`).join("; "));
} else if (part.type === "checklist") {
const done = part.items.filter((item) => item.checked).length;
const open = part.items.filter((item) => !item.checked).map((item) => item.label);
parts.push(
`checklist ${done}/${part.items.length} done${open.length ? `, open: ${open.join(", ")}` : ""}`,
);
} else if (part.type === "select") {
const chosen = part.options.filter((option) => part.selected?.includes(option.id));
parts.push(
`options: ${part.options.map((option) => option.label).join(" | ")}${
chosen.length ? ` (selected: ${chosen.map((option) => option.label).join(", ")})` : ""
}`,
);
} else {
parts.push(`actions: ${part.actions.map((action) => action.label).join(" | ")}`);
}
}
if (block.answer) parts.push(`answered: ${block.answer.text}`);
return `[card: ${parts.join(" — ")}]`;
}

View File

@ -9,6 +9,7 @@ export * from "./avatar-shape.js";
export * from "./bot-messages.js"; export * from "./bot-messages.js";
export * from "./bot-reply-sanitize.js"; export * from "./bot-reply-sanitize.js";
export * from "./bot-sections.js"; export * from "./bot-sections.js";
export * from "./cards.js";
export * from "./cloud-agent.js"; export * from "./cloud-agent.js";
export * from "./coach.js"; export * from "./coach.js";
export * from "./compose-update.js"; export * from "./compose-update.js";

View File

@ -249,7 +249,10 @@ export function speechFromBlocks(blocks: MessageBlock[]): string {
return block.result || `${block.name} finished`; return block.result || `${block.name} finished`;
} }
if (block.kind === "card") { if (block.kind === "card") {
return block.lines.map((line) => `${line.k}: ${line.v}`).join(". "); const header = block.sections?.find((part) => part.type === "header");
if (header?.type === "header")
return [header.title, header.subtitle].filter(Boolean).join(". ");
return (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join(". ");
} }
return ""; return "";
}) })