ソースを参照

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
Louis Young 7 ヶ月 前
コミット
aab4183d65
100 ファイル変更3292 行追加188 行削除
  1. 6 7
      .vscode/settings.json
  2. 2 0
      apps/demo-free-layout/package.json
  3. 2 0
      apps/demo-free-layout/src/components/base-node/index.tsx
  4. 8 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.css
  5. 61 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.tsx
  6. 52 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/header/index.tsx
  7. 56 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/header/style.ts
  8. 32 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/icon/success.tsx
  9. 22 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/icon/warning.tsx
  10. 42 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/index.tsx
  11. 14 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.css
  12. 233 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.tsx
  13. 137 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.css
  14. 154 0
      apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.tsx
  15. 78 0
      apps/demo-free-layout/src/components/testrun/testrun-button/index.tsx
  16. 138 0
      apps/demo-free-layout/src/components/testrun/testrun-sidesheet/index.tsx
  17. 2 4
      apps/demo-free-layout/src/components/tools/index.tsx
  18. 3 3
      apps/demo-free-layout/src/components/tools/run.tsx
  19. 9 3
      apps/demo-free-layout/src/form-components/form-outputs/index.tsx
  20. 13 4
      apps/demo-free-layout/src/hooks/use-editor-props.tsx
  21. 124 83
      apps/demo-free-layout/src/initial-data.ts
  22. 3 3
      apps/demo-free-layout/src/nodes/end/form-meta.tsx
  23. 18 4
      apps/demo-free-layout/src/nodes/llm/index.ts
  24. 2 2
      apps/demo-free-layout/src/nodes/loop/index.ts
  25. 2 1
      apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx
  26. 1 0
      apps/demo-free-layout/src/plugins/index.ts
  27. 17 0
      apps/demo-free-layout/src/plugins/runtime-plugin/browser-client/index.ts
  28. 23 0
      apps/demo-free-layout/src/plugins/runtime-plugin/create-runtime-plugin.ts
  29. 2 0
      apps/demo-free-layout/src/plugins/runtime-plugin/index.ts
  30. 172 0
      apps/demo-free-layout/src/plugins/runtime-plugin/runtime-service/index.ts
  31. 7 0
      apps/demo-free-layout/src/plugins/runtime-plugin/server-client/constant.ts
  32. 134 0
      apps/demo-free-layout/src/plugins/runtime-plugin/server-client/index.ts
  33. 4 0
      apps/demo-free-layout/src/plugins/runtime-plugin/server-client/type.ts
  34. 16 0
      apps/demo-free-layout/src/plugins/runtime-plugin/type.ts
  35. 0 1
      apps/demo-free-layout/src/services/index.ts
  36. 0 47
      apps/demo-free-layout/src/services/running-service.ts
  37. 626 17
      common/config/rush/pnpm-lock.yaml
  38. 4 2
      cspell.json
  39. 4 7
      packages/plugins/free-container-plugin/src/sub-canvas/components/render/index.tsx
  40. 1 0
      packages/plugins/free-container-plugin/src/sub-canvas/components/tips/style.ts
  41. 6 0
      packages/runtime/interface/.eslintrc.cjs
  42. 44 0
      packages/runtime/interface/package.json
  43. 22 0
      packages/runtime/interface/src/api/constant.ts
  44. 19 0
      packages/runtime/interface/src/api/define.ts
  45. 10 0
      packages/runtime/interface/src/api/index.ts
  46. 31 0
      packages/runtime/interface/src/api/schema.ts
  47. 31 0
      packages/runtime/interface/src/api/server-info/index.ts
  48. 27 0
      packages/runtime/interface/src/api/task-cancel/index.ts
  49. 31 0
      packages/runtime/interface/src/api/task-report/index.ts
  50. 25 0
      packages/runtime/interface/src/api/task-result/index.ts
  51. 31 0
      packages/runtime/interface/src/api/task-run/index.ts
  52. 18 0
      packages/runtime/interface/src/api/type.ts
  53. 43 0
      packages/runtime/interface/src/api/validation/index.ts
  54. 18 0
      packages/runtime/interface/src/client/index.ts
  55. 5 0
      packages/runtime/interface/src/index.ts
  56. 11 0
      packages/runtime/interface/src/node/constant.ts
  57. 13 0
      packages/runtime/interface/src/node/end/index.ts
  58. 4 0
      packages/runtime/interface/src/node/index.ts
  59. 20 0
      packages/runtime/interface/src/node/llm/index.ts
  60. 10 0
      packages/runtime/interface/src/node/start/index.ts
  61. 3 0
      packages/runtime/interface/src/runtime/base/index.ts
  62. 2 0
      packages/runtime/interface/src/runtime/base/inputs-outputs.ts
  63. 9 0
      packages/runtime/interface/src/runtime/base/invoke.ts
  64. 1 0
      packages/runtime/interface/src/runtime/base/value-object.ts
  65. 5 0
      packages/runtime/interface/src/runtime/container/index.ts
  66. 25 0
      packages/runtime/interface/src/runtime/context/index.ts
  67. 14 0
      packages/runtime/interface/src/runtime/document/document.ts
  68. 16 0
      packages/runtime/interface/src/runtime/document/edge.ts
  69. 4 0
      packages/runtime/interface/src/runtime/document/index.ts
  70. 41 0
      packages/runtime/interface/src/runtime/document/node.ts
  71. 16 0
      packages/runtime/interface/src/runtime/document/port.ts
  72. 16 0
      packages/runtime/interface/src/runtime/engine/index.ts
  73. 8 0
      packages/runtime/interface/src/runtime/executor/executor.ts
  74. 7 0
      packages/runtime/interface/src/runtime/executor/index.ts
  75. 26 0
      packages/runtime/interface/src/runtime/executor/node-executor.ts
  76. 14 0
      packages/runtime/interface/src/runtime/index.ts
  77. 17 0
      packages/runtime/interface/src/runtime/io-center/index.ts
  78. 23 0
      packages/runtime/interface/src/runtime/reporter/index.ts
  79. 2 0
      packages/runtime/interface/src/runtime/snapshot/index.ts
  80. 10 0
      packages/runtime/interface/src/runtime/snapshot/snapshot-center.ts
  81. 21 0
      packages/runtime/interface/src/runtime/snapshot/snapshot.ts
  82. 20 0
      packages/runtime/interface/src/runtime/state/index.ts
  83. 33 0
      packages/runtime/interface/src/runtime/status/index.ts
  84. 14 0
      packages/runtime/interface/src/runtime/task/index.ts
  85. 12 0
      packages/runtime/interface/src/runtime/validation/index.ts
  86. 44 0
      packages/runtime/interface/src/runtime/variable/index.ts
  87. 14 0
      packages/runtime/interface/src/schema/constant.ts
  88. 6 0
      packages/runtime/interface/src/schema/edge.ts
  89. 8 0
      packages/runtime/interface/src/schema/index.ts
  90. 33 0
      packages/runtime/interface/src/schema/json-schema.ts
  91. 6 0
      packages/runtime/interface/src/schema/node-meta.ts
  92. 19 0
      packages/runtime/interface/src/schema/node.ts
  93. 29 0
      packages/runtime/interface/src/schema/value.ts
  94. 7 0
      packages/runtime/interface/src/schema/workflow.ts
  95. 6 0
      packages/runtime/interface/src/schema/xy.ts
  96. 29 0
      packages/runtime/interface/tsconfig.json
  97. 6 0
      packages/runtime/js-core/.eslintrc.cjs
  98. 53 0
      packages/runtime/js-core/package.json
  99. 17 0
      packages/runtime/js-core/src/api/index.ts
  100. 13 0
      packages/runtime/js-core/src/api/task-cancel.ts

