From 34d70e769f17e0989a8ad06b2365589a998086cb Mon Sep 17 00:00:00 2001 From: mlogclub Date: Thu, 25 Jun 2026 18:28:16 +0800 Subject: [PATCH] feat: enhance AI workflow with new ticket creation and fallback handling logic --- internal/ai/runtime/workflow/executor.go | 27 +++ internal/ai/runtime/workflow/executor_test.go | 53 +++++ internal/ai/workflow/registry/registry.go | 14 +- .../ai/workflow/validator/validator_test.go | 24 ++ .../ai_agent_workflow_service_test.go | 83 ++++++- internal/services/ai_workflow_service.go | 93 +++++++- .../_components/config-workbench.tsx | 216 +++++++++++++++++- web/lib/api/admin.ts | 5 +- 8 files changed, 490 insertions(+), 25 deletions(-) diff --git a/internal/ai/runtime/workflow/executor.go b/internal/ai/runtime/workflow/executor.go index f347361..ef4103c 100644 --- a/internal/ai/runtime/workflow/executor.go +++ b/internal/ai/runtime/workflow/executor.go @@ -17,6 +17,7 @@ import ( "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/utils" "agent-desk/internal/services" ) @@ -311,10 +312,22 @@ func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error { "ticketId": item.ID, "ticketNo": item.TicketNo, "created": true, + "message": buildTicketCreatedMessage(item), }) return nil } +func buildTicketCreatedMessage(item *models.Ticket) string { + if item == nil { + return "工单已创建。" + } + ticketNo := strings.TrimSpace(item.TicketNo) + if ticketNo == "" { + return fmt.Sprintf("工单已创建,工单 ID:%d。", item.ID) + } + return "工单已创建,工单号:" + ticketNo + "。" +} + func workflowAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal { username := strings.TrimSpace(aiAgent.Name) if username == "" { @@ -543,6 +556,10 @@ func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node ds if prompt := strings.TrimSpace(readStringConfig(node.Config, "prompt")); prompt != "" { systemPrompt = strings.TrimSpace(systemPrompt + "\n\n" + prompt) } + if _, declaresKnowledge := node.Inputs["knowledgeItems"]; declaresKnowledge && len(utils.SplitInt64s(state.input.AIAgent.KnowledgeIDs)) > 0 && !hasItems(state.resolveInput(node, "knowledgeItems")) { + state.setNodeVars(node.ID, map[string]any{"replyText": workflowKnowledgeFallbackReply(state.input.AIAgent)}) + return nil + } if knowledgeItems != "" { userPrompt = userPrompt + "\n\nKnowledge context:\n" + knowledgeItems } @@ -556,6 +573,16 @@ func (e *Executor) executeLLMReply(ctx context.Context, state *runState, node ds return nil } +func workflowKnowledgeFallbackReply(aiAgent models.AIAgent) string { + if reply := strings.TrimSpace(aiAgent.FallbackMessage); reply != "" { + return reply + } + if aiAgent.FallbackMode == enums.AIAgentFallbackModeSuggestRetry { + return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。" + } + return "当前知识库暂无明确信息。" +} + func (s *runState) nextNodeID(sourceNodeID string) (string, bool, error) { edges := s.outgoing[sourceNodeID] if len(edges) == 0 { diff --git a/internal/ai/runtime/workflow/executor_test.go b/internal/ai/runtime/workflow/executor_test.go index 91a662d..111d88b 100644 --- a/internal/ai/runtime/workflow/executor_test.go +++ b/internal/ai/runtime/workflow/executor_test.go @@ -228,6 +228,31 @@ func TestExecutorPrepareTicketDraftOutputsDraftVariable(t *testing.T) { assertPath(t, result.NodePath, []string{"start_1", "draft_1", "ready_end"}) } +func TestExecutorLLMReplyUsesAgentFallbackWhenDeclaredKnowledgeIsEmpty(t *testing.T) { + result, err := NewExecutor().Execute(context.Background(), Input{ + Definition: emptyKnowledgeReplyDefinition(), + UserMessage: models.Message{ + Content: "产品功能", + }, + AIAgent: models.AIAgent{ + KnowledgeIDs: "1", + FallbackMode: enums.AIAgentFallbackModeNoAnswer, + FallbackMessage: "我暂时没有找到足够准确的信息。你可以补充更具体的问题,我再继续帮你查。", + SystemPrompt: "不要编造事实。", + }, + AIConfig: models.AIConfig{ + ModelName: "should-not-be-called", + }, + }) + if err != nil { + t.Fatalf("execute workflow: %v", err) + } + if result.ReplyText != "我暂时没有找到足够准确的信息。你可以补充更具体的问题,我再继续帮你查。" { + t.Fatalf("expected fallback reply, got %q", result.ReplyText) + } + assertPath(t, result.NodePath, []string{"start_1", "reply_1", "send_1", "end_1"}) +} + func TestExecutorHumanConfirmInterruptsWithCheckpoint(t *testing.T) { result, err := NewExecutor().Execute(context.Background(), Input{ Definition: humanConfirmWorkflowDefinition(), @@ -333,6 +358,11 @@ func TestExecutorResumeCreatesTicketAfterHumanConfirmation(t *testing.T) { if ticket.Title == "" || !strings.Contains(ticket.Description, "订单支付失败") { t.Fatalf("unexpected ticket: %+v", ticket) } + + trace := findNodeTrace(result.NodeTraces, "create_ticket_1") + if trace == nil || !strings.Contains(trace.OutputPreview, "工单已创建") { + t.Fatalf("expected create_ticket output to include customer-visible result message, got %#v", trace) + } } func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace { @@ -344,6 +374,29 @@ func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace { return nil } +func emptyKnowledgeReplyDefinition() dsl.Definition { + return dsl.Definition{ + SchemaVersion: 1, + EntryNodeID: "start_1", + Nodes: []dsl.Node{ + {ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "Start"}, + {ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "Reply", Inputs: map[string]dsl.VariableSelector{ + "userMessage": {NodeID: "start_1", Field: "userMessage"}, + "knowledgeItems": {NodeID: "missing_retrieve", Field: "items"}, + }}, + {ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "Send", Inputs: map[string]dsl.VariableSelector{ + "replyText": {NodeID: "reply_1", Field: "replyText"}, + }}, + {ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "End"}, + }, + Edges: []dsl.Edge{ + {ID: "edge_start_reply", Source: "start_1", Target: "reply_1"}, + {ID: "edge_reply_send", Source: "reply_1", Target: "send_1"}, + {ID: "edge_send_end", Source: "send_1", Target: "end_1"}, + }, + } +} + func conditionalReplyDefinition() dsl.Definition { return dsl.Definition{ SchemaVersion: 1, diff --git a/internal/ai/workflow/registry/registry.go b/internal/ai/workflow/registry/registry.go index 102b9e8..7b74518 100644 --- a/internal/ai/workflow/registry/registry.go +++ b/internal/ai/workflow/registry/registry.go @@ -137,17 +137,19 @@ func DefaultRegistry() *Registry { }, OutputSchema: []VariableSpec{ output("ticketId", VariableTypeInteger, "Created ticket ID."), + output("ticketNo", VariableTypeString, "Created ticket number."), + output("created", VariableTypeBoolean, "Whether the ticket was created."), + output("message", VariableTypeString, "Customer-visible ticket creation result."), }, }, NodeSpec{ - Type: NodeTypeHandoffToHuman, - Title: "Handoff To Human", - Description: "Transfer the conversation to human support.", - RiskLevel: NodeRiskLevelHigh, - RequiresConfirmationPredecessor: true, + Type: NodeTypeHandoffToHuman, + Title: "Handoff To Human", + Description: "Transfer the conversation to human support.", + RiskLevel: NodeRiskLevelHigh, InputSchema: []VariableSpec{ requiredInput("reason", VariableTypeString, "Handoff reason."), - requiredInput("confirmed", VariableTypeBoolean, "Confirmation result."), + optionalInput("confirmed", VariableTypeBoolean, "Confirmation result."), }, OutputSchema: []VariableSpec{ output("handoffId", VariableTypeInteger, "Handoff operation ID."), diff --git a/internal/ai/workflow/validator/validator_test.go b/internal/ai/workflow/validator/validator_test.go index 1a9c5d8..f744dd1 100644 --- a/internal/ai/workflow/validator/validator_test.go +++ b/internal/ai/workflow/validator/validator_test.go @@ -110,6 +110,30 @@ func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) { } } +func TestValidateDefinitionAcceptsDirectHandoffToHuman(t *testing.T) { + def := dsl.Definition{ + SchemaVersion: 1, + EntryNodeID: "start_1", + Nodes: []dsl.Node{ + {ID: "start_1", Type: "start"}, + {ID: "handoff_1", Type: "handoff_to_human", Inputs: map[string]dsl.VariableSelector{ + "reason": {NodeID: "start_1", Field: "userMessage"}, + }}, + {ID: "end_1", Type: "end"}, + }, + Edges: []dsl.Edge{ + {ID: "e1", Source: "start_1", Target: "handoff_1"}, + {ID: "e2", Source: "handoff_1", Target: "end_1"}, + }, + } + + result := validator.ValidateDefinition(def, registry.DefaultRegistry()) + + if !result.Valid { + t.Fatalf("expected direct handoff_to_human to be valid, got %#v", result.Errors) + } +} + func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) { def := dsl.Definition{ SchemaVersion: 1, diff --git a/internal/services/ai_agent_workflow_service_test.go b/internal/services/ai_agent_workflow_service_test.go index e52b983..d1c32fb 100644 --- a/internal/services/ai_agent_workflow_service_test.go +++ b/internal/services/ai_agent_workflow_service_test.go @@ -5,6 +5,8 @@ import ( "testing" "agent-desk/internal/ai/workflow/dsl" + workflowregistry "agent-desk/internal/ai/workflow/registry" + workflowvalidator "agent-desk/internal/ai/workflow/validator" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" @@ -50,11 +52,33 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { if stored.EntryNodeID == "" { t.Fatalf("expected default draft definition") } - if len(stored.Nodes) < 5 { - t.Fatalf("expected product-ready default workflow, got nodes: %#v", stored.Nodes) + validation := workflowvalidator.ValidateDefinition(stored, workflowregistry.DefaultRegistry()) + if !validation.Valid { + t.Fatalf("expected default workflow to be valid, got %#v", validation.Errors) } - if !workflowHasNodeType(stored, "knowledge_retrieve") || !workflowHasNodeType(stored, "llm_reply") || !workflowHasNodeType(stored, "send_reply") { - t.Fatalf("expected default workflow to include retrieve, llm reply, and send reply nodes: %#v", stored.Nodes) + if nodeTypeByID(stored, "route_intent_1") != workflowregistry.NodeTypeCondition { + t.Fatalf("expected default workflow to start with a clear intent router, got nodes: %#v", stored.Nodes) + } + for _, nodeType := range []string{ + workflowregistry.NodeTypeHandoffToHuman, + workflowregistry.NodeTypePrepareTicketDraft, + workflowregistry.NodeTypeHumanConfirm, + workflowregistry.NodeTypeCreateTicket, + workflowregistry.NodeTypeKnowledgeRetrieve, + workflowregistry.NodeTypeAnswerabilityGate, + workflowregistry.NodeTypeLLMReply, + workflowregistry.NodeTypeSendReply, + } { + if !workflowHasNodeType(stored, nodeType) { + t.Fatalf("expected default workflow to include %s node: %#v", nodeType, stored.Nodes) + } + } + assertConditionEdgeToNodeType(t, stored, "route_intent_1", workflowregistry.NodeTypeHandoffToHuman, "contains", "人工") + assertConditionEdgeToNodeType(t, stored, "route_intent_1", workflowregistry.NodeTypePrepareTicketDraft, "contains", "工单") + assertConditionEdgeToNodeType(t, stored, "answerability_1", workflowregistry.NodeTypeLLMReply, "eq", "answerable") + assertDefaultEdgeToNodeType(t, stored, "answerability_1", workflowregistry.NodeTypeLLMReply) + if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") { + t.Fatalf("expected create_ticket to flow into a customer-visible result reply") } } @@ -181,3 +205,54 @@ func workflowHasNodeType(def dsl.Definition, nodeType string) bool { } return false } + +func nodeTypeByID(def dsl.Definition, nodeID string) string { + for _, node := range def.Nodes { + if node.ID == nodeID { + return node.Type + } + } + return "" +} + +func assertConditionEdgeToNodeType(t *testing.T, def dsl.Definition, sourceID string, targetType string, operator string, right any) { + t.Helper() + nodeTypes := workflowNodeTypeMap(def) + for _, edge := range def.Edges { + if edge.Source != sourceID || nodeTypes[edge.Target] != targetType || edge.Condition == nil { + continue + } + if edge.Condition.Operator == operator && edge.Condition.Right == right { + return + } + } + t.Fatalf("expected %s conditional edge from %s to %s with right=%v, got %#v", operator, sourceID, targetType, right, def.Edges) +} + +func assertDefaultEdgeToNodeType(t *testing.T, def dsl.Definition, sourceID string, targetType string) { + t.Helper() + nodeTypes := workflowNodeTypeMap(def) + for _, edge := range def.Edges { + if edge.Source == sourceID && nodeTypes[edge.Target] == targetType && edge.Condition == nil { + return + } + } + t.Fatalf("expected default edge from %s to %s, got %#v", sourceID, targetType, def.Edges) +} + +func workflowEdgeExists(def dsl.Definition, sourceID string, targetID string) bool { + for _, edge := range def.Edges { + if edge.Source == sourceID && edge.Target == targetID { + return true + } + } + return false +} + +func workflowNodeTypeMap(def dsl.Definition) map[string]string { + ret := make(map[string]string, len(def.Nodes)) + for _, node := range def.Nodes { + ret[node.ID] = node.Type + } + return ret +} diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index 0bfd262..be4a465 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -422,24 +422,103 @@ func defaultAgentWorkflowDefinition() dsl.Definition { SchemaVersion: 1, EntryNodeID: "start_1", Nodes: []dsl.Node{ - {ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 120}}, - {ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 260, Y: 120}, Inputs: map[string]dsl.VariableSelector{ + {ID: "start_1", Type: workflowregistry.NodeTypeStart, Name: "开始", Position: dsl.Position{X: 0, Y: 260}}, + {ID: "route_intent_1", Type: workflowregistry.NodeTypeCondition, Name: "意图分流", Position: dsl.Position{X: 260, Y: 260}}, + {ID: "handoff_1", Type: workflowregistry.NodeTypeHandoffToHuman, Name: "转人工", Position: dsl.Position{X: 560, Y: 80}, Inputs: map[string]dsl.VariableSelector{ + "reason": {NodeID: "start_1", Field: "userMessage"}, + }}, + {ID: "handoff_end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 860, Y: 80}}, + {ID: "draft_ticket_1", Type: workflowregistry.NodeTypePrepareTicketDraft, Name: "整理工单草稿", Position: dsl.Position{X: 560, Y: 240}, Inputs: map[string]dsl.VariableSelector{ + "issue": {NodeID: "start_1", Field: "userMessage"}, + }}, + {ID: "ticket_confirm_prompt_1", Type: workflowregistry.NodeTypeLLMReply, Name: "建单确认文案", Position: dsl.Position{X: 860, Y: 240}, Config: json.RawMessage(`{"staticReply":"我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。"}`), Inputs: map[string]dsl.VariableSelector{ + "userMessage": {NodeID: "start_1", Field: "userMessage"}, + }}, + {ID: "ticket_confirm_1", Type: workflowregistry.NodeTypeHumanConfirm, Name: "确认建单", Position: dsl.Position{X: 1160, Y: 240}, Inputs: map[string]dsl.VariableSelector{ + "prompt": {NodeID: "ticket_confirm_prompt_1", Field: "replyText"}, + }}, + {ID: "create_ticket_1", Type: workflowregistry.NodeTypeCreateTicket, Name: "创建工单", Position: dsl.Position{X: 1460, Y: 180}, Inputs: map[string]dsl.VariableSelector{ + "ticketDraft": {NodeID: "draft_ticket_1", Field: "ticketDraft"}, + "confirmed": {NodeID: "ticket_confirm_1", Field: "confirmed"}, + }}, + {ID: "ticket_result_reply_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送建单结果", Position: dsl.Position{X: 1760, Y: 180}, Inputs: map[string]dsl.VariableSelector{ + "replyText": {NodeID: "create_ticket_1", Field: "message"}, + }}, + {ID: "ticket_cancel_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "取消建单提示", Position: dsl.Position{X: 1460, Y: 320}, Config: json.RawMessage(`{"staticReply":"已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}`), Inputs: map[string]dsl.VariableSelector{ + "userMessage": {NodeID: "start_1", Field: "userMessage"}, + }}, + {ID: "send_ticket_cancel_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送取消提示", Position: dsl.Position{X: 1760, Y: 320}, Inputs: map[string]dsl.VariableSelector{ + "replyText": {NodeID: "ticket_cancel_reply_1", Field: "replyText"}, + }}, + {ID: "retrieve_1", Type: workflowregistry.NodeTypeKnowledgeRetrieve, Name: "知识检索", Position: dsl.Position{X: 560, Y: 500}, Inputs: map[string]dsl.VariableSelector{ "query": {NodeID: "start_1", Field: "userMessage"}, }}, - {ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 520, Y: 120}, Inputs: map[string]dsl.VariableSelector{ + {ID: "answerability_1", Type: workflowregistry.NodeTypeAnswerabilityGate, Name: "可回答判断", Position: dsl.Position{X: 860, Y: 500}, Inputs: map[string]dsl.VariableSelector{ "userMessage": {NodeID: "start_1", Field: "userMessage"}, "knowledgeItems": {NodeID: "retrieve_1", Field: "items"}, }}, - {ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送回复", Position: dsl.Position{X: 780, Y: 120}, Inputs: map[string]dsl.VariableSelector{ + {ID: "reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "AI 回复", Position: dsl.Position{X: 1160, Y: 440}, Inputs: map[string]dsl.VariableSelector{ + "userMessage": {NodeID: "start_1", Field: "userMessage"}, + "knowledgeItems": {NodeID: "retrieve_1", Field: "items"}, + }}, + {ID: "send_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送回复", Position: dsl.Position{X: 1460, Y: 440}, Inputs: map[string]dsl.VariableSelector{ "replyText": {NodeID: "reply_1", Field: "replyText"}, }}, - {ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 1040, Y: 120}}, + {ID: "fallback_reply_1", Type: workflowregistry.NodeTypeLLMReply, Name: "兜底追问", Position: dsl.Position{X: 1160, Y: 600}, Inputs: map[string]dsl.VariableSelector{ + "userMessage": {NodeID: "start_1", Field: "userMessage"}, + "knowledgeItems": {NodeID: "retrieve_1", Field: "items"}, + }}, + {ID: "send_fallback_1", Type: workflowregistry.NodeTypeSendReply, Name: "发送兜底", Position: dsl.Position{X: 1460, Y: 600}, Inputs: map[string]dsl.VariableSelector{ + "replyText": {NodeID: "fallback_reply_1", Field: "replyText"}, + }}, + {ID: "end_1", Type: workflowregistry.NodeTypeEnd, Name: "结束", Position: dsl.Position{X: 2060, Y: 440}}, }, Edges: []dsl.Edge{ - {ID: "edge_start_retrieve", Source: "start_1", Target: "retrieve_1"}, - {ID: "edge_retrieve_reply", Source: "retrieve_1", Target: "reply_1"}, + {ID: "edge_start_route_intent", Source: "start_1", Target: "route_intent_1"}, + {ID: "edge_intent_handoff", Source: "route_intent_1", Target: "handoff_1", Condition: &dsl.Condition{ + Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, + Operator: "contains", + Right: "人工", + }}, + {ID: "edge_intent_ticket", Source: "route_intent_1", Target: "draft_ticket_1", Condition: &dsl.Condition{ + Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, + Operator: "contains", + Right: "工单", + }}, + {ID: "edge_intent_complaint", Source: "route_intent_1", Target: "draft_ticket_1", Condition: &dsl.Condition{ + Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, + Operator: "contains", + Right: "投诉", + }}, + {ID: "edge_intent_incident", Source: "route_intent_1", Target: "draft_ticket_1", Condition: &dsl.Condition{ + Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"}, + Operator: "contains", + Right: "报障", + }}, + {ID: "edge_intent_knowledge_default", Source: "route_intent_1", Target: "retrieve_1"}, + {ID: "edge_handoff_end", Source: "handoff_1", Target: "handoff_end_1"}, + {ID: "edge_draft_ticket_confirm_prompt", Source: "draft_ticket_1", Target: "ticket_confirm_prompt_1"}, + {ID: "edge_ticket_prompt_confirm", Source: "ticket_confirm_prompt_1", Target: "ticket_confirm_1"}, + {ID: "edge_ticket_confirm_create", Source: "ticket_confirm_1", Target: "create_ticket_1", Condition: &dsl.Condition{ + Left: &dsl.VariableSelector{NodeID: "ticket_confirm_1", Field: "confirmed"}, + Operator: "is_true", + }}, + {ID: "edge_ticket_confirm_cancel", Source: "ticket_confirm_1", Target: "ticket_cancel_reply_1"}, + {ID: "edge_create_ticket_result", Source: "create_ticket_1", Target: "ticket_result_reply_1"}, + {ID: "edge_ticket_result_end", Source: "ticket_result_reply_1", Target: "end_1"}, + {ID: "edge_ticket_cancel_send", Source: "ticket_cancel_reply_1", Target: "send_ticket_cancel_1"}, + {ID: "edge_ticket_cancel_end", Source: "send_ticket_cancel_1", Target: "end_1"}, + {ID: "edge_retrieve_answerability", Source: "retrieve_1", Target: "answerability_1"}, + {ID: "edge_answerability_reply", Source: "answerability_1", Target: "reply_1", Condition: &dsl.Condition{ + Left: &dsl.VariableSelector{NodeID: "answerability_1", Field: "answerability"}, + Operator: "eq", + Right: "answerable", + }}, + {ID: "edge_answerability_fallback", Source: "answerability_1", Target: "fallback_reply_1"}, {ID: "edge_reply_send", Source: "reply_1", Target: "send_1"}, + {ID: "edge_fallback_send", Source: "fallback_reply_1", Target: "send_fallback_1"}, {ID: "edge_send_end", Source: "send_1", Target: "end_1"}, + {ID: "edge_send_fallback_end", Source: "send_fallback_1", Target: "end_1"}, }, } } diff --git a/web/app/dashboard/ai-agents/_components/config-workbench.tsx b/web/app/dashboard/ai-agents/_components/config-workbench.tsx index 17153b8..d06f376 100644 --- a/web/app/dashboard/ai-agents/_components/config-workbench.tsx +++ b/web/app/dashboard/ai-agents/_components/config-workbench.tsx @@ -101,24 +101,134 @@ const emptyDefinition: AIWorkflowDefinition = { id: "start_1", type: "start", name: "开始", - position: { x: 0, y: 120 }, + position: { x: 0, y: 260 }, config: {}, }, + { + id: "route_intent_1", + type: "condition", + name: "意图分流", + position: { x: 260, y: 260 }, + config: {}, + }, + { + id: "handoff_1", + type: "handoff_to_human", + name: "转人工", + position: { x: 560, y: 80 }, + config: {}, + inputs: { + reason: { nodeId: "start_1", field: "userMessage" }, + }, + }, + { + id: "handoff_end_1", + type: "end", + name: "结束", + position: { x: 860, y: 80 }, + config: {}, + }, + { + id: "draft_ticket_1", + type: "prepare_ticket_draft", + name: "整理工单草稿", + position: { x: 560, y: 240 }, + config: {}, + inputs: { + issue: { nodeId: "start_1", field: "userMessage" }, + }, + }, + { + id: "ticket_confirm_prompt_1", + type: "llm_reply", + name: "建单确认文案", + position: { x: 860, y: 240 }, + config: { + staticReply: "我已整理工单草稿。请回复“确认”创建工单,或回复“取消”放弃。", + }, + inputs: { + userMessage: { nodeId: "start_1", field: "userMessage" }, + }, + }, + { + id: "ticket_confirm_1", + type: "human_confirm", + name: "确认建单", + position: { x: 1160, y: 240 }, + config: {}, + inputs: { + prompt: { nodeId: "ticket_confirm_prompt_1", field: "replyText" }, + }, + }, + { + id: "create_ticket_1", + type: "create_ticket", + name: "创建工单", + position: { x: 1460, y: 180 }, + config: {}, + inputs: { + ticketDraft: { nodeId: "draft_ticket_1", field: "ticketDraft" }, + confirmed: { nodeId: "ticket_confirm_1", field: "confirmed" }, + }, + }, + { + id: "ticket_result_reply_1", + type: "send_reply", + name: "发送建单结果", + position: { x: 1760, y: 180 }, + config: {}, + inputs: { + replyText: { nodeId: "create_ticket_1", field: "message" }, + }, + }, + { + id: "ticket_cancel_reply_1", + type: "llm_reply", + name: "取消建单提示", + position: { x: 1460, y: 320 }, + config: { + staticReply: "已取消创建工单。你可以继续补充问题,我会继续帮你处理。", + }, + inputs: { + userMessage: { nodeId: "start_1", field: "userMessage" }, + }, + }, + { + id: "send_ticket_cancel_1", + type: "send_reply", + name: "发送取消提示", + position: { x: 1760, y: 320 }, + config: {}, + inputs: { + replyText: { nodeId: "ticket_cancel_reply_1", field: "replyText" }, + }, + }, { id: "retrieve_1", type: "knowledge_retrieve", name: "知识检索", - position: { x: 260, y: 120 }, + position: { x: 560, y: 500 }, config: {}, inputs: { query: { nodeId: "start_1", field: "userMessage" }, }, }, + { + id: "answerability_1", + type: "answerability_gate", + name: "可回答判断", + position: { x: 860, y: 500 }, + config: {}, + inputs: { + userMessage: { nodeId: "start_1", field: "userMessage" }, + knowledgeItems: { nodeId: "retrieve_1", field: "items" }, + }, + }, { id: "reply_1", type: "llm_reply", name: "AI 回复", - position: { x: 520, y: 120 }, + position: { x: 1160, y: 440 }, config: {}, inputs: { userMessage: { nodeId: "start_1", field: "userMessage" }, @@ -129,25 +239,117 @@ const emptyDefinition: AIWorkflowDefinition = { id: "send_1", type: "send_reply", name: "发送回复", - position: { x: 780, y: 120 }, + position: { x: 1460, y: 440 }, config: {}, inputs: { replyText: { nodeId: "reply_1", field: "replyText" }, }, }, + { + id: "fallback_reply_1", + type: "llm_reply", + name: "兜底追问", + position: { x: 1160, y: 600 }, + config: {}, + inputs: { + userMessage: { nodeId: "start_1", field: "userMessage" }, + knowledgeItems: { nodeId: "retrieve_1", field: "items" }, + }, + }, + { + id: "send_fallback_1", + type: "send_reply", + name: "发送兜底", + position: { x: 1460, y: 600 }, + config: {}, + inputs: { + replyText: { nodeId: "fallback_reply_1", field: "replyText" }, + }, + }, { id: "end_1", type: "end", name: "结束", - position: { x: 1040, y: 120 }, + position: { x: 2060, y: 440 }, config: {}, }, ], edges: [ - { id: "edge_start_retrieve", source: "start_1", target: "retrieve_1" }, - { id: "edge_retrieve_reply", source: "retrieve_1", target: "reply_1" }, + { id: "edge_start_route_intent", source: "start_1", target: "route_intent_1" }, + { + id: "edge_intent_handoff", + source: "route_intent_1", + target: "handoff_1", + condition: { + left: { nodeId: "start_1", field: "userMessage" }, + operator: "contains", + right: "人工", + }, + }, + { + id: "edge_intent_ticket", + source: "route_intent_1", + target: "draft_ticket_1", + condition: { + left: { nodeId: "start_1", field: "userMessage" }, + operator: "contains", + right: "工单", + }, + }, + { + id: "edge_intent_complaint", + source: "route_intent_1", + target: "draft_ticket_1", + condition: { + left: { nodeId: "start_1", field: "userMessage" }, + operator: "contains", + right: "投诉", + }, + }, + { + id: "edge_intent_incident", + source: "route_intent_1", + target: "draft_ticket_1", + condition: { + left: { nodeId: "start_1", field: "userMessage" }, + operator: "contains", + right: "报障", + }, + }, + { id: "edge_intent_knowledge_default", source: "route_intent_1", target: "retrieve_1" }, + { id: "edge_handoff_end", source: "handoff_1", target: "handoff_end_1" }, + { id: "edge_draft_ticket_confirm_prompt", source: "draft_ticket_1", target: "ticket_confirm_prompt_1" }, + { id: "edge_ticket_prompt_confirm", source: "ticket_confirm_prompt_1", target: "ticket_confirm_1" }, + { + id: "edge_ticket_confirm_create", + source: "ticket_confirm_1", + target: "create_ticket_1", + condition: { + left: { nodeId: "ticket_confirm_1", field: "confirmed" }, + operator: "is_true", + }, + }, + { id: "edge_ticket_confirm_cancel", source: "ticket_confirm_1", target: "ticket_cancel_reply_1" }, + { id: "edge_create_ticket_result", source: "create_ticket_1", target: "ticket_result_reply_1" }, + { id: "edge_ticket_result_end", source: "ticket_result_reply_1", target: "end_1" }, + { id: "edge_ticket_cancel_send", source: "ticket_cancel_reply_1", target: "send_ticket_cancel_1" }, + { id: "edge_ticket_cancel_end", source: "send_ticket_cancel_1", target: "end_1" }, + { id: "edge_retrieve_answerability", source: "retrieve_1", target: "answerability_1" }, + { + id: "edge_answerability_reply", + source: "answerability_1", + target: "reply_1", + condition: { + left: { nodeId: "answerability_1", field: "answerability" }, + operator: "eq", + right: "answerable", + }, + }, + { id: "edge_answerability_fallback", source: "answerability_1", target: "fallback_reply_1" }, { id: "edge_reply_send", source: "reply_1", target: "send_1" }, + { id: "edge_fallback_send", source: "fallback_reply_1", target: "send_fallback_1" }, { id: "edge_send_end", source: "send_1", target: "end_1" }, + { id: "edge_send_fallback_end", source: "send_fallback_1", target: "end_1" }, ], } diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 473ee0b..8a181d0 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -323,7 +323,10 @@ export type AIWorkflowDefinition = { source: string target: string condition?: { - expression: string + expression?: string + left?: AIWorkflowVariableSelector + operator?: string + right?: unknown } }[] }