v1.9.7: ask_user_input elicitation tool
This commit is contained in:
324
apps/web/src/components/AskUserInputCard.tsx
Normal file
324
apps/web/src/components/AskUserInputCard.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
AskUserAnswer,
|
||||
AskUserAnswerSet,
|
||||
AskUserQuestion,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
} from '@/api/types';
|
||||
|
||||
// Batch 9.7. Inline interactive picker. Renders inside MessageList in place of
|
||||
// the standard ToolCallLine when the assistant emits an ask_user_input tool
|
||||
// call. While the tool result is null (server pre-stamps a sentinel with
|
||||
// output=null), shows the form; once the WS tool_result frame arrives with a
|
||||
// real AnswerSet, flips to read-only review mode.
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCall;
|
||||
toolResult: ToolResult | null;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
||||
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
|
||||
const arr = (raw as { questions: unknown }).questions;
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const out: AskUserQuestion[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const q = item as { question?: unknown; type?: unknown; options?: unknown };
|
||||
if (typeof q.question !== 'string') continue;
|
||||
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
|
||||
if (!Array.isArray(q.options)) continue;
|
||||
const opts = q.options.filter((o): o is string => typeof o === 'string');
|
||||
if (opts.length < 2) continue;
|
||||
out.push({ question: q.question, type: q.type, options: opts });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
|
||||
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
|
||||
const arr = (raw as { answers: unknown }).answers;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
const answers: AskUserAnswer[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
|
||||
if (typeof a.question !== 'string') continue;
|
||||
if (!Array.isArray(a.selected_options)) continue;
|
||||
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
|
||||
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
|
||||
answers.push({
|
||||
question: a.question,
|
||||
selected_options: sel,
|
||||
free_text: (a.free_text as string | null) ?? null,
|
||||
});
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||
ask_user_input: malformed tool args
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool result with a non-null output means the answer is already submitted.
|
||||
// The pending sentinel uses output=null, so this branch only triggers after
|
||||
// the real WS tool_result frame lands.
|
||||
const answered = toolResult && toolResult.output !== null;
|
||||
if (answered) {
|
||||
const answerSet = parseAnswerSet(toolResult!.output);
|
||||
return <AnsweredView questions={questions} answers={answerSet} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
||||
);
|
||||
}
|
||||
|
||||
function PendingView({
|
||||
questions,
|
||||
toolCallId,
|
||||
chatId,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
toolCallId: string;
|
||||
chatId: string;
|
||||
}) {
|
||||
// Per-question selections + free text. Selections are option arrays so the
|
||||
// multi_select case is uniform; single_select just constrains to length 1.
|
||||
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
|
||||
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const singleQuestion = questions.length === 1;
|
||||
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
|
||||
|
||||
// Submit button shows when:
|
||||
// - more than one question (always batched), OR
|
||||
// - one question and the user has typed free text (committing it needs an
|
||||
// explicit Submit so an accidental Tab/click doesn't lose it).
|
||||
// For one question with no free text, clicking an option submits inline.
|
||||
const showSubmitButton = !singleQuestion || anyFreeText;
|
||||
|
||||
// Every question must have at least one of (option, free text).
|
||||
const allComplete = questions.every((_, i) => {
|
||||
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
|
||||
});
|
||||
|
||||
function buildAnswers(): AskUserAnswer[] {
|
||||
return questions.map((q, i) => {
|
||||
const freeText = freeTexts[i]!.trim();
|
||||
return {
|
||||
question: q.question,
|
||||
selected_options: selections[i]!,
|
||||
free_text: freeText.length > 0 ? freeText : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(answers: AskUserAnswer[]) {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.chats.answerUserInput(chatId, toolCallId, answers);
|
||||
// Card stays mounted; the incoming WS tool_result frame will flip it
|
||||
// into AnsweredView via the parent prop change.
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'submit failed');
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSingle(qIdx: number, option: string) {
|
||||
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
|
||||
// Immediate submit for the single-question single-select shortcut. Only
|
||||
// fires when no free text exists anywhere — once the user typed, the
|
||||
// Submit button takes over so the typed text isn't silently dropped.
|
||||
if (singleQuestion && !anyFreeText) {
|
||||
const answers: AskUserAnswer[] = [
|
||||
{
|
||||
question: questions[0]!.question,
|
||||
selected_options: [option],
|
||||
free_text: null,
|
||||
},
|
||||
];
|
||||
void submit(answers);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMulti(qIdx: number, option: string) {
|
||||
setSelections((prev) =>
|
||||
prev.map((arr, i) => {
|
||||
if (i !== qIdx) return arr;
|
||||
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setFreeText(qIdx: number, value: string) {
|
||||
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-sm">
|
||||
<div className="px-4 py-3 space-y-4">
|
||||
{questions.map((q, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
{q.type === 'single_select' ? (
|
||||
<RadioGroup
|
||||
value={selections[i]![0] ?? ''}
|
||||
onValueChange={(v) => pickSingle(i, v)}
|
||||
disabled={submitting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
const checked = selections[i]!.includes(opt);
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={submitting}
|
||||
onChange={() => toggleMulti(i, opt)}
|
||||
className="mt-1 size-3.5 rounded border-input accent-primary"
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1 space-y-1">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Or type a custom answer
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={freeTexts[i]}
|
||||
disabled={submitting}
|
||||
placeholder="Free text…"
|
||||
onChange={(e) => setFreeText(i, e.target.value)}
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSubmitButton && (
|
||||
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!allComplete || submitting}
|
||||
onClick={() => void submit(buildAnswers())}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnsweredView({
|
||||
questions,
|
||||
answers,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
answers: AskUserAnswerSet | null;
|
||||
}) {
|
||||
if (!answers) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
|
||||
ask_user_input: answers unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/10 text-sm">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{questions.map((q, i) => {
|
||||
const a = answers.answers[i];
|
||||
if (!a) return null;
|
||||
return (
|
||||
<div key={i} className="space-y-1.5">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
<div className="space-y-0.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const selected = a.selected_options.includes(opt);
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className={
|
||||
selected
|
||||
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
|
||||
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
|
||||
}
|
||||
>
|
||||
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
|
||||
{selected && <Check className="size-3 text-primary" />}
|
||||
</span>
|
||||
<span>{opt}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{a.free_text && (
|
||||
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
|
||||
{a.free_text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Chat, Message } from '@/api/types';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
||||
import { AskUserInputCard } from './AskUserInputCard';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
@@ -12,9 +13,11 @@ interface Props {
|
||||
// v1.8.2: pre-render units. The single linear `messages` array gets walked
|
||||
// into a render-time list where each tool_call is a first-class item and
|
||||
// tool_result messages are folded onto their matching tool_run by id.
|
||||
// Batch 9.7: tool_run carries chat_id so AskUserInputCard can post the
|
||||
// answer without threading the chat id through MessageList's parent.
|
||||
type RenderItem =
|
||||
| { kind: 'message'; message: Message; capHitInfo?: { position: number; isLatest: boolean } }
|
||||
| { kind: 'tool_run'; run: ToolRun; key: string }
|
||||
| { kind: 'tool_run'; run: ToolRun; key: string; chatId: string }
|
||||
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
||||
|
||||
const GROUP_THRESHOLD = 3;
|
||||
@@ -50,7 +53,7 @@ function flatten(messages: Message[]): RenderItem[] {
|
||||
for (const tc of m.tool_calls!) {
|
||||
const run: ToolRun = { call: tc, result: null };
|
||||
runsByCallId.set(tc.id, run);
|
||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
||||
items.push({ kind: 'tool_run', run, key: tc.id, chatId: m.chat_id });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -63,6 +66,9 @@ function flatten(messages: Message[]): RenderItem[] {
|
||||
// Second pass: collapse runs of >=GROUP_THRESHOLD consecutive tool_run items
|
||||
// of the same tool name into a single tool_group. Any other render item
|
||||
// (text bubble, sentinel, user message) breaks the chain.
|
||||
// Batch 9.7: ask_user_input never groups — each pause has its own card so
|
||||
// grouping would render them as collapsed ToolCallLines which can't surface
|
||||
// the interactive form.
|
||||
function group(items: RenderItem[]): RenderItem[] {
|
||||
const out: RenderItem[] = [];
|
||||
let i = 0;
|
||||
@@ -74,6 +80,11 @@ function group(items: RenderItem[]): RenderItem[] {
|
||||
continue;
|
||||
}
|
||||
const name = item.run.call.name;
|
||||
if (name === 'ask_user_input') {
|
||||
out.push(item);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let j = i + 1;
|
||||
while (
|
||||
j < items.length &&
|
||||
@@ -82,7 +93,12 @@ function group(items: RenderItem[]): RenderItem[] {
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
|
||||
const run = items.slice(i, j) as Array<{
|
||||
kind: 'tool_run';
|
||||
run: ToolRun;
|
||||
key: string;
|
||||
chatId: string;
|
||||
}>;
|
||||
if (run.length >= GROUP_THRESHOLD) {
|
||||
out.push({
|
||||
kind: 'tool_group',
|
||||
@@ -150,6 +166,16 @@ export function MessageList({ messages, sessionChats }: Props) {
|
||||
);
|
||||
}
|
||||
if (item.kind === 'tool_run') {
|
||||
if (item.run.call.name === 'ask_user_input') {
|
||||
return (
|
||||
<AskUserInputCard
|
||||
key={item.key}
|
||||
toolCall={item.run.call}
|
||||
toolResult={item.run.result}
|
||||
chatId={item.chatId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ToolCallLine key={item.key} run={item.run} />;
|
||||
}
|
||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||
|
||||
Reference in New Issue
Block a user