+ 6 - 7
.vscode/settings.json

@@ -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"
   },

+ 2 - 0
apps/demo-free-layout/package.json

@@ -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",

+ 2 - 0
apps/demo-free-layout/src/components/base-node/index.tsx

@@ -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>
   );

+ 8 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.css

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

+ 61 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/group/index.tsx

@@ -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}
+    </>
+  );
+};

+ 52 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/header/index.tsx

@@ -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>
+  );
+};

+ 56 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/header/style.ts

@@ -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);
+  }
+`;

+ 32 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/icon/success.tsx

@@ -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>
+);

+ 22 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/icon/warning.tsx

@@ -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>
+);

+ 42 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/index.tsx

@@ -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} />;
+};

+ 14 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.css

@@ -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);
+}

+ 233 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/render/index.tsx

@@ -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>
+  );
+};

+ 137 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.css

@@ -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;
+}

+ 154 - 0
apps/demo-free-layout/src/components/testrun/node-status-bar/viewer/index.tsx

@@ -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>
+  );
+};

+ 78 - 0
apps/demo-free-layout/src/components/testrun/testrun-button/index.tsx

@@ -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)} />
+    </>
+  );
+}

+ 138 - 0
apps/demo-free-layout/src/components/testrun/testrun-sidesheet/index.tsx

@@ -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>
+  );
+};

+ 2 - 4
apps/demo-free-layout/src/components/tools/index.tsx

@@ -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>
   );

+ 3 - 3
apps/demo-free-layout/src/components/tools/run.tsx

@@ -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 (

+ 9 - 3
apps/demo-free-layout/src/form-components/form-outputs/index.tsx

@@ -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>
   );
-}
+};

+ 13 - 4
apps/demo-free-layout/src/hooks/use-editor-props.tsx

@@ -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',
+          // },
+        }),
       ],
     }),
     []

+ 124 - 83
apps/demo-free-layout/src/initial-data.ts

@@ -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: {
@@ -103,46 +103,59 @@ export const initialData: FlowDocumentJSON = {
       },
     },
     {
-      id: 'loop_H8M3U',
-      type: 'loop',
+      id: '159623',
+      type: 'comment',
       meta: {
         position: {
-          x: 1020,
-          y: 547.96875,
+          x: 640,
+          y: 573.96875,
         },
       },
       data: {
-        title: 'Loop_2',
-        batchFor: {
-          type: 'ref',
-          content: ['start_0', 'array_obj'],
+        size: {
+          width: 240,
+          height: 150,
         },
-        outputs: {
-          type: 'object',
-          properties: {
-            result: {
-              type: 'string',
-            },
-          },
+        note: 'hi ~\n\nthis is a comment node\n\n- flowgram.ai',
+      },
+    },
+    {
+      id: 'loop_sGybT',
+      type: 'loop',
+      meta: {
+        position: {
+          x: 1373.5714285714287,
+          y: 394.9758064516129,
         },
       },
+      data: {
+        title: 'Loop_1',
+      },
       blocks: [
         {
-          id: 'llm_CBdCg',
+          id: 'llm_6aSyo',
           type: 'llm',
           meta: {
             position: {
-              x: 180,
-              y: 0,
+              x: -196.8663594470046,
+              y: 142.0046082949309,
             },
           },
           data: {
-            title: 'LLM_4',
+            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,
@@ -158,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: {
@@ -185,21 +204,29 @@ export const initialData: FlowDocumentJSON = {
           },
         },
         {
-          id: 'llm_gZafu',
+          id: 'llm_ZqKlP',
           type: 'llm',
           meta: {
             position: {
-              x: 640,
-              y: 0,
+              x: 253.1797235023041,
+              y: 142.00460829493088,
             },
           },
           data: {
-            title: 'LLM_5',
+            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,
@@ -215,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: {
@@ -244,58 +277,46 @@ export const initialData: FlowDocumentJSON = {
       ],
       edges: [
         {
-          sourceNodeID: 'llm_CBdCg',
-          targetNodeID: 'llm_gZafu',
+          sourceNodeID: 'llm_6aSyo',
+          targetNodeID: 'llm_ZqKlP',
         },
       ],
     },
     {
-      id: '159623',
-      type: 'comment',
-      meta: {
-        position: {
-          x: 640,
-          y: 522.46875,
-        },
-      },
-      data: {
-        size: {
-          width: 240,
-          height: 150,
-        },
-        note: 'hi ~\n\nthis is a comment node\n\n- flowgram.ai',
-      },
-    },
-    {
-      id: 'group_V-_st',
+      id: 'group_5ci0o',
       type: 'group',
       meta: {
         position: {
-          x: 1020,
-          y: 96.25,
+          x: 0,
+          y: 0,
         },
       },
-      data: {
-        title: 'LLM_Group',
-        color: 'Violet',
-      },
+      data: {},
       blocks: [
         {
-          id: 'llm_0',
+          id: 'llm_8--A3',
           type: 'llm',
           meta: {
             position: {
-              x: 640,
-              y: 0,
+              x: 1177.8341013824886,
+              y: 19.25,
             },
           },
           data: {
-            title: 'LLM_0',
+            title: 'LLM_1',
             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 +332,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 +365,29 @@ export const initialData: FlowDocumentJSON = {
           },
         },
         {
-          id: 'llm_l_TcE',
+          id: 'llm_vTyMa',
           type: 'llm',
           meta: {
             position: {
-              x: 180,
-              y: 0,
+              x: 1625.6221198156682,
+              y: 19.25,
             },
           },
           data: {
-            title: 'LLM_1',
+            title: 'LLM_2',
             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 +403,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,17 +438,17 @@ export const initialData: FlowDocumentJSON = {
       ],
       edges: [
         {
-          sourceNodeID: 'llm_l_TcE',
-          targetNodeID: 'llm_0',
+          sourceNodeID: 'condition_0',
+          targetNodeID: 'llm_8--A3',
+          sourcePortID: 'if_0',
         },
         {
-          sourceNodeID: 'llm_0',
-          targetNodeID: 'end_0',
+          sourceNodeID: 'llm_8--A3',
+          targetNodeID: 'llm_vTyMa',
         },
         {
-          sourceNodeID: 'condition_0',
-          targetNodeID: 'llm_l_TcE',
-          sourcePortID: 'if_0',
+          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',
     },
   ],

+ 3 - 3
apps/demo-free-layout/src/nodes/end/form-meta.tsx

@@ -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>
     </>
   );

+ 18 - 4
apps/demo-free-layout/src/nodes/llm/index.ts

@@ -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: {

+ 2 - 2
apps/demo-free-layout/src/nodes/loop/index.ts

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

+ 2 - 1
apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx

@@ -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>
     </>

+ 1 - 0
apps/demo-free-layout/src/plugins/index.ts

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

+ 17 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/browser-client/index.ts

@@ -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;
+}

+ 23 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/create-runtime-plugin.ts

@@ -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);
+    }
+  },
+});

+ 2 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/index.ts

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

+ 172 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/runtime-service/index.ts

@@ -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);
+      }
+    });
+  }
+}

+ 7 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/server-client/constant.ts

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

+ 134 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/server-client/index.ts

@@ -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}`;
+  }
+}

