Parcourir la source

feat(material): prompt editor with inputs (#478)

* feat(material): prompt editor with inputs

* fix: ts-check
Yiwei Mao il y a 6 mois
Parent
commit
1779df10b8

+ 1 - 0
packages/materials/form-materials/src/components/index.ts

@@ -13,3 +13,4 @@ export * from './condition-row';
 export * from './batch-outputs';
 export * from './prompt-editor';
 export * from './prompt-editor-with-variables';
+export * from './prompt-editor-with-inputs';

+ 13 - 0
packages/materials/form-materials/src/components/prompt-editor-with-inputs/config.json

@@ -0,0 +1,13 @@
+{
+  "name": "prompt-editor-with-inputs",
+  "depMaterials": [
+    "prompt-editor",
+    "flow-value"
+  ],
+  "depPackages": [
+    "@coze-editor/editor@0.1.0-alpha.8d7a30",
+    "@codemirror/view",
+    "styled-components",
+    "@douyinfe/semi-ui"
+  ]
+}

+ 82 - 0
packages/materials/form-materials/src/components/prompt-editor-with-inputs/extensions/inputs-tree.tsx

@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React, { useEffect, useState } from 'react';
+
+import { Popover } from '@douyinfe/semi-ui';
+import {
+  Mention,
+  MentionOpenChangeEvent,
+  getCurrentMentionReplaceRange,
+  useEditor,
+  PositionMirror,
+} from '@coze-editor/editor/react';
+import { EditorAPI } from '@coze-editor/editor/preset-prompt';
+
+import { InputsPicker } from '../inputs-picker';
+import { IFlowValue } from '../../../typings';
+
+export function InputsTree({ inputsValues }: { inputsValues: Record<string, IFlowValue> }) {
+  const [posKey, setPosKey] = useState('');
+  const [visible, setVisible] = useState(false);
+  const [position, setPosition] = useState(-1);
+  const editor = useEditor<EditorAPI>();
+
+  function insert(variablePath: string) {
+    const range = getCurrentMentionReplaceRange(editor.$view.state);
+
+    if (!range) {
+      return;
+    }
+
+    editor.replaceText({
+      ...range,
+      text: '{{' + variablePath + '}}',
+    });
+
+    setVisible(false);
+  }
+
+  function handleOpenChange(e: MentionOpenChangeEvent) {
+    setPosition(e.state.selection.main.head);
+    setVisible(e.value);
+  }
+
+  useEffect(() => {
+    if (!editor) {
+      return;
+    }
+  }, [editor, visible]);
+
+  return (
+    <>
+      <Mention triggerCharacters={['{', '{}', '@']} onOpenChange={handleOpenChange} />
+
+      <Popover
+        visible={visible}
+        trigger="custom"
+        position="topLeft"
+        rePosKey={posKey}
+        content={
+          <div style={{ width: 300 }}>
+            <InputsPicker
+              inputsValues={inputsValues}
+              onSelect={(v) => {
+                insert(v);
+              }}
+            />
+          </div>
+        }
+      >
+        {/* PositionMirror allows the Popover to appear at the specified cursor position */}
+        <PositionMirror
+          position={position}
+          // When Doc scroll, update position
+          onChange={() => setPosKey(String(Math.random()))}
+        />
+      </Popover>
+    </>
+  );
+}

+ 22 - 0
packages/materials/form-materials/src/components/prompt-editor-with-inputs/index.tsx

@@ -0,0 +1,22 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import { InputsTree } from './extensions/inputs-tree';
+import { PromptEditor, PromptEditorPropsType } from '../prompt-editor';
+import { IFlowValue } from '../../typings';
+
+interface PropsType extends PromptEditorPropsType {
+  inputsValues: Record<string, IFlowValue>;
+}
+
+export function PromptEditorWithInputs({ inputsValues, ...restProps }: PropsType) {
+  return (
+    <PromptEditor {...restProps}>
+      <InputsTree inputsValues={inputsValues} />
+    </PromptEditor>
+  );
+}

+ 100 - 0
packages/materials/form-materials/src/components/prompt-editor-with-inputs/inputs-picker.tsx

@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React, { useMemo } from 'react';
+
+import { last } from 'lodash';
+import {
+  type ArrayType,
+  ASTMatch,
+  type BaseType,
+  type BaseVariableField,
+  useScopeAvailable,
+} from '@flowgram.ai/editor';
+import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
+import { Tree } from '@douyinfe/semi-ui';
+
+import { IFlowValue } from '../../typings';
+
+type VariableField = BaseVariableField<{ icon?: string | JSX.Element; title?: string }>;
+
+export function InputsPicker({
+  inputsValues,
+  onSelect,
+}: {
+  inputsValues: Record<string, IFlowValue>;
+  onSelect: (v: string) => void;
+}) {
+  const available = useScopeAvailable();
+
+  const getArrayDrilldown = (type: ArrayType, depth = 1): { type: BaseType; depth: number } => {
+    if (ASTMatch.isArray(type.items)) {
+      return getArrayDrilldown(type.items, depth + 1);
+    }
+
+    return { type: type.items, depth: depth };
+  };
+
+  const renderVariable = (variable: VariableField, keyPath: string[]): TreeNodeData => {
+    let type = variable?.type;
+
+    let children: TreeNodeData[] | undefined;
+
+    if (ASTMatch.isObject(type)) {
+      children = (type.properties || [])
+        .map((_property) => renderVariable(_property as VariableField, [...keyPath, _property.key]))
+        .filter(Boolean) as TreeNodeData[];
+    }
+
+    if (ASTMatch.isArray(type)) {
+      const drilldown = getArrayDrilldown(type);
+
+      if (ASTMatch.isObject(drilldown.type)) {
+        children = (drilldown.type.properties || [])
+          .map((_property) =>
+            renderVariable(_property as VariableField, [
+              ...keyPath,
+              ...new Array(drilldown.depth).fill('[0]'),
+              _property.key,
+            ])
+          )
+          .filter(Boolean) as TreeNodeData[];
+      }
+    }
+
+    const key = keyPath
+      .map((_key, idx) => (_key === '[0]' || idx === 0 ? _key : `.${_key}`))
+      .join('');
+
+    return {
+      key: key,
+      label: last(keyPath),
+      value: key,
+      children,
+    };
+  };
+
+  const treeData: TreeNodeData[] = useMemo(
+    () =>
+      Object.entries(inputsValues).map(([key, value]) => {
+        if (value.type === 'ref') {
+          const variable = available.getByKeyPath(value.content || []);
+
+          if (variable) {
+            return renderVariable(variable, [key]);
+          }
+        }
+
+        return {
+          key,
+          value: key,
+          label: key,
+        };
+      }),
+    []
+  );
+
+  return <Tree treeData={treeData} onSelect={(v) => onSelect(v)} />;
+}

+ 3 - 2
packages/materials/form-materials/src/components/prompt-editor-with-variables/config.json

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