feat(runtime): flowgram workflow node.js runtime (#304)

* feat(runtime): init nodejs runtime

* feat(runtime): init folder struct

* feat(runtime): interface & test

* feat(runtime): basic api & schema interfaces

* feat(runtime): init runtime model framework

* feat(runtime): create document & node entities

* feat(runtime): runtime engine basic execute logic

* feat(runtime): node add variable data

* refactor(runtime): split to sub domains

* test(runtime): document module test

* feat(runtime): variable store

* feat(runtime): workflow runtime executor

* chore(demo): reset initial data

* feat(runtime): workflow runtime branch logic

* feat(runtime): workflow runtime access to ai model

* feat(runtime): workflow runtime data all add to context

* feat(runtime): workflow runtime invoke record snaphots

* feat(runtime): workflow runtime status

* feat(runtime): main api request processing chain

* chore(demo): reset initial data

* refactor(runtime): types move to interface package

* feat(runtime): router access api defines & interfaces

* feat(runtime): standardize api register & gen api docs

* feat(runtime): create snapshot before node execute

* fix(sub-canvas): tips cannot close

* chore(demo): reset initial data

* feat(demo): make node schema runnable

* feat(demo): access test run

* feat(runtime): runtime core can run in both browser & server env

* fix(runtime): condition value empty issue

* feat(runtime): beautify structure data view

* feat(demo): test run sidesheet

* chore(demo): test run sidesheet button fixed

* feat(demo): running node show flowing line

* chore(demo): hide node result overflow

* chore(demo): reset initial data

* feat(runtime): workflow runtime support loop node

* fix(container): sub canvas height issue

* feat(demo): test run multiple result render

* test(runtime): enbale test coverage

* refactor(runtime): interface folders structure

* refactor(runtime): core folders structure

* refactor(runtime): core export apis & access to router

* feat(demo): runtime plugin

* feat(runtime): server add try-catch protection

* fix(runtime): node process reset end time

* chore: format json

* chore: rush update

* refactor(demo): running service move to runtime-plugin as built-in runtime service

* fix(runtime): build error

* test(runtime): disable nodejs test

* fix(demo): test run result key indent width
This commit is contained in:
Louis Young 2025-06-09 17:13:24 +08:00 committed by GitHub
parent 1668d9f26e
commit aab4183d65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
199 changed files with 9571 additions and 385 deletions

13
.vscode/settings.json vendored
View File

@ -69,7 +69,10 @@
},
"search.useIgnoreFiles": true,
//
"editor.rulers": [80, 120],
"editor.rulers": [
80,
120
],
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
@ -97,7 +100,6 @@
"scss.validate": false,
"less.validate": false,
"emmet.triggerExpansionOnTab": true,
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
@ -108,10 +110,10 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.json-language-features"
},
"[less]": {
"editor.defaultFormatter": "vscode.css-language-features"
@ -125,9 +127,6 @@
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[ignore]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
},

View File

@ -30,6 +30,7 @@
"dependencies": {
"@douyinfe/semi-icons": "^2.80.0",
"@douyinfe/semi-ui": "^2.80.0",
"@flowgram.ai/runtime-interface": "workspace:*",
"@flowgram.ai/free-layout-editor": "workspace:*",
"@flowgram.ai/free-snap-plugin": "workspace:*",
"@flowgram.ai/free-lines-plugin": "workspace:*",
@ -38,6 +39,7 @@
"@flowgram.ai/free-container-plugin": "workspace:*",
"@flowgram.ai/free-group-plugin": "workspace:*",
"@flowgram.ai/form-materials": "workspace:*",
"@flowgram.ai/runtime-js": "workspace:*",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2",
"react": "^18",

View File

@ -3,6 +3,7 @@ import { useCallback } from 'react';
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor';
import { ConfigProvider } from '@douyinfe/semi-ui';
import { NodeStatusBar } from '../testrun/node-status-bar';
import { NodeRenderContext } from '../../context';
import { ErrorIcon } from './styles';
import { NodeWrapper } from './node-wrapper';
@ -32,6 +33,7 @@ export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
{form?.state.invalid && <ErrorIcon />}
{form?.render()}
</NodeWrapper>
<NodeStatusBar />
</NodeRenderContext.Provider>
</ConfigProvider>
);

View File

@ -0,0 +1,8 @@
.node-status-group {
padding: 6px;
font-weight: 500;
color: #333;
font-size: 15px;
display: flex;
align-items: center;
}

View File

@ -0,0 +1,61 @@
import { FC, useState } from 'react';
import { IconSmallTriangleDown } from '@douyinfe/semi-icons';
import { DataStructureViewer } from '../viewer';
import './index.css';
import { Tag } from '@douyinfe/semi-ui';
interface NodeStatusGroupProps {
title: string;
data: unknown;
optional?: boolean;
disableCollapse?: boolean;
}
const isObjectHasContent = (obj: any = {}): boolean => Object.keys(obj).length > 0;
export const NodeStatusGroup: FC<NodeStatusGroupProps> = ({
title,
data,
optional = false,
disableCollapse = false,
}) => {
const hasContent = isObjectHasContent(data);
const [isExpanded, setIsExpanded] = useState(true);
if (optional && !hasContent) {
return null;
}
return (
<>
<div className="node-status-group" onClick={() => hasContent && setIsExpanded(!isExpanded)}>
{!disableCollapse && (
<IconSmallTriangleDown
style={{
transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
cursor: 'pointer',
marginRight: '4px',
opacity: hasContent ? 1 : 0,
}}
/>
)}
<span>{title}:</span>
{!hasContent && (
<Tag
size="small"
style={{
marginLeft: 4,
}}
>
null
</Tag>
)}
</div>
{hasContent && isExpanded ? <DataStructureViewer data={data} /> : null}
</>
);
};

View File

@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { IconChevronDown } from '@douyinfe/semi-icons';
import { useNodeRenderContext } from '../../../../hooks';
import { NodeStatusHeaderContentStyle, NodeStatusHeaderStyle } from './style';
interface NodeStatusBarProps {
header?: React.ReactNode;
defaultShowDetail?: boolean;
extraBtns?: React.ReactNode[];
}
export const NodeStatusHeader: React.FC<React.PropsWithChildren<NodeStatusBarProps>> = ({
header,
defaultShowDetail,
children,
extraBtns = [],
}) => {
const [showDetail, setShowDetail] = useState(defaultShowDetail);
const { selectNode } = useNodeRenderContext();
const handleToggleShowDetail = (e: React.MouseEvent) => {
e.stopPropagation();
selectNode(e);
setShowDetail(!showDetail);
};
return (
<NodeStatusHeaderStyle
// 必须要禁止 down 冒泡,防止判定圈选和 node hover不支持多边形
onMouseDown={(e) => e.stopPropagation()}
>
<NodeStatusHeaderContentStyle
className={showDetail ? 'status-header-opened' : ''}
// 必须要禁止 down 冒泡,防止判定圈选和 node hover不支持多边形
onMouseDown={(e) => e.stopPropagation()}
// 其他事件统一走点击事件,且也需要阻止冒泡
onClick={handleToggleShowDetail}
>
<div className="status-title">
{header}
{extraBtns.length > 0 ? extraBtns : null}
</div>
<div className="status-btns">
<IconChevronDown className={showDetail ? 'is-show-detail' : ''} />
</div>
</NodeStatusHeaderContentStyle>
{showDetail ? children : null}
</NodeStatusHeaderStyle>
);
};

View File

