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 { canContainNode } from '../../utils';
|
||||||
import { FlowNodeRegistry } from '../../typings';
|
import { FlowNodeRegistry } from '../../typings';
|
||||||
import { nodeRegistries } from '../../nodes';
|
import { getActiveNodeRegistries } from '../../nodes';
|
||||||
|
|
||||||
const NodeWrap = styled.div`
|
const NodeWrap = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -72,7 +72,7 @@ interface NodeListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NodeList: FC<NodeListProps> = (props) => {
|
export const NodeList: FC<NodeListProps> = (props) => {
|
||||||
const { onSelect, containerNode, fromPort } = props;
|
const { onSelect, containerNode } = props;
|
||||||
const context = useClientContext();
|
const context = useClientContext();
|
||||||
const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
|
const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
|
||||||
const json = registry.onAdd?.(context);
|
const json = registry.onAdd?.(context);
|
||||||
@@ -82,10 +82,9 @@ export const NodeList: FC<NodeListProps> = (props) => {
|
|||||||
nodeJSON: json,
|
nodeJSON: json,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
console.log('>>> fromNode', fromPort?.node);
|
|
||||||
return (
|
return (
|
||||||
<NodesWrap style={{ width: 80 * 2 + 20 }}>
|
<NodesWrap style={{ width: 80 * 2 + 20 }}>
|
||||||
{nodeRegistries
|
{getActiveNodeRegistries()
|
||||||
.filter((register) => register.meta.nodePanelVisible !== false)
|
.filter((register) => register.meta.nodePanelVisible !== false)
|
||||||
.filter((register) => {
|
.filter((register) => {
|
||||||
if (register.meta.onlyInContainer) {
|
if (register.meta.onlyInContainer) {
|
||||||
@@ -107,7 +106,7 @@ export const NodeList: FC<NodeListProps> = (props) => {
|
|||||||
icon={
|
icon={
|
||||||
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.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)}
|
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 '@flowgram.ai/free-layout-editor/index.css';
|
||||||
import './styles/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 { initialData } from './initial-data';
|
||||||
import { useEditorProps } from './hooks';
|
import { useEditorProps } from './hooks';
|
||||||
import type { FlowDocumentJSON } from './typings';
|
|
||||||
|
|
||||||
const MESSAGE_SOURCE = 'agent-desk';
|
const MESSAGE_SOURCE = 'agent-desk';
|
||||||
|
|
||||||
@@ -22,13 +28,16 @@ type LoadMessage = {
|
|||||||
type: 'workflow:load';
|
type: 'workflow:load';
|
||||||
documentKey: string;
|
documentKey: string;
|
||||||
document: FlowDocumentJSON;
|
document: FlowDocumentJSON;
|
||||||
|
nodeSpecs?: WorkflowNodeSpec[];
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Editor = () => {
|
export const Editor = () => {
|
||||||
const [documentKey, setDocumentKey] = useState('official-default');
|
const [documentKey, setDocumentKey] = useState('official-default');
|
||||||
|
const [documentRevision, setDocumentRevision] = useState(0);
|
||||||
const [document, setDocument] = useState<FlowDocumentJSON>(initialData);
|
const [document, setDocument] = useState<FlowDocumentJSON>(initialData);
|
||||||
const [readonly, setReadonly] = useState(false);
|
const [readonly, setReadonly] = useState(false);
|
||||||
|
const [registries, setRegistries] = useState(nodeRegistries);
|
||||||
const handleDocumentChange = useCallback((nextDocument: FlowDocumentJSON) => {
|
const handleDocumentChange = useCallback((nextDocument: FlowDocumentJSON) => {
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{
|
{
|
||||||
@@ -39,7 +48,7 @@ export const Editor = () => {
|
|||||||
window.location.origin
|
window.location.origin
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
const editorProps = useEditorProps(document, nodeRegistries, handleDocumentChange, readonly);
|
const editorProps = useEditorProps(document, registries, handleDocumentChange, readonly);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent<LoadMessage>) => {
|
const handleMessage = (event: MessageEvent<LoadMessage>) => {
|
||||||
@@ -50,8 +59,21 @@ export const Editor = () => {
|
|||||||
) {
|
) {
|
||||||
return;
|
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);
|
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));
|
setReadonly(Boolean(event.data.readonly));
|
||||||
};
|
};
|
||||||
window.addEventListener('message', handleMessage);
|
window.addEventListener('message', handleMessage);
|
||||||
@@ -64,7 +86,10 @@ export const Editor = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="doc-free-feature-overview">
|
<div className="doc-free-feature-overview">
|
||||||
<FreeLayoutEditorProvider key={`${documentKey}-${readonly}`} {...editorProps}>
|
<FreeLayoutEditorProvider
|
||||||
|
key={`${documentKey}-${documentRevision}-${readonly}`}
|
||||||
|
{...editorProps}
|
||||||
|
>
|
||||||
<div className="demo-container">
|
<div className="demo-container">
|
||||||
<DockedPanelLayer>
|
<DockedPanelLayer>
|
||||||
<EditorRenderer className="demo-editor" />
|
<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 { BlockEndNodeRegistry } from './block-end';
|
||||||
import { MultiConditionNodeRegistry } from "./multi-condition";
|
import { MultiConditionNodeRegistry } from "./multi-condition";
|
||||||
export { WorkflowNodeType } from './constants';
|
export { WorkflowNodeType } from './constants';
|
||||||
|
export { createBusinessNodeRegistries, enrichDocumentWithNodeSpecs } from './business';
|
||||||
|
export type { WorkflowNodeSpec } from './business';
|
||||||
|
|
||||||
export const nodeRegistries: FlowNodeRegistry[] = [
|
export const nodeRegistries: FlowNodeRegistry[] = [
|
||||||
ConditionNodeRegistry,
|
ConditionNodeRegistry,
|
||||||
@@ -43,3 +45,13 @@ export const nodeRegistries: FlowNodeRegistry[] = [
|
|||||||
GroupNodeRegistry,
|
GroupNodeRegistry,
|
||||||
MultiConditionNodeRegistry,
|
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?: {
|
info?: {
|
||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
title?: string;
|
||||||
};
|
};
|
||||||
canAdd?: (ctx: FreeLayoutPluginContext) => boolean;
|
canAdd?: (ctx: FreeLayoutPluginContext) => boolean;
|
||||||
canDelete?: (ctx: FreeLayoutPluginContext, from: FlowNodeEntity) => 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"))
|
draft := asMap(state.resolveInput(node, "ticketDraft"))
|
||||||
title := strings.TrimSpace(toString(draft["title"]))
|
title := strings.TrimSpace(toString(draft["title"]))
|
||||||
description := strings.TrimSpace(toString(draft["description"]))
|
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{
|
result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
|
||||||
Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
|
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,
|
IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
func asMap(value any) map[string]any {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ func DefaultRegistry() *Registry {
|
|||||||
InputSchema: []VariableSpec{
|
InputSchema: []VariableSpec{
|
||||||
requiredInput("ticketDraft", "工单草稿", VariableTypeObject, "已经由客户确认的工单草稿内容。"),
|
requiredInput("ticketDraft", "工单草稿", VariableTypeObject, "已经由客户确认的工单草稿内容。"),
|
||||||
requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认创建工单。"),
|
requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认创建工单。"),
|
||||||
|
optionalInput("tagIds", "工单标签", VariableTypeIntegerArray, "创建工单时附加的标签 ID 列表。"),
|
||||||
|
optionalInput("assigneeId", "处理人", VariableTypeInteger, "创建工单后默认指派的客服用户 ID。"),
|
||||||
},
|
},
|
||||||
OutputSchema: []VariableSpec{
|
OutputSchema: []VariableSpec{
|
||||||
output("ticketId", "工单 ID", VariableTypeInteger, "创建成功后的工单内部编号。"),
|
output("ticketId", "工单 ID", VariableTypeInteger, "创建成功后的工单内部编号。"),
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ package registry
|
|||||||
|
|
||||||
import "testing"
|
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) {
|
func TestDefaultRegistryVariablesHaveBusinessLabels(t *testing.T) {
|
||||||
for _, spec := range DefaultRegistry().List() {
|
for _, spec := range DefaultRegistry().List() {
|
||||||
for _, variable := range append(spec.InputSchema, spec.OutputSchema...) {
|
for _, variable := range append(spec.InputSchema, spec.OutputSchema...) {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ type NodeSpec struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Executable bool `json:"executable"`
|
||||||
RiskLevel NodeRiskLevel `json:"riskLevel"`
|
RiskLevel NodeRiskLevel `json:"riskLevel"`
|
||||||
Interruptible bool `json:"interruptible"`
|
Interruptible bool `json:"interruptible"`
|
||||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||||
@@ -68,12 +70,54 @@ func NewRegistry(specs ...NodeSpec) *Registry {
|
|||||||
if spec.Type == "" {
|
if spec.Type == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
spec.Executable = IsExecutableNodeType(spec.Type)
|
||||||
|
if spec.Category == "" {
|
||||||
|
spec.Category = NodeCategory(spec.Type)
|
||||||
|
}
|
||||||
ret.specsByType[spec.Type] = spec
|
ret.specsByType[spec.Type] = spec
|
||||||
ret.specs = append(ret.specs, spec)
|
ret.specs = append(ret.specs, spec)
|
||||||
}
|
}
|
||||||
return ret
|
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) {
|
func (r *Registry) Get(nodeType string) (NodeSpec, bool) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return NodeSpec{}, false
|
return NodeSpec{}, false
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ func (v *definitionValidator) validateNodes() {
|
|||||||
v.addError(field+".type", "unknown node type: "+node.Type)
|
v.addError(field+".type", "unknown node type: "+node.Type)
|
||||||
continue
|
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 {
|
switch node.Type {
|
||||||
case registry.NodeTypeStart:
|
case registry.NodeTypeStart:
|
||||||
v.startNodeIDs = append(v.startNodeIDs, node.ID)
|
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) {
|
func TestValidateDefinitionAcceptsOfficialFlowGramCondition(t *testing.T) {
|
||||||
def := dsl.Definition{
|
def := dsl.Definition{
|
||||||
Nodes: []dsl.Node{
|
Nodes: []dsl.Node{
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor
|
|||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
Description: item.Description,
|
Description: item.Description,
|
||||||
Icon: item.Icon,
|
Icon: item.Icon,
|
||||||
|
Category: item.Category,
|
||||||
|
Executable: item.Executable,
|
||||||
RiskLevel: item.RiskLevel,
|
RiskLevel: item.RiskLevel,
|
||||||
Interruptible: item.Interruptible,
|
Interruptible: item.Interruptible,
|
||||||
RequiresConfirmationPredecessor: item.RequiresConfirmationPredecessor,
|
RequiresConfirmationPredecessor: item.RequiresConfirmationPredecessor,
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ type AIWorkflowNodeSpecResponse struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Executable bool `json:"executable"`
|
||||||
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
|
RiskLevel workflowregistry.NodeRiskLevel `json:"riskLevel"`
|
||||||
Interruptible bool `json:"interruptible"`
|
Interruptible bool `json:"interruptible"`
|
||||||
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
RequiresConfirmationPredecessor bool `json:"requiresConfirmationPredecessor"`
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput)
|
|||||||
switch toolCode {
|
switch toolCode {
|
||||||
case toolx.GraphCreateTicketConfirm.Code:
|
case toolx.GraphCreateTicketConfirm.Code:
|
||||||
item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
|
item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
|
||||||
ConversationID: input.Conversation.ID,
|
ConversationID: input.Conversation.ID,
|
||||||
Title: businessToolString(input.Arguments["title"]),
|
Title: businessToolString(input.Arguments["title"]),
|
||||||
Description: businessToolString(input.Arguments["description"]),
|
Description: businessToolString(input.Arguments["description"]),
|
||||||
|
TagIDs: businessToolInt64Slice(input.Arguments["tagIds"]),
|
||||||
|
CurrentAssigneeID: businessToolInt64(input.Arguments["assigneeId"]),
|
||||||
}, businessToolPrincipal(input.AIAgent))
|
}, businessToolPrincipal(input.AIAgent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -106,6 +108,36 @@ func businessToolString(value any) string {
|
|||||||
return strings.TrimSpace(text)
|
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 {
|
func businessToolPrincipal(agent models.AIAgent) *dto.AuthPrincipal {
|
||||||
name := strings.TrimSpace(agent.Name)
|
name := strings.TrimSpace(agent.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client"
|
"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 MESSAGE_SOURCE = "agent-desk"
|
||||||
|
const EMPTY_NODE_SPECS: AIWorkflowNodeSpec[] = []
|
||||||
|
|
||||||
type EditorMessage =
|
type EditorMessage =
|
||||||
| {
|
| {
|
||||||
@@ -20,31 +21,36 @@ type EditorMessage =
|
|||||||
export function OfficialWorkflowEditor({
|
export function OfficialWorkflowEditor({
|
||||||
documentKey,
|
documentKey,
|
||||||
definition,
|
definition,
|
||||||
|
nodeSpecs = EMPTY_NODE_SPECS,
|
||||||
onDefinitionChange,
|
onDefinitionChange,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
}: {
|
}: {
|
||||||
documentKey: string
|
documentKey: string
|
||||||
definition: AIWorkflowDefinition
|
definition: AIWorkflowDefinition
|
||||||
|
nodeSpecs?: AIWorkflowNodeSpec[]
|
||||||
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
onDefinitionChange: (definition: AIWorkflowDefinition) => void
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
}) {
|
}) {
|
||||||
const frameRef = useRef<HTMLIFrameElement>(null)
|
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||||
const definitionRef = useRef(definition)
|
const definitionRef = useRef(definition)
|
||||||
|
|
||||||
definitionRef.current = definition
|
useEffect(() => {
|
||||||
|
definitionRef.current = definition
|
||||||
|
}, [definition])
|
||||||
|
|
||||||
function loadDocument() {
|
const loadDocument = useCallback(() => {
|
||||||
frameRef.current?.contentWindow?.postMessage(
|
frameRef.current?.contentWindow?.postMessage(
|
||||||
{
|
{
|
||||||
source: MESSAGE_SOURCE,
|
source: MESSAGE_SOURCE,
|
||||||
type: "workflow:load",
|
type: "workflow:load",
|
||||||
documentKey,
|
documentKey,
|
||||||
document: definitionRef.current,
|
document: definitionRef.current,
|
||||||
|
nodeSpecs,
|
||||||
readonly,
|
readonly,
|
||||||
},
|
},
|
||||||
window.location.origin
|
window.location.origin
|
||||||
)
|
)
|
||||||
}
|
}, [documentKey, nodeSpecs, readonly])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent<EditorMessage>) => {
|
const handleMessage = (event: MessageEvent<EditorMessage>) => {
|
||||||
@@ -63,11 +69,11 @@ export function OfficialWorkflowEditor({
|
|||||||
}
|
}
|
||||||
window.addEventListener("message", handleMessage)
|
window.addEventListener("message", handleMessage)
|
||||||
return () => window.removeEventListener("message", handleMessage)
|
return () => window.removeEventListener("message", handleMessage)
|
||||||
}, [documentKey, onDefinitionChange, readonly])
|
}, [loadDocument, onDefinitionChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDocument()
|
loadDocument()
|
||||||
}, [documentKey, readonly])
|
}, [loadDocument])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ import { toast } from "sonner"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { OptionCombobox } from "@/components/option-combobox"
|
||||||
import {
|
import {
|
||||||
createAIWorkflow,
|
createAIWorkflow,
|
||||||
fetchAIWorkflow,
|
fetchAIWorkflow,
|
||||||
fetchAIWorkflowDefaultDefinition,
|
fetchAIWorkflowDefaultDefinition,
|
||||||
|
fetchAIWorkflowNodeSpecs,
|
||||||
|
fetchAIWorkflowTemplates,
|
||||||
publishAIWorkflow,
|
publishAIWorkflow,
|
||||||
updateAIWorkflow,
|
updateAIWorkflow,
|
||||||
type AIWorkflow,
|
type AIWorkflow,
|
||||||
type AIWorkflowDefinition,
|
type AIWorkflowDefinition,
|
||||||
|
type AIWorkflowNodeSpec,
|
||||||
|
type AIWorkflowTemplate,
|
||||||
} from "@/lib/api/admin"
|
} from "@/lib/api/admin"
|
||||||
|
|
||||||
import { OfficialWorkflowEditor } from "./official-workflow-editor"
|
import { OfficialWorkflowEditor } from "./official-workflow-editor"
|
||||||
@@ -59,6 +64,9 @@ export function WorkflowWorkbench({
|
|||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [definition, setDefinition] =
|
const [definition, setDefinition] =
|
||||||
useState<AIWorkflowDefinition>(emptyDefinition)
|
useState<AIWorkflowDefinition>(emptyDefinition)
|
||||||
|
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
|
||||||
|
const [templates, setTemplates] = useState<AIWorkflowTemplate[]>([])
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
@@ -71,6 +79,12 @@ export function WorkflowWorkbench({
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoaded(false)
|
setLoaded(false)
|
||||||
|
const [specs, availableTemplates] = await Promise.all([
|
||||||
|
fetchAIWorkflowNodeSpecs(),
|
||||||
|
fetchAIWorkflowTemplates(),
|
||||||
|
])
|
||||||
|
setNodeSpecs(specs)
|
||||||
|
setTemplates(availableTemplates)
|
||||||
if (!workflowID) {
|
if (!workflowID) {
|
||||||
setDefinition(await fetchAIWorkflowDefaultDefinition())
|
setDefinition(await fetchAIWorkflowDefaultDefinition())
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
@@ -356,6 +370,29 @@ export function WorkflowWorkbench({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -377,8 +414,13 @@ export function WorkflowWorkbench({
|
|||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
{loaded ? (
|
{loaded ? (
|
||||||
<OfficialWorkflowEditor
|
<OfficialWorkflowEditor
|
||||||
documentKey={workflowID ? `workflow-${workflowID}` : "new"}
|
documentKey={
|
||||||
|
workflowID
|
||||||
|
? `workflow-${workflowID}`
|
||||||
|
: `new-${selectedTemplate || "blank"}`
|
||||||
|
}
|
||||||
definition={definition}
|
definition={definition}
|
||||||
|
nodeSpecs={nodeSpecs}
|
||||||
onDefinitionChange={handleDefinitionChange}
|
onDefinitionChange={handleDefinitionChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -450,6 +450,8 @@ export type AIWorkflowNodeSpec = {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
icon: string
|
icon: string
|
||||||
|
category: "trigger" | "control" | "ai" | "business" | "utility"
|
||||||
|
executable: boolean
|
||||||
riskLevel: "low" | "medium" | "high"
|
riskLevel: "low" | "medium" | "high"
|
||||||
interruptible: boolean
|
interruptible: boolean
|
||||||
requiresConfirmationPredecessor: boolean
|
requiresConfirmationPredecessor: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user