From ad971370cfe550b0f44174ac0495afa6a466b34f Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sun, 16 Aug 2026 21:27:45 +0800 Subject: [PATCH] feat: implement business node specifications and enhance workflow editor with node specs support --- .../src/components/node-panel/node-list.tsx | 9 +- flowgram-editor/src/editor.tsx | 35 ++++- flowgram-editor/src/nodes/business.ts | 147 ++++++++++++++++++ flowgram-editor/src/nodes/index.ts | 12 ++ flowgram-editor/src/typings/node.ts | 1 + internal/ai/runtime/workflow/executor.go | 24 ++- internal/ai/workflow/registry/registry.go | 2 + .../ai/workflow/registry/registry_test.go | 16 ++ internal/ai/workflow/registry/spec.go | 44 ++++++ internal/ai/workflow/validator/validator.go | 4 + .../ai/workflow/validator/validator_test.go | 17 ++ internal/builders/ai_workflow_builder.go | 2 + .../pkg/dto/response/ai_workflow_response.go | 2 + internal/services/business_tool_executor.go | 38 ++++- .../_components/official-workflow-editor.tsx | 20 ++- .../_components/workflow-workbench.tsx | 44 +++++- web/lib/api/admin.ts | 2 + 17 files changed, 397 insertions(+), 22 deletions(-) create mode 100644 flowgram-editor/src/nodes/business.ts diff --git a/flowgram-editor/src/components/node-panel/node-list.tsx b/flowgram-editor/src/components/node-panel/node-list.tsx index 006b16f..1fa5dcd 100644 --- a/flowgram-editor/src/components/node-panel/node-list.tsx +++ b/flowgram-editor/src/components/node-panel/node-list.tsx @@ -15,7 +15,7 @@ import { import { canContainNode } from '../../utils'; import { FlowNodeRegistry } from '../../typings'; -import { nodeRegistries } from '../../nodes'; +import { getActiveNodeRegistries } from '../../nodes'; const NodeWrap = styled.div` width: 100%; @@ -72,7 +72,7 @@ interface NodeListProps { } export const NodeList: FC = (props) => { - const { onSelect, containerNode, fromPort } = props; + const { onSelect, containerNode } = props; const context = useClientContext(); const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => { const json = registry.onAdd?.(context); @@ -82,10 +82,9 @@ export const NodeList: FC = (props) => { nodeJSON: json, }); }; - console.log('>>> fromNode', fromPort?.node); return ( - {nodeRegistries + {getActiveNodeRegistries() .filter((register) => register.meta.nodePanelVisible !== false) .filter((register) => { if (register.meta.onlyInContainer) { @@ -107,7 +106,7 @@ export const NodeList: FC = (props) => { icon={ } - label={registry.type as string} + label={registry.info?.title || (registry.type as string)} onClick={(e) => handleClick(e, registry)} /> ))} diff --git a/flowgram-editor/src/editor.tsx b/flowgram-editor/src/editor.tsx index c8acc78..fbc4bbe 100644 --- a/flowgram-editor/src/editor.tsx +++ b/flowgram-editor/src/editor.tsx @@ -10,10 +10,16 @@ import { EditorRenderer, FreeLayoutEditorProvider } from '@flowgram.ai/free-layo import '@flowgram.ai/free-layout-editor/index.css'; import './styles/index.css'; -import { nodeRegistries } from './nodes'; +import type { FlowDocumentJSON } from './typings'; +import { + createBusinessNodeRegistries, + enrichDocumentWithNodeSpecs, + nodeRegistries, + setActiveNodeRegistries, + type WorkflowNodeSpec, +} from './nodes'; import { initialData } from './initial-data'; import { useEditorProps } from './hooks'; -import type { FlowDocumentJSON } from './typings'; const MESSAGE_SOURCE = 'agent-desk'; @@ -22,13 +28,16 @@ type LoadMessage = { type: 'workflow:load'; documentKey: string; document: FlowDocumentJSON; + nodeSpecs?: WorkflowNodeSpec[]; readonly?: boolean; }; export const Editor = () => { const [documentKey, setDocumentKey] = useState('official-default'); + const [documentRevision, setDocumentRevision] = useState(0); const [document, setDocument] = useState(initialData); const [readonly, setReadonly] = useState(false); + const [registries, setRegistries] = useState(nodeRegistries); const handleDocumentChange = useCallback((nextDocument: FlowDocumentJSON) => { window.parent.postMessage( { @@ -39,7 +48,7 @@ export const Editor = () => { window.location.origin ); }, []); - const editorProps = useEditorProps(document, nodeRegistries, handleDocumentChange, readonly); + const editorProps = useEditorProps(document, registries, handleDocumentChange, readonly); useEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -50,8 +59,21 @@ export const Editor = () => { ) { return; } + const nodeSpecs = event.data.nodeSpecs ?? []; + const executableTypes = new Set( + nodeSpecs.filter((spec) => spec.executable).map((spec) => spec.type) + ); + const builtInRegistries = + executableTypes.size > 0 + ? nodeRegistries.filter((registry) => executableTypes.has(registry.type as string)) + : nodeRegistries; + const businessRegistries = createBusinessNodeRegistries(nodeSpecs); + const nextRegistries = [...builtInRegistries, ...businessRegistries]; setDocumentKey(event.data.documentKey); - setDocument(event.data.document); + setDocumentRevision((revision) => revision + 1); + setActiveNodeRegistries(nextRegistries); + setRegistries(nextRegistries); + setDocument(enrichDocumentWithNodeSpecs(event.data.document, nodeSpecs)); setReadonly(Boolean(event.data.readonly)); }; window.addEventListener('message', handleMessage); @@ -64,7 +86,10 @@ export const Editor = () => { return (
- +
diff --git a/flowgram-editor/src/nodes/business.ts b/flowgram-editor/src/nodes/business.ts new file mode 100644 index 0000000..2c42259 --- /dev/null +++ b/flowgram-editor/src/nodes/business.ts @@ -0,0 +1,147 @@ +import { nanoid } from 'nanoid'; +import type { IFlowValue } from '@flowgram.ai/form-materials'; + +import type { FlowDocumentJSON, FlowNodeJSON, FlowNodeRegistry } from '../typings'; +import iconVariable from '../assets/icon-variable.png'; + +export type WorkflowVariableSpec = { + name: string; + label?: string; + type: string; + required?: boolean; + description: string; +}; + +export type WorkflowNodeSpec = { + type: string; + title: string; + description: string; + icon: string; + category: string; + executable: boolean; + riskLevel: 'low' | 'medium' | 'high'; + interruptible: boolean; + requiresConfirmationPredecessor: boolean; + inputSchema?: WorkflowVariableSpec[]; + outputSchema?: WorkflowVariableSpec[]; + defaultInputs?: Record; +}; + +const builtInNodeTypes = new Set(['start', 'end', 'llm', 'condition']); + +function schemaType(type: string): string { + switch (type) { + case 'integer': + return 'number'; + case 'array': + case 'array': + case 'array': + return 'array'; + default: + return type; + } +} + +function buildSchema(variables: WorkflowVariableSpec[] | undefined) { + const properties = Object.fromEntries( + (variables ?? []).map((variable) => [ + variable.name, + { + type: schemaType(variable.type), + title: variable.label || variable.name, + description: variable.description, + extra: variable.type === 'string' ? { formComponent: 'prompt-editor' } : undefined, + }, + ]) + ); + return { + type: 'object' as const, + required: (variables ?? []).filter((item) => item.required).map((item) => item.name), + properties, + }; +} + +export function createBusinessNodeRegistries(specs: WorkflowNodeSpec[]): FlowNodeRegistry[] { + return specs + .filter((spec) => spec.executable && !builtInNodeTypes.has(spec.type)) + .map((spec) => ({ + type: spec.type, + info: { icon: iconVariable, title: spec.title, description: spec.description }, + meta: { + defaultPorts: [{ type: 'input' }, { type: 'output' }], + size: { width: 360, height: 280 }, + }, + onAdd() { + return { + id: `${spec.type}_${nanoid(5)}`, + type: spec.type, + data: { + title: spec.title, + inputsValues: structuredClone(spec.defaultInputs ?? {}), + inputs: buildSchema(spec.inputSchema), + outputs: buildSchema(spec.outputSchema), + nodeSpec: { + category: spec.category, + riskLevel: spec.riskLevel, + interruptible: spec.interruptible, + requiresConfirmationPredecessor: spec.requiresConfirmationPredecessor, + }, + }, + } as FlowNodeJSON; + }, + })); +} + +export function enrichDocumentWithNodeSpecs( + document: FlowDocumentJSON, + specs: WorkflowNodeSpec[] +): FlowDocumentJSON { + const specsByType = new Map(specs.map((spec) => [spec.type, spec])); + const conditionNodeIDs = new Set( + document.nodes + .filter((node) => node.type === 'condition' && Array.isArray(node.data.config?.branches)) + .map((node) => node.id) + ); + return { + ...document, + edges: document.edges.map((edge) => + conditionNodeIDs.has(edge.sourceNodeID) && edge.sourcePortID === 'default' + ? { ...edge, sourcePortID: 'else' } + : edge + ), + nodes: document.nodes.map((node) => { + const spec = specsByType.get(node.type as string); + if (!spec) return node; + const legacyBranches = Array.isArray(node.data.config?.branches) + ? node.data.config.branches + : []; + const conditions = legacyBranches + .filter((branch: any) => !branch.default && branch.condition) + .map((branch: any) => ({ + key: branch.id, + value: { + left: branch.condition.left, + operator: branch.condition.operator, + right: { type: 'constant', content: branch.condition.right }, + }, + })); + return { + ...node, + data: { + ...node.data, + title: node.data.title || spec.title, + inputsValues: node.data.inputsValues ?? structuredClone(spec.defaultInputs ?? {}), + inputs: node.data.inputs ?? buildSchema(spec.inputSchema), + outputs: node.data.outputs ?? buildSchema(spec.outputSchema), + nodeSpec: { + category: spec.category, + riskLevel: spec.riskLevel, + interruptible: spec.interruptible, + requiresConfirmationPredecessor: spec.requiresConfirmationPredecessor, + }, + ...(node.type === 'condition' && conditions.length > 0 ? { conditions } : {}), + }, + }; + }), + }; +} diff --git a/flowgram-editor/src/nodes/index.ts b/flowgram-editor/src/nodes/index.ts index 4cbb3e8..b61e978 100644 --- a/flowgram-editor/src/nodes/index.ts +++ b/flowgram-editor/src/nodes/index.ts @@ -25,6 +25,8 @@ import { BlockStartNodeRegistry } from './block-start'; import { BlockEndNodeRegistry } from './block-end'; import { MultiConditionNodeRegistry } from "./multi-condition"; export { WorkflowNodeType } from './constants'; +export { createBusinessNodeRegistries, enrichDocumentWithNodeSpecs } from './business'; +export type { WorkflowNodeSpec } from './business'; export const nodeRegistries: FlowNodeRegistry[] = [ ConditionNodeRegistry, @@ -43,3 +45,13 @@ export const nodeRegistries: FlowNodeRegistry[] = [ GroupNodeRegistry, MultiConditionNodeRegistry, ]; + +let activeNodeRegistries = nodeRegistries; + +export function setActiveNodeRegistries(registries: FlowNodeRegistry[]) { + activeNodeRegistries = registries; +} + +export function getActiveNodeRegistries() { + return activeNodeRegistries; +} diff --git a/flowgram-editor/src/typings/node.ts b/flowgram-editor/src/typings/node.ts index d6451fc..b1a1411 100644 --- a/flowgram-editor/src/typings/node.ts +++ b/flowgram-editor/src/typings/node.ts @@ -65,6 +65,7 @@ export interface FlowNodeRegistry extends FlowNodeRegistryDefault { info?: { icon: string; description: string; + title?: string; }; canAdd?: (ctx: FreeLayoutPluginContext) => boolean; canDelete?: (ctx: FreeLayoutPluginContext, from: FlowNodeEntity) => boolean; diff --git a/internal/ai/runtime/workflow/executor.go b/internal/ai/runtime/workflow/executor.go index af9a94a..1633a9c 100644 --- a/internal/ai/runtime/workflow/executor.go +++ b/internal/ai/runtime/workflow/executor.go @@ -395,9 +395,13 @@ func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error { draft := asMap(state.resolveInput(node, "ticketDraft")) title := strings.TrimSpace(toString(draft["title"])) description := strings.TrimSpace(toString(draft["description"])) + tagIDs := toInt64Slice(state.resolveInput(node, "tagIds")) + assigneeID := toInt64(state.resolveInput(node, "assigneeId")) result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{ Conversation: state.input.Conversation, AIAgent: state.input.AIAgent, - ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{"title": title, "description": description}, + ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{ + "title": title, "description": description, "tagIds": tagIDs, "assigneeId": assigneeID, + }, IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true, }) if err != nil { @@ -1336,6 +1340,24 @@ func toFloat(value any) float64 { } } +func toInt64(value any) int64 { + return int64(toFloat(value)) +} + +func toInt64Slice(value any) []int64 { + rv := reflect.ValueOf(value) + if !rv.IsValid() || (rv.Kind() != reflect.Array && rv.Kind() != reflect.Slice) { + return nil + } + ret := make([]int64, 0, rv.Len()) + for index := 0; index < rv.Len(); index++ { + if id := toInt64(rv.Index(index).Interface()); id > 0 { + ret = append(ret, id) + } + } + return ret +} + func asMap(value any) map[string]any { switch v := value.(type) { case map[string]any: diff --git a/internal/ai/workflow/registry/registry.go b/internal/ai/workflow/registry/registry.go index a1df8ae..e627c42 100644 --- a/internal/ai/workflow/registry/registry.go +++ b/internal/ai/workflow/registry/registry.go @@ -251,6 +251,8 @@ func DefaultRegistry() *Registry { InputSchema: []VariableSpec{ requiredInput("ticketDraft", "工单草稿", VariableTypeObject, "已经由客户确认的工单草稿内容。"), requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认创建工单。"), + optionalInput("tagIds", "工单标签", VariableTypeIntegerArray, "创建工单时附加的标签 ID 列表。"), + optionalInput("assigneeId", "处理人", VariableTypeInteger, "创建工单后默认指派的客服用户 ID。"), }, OutputSchema: []VariableSpec{ output("ticketId", "工单 ID", VariableTypeInteger, "创建成功后的工单内部编号。"), diff --git a/internal/ai/workflow/registry/registry_test.go b/internal/ai/workflow/registry/registry_test.go index 539067f..824b4f1 100644 --- a/internal/ai/workflow/registry/registry_test.go +++ b/internal/ai/workflow/registry/registry_test.go @@ -2,6 +2,22 @@ package registry import "testing" +func TestDefaultRegistryMarksOnlyRuntimeSupportedNodesExecutable(t *testing.T) { + registry := DefaultRegistry() + for _, nodeType := range []string{NodeTypeCreateTicket, NodeTypeHumanConfirm, NodeTypeSendReply, NodeTypeLLM} { + spec, ok := registry.Get(nodeType) + if !ok || !spec.Executable { + t.Fatalf("expected %s to be executable, got %#v", nodeType, spec) + } + } + for _, nodeType := range []string{NodeTypeHTTP, NodeTypeCode, NodeTypeLoop} { + spec, ok := registry.Get(nodeType) + if !ok || spec.Executable { + t.Fatalf("expected %s to be unavailable in server runtime, got %#v", nodeType, spec) + } + } +} + func TestDefaultRegistryVariablesHaveBusinessLabels(t *testing.T) { for _, spec := range DefaultRegistry().List() { for _, variable := range append(spec.InputSchema, spec.OutputSchema...) { diff --git a/internal/ai/workflow/registry/spec.go b/internal/ai/workflow/registry/spec.go index 0337696..192fa8b 100644 --- a/internal/ai/workflow/registry/spec.go +++ b/internal/ai/workflow/registry/spec.go @@ -45,6 +45,8 @@ type NodeSpec struct { Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` + Category string `json:"category"` + Executable bool `json:"executable"` RiskLevel NodeRiskLevel `json:"riskLevel"` Interruptible bool `json:"interruptible"` RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"` @@ -68,12 +70,54 @@ func NewRegistry(specs ...NodeSpec) *Registry { if spec.Type == "" { continue } + spec.Executable = IsExecutableNodeType(spec.Type) + if spec.Category == "" { + spec.Category = NodeCategory(spec.Type) + } ret.specsByType[spec.Type] = spec ret.specs = append(ret.specs, spec) } return ret } +func IsExecutableNodeType(nodeType string) bool { + switch nodeType { + case NodeTypeStart, + NodeTypeConversationUnderstanding, + NodeTypeReplyPolicy, + NodeTypeKnowledgeRetrieve, + NodeTypeAnswerabilityGate, + NodeTypeCondition, + NodeTypeAnalyzeConversation, + NodeTypePrepareTicketDraft, + NodeTypeHumanConfirm, + NodeTypeCreateTicket, + NodeTypeLLMReply, + NodeTypeLLM, + NodeTypeSendReply, + NodeTypeHandoffToHuman, + NodeTypeEnd: + return true + default: + return false + } +} + +func NodeCategory(nodeType string) string { + switch nodeType { + case NodeTypeStart, NodeTypeEnd: + return "trigger" + case NodeTypeCondition, NodeTypeMultiCondition, NodeTypeLoop, NodeTypeBlockStart, NodeTypeBlockEnd, NodeTypeContinue, NodeTypeBreak: + return "control" + case NodeTypeConversationUnderstanding, NodeTypeReplyPolicy, NodeTypeAnswerabilityGate, NodeTypeAnalyzeConversation, NodeTypeLLMReply, NodeTypeLLM, NodeTypeKnowledgeRetrieve: + return "ai" + case NodeTypePrepareTicketDraft, NodeTypeHumanConfirm, NodeTypeCreateTicket, NodeTypeHandoffToHuman, NodeTypeSendReply: + return "business" + default: + return "utility" + } +} + func (r *Registry) Get(nodeType string) (NodeSpec, bool) { if r == nil { return NodeSpec{}, false diff --git a/internal/ai/workflow/validator/validator.go b/internal/ai/workflow/validator/validator.go index ad97076..dc3f9e8 100644 --- a/internal/ai/workflow/validator/validator.go +++ b/internal/ai/workflow/validator/validator.go @@ -82,6 +82,10 @@ func (v *definitionValidator) validateNodes() { v.addError(field+".type", "unknown node type: "+node.Type) continue } + if !registry.IsExecutableNodeType(node.Type) { + v.addError(field+".type", "node type is not supported by the server runtime: "+node.Type) + continue + } switch node.Type { case registry.NodeTypeStart: v.startNodeIDs = append(v.startNodeIDs, node.ID) diff --git a/internal/ai/workflow/validator/validator_test.go b/internal/ai/workflow/validator/validator_test.go index 798b3fc..edba6a7 100644 --- a/internal/ai/workflow/validator/validator_test.go +++ b/internal/ai/workflow/validator/validator_test.go @@ -18,6 +18,23 @@ func TestValidateDefinitionAcceptsMinimalFlowGramStyleFlow(t *testing.T) { } } +func TestValidateDefinitionRejectsNodeMissingFromServerRuntime(t *testing.T) { + def := dsl.Definition{ + Nodes: []dsl.Node{ + node("start_1", "start", nil, nil), + node("http_1", "http", nil, nil), + node("end_1", "end", nil, nil), + }, + Edges: []dsl.Edge{edge("start_1", "http_1"), edge("http_1", "end_1")}, + } + + result := validator.ValidateDefinition(def, registry.DefaultRegistry()) + + if result.Valid || !hasValidationMessage(result, "not supported by the server runtime") { + t.Fatalf("expected unsupported-runtime error, got %#v", result.Errors) + } +} + func TestValidateDefinitionAcceptsOfficialFlowGramCondition(t *testing.T) { def := dsl.Definition{ Nodes: []dsl.Node{ diff --git a/internal/builders/ai_workflow_builder.go b/internal/builders/ai_workflow_builder.go index e1f10c4..7fcf5d8 100644 --- a/internal/builders/ai_workflow_builder.go +++ b/internal/builders/ai_workflow_builder.go @@ -77,6 +77,8 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor Title: item.Title, Description: item.Description, Icon: item.Icon, + Category: item.Category, + Executable: item.Executable, RiskLevel: item.RiskLevel, Interruptible: item.Interruptible, RequiresConfirmationPredecessor: item.RequiresConfirmationPredecessor, diff --git a/internal/pkg/dto/response/ai_workflow_response.go b/internal/pkg/dto/response/ai_workflow_response.go index 519c662..9f6a127 100644 --- a/internal/pkg/dto/response/ai_workflow_response.go +++ b/internal/pkg/dto/response/ai_workflow_response.go @@ -60,6 +60,8 @@ type AIWorkflowNodeSpecResponse struct { Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` + Category string `json:"category"` + Executable bool `json:"executable"` RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"` Interruptible bool `json:"interruptible"` RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"` diff --git a/internal/services/business_tool_executor.go b/internal/services/business_tool_executor.go index f026f08..64e2897 100644 --- a/internal/services/business_tool_executor.go +++ b/internal/services/business_tool_executor.go @@ -82,9 +82,11 @@ func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput) switch toolCode { case toolx.GraphCreateTicketConfirm.Code: item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{ - ConversationID: input.Conversation.ID, - Title: businessToolString(input.Arguments["title"]), - Description: businessToolString(input.Arguments["description"]), + ConversationID: input.Conversation.ID, + Title: businessToolString(input.Arguments["title"]), + Description: businessToolString(input.Arguments["description"]), + TagIDs: businessToolInt64Slice(input.Arguments["tagIds"]), + CurrentAssigneeID: businessToolInt64(input.Arguments["assigneeId"]), }, businessToolPrincipal(input.AIAgent)) if err != nil { return "", err @@ -106,6 +108,36 @@ func businessToolString(value any) string { return strings.TrimSpace(text) } +func businessToolInt64(value any) int64 { + switch typed := value.(type) { + case int64: + return typed + case int: + return int64(typed) + case float64: + return int64(typed) + default: + return 0 + } +} + +func businessToolInt64Slice(value any) []int64 { + switch typed := value.(type) { + case []int64: + return typed + case []any: + ret := make([]int64, 0, len(typed)) + for _, item := range typed { + if id := businessToolInt64(item); id > 0 { + ret = append(ret, id) + } + } + return ret + default: + return nil + } +} + func businessToolPrincipal(agent models.AIAgent) *dto.AuthPrincipal { name := strings.TrimSpace(agent.Name) if name == "" { diff --git a/web/app/dashboard/ai-workflows/_components/official-workflow-editor.tsx b/web/app/dashboard/ai-workflows/_components/official-workflow-editor.tsx index c6b9b55..14c15ae 100644 --- a/web/app/dashboard/ai-workflows/_components/official-workflow-editor.tsx +++ b/web/app/dashboard/ai-workflows/_components/official-workflow-editor.tsx @@ -1,10 +1,11 @@ "use client" -import { useEffect, useRef } from "react" +import { useCallback, useEffect, useRef } from "react" -import type { AIWorkflowDefinition } from "@/lib/api/admin" +import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin" const MESSAGE_SOURCE = "agent-desk" +const EMPTY_NODE_SPECS: AIWorkflowNodeSpec[] = [] type EditorMessage = | { @@ -20,31 +21,36 @@ type EditorMessage = export function OfficialWorkflowEditor({ documentKey, definition, + nodeSpecs = EMPTY_NODE_SPECS, onDefinitionChange, readonly = false, }: { documentKey: string definition: AIWorkflowDefinition + nodeSpecs?: AIWorkflowNodeSpec[] onDefinitionChange: (definition: AIWorkflowDefinition) => void readonly?: boolean }) { const frameRef = useRef(null) const definitionRef = useRef(definition) - definitionRef.current = definition + useEffect(() => { + definitionRef.current = definition + }, [definition]) - function loadDocument() { + const loadDocument = useCallback(() => { frameRef.current?.contentWindow?.postMessage( { source: MESSAGE_SOURCE, type: "workflow:load", documentKey, document: definitionRef.current, + nodeSpecs, readonly, }, window.location.origin ) - } + }, [documentKey, nodeSpecs, readonly]) useEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -63,11 +69,11 @@ export function OfficialWorkflowEditor({ } window.addEventListener("message", handleMessage) return () => window.removeEventListener("message", handleMessage) - }, [documentKey, onDefinitionChange, readonly]) + }, [loadDocument, onDefinitionChange]) useEffect(() => { loadDocument() - }, [documentKey, readonly]) + }, [loadDocument]) return (