feat(onboarding): ask the onboarding questions in the main conversation
The wizard asked for the account name again and a "Tell us about yourself" form, and the focus answer in the main conversation was never saved. The main bot now gets to know the user in the main conversation instead, one card after each answer. - UserOnboarding keeps the pending step, the focus and when onboarding finished. - onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications) as cards plus what their answers change; onboarding.ts commits an answer, the focus app suggestions and the next card or the closing line in one transaction. - The choice block gains a step, an optional text input and the typed answer; choose takes the step, the text and whether the device can still turn push on. - The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the push seam are placeholders until the coach and notification modules are merged. - The wizard drops the preferences step and fills time zone, locale and language from the browser; the main bot's prompt notes open onboarding cards; agent history shows a card's question and answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
3d180dc2dd
commit
f4e02eb248
318
apps/api/src/onboarding-intake.ts
Normal file
318
apps/api/src/onboarding-intake.ts
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import type { MessageBlock, OnboardingStep } from "@rakazo/contracts";
|
||||||
|
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||||
|
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
|
||||||
|
import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The questions the main bot asks a new user in the main conversation, one card at a time.
|
||||||
|
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
|
||||||
|
* the user sees. Everything here is deterministic, so no model tokens are spent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
|
||||||
|
type ChoiceOption = ChoiceBlock["options"][number];
|
||||||
|
|
||||||
|
/** When the main bot checks in; the same shape the coach rhythm stores. */
|
||||||
|
export interface CheckInRhythm {
|
||||||
|
morning: string | null;
|
||||||
|
evening: string | null;
|
||||||
|
weekly: { day: number; time: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuietHours {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A validated answer: an option of the card, or the user's own words. */
|
||||||
|
export type IntakeAnswer =
|
||||||
|
| { option: ChoiceOption; text?: undefined }
|
||||||
|
| { option?: undefined; text: string };
|
||||||
|
|
||||||
|
/** What a card may adapt to: times are written in the user's locale. */
|
||||||
|
export interface IntakeContext {
|
||||||
|
locale: string;
|
||||||
|
timeZone: string;
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntakeEffects {
|
||||||
|
prisma: PrismaClient;
|
||||||
|
tx: Prisma.TransactionClient;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntakeStep {
|
||||||
|
id: OnboardingStep;
|
||||||
|
/** Asked only when this holds; otherwise onboarding moves past the step. */
|
||||||
|
asks?: (device: { canEnablePush: boolean }) => boolean;
|
||||||
|
card: (context: IntakeContext) => ChoiceBlock;
|
||||||
|
/** Records a validated answer. Runs while the main conversation is locked. */
|
||||||
|
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusOption {
|
||||||
|
id: string;
|
||||||
|
letter: string;
|
||||||
|
label: string;
|
||||||
|
summary: string;
|
||||||
|
apps: string[];
|
||||||
|
/** Stored as a `goals` fact, so it stays a plain English statement. */
|
||||||
|
fact: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FOCUS_OPTIONS: FocusOption[] = [
|
||||||
|
{
|
||||||
|
id: "day",
|
||||||
|
letter: "A",
|
||||||
|
label: "Day-to-day work",
|
||||||
|
summary: "Slack, calendar, and email",
|
||||||
|
apps: ["slack", "gmail", "googlecalendar"],
|
||||||
|
fact: "Wants help with day-to-day work first",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inbox",
|
||||||
|
letter: "B",
|
||||||
|
label: "Inbox & email",
|
||||||
|
summary: "email and calendar",
|
||||||
|
apps: ["gmail", "googlecalendar", "slack"],
|
||||||
|
fact: "Wants help with inbox and email first",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "research",
|
||||||
|
letter: "C",
|
||||||
|
label: "Research & writing",
|
||||||
|
summary: "the web, notes, and docs",
|
||||||
|
apps: ["hackernews", "notion", "googledocs"],
|
||||||
|
fact: "Wants help with research and writing first",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crew",
|
||||||
|
letter: "D",
|
||||||
|
label: "Specialist crew",
|
||||||
|
summary: "a coordinated team of focused Oos",
|
||||||
|
apps: ["slack", "gmail", "googlecalendar"],
|
||||||
|
fact: "Wants a coordinated crew of specialist bots",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
|
||||||
|
|
||||||
|
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
|
||||||
|
export function focusCard(step?: "focus"): ChoiceBlock {
|
||||||
|
return {
|
||||||
|
kind: "choice",
|
||||||
|
question: "What do you want me on first?",
|
||||||
|
options: [
|
||||||
|
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
||||||
|
...(step ? [SKIP] : []),
|
||||||
|
],
|
||||||
|
...(step ? { step } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RHYTHMS: Record<string, CheckInRhythm> = {
|
||||||
|
morning: { morning: "08:00", evening: null, weekly: null },
|
||||||
|
morning_evening: { morning: "08:00", evening: "18:00", weekly: null },
|
||||||
|
weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } },
|
||||||
|
none: { morning: null, evening: null, weekly: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUIET_HOURS: Record<string, QuietHours> = {
|
||||||
|
"22-07": { start: "22:00", end: "07:00" },
|
||||||
|
"23-08": { start: "23:00", end: "08:00" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Coach and notification effects. They land with the coach (goals, rhythm) and notification
|
||||||
|
// settings modules; until then the answer stays on its card.
|
||||||
|
async function saveGoal(_effects: IntakeEffects, _title: string): Promise<void> {}
|
||||||
|
async function saveRhythm(_effects: IntakeEffects, _rhythm: CheckInRhythm): Promise<void> {}
|
||||||
|
async function saveQuietHours(_effects: IntakeEffects, _quietHours: QuietHours): Promise<void> {}
|
||||||
|
|
||||||
|
export const INTAKE_STEPS: IntakeStep[] = [
|
||||||
|
{
|
||||||
|
id: "focus",
|
||||||
|
card: () => focusCard("focus"),
|
||||||
|
save: async ({ prisma, tx, userId }, answer) => {
|
||||||
|
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
|
||||||
|
if (!option) return;
|
||||||
|
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
|
||||||
|
await updateUserProfile(prisma, userId, {
|
||||||
|
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "goal",
|
||||||
|
card: () => ({
|
||||||
|
kind: "choice",
|
||||||
|
step: "goal",
|
||||||
|
question: "What’s one thing you want to get done in the next few weeks?",
|
||||||
|
textInput: { placeholder: "Your goal" },
|
||||||
|
options: [SKIP],
|
||||||
|
}),
|
||||||
|
save: async (effects, answer) => {
|
||||||
|
if (answer.text) await saveGoal(effects, answer.text);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rhythm",
|
||||||
|
card: () => ({
|
||||||
|
kind: "choice",
|
||||||
|
step: "rhythm",
|
||||||
|
question: "When should I check in with you?",
|
||||||
|
options: [
|
||||||
|
{ id: "morning", letter: "A", label: "Every morning" },
|
||||||
|
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
|
||||||
|
{ id: "weekly", letter: "C", label: "Once a week" },
|
||||||
|
{ id: "none", letter: "", label: "Not now" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
save: async (effects, answer) => {
|
||||||
|
const rhythm = answer.option && RHYTHMS[answer.option.id];
|
||||||
|
if (rhythm) await saveRhythm(effects, rhythm);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quiet_hours",
|
||||||
|
card: (context) => ({
|
||||||
|
kind: "choice",
|
||||||
|
step: "quiet_hours",
|
||||||
|
question: "When should I stay quiet?",
|
||||||
|
options: [
|
||||||
|
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
|
||||||
|
id,
|
||||||
|
letter: String.fromCharCode(65 + index),
|
||||||
|
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
|
||||||
|
})),
|
||||||
|
SKIP,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
save: async (effects, answer) => {
|
||||||
|
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
|
||||||
|
if (quietHours) await saveQuietHours(effects, quietHours);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
|
||||||
|
id: "notifications",
|
||||||
|
asks: ({ canEnablePush }) => canEnablePush,
|
||||||
|
card: () => ({
|
||||||
|
kind: "choice",
|
||||||
|
step: "notifications",
|
||||||
|
question: "Can I send you notifications?",
|
||||||
|
options: [
|
||||||
|
{ id: "on", letter: "A", label: "Turn on" },
|
||||||
|
{ id: "later", letter: "", label: "Not now" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
save: async () => {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function intakeStep(id: OnboardingStep): IntakeStep {
|
||||||
|
const step = INTAKE_STEPS.find((entry) => entry.id === id);
|
||||||
|
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The step after `id` that applies to this device, or null when onboarding is done. */
|
||||||
|
export function nextIntakeStep(
|
||||||
|
id: OnboardingStep,
|
||||||
|
device: { canEnablePush: boolean },
|
||||||
|
): IntakeStep | null {
|
||||||
|
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
|
||||||
|
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks an answer against the card it answers; null when the card does not offer it. */
|
||||||
|
export function readIntakeAnswer(
|
||||||
|
card: ChoiceBlock,
|
||||||
|
optionId: string,
|
||||||
|
text: string | undefined,
|
||||||
|
): IntakeAnswer | null {
|
||||||
|
if (optionId === CHOICE_TEXT_ANSWER_ID) {
|
||||||
|
const words = text?.trim();
|
||||||
|
return card.textInput && words ? { text: words } : null;
|
||||||
|
}
|
||||||
|
const option = card.options.find((entry) => entry.id === optionId);
|
||||||
|
return option ? { option } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
|
||||||
|
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
|
||||||
|
return card.options.find((option) => !option.letter);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rhythmForAnswer(answerId: string | undefined): CheckInRhythm | null {
|
||||||
|
return (answerId && RHYTHMS[answerId]) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
|
||||||
|
export function intakeContext(
|
||||||
|
profile: { locale: string | null; timeZone: string | null } | null,
|
||||||
|
now: Date,
|
||||||
|
): IntakeContext {
|
||||||
|
let locale = "en";
|
||||||
|
try {
|
||||||
|
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
|
||||||
|
} catch {
|
||||||
|
// Not a language tag; keep English.
|
||||||
|
}
|
||||||
|
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(time: string, locale: string): string {
|
||||||
|
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rhythmCrons(rhythm: CheckInRhythm): string[] {
|
||||||
|
const cron = (time: string, day = "*") => {
|
||||||
|
const [hour, minute] = time.split(":").map(Number);
|
||||||
|
return `${minute} ${hour} * * ${day}`;
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
...(rhythm.morning ? [cron(rhythm.morning)] : []),
|
||||||
|
...(rhythm.evening ? [cron(rhythm.evening)] : []),
|
||||||
|
...(rhythm.weekly ? [cron(rhythm.weekly.time, String(rhythm.weekly.day))] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDay(date: Date, timeZone: string): number {
|
||||||
|
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
})
|
||||||
|
.format(date)
|
||||||
|
.split("-")
|
||||||
|
.map(Number);
|
||||||
|
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line that says what happens next: the first check-in, when there is one. */
|
||||||
|
export function closingLine(rhythm: CheckInRhythm | null, context: IntakeContext): string {
|
||||||
|
const next = rhythm
|
||||||
|
? nextCronDateAcross(rhythmCrons(rhythm), context.now, context.timeZone)
|
||||||
|
: null;
|
||||||
|
if (!next) return "All set. I’m here whenever you need me.";
|
||||||
|
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
|
||||||
|
const day =
|
||||||
|
days === 0
|
||||||
|
? "today"
|
||||||
|
: days === 1
|
||||||
|
? "tomorrow"
|
||||||
|
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
|
||||||
|
const time = new Intl.DateTimeFormat(context.locale, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: context.timeZone,
|
||||||
|
}).format(next);
|
||||||
|
return `All set. I’ll check in ${day} at ${time}.`;
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
import type { ConnectorRegistry } from "@rakazo/adapters";
|
import type { ConnectorRegistry } from "@rakazo/adapters";
|
||||||
import type { Actor, MessageBlock } from "@rakazo/contracts";
|
import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts";
|
||||||
|
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||||
import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
import { featuredConnectorProvidersMatch } from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
appendEventInTransaction,
|
appendEventInTransaction,
|
||||||
@ -10,12 +12,27 @@ import {
|
|||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
type ThreadEvents,
|
type ThreadEvents,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
|
import {
|
||||||
|
type ChoiceBlock,
|
||||||
|
closingLine,
|
||||||
|
FOCUS_OPTIONS,
|
||||||
|
type FocusOption,
|
||||||
|
focusCard,
|
||||||
|
type IntakeAnswer,
|
||||||
|
type IntakeContext,
|
||||||
|
intakeContext,
|
||||||
|
intakeStep,
|
||||||
|
nextIntakeStep,
|
||||||
|
readIntakeAnswer,
|
||||||
|
rhythmForAnswer,
|
||||||
|
secondaryOption,
|
||||||
|
} from "./onboarding-intake.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run conversational onboarding, seeded deterministically into the bot's
|
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
|
||||||
* thread: greeting, a focus choice, and available app cards the user authorizes
|
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
|
||||||
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
|
* conversation, one card after each answer. Any other bot gets one focus card that suggests
|
||||||
* talks in the main conversation, where onboarding lands.
|
* apps the user authorizes inline. Focus never renames a bot.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type OnboardingDeps = {
|
type OnboardingDeps = {
|
||||||
@ -24,44 +41,7 @@ type OnboardingDeps = {
|
|||||||
connectors: ConnectorRegistry;
|
connectors: ConnectorRegistry;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FocusOption = {
|
type Target = { spaceId: string; botId: string; threadId: string };
|
||||||
id: string;
|
|
||||||
letter: string;
|
|
||||||
label: string;
|
|
||||||
summary: string;
|
|
||||||
apps: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const FOCUS_OPTIONS: FocusOption[] = [
|
|
||||||
{
|
|
||||||
id: "day",
|
|
||||||
letter: "A",
|
|
||||||
label: "Day-to-day work",
|
|
||||||
summary: "Slack, calendar, and email",
|
|
||||||
apps: ["slack", "gmail", "googlecalendar"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "inbox",
|
|
||||||
letter: "B",
|
|
||||||
label: "Inbox & email",
|
|
||||||
summary: "email and calendar",
|
|
||||||
apps: ["gmail", "googlecalendar", "slack"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "research",
|
|
||||||
letter: "C",
|
|
||||||
label: "Research & writing",
|
|
||||||
summary: "the web, notes, and docs",
|
|
||||||
apps: ["hackernews", "notion", "googledocs"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "crew",
|
|
||||||
letter: "D",
|
|
||||||
label: "Specialist crew",
|
|
||||||
summary: "a coordinated team of focused Oos",
|
|
||||||
apps: ["slack", "gmail", "googlecalendar"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const APP_DESCRIPTIONS: Record<string, string> = {
|
const APP_DESCRIPTIONS: Record<string, string> = {
|
||||||
slack: "Search, read, and send messages.",
|
slack: "Search, read, and send messages.",
|
||||||
@ -85,38 +65,36 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
|
|||||||
return { bot, thread: bot.thread };
|
return { bot, thread: bot.thread };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(
|
async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise<void> {
|
||||||
deps: OnboardingDeps,
|
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR UPDATE`;
|
||||||
target: { spaceId: string; botId: string; threadId: string },
|
}
|
||||||
|
|
||||||
|
async function postInTransaction(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
target: Target,
|
||||||
blocks: MessageBlock[],
|
blocks: MessageBlock[],
|
||||||
): Promise<string> {
|
): Promise<{ seq: number }> {
|
||||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
role: "bot",
|
role: "bot",
|
||||||
blocks,
|
blocks,
|
||||||
botId: target.botId,
|
botId: target.botId,
|
||||||
});
|
});
|
||||||
const event = await appendEventInTransaction(tx, {
|
return appendEventInTransaction(tx, {
|
||||||
spaceId: target.spaceId,
|
spaceId: target.spaceId,
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
botId: target.botId,
|
botId: target.botId,
|
||||||
type: "thread.message.created",
|
type: "thread.message.created",
|
||||||
payload: { messageId: message.id, role: "bot", blocks },
|
payload: { messageId: message.id, role: "bot", blocks },
|
||||||
});
|
});
|
||||||
return { message, event };
|
|
||||||
});
|
|
||||||
await deps.events.notify(target.threadId, committed.event.seq);
|
|
||||||
return committed.message.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateBlocks(
|
async function updateBlocksInTransaction(
|
||||||
deps: OnboardingDeps,
|
tx: Prisma.TransactionClient,
|
||||||
target: { spaceId: string; botId: string; threadId: string },
|
target: Target,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
blocks: MessageBlock[],
|
blocks: MessageBlock[],
|
||||||
): Promise<void> {
|
): Promise<{ seq: number }> {
|
||||||
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
|
||||||
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
await tx.message.update({ where: { id: messageId }, data: { blocks } });
|
||||||
return appendEventInTransaction(tx, {
|
return appendEventInTransaction(tx, {
|
||||||
spaceId: target.spaceId,
|
spaceId: target.spaceId,
|
||||||
@ -125,34 +103,60 @@ async function updateBlocks(
|
|||||||
type: "thread.message.updated",
|
type: "thread.message.updated",
|
||||||
payload: { messageId, role: "bot", blocks },
|
payload: { messageId, role: "bot", blocks },
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
async function updateBlocks(
|
||||||
|
deps: OnboardingDeps,
|
||||||
|
target: Target,
|
||||||
|
messageId: string,
|
||||||
|
blocks: MessageBlock[],
|
||||||
|
): Promise<void> {
|
||||||
|
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
|
||||||
|
updateBlocksInTransaction(tx, target, messageId, blocks),
|
||||||
|
);
|
||||||
await deps.events.notify(target.threadId, event.seq);
|
await deps.events.notify(target.threadId, event.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
/** Sentinel answerId for a focus card the user dismissed without choosing. */
|
||||||
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
|
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
|
||||||
|
|
||||||
|
/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */
|
||||||
export async function startOnboarding(
|
export async function startOnboarding(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
botId: string,
|
botId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { thread } = await requireBotThread(deps, actor, botId);
|
await requireBotThread(deps, actor, botId);
|
||||||
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
|
|
||||||
if (existing > 0) return;
|
|
||||||
// Fresh chats start empty (same as web). The focus card is posted later via
|
|
||||||
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
|
|
||||||
// user already engaged.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageHasChoice(blocks: MessageBlock[]): boolean {
|
function isChoice(block: MessageBlock): block is ChoiceBlock {
|
||||||
return blocks.some((block) => block.kind === "choice");
|
return block.kind === "choice";
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
|
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
|
||||||
return blocks.some((block) => block.kind === "choice" && !block.answerId);
|
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The newest card in the thread that still waits for an answer. */
|
||||||
|
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
|
||||||
|
const messages = await client.message.findMany({
|
||||||
|
where: { threadId, role: "bot" },
|
||||||
|
select: { id: true, blocks: true },
|
||||||
|
orderBy: { seq: "desc" },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
for (const message of messages) {
|
||||||
|
const blocks = message.blocks as MessageBlock[];
|
||||||
|
const card = pendingChoice(blocks);
|
||||||
|
if (card) return { messageId: message.id, blocks, card };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the first card once. The main bot starts the onboarding questions, one set per user;
|
||||||
|
* any other bot posts its focus card only while its thread is still idle.
|
||||||
|
*/
|
||||||
export async function promptFocus(
|
export async function promptFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@ -160,44 +164,31 @@ export async function promptFocus(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
const blocks: MessageBlock[] = [
|
const main = bot.kind === "main";
|
||||||
{
|
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
|
||||||
kind: "choice",
|
// and a concurrent user send cannot publish first.
|
||||||
question: "What do you want me on first?",
|
|
||||||
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
// Check + insert + event in one transaction so concurrent promptFocus calls
|
|
||||||
// cannot duplicate cards, and a concurrent user send cannot publish first.
|
|
||||||
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
// Serialize concurrent promptFocus callers on this thread before the gate check.
|
await lockThread(tx, thread.id);
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const recent = await tx.message.findMany({
|
const recent = await tx.message.findMany({
|
||||||
where: { threadId: thread.id },
|
where: { threadId: thread.id },
|
||||||
select: { role: true, blocks: true },
|
select: { role: true, blocks: true },
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
});
|
||||||
if (recent.some((message) => message.role === "user")) return null;
|
if (!main && recent.some((message) => message.role === "user")) return null;
|
||||||
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
|
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
|
||||||
const message = await createThreadMessageInTransaction(tx, {
|
if (!main) return postInTransaction(tx, target, [focusCard()]);
|
||||||
threadId: target.threadId,
|
const first = intakeStep("focus");
|
||||||
role: "bot",
|
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
|
||||||
blocks,
|
return postInTransaction(tx, target, [
|
||||||
botId: target.botId,
|
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
const event = await appendEventInTransaction(tx, {
|
if (committed) await deps.events.notify(target.threadId, committed.seq);
|
||||||
spaceId: target.spaceId,
|
|
||||||
threadId: target.threadId,
|
|
||||||
botId: target.botId,
|
|
||||||
type: "thread.message.created",
|
|
||||||
payload: { messageId: message.id, role: "bot", blocks },
|
|
||||||
});
|
|
||||||
return { message, event };
|
|
||||||
});
|
|
||||||
if (!committed) return;
|
|
||||||
await deps.events.notify(target.threadId, committed.event.seq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
|
||||||
export async function dismissFocus(
|
export async function dismissFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@ -205,81 +196,143 @@ export async function dismissFocus(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
|
||||||
const recent = await tx.message.findMany({
|
if (pending?.card.step && skip) {
|
||||||
where: { threadId: thread.id },
|
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
|
||||||
orderBy: { createdAt: "asc" },
|
return;
|
||||||
});
|
}
|
||||||
const pending = recent.find((message) =>
|
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
await lockThread(tx, thread.id);
|
||||||
|
const current = await findPendingCard(tx, thread.id);
|
||||||
|
if (!current || current.card.step) return null;
|
||||||
|
const blocks = current.blocks.map((block) =>
|
||||||
|
block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block,
|
||||||
);
|
);
|
||||||
if (!pending) return null;
|
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
|
||||||
block.kind === "choice" && !block.answerId
|
|
||||||
? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID }
|
|
||||||
: block,
|
|
||||||
);
|
|
||||||
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
|
||||||
const event = await appendEventInTransaction(tx, {
|
|
||||||
spaceId: target.spaceId,
|
|
||||||
threadId: target.threadId,
|
|
||||||
botId: target.botId,
|
|
||||||
type: "thread.message.updated",
|
|
||||||
payload: { messageId: pending.id, role: "bot", blocks },
|
|
||||||
});
|
});
|
||||||
return { messageId: pending.id, blocks, event };
|
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||||
});
|
|
||||||
if (!claimed) return;
|
|
||||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChooseInput {
|
||||||
|
step?: OnboardingStep;
|
||||||
|
optionId: string;
|
||||||
|
text?: string;
|
||||||
|
canEnablePush?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers the pending card. The answer is checked against that card, a stale answer (another
|
||||||
|
* card is pending by now) is ignored, and everything that follows is committed together with
|
||||||
|
* it: the focus card's app suggestions, then the next onboarding question or the closing line.
|
||||||
|
*/
|
||||||
export async function chooseFocus(
|
export async function chooseFocus(
|
||||||
deps: OnboardingDeps,
|
deps: OnboardingDeps,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
botId: string,
|
botId: string,
|
||||||
optionId: string,
|
input: ChooseInput,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
|
|
||||||
if (!option) throw new IsolationError();
|
|
||||||
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
const { bot, thread } = await requireBotThread(deps, actor, botId);
|
||||||
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
|
||||||
|
const pending = await findPendingCard(deps.prisma, thread.id);
|
||||||
|
if (!pending || pending.card.step !== input.step) return;
|
||||||
|
const answer = readIntakeAnswer(pending.card, input.optionId, input.text);
|
||||||
|
if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
|
||||||
|
const focus =
|
||||||
|
!pending.card.step || pending.card.step === "focus"
|
||||||
|
? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id)
|
||||||
|
: undefined;
|
||||||
|
if (!pending.card.step && !focus) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
|
||||||
|
}
|
||||||
|
// Fetched before the transaction: the connector catalog can take seconds.
|
||||||
|
const followUp = focus
|
||||||
|
? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step))
|
||||||
|
: [];
|
||||||
|
|
||||||
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||||
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
|
await lockThread(tx, thread.id);
|
||||||
const recent = await tx.message.findMany({
|
const current = await findPendingCard(tx, thread.id);
|
||||||
where: { threadId: thread.id },
|
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
|
||||||
orderBy: { createdAt: "asc" },
|
const onboarding = current.card.step
|
||||||
|
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
|
||||||
|
: null;
|
||||||
|
if (current.card.step) {
|
||||||
|
if (onboarding?.step !== current.card.step) return null;
|
||||||
|
await intakeStep(current.card.step).save(
|
||||||
|
{ prisma: deps.prisma, tx, userId: actor.userId },
|
||||||
|
answer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const answered = answeredCard(current.card, answer);
|
||||||
|
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
|
||||||
|
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
|
||||||
|
for (const message of followUp) last = await postInTransaction(tx, target, message);
|
||||||
|
if (current.card.step) {
|
||||||
|
last = await askNext(tx, target, actor.userId, current.card.step, input.canEnablePush);
|
||||||
|
}
|
||||||
|
return last;
|
||||||
});
|
});
|
||||||
const pending = recent.find((message) =>
|
if (event) await deps.events.notify(target.threadId, event.seq);
|
||||||
messageHasPendingChoice(message.blocks as MessageBlock[]),
|
}
|
||||||
);
|
|
||||||
if (!pending) return null;
|
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
|
||||||
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
|
return answer.option
|
||||||
block.kind === "choice" ? { ...block, answerId: option.id } : block,
|
? { ...card, answerId: answer.option.id }
|
||||||
);
|
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
|
||||||
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
|
}
|
||||||
const event = await appendEventInTransaction(tx, {
|
|
||||||
spaceId: target.spaceId,
|
async function loadIntakeContext(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: string,
|
||||||
|
now: Date,
|
||||||
|
): Promise<IntakeContext> {
|
||||||
|
const profile = await tx.userProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { locale: true, timeZone: true },
|
||||||
|
});
|
||||||
|
return intakeContext(profile, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts the next onboarding question, or the closing line once every step is answered. */
|
||||||
|
async function askNext(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
target: Target,
|
||||||
|
userId: string,
|
||||||
|
answeredStep: OnboardingStep,
|
||||||
|
canEnablePush = false,
|
||||||
|
): Promise<{ seq: number }> {
|
||||||
|
const now = new Date();
|
||||||
|
const context = await loadIntakeContext(tx, userId, now);
|
||||||
|
const next = nextIntakeStep(answeredStep, { canEnablePush });
|
||||||
|
if (next) {
|
||||||
|
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
|
||||||
|
return postInTransaction(tx, target, [next.card(context)]);
|
||||||
|
}
|
||||||
|
const rhythmCard = await tx.message.findFirst({
|
||||||
|
where: {
|
||||||
threadId: target.threadId,
|
threadId: target.threadId,
|
||||||
botId: target.botId,
|
role: "bot",
|
||||||
type: "thread.message.updated",
|
blocks: { array_contains: [{ kind: "choice", step: "rhythm" }] },
|
||||||
payload: { messageId: pending.id, role: "bot", blocks },
|
|
||||||
});
|
|
||||||
return { messageId: pending.id, blocks, event };
|
|
||||||
});
|
|
||||||
if (!claimed) return;
|
|
||||||
await deps.events.notify(target.threadId, claimed.event.seq);
|
|
||||||
|
|
||||||
// Keep the name and title the user chose when creating the bot; the focus
|
|
||||||
// step only suggests apps, it must not rename the bot.
|
|
||||||
await post(deps, target, [
|
|
||||||
{
|
|
||||||
kind: "text",
|
|
||||||
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
|
|
||||||
},
|
},
|
||||||
]);
|
orderBy: { seq: "desc" },
|
||||||
|
select: { blocks: true },
|
||||||
|
});
|
||||||
|
const rhythm = rhythmForAnswer(
|
||||||
|
(rhythmCard?.blocks as MessageBlock[] | undefined)?.find(isChoice)?.answerId,
|
||||||
|
);
|
||||||
|
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
|
||||||
|
return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */
|
||||||
|
async function focusFollowUp(
|
||||||
|
deps: OnboardingDeps,
|
||||||
|
actor: Actor,
|
||||||
|
botId: string,
|
||||||
|
option: FocusOption,
|
||||||
|
onboarding: boolean,
|
||||||
|
): Promise<MessageBlock[][]> {
|
||||||
const providers = deps.connectors.managedProviders();
|
const providers = deps.connectors.managedProviders();
|
||||||
const catalog = (
|
const catalog = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -290,14 +343,14 @@ export async function chooseFocus(
|
|||||||
traceId: "onboarding.choose",
|
traceId: "onboarding.choose",
|
||||||
spaceId: actor.spaceId,
|
spaceId: actor.spaceId,
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
botId: bot.id,
|
botId,
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
).flat();
|
).flat();
|
||||||
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
|
const cards = option.apps.flatMap((slug) => {
|
||||||
const entry = catalog.find(
|
const entry = catalog.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.slug.toLowerCase() === slug.toLowerCase() ||
|
item.slug.toLowerCase() === slug.toLowerCase() ||
|
||||||
@ -317,26 +370,22 @@ export async function chooseFocus(
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
|
// In onboarding the next question follows; another bot asks what to start with.
|
||||||
return;
|
const text = onboarding ? "Got it." : "Got it. What would you like to work on first?";
|
||||||
|
return [[{ kind: "text", text }]];
|
||||||
}
|
}
|
||||||
const cardNames = cards
|
const names = cards.map((card) => card.name);
|
||||||
.map((card) => (card.kind === "app_connect" ? card.name : ""))
|
const named =
|
||||||
.filter(Boolean);
|
names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0];
|
||||||
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
|
return [
|
||||||
await post(deps, target, [
|
[
|
||||||
{
|
{
|
||||||
kind: "text",
|
kind: "text",
|
||||||
text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
|
text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`,
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
await post(deps, target, cards);
|
cards,
|
||||||
await post(deps, target, [
|
];
|
||||||
{
|
|
||||||
kind: "text",
|
|
||||||
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAppConnected(
|
export async function markAppConnected(
|
||||||
@ -376,7 +425,3 @@ export async function markAppConnected(
|
|||||||
await updateBlocks(deps, target, message.id, next);
|
await updateBlocks(deps, target, message.id, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(value: string): string {
|
|
||||||
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3417,7 +3417,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
|
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
|
||||||
await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId);
|
await chooseFocus(onboardingDeps, context.actor, input.botId, input);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
|
||||||
|
|||||||
@ -1,56 +1,25 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { onboardingProfileUpdate } from "./onboarding-profile";
|
import { onboardingProfileUpdate } from "./onboarding-profile";
|
||||||
|
|
||||||
const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" };
|
|
||||||
|
|
||||||
describe("onboardingProfileUpdate", () => {
|
describe("onboardingProfileUpdate", () => {
|
||||||
it("turns answers into profile fields and onboarding facts", () => {
|
it("fills the time zone, locale and chosen language from the browser", () => {
|
||||||
expect(
|
expect(
|
||||||
onboardingProfileUpdate({
|
onboardingProfileUpdate({
|
||||||
answers: {
|
browserTimeZone: "Europe/Vienna",
|
||||||
name: " Alice ",
|
|
||||||
timezone: "Europe/Vienna",
|
|
||||||
hobbies: ["music", "tech", "none"],
|
|
||||||
workStyle: "deep-dive",
|
|
||||||
communication: "direct",
|
|
||||||
},
|
|
||||||
accountName: "Alice Example",
|
|
||||||
browserTimeZone: "UTC",
|
|
||||||
locale: "de-AT",
|
locale: "de-AT",
|
||||||
language: "de",
|
language: "de",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" });
|
||||||
name: "Alice",
|
|
||||||
timeZone: "Europe/Vienna",
|
|
||||||
locale: "de-AT",
|
|
||||||
language: "de",
|
|
||||||
communicationStyle: "Direct and concise",
|
|
||||||
addFacts: [
|
|
||||||
{ category: "preferences", value: "Interested in music", source: "onboarding" },
|
|
||||||
{ category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" },
|
|
||||||
{ category: "habits", value: "Prefers focused deep dives", source: "onboarding" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the account name and falls back to the browser time zone", () => {
|
it("leaves out a language the user did not pick and malformed values", () => {
|
||||||
expect(
|
expect(
|
||||||
onboardingProfileUpdate({
|
onboardingProfileUpdate({
|
||||||
answers: { ...blank, name: "Alice", timezone: "Not/AZone" },
|
browserTimeZone: "Not/AZone",
|
||||||
accountName: "Alice",
|
|
||||||
browserTimeZone: "America/Toronto",
|
|
||||||
}),
|
|
||||||
).toEqual({ timeZone: "America/Toronto" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves out a language the user did not pick and malformed tags", () => {
|
|
||||||
expect(
|
|
||||||
onboardingProfileUpdate({
|
|
||||||
answers: blank,
|
|
||||||
browserTimeZone: "UTC",
|
|
||||||
locale: "not a tag",
|
locale: "not a tag",
|
||||||
language: null,
|
language: null,
|
||||||
}),
|
}),
|
||||||
).toEqual({ timeZone: "UTC" });
|
).toEqual({});
|
||||||
|
expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,73 +1,19 @@
|
|||||||
import type { UserFactCategory } from "@rakazo/contracts";
|
|
||||||
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
|
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
|
||||||
|
|
||||||
export interface OnboardingAnswers {
|
/**
|
||||||
name: string;
|
* The profile fields onboarding fills in without asking: the browser's time zone and locale,
|
||||||
timezone: string;
|
* and the language the user picked for their Oos. The name comes from the account.
|
||||||
hobbies: string[];
|
*/
|
||||||
workStyle: string;
|
|
||||||
communication: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stored as data for the user's bots, so these stay plain English statements.
|
|
||||||
const HOBBY_FACTS: Record<string, string> = {
|
|
||||||
coding: "Interested in coding",
|
|
||||||
design: "Interested in design",
|
|
||||||
writing: "Interested in writing",
|
|
||||||
music: "Interested in music",
|
|
||||||
gaming: "Interested in gaming",
|
|
||||||
reading: "Interested in reading",
|
|
||||||
sports: "Interested in sports",
|
|
||||||
cooking: "Interested in cooking",
|
|
||||||
tech: "Interested in tech and gadgets",
|
|
||||||
};
|
|
||||||
|
|
||||||
const WORK_STYLE_FACTS: Record<string, string> = {
|
|
||||||
structured: "Works in a structured, planned way",
|
|
||||||
flexible: "Works flexibly and spontaneously",
|
|
||||||
"deep-dive": "Prefers focused deep dives",
|
|
||||||
quick: "Works quickly and pragmatically",
|
|
||||||
};
|
|
||||||
|
|
||||||
const COMMUNICATION_STYLES: Record<string, string> = {
|
|
||||||
direct: "Direct and concise",
|
|
||||||
detailed: "Detailed and explanatory",
|
|
||||||
balanced: "Balanced",
|
|
||||||
casual: "Casual and friendly",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */
|
|
||||||
export function onboardingProfileUpdate(input: {
|
export function onboardingProfileUpdate(input: {
|
||||||
answers: OnboardingAnswers;
|
|
||||||
/** The account name, which only changes when the user typed a different one. */
|
|
||||||
accountName?: string;
|
|
||||||
/** Used when the typed time zone is empty or unknown. */
|
|
||||||
browserTimeZone: string;
|
browserTimeZone: string;
|
||||||
/** Browser locale, e.g. "de-AT". */
|
/** Browser locale, e.g. "de-AT". */
|
||||||
locale?: string;
|
locale?: string;
|
||||||
/** Language the user picked for their Oos, when they picked one. */
|
/** Language the user picked for their Oos, when they picked one. */
|
||||||
language?: string | null;
|
language?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { answers } = input;
|
|
||||||
const name = answers.name.trim();
|
|
||||||
const typedZone = answers.timezone.trim();
|
|
||||||
const workStyle = WORK_STYLE_FACTS[answers.workStyle];
|
|
||||||
const facts: Array<{ category: UserFactCategory; value: string }> = [
|
|
||||||
...answers.hobbies.flatMap((hobby) => {
|
|
||||||
const value = HOBBY_FACTS[hobby];
|
|
||||||
return value ? [{ category: "preferences" as const, value }] : [];
|
|
||||||
}),
|
|
||||||
...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []),
|
|
||||||
];
|
|
||||||
const communicationStyle = COMMUNICATION_STYLES[answers.communication];
|
|
||||||
return {
|
return {
|
||||||
...(name && name !== input.accountName?.trim() ? { name } : {}),
|
...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}),
|
||||||
timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone,
|
|
||||||
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
|
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
|
||||||
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
|
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
|
||||||
...(communicationStyle ? { communicationStyle } : {}),
|
|
||||||
...(facts.length > 0
|
|
||||||
? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/web/src/lib/onboarding-push.ts
Normal file
14
apps/web/src/lib/onboarding-push.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Push for the onboarding notifications card. The server offers the card only when the browser
|
||||||
|
* that answered the card before it could still turn push on, and tapping "Turn on" is the user
|
||||||
|
* gesture that subscribes before the card is answered. Both wait for the web push client
|
||||||
|
* (`lib/web-push.ts`); until it lands the card is never offered.
|
||||||
|
*/
|
||||||
|
export async function canOfferPush(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribes this browser; true when push is on afterwards. */
|
||||||
|
export async function turnOnPush(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
|
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
|
||||||
import { Button, Input } from "@rakazo/ui-web";
|
import { Button } from "@rakazo/ui-web";
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
||||||
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
|
||||||
@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo
|
|||||||
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
|
||||||
import { rpc, selectSpace } from "../lib/rpc";
|
import { rpc, selectSpace } from "../lib/rpc";
|
||||||
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
|
||||||
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
|
|
||||||
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
|
||||||
|
|
||||||
/** Selects the main conversation's space and returns its route. */
|
/** Selects the main conversation's space and returns its route. */
|
||||||
@ -53,57 +52,11 @@ function defaultEmojiConfig(): OnboardingEmojiConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserPreferences {
|
|
||||||
name: string;
|
|
||||||
timezone: string;
|
|
||||||
hobbies: string[];
|
|
||||||
workStyle: string;
|
|
||||||
communication: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HOBBY_OPTIONS = [
|
|
||||||
{ key: "coding", label: "Coding" },
|
|
||||||
{ key: "design", label: "Design" },
|
|
||||||
{ key: "writing", label: "Writing" },
|
|
||||||
{ key: "music", label: "Music" },
|
|
||||||
{ key: "gaming", label: "Gaming" },
|
|
||||||
{ key: "reading", label: "Reading" },
|
|
||||||
{ key: "sports", label: "Sports" },
|
|
||||||
{ key: "cooking", label: "Cooking" },
|
|
||||||
{ key: "tech", label: "Tech & Gadgets" },
|
|
||||||
{ key: "none", label: <Trans>Nothing specific</Trans> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const WORK_STYLE_OPTIONS = [
|
|
||||||
{ key: "structured", label: <Trans>Structured & planned</Trans> },
|
|
||||||
{ key: "flexible", label: <Trans>Flexible & spontaneous</Trans> },
|
|
||||||
{ key: "deep-dive", label: <Trans>Deep-dive focused</Trans> },
|
|
||||||
{ key: "quick", label: <Trans>Quick & pragmatic</Trans> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const COMMUNICATION_OPTIONS = [
|
|
||||||
{ key: "direct", label: <Trans>Direct & concise</Trans> },
|
|
||||||
{ key: "detailed", label: <Trans>Detailed & explanatory</Trans> },
|
|
||||||
{ key: "balanced", label: <Trans>Balanced</Trans> },
|
|
||||||
{ key: "casual", label: <Trans>Casual & friendly</Trans> },
|
|
||||||
];
|
|
||||||
|
|
||||||
function defaultPreferences(): UserPreferences {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
timezone: localTimezone(),
|
|
||||||
hobbies: [],
|
|
||||||
workStyle: "",
|
|
||||||
communication: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OnboardingPage() {
|
export function OnboardingPage() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const fieldId = useId();
|
|
||||||
const [step, setStep] = useState<
|
const [step, setStep] = useState<
|
||||||
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot"
|
"loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot"
|
||||||
>("emoji");
|
>("emoji");
|
||||||
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
const [selectedVoiceId, setSelectedVoiceId] = useState("");
|
||||||
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
const [voiceSkipped, setVoiceSkipped] = useState(false);
|
||||||
@ -114,11 +67,9 @@ export function OnboardingPage() {
|
|||||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||||
const [crewReady, setCrewReady] = useState(false);
|
const [crewReady, setCrewReady] = useState(false);
|
||||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
|
||||||
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
|
||||||
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("rakazo.uiLocale") ?? "en";
|
return localStorage.getItem("rakazo.uiLocale") ?? "en";
|
||||||
@ -140,7 +91,6 @@ export function OnboardingPage() {
|
|||||||
.then(async ([me, integrations]) => {
|
.then(async ([me, integrations]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBootstrapMe(me);
|
setBootstrapMe(me);
|
||||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
|
||||||
// Get bot ID for scan phase
|
// Get bot ID for scan phase
|
||||||
try {
|
try {
|
||||||
const botList = await rpc.bots.list();
|
const botList = await rpc.bots.list();
|
||||||
@ -178,17 +128,10 @@ export function OnboardingPage() {
|
|||||||
};
|
};
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
function toggleHobby(key: string) {
|
|
||||||
const hobbies = prefs.hobbies.includes(key)
|
|
||||||
? prefs.hobbies.filter((h) => h !== key)
|
|
||||||
: [...prefs.hobbies, key];
|
|
||||||
setPrefs({ ...prefs, hobbies });
|
|
||||||
}
|
|
||||||
|
|
||||||
const discoveryPhase: OnboardingDiscoveryPhase =
|
const discoveryPhase: OnboardingDiscoveryPhase =
|
||||||
step === "loading"
|
step === "loading"
|
||||||
? "loading"
|
? "loading"
|
||||||
: step === "emoji" || step === "preferences" || step === "language" || step === "voice"
|
: step === "emoji" || step === "language" || step === "voice"
|
||||||
? "emoji"
|
? "emoji"
|
||||||
: step === "integrations"
|
: step === "integrations"
|
||||||
? "integrations"
|
? "integrations"
|
||||||
@ -229,8 +172,6 @@ export function OnboardingPage() {
|
|||||||
await rpc.profile
|
await rpc.profile
|
||||||
.update(
|
.update(
|
||||||
onboardingProfileUpdate({
|
onboardingProfileUpdate({
|
||||||
answers: prefs,
|
|
||||||
accountName: bootstrapMe?.name,
|
|
||||||
browserTimeZone: localTimezone(),
|
browserTimeZone: localTimezone(),
|
||||||
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
|
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
|
||||||
language: languageChoice,
|
language: languageChoice,
|
||||||
@ -252,22 +193,15 @@ export function OnboardingPage() {
|
|||||||
|
|
||||||
const me = await rpc.me().catch(() => null);
|
const me = await rpc.me().catch(() => null);
|
||||||
if (me) setBootstrapMe(me);
|
if (me) setBootstrapMe(me);
|
||||||
const harvested = await persistUserProfileHarvest(
|
await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch(
|
||||||
rpc,
|
() => null,
|
||||||
collectLocalProfileSignals(me ?? undefined),
|
);
|
||||||
).catch(() => null);
|
|
||||||
if (harvested) setHelpVibe(harvested);
|
|
||||||
setProfilePersisted(true);
|
setProfilePersisted(true);
|
||||||
for (const serverId of integrationServers) {
|
for (const serverId of integrationServers) {
|
||||||
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
|
||||||
}
|
}
|
||||||
const started = await rpc.onboarding
|
// The main bot asks the rest in the main conversation.
|
||||||
.start({ botId: bot.id })
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
if (started) {
|
|
||||||
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
|
||||||
}
|
|
||||||
const main = await rpc.spaces
|
const main = await rpc.spaces
|
||||||
.list()
|
.list()
|
||||||
.then((navigation) => navigation.main)
|
.then((navigation) => navigation.main)
|
||||||
@ -300,11 +234,7 @@ export function OnboardingPage() {
|
|||||||
<OnboardingDiscoveryShell
|
<OnboardingDiscoveryShell
|
||||||
items={discoveryItems}
|
items={discoveryItems}
|
||||||
compact={
|
compact={
|
||||||
step === "emoji" ||
|
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
|
||||||
step === "preferences" ||
|
|
||||||
step === "language" ||
|
|
||||||
step === "voice" ||
|
|
||||||
step === "integrations"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
|
||||||
@ -313,140 +243,10 @@ export function OnboardingPage() {
|
|||||||
<OnboardingOoDesigner
|
<OnboardingOoDesigner
|
||||||
config={emojiConfig}
|
config={emojiConfig}
|
||||||
onChange={setEmojiConfig}
|
onChange={setEmojiConfig}
|
||||||
onContinue={() => setStep("preferences")}
|
onContinue={() => setStep("language")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "preferences" ? (
|
|
||||||
<div className="mx-auto max-w-xl">
|
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
|
||||||
<Trans>Tell us about yourself</Trans>
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
<Trans>A few quick preferences so your Oo can be better tailored to you.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<label
|
|
||||||
htmlFor={`${fieldId}-name`}
|
|
||||||
className="block text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={`${fieldId}-name`}
|
|
||||||
value={prefs.name}
|
|
||||||
onChange={(e) => setPrefs({ ...prefs, name: e.target.value })}
|
|
||||||
placeholder={t`Your name`}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<label
|
|
||||||
htmlFor={`${fieldId}-timezone`}
|
|
||||||
className="block text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
<Trans>Timezone</Trans>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={`${fieldId}-timezone`}
|
|
||||||
value={prefs.timezone}
|
|
||||||
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
|
|
||||||
placeholder={t`Auto-detected or enter manually`}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset className="mt-6 border-0 p-0">
|
|
||||||
<legend className="block text-sm font-medium text-foreground">
|
|
||||||
<Trans>Hobbies & interests</Trans>
|
|
||||||
</legend>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
<Trans>Select all that apply</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{HOBBY_OPTIONS.map((hobby) => (
|
|
||||||
<button
|
|
||||||
key={hobby.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleHobby(hobby.key)}
|
|
||||||
aria-pressed={prefs.hobbies.includes(hobby.key)}
|
|
||||||
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
|
|
||||||
prefs.hobbies.includes(hobby.key)
|
|
||||||
? "border-primary bg-primary/10 text-foreground"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{hobby.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="mt-6 border-0 p-0">
|
|
||||||
<legend className="block text-sm font-medium text-foreground">
|
|
||||||
<Trans>Work style</Trans>
|
|
||||||
</legend>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
<Trans>How do you like to work?</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
{WORK_STYLE_OPTIONS.map((ws) => (
|
|
||||||
<button
|
|
||||||
key={ws.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPrefs({ ...prefs, workStyle: ws.key })}
|
|
||||||
aria-pressed={prefs.workStyle === ws.key}
|
|
||||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
|
||||||
prefs.workStyle === ws.key
|
|
||||||
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ws.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="mt-6 border-0 p-0">
|
|
||||||
<legend className="block text-sm font-medium text-foreground">
|
|
||||||
<Trans>Communication style</Trans>
|
|
||||||
</legend>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
<Trans>How should your Oo talk to you?</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
||||||
{COMMUNICATION_OPTIONS.map((comm) => (
|
|
||||||
<button
|
|
||||||
key={comm.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPrefs({ ...prefs, communication: comm.key })}
|
|
||||||
aria-pressed={prefs.communication === comm.key}
|
|
||||||
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
|
|
||||||
prefs.communication === comm.key
|
|
||||||
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
|
||||||
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{comm.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setStep("language")}>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{step === "language" ? (
|
{step === "language" ? (
|
||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-xl">
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
@ -477,7 +277,7 @@ export function OnboardingPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex items-center justify-between">
|
<div className="mt-8 flex items-center justify-between">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
|
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
|
||||||
<Trans>Back</Trans>
|
<Trans>Back</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setStep("voice")}>
|
<Button onClick={() => setStep("voice")}>
|
||||||
|
|||||||
@ -1,15 +1,34 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro";
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { MessageBlock } from "@rakazo/contracts";
|
import type { MessageBlock } from "@rakazo/contracts";
|
||||||
|
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
||||||
import { abortableDelay } from "@rakazo/core";
|
import { abortableDelay } from "@rakazo/core";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
import {
|
||||||
import { X } from "lucide-react";
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@rakazo/ui-web";
|
||||||
|
import { ArrowUp, Check, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
||||||
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
|
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
|
||||||
import { chartViewport } from "../../lib/chart-viewport";
|
import { chartViewport } from "../../lib/chart-viewport";
|
||||||
import { connectMcpOauth } from "../../lib/mcp-connect";
|
import { connectMcpOauth } from "../../lib/mcp-connect";
|
||||||
|
import { canOfferPush, turnOnPush } from "../../lib/onboarding-push";
|
||||||
import { rpc } from "../../lib/rpc";
|
import { rpc } from "../../lib/rpc";
|
||||||
|
|
||||||
|
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A question card. Lettered options are the answers; an option without a letter ("Skip",
|
||||||
|
* "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding
|
||||||
|
* cards may also take an answer in the user's own words.
|
||||||
|
*/
|
||||||
export function ChoiceCard({
|
export function ChoiceCard({
|
||||||
botId,
|
botId,
|
||||||
block,
|
block,
|
||||||
@ -22,14 +41,30 @@ export function ChoiceCard({
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
||||||
const dismissed = locallyDismissed || block.answerId === "_dismissed";
|
const dismissed = locallyDismissed || block.answerId === "_dismissed";
|
||||||
|
const answered = Boolean(block.answerId);
|
||||||
|
const answers = block.options.filter((option) => option.letter);
|
||||||
|
const waysOut = block.options.filter((option) => !option.letter);
|
||||||
|
const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`;
|
||||||
|
|
||||||
async function choose(optionId: string) {
|
async function choose(optionId: string, words?: string) {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await rpc.onboarding.choose({ botId, optionId });
|
// Tapping "Turn on" is the gesture the browser needs before it asks for permission.
|
||||||
|
const answer =
|
||||||
|
block.step === "notifications" && optionId === "on" && !(await turnOnPush())
|
||||||
|
? "later"
|
||||||
|
: optionId;
|
||||||
|
await rpc.onboarding.choose({
|
||||||
|
botId,
|
||||||
|
step: block.step,
|
||||||
|
optionId: answer,
|
||||||
|
...(words ? { text: words } : {}),
|
||||||
|
canEnablePush: await canOfferPush(),
|
||||||
|
});
|
||||||
await onBotChanged();
|
await onBotChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t`Could not save this choice`);
|
setError(err instanceof Error ? err.message : t`Could not save this choice`);
|
||||||
@ -52,10 +87,18 @@ export function ChoiceCard({
|
|||||||
|
|
||||||
if (dismissed) return null;
|
if (dismissed) return null;
|
||||||
|
|
||||||
|
const chosen = block.answerText
|
||||||
|
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText }
|
||||||
|
: block.options.find((option) => option.id === block.answerId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="apple-glass-card relative w-[440px] max-w-full rounded-[24px] px-5 py-4.5 transition-all">
|
<BuiCard
|
||||||
{!block.answerId ? (
|
data-testid="choice-card"
|
||||||
|
data-step={block.step}
|
||||||
|
className="relative w-[440px] max-w-full px-5 py-4"
|
||||||
|
>
|
||||||
|
{!answered && waysOut.length === 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@ -73,46 +116,108 @@ export function ChoiceCard({
|
|||||||
{block.subtitle ? (
|
{block.subtitle ? (
|
||||||
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
|
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mt-3.5 space-y-2.5">
|
{answered ? (
|
||||||
{block.options
|
chosen ? (
|
||||||
.filter((option) => !block.answerId || option.id === block.answerId)
|
<div
|
||||||
.map((option) => (
|
data-testid="choice-answer"
|
||||||
|
className="mt-3 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3 text-ui-body font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
|
||||||
|
<span className="flex-1 leading-snug" dir="auto">
|
||||||
|
{chosen.label}
|
||||||
|
</span>
|
||||||
|
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{answers.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{answers.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`focus-choice-${option.id}`}
|
data-testid={testId(option)}
|
||||||
aria-label={option.label}
|
aria-label={option.label}
|
||||||
disabled={Boolean(block.answerId) || pending}
|
disabled={pending}
|
||||||
onClick={() => void choose(option.id)}
|
onClick={() => void choose(option.id)}
|
||||||
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-white/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
|
className="tactile-press flex w-full items-start gap-3 rounded-xl border border-border bg-background p-3 text-start text-ui-body leading-snug text-foreground transition-colors hover:bg-muted disabled:opacity-60"
|
||||||
block.answerId === option.id
|
|
||||||
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
|
|
||||||
: "bg-white/[0.04] hover:bg-white/[0.08] hover:border-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span
|
<ChoiceLetter letter={option.letter} />
|
||||||
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
|
<span className="flex-1">{option.label}</span>
|
||||||
block.answerId === option.id
|
|
||||||
? "bg-blue-500 text-white"
|
|
||||||
: "bg-white/10 text-foreground/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.letter}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
{block.answerId === option.id ? (
|
|
||||||
<span className="mt-0.5 text-blue-400 font-bold">✓</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{block.textInput ? (
|
||||||
|
<form
|
||||||
|
className="mt-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const words = text.trim();
|
||||||
|
if (words) void choose(CHOICE_TEXT_ANSWER_ID, words);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputGroup className="h-control-lg rounded-xl bg-background">
|
||||||
|
<InputGroupInput
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => setText(event.target.value)}
|
||||||
|
placeholder={block.textInput.placeholder}
|
||||||
|
aria-label={block.question}
|
||||||
|
maxLength={200}
|
||||||
|
disabled={pending}
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupButton
|
||||||
|
type="submit"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="default"
|
||||||
|
aria-label={t`Send`}
|
||||||
|
disabled={pending || !text.trim()}
|
||||||
|
className="rounded-lg"
|
||||||
|
>
|
||||||
|
<ArrowUp aria-hidden />
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{waysOut.length > 0 ? (
|
||||||
|
<div className="mt-2 flex justify-end gap-1">
|
||||||
|
{waysOut.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.id}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-testid={testId(option)}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => void choose(option.id)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
||||||
|
</BuiCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`mt-0.5 grid size-6 shrink-0 place-items-center rounded-lg text-ui-body-sm font-semibold ${
|
||||||
|
selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const user: UserContext = {
|
|||||||
{ category: "work", value: "Owns a bakery", confidence: 1 },
|
{ category: "work", value: "Owns a bakery", confidence: 1 },
|
||||||
{ category: "preferences", value: "Prefers morning meetings", confidence: 0.8 },
|
{ category: "preferences", value: "Prefers morning meetings", confidence: 0.8 },
|
||||||
],
|
],
|
||||||
|
onboardingOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("formatBotIdentity", () => {
|
describe("formatBotIdentity", () => {
|
||||||
|
|||||||
@ -46,6 +46,13 @@ export function formatMainAgentRole(toolNames: ReadonlySet<string>): string {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While the main bot's onboarding cards are still open, it leaves their questions to them, so
|
||||||
|
* a reply does not ask what the next card asks.
|
||||||
|
*/
|
||||||
|
export const ONBOARDING_OPEN_INSTRUCTION =
|
||||||
|
"The user is still answering onboarding cards in the main conversation: what to focus on, a goal, when to check in, quiet hours and notifications. Do not ask these questions yourself; answer what the user writes.";
|
||||||
|
|
||||||
/** One line of escaped user text, clipped to `max` characters. */
|
/** One line of escaped user text, clipped to `max` characters. */
|
||||||
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
|
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
|
||||||
const text = (value ?? "").replace(/\s+/g, " ").trim();
|
const text = (value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
|||||||
@ -248,6 +248,7 @@ import {
|
|||||||
formatMainAgentRole,
|
formatMainAgentRole,
|
||||||
formatSubagentContext,
|
formatSubagentContext,
|
||||||
formatUserContext,
|
formatUserContext,
|
||||||
|
ONBOARDING_OPEN_INSTRUCTION,
|
||||||
} from "./executor-prompt.js";
|
} from "./executor-prompt.js";
|
||||||
import {
|
import {
|
||||||
isProtectedComputerLifecycleCommandReadingScripts,
|
isProtectedComputerLifecycleCommandReadingScripts,
|
||||||
@ -3723,6 +3724,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined,
|
bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined,
|
||||||
timeInstruction,
|
timeInstruction,
|
||||||
userBlock,
|
userBlock,
|
||||||
|
bot.kind === "main" &&
|
||||||
|
userContext.onboardingOpen &&
|
||||||
|
!(messagingChannelRun || foreignAgentRun)
|
||||||
|
? ONBOARDING_OPEN_INSTRUCTION
|
||||||
|
: undefined,
|
||||||
groupContext?.instructions,
|
groupContext?.instructions,
|
||||||
inboundHandoffInstruction,
|
inboundHandoffInstruction,
|
||||||
delegatedTaskInstruction,
|
delegatedTaskInstruction,
|
||||||
|
|||||||
@ -100,6 +100,20 @@ export const DELEGATED_TASK_STATUSES = [
|
|||||||
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
|
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
|
||||||
export type DelegatedTaskStatus = z.infer<typeof DelegatedTaskStatusSchema>;
|
export type DelegatedTaskStatus = z.infer<typeof DelegatedTaskStatusSchema>;
|
||||||
|
|
||||||
|
/** Questions the main bot asks a new user, one card at a time, in the main conversation. */
|
||||||
|
export const ONBOARDING_STEPS = [
|
||||||
|
"focus",
|
||||||
|
"goal",
|
||||||
|
"rhythm",
|
||||||
|
"quiet_hours",
|
||||||
|
"notifications",
|
||||||
|
] as const;
|
||||||
|
export const OnboardingStepSchema = z.enum(ONBOARDING_STEPS);
|
||||||
|
export type OnboardingStep = z.infer<typeof OnboardingStepSchema>;
|
||||||
|
|
||||||
|
/** `optionId` of a choice answered in the user's own words. */
|
||||||
|
export const CHOICE_TEXT_ANSWER_ID = "_text";
|
||||||
|
|
||||||
/** A question or approval that pauses its run until the user answers. */
|
/** A question or approval that pauses its run until the user answers. */
|
||||||
export const AskBlockSchema = z.object({
|
export const AskBlockSchema = z.object({
|
||||||
kind: z.literal("ask"),
|
kind: z.literal("ask"),
|
||||||
@ -134,9 +148,16 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
kind: z.literal("choice"),
|
kind: z.literal("choice"),
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
subtitle: z.string().optional(),
|
subtitle: z.string().optional(),
|
||||||
|
/** An option without a letter is a secondary answer such as "Skip". */
|
||||||
options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })),
|
options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })),
|
||||||
|
/** The onboarding step this card asks; absent on a specialist's focus card. */
|
||||||
|
step: OnboardingStepSchema.optional(),
|
||||||
|
/** Lets the user answer in their own words. */
|
||||||
|
textInput: z.object({ placeholder: z.string() }).optional(),
|
||||||
/** Set once the user picks an option; renders the picker as answered. */
|
/** Set once the user picks an option; renders the picker as answered. */
|
||||||
answerId: z.string().optional(),
|
answerId: z.string().optional(),
|
||||||
|
/** The user's words when they answered through the text input. */
|
||||||
|
answerText: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
||||||
|
|||||||
@ -75,7 +75,7 @@ import {
|
|||||||
VoiceInfoSchema,
|
VoiceInfoSchema,
|
||||||
VoiceStatusSchema,
|
VoiceStatusSchema,
|
||||||
} from "./domain.js";
|
} from "./domain.js";
|
||||||
import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js";
|
import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js";
|
||||||
import { Id, IsoDate } from "./ids.js";
|
import { Id, IsoDate } from "./ids.js";
|
||||||
import { InboxListOutputSchema } from "./inbox.js";
|
import { InboxListOutputSchema } from "./inbox.js";
|
||||||
import {
|
import {
|
||||||
@ -660,15 +660,31 @@ export const appContract = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onboarding: {
|
onboarding: {
|
||||||
/** Seed the first-run greeting into the bot's thread (focus card is separate). */
|
/** Checks that the caller may onboard this bot's thread. Posts nothing. */
|
||||||
start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||||
/** Post the focus choice card when the thread is still idle. */
|
/**
|
||||||
|
* Posts the first card: for the main bot the first onboarding question, once per user;
|
||||||
|
* for another bot its focus card while the thread is still idle.
|
||||||
|
*/
|
||||||
promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||||
/** Answer the focus choice; posts the app cards. Does not rename the bot. */
|
/**
|
||||||
|
* Answers the pending card and posts what follows it: the focus card's app suggestions,
|
||||||
|
* then the next onboarding question. A stale answer (another step is pending) is ignored.
|
||||||
|
*/
|
||||||
choose: oc
|
choose: oc
|
||||||
.input(z.object({ botId: Id, optionId: z.string() }))
|
.input(
|
||||||
|
z.object({
|
||||||
|
botId: Id,
|
||||||
|
step: OnboardingStepSchema.optional(),
|
||||||
|
optionId: z.string(),
|
||||||
|
/** The answer in the user's words, with `optionId` "_text". */
|
||||||
|
text: z.string().trim().min(1).max(200).optional(),
|
||||||
|
/** This device could still turn on push notifications. */
|
||||||
|
canEnablePush: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.output(z.object({ ok: z.literal(true) })),
|
.output(z.object({ ok: z.literal(true) })),
|
||||||
/** Dismiss the unanswered focus card without choosing an option. */
|
/** Dismisses the pending card; an onboarding question counts as skipped. */
|
||||||
dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })),
|
||||||
/** Flip an app_connect card to connected after authorization completes. */
|
/** Flip an app_connect card to connected after authorization completes. */
|
||||||
appConnected: oc
|
appConnected: oc
|
||||||
|
|||||||
@ -109,3 +109,27 @@ describe("peer message history", () => {
|
|||||||
).toBe("[to Analyst] chart it");
|
).toBe("[to Analyst] chart it");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("choice card history", () => {
|
||||||
|
const card = {
|
||||||
|
kind: "choice" as const,
|
||||||
|
question: "When should I check in with you?",
|
||||||
|
options: [
|
||||||
|
{ id: "morning", letter: "A", label: "Every morning" },
|
||||||
|
{ id: "none", letter: "", label: "Not now" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("keeps the question and the answer the user picked or typed", () => {
|
||||||
|
expect(blocksToAgentHistoryText([card])).toBe("When should I check in with you?");
|
||||||
|
expect(blocksToAgentHistoryText([{ ...card, answerId: "morning" }])).toBe(
|
||||||
|
"When should I check in with you?\n[answered: Every morning]",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
blocksToAgentHistoryText([{ ...card, answerId: "_text", answerText: "Before lunch" }]),
|
||||||
|
).toBe("When should I check in with you?\n[answered: Before lunch]");
|
||||||
|
expect(blocksToAgentHistoryText([{ ...card, answerId: "_dismissed" }])).toBe(
|
||||||
|
"When should I check in with you?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -105,6 +105,12 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
|
|||||||
if (block.kind === "handoff") {
|
if (block.kind === "handoff") {
|
||||||
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
|
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
|
||||||
}
|
}
|
||||||
|
// A card's question and answer, so a later turn knows what the user already chose.
|
||||||
|
if (block.kind === "choice") {
|
||||||
|
const answer =
|
||||||
|
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
|
||||||
|
return answer ? `${block.question}\n[answered: ${answer}]` : block.question;
|
||||||
|
}
|
||||||
if ("text" in block && typeof block.text === "string") return block.text;
|
if ("text" in block && typeof block.text === "string") return block.text;
|
||||||
return "";
|
return "";
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_onboarding" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"step" TEXT,
|
||||||
|
"focus" TEXT,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_onboarding_pkey" PRIMARY KEY ("userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- The table is new and empty, so its constraint applies immediately.
|
||||||
|
ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_step_chk" CHECK ("step" IS NULL OR "step" IN ('focus', 'goal', 'rhythm', 'quiet_hours', 'notifications'));
|
||||||
@ -46,6 +46,7 @@ model User {
|
|||||||
botSecrets BotSecret[]
|
botSecrets BotSecret[]
|
||||||
agentSecrets AgentSecret[]
|
agentSecrets AgentSecret[]
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
|
onboarding UserOnboarding?
|
||||||
facts UserFact[]
|
facts UserFact[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@ -1429,6 +1430,21 @@ model UserProfile {
|
|||||||
@@map("user_profiles")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the user is in the onboarding questions the main bot asks in the main conversation.
|
||||||
|
model UserOnboarding {
|
||||||
|
userId String @id
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
/// The step whose card is waiting for an answer; null once onboarding is complete.
|
||||||
|
step String?
|
||||||
|
focus String?
|
||||||
|
completedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// CHECK constraint user_onboarding_step_chk is created in SQL.
|
||||||
|
@@map("user_onboarding")
|
||||||
|
}
|
||||||
|
|
||||||
/// One durable statement about the user, with its origin and consent state.
|
/// One durable statement about the user, with its origin and consent state.
|
||||||
model UserFact {
|
model UserFact {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export interface UserContext {
|
|||||||
communicationStyle: string;
|
communicationStyle: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
facts: Array<{ category: UserFactCategory; value: string; confidence: number }>;
|
facts: Array<{ category: UserFactCategory; value: string; confidence: number }>;
|
||||||
|
/** The main bot is still asking its onboarding questions on cards in the main conversation. */
|
||||||
|
onboardingOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown): boolean {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
@ -258,7 +260,10 @@ export async function loadUserContext(
|
|||||||
input: { userId: string; spaceId: string },
|
input: { userId: string; spaceId: string },
|
||||||
): Promise<UserContext> {
|
): Promise<UserContext> {
|
||||||
const [user, profile, facts] = await Promise.all([
|
const [user, profile, facts] = await Promise.all([
|
||||||
prisma.user.findUnique({ where: { id: input.userId }, select: { name: true } }),
|
prisma.user.findUnique({
|
||||||
|
where: { id: input.userId },
|
||||||
|
select: { name: true, onboarding: { select: { completedAt: true } } },
|
||||||
|
}),
|
||||||
prisma.userProfile.findUnique({ where: { userId: input.userId } }),
|
prisma.userProfile.findUnique({ where: { userId: input.userId } }),
|
||||||
prisma.userFact.findMany({
|
prisma.userFact.findMany({
|
||||||
where: {
|
where: {
|
||||||
@ -283,5 +288,6 @@ export async function loadUserContext(
|
|||||||
value: fact.value,
|
value: fact.value,
|
||||||
confidence: fact.confidence,
|
confidence: fact.confidence,
|
||||||
})),
|
})),
|
||||||
|
onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user