Files
makelore/src/pages/Chat/CodingInteractionPanel.tsx

168 lines
7.3 KiB
TypeScript

import { useState } from 'react';
import { Check, CircleHelp, LoaderCircle, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { respondCodingConversationInteraction } from '@/lib/coding-conversations';
import type {
ConversationInteraction,
ConversationInteractionResponse,
} from '@/types/coding-conversation';
export function CodingInteractionPanel({
conversationId,
interactions,
onSettled,
}: {
conversationId: string;
interactions: ConversationInteraction[];
onSettled?(): Promise<void> | void;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [busyId, setBusyId] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const pendingInteractions = interactions.filter(({ status }) => status === 'pending');
if (pendingInteractions.length === 0) return null;
const respond = (response: ConversationInteractionResponse) => {
if (busyId) return;
setBusyId(response.interactionId);
setErrors((current) => {
const next = { ...current };
delete next[response.interactionId];
return next;
});
void respondCodingConversationInteraction(conversationId, response)
.then(() => onSettled?.())
.catch((error) => {
setErrors((current) => ({
...current,
[response.interactionId]: `${error instanceof Error ? error.message : String(error)}。请求可能已失效,请刷新对话。`,
}));
})
.finally(() => setBusyId((current) => current === response.interactionId ? null : current));
};
return (
<section
className="mx-auto w-[calc(100%_-_2rem)] max-w-[46rem] shrink-0 space-y-2 pt-2"
aria-label="等待回答的 Agent 交互"
data-testid="coding-interactions"
>
{pendingInteractions.map((interaction) => {
const busy = busyId === interaction.id;
const value = values[interaction.id] ?? '';
return (
<div key={interaction.id} className="rounded-lg border border-brand/20 bg-brand-soft/55 p-3 shadow-soft">
<div className="flex items-start gap-2">
<CircleHelp className="mt-0.5 h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex gap-2">
<p className="min-w-0 flex-1 text-sm font-semibold">{interaction.title}</p>
<span className="text-xs text-muted-foreground"></span>
</div>
{interaction.message && <p className="mt-1 text-pretty text-xs leading-5 text-muted-foreground">{interaction.message}</p>}
</div>
</div>
{interaction.kind === 'select' && (
<div className="mt-3 space-y-2">
<div className="flex flex-wrap gap-2">
{(interaction.options ?? []).map((option) => (
<Button
key={option.id}
type="button"
variant="outline"
className="h-auto min-h-9 rounded-lg px-3 py-2 text-left"
disabled={Boolean(busyId)}
title={option.description}
onClick={() => respond({ interactionId: interaction.id, optionId: option.id })}
>
{option.label}
</Button>
))}
</div>
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
const answer = value.trim();
if (answer) respond({ interactionId: interaction.id, value: answer });
}}
>
<Input
value={value}
disabled={Boolean(busyId)}
aria-label={`${interaction.title}的其他回答`}
placeholder="输入其他回答"
className="rounded-lg bg-background"
onChange={(event) => setValues((current) => ({
...current,
[interaction.id]: event.target.value,
}))}
/>
<Button
type="submit"
className="min-h-9 shrink-0 rounded-lg"
disabled={Boolean(busyId) || !value.trim()}
>
</Button>
</form>
</div>
)}
{interaction.kind === 'confirm' && (
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" className="min-h-9 rounded-lg" disabled={Boolean(busyId)} onClick={() => respond({ interactionId: interaction.id, confirmed: true })}>
<Check className="mr-1.5 h-4 w-4" aria-hidden="true" />
</Button>
<Button type="button" variant="outline" className="min-h-9 rounded-lg" disabled={Boolean(busyId)} onClick={() => respond({ interactionId: interaction.id, confirmed: false })}>
</Button>
</div>
)}
{(interaction.kind === 'input' || interaction.kind === 'editor') && (
<div className="mt-3 space-y-2">
{interaction.kind === 'editor'
? (
<Textarea
value={value}
rows={5}
disabled={Boolean(busyId)}
aria-label={`${interaction.title}的回答`}
className="resize-y rounded-lg bg-background"
onChange={(event) => setValues((current) => ({ ...current, [interaction.id]: event.target.value }))}
/>
)
: (
<Input
value={value}
disabled={Boolean(busyId)}
aria-label={`${interaction.title}的回答`}
className="rounded-lg bg-background"
onChange={(event) => setValues((current) => ({ ...current, [interaction.id]: event.target.value }))}
/>
)}
<Button type="button" className="min-h-9 rounded-lg" disabled={Boolean(busyId) || !value.trim()} onClick={() => respond({ interactionId: interaction.id, value })}>
</Button>
</div>
)}
<div className="mt-2 flex items-center justify-between gap-2">
<Button type="button" variant="ghost" className="min-h-9 rounded-lg px-3 text-muted-foreground" disabled={Boolean(busyId)} onClick={() => respond({ interactionId: interaction.id, cancelled: true })}>
<X className="mr-1.5 h-4 w-4" aria-hidden="true" />
</Button>
{busy && <LoaderCircle className="h-4 w-4 animate-spin text-muted-foreground" aria-label="正在提交回答" />}
</div>
{errors[interaction.id] && <p className="mt-2 text-xs text-destructive" role="alert">{errors[interaction.id]}</p>}
</div>
);
})}
</section>
);
}