+ 4 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/server-client/type.ts

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

+ 16 - 0
apps/demo-free-layout/src/plugins/runtime-plugin/type.ts

@@ -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;
+}

+ 0 - 1
apps/demo-free-layout/src/services/index.ts

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

+ 0 - 47
apps/demo-free-layout/src/services/running-service.ts

@@ -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)
-    );
-  }
-}

ファイルの差分が大きいため隠しています
+ 626 - 17
common/config/rush/pnpm-lock.yaml


+ 4 - 2
cspell.json

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

+ 4 - 7
packages/plugins/free-container-plugin/src/sub-canvas/components/render/index.tsx

@@ -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"

+ 1 - 0
packages/plugins/free-container-plugin/src/sub-canvas/components/tips/style.ts

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

+ 6 - 0
packages/runtime/interface/.eslintrc.cjs

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

+ 44 - 0
packages/runtime/interface/package.json

@@ -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/"
+  }
+}

+ 22 - 0
packages/runtime/interface/src/api/constant.ts

@@ -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',
+}

+ 19 - 0
packages/runtime/interface/src/api/define.ts

@@ -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[];

+ 10 - 0
packages/runtime/interface/src/api/index.ts

@@ -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';

+ 31 - 0
packages/runtime/interface/src/api/schema.ts

