Selaa lähdekoodia

feat: prompt editor variable display (#453)

* feat: prompt editor variable display

* feat: prompt editor with variables docs
Yiwei Mao 6 kuukautta sitten
vanhempi
commit
9210f5041d

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

@@ -185,7 +185,7 @@ export const initialData: FlowDocumentJSON = {
               },
               prompt: {
                 type: 'constant',
-                content: '',
+                content: '# User Input\nquery:{{loop_sGybT_locals.item.int}}',
               },
             },
             inputs: {
@@ -262,7 +262,7 @@ export const initialData: FlowDocumentJSON = {
               },
               prompt: {
                 type: 'constant',
-                content: '',
+                content: '# User Input\nquery:{{loop_sGybT_locals.item.str}}',
               },
             },
             inputs: {
@@ -358,7 +358,7 @@ export const initialData: FlowDocumentJSON = {
               },
               prompt: {
                 type: 'constant',
-                content: '',
+                content: '# User Input\nquery:{{start_0.query}}\nenable:{{start_0.enable}}',
               },
             },
             inputs: {
@@ -435,7 +435,7 @@ export const initialData: FlowDocumentJSON = {
               },
               prompt: {
                 type: 'constant',
-                content: '',
+                content: '# LLM Input\nresult:{{llm_8--A3.result}}',
               },
             },
             inputs: {

+ 18 - 0
apps/docs/src/en/guide/advanced/form-materials.mdx

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

BIN
apps/docs/src/public/materials/prompt-editor-with-variables.png


+ 16 - 0
apps/docs/src/zh/guide/advanced/form-materials.mdx

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

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

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

+ 173 - 0
packages/materials/form-materials/src/components/prompt-editor-with-variables/extensions/variable-tag.tsx

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

+ 2 - 4
packages/materials/form-materials/src/components/prompt-editor-with-variables/extensions/variable.tsx → packages/materials/form-materials/src/components/prompt-editor-with-variables/extensions/variable-tree.tsx

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

+ 4 - 2
packages/materials/form-materials/src/components/prompt-editor-with-variables/index.tsx

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

+ 44 - 0
packages/materials/form-materials/src/components/prompt-editor-with-variables/styles.tsx

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

+ 1 - 1
packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts

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

+ 7 - 2
packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts

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