@ -0,0 +1,56 @@
import styled from 'styled-components';
export const NodeStatusHeaderStyle = styled.div`
border: 1px solid rgba(68, 83, 130, 0.25);
border-radius: 8px;
background-color: #fff;
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 100%;
`;
export const NodeStatusHeaderContentStyle = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px;
&-opened {
padding-bottom: 0;
}
.status-title {
height: 24px;
display: flex;
align-items: center;
column-gap: 8px;
min-width: 0;
:global {
.coz-tag {
height: 20px;
}
.semi-tag-content {
font-weight: 500;
line-height: 16px;
font-size: 12px;
}
.semi-tag-suffix-icon > div {
font-size: 14px;
}
}
}
.status-btns {
height: 24px;
display: flex;
align-items: center;
column-gap: 4px;
}
.is-show-detail {
transform: rotate(180deg);
}
`;

View File

@ -0,0 +1,32 @@
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconSuccessFill = ({ className, style }: Props) => (
<svg
className={className}
style={style}
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<g clipPath="url(#icon-workflow-run-success_svg__a)">
<path
fill="#3EC254"
d="M.833 10A9.166 9.166 0 0 0 10 19.168a9.166 9.166 0 0 0 9.167-9.166A9.166 9.166 0 0 0 10 .834a9.166 9.166 0 0 0-9.167 9.167"
></path>
<path
fill="#fff"
d="M6.077 9.755a.833.833 0 0 0 0 1.179l2.357 2.357a.833.833 0 0 0 1.179 0l4.714-4.714a.833.833 0 1 0-1.178-1.179l-4.125 4.125-1.768-1.768a.833.833 0 0 0-1.179 0"
></path>
</g>
<defs>
<clipPath id="icon-workflow-run-success_svg__a">
<path fill="#fff" d="M0 0h20v20H0z"></path>
</clipPath>
</defs>
</svg>
);

View File

@ -0,0 +1,22 @@
interface Props {
className?: string;
style?: React.CSSProperties;
}
export const IconWarningFill = ({ className, style }: Props) => (
<svg
className={className}
style={style}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z"
></path>
</svg>
);

View File

@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { NodeReport } from '@flowgram.ai/runtime-interface';
import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
import { NodeStatusRender } from './render';
const useNodeReport = () => {
const node = useCurrentEntity();
const [report, setReport] = useState<NodeReport>();
const runtimeService = useService(WorkflowRuntimeService);
useEffect(() => {
const reportDisposer = runtimeService.onNodeReportChange((nodeReport) => {
if (nodeReport.id !== node.id) {
return;
}
setReport(nodeReport);
});
const resetDisposer = runtimeService.onReset(() => {
setReport(undefined);
});
return () => {
reportDisposer.dispose();
resetDisposer.dispose();
};
}, []);
return report;
};
export const NodeStatusBar = () => {
const report = useNodeReport();
if (!report) {
return null;
}
return <NodeStatusRender report={report} />;
};

View File

@ -0,0 +1,14 @@
.node-status-succeed {
background-color: rgba(105, 209, 140, 0.3);
color: rgba(0, 178, 60, 1);
}
.node-status-processing {
background-color: rgba(153, 187, 255, 0.3);
color: rgba(61, 121, 242, 1);
}
.node-status-failed {
background-color: rgba(255, 163, 171, 0.3);
color: rgba(229, 50, 65, 1);
}

View File

@ -0,0 +1,233 @@
import { FC, useMemo, useState } from 'react';
import { NodeReport, WorkflowStatus } from '@flowgram.ai/runtime-interface';
import { Tag, Button, Select } from '@douyinfe/semi-ui';
import { IconSpin } from '@douyinfe/semi-icons';
import { IconWarningFill } from '../icon/warning';
import { IconSuccessFill } from '../icon/success';
import { NodeStatusHeader } from '../header';
import './index.css';
import { NodeStatusGroup } from '../group';
interface NodeStatusRenderProps {
report: NodeReport;
}
const msToSeconds = (ms: number): string => (ms / 1000).toFixed(2) + 's';
const displayCount = 6;
export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
const { status: nodeStatus } = report;
const [currentSnapshotIndex, setCurrentSnapshotIndex] = useState(0);
const snapshots = report.snapshots || [];
const currentSnapshot = snapshots[currentSnapshotIndex] || snapshots[0];
// 节点 5 个状态
const isNodePending = nodeStatus === WorkflowStatus.Pending;
const isNodeProcessing = nodeStatus === WorkflowStatus.Processing;
const isNodeFailed = nodeStatus === WorkflowStatus.Failed;
const isNodeSucceed = nodeStatus === WorkflowStatus.Succeeded;
const isNodeCanceled = nodeStatus === WorkflowStatus.Canceled;
const tagColor = useMemo(() => {
if (isNodeSucceed) {
return 'node-status-succeed';
}
if (isNodeFailed) {
return 'node-status-failed';
}
if (isNodeProcessing) {
return 'node-status-processing';
}
}, [isNodeSucceed, isNodeFailed, isNodeProcessing]);
const renderIcon = () => {
if (isNodeProcessing) {
return (
<IconSpin
spin
style={{
color: 'rgba(77,83,232,1',
}}
/>
);
}
if (isNodeSucceed) {
return <IconSuccessFill />;
}
return <IconWarningFill className={tagColor} />;
};
const renderDesc = () => {
const getDesc = () => {
if (isNodeProcessing) {
return 'Running';
} else if (isNodePending) {
return 'Run terminated';
} else if (isNodeSucceed) {
return 'Succeed';
} else if (isNodeFailed) {
return 'Failed';
} else if (isNodeCanceled) {
return 'Canceled';
}
};
const desc = getDesc();
return desc ? <p style={{ margin: 0 }}>{desc}</p> : null;
};
const renderCost = () => (
<Tag size="small" className={tagColor}>
{msToSeconds(report.timeCost)}
</Tag>
);
const renderSnapshotNavigation = () => {
if (snapshots.length <= 1) {
return null;
}
const count = (
<p
style={{
fontWeight: 500,
color: '#333',
fontSize: '15px',
marginLeft: 12,
}}
>
Total: {snapshots.length}
</p>
);
if (snapshots.length <= displayCount) {
return (
<>
{count}
<div
style={{
margin: '12px',
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{snapshots.map((_, index) => (
<Button
key={index}
size="small"
type={currentSnapshotIndex === index ? 'primary' : 'tertiary'}
onClick={() => setCurrentSnapshotIndex(index)}
style={{
minWidth: '32px',
height: '32px',
padding: '0',
borderRadius: '4px',
fontSize: '12px',
border: '1px solid',
borderColor:
currentSnapshotIndex === index ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
fontWeight: currentSnapshotIndex === index ? '800' : '500',
}}
>
{index + 1}
</Button>
))}
</div>
</>
);
}
// 超过5个时前5个显示为按钮剩余的放在下拉选择中
return (
<>
{count}
<div
style={{
margin: '12px',
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{snapshots.slice(0, displayCount).map((_, index) => (
<Button
key={index}
size="small"
type="tertiary"
onClick={() => setCurrentSnapshotIndex(index)}
style={{
minWidth: '32px',
height: '32px',
padding: '0',
borderRadius: '4px',
fontSize: '12px',
border: '1px solid',
borderColor: currentSnapshotIndex === index ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
fontWeight: currentSnapshotIndex === index ? '800' : '500',
}}
>
{index + 1}
</Button>
))}
<Select
value={currentSnapshotIndex >= displayCount ? currentSnapshotIndex : undefined}
onChange={(value) => setCurrentSnapshotIndex(value as number)}
style={{
width: '100px',
height: '32px',
border: '1px solid',
borderColor:
currentSnapshotIndex >= displayCount ? '#4d53e8' : 'rgba(29, 28, 35, 0.08)',
}}
size="small"
placeholder="Select"
>
{snapshots.slice(displayCount).map((_, index) => {
const actualIndex = index + displayCount;
return (
<Select.Option key={actualIndex} value={actualIndex}>
{actualIndex + 1}
</Select.Option>
);
})}
</Select>
</div>
</>
);
};
if (!report) {
return null;
}
return (
<NodeStatusHeader
header={
<>
{renderIcon()}
{renderDesc()}
{renderCost()}
</>
}
>
<div
style={{
width: '100%',
height: '100%',
padding: '0px 2px 10px 2px',
}}
>
{renderSnapshotNavigation()}
<NodeStatusGroup title="Inputs" data={currentSnapshot?.inputs} />
<NodeStatusGroup title="Outputs" data={currentSnapshot?.outputs} />
<NodeStatusGroup title="Branch" data={currentSnapshot?.branch} optional />
<NodeStatusGroup title="Data" data={currentSnapshot?.data} optional />
</div>
</NodeStatusHeader>
);
};

View File

@ -0,0 +1,137 @@
.node-status-data-structure-viewer {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
color: #333;
background: #fafafa;
border-radius: 6px;
padding: 12px 12px 12px 0;
margin: 12px;
border: 1px solid #e1e4e8;
overflow: hidden;
}
.tree-node {
margin: 2px 0;
}
.tree-node-header {
display: flex;
align-items: flex-start;
gap: 4px;
min-height: 20px;
padding: 2px 0;
border-radius: 3px;
transition: background-color 0.15s ease;
}
.tree-node-header:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.expand-button {
background: none;
border: none;
cursor: pointer;
font-size: 10px;
color: #666;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
transition: all 0.15s ease;
padding: 0;
margin: 0;
}
.expand-button:hover {
background-color: rgba(0, 0, 0, 0.1);
color: #333;
}
.expand-button.expanded {
transform: rotate(90deg);
}
.expand-button.collapsed {
transform: rotate(0deg);
}
.expand-placeholder {
width: 16px;
height: 16px;
display: inline-block;
flex-shrink: 0;
}
.node-label {
color: #0969da;
font-weight: 500;
cursor: pointer;
user-select: auto;
margin-right: 4px;
}
.node-label:hover {
text-decoration: underline;
}
.node-value {
margin-left: 4px;
}
.primitive-value-quote {
color: #8f8f8f;
}
.primitive-value {
cursor: pointer;
user-select: all;
padding: 1px 3px;
border-radius: 3px;
transition: background-color 0.15s ease;
}
.primitive-value:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.primitive-value.string {
color: #032f62;
background-color: rgba(3, 47, 98, 0.05);
}
.primitive-value.number {
color: #005cc5;
background-color: rgba(0, 92, 197, 0.05);
}
.primitive-value.boolean {
color: #e36209;
background-color: rgba(227, 98, 9, 0.05);
}
.primitive-value.null,
.primitive-value.undefined {
color: #6a737d;
font-style: italic;
background-color: rgba(106, 115, 125, 0.05);
}
.tree-node-children {
margin-left: 8px;
padding-left: 8px;
position: relative;
}
.tree-node-children::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: #e1e4e8;
}

View File

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import './index.css';
import { Toast } from '@douyinfe/semi-ui';
interface DataStructureViewerProps {
data: any;
level?: number;
}
interface TreeNodeProps {
label: string;
value: any;
level: number;
isLast?: boolean;
}
const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false }) => {
const [isExpanded, setIsExpanded] = useState(true);
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
Toast.success('Copied');
};
const isExpandable = (val: any) =>
val !== null &&
typeof val === 'object' &&
((Array.isArray(val) && val.length > 0) ||
(!Array.isArray(val) && Object.keys(val).length > 0));
const renderPrimitiveValue = (val: any) => {
if (val === null) return <span className="primitive-value null">null</span>;
if (val === undefined) return <span className="primitive-value undefined">undefined</span>;
switch (typeof val) {
case 'string':
return (
<span className="string">
<span className="primitive-value-quote">{'"'}</span>
<span className="primitive-value" onDoubleClick={() => handleCopy(val)}>
{val}
</span>
<span className="primitive-value-quote">{'"'}</span>
</span>
);
case 'number':
return (
<span className="primitive-value number" onDoubleClick={() => handleCopy(String(val))}>
{val}
</span>
);
case 'boolean':
return (
<span
className="primitive-value boolean"
onDoubleClick={() => handleCopy(val.toString())}
>
{val.toString()}
</span>
);
default:
return (
<span className="primitive-value" onDoubleClick={() => handleCopy(String(val))}>
{String(val)}
</span>
);
}
};
const renderChildren = () => {
if (Array.isArray(value)) {
return value.map((item, index) => (
<TreeNode
key={index}
label={`${index + 1}.`}
value={item}
level={level + 1}
isLast={index === value.length - 1}
/>
));
} else {
const entries = Object.entries(value);
return entries.map(([key, val], index) => (
<TreeNode
key={key}
label={`${key}:`}
value={val}
level={level + 1}
isLast={index === entries.length - 1}
/>
));
}
};
return (
<div className="tree-node">
<div className="tree-node-header">
{isExpandable(value) ? (
<button
className={`expand-button ${isExpanded ? 'expanded' : 'collapsed'}`}
onClick={() => setIsExpanded(!isExpanded)}
>
</button>
) : (
<span className="expand-placeholder"></span>
)}
<span
className="node-label"
onClick={() =>
handleCopy(
JSON.stringify({
[label]: value,
})
)
}
>
{label}
</span>
{!isExpandable(value) && <span className="node-value">{renderPrimitiveValue(value)}</span>}
</div>
{isExpandable(value) && isExpanded && (
<div className="tree-node-children">{renderChildren()}</div>
)}
</div>
);
};
export const DataStructureViewer: React.FC<DataStructureViewerProps> = ({ data, level = 0 }) => {
if (data === null || data === undefined || typeof data !== 'object') {
return (
<div className="node-status-data-structure-viewer">
<TreeNode label="value" value={data} level={0} />
</div>
);
}
const entries = Object.entries(data);
return (
<div className="node-status-data-structure-viewer">
{entries.map(([key, value], index) => (
<TreeNode
key={key}
label={key}
value={value}
level={0}
isLast={index === entries.length - 1}
/>
))}
</div>
);
};

View File

@ -0,0 +1,78 @@
import { useState, useEffect, useCallback } from 'react';
import { useClientContext, getNodeForm, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { Button, Badge, SideSheet } from '@douyinfe/semi-ui';
import { IconPlay } from '@douyinfe/semi-icons';
import { TestRunSideSheet } from '../testrun-sidesheet';
export function TestRunButton(props: { disabled: boolean }) {
const [errorCount, setErrorCount] = useState(0);
const clientContext = useClientContext();
const [visible, setVisible] = useState(false);
const updateValidateData = useCallback(() => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
const count = allForms.filter((form) => form?.state.invalid).length;
setErrorCount(count);
}, [clientContext]);
/**
* Validate all node and Save
*/
const onTestRun = useCallback(async () => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
await Promise.all(allForms.map(async (form) => form?.validate()));
console.log('>>>>> save data: ', clientContext.document.toJSON());
setVisible(true);
}, [clientContext]);
/**
* Listen single node validate
*/
useEffect(() => {
const listenSingleNodeValidate = (node: FlowNodeEntity) => {
const form = getNodeForm(node);
if (form) {
const formValidateDispose = form.onValidate(() => updateValidateData());
node.onDispose(() => formValidateDispose.dispose());
}
};
clientContext.document.getAllNodes().map((node) => listenSingleNodeValidate(node));
const dispose = clientContext.document.onNodeCreate(({ node }) =>
listenSingleNodeValidate(node)
);
return () => dispose.dispose();
}, [clientContext]);
const button =
errorCount === 0 ? (
<Button
disabled={props.disabled}
onClick={onTestRun}
icon={<IconPlay size="small" />}
style={{ backgroundColor: 'rgba(0,178,60,1)', borderRadius: '8px', color: '#fff' }}
>
Test Run
</Button>
) : (
<Badge count={errorCount} position="rightTop" type="danger">
<Button
type="danger"
disabled={props.disabled}
onClick={onTestRun}
icon={<IconPlay size="small" />}
style={{ backgroundColor: 'rgba(255,115,0, 1)', borderRadius: '8px', color: '#fff' }}
>
  Test Run
</Button>
</Badge>
);
return (
<>
{button}
<TestRunSideSheet visible={visible} onCancel={() => setVisible((v) => !v)} />
</>
);
}

View File

@ -0,0 +1,138 @@
import { FC, useEffect, useState } from 'react';
import { WorkflowInputs, WorkflowOutputs } from '@flowgram.ai/runtime-interface';
import { useService } from '@flowgram.ai/free-layout-editor';
import { Button, JsonViewer, SideSheet } from '@douyinfe/semi-ui';
import { IconPlay, IconSpin, IconStop } from '@douyinfe/semi-icons';
import { NodeStatusGroup } from '../node-status-bar/group';
import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
interface TestRunSideSheetProps {
visible: boolean;
onCancel: () => void;
}
export const TestRunSideSheet: FC<TestRunSideSheetProps> = ({ visible, onCancel }) => {
const runtimeService = useService(WorkflowRuntimeService);
const [isRunning, setRunning] = useState(false);
const [value, setValue] = useState<string>(`{}`);
const [error, setError] = useState<string | undefined>();
const [result, setResult] = useState<
| {
inputs: WorkflowInputs;
outputs: WorkflowOutputs;
}
| undefined
>();
const onTestRun = async () => {
if (isRunning) {
await runtimeService.taskCancel();
return;
}
setResult(undefined);
setError(undefined);
setRunning(true);
try {
await runtimeService.taskRun(value);
} catch (e: any) {
setError(e.message);
}
};
const onClose = async () => {
await runtimeService.taskCancel();
setValue(`{}`);
setRunning(false);
onCancel();
};
useEffect(() => {
const disposer = runtimeService.onTerminated(({ result }) => {
setRunning(false);
setResult(result);
});
return () => disposer.dispose();
}, []);
const renderRunning = (
<div
style={{
width: '100%',
height: '80%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 16,
}}
>
<IconSpin spin size="large" />
<div
style={{
fontSize: '18px',
}}
>
Running...
</div>
</div>
);
const renderForm = (
<div>
<div
style={{
fontSize: '15px',
fontWeight: '500',
marginBottom: '10px',
color: '#333',
}}
>
Input
</div>
<JsonViewer showSearch={false} height={300} value={value} onChange={setValue} />
<div
style={{
color: 'red',
fontSize: '14px',
marginTop: '30px',
}}
>
{error}
</div>
<NodeStatusGroup title="Inputs" data={result?.inputs} optional disableCollapse />
<NodeStatusGroup title="Outputs" data={result?.outputs} optional disableCollapse />
</div>
);
const renderButton = (
<Button
onClick={onTestRun}
icon={isRunning ? <IconStop size="small" /> : <IconPlay size="small" />}
style={{
backgroundColor: isRunning ? 'rgba(87,104,161,0.08)' : 'rgba(0,178,60,1)',
borderRadius: '8px',
color: isRunning ? 'rgba(15,21,40,0.82)' : '#fff',
marginBottom: '16px',
width: '100%',
height: '40px',
}}
>
{isRunning ? 'Cancel' : 'Test Run'}
</Button>
);
return (
<SideSheet
title="Test Run"
visible={visible}
mask={false}
onCancel={onClose}
footer={renderButton}
>
{isRunning ? renderRunning : renderForm}
</SideSheet>
);
};

View File

@ -5,12 +5,11 @@ import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { Tooltip, IconButton, Divider } from '@douyinfe/semi-ui';
import { IconUndo, IconRedo } from '@douyinfe/semi-icons';
import { TestRunButton } from '../testrun/testrun-button';
import { AddNode } from '../add-node';
import { ZoomSelect } from './zoom-select';
import { SwitchLine } from './switch-line';
import { ToolContainer, ToolSection } from './styles';
import { Save } from './save';
import { Run } from './run';
import { Readonly } from './readonly';
import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
@ -71,8 +70,7 @@ export const DemoTools = () => {
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
<AddNode disabled={playground.config.readonly} />
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
<Save disabled={playground.config.readonly} />
<Run />
<TestRunButton disabled={playground.config.readonly} />
</ToolSection>
</ToolContainer>
);

View File

@ -3,17 +3,17 @@ import { useState } from 'react';
import { useService } from '@flowgram.ai/free-layout-editor';
import { Button } from '@douyinfe/semi-ui';
import { RunningService } from '../../services';
import { WorkflowRuntimeService } from '../../plugins/runtime-plugin/runtime-service';
/**
* Run the simulation and highlight the lines
*/
export function Run() {
const [isRunning, setRunning] = useState(false);
const runningService = useService(RunningService);
const runtimeService = useService(WorkflowRuntimeService);
const onRun = async () => {
setRunning(true);
await runningService.startRun();
await runtimeService.taskRun('{}');
setRunning(false);
};
return (

View File

@ -1,3 +1,5 @@
import { FC } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { TypeTag } from '../type-tag';
@ -5,13 +7,17 @@ import { JsonSchema } from '../../typings';
import { useIsSidebar } from '../../hooks';
import { FormOutputsContainer } from './styles';
export function FormOutputs() {
interface FormOutputsProps {
name?: string;
}
export const FormOutputs: FC<FormOutputsProps> = ({ name = 'outputs' }) => {
const isSidebar = useIsSidebar();
if (isSidebar) {
return null;
}
return (
<Field<JsonSchema> name={'outputs'}>
<Field<JsonSchema> name={name}>
{({ field }) => {
const properties = field.value?.properties;
if (properties) {
@ -25,4 +31,4 @@ export function FormOutputs() {
}}
</Field>
);
}
};

View File

@ -13,8 +13,9 @@ import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
import { onDragLineEnd } from '../utils';
import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
import { shortcuts } from '../shortcuts';
import { CustomService, RunningService } from '../services';
import { createSyncVariablePlugin, createContextMenuPlugin } from '../plugins';
import { CustomService } from '../services';
import { WorkflowRuntimeService } from '../plugins/runtime-plugin/runtime-service';
import { createSyncVariablePlugin, createRuntimePlugin, createContextMenuPlugin } from '../plugins';
import { defaultFormMeta } from '../nodes/default-form-meta';
import { WorkflowNodeType } from '../nodes';
import { SelectorBoxPopover } from '../components/selector-box-popover';
@ -160,7 +161,7 @@ export function useEditorProps(
/**
* Running line
*/
isFlowingLine: (ctx, line) => ctx.get(RunningService).isFlowingLine(line),
isFlowingLine: (ctx, line) => ctx.get(WorkflowRuntimeService).isFlowingLine(line),
/**
* Shortcuts
@ -171,7 +172,6 @@ export function useEditorProps(
*/
onBind: ({ bind }) => {
bind(CustomService).toSelf().inSingletonScope();
bind(RunningService).toSelf().inSingletonScope();
},
/**
* Playground init
@ -264,6 +264,15 @@ export function useEditorProps(
* ContextMenu plugin
*/
createContextMenuPlugin({}),
createRuntimePlugin({
mode: 'browser',
// mode: 'server',
// serverConfig: {
// domain: 'localhost',
// port: 4000,
// protocol: 'http',
// },
}),
],
}),
[]

View File

@ -48,7 +48,7 @@ export const initialData: FlowDocumentJSON = {
meta: {
position: {
x: 640,
y: 363.25,
y: 318.25,
},
},
data: {
@ -86,13 +86,13 @@ export const initialData: FlowDocumentJSON = {
type: 'end',
meta: {
position: {
x: 2220,
x: 2202.9953917050693,
y: 381.75,
},
},
data: {
title: 'End',
outputs: {
inputs: {
type: 'object',
properties: {
result: {
@ -102,160 +102,13 @@ export const initialData: FlowDocumentJSON = {
},
},
},
{
id: 'loop_H8M3U',
type: 'loop',
meta: {
position: {
x: 1020,
y: 547.96875,
},
},
data: {
title: 'Loop_2',
batchFor: {
type: 'ref',
content: ['start_0', 'array_obj'],
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
blocks: [
{
id: 'llm_CBdCg',
type: 'llm',
meta: {
position: {
x: 180,
y: 0,
},
},
data: {
title: 'LLM_4',
inputsValues: {
modelType: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: 'You are an AI assistant.',
},
prompt: {
type: 'constant',
content: '',
},
},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
properties: {
modelType: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
},
prompt: {
type: 'string',
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
{
id: 'llm_gZafu',
type: 'llm',
meta: {
position: {
x: 640,
y: 0,
},
},
data: {
title: 'LLM_5',
inputsValues: {
modelType: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: 'You are an AI assistant.',
},
prompt: {
type: 'constant',
content: '',
},
},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
properties: {
modelType: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
},
prompt: {
type: 'string',
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
],
edges: [
{
sourceNodeID: 'llm_CBdCg',
targetNodeID: 'llm_gZafu',
},
],
},
{
id: '159623',
type: 'comment',
meta: {
position: {
x: 640,
y: 522.46875,
y: 573.96875,
},
},
data: {
@ -267,35 +120,42 @@ export const initialData: FlowDocumentJSON = {
},
},
{
id: 'group_V-_st',
type: 'group',
id: 'loop_sGybT',
type: 'loop',
meta: {
position: {
x: 1020,
y: 96.25,
x: 1373.5714285714287,
y: 394.9758064516129,
},
},
data: {
title: 'LLM_Group',
color: 'Violet',
title: 'Loop_1',
},
blocks: [
{
id: 'llm_0',
id: 'llm_6aSyo',
type: 'llm',
meta: {
position: {
x: 640,
y: 0,
x: -196.8663594470046,
y: 142.0046082949309,
},
},
data: {
title: 'LLM_0',
title: 'LLM_3',
inputsValues: {
modelType: {
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,
@ -311,9 +171,15 @@ export const initialData: FlowDocumentJSON = {
},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelType: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {
@ -338,21 +204,29 @@ export const initialData: FlowDocumentJSON = {
},
},
{
id: 'llm_l_TcE',
id: 'llm_ZqKlP',
type: 'llm',
meta: {
position: {
x: 180,
y: 0,
x: 253.1797235023041,
y: 142.00460829493088,
},
},
data: {
title: 'LLM_1',
title: 'LLM_4',
inputsValues: {
modelType: {
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,
@ -368,9 +242,15 @@ export const initialData: FlowDocumentJSON = {
},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelType: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {
@ -397,18 +277,179 @@ export const initialData: FlowDocumentJSON = {
],
edges: [
{
sourceNodeID: 'llm_l_TcE',
targetNodeID: 'llm_0',
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: 'You are an AI assistant.',
},
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',
},
prompt: {
type: 'string',
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
{
sourceNodeID: 'llm_0',
targetNodeID: 'end_0',
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: 'You are an AI assistant.',
},
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',
},
prompt: {
type: 'string',
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
],
edges: [
{
sourceNodeID: 'condition_0',
targetNodeID: 'llm_l_TcE',
targetNodeID: 'llm_8--A3',
sourcePortID: 'if_0',
},
{
sourceNodeID: 'llm_8--A3',
targetNodeID: 'llm_vTyMa',
},
{
sourceNodeID: 'llm_vTyMa',
targetNodeID: 'end_0',
},
],
},
],
@ -419,20 +460,20 @@ export const initialData: FlowDocumentJSON = {
},
{
sourceNodeID: 'condition_0',
targetNodeID: 'llm_l_TcE',
targetNodeID: 'llm_8--A3',
sourcePortID: 'if_0',
},
{
sourceNodeID: 'condition_0',
targetNodeID: 'loop_H8M3U',
targetNodeID: 'loop_sGybT',
sourcePortID: 'if_f0rOAt',
},
{
sourceNodeID: 'llm_0',
sourceNodeID: 'llm_vTyMa',
targetNodeID: 'end_0',
},
{
sourceNodeID: 'loop_H8M3U',
sourceNodeID: 'loop_sGybT',
targetNodeID: 'end_0',
},
],

View File

@ -15,7 +15,7 @@ export const renderForm = () => {
<FormHeader />
<FormContent>
<Field
name="outputs.properties"
name="inputs.properties"
render={({
field: { value: propertiesSchemaValue, onChange: propertiesSchemaChange },
}: FieldRenderProps<Record<string, JsonSchema>>) => (
@ -43,7 +43,7 @@ export const renderForm = () => {
</Field>
)}
/>
<FormOutputs />
<FormOutputs name="inputs" />
</FormContent>
</>
);
@ -52,7 +52,7 @@ export const renderForm = () => {
<>
<FormHeader />
<FormContent>
<FormOutputs />
<FormOutputs name="inputs" />
</FormContent>
</>
);

View File

@ -15,7 +15,7 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
meta: {
size: {
width: 360,
height: 305,
height: 300,
},
},
onAdd() {
@ -25,10 +25,18 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
data: {
title: `LLM_${++index}`,
inputsValues: {
modelType: {
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,
@ -44,9 +52,15 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelType: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {

View File

@ -39,8 +39,8 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
* padding
*/
padding: () => ({
top: 125,
bottom: 100,
top: 120,
bottom: 60,
left: 100,
right: 100,
}),

View File

@ -14,6 +14,7 @@ interface LoopNodeJSON extends FlowNodeJSON {
export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
const isSidebar = useIsSidebar();
const { readonly } = useNodeRenderContext();
const formHeight = 85;
const batchFor = (
<Field<IFlowRefValue> name={`batchFor`}>
@ -48,7 +49,7 @@ export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
<FormHeader />
<FormContent>
{batchFor}
<SubCanvasRender />
<SubCanvasRender offsetY={-formHeight} />
<FormOutputs />
</FormContent>
</>

View File

@ -1,2 +1,3 @@
export { createSyncVariablePlugin } from './sync-variable-plugin/sync-variable-plugin';
export { createContextMenuPlugin } from './context-menu-plugin';
export { createRuntimePlugin } from './runtime-plugin';

View File

@ -0,0 +1,17 @@
/* eslint-disable no-console */
import { TaskCancelAPI, TaskReportAPI, TaskResultAPI, TaskRunAPI } from '@flowgram.ai/runtime-js';
import { FlowGramAPIName, IRuntimeClient } from '@flowgram.ai/runtime-interface';
import { injectable } from '@flowgram.ai/free-layout-editor';
@injectable()
export class WorkflowRuntimeClient implements IRuntimeClient {
constructor() {}
public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun] = TaskRunAPI;
public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport] = TaskReportAPI;
public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult] = TaskResultAPI;
public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel] = TaskCancelAPI;
}

View File

@ -0,0 +1,23 @@
import { definePluginCreator, PluginContext } from '@flowgram.ai/free-layout-editor';
import { RuntimePluginOptions } from './type';
import { WorkflowRuntimeServerClient } from './server-client';
import { WorkflowRuntimeService } from './runtime-service';
import { WorkflowRuntimeClient } from './browser-client';
export const createRuntimePlugin = definePluginCreator<RuntimePluginOptions, PluginContext>({
onBind({ bind, rebind }, options) {
bind(WorkflowRuntimeClient).toSelf().inSingletonScope();
bind(WorkflowRuntimeServerClient).toSelf().inSingletonScope();
if (options.mode === 'server') {
rebind(WorkflowRuntimeClient).to(WorkflowRuntimeServerClient);
}
bind(WorkflowRuntimeService).toSelf().inSingletonScope();
},
onInit(ctx, options) {
if (options.mode === 'server') {
const serverClient = ctx.get(WorkflowRuntimeServerClient);
serverClient.init(options.serverConfig);
}
},
});

View File

@ -0,0 +1,2 @@
export { createRuntimePlugin } from './create-runtime-plugin';
export { WorkflowRuntimeClient } from './browser-client';

View File

@ -0,0 +1,172 @@
import {
IReport,
NodeReport,
WorkflowInputs,
WorkflowOutputs,
WorkflowStatus,
} from '@flowgram.ai/runtime-interface';
import {
injectable,
inject,
WorkflowDocument,
Playground,
WorkflowLineEntity,
WorkflowNodeEntity,
WorkflowNodeLinesData,
Emitter,
getNodeForm,
} from '@flowgram.ai/free-layout-editor';
import { WorkflowRuntimeClient } from '../browser-client';
const SYNC_TASK_REPORT_INTERVAL = 500;
interface NodeRunningStatus {
nodeID: string;
status: WorkflowStatus;
nodeResultLength: number;
}
@injectable()
export class WorkflowRuntimeService {
@inject(Playground) playground: Playground;
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowRuntimeClient) runtimeClient: WorkflowRuntimeClient;
private runningNodes: WorkflowNodeEntity[] = [];
private taskID?: string;
private syncTaskReportIntervalID?: ReturnType<typeof setInterval>;
private reportEmitter = new Emitter<NodeReport>();
private resetEmitter = new Emitter<{}>();
public terminatedEmitter = new Emitter<{
result?: {
inputs: WorkflowInputs;
outputs: WorkflowOutputs;
};
}>();
private nodeRunningStatus: Map<string, NodeRunningStatus>;
public onNodeReportChange = this.reportEmitter.event;
public onReset = this.resetEmitter.event;
public onTerminated = this.terminatedEmitter.event;
public isFlowingLine(line: WorkflowLineEntity) {
return this.runningNodes.some((node) =>
node.getData(WorkflowNodeLinesData).inputLines.includes(line)
);
}
public async taskRun(inputsString: string): Promise<void> {
if (this.taskID) {
await this.taskCancel();
}
if (!this.validate()) {
return;
}
this.reset();
const output = await this.runtimeClient.TaskRun({
schema: JSON.stringify(this.document.toJSON()),
inputs: JSON.parse(inputsString) as WorkflowInputs,
});
if (!output) {
this.terminatedEmitter.fire({});
return;
}
this.taskID = output.taskID;
this.syncTaskReportIntervalID = setInterval(() => {
this.syncTaskReport();
}, SYNC_TASK_REPORT_INTERVAL);
}
public async taskCancel(): Promise<void> {
if (!this.taskID) {
return;
}
await this.runtimeClient.TaskCancel({
taskID: this.taskID,
});
}
private async validate(): Promise<boolean> {
const allForms = this.document.getAllNodes().map((node) => getNodeForm(node));
const formValidations = await Promise.all(allForms.map(async (form) => form?.validate()));
const validations = formValidations.filter((validation) => validation !== undefined);
const isValid = validations.every((validation) => validation);
return isValid;
}
private reset(): void {
this.taskID = undefined;
this.nodeRunningStatus = new Map();
this.runningNodes = [];
if (this.syncTaskReportIntervalID) {
clearInterval(this.syncTaskReportIntervalID);
}
this.resetEmitter.fire({});
}
private async syncTaskReport(): Promise<void> {
if (!this.taskID) {
return;
}
const output = await this.runtimeClient.TaskReport({
taskID: this.taskID,
});
if (!output) {
clearInterval(this.syncTaskReportIntervalID);
console.error('Sync task report failed');
return;
}
const { workflowStatus, inputs, outputs } = output;
if (workflowStatus.terminated) {
clearInterval(this.syncTaskReportIntervalID);
if (Object.keys(outputs).length > 0) {
this.terminatedEmitter.fire({ result: { inputs, outputs } });
} else {
this.terminatedEmitter.fire({});
}
}
this.updateReport(output);
}
private updateReport(report: IReport): void {
const { reports } = report;
this.runningNodes = [];
this.document.getAllNodes().forEach((node) => {
const nodeID = node.id;
const nodeReport = reports[nodeID];
if (!nodeReport) {
return;
}
if (nodeReport.status === WorkflowStatus.Processing) {
this.runningNodes.push(node);
}
const runningStatus = this.nodeRunningStatus.get(nodeID);
if (
!runningStatus ||
nodeReport.status !== runningStatus.status ||
nodeReport.snapshots.length !== runningStatus.nodeResultLength
) {
this.nodeRunningStatus.set(nodeID, {
nodeID,
status: nodeReport.status,
nodeResultLength: nodeReport.snapshots.length,
});
this.reportEmitter.fire(nodeReport);
this.document.linesManager.forceUpdate();
} else if (nodeReport.status === WorkflowStatus.Processing) {
this.reportEmitter.fire(nodeReport);
}
});
}
}

View File

@ -0,0 +1,7 @@
import { ServerConfig } from '../type';
export const DEFAULT_SERVER_CONFIG: ServerConfig = {
domain: 'localhost',
port: 4000,
protocol: 'http',
};

View File

@ -0,0 +1,134 @@
import {
FlowGramAPIName,
IRuntimeClient,
TaskCancelInput,
TaskCancelOutput,
TaskReportInput,
TaskReportOutput,
TaskResultInput,
TaskResultOutput,
TaskRunInput,
TaskRunOutput,
} from '@flowgram.ai/runtime-interface';
import { injectable } from '@flowgram.ai/free-layout-editor';
import type { ServerError } from './type';
import { DEFAULT_SERVER_CONFIG } from './constant';
import { ServerConfig } from '../type';
@injectable()
export class WorkflowRuntimeServerClient implements IRuntimeClient {
private config: ServerConfig = DEFAULT_SERVER_CONFIG;
constructor() {}
public init(config: ServerConfig) {
this.config = config;
}
public async [FlowGramAPIName.TaskRun](input: TaskRunInput): Promise<TaskRunOutput | undefined> {
try {
const body = JSON.stringify(input);
const response = await fetch(this.getURL('/api/task/run'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: body,
redirect: 'follow',
});
const output: TaskRunOutput | ServerError = await response.json();
if (this.isError(output)) {
console.error('TaskRun failed', output);
return;
}
return output;
} catch (e) {
console.error(e);
return;
}
}
public async [FlowGramAPIName.TaskReport](
input: TaskReportInput
): Promise<TaskReportOutput | undefined> {
try {
const response = await fetch(this.getURL(`/api/task/report?taskID=${input.taskID}`), {
method: 'GET',
redirect: 'follow',
});
const output: TaskReportOutput | ServerError = await response.json();
if (this.isError(output)) {
console.error('TaskReport failed', output);
return;
}
return output;
} catch (e) {
console.error(e);
return;
}
}
public async [FlowGramAPIName.TaskResult](
input: TaskResultInput
): Promise<TaskResultOutput | undefined> {
try {
const response = await fetch(this.getURL(`/api/task/result?taskID=${input.taskID}`), {
method: 'GET',
redirect: 'follow',
});
const output: TaskResultOutput | ServerError = await response.json();
if (this.isError(output)) {
console.error('TaskReport failed', output);
return {
success: false,
};
}
return output;
} catch (e) {
console.error(e);
return {
success: false,
};
}
}
public async [FlowGramAPIName.TaskCancel](input: TaskCancelInput): Promise<TaskCancelOutput> {
try {
const body = JSON.stringify(input);
const response = await fetch(this.getURL(`/api/task/cancel`), {
method: 'PUT',
redirect: 'follow',
headers: {
'Content-Type': 'application/json',
},
body,
});
const output: TaskCancelOutput | ServerError = await response.json();
if (this.isError(output)) {
console.error('TaskReport failed', output);
return {
success: false,
};
}
return output;
} catch (e) {
console.error(e);
return {
success: false,
};
}
}
private isError(output: unknown | undefined): output is ServerError {
return !!output && (output as ServerError).code !== undefined;
}
private getURL(path: string): string {
const protocol = this.config.protocol ?? window.location.protocol;
const host = this.config.port
? `${this.config.domain}:${this.config.port}`
: this.config.domain;
return `${protocol}://${host}${path}`;
}
}

