Let bots ask multiple-choice questions before acting
ask_user puts up to six questions on one card (radios for one pick, checkboxes for several, an optional typed Other) as a [question:ID] chip. The user's answers are checked against the questions, stored in bot_questions and sent to the bot as their chat message; Skip says so. Offered in chat replies and user-started tasks, never in routines, hook calls or chatroom sessions. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BotQuestionAnswer, BotQuestionView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
question_id: number;
|
||||
/**
|
||||
* One per question; none skips them.
|
||||
*/
|
||||
answers?: BotQuestionAnswer[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers questions a bot asked from their card, or skips them, returning
|
||||
* the questions as they now stand.
|
||||
*/
|
||||
export default async function decideBotQuestion({ question_id, answers }: Params) {
|
||||
return await fetchApi<{ question_id: number; answers?: BotQuestionAnswer[] }, APIResponseObject<BotQuestionView>>(
|
||||
`/api/admin/bot-questions/${answers ? "answer" : "skip"}-question`,
|
||||
{ method: "POST", body: answers ? { question_id, answers } : { question_id } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { BotQuestionView } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
question_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Questions already read, by id, so a thread naming them twice asks once.
|
||||
* An answer replaces their entry.
|
||||
*/
|
||||
const loaded = new Map<number, Promise<BotQuestionView | null>>();
|
||||
|
||||
function loadQuestion({ question_id }: Params) {
|
||||
const cached = loaded.get(question_id);
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = fetchApi<{}, APIResponseObject<BotQuestionView>>(
|
||||
`/api/admin/bot-questions/get-question?question_id=${question_id}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
.then((res) => (res?.success && res.singleRes ? res.singleRes : null))
|
||||
.catch(() => null);
|
||||
|
||||
loaded.set(question_id, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Questions a bot asked (`[question:ID]`), for their chip and card: read
|
||||
* when the chip renders, so it shows whether they're still waiting. Only
|
||||
* the user they were asked of can read them; anyone else gets null.
|
||||
*/
|
||||
export default function useBotQuestion({ question_id }: Params) {
|
||||
const [view, setViewState] = useState<BotQuestionView | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
|
||||
loadQuestion({ question_id }).then((result) => {
|
||||
if (!live) return;
|
||||
setViewState(result);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [question_id]);
|
||||
|
||||
const setView = useCallback(
|
||||
(next: BotQuestionView) => {
|
||||
loaded.set(question_id, Promise.resolve(next));
|
||||
setViewState(next);
|
||||
},
|
||||
[question_id],
|
||||
);
|
||||
|
||||
return { view, loading, setView };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { BotQuestionView } from "@/src/types";
|
||||
import BotQuestionCard from "./bot-question-card";
|
||||
|
||||
const deploy: BotQuestionView = {
|
||||
id: 4,
|
||||
title: "Before I deploy",
|
||||
questions: [
|
||||
{ question: "Which environment?", kind: "single", options: ["Staging (suggested)", "Production"], allow_other: true },
|
||||
{ question: "Which checks should run first?", kind: "multiple", options: ["Lint", "Tests"], allow_other: false },
|
||||
],
|
||||
answers: null,
|
||||
status: "pending",
|
||||
bot_id: 6,
|
||||
bot_name: "Ada",
|
||||
};
|
||||
|
||||
function render({ view }: { view: BotQuestionView }) {
|
||||
return renderToString(<BotQuestionCard view={view} onDecided={() => {}} onDone={() => {}} />);
|
||||
}
|
||||
|
||||
describe("BotQuestionCard", () => {
|
||||
test("asks each question with radios for one pick and checkboxes for several", () => {
|
||||
const html = render({ view: deploy });
|
||||
|
||||
expect(html).toContain("Which environment?");
|
||||
expect(html).toContain(`type="radio"`);
|
||||
expect(html).toContain("Staging (suggested)");
|
||||
expect(html).toContain(">Other<");
|
||||
expect(html).toContain("twui-checkbox");
|
||||
expect(html).toContain("Pick any");
|
||||
expect(html).toContain(">Send answers<");
|
||||
});
|
||||
|
||||
test("once answered, shows what was picked and offers no inputs", () => {
|
||||
const html = render({
|
||||
view: {
|
||||
...deploy,
|
||||
status: "answered",
|
||||
answers: [
|
||||
{ choices: [], other: "QA box" },
|
||||
{ choices: [0, 1], other: "" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(html).not.toContain(`type="radio"`);
|
||||
expect(html).not.toContain(">Send answers<");
|
||||
expect(html).toContain("Other: QA box");
|
||||
expect(html).toContain("Lint; Tests");
|
||||
});
|
||||
|
||||
test("once skipped, says so", () => {
|
||||
expect(render({ view: { ...deploy, status: "skipped" } })).toContain("Nothing was answered");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { BotQuestionView } from "@/src/types";
|
||||
import botQuestionAnswersText from "@/src/utils/bot-question-answers-text";
|
||||
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
|
||||
import decideBotQuestion from "../(functions)/decide-bot-question";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import botQuestionDraftAnswer, { type BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
|
||||
import BotQuestionField from "./bot-question-field";
|
||||
|
||||
type Props = {
|
||||
view: BotQuestionView;
|
||||
onDecided: (view: BotQuestionView) => void;
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The card a user answers a bot's questions in: each question with its
|
||||
* options (radios for one pick, checkboxes for several, "Other" to type
|
||||
* their own). Sending or skipping tells the bot in chat, as the user's
|
||||
* message; once decided it shows what they answered.
|
||||
*/
|
||||
export default function BotQuestionCard({ view, onDecided, onDone }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [drafts, setDrafts] = useState<BotQuestionDraft[]>(() =>
|
||||
view.questions.map(() => ({ choices: [], other_on: false, other: "" })),
|
||||
);
|
||||
const [busy, setBusy] = useState<"answer" | "skip">();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const open = view.status == "pending";
|
||||
|
||||
const setDraft = ({ index, draft }: { index: number; draft: BotQuestionDraft }) => {
|
||||
setError(undefined);
|
||||
setDrafts((current) => current.map((item, i) => (i == index ? draft : item)));
|
||||
};
|
||||
|
||||
const decide = async (decision: "answer" | "skip") => {
|
||||
const answers = drafts.map((draft) => botQuestionDraftAnswer({ draft }));
|
||||
|
||||
if (decision == "answer") {
|
||||
try {
|
||||
checkBotQuestionAnswers({ questions: view.questions, answers });
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : `Answer every question.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const question =
|
||||
decision == "answer"
|
||||
? `Send your answers to ${view.bot_name}?`
|
||||
: `Skip these questions? ${view.bot_name} is told you didn't answer them.`;
|
||||
|
||||
if (!window.confirm(question)) return;
|
||||
|
||||
setBusy(decision);
|
||||
setError(undefined);
|
||||
|
||||
const res = await decideBotQuestion({
|
||||
question_id: view.id,
|
||||
answers: decision == "answer" ? answers : undefined,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setBusy(undefined);
|
||||
setError(res?.msg || `Couldn't ${decision == "answer" ? "send your answers" : "skip them"}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
onDecided(res.singleRes);
|
||||
|
||||
await sendBotMessage({
|
||||
bot_id: Number(pageProps?.bot?.id) || 0,
|
||||
workspace_id: pageProps?.workspace?.id || null,
|
||||
text: botQuestionAnswersText({ view: res.singleRes }),
|
||||
media_ids: [],
|
||||
}).catch(() => null);
|
||||
|
||||
setBusy(undefined);
|
||||
onDone();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{view.questions.map((item, index) => (
|
||||
<BotQuestionField
|
||||
key={index}
|
||||
question_id={view.id}
|
||||
index={index}
|
||||
item={item}
|
||||
draft={drafts[index]!}
|
||||
answer={view.answers?.[index] || null}
|
||||
open={open}
|
||||
onChange={(draft) => setDraft({ index, draft })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{view.status == "skipped" ? (
|
||||
<Span className="mt-4 text-[13px] leading-5 text-ink">Skipped. Nothing was answered.</Span>
|
||||
) : null}
|
||||
|
||||
{error ? <Span className="mt-3 text-[13px] leading-5 text-ink">{error}</Span> : null}
|
||||
|
||||
{open ? (
|
||||
<Row className="mt-5 w-full gap-2! justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
title="Skip: the bot is told you didn't answer"
|
||||
loading={busy == "skip"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => decide("skip")}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
title="Send your answers"
|
||||
loading={busy == "answer"}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => decide("answer")}
|
||||
>
|
||||
Send answers
|
||||
</Button>
|
||||
</Row>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Radios from "@moduletrace/twui/form/Radios";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotQuestionAnswer, BotQuestionItem } from "@/src/types";
|
||||
import type { BotQuestionDraft } from "../(utils)/bot-question-draft-answer";
|
||||
import BotQuestionOption from "./bot-question-option";
|
||||
import MessageDetailsHeading from "./message-details-heading";
|
||||
|
||||
type Props = {
|
||||
question_id: number;
|
||||
index: number;
|
||||
item: BotQuestionItem;
|
||||
draft: BotQuestionDraft;
|
||||
/**
|
||||
* What the user answered, once they did.
|
||||
*/
|
||||
answer: BotQuestionAnswer | null;
|
||||
/**
|
||||
* Whether it still takes answers.
|
||||
*/
|
||||
open: boolean;
|
||||
onChange: (draft: BotQuestionDraft) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Value of the "Other" radio, beside the options' indexes.
|
||||
*/
|
||||
const OtherValue = "other";
|
||||
|
||||
/**
|
||||
* One of a bot's questions in its card: the question, then its options as
|
||||
* radios (one pick) or checkboxes (several), with "Other" and a field to
|
||||
* type into when the bot allows it. Once answered, what the user picked.
|
||||
*/
|
||||
export default function BotQuestionField({ question_id, index, item, draft, answer, open, onChange }: Props) {
|
||||
const single = item.kind == "single";
|
||||
const picked = answer
|
||||
? [...answer.choices.map((choice) => item.options[choice]), answer.other ? `Other: ${answer.other}` : ""].filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
<MessageDetailsHeading>{`${index + 1}. ${single ? "Pick one" : "Pick any"}`}</MessageDetailsHeading>
|
||||
<Span className="whitespace-pre-wrap text-[13px] leading-5 font-bold text-ink break-words">{item.question}</Span>
|
||||
|
||||
{open ? (
|
||||
<Stack className="mt-2 w-full gap-2! items-stretch">
|
||||
{single ? (
|
||||
<Radios
|
||||
name={`question-${question_id}-${index}`}
|
||||
values={[
|
||||
...item.options.map((option, choice) => ({ value: String(choice), title: option })),
|
||||
...(item.allow_other ? [{ value: OtherValue, title: "Other" }] : []),
|
||||
]}
|
||||
wrapperProps={{ className: "w-full flex-col items-start gap-2!" }}
|
||||
labelProps={{ className: "text-[13px] leading-5 text-ink break-words cursor-pointer" }}
|
||||
changeHandler={(value) =>
|
||||
onChange(
|
||||
value == OtherValue
|
||||
? { ...draft, choices: [], other_on: true }
|
||||
: { ...draft, choices: [Number(value)], other_on: false },
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack className="w-full gap-2! items-stretch">
|
||||
{item.options.map((option, choice) => (
|
||||
<BotQuestionOption
|
||||
key={choice}
|
||||
label={option}
|
||||
checked={draft.choices.includes(choice)}
|
||||
onChange={(checked) =>
|
||||
onChange({
|
||||
...draft,
|
||||
choices: checked
|
||||
? [...new Set([...draft.choices, choice])]
|
||||
: draft.choices.filter((c) => c != choice),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{item.allow_other ? (
|
||||
<BotQuestionOption
|
||||
label="Other"
|
||||
checked={draft.other_on}
|
||||
onChange={(checked) => onChange({ ...draft, other_on: checked })}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{draft.other_on ? (
|
||||
<Input
|
||||
id={`question-${question_id}-${index}-other`}
|
||||
title="Your answer"
|
||||
placeholder="Your own answer"
|
||||
maxLength={AppData.BotQuestionOtherMaxChars}
|
||||
value={draft.other}
|
||||
onChange={(e) => onChange({ ...draft, other: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : answer ? (
|
||||
<Span className="mt-1 whitespace-pre-wrap text-[13px] leading-5 text-ink-secondary break-words">
|
||||
{picked.join("; ")}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One option of a multiple-choice question in a bot's question card.
|
||||
*/
|
||||
export default function BotQuestionOption({ label, checked, onChange }: Props) {
|
||||
return (
|
||||
<Checkbox
|
||||
title={label}
|
||||
label={label}
|
||||
size={18}
|
||||
checked={checked}
|
||||
changeHandler={(value) => {
|
||||
if (value != checked) onChange(value);
|
||||
}}
|
||||
wrapperClassName="flex-nowrap! md:flex-nowrap items-start! cursor-pointer"
|
||||
labelProps={{ className: "whitespace-normal md:whitespace-normal text-[13px] leading-5 text-ink break-words" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { MessageCircleQuestion } from "lucide-react";
|
||||
import useBotQuestion from "../(hooks)/use-bot-question";
|
||||
import BotQuestionCard from "./bot-question-card";
|
||||
|
||||
type Props = {
|
||||
question_id: number;
|
||||
};
|
||||
|
||||
const StatusLabels = {
|
||||
pending: "waiting for you",
|
||||
answered: "answered",
|
||||
skipped: "skipped",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Questions a bot asked before acting (`botQuestionRef`): a chip naming
|
||||
* them and where they stand, which opens the card the user answers them
|
||||
* in. Anyone they weren't asked of sees their number only.
|
||||
*/
|
||||
export default function BotQuestionRefChip({ question_id }: Props) {
|
||||
const { view, loading, setView } = useBotQuestion({ question_id });
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
<Span className="font-bold">
|
||||
{loading ? `questions ${question_id}` : `questions ${question_id} (not available)`}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
const count = view.questions.length;
|
||||
const name = view.title || (count == 1 ? "1 question" : `${count} questions`);
|
||||
const status = StatusLabels[view.status];
|
||||
const pending = view.status == "pending";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
type="button"
|
||||
variant={pending ? undefined : "outlined"}
|
||||
color={pending ? undefined : "gray"}
|
||||
size="smaller"
|
||||
title={pending ? `Answer ${name}` : `${name}: ${status}`}
|
||||
className={twMerge(
|
||||
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap",
|
||||
!pending && "text-ink",
|
||||
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||
)}
|
||||
>
|
||||
<MessageCircleQuestion size={14} aria-hidden className="shrink-0" />
|
||||
<Span className="min-w-0 truncate">{`${name} · ${status}`}</Span>
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{view.title || `${view.bot_name} asks before going on`}
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{pending
|
||||
? `${view.bot_name} wants to be sure what you want before it acts. Your answers go to it as your message.`
|
||||
: `What ${view.bot_name} asked before going on.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-2 max-h-[65vh] w-full gap-0! items-stretch overflow-y-auto">
|
||||
<BotQuestionCard view={view} onDecided={setView} onDone={() => setOpen(false)} />
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
WorkspaceRefHrefPrefix,
|
||||
} from "@/src/utils/link-computer-refs";
|
||||
import { parseFolderRefHref } from "@/src/utils/folder-ref";
|
||||
import { botQuestionRefId } from "@/src/utils/bot-question-ref";
|
||||
import { graphicRefId } from "@/src/utils/graphic-ref";
|
||||
import { chatroomRefId } from "@/src/utils/chatroom-ref";
|
||||
import BotQuestionRefChip from "./bot-question-ref-chip";
|
||||
import BotRefChip from "./bot-ref-chip";
|
||||
import ChatroomRefChip from "./chatroom-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
@@ -36,7 +38,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
* folder it named a chip that opens it in the files browser, an MCP server,
|
||||
* tool, skill or hook it proposed a chip that opens the confirm card, a
|
||||
* secret it showed a masked chip, a secret it asked for a chip that opens
|
||||
* the card it's typed into, a graphic it made the graphic itself, a
|
||||
* the card it's typed into, questions it asked a chip that opens the card
|
||||
* they're answered in, a graphic it made the graphic itself, a
|
||||
* chatroom a chip that opens it;
|
||||
* anything else opens in a new tab.
|
||||
*/
|
||||
@@ -104,6 +107,10 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
|
||||
}
|
||||
|
||||
const question_id = botQuestionRefId({ href });
|
||||
|
||||
if (question_id) return <BotQuestionRefChip question_id={question_id} />;
|
||||
|
||||
const chatroom_id = chatroomRefId({ href });
|
||||
|
||||
if (chatroom_id) return <ChatroomRefChip chatroom_id={chatroom_id} />;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { BotQuestionAnswer } from "@/src/types";
|
||||
|
||||
/**
|
||||
* One question's answer while the user fills in the card: the options
|
||||
* picked, whether "Other" is picked, and what's typed into it.
|
||||
*/
|
||||
export type BotQuestionDraft = {
|
||||
choices: number[];
|
||||
other_on: boolean;
|
||||
other: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
draft: BotQuestionDraft;
|
||||
};
|
||||
|
||||
/**
|
||||
* A question's draft as the answer sent: "Other" counts only while picked.
|
||||
*/
|
||||
export default function botQuestionDraftAnswer({ draft }: Params): BotQuestionAnswer {
|
||||
return { choices: draft.choices, other: draft.other_on ? draft.other.trim() : "" };
|
||||
}
|
||||
Reference in New Issue
Block a user