feat: implement business node specifications and enhance workflow editor with node specs support

This commit is contained in:
mlogclub
2026-08-16 21:27:45 +08:00
parent 16a0d6f5bd
commit ad971370cf
17 changed files with 397 additions and 22 deletions
@@ -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<NodeListProps> = (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<NodeListProps> = (props) => {
nodeJSON: json,
});
};
console.log('>>> fromNode', fromPort?.node);
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{nodeRegistries
{getActiveNodeRegistries()
.filter((register) => register.meta.nodePanelVisible !== false)
.filter((register) => {
if (register.meta.onlyInContainer) {
@@ -107,7 +106,7 @@ export const NodeList: FC<NodeListProps> = (props) => {
icon={
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
}
label={registry.type as string}
label={registry.info?.title || (registry.type as string)}
onClick={(e) => handleClick(e, registry)}
/>
))}
+30 -5
View File
@@ -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<FlowDocumentJSON>(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<LoadMessage>) => {
@@ -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 (
<div className="doc-free-feature-overview">
<FreeLayoutEditorProvider key={`${documentKey}-${readonly}`} {...editorProps}>
<FreeLayoutEditorProvider
key={`${documentKey}-${documentRevision}-${readonly}`}
{...editorProps}
>
<div className="demo-container">
<DockedPanelLayer>
<EditorRenderer className="demo-editor" />
+147
View File
@@ -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<string, IFlowValue>;
};
const builtInNodeTypes = new Set(['start', 'end', 'llm', 'condition']);
function schemaType(type: string): string {
switch (type) {
case 'integer':
return 'number';
case 'array<string>':
case 'array<int>':
case 'array<object>':
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 } : {}),
},
};
}),
};
}
+12
View File
@@ -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;
}
+1
View File
@@ -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;
+23 -1
View File
@@ -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:
@@ -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, "创建成功后的工单内部编号。"),
@@ -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...) {
+44
View File
@@ -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
@@ -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)
@@ -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{
+2
View File
@@ -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,
@@ -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"`
@@ -85,6 +85,8 @@ func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput)
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 == "" {
@@ -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<HTMLIFrameElement>(null)
const definitionRef = useRef(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<EditorMessage>) => {
@@ -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 (
<iframe
@@ -8,14 +8,19 @@ import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { OptionCombobox } from "@/components/option-combobox"
import {
createAIWorkflow,
fetchAIWorkflow,
fetchAIWorkflowDefaultDefinition,
fetchAIWorkflowNodeSpecs,
fetchAIWorkflowTemplates,
publishAIWorkflow,
updateAIWorkflow,
type AIWorkflow,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
type AIWorkflowTemplate,
} from "@/lib/api/admin"
import { OfficialWorkflowEditor } from "./official-workflow-editor"
@@ -59,6 +64,9 @@ export function WorkflowWorkbench({
const [description, setDescription] = useState("")
const [definition, setDefinition] =
useState<AIWorkflowDefinition>(emptyDefinition)
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [templates, setTemplates] = useState<AIWorkflowTemplate[]>([])
const [selectedTemplate, setSelectedTemplate] = useState("")
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [loaded, setLoaded] = useState(false)
@@ -71,6 +79,12 @@ export function WorkflowWorkbench({
const load = useCallback(async () => {
setLoaded(false)
const [specs, availableTemplates] = await Promise.all([
fetchAIWorkflowNodeSpecs(),
fetchAIWorkflowTemplates(),
])
setNodeSpecs(specs)
setTemplates(availableTemplates)
if (!workflowID) {
setDefinition(await fetchAIWorkflowDefaultDefinition())
setLoaded(true)
@@ -356,6 +370,29 @@ export function WorkflowWorkbench({
)}
</div>
<div className="flex shrink-0 gap-2">
{!active ? (
<OptionCombobox
value={selectedTemplate}
placeholder="选择流程模板"
searchPlaceholder="搜索流程模板"
emptyText="暂无流程模板"
triggerClassName="w-48"
options={templates.map((template) => ({
value: template.code,
label: template.name,
description: template.description,
}))}
onChange={(code) => {
const template = templates.find((item) => item.code === code)
if (!template) return
setSelectedTemplate(code)
setDefinition(structuredClone(template.definition))
if (!name.trim()) setName(template.name)
if (!description.trim()) setDescription(template.description)
setDirty(true)
}}
/>
) : null}
<Button
variant="outline"
disabled={saving}
@@ -377,8 +414,13 @@ export function WorkflowWorkbench({
<div className="min-h-0 flex-1 overflow-hidden">
{loaded ? (
<OfficialWorkflowEditor
documentKey={workflowID ? `workflow-${workflowID}` : "new"}
documentKey={
workflowID
? `workflow-${workflowID}`
: `new-${selectedTemplate || "blank"}`
}
definition={definition}
nodeSpecs={nodeSpecs}
onDefinitionChange={handleDefinitionChange}
/>
) : (
+2
View File
@@ -450,6 +450,8 @@ export type AIWorkflowNodeSpec = {
title: string
description: string
icon: string
category: "trigger" | "control" | "ai" | "business" | "utility"
executable: boolean
riskLevel: "low" | "medium" | "high"
interruptible: boolean
requiresConfirmationPredecessor: boolean