325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|