diff --git a/web/app/dashboard/ai-agents/_components/config-workbench.tsx b/web/app/dashboard/ai-agents/_components/config-workbench.tsx new file mode 100644 index 0000000..2fd5294 --- /dev/null +++ b/web/app/dashboard/ai-agents/_components/config-workbench.tsx @@ -0,0 +1,853 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowUpIcon, + BotMessageSquareIcon, + BrainCircuitIcon, + CheckCircle2Icon, + DatabaseIcon, + GitBranchIcon, + LifeBuoyIcon, + PlugIcon, + SaveIcon, + SendIcon, + SettingsIcon, + ShieldCheckIcon, + Trash2Icon, +} from "lucide-react" +import { toast } from "sonner" + +import { OptionCombobox } from "@/components/option-combobox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + fetchAIAgent, + fetchAIAgentWorkflow, + fetchAIConfigsAll, + fetchAIWorkflowNodeSpecs, + fetchAgentTeamsAll, + fetchKnowledgeBasesAll, + fetchMCPCatalog, + fetchSkillDefinitionsAll, + publishAIAgentWorkflow, + saveAIAgentWorkflow, + updateAIAgent, + validateAIWorkflow, + type AIAgent, + type AIConfig, + type AIWorkflow, + type AIWorkflowDefinition, + type AIWorkflowNodeSpec, + type AIWorkflowValidationResult, + type AdminAgentTeam, + type CreateAIAgentPayload, + type KnowledgeBase, + type MCPToolCatalogItem, + type MCPToolSourceType, + type SkillDefinition, +} from "@/lib/api/admin" +import { + AIAgentFallbackMode, + AIAgentHandoffMode, + AIModelType, + IMConversationServiceMode, + Status, +} from "@/lib/generated/enums" +import { WorkflowEditor } from "../../ai-workflows/_components/workflow-editor" + +type DirectToolItem = CreateAIAgentPayload["directTools"][number] + +type DirectToolOption = { + value: string + label: string + meta: DirectToolItem + sourceType: MCPToolSourceType + groupLabel: string +} + +type SectionKey = + | "basic" + | "model" + | "knowledge" + | "skills" + | "tools" + | "workflow" + | "handoff" + | "publish" + +const emptyDefinition: AIWorkflowDefinition = { + schemaVersion: 1, + entryNodeId: "start_1", + nodes: [ + { + id: "start_1", + type: "start", + name: "Start", + position: { x: 0, y: 80 }, + config: {}, + }, + { + id: "end_1", + type: "end", + name: "End", + position: { x: 360, y: 80 }, + config: {}, + }, + ], + edges: [{ id: "edge_start_end", source: "start_1", target: "end_1" }], +} + +function toText(value: string | number | undefined | null) { + if (value === undefined || value === null || value === 0) return "" + return String(value) +} + +function uniqueNumbers(input: number[]) { + return Array.from(new Set(input.filter((id) => Number.isFinite(id) && id > 0))) +} + +export function AIAgentConfigWorkbench({ + agentId, + onClose, + onAgentSaved, +}: { + agentId: number + onClose: () => void + onAgentSaved?: () => void +}) { + const [activeSection, setActiveSection] = useState("basic") + const [agent, setAgent] = useState(null) + const [workflow, setWorkflow] = useState(null) + const [nodeSpecs, setNodeSpecs] = useState([]) + const [validation, setValidation] = useState(null) + const [loading, setLoading] = useState(true) + const [savingAgent, setSavingAgent] = useState(false) + const [savingWorkflow, setSavingWorkflow] = useState(false) + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [aiConfigId, setAIConfigId] = useState("") + const [serviceMode, setServiceMode] = useState(String(IMConversationServiceMode.AIFirst)) + const [systemPrompt, setSystemPrompt] = useState("") + const [welcomeMessage, setWelcomeMessage] = useState("") + const [replyTimeoutSeconds, setReplyTimeoutSeconds] = useState("180") + const [handoffMode, setHandoffMode] = useState(String(AIAgentHandoffMode.WaitPool)) + const [fallbackMode, setFallbackMode] = useState(String(AIAgentFallbackMode.NoAnswer)) + const [fallbackMessage, setFallbackMessage] = useState("") + const [selectedKnowledgeIds, setSelectedKnowledgeIds] = useState([]) + const [selectedTeamIds, setSelectedTeamIds] = useState([]) + const [selectedSkillIds, setSelectedSkillIds] = useState([]) + const [directTools, setDirectTools] = useState([]) + + const [workflowName, setWorkflowName] = useState("") + const [workflowDescription, setWorkflowDescription] = useState("") + const [definition, setDefinition] = useState(emptyDefinition) + + const [aiConfigs, setAIConfigs] = useState([]) + const [knowledgeBases, setKnowledgeBases] = useState([]) + const [agentTeams, setAgentTeams] = useState([]) + const [skills, setSkills] = useState([]) + const [toolCatalog, setToolCatalog] = useState([]) + const [knowledgeToAdd, setKnowledgeToAdd] = useState("") + const [teamToAdd, setTeamToAdd] = useState("") + const [skillToAdd, setSkillToAdd] = useState("") + const [directToolGroupToAdd, setDirectToolGroupToAdd] = useState("") + const [directToolToAdd, setDirectToolToAdd] = useState("") + + const editorKey = useMemo( + () => `${workflow?.id ?? "new"}-${workflow?.updatedAt ?? ""}`, + [workflow?.id, workflow?.updatedAt] + ) + + const loadData = useCallback(async () => { + if (!Number.isFinite(agentId) || agentId <= 0) { + setLoading(false) + return + } + setLoading(true) + try { + const [ + agentDetail, + workflowDetail, + specs, + configs, + bases, + teams, + skillList, + catalog, + ] = await Promise.all([ + fetchAIAgent(agentId), + fetchAIAgentWorkflow(agentId), + fetchAIWorkflowNodeSpecs(), + fetchAIConfigsAll({ modelType: AIModelType.LLM }), + fetchKnowledgeBasesAll({ status: Status.Ok }), + fetchAgentTeamsAll(), + fetchSkillDefinitionsAll({ status: Status.Ok }), + fetchMCPCatalog(), + ]) + + setAgent(agentDetail) + setWorkflow(workflowDetail) + setNodeSpecs(specs ?? []) + setAIConfigs(configs ?? []) + setKnowledgeBases(bases ?? []) + setAgentTeams(teams ?? []) + setSkills(skillList ?? []) + setToolCatalog(catalog ?? []) + setName(agentDetail.name) + setDescription(agentDetail.description || "") + setAIConfigId(toText(agentDetail.aiConfigId)) + setServiceMode(String(agentDetail.serviceMode || IMConversationServiceMode.AIFirst)) + setSystemPrompt(agentDetail.systemPrompt || "") + setWelcomeMessage(agentDetail.welcomeMessage || "") + setReplyTimeoutSeconds(String(agentDetail.replyTimeoutSeconds ?? 180)) + setHandoffMode(String(agentDetail.handoffMode || AIAgentHandoffMode.WaitPool)) + setFallbackMode(String(agentDetail.fallbackMode || AIAgentFallbackMode.NoAnswer)) + setFallbackMessage(agentDetail.fallbackMessage || "") + setSelectedKnowledgeIds(agentDetail.knowledgeIds ?? []) + setSelectedTeamIds((agentDetail.teams ?? []).map((team) => team.id)) + setSelectedSkillIds(agentDetail.skillIds ?? []) + setDirectTools(agentDetail.directTools ?? []) + setWorkflowName(workflowDetail.name || `${agentDetail.name} 会话流程`) + setWorkflowDescription(workflowDetail.description || "") + setDefinition(workflowDetail.draftDefinition ?? emptyDefinition) + setValidation(null) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to load Agent config") + } finally { + setLoading(false) + } + }, [agentId]) + + useEffect(() => { + void loadData() + }, [loadData]) + + const serviceModeOptions = useMemo( + () => [ + { value: String(IMConversationServiceMode.AIOnly), label: "仅 AI" }, + { value: String(IMConversationServiceMode.HumanOnly), label: "仅人工" }, + { value: String(IMConversationServiceMode.AIFirst), label: "AI 优先" }, + ], + [] + ) + const handoffModeOptions = useMemo( + () => [ + { value: String(AIAgentHandoffMode.WaitPool), label: "进入待接入池" }, + { value: String(AIAgentHandoffMode.DefaultTeamPool), label: "进入默认客服组待接入池" }, + { value: String(AIAgentHandoffMode.AIHoldAndNotify), label: "AI托底并提醒人工" }, + ], + [] + ) + const fallbackModeOptions = useMemo( + () => [ + { value: String(AIAgentFallbackMode.NoAnswer), label: "直接声明无答案" }, + { value: String(AIAgentFallbackMode.SuggestRetry), label: "建议补充信息" }, + ], + [] + ) + const aiConfigOptions = useMemo( + () => aiConfigs.map((item) => ({ value: String(item.id), label: `${item.name} · ${item.modelName}` })), + [aiConfigs] + ) + const knowledgeOptions = useMemo( + () => knowledgeBases.map((item) => ({ value: String(item.id), label: item.name })), + [knowledgeBases] + ) + const teamOptions = useMemo( + () => agentTeams.map((item) => ({ value: String(item.id), label: item.name })), + [agentTeams] + ) + const skillOptions = useMemo( + () => skills.map((item) => ({ value: String(item.id), label: item.name })), + [skills] + ) + const directToolOptions = useMemo( + () => + toolCatalog + .filter((tool) => !tool.autoInjected && tool.sourceType === "mcp") + .map((tool) => ({ + value: tool.toolCode, + label: `${tool.title || tool.toolName} · ${tool.toolCode}`, + sourceType: tool.sourceType, + 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, + }, + })), + [toolCatalog] + ) + const directToolGroupOptions = useMemo( + () => + Array.from( + new Map( + directToolOptions.map((option) => [ + option.groupLabel, + { value: option.groupLabel, label: option.groupLabel }, + ]) + ).values() + ), + [directToolOptions] + ) + const addableDirectToolOptions = useMemo( + () => + directToolOptions.filter( + (option) => + option.groupLabel === directToolGroupToAdd && + !directTools.some((tool) => tool.toolCode === option.value) + ), + [directToolGroupToAdd, directToolOptions, directTools] + ) + + function selectedOptions(ids: number[], options: { value: string; label: string }[]) { + return ids + .map((id) => options.find((option) => Number(option.value) === id)) + .filter((option): option is { value: string; label: string } => !!option) + } + + function addSelected(value: string, current: number[], setNext: (ids: number[]) => void) { + const id = Number(value) + if (!Number.isFinite(id) || id <= 0 || current.includes(id)) return + setNext([...current, id]) + } + + function moveKnowledge(index: number, direction: -1 | 1) { + const targetIndex = index + direction + if (targetIndex < 0 || targetIndex >= selectedKnowledgeIds.length) return + const next = [...selectedKnowledgeIds] + const current = next[index] + next[index] = next[targetIndex] + next[targetIndex] = current + setSelectedKnowledgeIds(next) + } + + function addDirectTool(value: string) { + const option = directToolOptions.find((item) => item.value === value) + if (!option) return + setDirectTools((current) => + current.some((tool) => tool.toolCode === option.meta.toolCode) + ? current + : [...current, option.meta] + ) + setDirectToolToAdd("") + } + + function buildPayload(): CreateAIAgentPayload { + return { + name: name.trim(), + description: description.trim(), + aiConfigId: Number(aiConfigId), + serviceMode: Number(serviceMode), + systemPrompt: systemPrompt.trim(), + welcomeMessage: welcomeMessage.trim(), + replyTimeoutSeconds: Number(replyTimeoutSeconds), + teamIds: uniqueNumbers(selectedTeamIds), + handoffMode: Number(handoffMode), + fallbackMode: Number(fallbackMode), + fallbackMessage: fallbackMessage.trim(), + knowledgeIds: uniqueNumbers(selectedKnowledgeIds), + skillIds: uniqueNumbers(selectedSkillIds), + directTools, + graphTools: [], + } + } + + async function saveAgentSettings() { + if (!agent) return + setSavingAgent(true) + try { + const payload = buildPayload() + await updateAIAgent({ id: agent.id, ...payload }) + toast.success("Agent config saved") + await loadData() + onAgentSaved?.() + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to save Agent config") + } finally { + setSavingAgent(false) + } + } + + async function saveWorkflowDraft() { + setSavingWorkflow(true) + try { + const saved = await saveAIAgentWorkflow({ + agentId, + name: workflowName, + description: workflowDescription, + definition, + }) + setWorkflow(saved) + toast.success("Workflow draft saved") + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to save workflow draft") + } finally { + setSavingWorkflow(false) + } + } + + async function validateWorkflowDraft() { + setSavingWorkflow(true) + try { + const result = await validateAIWorkflow(definition) + setValidation(result) + toast[result.valid ? "success" : "error"]( + result.valid ? "Workflow is valid" : "Workflow has validation errors" + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to validate workflow") + } finally { + setSavingWorkflow(false) + } + } + + async function publishWorkflow() { + setSavingWorkflow(true) + try { + await saveAIAgentWorkflow({ + agentId, + name: workflowName, + description: workflowDescription, + definition, + }) + const version = await publishAIAgentWorkflow(agentId, definition) + toast.success(`Published version ${version.version}`) + await loadData() + onAgentSaved?.() + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to publish workflow") + } finally { + setSavingWorkflow(false) + } + } + + const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [ + { key: "basic", title: "基础信息", icon: }, + { key: "model", title: "模型与 Prompt", icon: }, + { key: "knowledge", title: "知识库", icon: }, + { key: "skills", title: "Skills", icon: }, + { key: "tools", title: "工具能力", icon: }, + { key: "workflow", title: "会话流程", icon: }, + { key: "handoff", title: "转人工与兜底", icon: }, + { key: "publish", title: "发布状态", icon: }, + ] + + const selectedKnowledgeOptions = selectedOptions(selectedKnowledgeIds, knowledgeOptions) + const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions) + const selectedSkillOptions = selectedOptions(selectedSkillIds, skillOptions) + + return ( +
+
+
+ +
+ +
+
+

{agent?.name ?? "AI Agent 配置"}

+
+ Agent #{agentId || "-"} + {agent?.statusName ? {agent.statusName} : null} + {agent?.workflowVersionId ? 已发布流程 : 草稿流程} +
+
+
+
+ + +
+
+ +
+ + +
+ {loading ? ( +
+ 加载中... +
+ ) : ( +
+ {activeSection === "basic" ? ( + +
+ + setName(event.target.value)} /> + + + + +
+ +