Compare commits

...

4 Commits

Author SHA1 Message Date
Yiwei Mao
4a857ba9a3
fix(materials): mouseup event propagation in condition (#458)
* fix(materials): mouseup event propagation in condition

* fix(docs): form-materials
2025-07-03 12:56:10 +00:00
chenjiawei.inizio
e5d73966b4
fix: license header autoinstall (#455)
* fix: autoinstall license dep

* chore: add filter
2025-07-03 10:16:49 +00:00
Louis Young
0903f0fe07
feat(demo): loop built in start & end node (#452)
* fix(container): node move out container abnormal flashing

* chore(demo): update initial data

* feat(demo): loop built in start & end node

* chore(demo): update initial data
2025-07-03 10:14:47 +00:00
Yiwei Mao
9210f5041d
feat: prompt editor variable display (#453)
* feat: prompt editor variable display

* feat: prompt editor with variables docs
2025-07-03 09:32:23 +00:00
35 changed files with 796 additions and 249 deletions

View File

@ -8,6 +8,7 @@ import React, { useState, useContext } from 'react';
import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { FlowNodeMeta } from '../../typings';
import { useNodeRenderContext, usePortClick } from '../../hooks';
import { SidebarContext } from '../../context';
import { scrollToView } from './utils';
@ -25,12 +26,13 @@ export interface NodeWrapperProps {
export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
const { children, isScrollToView = false } = props;
const nodeRender = useNodeRenderContext();
const { selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur } = nodeRender;
const { node, selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur } = nodeRender;
const [isDragging, setIsDragging] = useState(false);
const sidebar = useContext(SidebarContext);
const form = nodeRender.form;
const ctx = useClientContext();
const onPortClick = usePortClick();
const meta = node.getNodeMeta<FlowNodeMeta>();
const portsRender = ports.map((p) => (
<WorkflowPortRender key={p.id} entity={p} onClick={onPortClick} />
@ -66,6 +68,7 @@ export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
onBlur={onBlur}
data-node-selected={String(selected)}
style={{
...meta.wrapperStyle,
outline: form?.state.invalid ? '1px solid red' : 'none',
}}
>

View File

@ -10,7 +10,7 @@ import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import { visibleNodeRegistries } from '../../nodes';
import { nodeRegistries } from '../../nodes';
const NodeWrap = styled.div`
width: 100%;
@ -77,17 +77,19 @@ export const NodeList: FC<NodeListProps> = (props) => {
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{visibleNodeRegistries.map((registry) => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context) ?? true)}
icon={
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
}
label={registry.type as string}
onClick={(e) => handleClick(e, registry)}
/>
))}
{nodeRegistries
.filter((register) => register.meta.nodePanelVisible !== false)
.map((registry) => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context) ?? true)}
icon={
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
}
label={registry.type as string}
onClick={(e) => handleClick(e, registry)}
/>
))}
</NodesWrap>
);
};

View File

@ -68,8 +68,8 @@ export const SidebarRenderer = () => {
if (!node) {
return false;
}
const { sidebarDisable = false } = node.getNodeMeta<FlowNodeMeta>();
return !sidebarDisable;
const { sidebarDisabled = false } = node.getNodeMeta<FlowNodeMeta>();
return !sidebarDisabled;
}, [node]);
if (playground.config.readonly) {

View File

@ -124,14 +124,18 @@ export function useEditorProps(
return true;
},
canDropToNode: (ctx, params) => {
const { dragNodeType, dropNodeType } = params;
const { dragNodeType } = params;
/**
* / loop or group
* The start and end nodes cannot be dragged into loop or group
* /
* The start and end nodes cannot change container
*/
if (
(dragNodeType === 'start' || dragNodeType === 'end') &&
(dropNodeType === 'loop' || dropNodeType === 'group')
[
WorkflowNodeType.Start,
WorkflowNodeType.End,
WorkflowNodeType.BlockStart,
WorkflowNodeType.BlockEnd,
].includes(dragNodeType as WorkflowNodeType)
) {
return false;
}

View File

@ -12,7 +12,7 @@ export const initialData: FlowDocumentJSON = {
type: 'start',
meta: {
position: {
x: 180,
x: 186.39660158249967,
y: 381.75,
},
},
@ -91,7 +91,7 @@ export const initialData: FlowDocumentJSON = {
type: 'end',
meta: {
position: {
x: 2202.9953917050693,
x: 2489.2950705293442,
y: 381.75,
},
},
@ -125,12 +125,194 @@ export const initialData: FlowDocumentJSON = {
},
},
{
id: 'loop_sGybT',
id: 'group_5ci0o',
type: 'group',
meta: {
position: {
x: 163.32056949283722,
y: -76.50012170998413,
},
},
data: {},
blocks: [
{
id: 'llm_8--A3',
type: 'llm',
meta: {
position: {
x: 1177.8341013824886,
y: 9.249999999999977,
},
},
data: {
title: 'LLM_1',
inputsValues: {
modelName: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
apiKey: {
type: 'constant',
content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
},
apiHost: {
type: 'constant',
content: 'https://mock-ai-url/api/v3',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: '# Role\nYou are an AI assistant.\n',
},
prompt: {
type: 'constant',
content: '# User Input\nquery:{{start_0.query}}\nenable:{{start_0.enable}}',
},
},
inputs: {
type: 'object',
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
prompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
{
id: 'llm_vTyMa',
type: 'llm',
meta: {
position: {
x: 1621.3675909579388,
y: 19.24999999999997,
},
},
data: {
title: 'LLM_2',
inputsValues: {
modelName: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
apiKey: {
type: 'constant',
content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
},
apiHost: {
type: 'constant',
content: 'https://mock-ai-url/api/v3',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: '# Role\nYou are an AI assistant.\n',
},
prompt: {
type: 'constant',
content: '# LLM Input\nresult:{{llm_8--A3.result}}',
},
},
inputs: {
type: 'object',
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
prompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
],
edges: [
{
sourceNodeID: 'condition_0',
targetNodeID: 'llm_8--A3',
sourcePortID: 'if_0',
},
{
sourceNodeID: 'llm_8--A3',
targetNodeID: 'llm_vTyMa',
},
{
sourceNodeID: 'llm_vTyMa',
targetNodeID: 'end_0',
},
],
},
{
id: 'loop_ANNyh',
type: 'loop',
meta: {
position: {
x: 1373.5714285714287,
y: 394.9758064516129,
x: 1451.8161064396056,
y: 384.9037102954011,
},
},
data: {
@ -139,16 +321,6 @@ export const initialData: FlowDocumentJSON = {
type: 'ref',
content: ['start_0', 'array_obj'],
},
batchOutputs: {
results: {
type: 'ref',
content: ['llm_6aSyo', 'result'],
},
indexList: {
type: 'ref',
content: ['loop_sGybT_locals', 'index'],
},
},
},
blocks: [
{
@ -156,8 +328,8 @@ export const initialData: FlowDocumentJSON = {
type: 'llm',
meta: {
position: {
x: -196.8663594470046,
y: 142.0046082949309,
x: -110.10677817900246,
y: 182.98973079191808,
},
},
data: {
@ -233,8 +405,8 @@ export const initialData: FlowDocumentJSON = {
type: 'llm',
meta: {
position: {
x: 253.1797235023041,
y: 142.00460829493088,
x: 332.31739662589257,
y: 182.98973079191802,
},
},
data: {
@ -305,193 +477,41 @@ export const initialData: FlowDocumentJSON = {
},
},
},
{
id: 'block_start_loop_ANNyh',
type: 'block-start',
meta: {
position: {
x: -404.5309529838977,
y: 346.08973079191816,
},
},
data: {},
},
{
id: 'block_end_loop_ANNyh',
type: 'block-end',
meta: {
position: {
x: 626.7415714307878,
y: 346.08973079191793,
},
},
data: {},
},
],
edges: [
{
sourceNodeID: 'block_start_loop_ANNyh',
targetNodeID: 'llm_6aSyo',
},
{
sourceNodeID: 'llm_6aSyo',
targetNodeID: 'llm_ZqKlP',
},
],
},
{
id: 'group_5ci0o',
type: 'group',
meta: {
position: {
x: 0,
y: 0,
},
},
data: {},
blocks: [
{
id: 'llm_8--A3',
type: 'llm',
meta: {
position: {
x: 1177.8341013824886,
y: 19.25,
},
},
data: {
title: 'LLM_1',
inputsValues: {
modelName: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
apiKey: {
type: 'constant',
content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
},
apiHost: {
type: 'constant',
content: 'https://mock-ai-url/api/v3',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: '# Role\nYou are an AI assistant.\n',
},
prompt: {
type: 'constant',
content: '',
},
},
inputs: {
type: 'object',
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
prompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
{
id: 'llm_vTyMa',
type: 'llm',
meta: {
position: {
x: 1625.6221198156682,
y: 19.25,
},
},
data: {
title: 'LLM_2',
inputsValues: {
modelName: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
apiKey: {
type: 'constant',
content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
},
apiHost: {
type: 'constant',
content: 'https://mock-ai-url/api/v3',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: '# Role\nYou are an AI assistant.\n',
},
prompt: {
type: 'constant',
content: '',
},
},
inputs: {
type: 'object',
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
prompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
],
edges: [
{
sourceNodeID: 'condition_0',
targetNodeID: 'llm_8--A3',
sourcePortID: 'if_0',
},
{
sourceNodeID: 'llm_8--A3',
targetNodeID: 'llm_vTyMa',
},
{
sourceNodeID: 'llm_vTyMa',
targetNodeID: 'end_0',
sourceNodeID: 'llm_ZqKlP',
targetNodeID: 'block_end_loop_ANNyh',
},
],
},
@ -508,7 +528,7 @@ export const initialData: FlowDocumentJSON = {
},
{
sourceNodeID: 'condition_0',
targetNodeID: 'loop_sGybT',
targetNodeID: 'loop_ANNyh',
sourcePortID: 'if_f0rOAt',
},
{
@ -516,7 +536,7 @@ export const initialData: FlowDocumentJSON = {
targetNodeID: 'end_0',
},
{
sourceNodeID: 'loop_sGybT',
sourceNodeID: 'loop_ANNyh',
targetNodeID: 'end_0',
},
],

View File

@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
import { provideJsonSchemaOutputs, syncVariableTitle } from '@flowgram.ai/form-materials';
import { Avatar } from '@douyinfe/semi-ui';
import { FlowNodeJSON } from '../../typings';
import iconEnd from '../../assets/icon-end.jpg';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<div
style={{
width: 60,
height: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Avatar
shape="circle"
style={{
width: 40,
height: 40,
borderRadius: '50%',
cursor: 'move',
}}
alt="Icon"
src={iconEnd}
/>
</div>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
},
effect: {
title: syncVariableTitle,
outputs: provideJsonSchemaOutputs,
},
};

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeRegistry } from '../../typings';
import iconStart from '../../assets/icon-start.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
export const BlockEndNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.BlockEnd,
meta: {
isNodeEnd: true,
deleteDisable: true,
copyDisable: true,
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'input' }],
size: {
width: 100,
height: 100,
},
wrapperStyle: {
minWidth: 'unset',
borderWidth: 2,
borderRadius: 12,
cursor: 'move',
},
},
info: {
icon: iconStart,
description: 'The final node of the block.',
},
/**
* Render node via formMeta
*/
formMeta,
/**
* Start Node cannot be added
*/
canAdd() {
return false;
},
};

View File

@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
import { provideJsonSchemaOutputs, syncVariableTitle } from '@flowgram.ai/form-materials';
import { Avatar } from '@douyinfe/semi-ui';
import { FlowNodeJSON } from '../../typings';
import iconStart from '../../assets/icon-start.jpg';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<div
style={{
width: 60,
height: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Avatar
shape="circle"
style={{
width: 40,
height: 40,
borderRadius: '50%',
cursor: 'move',
}}
alt="Icon"
src={iconStart}
/>
</div>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
},
effect: {
title: syncVariableTitle,
outputs: provideJsonSchemaOutputs,
},
};

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeRegistry } from '../../typings';
import iconStart from '../../assets/icon-start.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
export const BlockStartNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.BlockStart,
meta: {
isStart: true,
deleteDisable: true,
copyDisable: true,
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'output' }],
size: {
width: 100,
height: 100,
},
wrapperStyle: {
minWidth: 'unset',
borderWidth: 2,
borderRadius: 12,
cursor: 'move',
},
},
info: {
icon: iconStart,
description: 'The starting node of the block.',
},
/**
* Render node via formMeta
*/
formMeta,
/**
* Start Node cannot be added
*/
canAdd() {
return false;
},
};

View File

@ -9,7 +9,8 @@ import { FlowNodeRegistry } from '../../typings';
export const CommentNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Comment,
meta: {
sidebarDisable: true,
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [],
renderKey: WorkflowNodeType.Comment,
size: {

View File

@ -9,5 +9,7 @@ export enum WorkflowNodeType {
LLM = 'llm',
Condition = 'condition',
Loop = 'loop',
BlockStart = 'block-start',
BlockEnd = 'block-end',
Comment = 'comment',
}

View File

@ -13,6 +13,7 @@ export const EndNodeRegistry: FlowNodeRegistry = {
meta: {
deleteDisable: true,
copyDisable: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'input' }],
size: {
width: 360,

View File

@ -8,9 +8,10 @@ import { StartNodeRegistry } from './start';
import { LoopNodeRegistry } from './loop';
import { LLMNodeRegistry } from './llm';
import { EndNodeRegistry } from './end';
import { WorkflowNodeType } from './constants';
import { ConditionNodeRegistry } from './condition';
import { CommentNodeRegistry } from './comment';
import { BlockStartNodeRegistry } from './block-start';
import { BlockEndNodeRegistry } from './block-end';
export { WorkflowNodeType } from './constants';
export const nodeRegistries: FlowNodeRegistry[] = [
@ -20,8 +21,6 @@ export const nodeRegistries: FlowNodeRegistry[] = [
LLMNodeRegistry,
LoopNodeRegistry,
CommentNodeRegistry,
BlockStartNodeRegistry,
BlockEndNodeRegistry,
];
export const visibleNodeRegistries = nodeRegistries.filter(
(r) => r.type !== WorkflowNodeType.Comment
);

View File

@ -20,7 +20,7 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
meta: {
size: {
width: 360,
height: 300,
height: 390,
},
},
onAdd() {

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { delay, WorkflowDocument, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { WorkflowNodeType } from '../constants';
export const createBuiltInNodes = async (node: WorkflowNodeEntity) => {
// wait for node render - 等待节点渲染
await delay(16);
if (node.blocks.length) {
return;
}
const document = node.document as WorkflowDocument;
document.createWorkflowNode(
{
id: `block_start_${node.id}`,
type: WorkflowNodeType.BlockStart,
meta: {
position: {
x: -80,
y: 0,
},
},
data: {},
},
false,
node.id
);
document.createWorkflowNode(
{
id: `block_end_${node.id}`,
type: WorkflowNodeType.BlockEnd,
meta: {
position: {
x: 80,
y: 0,
},
},
data: {},
},
false,
node.id
);
};

View File

@ -16,6 +16,7 @@ import { FlowNodeRegistry } from '../../typings';
import iconLoop from '../../assets/icon-loop.jpg';
import { LoopFormRender } from './loop-form-render';
import { WorkflowNodeType } from '../constants';
import { createBuiltInNodes } from './create-built-in-nodes';
let index = 0;
export const LoopNodeRegistry: FlowNodeRegistry = {
@ -62,16 +63,22 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
return !transform.bounds.contains(mousePos.x, mousePos.y);
},
expandable: false, // disable expanded
wrapperStyle: {
minWidth: 'unset',
},
},
onAdd() {
return {
id: `loop_${nanoid(5)}`,
type: 'loop',
type: WorkflowNodeType.Loop,
data: {
title: `Loop_${++index}`,
},
};
},
onCreate(node, json) {
createBuiltInNodes(node);
},
formMeta: {
...defaultFormMeta,
render: LoopFormRender,

View File

@ -14,6 +14,7 @@ export const StartNodeRegistry: FlowNodeRegistry = {
isStart: true,
deleteDisable: true,
copyDisable: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'output' }],
size: {
width: 360,

View File

@ -49,7 +49,9 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
* meta
*/
export interface FlowNodeMeta extends WorkflowNodeMeta {
sidebarDisable?: boolean;
sidebarDisabled?: boolean;
nodePanelHidden?: boolean;
wrapperStyle?: React.CSSProperties;
}
/**

View File

@ -12,7 +12,7 @@ export function MaterialDisplay(props: any) {
<br />
<PackageManagerTabs
command={{
'By Import': `import { ${props.exportName} } from '@flowgram.ai/materials'`,
'By Import': `import { ${props.exportName} } from '@flowgram.ai/form-materials'`,
// components/type-selector/index.tsx -> components/type-selector
'By CLI': `npx @flowgram.ai/form-materials@latest ${props.filePath
.split('/')

View File

@ -106,6 +106,24 @@ After the CLI runs successfully, the relevant materials will be automatically ad
ConditionRow is used for configuring a **single row** of condition judgment.
</MaterialDisplay>
### PromptEditorWithVariables
<MaterialDisplay
imgs={[{ src: '/materials/prompt-editor-with-variables.png', caption: 'LLM_3 and LLM_4 use variables from batch item of Loop' }]}
filePath="components/prompt-editor-with-variables/index.tsx"
exportName="PromptEditorWithVariables"
>
PromptEditorWithVariables is a Prompt editor that supports variable configuration.
Below is a configuration example for the Prompt editor, where the `query` variable is of string type and the `enable` variable is of boolean type:
```typescript
{
type: "template",
content: "#User Input:\nquery:{{start_0.query}}\nenable:{{start_0.enable}}"
}
```
</MaterialDisplay>
## Currently Supported Effect Materials
### provideBatchInput

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

View File

@ -108,7 +108,23 @@ CLI 运行成功后,相关物料会自动添加到当前项目下的 `src/form
ConditionRow 用于 **一行** 条件判断的配置
</MaterialDisplay>
### PromptEditorWithVariables
<MaterialDisplay
imgs={[{ src: '/materials/prompt-editor-with-variables.png', caption: 'LLM_3 和 LLM_4 的提示词中引用了循环的批处理变量' }]}
filePath="components/prompt-editor-with-variables/index.tsx"
exportName="PromptEditorWithVariables"
>
PromptEditorWithVariables 用于支持变量配置的 Prompt 编辑器。
下面是一个 Prompt 编辑器的配置示例,其中 `query` 变量为字符串类型,`enable` 变量为布尔类型:
```typescript
{
type: "template",
content: "#User Input:\nquery:{{start_0.query}}\nenable:{{start_0.enable}}"
}
```
</MaterialDisplay>
## 当前支持的 Effect 物料

View File

@ -11,6 +11,8 @@ const ignoreFile = fs.readFileSync(path.join(__dirname, "../../../.gitignore"),
encoding: "utf-8",
});
ig.add(ignoreFile);
// ignore cli install demos
ig.add(['.next', 'doc_build']);
const src = path.resolve(__dirname, '../../../');

View File

@ -11,5 +11,5 @@
if [ "$PRE_LINT" != "1" ]; then
node common/scripts/install-run-rush.js -q lint-staged || exit $?
node common/scripts/install-run-rush.js check
node common/autoinstallers/license-header/index.js
node common/scripts/install-run-rush.js license-header
fi

View File

@ -22,7 +22,8 @@
"Sandpack",
"testrun",
"zoomin",
"zoomout"
"zoomout",
"Bytedance"
],
"ignoreWords": [],
"import": []

View File

@ -67,7 +67,12 @@ export function ConditionRow({ style, value, onChange, readonly }: PropTypes) {
onChange={(v) => onChange({ ...value, right: v })}
/>
) : (
<UIInput size="small" disabled value={opConfig?.rightDisplay || 'Empty'} />
<UIInput
size="small"
disabled
style={{ pointerEvents: 'none' }}
value={opConfig?.rightDisplay || 'Empty'}
/>
)}
</UIRight>
</UIValues>

View File

@ -65,7 +65,12 @@ export function ConditionRow({ style, value, onChange, readonly }: PropTypes) {
onChange={(v) => onChange({ ...value, right: v })}
/>
) : (
<Input size="small" disabled value={opConfig?.rightDisplay || 'Empty'} />
<Input
size="small"
disabled
style={{ pointerEvents: 'none' }}
value={opConfig?.rightDisplay || 'Empty'}
/>
)}
</UIRight>
</UIValues>

View File

@ -1,6 +1,8 @@
{
"name": "prompt-editor",
"depMaterials": [],
"depMaterials": [
"variable-selector"
],
"depPackages": [
"@coze-editor/editor@0.1.0-alpha.8d7a30",
"@codemirror/view",

View File

@ -0,0 +1,173 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useLayoutEffect } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { isEqual, last } from 'lodash';
import {
BaseVariableField,
Disposable,
DisposableCollection,
Scope,
useCurrentScope,
} from '@flowgram.ai/editor';
import { Popover } from '@douyinfe/semi-ui';
import { IconIssueStroked } from '@douyinfe/semi-icons';
import { useInjector } from '@coze-editor/editor/react';
import {
Decoration,
DecorationSet,
EditorView,
MatchDecorator,
ViewPlugin,
WidgetType,
} from '@codemirror/view';
import { UIPopoverContent, UIRootTitle, UITag, UIVarName } from '../styles';
class VariableTagWidget extends WidgetType {
keyPath?: string[];
toDispose = new DisposableCollection();
scope: Scope;
root: Root;
constructor({ keyPath, scope }: { keyPath?: string[]; scope: Scope }) {
super();
this.keyPath = keyPath;
this.scope = scope;
}
renderIcon = (icon: string | JSX.Element) => {
if (typeof icon === 'string') {
return <img style={{ marginRight: 8 }} width={12} height={12} src={icon} />;
}
return icon;
};
renderVariable(v?: BaseVariableField) {
if (!v) {
this.root.render(
<UITag prefixIcon={<IconIssueStroked />} color="amber">
Unknown
</UITag>
);
return;
}
const rootField = last(v.parentFields);
const rootTitle = (
<UIRootTitle>{rootField?.meta.title ? `${rootField.meta.title} -` : ''}</UIRootTitle>
);
const rootIcon = this.renderIcon(rootField?.meta.icon);
this.root.render(
<Popover
content={
<UIPopoverContent>
{rootIcon}
{rootTitle}
<UIVarName>{v?.keyPath.slice(1).join('.')}</UIVarName>
</UIPopoverContent>
}
>
<UITag prefixIcon={rootIcon}>
{rootTitle}
<UIVarName>{v?.key}</UIVarName>
</UITag>
</Popover>
);
}
toDOM(view: EditorView): HTMLElement {
const dom = document.createElement('span');
this.root = createRoot(dom);
this.toDispose.push(
Disposable.create(() => {
this.root.unmount();
})
);
this.toDispose.push(
this.scope.available.trackByKeyPath(
this.keyPath,
(v) => {
this.renderVariable(v);
},
{ triggerOnInit: false }
)
);
this.renderVariable(this.scope.available.getByKeyPath(this.keyPath));
return dom;
}
eq(other: VariableTagWidget) {
return isEqual(this.keyPath, other.keyPath);
}
ignoreEvent(): boolean {
return false;
}
destroy(dom: HTMLElement): void {
this.toDispose.dispose();
}
}
export function VariableTagInject() {
const injector = useInjector();
const scope = useCurrentScope();
// 基于 {{var}} 的正则进行匹配,匹配后进行自定义渲染
useLayoutEffect(() => {
const atMatcher = new MatchDecorator({
regexp: /\{\{([^\}]+)\}\}/g,
decoration: (match) =>
Decoration.replace({
widget: new VariableTagWidget({
keyPath: match[1]?.split('.') ?? [],
scope,
}),
}),
});
return injector.inject([
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(private view: EditorView) {
this.decorations = atMatcher.createDeco(view);
}
update() {
this.decorations = atMatcher.createDeco(this.view);
}
},
{
decorations: (p) => p.decorations,
provide(p) {
return EditorView.atomicRanges.of(
(view) => view.plugin(p)?.decorations ?? Decoration.none
);
},
}
),
]);
}, [injector]);
return null;
}

View File

@ -17,7 +17,7 @@ import { EditorAPI } from '@coze-editor/editor/preset-prompt';
import { useVariableTree } from '../../variable-selector';
function Variable() {
export function VariableTree() {
const [posKey, setPosKey] = useState('');
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState(-1);
@ -53,7 +53,7 @@ function Variable() {
return (
<>
<Mention triggerCharacters={['{', '{}']} onOpenChange={handleOpenChange} />
<Mention triggerCharacters={['{', '{}', '@']} onOpenChange={handleOpenChange} />
<Popover
visible={visible}
@ -81,5 +81,3 @@ function Variable() {
</>
);
}
export default Variable;

View File

@ -5,13 +5,15 @@
import React from 'react';
import Variable from './extensions/variable';
import { VariableTree } from './extensions/variable-tree';
import { VariableTagInject } from './extensions/variable-tag';
import { PromptEditor, PromptEditorPropsType } from '../prompt-editor';
export function PromptEditorWithVariables(props: PromptEditorPropsType) {
return (
<PromptEditor {...props}>
<Variable />
<VariableTree />
<VariableTagInject />
</PromptEditor>
);
}

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
import { Tag } from '@douyinfe/semi-ui';
export const UIRootTitle = styled.div`
margin-right: 4px;
min-width: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--semi-color-text-2);
`;
export const UIVarName = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const UITag = styled(Tag)`
display: inline-flex;
align-items: center;
justify-content: flex-start;
max-width: 300px;
& .semi-tag-content-center {
justify-content: flex-start;
}
&.semi-tag {
margin: 0 5px;
}
`;
export const UIPopoverContent = styled.div`
padding: 10px;
display: inline-flex;
align-items: center;
justify-content: flex-start;
`;

View File

@ -88,17 +88,16 @@ export class NodeIntoContainerService {
) {
return;
}
const parentTransform = parentNode.getData<TransformData>(TransformData);
this.operationService.moveNode(node, {
parent: containerNode,
});
const parentTransform = parentNode.getData<TransformData>(TransformData);
await this.nextFrame();
parentTransform.fireChange();
this.operationService.updateNodePosition(node, {
x: parentTransform.position.x + nodeJSON.meta!.position!.x,
y: parentTransform.position.y + nodeJSON.meta!.position!.y,
});
parentTransform.fireChange();
await this.nextFrame();
parentTransform.fireChange();
this.emitter.fire({
type: NodeIntoContainerType.Out,
node,

View File

@ -41,7 +41,7 @@ export abstract class BaseVariableField<VariableMeta = any> extends ASTNode<
}
get keyPath(): string[] {
return this.parentFields.reverse().map((_field) => _field.key);
return [...this.parentFields.reverse().map((_field) => _field.key), this.key];
}
get meta(): VariableMeta {

View File

@ -7,6 +7,7 @@ import {
Observable,
Subject,
distinctUntilChanged,
distinctUntilKeyChanged,
map,
merge,
share,
@ -179,8 +180,12 @@ export class ScopeAvailableData {
merge(this.anyVariableChange$, this.variables$)
.pipe(
triggerOnInit ? startWith() : tap(() => null),
map(() => this.getByKeyPath(keyPath)),
distinctUntilChanged((_prevNode, _node) => _prevNode?.hash !== _node?.hash)
map(() => {
const v = this.getByKeyPath(keyPath);
return { v, hash: v?.hash };
}),
distinctUntilKeyChanged('hash'),
map(({ v }) => v)
)
.subscribe(cb)
);