2026-04-09 10:01:23 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowDownIcon,
|
|
|
|
|
|
ArrowUpIcon,
|
|
|
|
|
|
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 { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
|
import {
|
|
|
|
|
|
type AIAgent,
|
|
|
|
|
|
type AIConfig,
|
|
|
|
|
|
type AdminAgentTeam,
|
|
|
|
|
|
type CreateAIAgentPayload,
|
|
|
|
|
|
type KnowledgeBase,
|
2026-04-09 15:36:20 +08:00
|
|
|
|
type MCPToolCatalogItem,
|
2026-04-09 10:01:23 +08:00
|
|
|
|
type SkillDefinition,
|
|
|
|
|
|
fetchAIAgent,
|
|
|
|
|
|
fetchAIConfigsAll,
|
|
|
|
|
|
fetchAgentTeamsAll,
|
2026-04-09 15:36:20 +08:00
|
|
|
|
fetchMCPCatalog,
|
2026-04-09 10:01:23 +08:00
|
|
|
|
fetchKnowledgeBasesAll,
|
|
|
|
|
|
fetchSkillDefinitionsAll,
|
|
|
|
|
|
} from "@/lib/api/admin";
|
|
|
|
|
|
import {
|
|
|
|
|
|
AIAgentHandoffMode,
|
|
|
|
|
|
AIAgentHandoffModeLabels,
|
|
|
|
|
|
AIModelType,
|
|
|
|
|
|
IMConversationServiceMode,
|
|
|
|
|
|
IMConversationServiceModeLabels,
|
|
|
|
|
|
Status,
|
|
|
|
|
|
} from "@/lib/generated/enums";
|
|
|
|
|
|
import { getEnumOptions } from "@/lib/enums";
|
|
|
|
|
|
import { FieldDescription } from "@base-ui/react";
|
|
|
|
|
|
|
2026-04-10 14:43:57 +08:00
|
|
|
|
type DirectToolItem = CreateAIAgentPayload["directTools"][number];
|
|
|
|
|
|
|
|
|
|
|
|
type DirectToolOption = {
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
meta: DirectToolItem;
|
|
|
|
|
|
sourceType: string;
|
2026-04-10 15:41:02 +08:00
|
|
|
|
autoInjected: boolean;
|
2026-04-10 14:43:57 +08:00
|
|
|
|
groupLabel: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-09 10:01:23 +08:00
|
|
|
|
type EditDialogProps = {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
saving: boolean;
|
|
|
|
|
|
itemId: number | null;
|
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
|
onSubmit: (payload: CreateAIAgentPayload) => Promise<void>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const schema = z.object({
|
|
|
|
|
|
name: z.string().trim().min(1, "名称不能为空"),
|
|
|
|
|
|
description: z.string().trim(),
|
|
|
|
|
|
aiConfigId: z.string().trim().regex(/^\d+$/, "请选择 AI 配置"),
|
|
|
|
|
|
serviceMode: z.string().trim().min(1, "请选择服务模式"),
|
|
|
|
|
|
systemPrompt: z.string().trim(),
|
|
|
|
|
|
welcomeMessage: z.string().trim(),
|
|
|
|
|
|
replyTimeoutSeconds: z
|
|
|
|
|
|
.number()
|
|
|
|
|
|
.min(0, "回复超时秒数必须是大于等于 0 的整数"),
|
|
|
|
|
|
handoffMode: z.string().trim().min(1, "请选择转人工模式"),
|
|
|
|
|
|
fallbackMessage: z.string().trim(),
|
|
|
|
|
|
remark: z.string().trim(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
type EditForm = z.infer<typeof schema>;
|
|
|
|
|
|
|
|
|
|
|
|
const resolver = zodResolver(schema as never) as Resolver<
|
|
|
|
|
|
z.input<typeof schema>,
|
|
|
|
|
|
undefined,
|
|
|
|
|
|
z.output<typeof schema>
|
|
|
|
|
|
>;
|
|
|
|
|
|
|
|
|
|
|
|
const serviceModeOptions = getEnumOptions(IMConversationServiceModeLabels).map(
|
|
|
|
|
|
(option) => ({
|
|
|
|
|
|
value: String(option.value),
|
|
|
|
|
|
label: option.label,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handoffModeOptions = getEnumOptions(AIAgentHandoffModeLabels).map(
|
|
|
|
|
|
(option) => ({
|
|
|
|
|
|
value: String(option.value),
|
|
|
|
|
|
label: option.label,
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function buildForm(item: AIAgent | null): EditForm {
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: "",
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
aiConfigId: "",
|
|
|
|
|
|
serviceMode: String(IMConversationServiceMode.AIFirst),
|
|
|
|
|
|
systemPrompt: "",
|
|
|
|
|
|
welcomeMessage: "",
|
|
|
|
|
|
replyTimeoutSeconds: 180,
|
|
|
|
|
|
handoffMode: String(AIAgentHandoffMode.WaitPool),
|
|
|
|
|
|
fallbackMessage:
|
|
|
|
|
|
"我暂时没有找到足够准确的信息。你可以补充订单号、产品名或更具体的问题,我再继续帮你查。",
|
|
|
|
|
|
remark: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
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),
|
|
|
|
|
|
fallbackMessage: item.fallbackMessage || "",
|
|
|
|
|
|
remark: item.remark || "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildPayload(
|
|
|
|
|
|
form: EditForm,
|
|
|
|
|
|
knowledgeIds: number[],
|
|
|
|
|
|
teamIds: number[],
|
|
|
|
|
|
skillIds: number[],
|
|
|
|
|
|
directTools: CreateAIAgentPayload["directTools"],
|
|
|
|
|
|
): 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),
|
|
|
|
|
|
fallbackMessage: form.fallbackMessage.trim(),
|
|
|
|
|
|
knowledgeIds,
|
|
|
|
|
|
skillIds,
|
|
|
|
|
|
directTools,
|
|
|
|
|
|
remark: form.remark.trim(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 [loading, setLoading] = useState(false);
|
|
|
|
|
|
const form = useForm<
|
|
|
|
|
|
z.input<typeof schema>,
|
|
|
|
|
|
undefined,
|
|
|
|
|
|
z.output<typeof schema>
|
|
|
|
|
|
>({
|
|
|
|
|
|
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("");
|
2026-04-10 14:43:57 +08:00
|
|
|
|
const [directToolGroupToAdd, setDirectToolGroupToAdd] = useState("");
|
2026-04-09 10:01:23 +08:00
|
|
|
|
const [directToolToAdd, setDirectToolToAdd] = useState("");
|
|
|
|
|
|
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([]);
|
|
|
|
|
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
|
|
|
|
|
const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([]);
|
|
|
|
|
|
const [skills, setSkills] = useState<SkillDefinition[]>([]);
|
2026-04-10 14:43:57 +08:00
|
|
|
|
const [directTools, setDirectTools] = useState<DirectToolItem[]>([]);
|
|
|
|
|
|
const [directToolOptions, setDirectToolOptions] = useState<DirectToolOption[]>(
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
2026-04-09 15:36:20 +08:00
|
|
|
|
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
|
2026-04-09 10:01:23 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
async function loadDetail() {
|
|
|
|
|
|
if (!itemId) {
|
|
|
|
|
|
reset(buildForm(null));
|
|
|
|
|
|
setSelectedKnowledgeIds([]);
|
|
|
|
|
|
setSelectedTeamIds([]);
|
|
|
|
|
|
setSelectedSkillIds([]);
|
|
|
|
|
|
setDirectTools([]);
|
|
|
|
|
|
setKnowledgeToAdd("");
|
|
|
|
|
|
setTeamToAdd("");
|
|
|
|
|
|
setSkillToAdd("");
|
2026-04-10 14:43:57 +08:00
|
|
|
|
setDirectToolGroupToAdd("");
|
2026-04-09 10:01:23 +08:00
|
|
|
|
setDirectToolToAdd("");
|
|
|
|
|
|
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 ?? []);
|
|
|
|
|
|
setKnowledgeToAdd("");
|
|
|
|
|
|
setTeamToAdd("");
|
|
|
|
|
|
setSkillToAdd("");
|
2026-04-10 14:43:57 +08:00
|
|
|
|
setDirectToolGroupToAdd("");
|
2026-04-09 10:01:23 +08:00
|
|
|
|
setDirectToolToAdd("");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error(
|
|
|
|
|
|
error instanceof Error ? error.message : "加载 AI Agent 详情失败",
|
|
|
|
|
|
);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadDetail();
|
|
|
|
|
|
}, [itemId, reset]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
async function loadAIConfigs() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await fetchAIConfigsAll({
|
|
|
|
|
|
modelType: AIModelType.LLM,
|
|
|
|
|
|
});
|
|
|
|
|
|
setAIConfigs(data);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error(
|
|
|
|
|
|
error instanceof Error ? error.message : "加载 AI 配置失败",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadAIConfigs();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
async function loadAgentTeams() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await fetchAgentTeamsAll();
|
|
|
|
|
|
setAgentTeams(data);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error(error instanceof Error ? error.message : "加载客服组失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadAgentTeams();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
async function loadKnowledgeBases() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await fetchKnowledgeBasesAll({
|
|
|
|
|
|
status: Status.Ok,
|
|
|
|
|
|
});
|
|
|
|
|
|
setKnowledgeBases(data);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error(error instanceof Error ? error.message : "加载知识库失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadKnowledgeBases();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
async function loadSkills() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await fetchSkillDefinitionsAll({
|
|
|
|
|
|
status: Status.Ok,
|
|
|
|
|
|
});
|
|
|
|
|
|
setSkills(data);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error(error instanceof Error ? error.message : "加载 Skills 失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadSkills();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
async function loadDirectToolOptions() {
|
|
|
|
|
|
try {
|
2026-04-09 15:36:20 +08:00
|
|
|
|
const catalog = await fetchMCPCatalog();
|
|
|
|
|
|
setToolCatalog(catalog);
|
|
|
|
|
|
setDirectToolOptions(
|
2026-04-10 15:41:02 +08:00
|
|
|
|
catalog
|
|
|
|
|
|
.filter((tool) => !tool.autoInjected)
|
|
|
|
|
|
.map((tool) => ({
|
|
|
|
|
|
value: tool.toolCode,
|
|
|
|
|
|
label: `${tool.title || tool.toolName} · ${tool.toolCode}`,
|
|
|
|
|
|
sourceType: tool.sourceType,
|
|
|
|
|
|
autoInjected: tool.autoInjected,
|
|
|
|
|
|
groupLabel:
|
|
|
|
|
|
tool.sourceType === "builtin"
|
|
|
|
|
|
? "内置工具"
|
|
|
|
|
|
: tool.serverCode,
|
|
|
|
|
|
meta: {
|
|
|
|
|
|
toolCode: tool.toolCode,
|
|
|
|
|
|
serverCode: tool.serverCode,
|
|
|
|
|
|
toolName: tool.toolName,
|
|
|
|
|
|
title: tool.title || tool.toolName,
|
|
|
|
|
|
description: tool.description || "",
|
|
|
|
|
|
arguments: undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
})),
|
2026-04-09 10:01:23 +08:00
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error(
|
|
|
|
|
|
error instanceof Error ? error.message : "加载 Direct Tools 失败",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadDirectToolOptions();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
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) =>
|
2026-04-10 14:43:57 +08:00
|
|
|
|
option.groupLabel === directToolGroupToAdd &&
|
2026-04-09 15:36:20 +08:00
|
|
|
|
!directTools.some((tool) => tool.toolCode === option.value),
|
2026-04-09 10:01:23 +08:00
|
|
|
|
),
|
2026-04-10 14:43:57 +08:00
|
|
|
|
[directToolOptions, directToolGroupToAdd, directTools],
|
2026-04-09 10:01:23 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-10 14:43:57 +08:00
|
|
|
|
const directToolGroupOptions = useMemo(
|
2026-04-09 10:01:23 +08:00
|
|
|
|
() =>
|
|
|
|
|
|
Array.from(
|
|
|
|
|
|
new Map(
|
|
|
|
|
|
directToolOptions.map((option) => [
|
2026-04-10 14:43:57 +08:00
|
|
|
|
option.groupLabel,
|
2026-04-09 10:01:23 +08:00
|
|
|
|
{
|
2026-04-10 14:43:57 +08:00
|
|
|
|
value: option.groupLabel,
|
|
|
|
|
|
label: option.groupLabel,
|
2026-04-09 10:01:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
]),
|
|
|
|
|
|
).values(),
|
|
|
|
|
|
),
|
|
|
|
|
|
[directToolOptions],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-10 14:43:57 +08:00
|
|
|
|
const directToolsGrouped = useMemo(() => {
|
|
|
|
|
|
const groups = new Map<string, DirectToolItem[]>();
|
2026-04-09 10:01:23 +08:00
|
|
|
|
for (const tool of directTools) {
|
2026-04-10 14:43:57 +08:00
|
|
|
|
const groupLabel =
|
|
|
|
|
|
tool.serverCode === "builtin" || tool.toolCode.startsWith("builtin/")
|
|
|
|
|
|
? "内置工具"
|
|
|
|
|
|
: tool.serverCode || "未分组";
|
|
|
|
|
|
const current = groups.get(groupLabel) ?? [];
|
2026-04-09 10:01:23 +08:00
|
|
|
|
current.push(tool);
|
2026-04-10 14:43:57 +08:00
|
|
|
|
groups.set(groupLabel, current);
|
2026-04-09 10:01:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
return Array.from(groups.entries());
|
|
|
|
|
|
}, [directTools]);
|
|
|
|
|
|
|
|
|
|
|
|
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 ??
|
|
|
|
|
|
"未选择";
|
|
|
|
|
|
|
|
|
|
|
|
async function onFormSubmit(values: EditForm) {
|
|
|
|
|
|
await onSubmit(
|
|
|
|
|
|
buildPayload(
|
|
|
|
|
|
values,
|
|
|
|
|
|
selectedKnowledgeIds,
|
|
|
|
|
|
selectedTeamIds,
|
|
|
|
|
|
selectedSkillIds,
|
|
|
|
|
|
directTools,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) =>
|
2026-04-09 15:36:20 +08:00
|
|
|
|
item.toolCode === option.meta.toolCode,
|
2026-04-09 10:01:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return prev;
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...prev, option.meta];
|
|
|
|
|
|
});
|
2026-04-10 14:43:57 +08:00
|
|
|
|
setDirectToolGroupToAdd(option.groupLabel);
|
2026-04-09 10:01:23 +08:00
|
|
|
|
setDirectToolToAdd("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleRemoveDirectTool(value: string) {
|
2026-04-09 15:36:20 +08:00
|
|
|
|
setDirectTools((prev) => prev.filter((item) => item.toolCode !== value));
|
2026-04-09 10:01:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ProjectDialog
|
|
|
|
|
|
open={open}
|
|
|
|
|
|
onOpenChange={onOpenChange}
|
|
|
|
|
|
title={itemId ? "编辑 AI Agent" : "新建 AI Agent"}
|
|
|
|
|
|
size="xl"
|
|
|
|
|
|
defaultFullscreen
|
|
|
|
|
|
footer={
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="submit" form={formId} disabled={saving || loading}>
|
|
|
|
|
|
{saving ? "保存中..." : "保存"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
|
<div className="text-muted-foreground">加载中...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<form
|
|
|
|
|
|
id={formId}
|
|
|
|
|
|
onSubmit={handleSubmit(onFormSubmit)}
|
|
|
|
|
|
className="space-y-6"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="基础信息"
|
|
|
|
|
|
description="定义这个 Agent 是谁、使用哪个模型、以什么服务模式工作。"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
|
|
|
|
|
<Field data-invalid={!!errors.name}>
|
|
|
|
|
|
<FieldLabel htmlFor="ai-agent-name">名称</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Input id="ai-agent-name" {...register("name")} />
|
|
|
|
|
|
<FieldError errors={[errors.name]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
|
|
<Field data-invalid={!!errors.aiConfigId}>
|
|
|
|
|
|
<FieldLabel>AI 配置</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Controller
|
|
|
|
|
|
control={control}
|
|
|
|
|
|
name="aiConfigId"
|
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
|
<OptionCombobox
|
|
|
|
|
|
value={field.value}
|
|
|
|
|
|
options={aiConfigOptions}
|
|
|
|
|
|
placeholder="请选择 AI 配置"
|
|
|
|
|
|
searchPlaceholder="搜索 AI 配置"
|
|
|
|
|
|
emptyText="未找到 AI 配置"
|
|
|
|
|
|
onChange={field.onChange}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<FieldError errors={[errors.aiConfigId]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
|
|
<Field data-invalid={!!errors.serviceMode}>
|
|
|
|
|
|
<FieldLabel>服务模式</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Controller
|
|
|
|
|
|
control={control}
|
|
|
|
|
|
name="serviceMode"
|
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
|
<OptionCombobox
|
|
|
|
|
|
value={field.value}
|
|
|
|
|
|
options={serviceModeOptions}
|
|
|
|
|
|
placeholder="请选择服务模式"
|
|
|
|
|
|
searchPlaceholder="搜索服务模式"
|
|
|
|
|
|
emptyText="未找到服务模式"
|
|
|
|
|
|
onChange={field.onChange}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<FieldError errors={[errors.serviceMode]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Field data-invalid={!!errors.description}>
|
|
|
|
|
|
<FieldLabel htmlFor="ai-agent-description">描述</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Textarea id="ai-agent-description" {...register("description")} />
|
|
|
|
|
|
<FieldError errors={[errors.description]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
|
|
<Field data-invalid={!!errors.remark}>
|
|
|
|
|
|
<FieldLabel htmlFor="ai-agent-remark">备注</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Textarea id="ai-agent-remark" rows={3} {...register("remark")} />
|
|
|
|
|
|
<FieldError errors={[errors.remark]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="能力配置"
|
|
|
|
|
|
description="知识库用于 RAG,Skills 用于业务流程,Direct Tools 用于低风险实时查询。"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
|
|
|
|
<div className="rounded-xl border bg-muted/10 p-4">
|
|
|
|
|
|
<div className="mb-1 text-sm font-medium">知识库</div>
|
|
|
|
|
|
<div className="mb-4 text-xs text-muted-foreground">
|
|
|
|
|
|
至少选择一个知识库,可拖动调整优先级。
|
|
|
|
|
|
</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="选择并添加知识库"
|
|
|
|
|
|
searchPlaceholder="搜索知识库"
|
|
|
|
|
|
emptyText="没有可添加的知识库"
|
|
|
|
|
|
onChange={handleAddKnowledge}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
disabled={!knowledgeToAdd}
|
|
|
|
|
|
onClick={() => handleAddKnowledge(knowledgeToAdd)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PlusIcon />
|
|
|
|
|
|
添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{selectedKnowledgeIds.length === 0 ? (
|
|
|
|
|
|
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
|
|
|
|
|
|
请至少选择一个知识库。
|
|
|
|
|
|
</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: "请至少选择一个知识库" }]} />
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rounded-xl border bg-muted/10 p-4">
|
|
|
|
|
|
<div className="mb-1 text-sm font-medium">Skills</div>
|
|
|
|
|
|
<div className="mb-4 text-xs text-muted-foreground">
|
|
|
|
|
|
用于固定业务流程和多步任务编排。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field>
|
|
|
|
|
|
<FieldContent className="space-y-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<OptionCombobox
|
|
|
|
|
|
value={skillToAdd}
|
|
|
|
|
|
options={addableSkillOptions}
|
|
|
|
|
|
placeholder="选择并添加 Skill"
|
|
|
|
|
|
searchPlaceholder="搜索 Skill"
|
|
|
|
|
|
emptyText="没有可添加的 Skill"
|
|
|
|
|
|
onChange={handleAddSkill}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
disabled={!skillToAdd}
|
|
|
|
|
|
onClick={() => handleAddSkill(skillToAdd)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PlusIcon />
|
|
|
|
|
|
添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{selectedSkillOptions.length === 0 ? (
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
|
不绑定 Skill 时,自动路由只会走知识库或转人工。
|
|
|
|
|
|
</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={`移除 Skill ${option.label}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2Icon className="size-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="rounded-xl border bg-muted/10 p-4">
|
2026-04-10 14:43:57 +08:00
|
|
|
|
<div className="mb-1 text-sm font-medium">Direct Tools</div>
|
2026-04-09 10:01:23 +08:00
|
|
|
|
<div className="mb-4 text-xs text-muted-foreground">
|
2026-04-10 14:43:57 +08:00
|
|
|
|
用于低风险、原子化的实时查询。可选择 MCP 工具,也可选择系统内置工具。
|
2026-04-10 15:41:02 +08:00
|
|
|
|
`tool_search` 会由 Runtime 自动注入,这里不需要手动添加。
|
2026-04-09 10:01:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<Field>
|
|
|
|
|
|
<FieldContent className="space-y-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="w-52">
|
|
|
|
|
|
<OptionCombobox
|
2026-04-10 14:43:57 +08:00
|
|
|
|
value={directToolGroupToAdd}
|
|
|
|
|
|
options={directToolGroupOptions}
|
|
|
|
|
|
placeholder="选择工具分组"
|
|
|
|
|
|
searchPlaceholder="搜索工具分组"
|
|
|
|
|
|
emptyText="没有可用的工具分组"
|
2026-04-09 10:01:23 +08:00
|
|
|
|
onChange={(value) => {
|
2026-04-10 14:43:57 +08:00
|
|
|
|
setDirectToolGroupToAdd(value);
|
2026-04-09 10:01:23 +08:00
|
|
|
|
setDirectToolToAdd("");
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<OptionCombobox
|
|
|
|
|
|
value={directToolToAdd}
|
|
|
|
|
|
options={addableDirectToolOptions}
|
2026-04-10 14:43:57 +08:00
|
|
|
|
placeholder="选择 Direct Tool"
|
2026-04-09 10:01:23 +08:00
|
|
|
|
searchPlaceholder="搜索 Direct Tool"
|
|
|
|
|
|
emptyText="没有可添加的 Direct Tool"
|
|
|
|
|
|
onChange={handleAddDirectTool}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
disabled={
|
2026-04-10 14:43:57 +08:00
|
|
|
|
!directToolGroupToAdd || !directToolToAdd
|
2026-04-09 10:01:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
onClick={() => handleAddDirectTool(directToolToAdd)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<PlusIcon />
|
|
|
|
|
|
添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{directTools.length === 0 ? (
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
2026-04-10 14:43:57 +08:00
|
|
|
|
不配置 Direct Tool 时,Agent 不会直接调用外部或内置工具,只会依赖知识库、Skill 和普通回复。
|
2026-04-09 10:01:23 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
2026-04-10 14:43:57 +08:00
|
|
|
|
directToolsGrouped.map(([groupLabel, tools]) => (
|
2026-04-09 10:01:23 +08:00
|
|
|
|
<div
|
2026-04-10 14:43:57 +08:00
|
|
|
|
key={groupLabel}
|
2026-04-09 10:01:23 +08:00
|
|
|
|
className="rounded-md border p-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
2026-04-10 14:43:57 +08:00
|
|
|
|
{groupLabel}
|
2026-04-09 10:01:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{tools.map((tool) => {
|
2026-04-09 15:36:20 +08:00
|
|
|
|
const value = tool.toolCode;
|
|
|
|
|
|
const catalogItem = toolCatalog.find(
|
|
|
|
|
|
(item) => item.toolCode === tool.toolCode,
|
|
|
|
|
|
);
|
2026-04-09 10:01:23 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
key={value}
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
className="gap-1 pr-1"
|
|
|
|
|
|
>
|
2026-04-09 15:36:20 +08:00
|
|
|
|
{tool.title || catalogItem?.title || value}
|
2026-04-10 14:43:57 +08:00
|
|
|
|
<span className="text-[10px] text-muted-foreground/80">
|
|
|
|
|
|
{catalogItem?.sourceType === "builtin"
|
|
|
|
|
|
? "内置"
|
|
|
|
|
|
: tool.serverCode || "MCP"}
|
|
|
|
|
|
</span>
|
2026-04-09 10:01:23 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
className="size-5"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
handleRemoveDirectTool(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
aria-label={`移除 Direct Tool ${value}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2Icon className="size-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="服务策略"
|
2026-04-10 16:03:06 +08:00
|
|
|
|
description="控制 Graph Tool 转人工配置、兜底策略和自动回复边界。"
|
2026-04-09 10:01:23 +08:00
|
|
|
|
>
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-4">
|
|
|
|
|
|
<Field data-invalid={!!errors.handoffMode}>
|
|
|
|
|
|
<FieldLabel>转人工模式</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Controller
|
|
|
|
|
|
control={control}
|
|
|
|
|
|
name="handoffMode"
|
|
|
|
|
|
render={({ field }) => (
|
|
|
|
|
|
<OptionCombobox
|
|
|
|
|
|
value={field.value}
|
|
|
|
|
|
options={handoffModeOptions}
|
|
|
|
|
|
placeholder="请选择转人工模式"
|
|
|
|
|
|
searchPlaceholder="搜索转人工模式"
|
|
|
|
|
|
emptyText="未找到转人工模式"
|
|
|
|
|
|
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"
|
|
|
|
|
|
>
|
|
|
|
|
|
回复超时秒数
|
|
|
|
|
|
<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>
|
|
|
|
|
|
AI 自动回复的异步执行超时时间。填 0 时使用系统默认值 180 秒。
|
|
|
|
|
|
</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-xl border bg-muted/10 p-4">
|
|
|
|
|
|
<div className="mb-1 text-sm font-medium">客服组</div>
|
|
|
|
|
|
<div className="mb-4 text-xs text-muted-foreground">
|
|
|
|
|
|
当前转人工模式:{selectedHandoffModeLabel}
|
|
|
|
|
|
{handoffMode ===
|
|
|
|
|
|
String(AIAgentHandoffMode.DefaultTeamPool)
|
|
|
|
|
|
? "。该模式要求至少配置一个客服组。"
|
|
|
|
|
|
: "。仅在涉及转人工时生效。"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Field>
|
|
|
|
|
|
<FieldContent className="space-y-3">
|
|
|
|
|
|
<OptionCombobox
|
|
|
|
|
|
value={teamToAdd}
|
|
|
|
|
|
options={addableTeamOptions}
|
|
|
|
|
|
placeholder="请选择客服组"
|
|
|
|
|
|
searchPlaceholder="搜索客服组"
|
|
|
|
|
|
emptyText="未找到客服组"
|
|
|
|
|
|
onChange={handleAddTeam}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{selectedTeamOptions.length === 0 ? (
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
|
未配置客服组
|
|
|
|
|
|
</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={`移除客服组 ${option.label}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2Icon className="size-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
|
title="话术配置"
|
|
|
|
|
|
description="配置用户能直接看到的欢迎语、兜底文案,以及影响整体回复风格的系统提示词。"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Field data-invalid={!!errors.welcomeMessage}>
|
|
|
|
|
|
<FieldLabel htmlFor="ai-agent-welcome-message">欢迎语</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="ai-agent-welcome-message"
|
|
|
|
|
|
rows={5}
|
|
|
|
|
|
{...register("welcomeMessage")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<FieldError errors={[errors.welcomeMessage]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
|
|
<Field data-invalid={!!errors.fallbackMessage}>
|
|
|
|
|
|
<FieldLabel htmlFor="ai-agent-fallback-message">
|
|
|
|
|
|
兜底文案
|
|
|
|
|
|
</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<div className="text-xs text-muted-foreground mb-1">
|
2026-04-10 16:03:06 +08:00
|
|
|
|
仅在兜底模式为“直接声明无答案”或“引导补充信息”时使用。转人工已统一改为 AI Graph Tool,不再通过兜底模式直接触发。
|
2026-04-09 10:01:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="ai-agent-fallback-message"
|
|
|
|
|
|
rows={5}
|
|
|
|
|
|
{...register("fallbackMessage")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<FieldError errors={[errors.fallbackMessage]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
|
|
<Field data-invalid={!!errors.systemPrompt}>
|
|
|
|
|
|
<FieldLabel htmlFor="ai-agent-system-prompt">
|
|
|
|
|
|
系统提示词
|
|
|
|
|
|
</FieldLabel>
|
|
|
|
|
|
<FieldContent>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="ai-agent-system-prompt"
|
|
|
|
|
|
rows={8}
|
|
|
|
|
|
{...register("systemPrompt")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<FieldError errors={[errors.systemPrompt]} />
|
|
|
|
|
|
</FieldContent>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</ProjectDialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SectionCard({
|
|
|
|
|
|
title,
|
|
|
|
|
|
description,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
description: string;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="rounded-xl border bg-card p-5">
|
|
|
|
|
|
<div className="mb-4">
|
|
|
|
|
|
<div className="text-base font-semibold">{title}</div>
|
|
|
|
|
|
<div className="mt-1 text-sm text-muted-foreground">{description}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-4">{children}</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|