feat: implement business node specifications and enhance workflow editor with node specs support
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
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<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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user