Files
ai-agent/web/app/dashboard/ai-agents/_components/edit.tsx
T
2026-06-22 10:08:51 +08:00

1300 lines
45 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
ArrowDownIcon,
ArrowUpIcon,
ChevronDownIcon,
InfoIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Controller, Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
fetchAIAgent,
fetchAIConfigsAll,
fetchAgentTeamsAll,
fetchKnowledgeBasesAll,
fetchMCPCatalog,
fetchSkillDefinitionsAll,
type AIAgent,
type AIConfig,
type AdminAgentTeam,
type CreateAIAgentPayload,
type KnowledgeBase,
type MCPToolCatalogItem,
type MCPToolSourceType,
type SkillDefinition,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import {
AIAgentFallbackMode,
AIAgentHandoffMode,
AIModelType,
IMConversationServiceMode,
Status,
} from "@/lib/generated/enums";
type DirectToolItem = CreateAIAgentPayload["directTools"][number];
type DirectToolOption = {
value: string;
label: string;
meta: DirectToolItem;
sourceType: MCPToolSourceType;
autoInjected: boolean;
groupLabel: string;
};
type GraphToolOption = {
value: string;
label: string;
};
type EditDialogProps = {
open: boolean;
saving: boolean;
itemId: number | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: CreateAIAgentPayload) => Promise<void>;
};
type TFunction = (key: string, values?: Record<string, string | number>) => string;
type EditForm = {
name: string;
description: string;
aiConfigId: string;
serviceMode: string;
systemPrompt: string;
welcomeMessage: string;
replyTimeoutSeconds: number;
handoffMode: string;
fallbackMode: string;
fallbackMessage: string;
};
function getServiceModeOptions(t: TFunction) {
return [
{ value: String(IMConversationServiceMode.AIOnly), label: t("aiAgent.serviceAiOnly") },
{ value: String(IMConversationServiceMode.HumanOnly), label: t("aiAgent.serviceHumanOnly") },
{ value: String(IMConversationServiceMode.AIFirst), label: t("aiAgent.serviceAiFirst") },
];
}
function getHandoffModeOptions(t: TFunction) {
return [
{ value: String(AIAgentHandoffMode.WaitPool), label: t("aiAgent.handoffWaitPool") },
{ value: String(AIAgentHandoffMode.DefaultTeamPool), label: t("aiAgent.handoffDefaultTeamPool") },
{ value: String(AIAgentHandoffMode.AIHoldAndNotify), label: t("aiAgent.handoffAiHoldAndNotify") },
];
}
function getFallbackModeOptions(t: TFunction) {
return [
{ value: String(AIAgentFallbackMode.NoAnswer), label: t("aiAgent.fallbackNoAnswer") },
{ value: String(AIAgentFallbackMode.SuggestRetry), label: t("aiAgent.fallbackSuggestRetry") },
];
}
function buildForm(item: AIAgent | null): EditForm {
if (!item) {
return {
name: "",
description: "",
aiConfigId: "",
serviceMode: String(IMConversationServiceMode.AIFirst),
systemPrompt: "",
welcomeMessage: "",
replyTimeoutSeconds: 180,
handoffMode: String(AIAgentHandoffMode.WaitPool),
fallbackMode: String(AIAgentFallbackMode.NoAnswer),
fallbackMessage: "",
};
}
return {
name: item.name,
description: item.description || "",
aiConfigId: item.aiConfigId > 0 ? String(item.aiConfigId) : "",
serviceMode: String(item.serviceMode),
systemPrompt: item.systemPrompt || "",
welcomeMessage: item.welcomeMessage || "",
replyTimeoutSeconds: item.replyTimeoutSeconds ?? 180,
handoffMode: String(item.handoffMode),
fallbackMode: String(item.fallbackMode),
fallbackMessage: item.fallbackMessage || "",
};
}
function buildPayload(
form: EditForm,
knowledgeIds: number[],
teamIds: number[],
skillIds: number[],
directTools: CreateAIAgentPayload["directTools"],
graphTools: CreateAIAgentPayload["graphTools"],
): CreateAIAgentPayload {
return {
name: form.name.trim(),
description: form.description.trim(),
aiConfigId: Number(form.aiConfigId),
serviceMode: Number(form.serviceMode),
systemPrompt: form.systemPrompt.trim(),
welcomeMessage: form.welcomeMessage.trim(),
replyTimeoutSeconds: Number(form.replyTimeoutSeconds),
teamIds,
handoffMode: Number(form.handoffMode),
fallbackMode: Number(form.fallbackMode),
fallbackMessage: form.fallbackMessage.trim(),
knowledgeIds,
skillIds,
directTools,
graphTools,
};
}
export function EditDialog({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: EditDialogProps) {
if (!open) {
return null;
}
return (
<EditDialogBody
key={itemId ? `edit-${itemId}` : "create"}
open={open}
saving={saving}
itemId={itemId}
onOpenChange={onOpenChange}
onSubmit={onSubmit}
/>
);
}
function EditDialogBody({
open,
saving,
itemId,
onOpenChange,
onSubmit,
}: EditDialogProps) {
const formId = "ai-agent-edit-form";
const t = useI18n();
const [loading, setLoading] = useState(false);
const schema = useMemo(
() =>
z.object({
name: z.string().trim().min(1, t("aiAgent.nameRequired")),
description: z.string().trim(),
aiConfigId: z.string().trim().regex(/^\d+$/, t("aiAgent.aiConfigRequired")),
serviceMode: z.string().trim().min(1, t("aiAgent.serviceModeRequired")),
systemPrompt: z.string().trim(),
welcomeMessage: z.string().trim(),
replyTimeoutSeconds: z
.number()
.min(0, t("aiAgent.replyTimeoutInvalid")),
handoffMode: z.string().trim().min(1, t("aiAgent.handoffModeRequired")),
fallbackMode: z.string().trim().min(1, t("aiAgent.fallbackModeRequired")),
fallbackMessage: z.string().trim(),
}),
[t],
);
const resolver = useMemo(
() => zodResolver(schema as never) as Resolver<EditForm>,
[schema],
);
const serviceModeOptions = useMemo(() => getServiceModeOptions(t), [t]);
const handoffModeOptions = useMemo(() => getHandoffModeOptions(t), [t]);
const fallbackModeOptions = useMemo(() => getFallbackModeOptions(t), [t]);
const form = useForm<EditForm>({
resolver,
defaultValues: buildForm(null),
});
const {
control,
handleSubmit,
register,
reset,
watch,
formState: { errors },
} = form;
const [selectedKnowledgeIds, setSelectedKnowledgeIds] = useState<number[]>(
[],
);
const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([]);
const [knowledgeToAdd, setKnowledgeToAdd] = useState("");
const [teamToAdd, setTeamToAdd] = useState("");
const [skillToAdd, setSkillToAdd] = useState("");
const [directToolGroupToAdd, setDirectToolGroupToAdd] = useState("");
const [directToolToAdd, setDirectToolToAdd] = useState("");
const [graphToolToAdd, setGraphToolToAdd] = useState("");
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([]);
const [skills, setSkills] = useState<SkillDefinition[]>([]);
const [directTools, setDirectTools] = useState<DirectToolItem[]>([]);
const [graphTools, setGraphTools] = useState<string[]>([]);
const [directToolOptions, setDirectToolOptions] = useState<DirectToolOption[]>(
[],
);
const [graphToolOptions, setGraphToolOptions] = useState<GraphToolOption[]>(
[],
);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
useEffect(() => {
async function loadDetail() {
if (!itemId) {
reset(buildForm(null));
setSelectedKnowledgeIds([]);
setSelectedTeamIds([]);
setSelectedSkillIds([]);
setDirectTools([]);
setGraphTools([]);
setKnowledgeToAdd("");
setTeamToAdd("");
setSkillToAdd("");
setDirectToolGroupToAdd("");
setDirectToolToAdd("");
setGraphToolToAdd("");
return;
}
setLoading(true);
try {
const data = await fetchAIAgent(itemId);
reset(buildForm(data));
setSelectedKnowledgeIds(data.knowledgeIds ?? []);
setSelectedTeamIds((data.teams ?? []).map((team) => team.id));
setSelectedSkillIds(data.skillIds ?? []);
setDirectTools(data.directTools ?? []);
setGraphTools(data.graphTools ?? []);
setKnowledgeToAdd("");
setTeamToAdd("");
setSkillToAdd("");
setDirectToolGroupToAdd("");
setDirectToolToAdd("");
setGraphToolToAdd("");
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("aiAgent.loadDetailFailed"),
);
} finally {
setLoading(false);
}
}
void loadDetail();
}, [itemId, reset, t]);
useEffect(() => {
async function loadAIConfigs() {
try {
const data = await fetchAIConfigsAll({
modelType: AIModelType.LLM,
});
setAIConfigs(data);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("aiAgent.loadAiConfigFailed"),
);
}
}
void loadAIConfigs();
}, [t]);
useEffect(() => {
async function loadAgentTeams() {
try {
const data = await fetchAgentTeamsAll();
setAgentTeams(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("aiAgent.loadTeamsFailed"));
}
}
void loadAgentTeams();
}, [t]);
useEffect(() => {
async function loadKnowledgeBases() {
try {
const data = await fetchKnowledgeBasesAll({
status: Status.Ok,
});
setKnowledgeBases(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("aiAgent.loadKnowledgeFailed"));
}
}
void loadKnowledgeBases();
}, [t]);
useEffect(() => {
async function loadSkills() {
try {
const data = await fetchSkillDefinitionsAll({
status: Status.Ok,
});
setSkills(data);
} catch (error) {
toast.error(error instanceof Error ? error.message : t("aiAgent.loadSkillsFailed"));
}
}
void loadSkills();
}, [t]);
useEffect(() => {
async function loadDirectToolOptions() {
try {
const catalog = await fetchMCPCatalog();
setToolCatalog(catalog);
setDirectToolOptions(
catalog
.filter((tool) => !tool.autoInjected && tool.sourceType === "mcp")
.map((tool) => ({
value: tool.toolCode,
label: `${tool.title || tool.toolName} · ${tool.toolCode}`,
sourceType: tool.sourceType,
autoInjected: tool.autoInjected,
groupLabel:
tool.sourceType === "builtin"
? t("aiAgent.builtinTools")
: tool.serverCode,
meta: {
toolCode: tool.toolCode,
serverCode: tool.serverCode,
toolName: tool.toolName,
title: tool.title || tool.toolName,
description: tool.description || "",
arguments: undefined,
},
})),
);
setGraphToolOptions(
catalog
.filter((tool) => tool.sourceType === "graph")
.map((tool) => ({
value: tool.toolCode,
label: `${tool.title || tool.toolName} · ${tool.toolCode}`,
})),
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("aiAgent.loadDirectToolsFailed"),
);
}
}
void loadDirectToolOptions();
}, [t]);
const aiConfigOptions = useMemo(
() =>
aiConfigs.map((item) => ({
value: String(item.id),
label: `${item.name} · ${item.modelName}`,
})),
[aiConfigs],
);
const teamOptions = useMemo(
() =>
agentTeams.map((item: AdminAgentTeam) => ({
value: String(item.id),
label: item.name,
})),
[agentTeams],
);
const knowledgeOptions = useMemo(
() =>
knowledgeBases.map((item) => ({
value: String(item.id),
label: item.name,
})),
[knowledgeBases],
);
const skillOptions = useMemo(
() =>
skills.map((item) => ({
value: String(item.id),
label: item.name,
})),
[skills],
);
const addableKnowledgeOptions = useMemo(
() =>
knowledgeOptions.filter(
(option) => !selectedKnowledgeIds.includes(Number(option.value)),
),
[knowledgeOptions, selectedKnowledgeIds],
);
const selectedKnowledgeOptions = useMemo(
() =>
selectedKnowledgeIds
.map((id) =>
knowledgeOptions.find((option) => Number(option.value) === id),
)
.filter(
(option): option is { value: string; label: string } => !!option,
),
[knowledgeOptions, selectedKnowledgeIds],
);
const addableSkillOptions = useMemo(
() =>
skillOptions.filter(
(option) => !selectedSkillIds.includes(Number(option.value)),
),
[selectedSkillIds, skillOptions],
);
const selectedSkillOptions = useMemo(
() =>
selectedSkillIds
.map((id) => skillOptions.find((option) => Number(option.value) === id))
.filter(
(option): option is { value: string; label: string } => !!option,
),
[selectedSkillIds, skillOptions],
);
const addableDirectToolOptions = useMemo(
() =>
directToolOptions.filter(
(option) =>
option.groupLabel === directToolGroupToAdd &&
!directTools.some((tool) => tool.toolCode === option.value),
),
[directToolOptions, directToolGroupToAdd, directTools],
);
const directToolGroupOptions = useMemo(
() =>
Array.from(
new Map(
directToolOptions.map((option) => [
option.groupLabel,
{
value: option.groupLabel,
label: option.groupLabel,
},
]),
).values(),
),
[directToolOptions],
);
const directToolsGrouped = useMemo(() => {
const groups = new Map<string, DirectToolItem[]>();
for (const tool of directTools) {
const groupLabel = tool.serverCode || t("aiAgent.ungrouped");
const current = groups.get(groupLabel) ?? [];
current.push(tool);
groups.set(groupLabel, current);
}
return Array.from(groups.entries());
}, [directTools, t]);
const addableGraphToolOptions = useMemo(
() =>
graphToolOptions.filter(
(option) => !graphTools.includes(option.value),
),
[graphToolOptions, graphTools],
);
const addableTeamOptions = useMemo(
() =>
teamOptions.filter(
(option) => !selectedTeamIds.includes(Number(option.value)),
),
[teamOptions, selectedTeamIds],
);
const selectedTeamOptions = useMemo(
() =>
selectedTeamIds
.map((id) => teamOptions.find((option) => Number(option.value) === id))
.filter(
(option): option is { value: string; label: string } => !!option,
),
[selectedTeamIds, teamOptions],
);
const handoffMode = watch("handoffMode");
const selectedHandoffModeLabel =
handoffModeOptions.find((item) => item.value === handoffMode)?.label ??
t("aiAgent.notSelected");
async function onFormSubmit(values: EditForm) {
await onSubmit(
buildPayload(
values,
selectedKnowledgeIds,
selectedTeamIds,
selectedSkillIds,
directTools,
graphTools,
),
);
}
function handleAddTeam(value: string) {
const id = Number(value);
if (!Number.isFinite(id) || id <= 0 || selectedTeamIds.includes(id)) {
return;
}
setSelectedTeamIds((prev) => [...prev, id]);
setTeamToAdd("");
}
function handleRemoveTeam(id: number) {
setSelectedTeamIds((prev) => prev.filter((item) => item !== id));
}
function handleAddKnowledge(value: string) {
const id = Number(value);
if (!Number.isFinite(id) || id <= 0 || selectedKnowledgeIds.includes(id)) {
return;
}
setSelectedKnowledgeIds((prev) => [...prev, id]);
setKnowledgeToAdd("");
}
function handleMoveKnowledge(index: number, direction: -1 | 1) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= selectedKnowledgeIds.length) {
return;
}
setSelectedKnowledgeIds((prev) => {
const next = [...prev];
const current = next[index];
next[index] = next[targetIndex];
next[targetIndex] = current;
return next;
});
}
function handleRemoveKnowledge(id: number) {
setSelectedKnowledgeIds((prev) => prev.filter((item) => item !== id));
}
function handleAddSkill(value: string) {
const id = Number(value);
if (!Number.isFinite(id) || id <= 0 || selectedSkillIds.includes(id)) {
return;
}
setSelectedSkillIds((prev) => [...prev, id]);
setSkillToAdd("");
}
function handleRemoveSkill(id: number) {
setSelectedSkillIds((prev) => prev.filter((item) => item !== id));
}
function handleAddDirectTool(value: string) {
const option = directToolOptions.find((item) => item.value === value);
if (!option) {
return;
}
setDirectTools((prev) => {
if (
prev.some(
(item) =>
item.toolCode === option.meta.toolCode,
)
) {
return prev;
}
return [...prev, option.meta];
});
setDirectToolGroupToAdd(option.groupLabel);
setDirectToolToAdd("");
}
function handleRemoveDirectTool(value: string) {
setDirectTools((prev) => prev.filter((item) => item.toolCode !== value));
}
function handleAddGraphTool(value: string) {
if (!value || graphTools.includes(value)) {
return;
}
setGraphTools((prev) => [...prev, value]);
setGraphToolToAdd("");
}
function handleRemoveGraphTool(value: string) {
setGraphTools((prev) => prev.filter((item) => item !== value));
}
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={itemId ? t("aiAgent.editTitle") : t("aiAgent.createTitle")}
size="xl"
defaultFullscreen
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("aiAgent.cancel")}
</Button>
<Button type="submit" form={formId} disabled={saving || loading}>
{saving ? t("aiAgent.saving") : t("aiAgent.save")}
</Button>
</>
}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">{t("aiAgent.loading")}</div>
</div>
) : (
<form
id={formId}
onSubmit={handleSubmit(onFormSubmit)}
className="space-y-6"
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ai-agent-name">{t("aiAgent.name")}</FieldLabel>
<FieldContent>
<Input id="ai-agent-name" {...register("name")} />
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.aiConfigId}>
<FieldLabel>{t("aiAgent.aiConfig")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="aiConfigId"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={aiConfigOptions}
placeholder={t("aiAgent.selectAiConfig")}
searchPlaceholder={t("aiAgent.searchAiConfig")}
emptyText={t("aiAgent.emptyAiConfig")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.aiConfigId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.serviceMode}>
<FieldLabel>{t("aiAgent.columnServiceMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="serviceMode"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={serviceModeOptions}
placeholder={t("aiAgent.selectServiceMode")}
searchPlaceholder={t("aiAgent.searchServiceMode")}
emptyText={t("aiAgent.emptyServiceMode")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.serviceMode]} />
</FieldContent>
</Field>
</div>
<Field data-invalid={!!errors.description}>
<FieldLabel htmlFor="ai-agent-description">{t("aiAgent.description")}</FieldLabel>
<FieldContent>
<Textarea id="ai-agent-description" {...register("description")} />
<FieldError errors={[errors.description]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.welcomeMessage}>
<FieldLabel htmlFor="ai-agent-welcome-message">{t("aiAgent.welcomeMessage")}</FieldLabel>
<FieldContent>
<Textarea
id="ai-agent-welcome-message"
rows={5}
{...register("welcomeMessage")}
/>
<FieldError errors={[errors.welcomeMessage]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.systemPrompt}>
<FieldLabel htmlFor="ai-agent-system-prompt">
{t("aiAgent.systemPrompt")}
</FieldLabel>
<FieldContent>
<Textarea
id="ai-agent-system-prompt"
rows={8}
{...register("systemPrompt")}
/>
<FieldError errors={[errors.systemPrompt]} />
</FieldContent>
</Field>
</div>
<SectionCard
title={t("aiAgent.sectionStrategy")}
>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(320px,1.15fr)]">
<div className="space-y-4">
<Field data-invalid={!!errors.handoffMode}>
<FieldLabel>{t("aiAgent.handoffMode")}</FieldLabel>
<FieldContent>
<Controller
control={control}
name="handoffMode"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={handoffModeOptions}
placeholder={t("aiAgent.selectHandoffMode")}
searchPlaceholder={t("aiAgent.searchHandoffMode")}
emptyText={t("aiAgent.emptyHandoffMode")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.handoffMode]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.replyTimeoutSeconds}>
<FieldLabel
htmlFor="ai-agent-reply-timeout-seconds"
className="flex items-center gap-1"
>
{t("aiAgent.replyTimeoutSeconds")}
<Popover>
<PopoverTrigger
render={
<button
type="button"
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<InfoIcon className="size-4" />
</button>
}
/>
<PopoverContent side="top" align="start" className="max-w-xs">
<PopoverDescription>
{t("aiAgent.replyTimeoutHelp")}
</PopoverDescription>
</PopoverContent>
</Popover>
</FieldLabel>
<FieldContent>
<Input
id="ai-agent-reply-timeout-seconds"
type="number"
min={0}
step={1}
{...register("replyTimeoutSeconds", { valueAsNumber: true })}
/>
<FieldError errors={[errors.replyTimeoutSeconds]} />
</FieldContent>
</Field>
</div>
<div className="rounded-md border bg-muted/10 p-3">
<div className="mb-1 text-sm font-medium">{t("aiAgent.teams")}</div>
<div className="mb-4 text-xs text-muted-foreground">
{t("aiAgent.currentHandoffMode", { mode: selectedHandoffModeLabel })}
{handoffMode ===
String(AIAgentHandoffMode.DefaultTeamPool)
? t("aiAgent.handoffNeedsTeam")
: t("aiAgent.handoffOnlyWhenNeeded")}
</div>
<Field>
<FieldContent className="space-y-3">
<OptionCombobox
value={teamToAdd}
options={addableTeamOptions}
placeholder={t("aiAgent.selectTeam")}
searchPlaceholder={t("aiAgent.searchTeam")}
emptyText={t("aiAgent.emptyTeam")}
onChange={handleAddTeam}
/>
<div className="flex flex-wrap gap-2">
{selectedTeamOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
{t("aiAgent.noTeams")}
</span>
) : (
selectedTeamOptions.map((option) => (
<Badge
key={option.value}
variant="secondary"
className="gap-1 pr-1"
>
{option.label}
<Button
type="button"
variant="ghost"
size="icon"
className="size-5"
onClick={() =>
handleRemoveTeam(Number(option.value))
}
aria-label={t("aiAgent.removeTeam", { name: option.label })}
>
<Trash2Icon className="size-3" />
</Button>
</Badge>
))
)}
</div>
</FieldContent>
</Field>
</div>
</div>
</SectionCard>
<SectionCard
title={t("aiAgent.sectionCapabilities")}
>
<Tabs defaultValue="knowledge" className="gap-4">
<TabsList className="w-fit">
<TabsTrigger value="knowledge">{t("aiAgent.knowledgeTab")}</TabsTrigger>
<TabsTrigger value="skills">{t("aiAgent.skillsTab")}</TabsTrigger>
<TabsTrigger value="direct-tools">{t("aiAgent.directToolsTab")}</TabsTrigger>
<TabsTrigger value="graph-tools">{t("aiAgent.graphToolsTab")}</TabsTrigger>
</TabsList>
<TabsContent value="knowledge" className="space-y-4">
<div className="text-xs text-muted-foreground">
{t("aiAgent.knowledgeHint")}
</div>
<Field data-invalid={selectedKnowledgeIds.length === 0}>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1">
<OptionCombobox
value={knowledgeToAdd}
options={addableKnowledgeOptions}
placeholder={t("aiAgent.selectKnowledge")}
searchPlaceholder={t("aiAgent.searchKnowledge")}
emptyText={t("aiAgent.emptyKnowledge")}
onChange={handleAddKnowledge}
/>
</div>
<Button
type="button"
variant="outline"
disabled={!knowledgeToAdd}
onClick={() => handleAddKnowledge(knowledgeToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
{selectedKnowledgeIds.length === 0 ? (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t("aiAgent.knowledgeRequired")}
</div>
) : (
<div className="space-y-2 rounded-md border p-3">
{selectedKnowledgeOptions.map((option, index) => (
<div key={option.value} className="flex items-center gap-2">
<Badge
variant="secondary"
className="min-w-8 justify-center"
>
{index + 1}
</Badge>
<div className="flex-1 text-sm">{option.label}</div>
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={index === 0}
onClick={() => handleMoveKnowledge(index, -1)}
>
<ArrowUpIcon />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={
index === selectedKnowledgeOptions.length - 1
}
onClick={() => handleMoveKnowledge(index, 1)}
>
<ArrowDownIcon />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() =>
handleRemoveKnowledge(Number(option.value))
}
>
<Trash2Icon />
</Button>
</div>
))}
</div>
)}
{selectedKnowledgeIds.length === 0 ? (
<FieldError errors={[{ message: t("aiAgent.knowledgeRequired") }]} />
) : null}
</FieldContent>
</Field>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-1">
<Field data-invalid={!!errors.fallbackMode}>
<FieldLabel>{t("aiAgent.fallbackMode")}</FieldLabel>
<FieldContent className="space-y-3">
<Controller
control={control}
name="fallbackMode"
render={({ field }) => (
<OptionCombobox
value={field.value}
options={fallbackModeOptions}
placeholder={t("aiAgent.selectFallbackMode")}
searchPlaceholder={t("aiAgent.searchFallbackMode")}
emptyText={t("aiAgent.emptyFallbackMode")}
onChange={field.onChange}
/>
)}
/>
<FieldError errors={[errors.fallbackMode]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.fallbackMessage}>
<FieldLabel htmlFor="ai-agent-fallback-message">
{t("aiAgent.fallbackMessage")}
</FieldLabel>
<FieldContent>
<div className="mb-1 text-xs text-muted-foreground">
{t("aiAgent.fallbackMessageHint")}
</div>
<Textarea
id="ai-agent-fallback-message"
rows={5}
{...register("fallbackMessage")}
/>
<FieldError errors={[errors.fallbackMessage]} />
</FieldContent>
</Field>
</div>
</TabsContent>
<TabsContent value="skills" className="space-y-4">
<div className="text-xs text-muted-foreground">
{t("aiAgent.skillsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1">
<OptionCombobox
value={skillToAdd}
options={addableSkillOptions}
placeholder={t("aiAgent.selectSkill")}
searchPlaceholder={t("aiAgent.searchSkill")}
emptyText={t("aiAgent.emptySkill")}
onChange={handleAddSkill}
/>
</div>
<Button
type="button"
variant="outline"
disabled={!skillToAdd}
onClick={() => handleAddSkill(skillToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{selectedSkillOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
{t("aiAgent.noSkillsHint")}
</span>
) : (
selectedSkillOptions.map((option) => (
<Badge
key={option.value}
variant="secondary"
className="gap-1 pr-1"
>
{option.label}
<Button
type="button"
variant="ghost"
size="icon"
className="size-5"
onClick={() =>
handleRemoveSkill(Number(option.value))
}
aria-label={t("aiAgent.removeSkill", { name: option.label })}
>
<Trash2Icon className="size-3" />
</Button>
</Badge>
))
)}
</div>
</FieldContent>
</Field>
</TabsContent>
<TabsContent value="direct-tools" className="space-y-4">
<div className="text-xs text-muted-foreground">
{t("aiAgent.directToolsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-52">
<OptionCombobox
value={directToolGroupToAdd}
options={directToolGroupOptions}
placeholder={t("aiAgent.selectToolGroup")}
searchPlaceholder={t("aiAgent.searchToolGroup")}
emptyText={t("aiAgent.emptyToolGroup")}
onChange={(value) => {
setDirectToolGroupToAdd(value);
setDirectToolToAdd("");
}}
/>
</div>
<div className="flex-1">
<OptionCombobox
value={directToolToAdd}
options={addableDirectToolOptions}
placeholder={t("aiAgent.selectDirectTool")}
searchPlaceholder={t("aiAgent.searchDirectTool")}
emptyText={t("aiAgent.emptyDirectTool")}
onChange={handleAddDirectTool}
/>
</div>
<Button
type="button"
variant="outline"
disabled={!directToolGroupToAdd || !directToolToAdd}
onClick={() => handleAddDirectTool(directToolToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
<div className="space-y-3">
{directTools.length === 0 ? (
<span className="text-sm text-muted-foreground">
{t("aiAgent.noDirectToolsHint")}
</span>
) : (
directToolsGrouped.map(([groupLabel, tools]) => (
<div key={groupLabel} className="rounded-md border p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">
{groupLabel}
</div>
<div className="flex flex-wrap gap-2">
{tools.map((tool) => {
const value = tool.toolCode;
const catalogItem = toolCatalog.find(
(item) => item.toolCode === tool.toolCode,
);
return (
<Badge
key={value}
variant="secondary"
className="gap-1 pr-1"
>
{tool.title || catalogItem?.title || value}
<span className="text-[10px] text-muted-foreground/80">
{tool.serverCode || "MCP"}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5"
onClick={() =>
handleRemoveDirectTool(value)
}
aria-label={t("aiAgent.removeDirectTool", { name: value })}
>
<Trash2Icon className="size-3" />
</Button>
</Badge>
);
})}
</div>
</div>
))
)}
</div>
</FieldContent>
</Field>
</TabsContent>
<TabsContent value="graph-tools" className="space-y-4">
<div className="text-xs text-muted-foreground">
{t("aiAgent.graphToolsHint")}
</div>
<Field>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1">
<OptionCombobox
value={graphToolToAdd}
options={addableGraphToolOptions}
placeholder={t("aiAgent.selectGraphTool")}
searchPlaceholder={t("aiAgent.searchGraphTool")}
emptyText={t("aiAgent.emptyGraphTool")}
onChange={handleAddGraphTool}
/>
</div>
<Button
type="button"
variant="outline"
disabled={!graphToolToAdd}
onClick={() => handleAddGraphTool(graphToolToAdd)}
>
<PlusIcon />
{t("aiAgent.add")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{graphTools.length === 0 ? (
<span className="text-sm text-muted-foreground">
{t("aiAgent.noGraphToolsHint")}
</span>
) : (
graphTools.map((toolCode) => {
const catalogItem = toolCatalog.find(
(item) => item.toolCode === toolCode,
);
return (
<Badge
key={toolCode}
variant="secondary"
className="gap-1 pr-1"
>
{catalogItem?.title || toolCode}
<span className="text-[10px] text-muted-foreground/80">
graph
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5"
onClick={() => handleRemoveGraphTool(toolCode)}
aria-label={t("aiAgent.removeGraphTool", { name: toolCode })}
>
<Trash2Icon className="size-3" />
</Button>
</Badge>
);
})
)}
</div>
</FieldContent>
</Field>
</TabsContent>
</Tabs>
</SectionCard>
</form>
)}
</ProjectDialog>
);
}
function SectionCard({
title,
description,
children,
defaultOpen = true,
}: {
title: string;
description?: string;
children: ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="border-t pt-4">
<button
type="button"
className="flex w-full items-start justify-between gap-4 text-left"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
>
<div className="min-w-0">
<div className="text-base font-semibold">{title}</div>
{description ? (
<div className="mt-1 text-sm text-muted-foreground">
{description}
</div>
) : null}
</div>
<span className="mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-muted/30 text-muted-foreground transition-colors hover:bg-muted/50">
<ChevronDownIcon
className={`size-4 transition-transform duration-200 ${
open ? "rotate-180" : "rotate-0"
}`}
/>
</span>
</button>
{open ? <div className="mt-3 space-y-4">{children}</div> : null}
</section>
);
}