@@ -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),
+  }),
+};

+ 31 - 0
packages/runtime/interface/src/api/server-info/index.ts

@@ -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(),
+    }),
+  },
+};

+ 27 - 0
packages/runtime/interface/src/api/task-cancel/index.ts

@@ -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(),
+    }),
+  },
+};

+ 31 - 0
packages/runtime/interface/src/api/task-report/index.ts

@@ -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),
+    }),
+  },
+};

+ 25 - 0
packages/runtime/interface/src/api/task-result/index.ts

@@ -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,
+  },
+};

+ 31 - 0
packages/runtime/interface/src/api/task-run/index.ts

@@ -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(),
+    }),
+  },
+};

+ 18 - 0
packages/runtime/interface/src/api/type.ts

@@ -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;
+}

+ 43 - 0
packages/runtime/interface/src/api/validation/index.ts

@@ -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(),
+          }),
+        })
+      ),
+    }),
+  },
+};

+ 18 - 0
packages/runtime/interface/src/client/index.ts

@@ -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>;
+}

+ 5 - 0
packages/runtime/interface/src/index.ts

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

+ 11 - 0
packages/runtime/interface/src/node/constant.ts

@@ -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',
+}

+ 13 - 0
packages/runtime/interface/src/node/end/index.ts

@@ -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>;

