251 lines
9.2 KiB
TypeScript
251 lines
9.2 KiB
TypeScript
import { Check } from "lucide-react";
|
|
import { useMemo } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { cn } from "@/lib/utils";
|
|
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
|
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
|
|
|
export type IntentClarificationSelection = {
|
|
selectedIds: string[];
|
|
custom: string;
|
|
};
|
|
|
|
/**
|
|
* Composite selection-map key for one clarification question. When the intent
|
|
* agent asks several questions in one turn, each needs its own selection state,
|
|
* so we key by `${msgId}::${clarificationId}` (falling back to the arrival
|
|
* index when the payload has no id). The map itself lives in `useStep1Intent`.
|
|
*/
|
|
export function clarificationKey(
|
|
msgId: number,
|
|
clarId: string | undefined,
|
|
index: number,
|
|
): string {
|
|
return `${msgId}::${clarId ?? `idx-${index}`}`;
|
|
}
|
|
|
|
type ClarificationOption = {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
};
|
|
|
|
function normalizeOptions(raw: unknown[]): ClarificationOption[] {
|
|
return raw
|
|
.map((opt, index) => {
|
|
if (typeof opt === "string") {
|
|
const label = opt.trim();
|
|
return label ? { id: `option-${index}`, label } : null;
|
|
}
|
|
if (opt && typeof opt === "object" && "label" in opt) {
|
|
const obj = opt as { id?: unknown; label?: unknown; description?: unknown };
|
|
const label = String(obj.label ?? "").trim();
|
|
if (!label) return null;
|
|
const id = String(obj.id ?? label).trim() || `option-${index}`;
|
|
const description =
|
|
typeof obj.description === "string" && obj.description.trim()
|
|
? obj.description.trim()
|
|
: undefined;
|
|
return { id, label, description };
|
|
}
|
|
return null;
|
|
})
|
|
.filter((opt): opt is ClarificationOption => opt !== null);
|
|
}
|
|
|
|
export function formatIntentClarificationAnswer(
|
|
payload: ClarificationPayload,
|
|
selection: IntentClarificationSelection,
|
|
): string | null {
|
|
const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []);
|
|
const selectedLabels = selection.selectedIds
|
|
.map((sid) => options.find((option) => option.id === sid)?.label)
|
|
.filter((label): label is string => Boolean(label && label.trim()));
|
|
const trimmedCustom = selection.custom.trim();
|
|
|
|
if (selectedLabels.length > 0 && trimmedCustom) {
|
|
return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
|
}
|
|
if (selectedLabels.length > 0) {
|
|
return `我选择:${selectedLabels.join("、")}`;
|
|
}
|
|
if (trimmedCustom) {
|
|
return trimmedCustom;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface IntentClarificationCardProps {
|
|
payload: ClarificationPayload;
|
|
fallbackContent?: string;
|
|
selection: IntentClarificationSelection;
|
|
onSelectionChange: (next: IntentClarificationSelection) => void;
|
|
disabled?: boolean;
|
|
submitting?: boolean;
|
|
/** Multi-select: submit current selection. Single-select: also used for custom text. */
|
|
onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
|
|
/**
|
|
* Defer submission: even single-select option clicks only *record* the
|
|
* selection (via onSelectionChange) instead of sending immediately, and the
|
|
* card hides its own submit button. Used when several questions are answered
|
|
* together and the parent owns one combined submit button.
|
|
*/
|
|
deferSubmit?: boolean;
|
|
}
|
|
|
|
export function IntentClarificationCard({
|
|
payload,
|
|
fallbackContent,
|
|
selection,
|
|
onSelectionChange,
|
|
disabled = false,
|
|
submitting = false,
|
|
onSubmit,
|
|
deferSubmit = false,
|
|
}: IntentClarificationCardProps) {
|
|
const options = useMemo(
|
|
() => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
|
|
[payload.options],
|
|
);
|
|
|
|
const allowMultiple = resolveAllowMultiple(
|
|
payload.allowMultiple,
|
|
payload.clarificationType,
|
|
);
|
|
|
|
const canSubmit = useMemo(() => {
|
|
return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
|
|
}, [selection.custom, selection.selectedIds]);
|
|
|
|
// In defer mode the parent owns the (combined) submit button.
|
|
const showSubmitButton = !deferSubmit && (allowMultiple || payload.allowCustom !== false);
|
|
|
|
function handleOptionClick(optionId: string) {
|
|
if (disabled || submitting) return;
|
|
if (!allowMultiple) {
|
|
if (deferSubmit) {
|
|
// Single-select but combined submit: just record the choice (replace),
|
|
// keep any custom text the user already typed.
|
|
onSelectionChange({ ...selection, selectedIds: [optionId] });
|
|
return;
|
|
}
|
|
onSubmit({ selectedIds: [optionId], custom: "" });
|
|
return;
|
|
}
|
|
const next = selection.selectedIds.includes(optionId)
|
|
? selection.selectedIds.filter((id) => id !== optionId)
|
|
: [...selection.selectedIds, optionId];
|
|
onSelectionChange({ ...selection, selectedIds: next });
|
|
}
|
|
|
|
function updateCustom(value: string) {
|
|
if (disabled || submitting) return;
|
|
onSelectionChange({ ...selection, custom: value });
|
|
}
|
|
|
|
return (
|
|
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
|
|
需要你的确认
|
|
</span>
|
|
{payload.clarificationType ? (
|
|
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
|
|
{payload.clarificationType}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{payload.context ? (
|
|
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
|
|
) : null}
|
|
<div className="text-xs font-medium leading-relaxed text-slate-800">
|
|
{payload.question || fallbackContent}
|
|
</div>
|
|
</div>
|
|
|
|
{options.length > 0 ? (
|
|
<div className="mt-3 grid gap-2">
|
|
<div className="text-[10px] text-slate-500">
|
|
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
|
</div>
|
|
{options.map((option) => {
|
|
const isSelected = selection.selectedIds.includes(option.id);
|
|
// Highlight selected options whenever the choice is "sticky" — i.e.
|
|
// multi-select, or single-select in defer mode (where a click records
|
|
// rather than immediately sends).
|
|
const showSelected = isSelected && (allowMultiple || deferSubmit);
|
|
return (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
disabled={disabled || submitting}
|
|
onClick={() => handleOptionClick(option.id)}
|
|
className={cn(
|
|
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
|
|
showSelected
|
|
? "border-primary bg-primary text-white shadow-sm"
|
|
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
|
|
!allowMultiple && !deferSubmit && !disabled && !submitting && "active:scale-[0.99]",
|
|
(disabled || submitting) && "cursor-not-allowed opacity-60",
|
|
)}
|
|
>
|
|
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
|
<span className="text-[11px] font-bold">{option.label}</span>
|
|
{option.description ? (
|
|
<span
|
|
className={cn(
|
|
"text-[10px] leading-snug",
|
|
showSelected ? "text-white/80" : "text-slate-500",
|
|
)}
|
|
>
|
|
{option.description}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
{showSelected ? (
|
|
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{payload.allowCustom !== false ? (
|
|
<div className="mt-3 flex flex-col gap-2">
|
|
<Textarea
|
|
value={selection.custom}
|
|
onChange={(event) => updateCustom(event.currentTarget.value)}
|
|
placeholder={
|
|
options.length > 0
|
|
? allowMultiple
|
|
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
|
: "如果以上选项都不合适,可以在这里输入自定义回答"
|
|
: "如果以上选项都不合适,可以在这里输入你的回答"
|
|
}
|
|
disabled={disabled || submitting}
|
|
className="min-h-16 resize-none border-slate-200 text-xs"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{showSubmitButton ? (
|
|
<div className="mt-3 flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={disabled || submitting || !canSubmit}
|
|
onClick={() => onSubmit()}
|
|
className="h-8 rounded-xl px-4 text-xs font-bold"
|
|
>
|
|
{submitting ? "发送中…" : "发送回答"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|