View File

@ -0,0 +1,4 @@
export interface ServerError {
code: string;
message: string;
}

View File

@ -0,0 +1,16 @@
export interface RuntimeBrowserOptions {
mode?: 'browser';
}
export interface RuntimeServerOptions {
mode: 'server';
serverConfig: ServerConfig;
}
export type RuntimePluginOptions = RuntimeBrowserOptions | RuntimeServerOptions;
export interface ServerConfig {
domain: string;
port?: number;
protocol?: string;
}

View File

@ -1,2 +1 @@
export { CustomService } from './custom-service';
export { RunningService } from './running-service';

View File

@ -1,47 +0,0 @@
import {
injectable,
inject,
WorkflowDocument,
Playground,
delay,
WorkflowLineEntity,
WorkflowNodeEntity,
WorkflowNodeLinesData,
} from '@flowgram.ai/free-layout-editor';
const RUNNING_INTERVAL = 1000;
@injectable()
export class RunningService {
@inject(Playground) playground: Playground;
@inject(WorkflowDocument) document: WorkflowDocument;
private _runningNodes: WorkflowNodeEntity[] = [];
async addRunningNode(node: WorkflowNodeEntity): Promise<void> {
this._runningNodes.push(node);
node.renderData.node.classList.add('node-running');
this.document.linesManager.forceUpdate(); // Refresh line renderer
await delay(RUNNING_INTERVAL);
// Child Nodes
await Promise.all(node.blocks.map((nextNode) => this.addRunningNode(nextNode)));
// Sibling Nodes
const nextNodes = node.getData(WorkflowNodeLinesData).outputNodes;
await Promise.all(nextNodes.map((nextNode) => this.addRunningNode(nextNode)));
}
async startRun(): Promise<void> {
await this.addRunningNode(this.document.getNode('start_0')!);
this._runningNodes.forEach((node) => {
node.renderData.node.classList.remove('node-running');
});
this._runningNodes = [];
this.document.linesManager.forceUpdate();
}
isFlowingLine(line: WorkflowLineEntity) {
return this._runningNodes.some((node) =>
node.getData(WorkflowNodeLinesData).outputLines.includes(line)
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,15 +9,17 @@
"douyinfe",
"flowgram",
"flowgram.ai",
"gedit",
"Hoverable",
"langchain",
"openbracket",
"rsbuild",
"rspack",
"rspress",
"Sandpack",
"testrun",
"zoomin",
"zoomout",
"gedit"
"zoomout"
],
"ignoreWords": [],
"import": []

View File

@ -1,23 +1,20 @@
import React, { CSSProperties, type FC } from 'react';
import { useCurrentEntity } from '@flowgram.ai/free-layout-core';
import { SubCanvasRenderStyle } from './style';
import { SubCanvasTips } from '../tips';
import { SubCanvasBorder } from '../border';
import { SubCanvasBackground } from '../background';
import { useNodeSize, useSyncNodeRenderSize } from '../../hooks';
interface ISubCanvasBorder {
interface ISubCanvasRender {
offsetY: number;
className?: string;
style?: CSSProperties;
}
export const SubCanvasRender: FC<ISubCanvasBorder> = ({ className, style }) => {
const node = useCurrentEntity();
export const SubCanvasRender: FC<ISubCanvasRender> = ({ className, style, offsetY }) => {
const nodeSize = useNodeSize();
const nodeHeight = nodeSize?.height ?? 0;
const { padding } = node.transform;
useSyncNodeRenderSize(nodeSize);
@ -25,7 +22,7 @@ export const SubCanvasRender: FC<ISubCanvasBorder> = ({ className, style }) => {
<SubCanvasRenderStyle
className={`sub-canvas-render ${className ?? ''}`}
style={{
height: nodeHeight - padding.top,
height: nodeHeight + offsetY,
...style,
}}
data-flow-editor-selectable="true"

View File

@ -1,6 +1,7 @@
import styled from 'styled-components';
export const SubCanvasTipsStyle = styled.div`
pointer-events: auto;
position: absolute;
top: 0;

View File

@ -0,0 +1,6 @@
const { defineConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineConfig({
preset: 'base',
packageRoot: __dirname,
});

View File

@ -0,0 +1,44 @@
{
"name": "@flowgram.ai/runtime-interface",
"version": "0.1.8",
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"dev": "npm run watch",
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "exit 0",
"test:cov": "exit 0",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"zod": "^3.24.4"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"eslint": "^8.54.0",
"tsup": "^8.0.1",
"typescript": "^5.0.4",
"vitest": "^0.34.6"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

View File

@ -0,0 +1,22 @@
export enum FlowGramAPIMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE',
PATCH = 'PATCH',
}
export enum FlowGramAPIName {
ServerInfo = 'ServerInfo',
TaskRun = 'TaskRun',
TaskReport = 'TaskReport',
TaskResult = 'TaskResult',
TaskCancel = 'TaskCancel',
Validation = 'Validation',
}
export enum FlowGramAPIModule {
Info = 'Info',
Task = 'Task',
Validation = 'Validation',
}

View File

@ -0,0 +1,19 @@
import { ValidationDefine } from './validation';
import { FlowGramAPIDefines } from './type';
import { TaskRunDefine } from './task-run';
import { TaskResultDefine } from './task-result';
import { TaskReportDefine } from './task-report';
import { TaskCancelDefine } from './task-cancel';
import { ServerInfoDefine } from './server-info';
import { FlowGramAPIName } from './constant';
export const FlowGramAPIs: FlowGramAPIDefines = {
[FlowGramAPIName.ServerInfo]: ServerInfoDefine,
[FlowGramAPIName.TaskRun]: TaskRunDefine,
[FlowGramAPIName.TaskReport]: TaskReportDefine,
[FlowGramAPIName.TaskResult]: TaskResultDefine,
[FlowGramAPIName.TaskCancel]: TaskCancelDefine,
[FlowGramAPIName.Validation]: ValidationDefine,
};
export const FlowGramAPINames = Object.keys(FlowGramAPIs) as FlowGramAPIName[];

View File

@ -0,0 +1,10 @@
export * from './type';
export * from './define';
export * from './constant';
export * from './task-run';
export * from './server-info';
export * from './task-report';
export * from './validation';
export * from './task-result';
export * from './task-cancel';

View File

@ -0,0 +1,31 @@
import z from 'zod';
const WorkflowIOZodSchema = z.record(z.string(), z.any());
const WorkflowSnapshotZodSchema = z.object({
id: z.string(),
nodeID: z.string(),
inputs: WorkflowIOZodSchema,
outputs: WorkflowIOZodSchema.optional(),
data: WorkflowIOZodSchema,
branch: z.string().optional(),
});
const WorkflowStatusZodShape = {
status: z.string(),
terminated: z.boolean(),
startTime: z.number(),
endTime: z.number().optional(),
timeCost: z.number(),
};
const WorkflowStatusZodSchema = z.object(WorkflowStatusZodShape);
export const WorkflowZodSchema = {
Inputs: WorkflowIOZodSchema,
Outputs: WorkflowIOZodSchema,
Status: WorkflowStatusZodSchema,
Snapshot: WorkflowSnapshotZodSchema,
NodeReport: z.object({
id: z.string(),
...WorkflowStatusZodShape,
snapshots: z.array(WorkflowSnapshotZodSchema),
}),
};

View File

@ -0,0 +1,31 @@
import z from 'zod';
import { type FlowGramAPIDefine } from '@api/type';
import { FlowGramAPIMethod, FlowGramAPIModule, FlowGramAPIName } from '@api/constant';
export interface ServerInfoInput {}
export interface ServerInfoOutput {
name: string;
title: string;
description: string;
runtime: string;
version: string;
time: string;
}
export const ServerInfoDefine: FlowGramAPIDefine = {
name: FlowGramAPIName.ServerInfo,
method: FlowGramAPIMethod.GET,
path: '/info',
module: FlowGramAPIModule.Info,
schema: {
input: z.undefined(),
output: z.object({
name: z.string(),
runtime: z.string(),
version: z.string(),
time: z.string(),
}),
},
};

View File

@ -0,0 +1,27 @@
import z from 'zod';
import { FlowGramAPIDefine } from '@api/type';
import { FlowGramAPIName, FlowGramAPIMethod, FlowGramAPIModule } from '@api/constant';
export interface TaskCancelInput {
taskID: string;
}
export type TaskCancelOutput = {
success: boolean;
};
export const TaskCancelDefine: FlowGramAPIDefine = {
name: FlowGramAPIName.TaskCancel,
method: FlowGramAPIMethod.PUT,
path: '/task/cancel',
module: FlowGramAPIModule.Task,
schema: {
input: z.object({
taskID: z.string(),
}),
output: z.object({
success: z.boolean(),
}),
},
};

View File

@ -0,0 +1,31 @@
import z from 'zod';
import { IReport } from '@runtime/index';
import { FlowGramAPIDefine } from '@api/type';
import { WorkflowZodSchema } from '@api/schema';
import { FlowGramAPIName, FlowGramAPIMethod, FlowGramAPIModule } from '@api/constant';
export interface TaskReportInput {
taskID: string;
}
export type TaskReportOutput = IReport | undefined;
export const TaskReportDefine: FlowGramAPIDefine = {
name: FlowGramAPIName.TaskReport,
method: FlowGramAPIMethod.GET,
path: '/task/report',
module: FlowGramAPIModule.Task,
schema: {
input: z.object({
taskID: z.string(),
}),
output: z.object({
id: z.string(),
inputs: WorkflowZodSchema.Inputs,
outputs: WorkflowZodSchema.Outputs,
workflowStatus: WorkflowZodSchema.Status,
reports: z.record(z.string(), WorkflowZodSchema.NodeReport),
}),
},
};

View File

@ -0,0 +1,25 @@
import z from 'zod';
import { WorkflowOutputs } from '@runtime/index';
import { FlowGramAPIDefine } from '@api/type';
import { WorkflowZodSchema } from '@api/schema';
import { FlowGramAPIName, FlowGramAPIMethod, FlowGramAPIModule } from '@api/constant';
export interface TaskResultInput {
taskID: string;
}
export type TaskResultOutput = WorkflowOutputs | undefined;
export const TaskResultDefine: FlowGramAPIDefine = {
name: FlowGramAPIName.TaskResult,
method: FlowGramAPIMethod.GET,
path: '/task/result',
module: FlowGramAPIModule.Task,
schema: {
input: z.object({
taskID: z.string(),
}),
output: WorkflowZodSchema.Outputs,
},
};

View File

@ -0,0 +1,31 @@
import z from 'zod';
import { WorkflowInputs } from '@runtime/index';
import { FlowGramAPIDefine } from '@api/type';
import { WorkflowZodSchema } from '@api/schema';
import { FlowGramAPIMethod, FlowGramAPIModule, FlowGramAPIName } from '@api/constant';
export interface TaskRunInput {
inputs: WorkflowInputs;
schema: string;
}
export interface TaskRunOutput {
taskID: string;
}
export const TaskRunDefine: FlowGramAPIDefine = {
name: FlowGramAPIName.TaskRun,
method: FlowGramAPIMethod.POST,
path: '/task/run',
module: FlowGramAPIModule.Task,
schema: {
input: z.object({
schema: z.string(),
inputs: WorkflowZodSchema.Inputs,
}),
output: z.object({
taskID: z.string(),
}),
},
};

View File

@ -0,0 +1,18 @@
import type z from 'zod';
import { FlowGramAPIMethod, FlowGramAPIModule, FlowGramAPIName } from './constant';
export interface FlowGramAPIDefine {
name: FlowGramAPIName;
method: FlowGramAPIMethod;
path: `/${string}`;
module: FlowGramAPIModule;
schema: {
input: z.ZodFirstPartySchemaTypes;
output: z.ZodFirstPartySchemaTypes;
};
}
export interface FlowGramAPIDefines {
[key: string]: FlowGramAPIDefine;
}

View File

@ -0,0 +1,43 @@
import z from 'zod';
import { ValidationResult } from '@runtime/index';
import { FlowGramAPIDefine } from '@api/type';
import { FlowGramAPIMethod, FlowGramAPIModule, FlowGramAPIName } from '@api/constant';
export interface ValidationReq {
schema: string;
}
export interface ValidationRes extends ValidationResult {}
export const ValidationDefine: FlowGramAPIDefine = {
name: FlowGramAPIName.Validation,
method: FlowGramAPIMethod.POST,
path: '/validation',
module: FlowGramAPIModule.Validation,
schema: {
input: z.object({
schema: z.string(),
}),
output: z.object({
valid: z.boolean(),
nodeErrors: z.array(
z.object({
message: z.string(),
nodeID: z.string(),
})
),
edgeErrors: z.array(
z.object({
message: z.string(),
edge: z.object({
sourceNodeID: z.string(),
targetNodeID: z.string(),
sourcePortID: z.string().optional(),
targetPortID: z.string().optional(),
}),
})
),
}),
},
};

View File

@ -0,0 +1,18 @@
import type {
FlowGramAPIName,
TaskCancelInput,
TaskCancelOutput,
TaskReportInput,
TaskReportOutput,
TaskResultInput,
TaskResultOutput,
TaskRunInput,
TaskRunOutput,
} from '@api/index';
export interface IRuntimeClient {
[FlowGramAPIName.TaskRun]: (input: TaskRunInput) => Promise<TaskRunOutput | undefined>;
[FlowGramAPIName.TaskReport]: (input: TaskReportInput) => Promise<TaskReportOutput | undefined>;
[FlowGramAPIName.TaskResult]: (input: TaskResultInput) => Promise<TaskResultOutput | undefined>;
[FlowGramAPIName.TaskCancel]: (input: TaskCancelInput) => Promise<TaskCancelOutput | undefined>;
}

View File

@ -0,0 +1,5 @@
export * from './api';
export * from './schema';
export * from './node';
export * from './runtime';
export * from './client';

View File

@ -0,0 +1,11 @@
export enum FlowGramNode {
Root = 'root',
Start = 'start',
End = 'end',
LLM = 'llm',
code = 'code',
Condition = 'condition',
Loop = 'loop',
Comment = 'comment',
Group = 'group',
}

View File

@ -0,0 +1,13 @@
import { IFlowConstantRefValue } from '@schema/value';
import { WorkflowNodeSchema } from '@schema/node';
import { IJsonSchema } from '@schema/json-schema';
import { FlowGramNode } from '@node/constant';
interface EndNodeData {
title: string;
inputs: IJsonSchema<'object'>;
outputs: IJsonSchema<'object'>;
outputValues: Record<string, IFlowConstantRefValue>;
}
export type EndNodeSchema = WorkflowNodeSchema<FlowGramNode.End, EndNodeData>;

View File

@ -0,0 +1,4 @@
export { FlowGramNode } from './constant';
export { EndNodeSchema } from './end';
export { LLMNodeSchema } from './llm';
export { StartNodeSchema } from './start';

View File

@ -0,0 +1,20 @@
import { IFlowConstantRefValue } from '@schema/value';
import { WorkflowNodeSchema } from '@schema/node';
import { IJsonSchema } from '@schema/json-schema';
import { FlowGramNode } from '@node/constant';
interface LLMNodeData {
title: string;
inputs: IJsonSchema<'object'>;
outputs: IJsonSchema<'object'>;
inputValues: {
apiKey: IFlowConstantRefValue;
modelType: IFlowConstantRefValue;
baseURL: IFlowConstantRefValue;
temperature: IFlowConstantRefValue;
systemPrompt: IFlowConstantRefValue;
prompt: IFlowConstantRefValue;
};
}
export type LLMNodeSchema = WorkflowNodeSchema<FlowGramNode.LLM, LLMNodeData>;

View File

@ -0,0 +1,10 @@
import { WorkflowNodeSchema } from '@schema/node';
import { IJsonSchema } from '@schema/json-schema';
import { FlowGramNode } from '@node/constant';
interface StartNodeData {
title: string;
outputs: IJsonSchema<'object'>;
}
export type StartNodeSchema = WorkflowNodeSchema<FlowGramNode.Start, StartNodeData>;

View File

@ -0,0 +1,3 @@
export type { VOData } from './value-object';
export type { InvokeParams, WorkflowRuntimeInvoke } from './invoke';
export type { WorkflowInputs, WorkflowOutputs } from './inputs-outputs';

View File

@ -0,0 +1,2 @@
export type WorkflowInputs = Record<string, any>;
export type WorkflowOutputs = Record<string, any>;

View File

@ -0,0 +1,9 @@
import { WorkflowSchema } from '@schema/index';
import { WorkflowInputs } from './inputs-outputs';
export interface InvokeParams {
schema: WorkflowSchema;
inputs: WorkflowInputs;
}
export type WorkflowRuntimeInvoke = (params: InvokeParams) => Promise<WorkflowInputs>;

View File

@ -0,0 +1 @@
export type VOData<T> = Omit<T, 'id'>;

View File

@ -0,0 +1,5 @@
export type ContainerService = any;
export interface IContainer {
get<T = ContainerService>(key: any): T;
}

View File

@ -0,0 +1,25 @@
import { IVariableStore } from '@runtime/variable';
import { IStatusCenter } from '@runtime/status';
import { ISnapshotCenter } from '@runtime/snapshot';
import { IIOCenter } from '@runtime/io-center';
import { IState } from '../state';
import { IReporter } from '../reporter';
import { IDocument } from '../document';
import { InvokeParams } from '../base';
export interface ContextData {
variableStore: IVariableStore;
state: IState;
document: IDocument;
ioCenter: IIOCenter;
snapshotCenter: ISnapshotCenter;
statusCenter: IStatusCenter;
reporter: IReporter;
}
export interface IContext extends ContextData {
id: string;
init(params: InvokeParams): void;
dispose(): void;
sub(): IContext;
}

View File

@ -0,0 +1,14 @@
import { WorkflowSchema } from '@schema/index';
import { INode } from './node';
import { IEdge } from './edge';
export interface IDocument {
id: string;
nodes: INode[];
edges: IEdge[];
root: INode;
start: INode;
end: INode;
init(schema: WorkflowSchema): void;
dispose(): void;
}

View File

@ -0,0 +1,16 @@
import { IPort } from './port';
import { INode } from './node';
export interface IEdge {
id: string;
from: INode;
to: INode;
fromPort: IPort;
toPort: IPort;
}
export interface CreateEdgeParams {
id: string;
from: INode;
to: INode;
}

View File

@ -0,0 +1,4 @@
export type { IDocument } from './document';
export type { IEdge, CreateEdgeParams } from './edge';
export type { NodeDeclare as NodeVariable, INode, CreateNodeParams } from './node';
export type { IPort, CreatePortParams } from './port';

View File

@ -0,0 +1,41 @@
import { IFlowConstantRefValue, IJsonSchema, PositionSchema } from '@schema/index';
import { FlowGramNode } from '@node/constant';
import { IPort } from './port';
import { IEdge } from './edge';
export interface NodeDeclare {
inputsValues?: Record<string, IFlowConstantRefValue>;
inputs?: IJsonSchema;
outputs?: IJsonSchema;
}
export interface INode<T = any> {
id: string;
type: FlowGramNode;
name: string;
position: PositionSchema;
declare: NodeDeclare;
data: T;
ports: {
inputs: IPort[];
outputs: IPort[];
};
edges: {
inputs: IEdge[];
outputs: IEdge[];
};
parent: INode | null;
children: INode[];
prev: INode[];
next: INode[];
isBranch: boolean;
}
export interface CreateNodeParams {
id: string;
type: FlowGramNode;
name: string;
position: PositionSchema;
variable?: NodeDeclare;
data?: any;
}

View File

@ -0,0 +1,16 @@
import { WorkflowPortType } from '@schema/index';
import { INode } from './node';
import { IEdge } from './edge';
export interface IPort {
id: string;
node: INode;
edges: IEdge[];
type: WorkflowPortType;
}
export interface CreatePortParams {
id: string;
node: INode;
type: WorkflowPortType;
}

View File

@ -0,0 +1,16 @@
import { ITask } from '../task';
import { IExecutor } from '../executor';
import { INode } from '../document';
import { IContext } from '../context';
import { InvokeParams } from '../base';
export interface EngineServices {
Executor: IExecutor;
}
export interface IEngine {
invoke(params: InvokeParams): ITask;
executeNode(params: { context: IContext; node: INode }): Promise<void>;
}
export const IEngine = Symbol.for('Engine');

View File

@ -0,0 +1,8 @@
import { ExecutionContext, ExecutionResult, INodeExecutor } from './node-executor';
export interface IExecutor {
execute: (context: ExecutionContext) => Promise<ExecutionResult>;
register: (executor: INodeExecutor) => void;
}
export const IExecutor = Symbol.for('Executor');

View File

@ -0,0 +1,7 @@
export { IExecutor } from './executor';
export type {
ExecutionContext,
ExecutionResult,
INodeExecutor,
INodeExecutorFactory,
} from './node-executor';

View File

@ -0,0 +1,26 @@
import { FlowGramNode } from '@node/index';
import { INode } from '../document';
import { IContext } from '../context';
import { IContainer } from '../container';
import { WorkflowInputs, WorkflowOutputs } from '../base';
export interface ExecutionContext {
node: INode;
inputs: WorkflowInputs;
container: IContainer;
runtime: IContext;
}
export interface ExecutionResult {
outputs: WorkflowOutputs;
branch?: string;
}
export interface INodeExecutor {
type: FlowGramNode;
execute: (context: ExecutionContext) => Promise<ExecutionResult>;
}
export interface INodeExecutorFactory {
new (): INodeExecutor;
}

View File

@ -0,0 +1,14 @@
export * from './container';
export * from './base';
export * from './engine';
export * from './context';
export * from './document';
export * from './executor';
export * from './io-center';
export * from './snapshot';
export * from './reporter';
export * from './state';
export * from './status';
export * from './task';
export * from './validation';
export * from './variable';

View File

@ -0,0 +1,17 @@
import { WorkflowInputs, WorkflowOutputs } from '../base';
export interface IOData {
inputs: WorkflowInputs;
outputs: WorkflowOutputs;
}
/** Input & Output */
export interface IIOCenter {
inputs: WorkflowInputs;
outputs: WorkflowOutputs;
setInputs(inputs: WorkflowInputs): void;
setOutputs(outputs: WorkflowOutputs): void;
init(inputs: WorkflowInputs): void;
dispose(): void;
export(): IOData;
}

View File

@ -0,0 +1,23 @@
import { StatusData, IStatusCenter } from '../status';
import { Snapshot, ISnapshotCenter } from '../snapshot';
import { WorkflowInputs, WorkflowOutputs } from '../base';
export interface NodeReport extends StatusData {
id: string;
snapshots: Snapshot[];
}
export interface IReport {
id: string;
inputs: WorkflowInputs;
outputs: WorkflowOutputs;
workflowStatus: StatusData;
reports: Record<string, NodeReport>;
}
export interface IReporter {
snapshotCenter: ISnapshotCenter;
statusCenter: IStatusCenter;
init(): void;
dispose(): void;
export(): IReport;
}

View File

@ -0,0 +1,2 @@
export type { ISnapshot, Snapshot, SnapshotData } from './snapshot';
export type { ISnapshotCenter } from './snapshot-center';

View File

@ -0,0 +1,10 @@
import { ISnapshot, Snapshot, SnapshotData } from './snapshot';
export interface ISnapshotCenter {
id: string;
create(snapshot: Partial<SnapshotData>): ISnapshot;
exportAll(): Snapshot[];
export(): Record<string, Snapshot[]>;
init(): void;
dispose(): void;
}

View File

@ -0,0 +1,21 @@
import { WorkflowInputs, WorkflowOutputs } from '../base';
export interface SnapshotData {
nodeID: string;
inputs: WorkflowInputs;
outputs: WorkflowOutputs;
data: any;
branch?: string;
}
export interface Snapshot extends SnapshotData {
id: string;
}
export interface ISnapshot {
id: string;
data: Partial<SnapshotData>;
addData(data: Partial<SnapshotData>): void;
validate(): boolean;
export(): Snapshot;
}

View File

@ -0,0 +1,20 @@
import { IFlowConstantRefValue, IFlowRefValue, WorkflowVariableType } from '@schema/index';
import { IVariableParseResult, IVariableStore } from '../variable';
import { INode } from '../document';
import { WorkflowInputs, WorkflowOutputs } from '../base';
export interface IState {
id: string;
variableStore: IVariableStore;
init(): void;
dispose(): void;
getNodeInputs(node: INode): WorkflowInputs;
setNodeOutputs(params: { node: INode; outputs: WorkflowOutputs }): void;
parseRef<T = unknown>(ref: IFlowRefValue): IVariableParseResult<T> | null;
parseValue<T = unknown>(
flowValue: IFlowConstantRefValue,
type?: WorkflowVariableType
): IVariableParseResult<T> | null;
isExecutedNode(node: INode): boolean;
addExecutedNode(node: INode): void;
}

View File

@ -0,0 +1,33 @@
export enum WorkflowStatus {
Pending = 'pending',
Processing = 'processing',
Succeeded = 'succeeded',
Failed = 'failed',
Canceled = 'canceled',
}
export interface StatusData {
status: WorkflowStatus;
terminated: boolean;
startTime: number;
endTime?: number;
timeCost: number;
}
export interface IStatus extends StatusData {
id: string;
process(): void;
success(): void;
fail(): void;
cancel(): void;
export(): StatusData;
}
export interface IStatusCenter {
workflow: IStatus;
nodeStatus(nodeID: string): IStatus;
init(): void;
dispose(): void;
getStatusNodeIDs(status: WorkflowStatus): string[];
exportNodeStatus(): Record<string, StatusData>;
}

View File

@ -0,0 +1,14 @@
import { IContext } from '../context';
import { WorkflowOutputs } from '../base';
export interface ITask {
id: string;
processing: Promise<WorkflowOutputs>;
context: IContext;
cancel(): void;
}
export interface TaskParams {
processing: Promise<WorkflowOutputs>;
context: IContext;
}

View File

@ -0,0 +1,12 @@
import { WorkflowSchema } from '@schema/index';
export interface ValidationResult {
valid: boolean;
errors?: string[];
}
export interface IValidation {
validate(schema: WorkflowSchema): ValidationResult;
}
export const IValidation = Symbol.for('Validation');

View File

@ -0,0 +1,44 @@
import { WorkflowVariableType } from '@schema/index';
interface VariableTypeInfo {
type: WorkflowVariableType;
itemsType?: WorkflowVariableType;
}
export interface IVariable<T = Object> extends VariableTypeInfo {
id: string;
nodeID: string;
key: string;
value: T;
}
export interface IVariableParseResult<T = unknown> extends VariableTypeInfo {
value: T;
type: WorkflowVariableType;
}
export interface IVariableStore {
id: string;
store: Map<string, Map<string, IVariable>>;
setParent(parent: IVariableStore): void;
setVariable(
params: {
nodeID: string;
key: string;
value: Object;
} & VariableTypeInfo
): void;
setValue(params: {
nodeID: string;
variableKey: string;
variablePath?: string[];
value: Object;
}): void;
getValue<T = unknown>(params: {
nodeID: string;
variableKey: string;
variablePath?: string[];
}): IVariableParseResult<T> | null;
init(): void;
dispose(): void;
}

View File

@ -0,0 +1,14 @@
export enum WorkflowPortType {
Input = 'input',
Output = 'output',
}
export enum WorkflowVariableType {
String = 'string',
Integer = 'integer',
Number = 'number',
Boolean = 'boolean',
Object = 'object',
Array = 'array',
Null = 'null',
}

View File

@ -0,0 +1,6 @@
export interface WorkflowEdgeSchema {
sourceNodeID: string;
targetNodeID: string;
sourcePortID?: string;
targetPortID?: string;
}

View File

@ -0,0 +1,8 @@
export { WorkflowEdgeSchema } from './edge';
export { JsonSchemaBasicType, IJsonSchema, IBasicJsonSchema } from './json-schema';
export { WorkflowNodeMetaSchema } from './node-meta';
export { WorkflowNodeSchema } from './node';
export { WorkflowSchema } from './workflow';
export { XYSchema, PositionSchema } from './xy';
export { WorkflowPortType, WorkflowVariableType } from './constant';
export { IFlowConstantRefValue, IFlowConstantValue, IFlowRefValue } from './value';

View File

@ -0,0 +1,33 @@
// TODO copy packages/materials/form-materials/src/typings/json-schema/index.ts
export type JsonSchemaBasicType =
| 'boolean'
| 'string'
| 'integer'
| 'number'
| 'object'
| 'array'
| 'map';
export interface IJsonSchema<T = string> {
type: T;
default?: any;
title?: string;
description?: string;
enum?: (string | number)[];
properties?: Record<string, IJsonSchema<T>>;
additionalProperties?: IJsonSchema<T>;
items?: IJsonSchema<T>;
required?: string[];
$ref?: string;
extra?: {
index?: number;
// Used in BaseType.isEqualWithJSONSchema, the type comparison will be weak
weak?: boolean;
// Set the render component
formComponent?: string;
[key: string]: any;
};
}
export type IBasicJsonSchema = IJsonSchema<JsonSchemaBasicType>;

View File

@ -0,0 +1,6 @@
import { PositionSchema } from './xy';
export interface WorkflowNodeMetaSchema {
position: PositionSchema;
canvasPosition?: PositionSchema;
}

View File

@ -0,0 +1,19 @@
import type { IFlowConstantRefValue } from './value';
import type { WorkflowNodeMetaSchema } from './node-meta';
import { IJsonSchema } from './json-schema';
import type { WorkflowEdgeSchema } from './edge';
export interface WorkflowNodeSchema<T = string, D = any> {
id: string;
type: T;
meta: WorkflowNodeMetaSchema;
data: D & {
title?: string;
inputsValues?: Record<string, IFlowConstantRefValue>;
inputs?: IJsonSchema;
outputs?: IJsonSchema;
[key: string]: any;
};
blocks?: WorkflowNodeSchema[];
edges?: WorkflowEdgeSchema[];
}

View File

@ -0,0 +1,29 @@
// TODO copy packages/materials/form-materials/src/typings/flow-value/index.ts
export interface IFlowConstantValue {
type: 'constant';
content?: string | number | boolean;
}
export interface IFlowRefValue {
type: 'ref';
content?: string[];
}
export interface IFlowExpressionValue {
type: 'expression';
content?: string;
}
export interface IFlowTemplateValue {
type: 'template';
content?: string;
}
export type IFlowValue =
| IFlowConstantValue
| IFlowRefValue
| IFlowExpressionValue
| IFlowTemplateValue;
export type IFlowConstantRefValue = IFlowConstantValue | IFlowRefValue;

View File

@ -0,0 +1,7 @@
import type { WorkflowNodeSchema } from './node';
import type { WorkflowEdgeSchema } from './edge';
export interface WorkflowSchema {
nodes: WorkflowNodeSchema[];
edges: WorkflowEdgeSchema[];
}

View File

@ -0,0 +1,6 @@
export interface XYSchema {
x: number;
y: number;
}
export type PositionSchema = XYSchema;

View File

@ -0,0 +1,29 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@api/*": [
"api/*"
],
"@node/*": [
"node/*"
],
"@runtime/*": [
"runtime/*"
],
"@schema/*": [
"schema/*"
],
"@client/*": [
"client/*"
]
}
},
"include": [
"./src"
],
"exclude": [
"node_modules"
]
}

View File

@ -0,0 +1,6 @@
const { defineConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineConfig({
preset: 'base',
packageRoot: __dirname,
});

View File

@ -0,0 +1,53 @@
{
"name": "@flowgram.ai/runtime-js",
"version": "0.1.8",
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"dev": "npm run watch",
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"@langchain/openai": "^0.5.11",
"@langchain/core": "^0.3.57",
"lodash-es": "^4.17.21",
"uuid": "^9.0.0",
"zod": "^3.24.4"
},
"devDependencies": {
"@flowgram.ai/runtime-interface": "workspace:*",
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"@types/lodash-es": "^4.17.12",
"@types/uuid": "^9.0.1",
"@vitest/coverage-v8": "^0.32.0",
"eslint": "^8.54.0",
"dotenv": "~16.5.0",
"tsup": "^8.0.1",
"typescript": "^5.0.4",
"vitest": "^0.34.6"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

View File

@ -0,0 +1,17 @@
import { FlowGramAPIName } from '@flowgram.ai/runtime-interface';
import { TaskRunAPI } from './task-run';
import { TaskResultAPI } from './task-result';
import { TaskReportAPI } from './task-report';
import { TaskCancelAPI } from './task-cancel';
export { TaskRunAPI, TaskResultAPI, TaskReportAPI, TaskCancelAPI };
export const WorkflowRuntimeAPIs: Record<FlowGramAPIName, (i: any) => any> = {
[FlowGramAPIName.TaskRun]: TaskRunAPI,
[FlowGramAPIName.TaskReport]: TaskReportAPI,
[FlowGramAPIName.TaskResult]: TaskResultAPI,
[FlowGramAPIName.TaskCancel]: TaskCancelAPI,
[FlowGramAPIName.ServerInfo]: () => {}, // TODO
[FlowGramAPIName.Validation]: () => {}, // TODO
};

View File

@ -0,0 +1,13 @@
import { TaskCancelInput, TaskCancelOutput } from '@flowgram.ai/runtime-interface';
import { WorkflowApplication } from '@application/workflow';
export const TaskCancelAPI = async (input: TaskCancelInput): Promise<TaskCancelOutput> => {
const app = WorkflowApplication.instance;
const { taskID } = input;
const success = app.cancel(taskID);
const output: TaskCancelOutput = {
success,
};
return output;
};

Some files were not shown because too many files have changed in this diff Show More