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

* feat: init prompt editor

* feat: simple prompt editor

* feat: split prompt editor

* feat: fix-layout prompt editor
This commit is contained in:
Yiwei Mao 2025-07-02 21:11:20 +08:00 committed by GitHub
parent 800a820e10
commit de7f2d3c07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1543 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,9 @@
"commander": "^11.0.0", "commander": "^11.0.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"inquirer": "^9.2.7", "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": { "devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/eslint-config": "workspace:*",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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