feat: prompt editor variable display (#453)

* feat: prompt editor variable display

* feat: prompt editor with variables docs
This commit is contained in:
Yiwei Mao 2025-07-03 17:32:23 +08:00 committed by GitHub
parent 25e20d8c20
commit 9210f5041d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 272 additions and 14 deletions

View File

@ -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: {

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

View File

@ -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 物料

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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