+ 4 - 0
packages/runtime/interface/src/node/index.ts

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

+ 20 - 0
packages/runtime/interface/src/node/llm/index.ts

@@ -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>;

+ 10 - 0
packages/runtime/interface/src/node/start/index.ts

@@ -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>;

+ 3 - 0
packages/runtime/interface/src/runtime/base/index.ts

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

+ 2 - 0
packages/runtime/interface/src/runtime/base/inputs-outputs.ts

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

+ 9 - 0
packages/runtime/interface/src/runtime/base/invoke.ts

@@ -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>;

+ 1 - 0
packages/runtime/interface/src/runtime/base/value-object.ts

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

+ 5 - 0
packages/runtime/interface/src/runtime/container/index.ts

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

+ 25 - 0
packages/runtime/interface/src/runtime/context/index.ts

@@ -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;
+}

+ 14 - 0
packages/runtime/interface/src/runtime/document/document.ts

@@ -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;
+}

+ 16 - 0
packages/runtime/interface/src/runtime/document/edge.ts

@@ -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;
+}

+ 4 - 0
packages/runtime/interface/src/runtime/document/index.ts

@@ -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';

+ 41 - 0
packages/runtime/interface/src/runtime/document/node.ts

@@ -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;
+}

+ 16 - 0
packages/runtime/interface/src/runtime/document/port.ts

@@ -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;
+}

+ 16 - 0
packages/runtime/interface/src/runtime/engine/index.ts

@@ -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');

+ 8 - 0
packages/runtime/interface/src/runtime/executor/executor.ts

@@ -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');

+ 7 - 0
packages/runtime/interface/src/runtime/executor/index.ts

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

+ 26 - 0
packages/runtime/interface/src/runtime/executor/node-executor.ts

@@ -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;
+}

+ 14 - 0
packages/runtime/interface/src/runtime/index.ts

@@ -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';

+ 17 - 0
packages/runtime/interface/src/runtime/io-center/index.ts

@@ -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;
+}

+ 23 - 0
packages/runtime/interface/src/runtime/reporter/index.ts

@@ -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;
+}

+ 2 - 0
packages/runtime/interface/src/runtime/snapshot/index.ts

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

+ 10 - 0
packages/runtime/interface/src/runtime/snapshot/snapshot-center.ts

@@ -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;
+}

+ 21 - 0
packages/runtime/interface/src/runtime/snapshot/snapshot.ts

@@ -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;
+}

+ 20 - 0
packages/runtime/interface/src/runtime/state/index.ts

@@ -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;
+}

+ 33 - 0
packages/runtime/interface/src/runtime/status/index.ts

@@ -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>;
+}

+ 14 - 0
packages/runtime/interface/src/runtime/task/index.ts

@@ -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;
+}

+ 12 - 0
packages/runtime/interface/src/runtime/validation/index.ts

@@ -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');

+ 44 - 0
packages/runtime/interface/src/runtime/variable/index.ts

@@ -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;
+}

+ 14 - 0
packages/runtime/interface/src/schema/constant.ts

@@ -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',
+}

+ 6 - 0
packages/runtime/interface/src/schema/edge.ts

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

+ 8 - 0
packages/runtime/interface/src/schema/index.ts

@@ -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';

+ 33 - 0
packages/runtime/interface/src/schema/json-schema.ts

@@ -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>;

+ 6 - 0
packages/runtime/interface/src/schema/node-meta.ts

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

+ 19 - 0
packages/runtime/interface/src/schema/node.ts

@@ -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[];
+}

+ 29 - 0
packages/runtime/interface/src/schema/value.ts

@@ -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;

+ 7 - 0
packages/runtime/interface/src/schema/workflow.ts

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

+ 6 - 0
packages/runtime/interface/src/schema/xy.ts

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

+ 29 - 0
packages/runtime/interface/tsconfig.json

@@ -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"
+  ]
+}

+ 6 - 0
packages/runtime/js-core/.eslintrc.cjs

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

+ 53 - 0
packages/runtime/js-core/package.json

@@ -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/"
+  }
+}

+ 17 - 0
packages/runtime/js-core/src/api/index.ts

@@ -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
+};

+ 13 - 0
packages/runtime/js-core/src/api/task-cancel.ts

@@ -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;
+};

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません