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

feat(material): prompt-editor with variables (#445)

* feat: init prompt editor

* feat: simple prompt editor

* feat: split prompt editor

* feat: fix-layout prompt editor
Yiwei Mao 6 месяцев назад
Родитель
Сommit
de7f2d3c07
21 измененных файлов с 1306 добавлено и 77 удалено
  1. 27 8
      apps/demo-fixed-layout/src/form-components/form-inputs/index.tsx
  2. 8 2
      apps/demo-fixed-layout/src/form-components/form-item/index.tsx
  3. 3 1
      apps/demo-fixed-layout/src/initial-data.ts
  4. 3 1
      apps/demo-fixed-layout/src/nodes/llm/index.ts
  5. 27 10
      apps/demo-free-layout/src/form-components/form-inputs/index.tsx
  6. 28 4
      apps/demo-free-layout/src/initial-data.ts
  7. 7 1
      apps/demo-free-layout/src/nodes/llm/index.ts
  8. 839 46
      common/config/rush/pnpm-lock.yaml
  9. 3 1
      packages/materials/form-materials/package.json
  10. 2 0
      packages/materials/form-materials/src/components/index.ts
  11. 10 0
      packages/materials/form-materials/src/components/prompt-editor-with-variables/config.json
  12. 85 0
      packages/materials/form-materials/src/components/prompt-editor-with-variables/extensions/variable.tsx
  13. 17 0
      packages/materials/form-materials/src/components/prompt-editor-with-variables/index.tsx
  14. 9 0
      packages/materials/form-materials/src/components/prompt-editor/config.json
  15. 58 0
      packages/materials/form-materials/src/components/prompt-editor/extensions/jinja.tsx
  16. 19 0
      packages/materials/form-materials/src/components/prompt-editor/extensions/language-support.tsx
  17. 75 0
      packages/materials/form-materials/src/components/prompt-editor/extensions/markdown.tsx
  18. 43 0
      packages/materials/form-materials/src/components/prompt-editor/index.tsx
  19. 18 0
      packages/materials/form-materials/src/components/prompt-editor/styles.tsx
  20. 16 0
      packages/materials/form-materials/src/components/prompt-editor/types.tsx
  21. 9 3
      packages/materials/form-materials/tsconfig.json

+ 27 - 8
apps/demo-fixed-layout/src/form-components/form-inputs/index.tsx

@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { DynamicValueInput } from '@flowgram.ai/form-materials';
+import { DynamicValueInput, PromptEditorWithVariables } from '@flowgram.ai/form-materials';
 import { Field } from '@flowgram.ai/fixed-layout-editor';
 
 import { FormItem } from '../form-item';
@@ -13,6 +13,7 @@ import { useNodeRenderContext } from '../../hooks';
 
 export function FormInputs() {
   const { readonly } = useNodeRenderContext();
+
   return (
     <Field<JsonSchema> name="inputs">
       {({ field: inputsField }) => {
@@ -23,21 +24,39 @@ export function FormInputs() {
         }
         const content = Object.keys(properties).map((key) => {
           const property = properties[key];
+
+          const formComponent = property.extra?.formComponent;
+
+          const vertical = ['prompt-editor'].includes(formComponent || '');
+
           return (
             <Field key={key} name={`inputsValues.${key}`} defaultValue={property.default}>
               {({ field, fieldState }) => (
                 <FormItem
                   name={key}
+                  vertical={vertical}
                   type={property.type as string}
                   required={required.includes(key)}
                 >
-                  <DynamicValueInput
-                    value={field.value}
-                    onChange={field.onChange}
-                    readonly={readonly}
-                    hasError={Object.keys(fieldState?.errors || {}).length > 0}
-                    schema={property}
-                  />
+                  {formComponent === 'prompt-editor' && (
+                    <PromptEditorWithVariables
+                      value={field.value}
+                      onChange={field.onChange}
+                      readonly={readonly}
+                      hasError={Object.keys(fieldState?.errors || {}).length > 0}
+                    />
+                  )}
+                  {!formComponent && (
+                    <DynamicValueInput
+                      value={field.value}
+                      onChange={field.onChange}
+                      readonly={readonly}
+                      hasError={Object.keys(fieldState?.errors || {}).length > 0}
+                      constantProps={{
+                        schema: property,
+                      }}
+                    />
+                  )}
                   <Feedback errors={fieldState?.errors} warnings={fieldState?.warnings} />
                 </FormItem>
               )}

+ 8 - 2
apps/demo-fixed-layout/src/form-components/form-item/index.tsx

@@ -19,6 +19,7 @@ interface FormItemProps {
   required?: boolean;
   description?: string;
   labelWidth?: number;
+  vertical?: boolean;
 }
 export function FormItem({
   children,
@@ -27,6 +28,7 @@ export function FormItem({
   description,
   type,
   labelWidth,
+  vertical,
 }: FormItemProps): JSX.Element {
   const renderTitle = useCallback(
     (showTooltip?: boolean) => (
@@ -47,9 +49,13 @@ export function FormItem({
         width: '100%',
         position: 'relative',
         display: 'flex',
-        justifyContent: 'center',
-        alignItems: 'center',
         gap: 8,
+        ...(vertical
+          ? { flexDirection: 'column' }
+          : {
+              justifyContent: 'center',
+              alignItems: 'center',
+            }),
       }}
     >
       <div

+ 3 - 1
apps/demo-fixed-layout/src/initial-data.ts

@@ -59,7 +59,7 @@ export const initialData: FlowDocumentJSON = {
           },
           systemPrompt: {
             type: 'constant',
-            content: 'You are an AI assistant.',
+            content: '# Role\nYou are an AI assistant.\n',
           },
           prompt: {
             type: 'constant',
@@ -78,9 +78,11 @@ export const initialData: FlowDocumentJSON = {
             },
             systemPrompt: {
               type: 'string',
+              extra: { formComponent: 'prompt-editor' },
             },
             prompt: {
               type: 'string',
+              extra: { formComponent: 'prompt-editor' },
             },
           },
         },

+ 3 - 1
apps/demo-fixed-layout/src/nodes/llm/index.ts

@@ -35,7 +35,7 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
           },
           systemPrompt: {
             type: 'constant',
-            content: 'You are an AI assistant.',
+            content: '# Role\nYou are an AI assistant.\n',
           },
           prompt: {
             type: 'constant',
@@ -54,9 +54,11 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
             },
             systemPrompt: {
               type: 'string',
+              extra: { formComponent: 'prompt-editor' },
             },
             prompt: {
               type: 'string',
+              extra: { formComponent: 'prompt-editor' },
             },
           },
         },

+ 27 - 10
apps/demo-free-layout/src/form-components/form-inputs/index.tsx

@@ -4,7 +4,7 @@
  */
 
 import { Field } from '@flowgram.ai/free-layout-editor';
-import { DynamicValueInput } from '@flowgram.ai/form-materials';
+import { DynamicValueInput, PromptEditorWithVariables } from '@flowgram.ai/form-materials';
 
 import { FormItem } from '../form-item';
 import { Feedback } from '../feedback';
@@ -13,6 +13,7 @@ import { useNodeRenderContext } from '../../hooks';
 
 export function FormInputs() {
   const { readonly } = useNodeRenderContext();
+
   return (
     <Field<JsonSchema> name="inputs">
       {({ field: inputsField }) => {
@@ -23,23 +24,39 @@ export function FormInputs() {
         }
         const content = Object.keys(properties).map((key) => {
           const property = properties[key];
+
+          const formComponent = property.extra?.formComponent;
+
+          const vertical = ['prompt-editor'].includes(formComponent || '');
+
           return (
             <Field key={key} name={`inputsValues.${key}`} defaultValue={property.default}>
               {({ field, fieldState }) => (
                 <FormItem
                   name={key}
+                  vertical={vertical}
                   type={property.type as string}
                   required={required.includes(key)}
                 >
-                  <DynamicValueInput
-                    value={field.value}
-                    onChange={field.onChange}
-                    readonly={readonly}
-                    hasError={Object.keys(fieldState?.errors || {}).length > 0}
-                    constantProps={{
-                      schema: property,
-                    }}
-                  />
+                  {formComponent === 'prompt-editor' && (
+                    <PromptEditorWithVariables
+                      value={field.value}
+                      onChange={field.onChange}
+                      readonly={readonly}
+                      hasError={Object.keys(fieldState?.errors || {}).length > 0}
+                    />
+                  )}
+                  {!formComponent && (
+                    <DynamicValueInput
+                      value={field.value}
+                      onChange={field.onChange}
+                      readonly={readonly}
+                      hasError={Object.keys(fieldState?.errors || {}).length > 0}
+                      constantProps={{
+                        schema: property,
+                      }}
+                    />
+                  )}
                   <Feedback errors={fieldState?.errors} warnings={fieldState?.warnings} />
                 </FormItem>
               )}

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

@@ -181,7 +181,7 @@ export const initialData: FlowDocumentJSON = {
               },
               systemPrompt: {
                 type: 'constant',
-                content: 'You are an AI assistant.',
+                content: '# Role\nYou are an AI assistant.\n',
               },
               prompt: {
                 type: 'constant',
@@ -206,9 +206,15 @@ export const initialData: FlowDocumentJSON = {
                 },
                 systemPrompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
                 prompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
               },
             },
@@ -252,7 +258,7 @@ export const initialData: FlowDocumentJSON = {
               },
               systemPrompt: {
                 type: 'constant',
-                content: 'You are an AI assistant.',
+                content: '# Role\nYou are an AI assistant.\n',
               },
               prompt: {
                 type: 'constant',
@@ -277,9 +283,15 @@ export const initialData: FlowDocumentJSON = {
                 },
                 systemPrompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
                 prompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
               },
             },
@@ -342,7 +354,7 @@ export const initialData: FlowDocumentJSON = {
               },
               systemPrompt: {
                 type: 'constant',
-                content: 'You are an AI assistant.',
+                content: '# Role\nYou are an AI assistant.\n',
               },
               prompt: {
                 type: 'constant',
@@ -367,9 +379,15 @@ export const initialData: FlowDocumentJSON = {
                 },
                 systemPrompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
                 prompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
               },
             },
@@ -413,7 +431,7 @@ export const initialData: FlowDocumentJSON = {
               },
               systemPrompt: {
                 type: 'constant',
-                content: 'You are an AI assistant.',
+                content: '# Role\nYou are an AI assistant.\n',
               },
               prompt: {
                 type: 'constant',
@@ -438,9 +456,15 @@ export const initialData: FlowDocumentJSON = {
                 },
                 systemPrompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
                 prompt: {
                   type: 'string',
+                  extra: {
+                    formComponent: 'prompt-editor',
+                  },
                 },
               },
             },

+ 7 - 1
apps/demo-free-layout/src/nodes/llm/index.ts

@@ -48,7 +48,7 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
           },
           systemPrompt: {
             type: 'constant',
-            content: 'You are an AI assistant.',
+            content: '# Role\nYou are an AI assistant.\n',
           },
           prompt: {
             type: 'constant',
@@ -73,9 +73,15 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
             },
             systemPrompt: {
               type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
             },
             prompt: {
               type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
+              },
             },
           },
         },

Разница между файлами не показана из-за своего большого размера
+ 839 - 46
common/config/rush/pnpm-lock.yaml


+ 3 - 1
packages/materials/form-materials/package.json

@@ -41,7 +41,9 @@
     "commander": "^11.0.0",
     "chalk": "^5.3.0",
     "inquirer": "^9.2.7",
-    "immer": "~10.1.1"
+    "immer": "~10.1.1",
+    "@coze-editor/editor": "0.1.0-alpha.8d7a30",
+    "@codemirror/view": "~6.38.0"
   },
   "devDependencies": {
     "@flowgram.ai/eslint-config": "workspace:*",

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

@@ -11,3 +11,5 @@ export * from './constant-input';
 export * from './dynamic-value-input';
 export * from './condition-row';
 export * from './batch-outputs';
+export * from './prompt-editor';
+export * from './prompt-editor-with-variables';

+ 10 - 0
packages/materials/form-materials/src/components/prompt-editor-with-variables/config.json

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

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

@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React, { useEffect, useState } from 'react';
+
+import { Popover, Tree } from '@douyinfe/semi-ui';
+import {
+  Mention,
+  MentionOpenChangeEvent,
+  getCurrentMentionReplaceRange,
+  useEditor,
+  PositionMirror,
+} from '@coze-editor/editor/react';
+import { EditorAPI } from '@coze-editor/editor/preset-prompt';
+
+import { useVariableTree } from '../../variable-selector';
+
+function Variable() {
+  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]);
+
+  const treeData = useVariableTree({});
+
+  return (
+    <>
+      <Mention triggerCharacters={['{', '{}']} onOpenChange={handleOpenChange} />
+
+      <Popover
+        visible={visible}
+        trigger="custom"
+        position="topLeft"
+        rePosKey={posKey}
+        content={
+          <div style={{ width: 300 }}>
+            <Tree
+              treeData={treeData}
+              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>
+    </>
+  );
+}
+
+export default Variable;

+ 17 - 0
packages/materials/form-materials/src/components/prompt-editor-with-variables/index.tsx

@@ -0,0 +1,17 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import Variable from './extensions/variable';
+import { PromptEditor, PromptEditorPropsType } from '../prompt-editor';
+
+export function PromptEditorWithVariables(props: PromptEditorPropsType) {
+  return (
+    <PromptEditor {...props}>
+      <Variable />
+    </PromptEditor>
+  );
+}

+ 9 - 0
packages/materials/form-materials/src/components/prompt-editor/config.json

@@ -0,0 +1,9 @@
+{
+  "name": "prompt-editor",
+  "depMaterials": [],
+  "depPackages": [
+    "@coze-editor/editor@0.1.0-alpha.8d7a30",
+    "@codemirror/view",
+    "styled-components"
+  ]
+}

+ 58 - 0
packages/materials/form-materials/src/components/prompt-editor/extensions/jinja.tsx

@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useLayoutEffect } from 'react';
+
+import { useInjector } from '@coze-editor/editor/react';
+import { astDecorator } from '@coze-editor/editor';
+import { EditorView } from '@codemirror/view';
+
+function JinjaHighlight() {
+  const injector = useInjector();
+
+  useLayoutEffect(
+    () =>
+      injector.inject([
+        astDecorator.whole.of((cursor) => {
+          if (cursor.name === 'JinjaStatementStart' || cursor.name === 'JinjaStatementEnd') {
+            return {
+              type: 'className',
+              className: 'jinja-statement-bracket',
+            };
+          }
+
+          if (cursor.name === 'JinjaComment') {
+            return {
+              type: 'className',
+              className: 'jinja-comment',
+            };
+          }
+
+          if (cursor.name === 'JinjaExpression') {
+            return {
+              type: 'className',
+              className: 'jinja-expression',
+            };
+          }
+        }),
+        EditorView.theme({
+          '.jinja-statement-bracket': {
+            color: '#D1009D',
+          },
+          '.jinja-comment': {
+            color: '#0607094D',
+          },
+          '.jinja-expression': {
+            color: '#4E40E5',
+          },
+        }),
+      ]),
+    [injector]
+  );
+
+  return null;
+}
+
+export default JinjaHighlight;

+ 19 - 0
packages/materials/form-materials/src/components/prompt-editor/extensions/language-support.tsx

@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useLayoutEffect } from 'react';
+
+import { useInjector } from '@coze-editor/editor/react';
+import { languageSupport } from '@coze-editor/editor/preset-prompt';
+
+function LanguageSupport() {
+  const injector = useInjector();
+
+  useLayoutEffect(() => injector.inject([languageSupport]), [injector]);
+
+  return null;
+}
+
+export default LanguageSupport;

+ 75 - 0
packages/materials/form-materials/src/components/prompt-editor/extensions/markdown.tsx

@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useLayoutEffect } from 'react';
+
+import { useInjector } from '@coze-editor/editor/react';
+import { astDecorator } from '@coze-editor/editor';
+import { EditorView } from '@codemirror/view';
+
+function MarkdownHighlight() {
+  const injector = useInjector();
+
+  useLayoutEffect(
+    () =>
+      injector.inject([
+        astDecorator.whole.of((cursor) => {
+          // # heading
+          if (cursor.name.startsWith('ATXHeading')) {
+            return {
+              type: 'className',
+              className: 'heading',
+            };
+          }
+
+          // *italic*
+          if (cursor.name === 'Emphasis') {
+            return {
+              type: 'className',
+              className: 'emphasis',
+            };
+          }
+
+          // **bold**
+          if (cursor.name === 'StrongEmphasis') {
+            return {
+              type: 'className',
+              className: 'strong-emphasis',
+            };
+          }
+
+          // -
+          // 1.
+          // >
+          if (cursor.name === 'ListMark' || cursor.name === 'QuoteMark') {
+            return {
+              type: 'className',
+              className: 'mark',
+            };
+          }
+        }),
+        EditorView.theme({
+          '.heading': {
+            color: '#00818C',
+            fontWeight: 'bold',
+          },
+          '.emphasis': {
+            fontStyle: 'italic',
+          },
+          '.strong-emphasis': {
+            fontWeight: 'bold',
+          },
+          '.mark': {
+            color: '#4E40E5',
+          },
+        }),
+      ]),
+    [injector]
+  );
+
+  return null;
+}
+
+export default MarkdownHighlight;

+ 43 - 0
packages/materials/form-materials/src/components/prompt-editor/index.tsx

@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import { Renderer, EditorProvider } from '@coze-editor/editor/react';
+import preset from '@coze-editor/editor/preset-prompt';
+
+import { PropsType } from './types';
+import { UIContainer } from './styles';
+import MarkdownHighlight from './extensions/markdown';
+import LanguageSupport from './extensions/language-support';
+import JinjaHighlight from './extensions/jinja';
+
+export type PromptEditorPropsType = PropsType;
+
+export function PromptEditor(props: PropsType) {
+  const { value, onChange, readonly, style, hasError, children } = props || {};
+
+  return (
+    <UIContainer $hasError={hasError} style={style}>
+      <EditorProvider>
+        <Renderer
+          plugins={preset}
+          defaultValue={String(value?.content)}
+          options={{
+            readOnly: readonly,
+            editable: !readonly,
+          }}
+          onChange={(e) => {
+            onChange({ type: 'template', content: e.value });
+          }}
+        />
+        <MarkdownHighlight />
+        <LanguageSupport />
+        <JinjaHighlight />
+        {children}
+      </EditorProvider>
+    </UIContainer>
+  );
+}

+ 18 - 0
packages/materials/form-materials/src/components/prompt-editor/styles.tsx

@@ -0,0 +1,18 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import styled, { css } from 'styled-components';
+
+export const UIContainer = styled.div<{ $hasError?: boolean }>`
+  background-color: var(--semi-color-fill-0);
+  padding-left: 10px;
+  padding-right: 6px;
+
+  ${({ $hasError }) =>
+    $hasError &&
+    css`
+      border: 1px solid var(--semi-color-danger-6);
+    `}
+`;

+ 16 - 0
packages/materials/form-materials/src/components/prompt-editor/types.tsx

@@ -0,0 +1,16 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import { IFlowTemplateValue } from '../../typings';
+
+export type PropsType = React.PropsWithChildren<{
+  value?: IFlowTemplateValue;
+  onChange: (value?: IFlowTemplateValue) => void;
+  readonly?: boolean;
+  hasError?: boolean;
+  style?: React.CSSProperties;
+}>;

+ 9 - 3
packages/materials/form-materials/tsconfig.json

@@ -1,8 +1,14 @@
 {
   "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
   "compilerOptions": {
-    "jsx": "react"
+    "jsx": "react",
+    "moduleResolution": "bundler"
   },
-  "include": ["./src", "./bin/**/*.ts"],
-  "exclude": ["node_modules"]
+  "include": [
+    "./src",
+    "./bin/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов