Просмотр исходного кода

feat(demo): test run input json mode (#543)

* fix(demo): condition init refresh ports

* feat(demo): test run json mode

* fix(runtime): llm node retry times limit 3
Louis Young 5 месяцев назад
Родитель
Сommit
f20946cc14

+ 8 - 0
apps/demo-free-layout/src/components/testrun/hooks/index.ts

@@ -0,0 +1,8 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { useFields } from './use-fields';
+export { useFormMeta } from './use-form-meta';
+export { useSyncDefault } from './use-sync-default';

+ 1 - 9
apps/demo-free-layout/src/components/testrun/testrun-form/use-fields.ts → apps/demo-free-layout/src/components/testrun/hooks/use-fields.ts

@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { TestRunFormField, TestRunFormMeta } from './type';
+import { TestRunFormField, TestRunFormMeta } from '../testrun-form/type';
 
 export const useFields = (params: {
   formMeta: TestRunFormMeta;
@@ -14,14 +14,6 @@ export const useFields = (params: {
 
   // Convert each meta item to a form field with value and onChange handler
   const fields: TestRunFormField[] = formMeta.map((meta) => {
-    // If there is no value in values but there is a default value, trigger onChange once
-    if (!(meta.name in values) && meta.defaultValue !== undefined) {
-      setValues({
-        ...values,
-        [meta.name]: meta.defaultValue,
-      });
-    }
-
     // Handle object type specially - serialize object to JSON string for display
     const getCurrentValue = (): unknown => {
       const rawValue = values[meta.name] ?? meta.defaultValue;

+ 1 - 1
apps/demo-free-layout/src/components/testrun/testrun-form/use-form-meta.ts → apps/demo-free-layout/src/components/testrun/hooks/use-form-meta.ts

@@ -13,8 +13,8 @@ import {
 } from '@flowgram.ai/free-layout-editor';
 import { IJsonSchema, JsonSchemaBasicType } from '@flowgram.ai/form-materials';
 
+import { TestRunFormMetaItem } from '../testrun-form/type';
 import { WorkflowNodeType } from '../../../nodes';
-import { TestRunFormMetaItem } from './type';
 
 const getWorkflowInputsDeclare = (document: WorkflowDocument): IJsonSchema => {
   const defaultDeclare = {

+ 28 - 0
apps/demo-free-layout/src/components/testrun/hooks/use-sync-default.ts

@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useEffect } from 'react';
+
+import { TestRunFormMeta } from '../testrun-form/type';
+
+export const useSyncDefault = (params: {
+  formMeta: TestRunFormMeta;
+  values: Record<string, unknown>;
+  setValues: (values: Record<string, unknown>) => void;
+}) => {
+  const { formMeta, values, setValues } = params;
+
+  useEffect(() => {
+    formMeta.map((meta) => {
+      // If there is no value in values but there is a default value, trigger onChange once
+      if (!(meta.name in values) && meta.defaultValue !== undefined) {
+        setValues({
+          ...values,
+          [meta.name]: meta.defaultValue,
+        });
+      }
+    });
+  }, [formMeta]);
+};

+ 17 - 2
apps/demo-free-layout/src/components/testrun/testrun-form/index.module.less

@@ -67,8 +67,8 @@
   max-height: 200px;
   background: #fff;
   padding: 8px 8px 8px 4px;
-  border-radius: 4px;
-  border: 1px solid #52649a0f;
+  border-radius: 8px;
+  border: 1px solid #7f92cd40;
   width: 348px;
 
   :global(.cm-editor) {
@@ -85,6 +85,21 @@
     min-height: 100px !important;
     max-height: 200px !important;
   }
+
+  :global(.cm-activeLine) {
+    background-color: #efefef78;
+  }
+
+  :global(.cm-activeLineGutter) {
+    background-color: #efefef78;
+  }
+
+  :global(.cm-gutters) {
+    background-color: #fff;
+    color: #000A298A;
+    border-right-color: transparent;
+    border-right-width: 0px;
+  }
 }
 
 .fieldTypeIndicator {

+ 9 - 2
apps/demo-free-layout/src/components/testrun/testrun-form/index.tsx

@@ -9,9 +9,10 @@ import classNames from 'classnames';
 import { CodeEditor } from '@flowgram.ai/form-materials';
 import { Input, Switch, InputNumber } from '@douyinfe/semi-ui';
 
+import { useFormMeta } from '../hooks/use-form-meta';
+import { useFields } from '../hooks/use-fields';
+import { useSyncDefault } from '../hooks';
 import { TypeTag } from '../../../form-components';
-import { useFormMeta } from './use-form-meta';
-import { useFields } from './use-fields';
 
 import styles from './index.module.less';
 
@@ -29,6 +30,12 @@ export const TestRunForm: FC<TestRunFormProps> = ({ values, setValues }) => {
     setValues,
   });
 
+  useSyncDefault({
+    formMeta,
+    values,
+    setValues,
+  });
+
   const renderField = (field: any) => {
     switch (field.type) {
       case 'boolean':

+ 44 - 0
apps/demo-free-layout/src/components/testrun/testrun-json-input/index.module.less

@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+.testrun-json-input {
+  min-height: 300px;
+  max-height: 400px;
+  background: #fff;
+  padding: 8px 8px 8px 4px;
+  border-radius: 8px;
+  border: 1px solid #7f92cd40;
+  width: 348px;
+
+  :global(.cm-editor) {
+    height: 100% !important;
+    overflow: auto !important;
+  }
+
+  :global(.cm-scroller) {
+    min-height: 300px !important;
+    max-height: 400px !important;
+  }
+
+  :global(.cm-content) {
+    min-height: 300px !important;
+    max-height: 400px !important;
+  }
+
+  :global(.cm-activeLine) {
+    background-color: #efefef78;
+  }
+
+  :global(.cm-activeLineGutter) {
+    background-color: #efefef78;
+  }
+
+  :global(.cm-gutters) {
+    background-color: #fff;
+    color: #000A298A;
+    border-right-color: transparent;
+    border-right-width: 0px;
+  }
+}

+ 37 - 0
apps/demo-free-layout/src/components/testrun/testrun-json-input/index.tsx

@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FC } from 'react';
+
+import { CodeEditor } from '@flowgram.ai/form-materials';
+
+import { useFormMeta, useSyncDefault } from '../hooks';
+
+import styles from './index.module.less';
+
+interface TestRunJsonInputProps {
+  values: Record<string, unknown>;
+  setValues: (values: Record<string, unknown>) => void;
+}
+
+export const TestRunJsonInput: FC<TestRunJsonInputProps> = ({ values, setValues }) => {
+  const formMeta = useFormMeta();
+
+  useSyncDefault({
+    formMeta,
+    values,
+    setValues,
+  });
+
+  return (
+    <div className={styles['testrun-json-input']}>
+      <CodeEditor
+        languageId="json"
+        value={JSON.stringify(values, null, 2)}
+        onChange={(value) => setValues(JSON.parse(value))}
+      />
+    </div>
+  );
+};

+ 15 - 4
apps/demo-free-layout/src/components/testrun/testrun-panel/index.module.less

@@ -4,12 +4,23 @@
  */
 
 .testrun-panel-form {
-  .title {
-    font-size: 15px;
-    font-weight: 500;
-    color: #333;
+
+  .testrun-panel-input {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin: 0 12px 8px 0;
+
+    .title {
+      font-size: 15px;
+      font-weight: 500;
+      color: #333;
+      flex: 1;
+    }
   }
 
+
   .error {
     color: red;
     font-size: 14px;

+ 27 - 3
apps/demo-free-layout/src/components/testrun/testrun-panel/index.tsx

@@ -8,9 +8,10 @@ import { FC, useContext, useEffect, useState } from 'react';
 import classnames from 'classnames';
 import { WorkflowInputs, WorkflowOutputs } from '@flowgram.ai/runtime-interface';
 import { useService } from '@flowgram.ai/free-layout-editor';
-import { Button, SideSheet } from '@douyinfe/semi-ui';
+import { Button, SideSheet, Switch } from '@douyinfe/semi-ui';
 import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
 
+import { TestRunJsonInput } from '../testrun-json-input';
 import { TestRunForm } from '../testrun-form';
 import { NodeStatusGroup } from '../node-status-bar/group';
 import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
@@ -39,6 +40,17 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
     | undefined
   >();
 
+  // en - Use localStorage to persist the JSON mode state
+  const [inputJSONMode, _setInputJSONMode] = useState(() => {
+    const savedMode = localStorage.getItem('testrun-input-json-mode');
+    return savedMode ? JSON.parse(savedMode) : false;
+  });
+
+  const setInputJSONMode = (checked: boolean) => {
+    _setInputJSONMode(checked);
+    localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked));
+  };
+
   const onTestRun = async () => {
     if (isRunning) {
       await runtimeService.taskCancel();
@@ -90,8 +102,20 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel
 
   const renderForm = (
     <div className={styles['testrun-panel-form']}>
-      <div className={styles.title}>Input Form</div>
-      <TestRunForm values={values} setValues={setValues} />
+      <div className={styles['testrun-panel-input']}>
+        <div className={styles.title}>Input Form</div>
+        <div>JSON Mode</div>
+        <Switch
+          checked={inputJSONMode}
+          onChange={(checked: boolean) => setInputJSONMode(checked)}
+          size="small"
+        />
+      </div>
+      {inputJSONMode ? (
+        <TestRunJsonInput values={values} setValues={setValues} />
+      ) : (
+        <TestRunForm values={values} setValues={setValues} />
+      )}
       {errors?.map((e) => (
         <div className={styles.error} key={e}>
           {e}

+ 11 - 2
apps/demo-free-layout/src/nodes/condition/condition-inputs/index.tsx

@@ -3,8 +3,10 @@
  * SPDX-License-Identifier: MIT
  */
 
+import { useLayoutEffect } from 'react';
+
 import { nanoid } from 'nanoid';
-import { Field, FieldArray } from '@flowgram.ai/free-layout-editor';
+import { Field, FieldArray, WorkflowNodePortsData } from '@flowgram.ai/free-layout-editor';
 import { ConditionRow, ConditionRowValueType } from '@flowgram.ai/form-materials';
 import { Button } from '@douyinfe/semi-ui';
 import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
@@ -20,7 +22,14 @@ interface ConditionValue {
 }
 
 export function ConditionInputs() {
-  const { readonly } = useNodeRenderContext();
+  const { node, readonly } = useNodeRenderContext();
+
+  useLayoutEffect(() => {
+    window.requestAnimationFrame(() => {
+      node.getData<WorkflowNodePortsData>(WorkflowNodePortsData).updateDynamicPorts();
+    });
+  }, [node]);
+
   return (
     <FieldArray name="conditions">
       {({ field }) => (

+ 0 - 79
packages/runtime/js-core/src/nodes/llm/api-validator.ts

@@ -1,79 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-export namespace APIValidator {
-  /**
-   * Simple validation for API host format
-   * Just check if it's a valid URL with http/https protocol
-   */
-  export const isValidFormat = (apiHost: string): boolean => {
-    if (!apiHost || typeof apiHost !== 'string') {
-      return false;
-    }
-
-    try {
-      const url = new URL(apiHost);
-      return url.protocol === 'http:' || url.protocol === 'https:';
-    } catch (error) {
-      return false;
-    }
-  };
-
-  /**
-   * Check if the API host is reachable by sending a simple request
-   * Any response (including 404, 500, etc.) indicates the host exists
-   * Only network-level failures indicate the host doesn't exist
-   */
-  export const isExist = async (apiHost: string): Promise<boolean> => {
-    try {
-      // Use AbortController to set a reasonable timeout
-      const controller = new AbortController();
-      const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
-
-      await fetch(apiHost, {
-        method: 'HEAD', // Use HEAD to minimize data transfer
-        signal: controller.signal,
-        // Disable following redirects to get the actual host response
-        redirect: 'manual',
-      });
-
-      clearTimeout(timeoutId);
-
-      // Any HTTP response (including errors like 404, 500) means the host exists
-      return true;
-    } catch (error: any) {
-      // Check if it's a timeout/abort error
-      if (error.name === 'AbortError') {
-        return false;
-      }
-
-      // For fetch errors, we need to distinguish between network failures and HTTP errors
-      // Network failures (DNS resolution failed, connection refused) mean host doesn't exist
-      // HTTP errors (404, 500, etc.) mean host exists but returned an error
-
-      // Unfortunately, fetch doesn't provide detailed error types
-      // But we can check if the error is related to network connectivity
-      const errorMessage = error.message?.toLowerCase() || '';
-
-      // These patterns typically indicate network-level failures
-      const networkFailurePatterns = [
-        'network error',
-        'connection refused',
-        'dns',
-        'resolve',
-        'timeout',
-        'unreachable',
-      ];
-
-      const isNetworkFailure = networkFailurePatterns.some((pattern) =>
-        errorMessage.includes(pattern)
-      );
-
-      // If it's a network failure, host doesn't exist
-      // Otherwise, assume host exists but returned an error
-      return !isNetworkFailure;
-    }
-  };
-}

+ 11 - 9
packages/runtime/js-core/src/nodes/llm/index.ts

@@ -13,8 +13,6 @@ import {
   INodeExecutor,
 } from '@flowgram.ai/runtime-interface';
 
-import { APIValidator } from './api-validator';
-
 export interface LLMExecutorInputs {
   modelName: string;
   apiKey: string;
@@ -29,7 +27,7 @@ export class LLMExecutor implements INodeExecutor {
 
   public async execute(context: ExecutionContext): Promise<ExecutionResult> {
     const inputs = context.inputs as LLMExecutorInputs;
-    await this.checkInputs(inputs);
+    this.checkInputs(inputs);
 
     const { modelName, temperature, apiKey, apiHost, systemPrompt, prompt } = inputs;
 
@@ -40,6 +38,7 @@ export class LLMExecutor implements INodeExecutor {
       configuration: {
         baseURL: apiHost,
       },
+      maxRetries: 3,
     });
 
     const messages: BaseMessageLike[] = [];
@@ -69,7 +68,7 @@ export class LLMExecutor implements INodeExecutor {
     };
   }
 
-  protected async checkInputs(inputs: LLMExecutorInputs) {
+  protected checkInputs(inputs: LLMExecutorInputs) {
     const { modelName, temperature, apiKey, apiHost, prompt } = inputs;
     const missingInputs = [];
 
@@ -83,14 +82,17 @@ export class LLMExecutor implements INodeExecutor {
       throw new Error(`LLM node missing required inputs: "${missingInputs.join('", "')}"`);
     }
 
-    // Validate apiHost format before checking existence
-    if (!APIValidator.isValidFormat(apiHost)) {
+    this.checkApiHost(apiHost);
+  }
+
+  private checkApiHost(apiHost: string): void {
+    if (!apiHost || typeof apiHost !== 'string') {
       throw new Error(`Invalid API host format - ${apiHost}`);
     }
 
-    const apiHostExists = await APIValidator.isExist(apiHost);
-    if (!apiHostExists) {
-      throw new Error(`Unreachable API host - ${apiHost}`);
+    const url = new URL(apiHost);
+    if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+      throw new Error(`Invalid API host protocol - ${url.protocol}`);
     }
   }
 }