Refa: refine code_exec component (#13925)

### What problem does this PR solve?

Refine code_exec component.

### Type of change

- [x] Refactoring
This commit is contained in:
Yongteng Lei
2026-04-07 11:48:29 +08:00
committed by GitHub
parent c4b0aaa874
commit 112007243d
30 changed files with 1767 additions and 427 deletions

View File

@@ -160,7 +160,7 @@ export interface ICodeForm {
arguments: Record<string, string>;
lang: string;
script?: string;
outputs: Record<string, { value: string; type: string }>;
outputs: Record<string, { value: unknown; type: string }>;
}
export interface IAgentForm {
@@ -192,7 +192,7 @@ export interface IAgentForm {
};
}
export type BaseNodeData<TForm extends any> = {
export type BaseNodeData<TForm = any> = {
label: string; // operator type
name: string; // operator name
color?: string;

View File

@@ -2078,6 +2078,9 @@ This delimiter is used to split the input text into several text pieces echo of
}`,
datatype: 'MINE type of the HTTP request',
insertVariableTip: `Enter / Insert variables`,
mergePath: 'Merge path',
mergePathTip:
'When enabled, a dot suffix immediately after a variable is merged into a path query, such as {node@result.name}.',
historyVersion: 'Version history',
version: {
created: 'Created',

View File

@@ -1262,6 +1262,9 @@ export default {
categoryName: '分類名稱',
nextStep: '下一步',
insertVariableTip: `輸入 / 插入變數`,
mergePath: '合併路徑',
mergePathTip:
'開啟後,緊跟在變數後面的點號後綴會合併為路徑查詢,例如 {node@result.name}。',
promptMessage: '提示詞是必填項',
promptTip:
'系統提示為大型模型提供任務描述、規定回覆方式,以及設定其他各種要求。系統提示通常與 key變數合用透過變數設定大型模型的輸入資料。你可以透過斜線或 (x) 按鈕顯示可用的 key。',

View File

@@ -1818,6 +1818,9 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
categoryName: '分类名称',
nextStep: '下一步',
insertVariableTip: `输入 / 插入变量`,
mergePath: '合并路径',
mergePathTip:
'开启后,紧跟在变量后面的点号后缀会合并为路径查询,例如 {node@result.name}。',
setting: '设置',
settings: {
agentSetting: 'Agent设置',

View File

@@ -27,6 +27,10 @@ export * from './pipeline';
import { ModelVariableType } from '@/constants/knowledge';
import { t } from 'i18next';
import {
buildDefaultCodeOutput,
serializeCodeOutputContract,
} from '../form/code-form/utils';
// DuckDuckGo's channel options
export enum Channel {
@@ -427,7 +431,7 @@ export const initialCodeValues = {
arg1: '',
arg2: '',
},
outputs: {},
outputs: serializeCodeOutputContract(buildDefaultCodeOutput()),
};
export const initialWaitingDialogueValues = {};

View File

@@ -2,6 +2,7 @@ import CopyToClipboard from '@/components/copy-to-clipboard';
import { Sheet, SheetContent, SheetHeader } from '@/components/ui/sheet';
import { useDebugSingle, useFetchInputForm } from '@/hooks/use-agent-request';
import { IModalProps } from '@/interfaces/common';
import { ICodeForm } from '@/interfaces/database/agent';
import { cn } from '@/lib/utils';
import { isEmpty } from 'lodash';
import { X } from 'lucide-react';
@@ -12,6 +13,70 @@ import 'react18-json-view/src/style.css';
import DebugContent from '../../debug-content';
import { transferInputsArrayToObject } from '../../form/begin-form/use-watch-change';
import { buildBeginInputListFromObject } from '../../form/begin-form/utils';
import {
deserializeCodeOutputContract,
getBusinessOutputs,
} from '../../form/code-form/utils';
import useGraphStore from '../../store';
import {
groupCodeExecDebugOutput,
shouldUseCodeExecDebugLayout,
} from './utils';
function DebugRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3 rounded-md border bg-background p-3">
<span className="text-sm text-text-secondary">{label}</span>
<span className="text-right text-sm text-text-primary">
{value || '-'}
</span>
</div>
);
}
function DebugJsonCard({
title,
value,
error,
}: {
title: string;
value: unknown;
error?: boolean;
}) {
return (
<div
className={cn('rounded-md border', {
'border-state-error': error,
})}
>
<div className="flex justify-between p-2">
<span>{title}</span>
<CopyToClipboard text={JSON.stringify(value ?? null, null, 2)} />
</div>
<JsonView
src={value ?? null}
displaySize
collapseStringsAfterLength={100000000000}
className="h-[260px] w-full overflow-auto break-words p-2"
dark
/>
</div>
);
}
function DebugTextCard({ title, value }: { title: string; value: string }) {
return (
<div className="rounded-md border">
<div className="flex justify-between p-2">
<span>{title}</span>
<CopyToClipboard text={value} />
</div>
<pre className="max-h-[260px] overflow-auto whitespace-pre-wrap break-words p-3 text-sm text-text-primary">
{value || '-'}
</pre>
</div>
);
}
interface IProps {
componentId?: string;
@@ -23,8 +88,13 @@ const SingleDebugSheet = ({
hideModal,
}: IModalProps<any> & IProps) => {
const { t } = useTranslation();
const getNode = useGraphStore((state) => state.getNode);
const inputForm = useFetchInputForm(componentId);
const { debugSingle, data, loading } = useDebugSingle();
const node = getNode(componentId);
const shouldUseCodeExecLayout = shouldUseCodeExecDebugLayout(
node?.data.label,
);
const list = useMemo(() => {
return buildBeginInputListFromObject(inputForm);
@@ -42,43 +112,124 @@ const SingleDebugSheet = ({
[componentId, debugSingle],
);
const formData = shouldUseCodeExecLayout
? (node?.data.form as ICodeForm | undefined)
: undefined;
const debugData = useMemo(
() =>
data && typeof data === 'object'
? (data as Record<string, unknown>)
: undefined,
[data],
);
const { contract, legacyOutputs } = useMemo(
() => deserializeCodeOutputContract(formData),
[formData],
);
const grouped = useMemo(
() => groupCodeExecDebugOutput(debugData, contract),
[contract, debugData],
);
const businessOutputPreview = useMemo(() => {
if (contract?.name && debugData && contract.name in debugData) {
return { [contract.name]: debugData[contract.name] };
}
if (legacyOutputs.length > 0 && debugData) {
return Object.fromEntries(
Object.keys(getBusinessOutputs(formData?.outputs)).map((key) => [
key,
debugData[key],
]),
);
}
return {};
}, [contract, debugData, formData?.outputs, legacyOutputs.length]);
const content = JSON.stringify(data, null, 2);
const hasError = shouldUseCodeExecLayout
? !isEmpty(grouped.systemOutputs._ERROR)
: !isEmpty((data as Record<string, unknown> | undefined)?._ERROR);
return (
<Sheet open={visible} modal={false}>
<SheetContent className="top-20 p-0" closeIcon={false}>
<SheetHeader className="py-2 px-5">
<SheetHeader className="px-5 py-2">
<div className="flex justify-between ">
{t('flow.testRun')}
<X onClick={hideModal} className="cursor-pointer" />
</div>
</SheetHeader>
<section className="overflow-y-auto pt-4 px-5">
<section className="overflow-y-auto px-5 pt-4">
<DebugContent
parameters={list}
ok={onOk}
isNext={false}
loading={loading}
className="flex-1 overflow-auto min-h-0 pb-5"
className="min-h-0 flex-1 overflow-auto pb-5"
maxHeight="max-h-[83vh]"
></DebugContent>
{!isEmpty(data) ? (
<div
className={cn('mt-4 rounded-md border', {
[`border-state-error`]: !isEmpty(data._ERROR),
})}
>
<div className="flex justify-between p-2">
<span>JSON</span>
<CopyToClipboard text={content}></CopyToClipboard>
<div className="mt-4 space-y-4">
{shouldUseCodeExecLayout ? (
<>
<div
className={cn('space-y-3 rounded-md border p-3', {
'border-state-error': hasError,
})}
>
<DebugRow
label="Business Output"
value={contract?.name || legacyOutputs.join(', ')}
/>
<DebugRow
label="Expected Type"
value={grouped.expectedType}
/>
<DebugRow label="Actual Type" value={grouped.actualType} />
</div>
{!isEmpty(businessOutputPreview) && (
<DebugJsonCard
title="Business Output Value"
value={businessOutputPreview}
error={hasError}
/>
)}
<DebugJsonCard
title="Raw Result"
value={grouped.rawResult}
error={hasError}
/>
<DebugTextCard title="Content" value={grouped.content} />
<DebugJsonCard
title="System Outputs"
value={grouped.systemOutputs}
error={hasError}
/>
</>
) : null}
<div
className={cn('rounded-md border', {
'border-state-error': hasError,
})}
>
<div className="flex justify-between p-2">
<span>
{shouldUseCodeExecLayout ? 'Raw Component Output' : 'JSON'}
</span>
<CopyToClipboard text={content}></CopyToClipboard>
</div>
<JsonView
src={data}
displaySize
collapseStringsAfterLength={100000000000}
className={cn('w-full overflow-auto break-words p-2', {
'h-[520px]': shouldUseCodeExecLayout,
'h-[800px]': !shouldUseCodeExecLayout,
})}
dark
/>
</div>
<JsonView
src={data}
displaySize
collapseStringsAfterLength={100000000000}
className="w-full h-[800px] break-words overflow-auto p-2"
dark
/>
</div>
) : null}
</section>

View File

@@ -0,0 +1,10 @@
import { Operator } from '../../constant';
import { shouldUseCodeExecDebugLayout } from './utils';
describe('shouldUseCodeExecDebugLayout', () => {
it('returns true only for CodeExec nodes', () => {
expect(shouldUseCodeExecDebugLayout(Operator.Code)).toBe(true);
expect(shouldUseCodeExecDebugLayout(Operator.Http)).toBe(false);
expect(shouldUseCodeExecDebugLayout(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import { Operator } from '../../constant';
import { CodeOutputContract } from '../../form/code-form/utils';
const SYSTEM_OUTPUT_NAMES = new Set([
'_ERROR',
'_ARTIFACTS',
'_ATTACHMENT_CONTENT',
]);
export type GroupedCodeExecDebugOutput = {
expectedType: string;
actualType: string;
rawResult: unknown;
content: string;
systemOutputs: Record<string, unknown>;
};
export function groupCodeExecDebugOutput(
data: Record<string, unknown> | undefined,
contract: CodeOutputContract | null,
): GroupedCodeExecDebugOutput {
const businessName = contract?.name ?? '';
const source = data ?? {};
const systemOutputs = Object.fromEntries(
Object.entries(source).filter(([key]) => SYSTEM_OUTPUT_NAMES.has(key)),
);
return {
expectedType: contract?.type ?? '',
actualType: String(source.actual_type ?? ''),
rawResult:
source.raw_result ?? (businessName ? source[businessName] : undefined),
content: String(source.content ?? ''),
systemOutputs,
};
}
export function shouldUseCodeExecDebugLayout(label?: string): boolean {
return label === Operator.Code;
}

View File

@@ -16,6 +16,7 @@ import { RAGFlowSelect } from '@/components/ui/select';
import { ProgrammingLanguage } from '@/constants/agent';
import { ICodeForm } from '@/interfaces/database/agent';
import { zodResolver } from '@hookform/resolvers/zod';
import { AlertTriangle } from 'lucide-react';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -33,6 +34,11 @@ import {
useHandleLanguageChange,
useWatchFormChange,
} from './use-watch-change';
import {
CodeExecPanelSystemOutputs,
getBusinessOutputs,
serializeCodeOutputContract,
} from './utils';
loader.config({ paths: { vs: '/vs' } });
@@ -41,18 +47,10 @@ const options = [
ProgrammingLanguage.Javascript,
].map((x) => ({ value: x, label: x }));
const DynamicFieldName = 'outputs';
const CodeSystemOutputs = {
content: {
type: 'string',
value: '',
},
};
function CodeForm({ node }: INextOperatorForm) {
const formData = node?.data.form as ICodeForm;
const { t } = useTranslation();
const values = useValues(node);
const { values, legacyOutputs } = useValues(node);
const isDarkTheme = useIsDarkTheme();
const form = useForm<FormSchemaType>({
@@ -63,6 +61,13 @@ function CodeForm({ node }: INextOperatorForm) {
useWatchFormChange(node?.id, form);
const handleLanguageChange = useHandleLanguageChange(node?.id, form);
const lang = form.watch('lang');
const currentOutput = form.watch('output');
const outputFieldDirty = !!form.formState.dirtyFields?.output;
const displayedBusinessOutputs =
legacyOutputs.length > 0 && !outputFieldDirty
? getBusinessOutputs(formData?.outputs)
: serializeCodeOutputContract(currentOutput);
return (
<Form {...form}>
@@ -103,7 +108,7 @@ function CodeForm({ node }: INextOperatorForm) {
<Editor
height={300}
theme={isDarkTheme ? 'vs-dark' : 'vs'}
language={formData.lang}
language={lang}
options={{
minimap: { enabled: false },
automaticLayout: true,
@@ -116,61 +121,62 @@ function CodeForm({ node }: INextOperatorForm) {
)}
/>
{formData.lang === ProgrammingLanguage.Python ? (
<DynamicInputVariable
node={node}
title={'Return Values'}
name={DynamicFieldName}
isOutputs
></DynamicInputVariable>
) : (
<div>
<VariableTitle title={'Return Values'}></VariableTitle>
<FormContainer className="space-y-5">
<FormField
control={form.control}
name={`${DynamicFieldName}.name`}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('common.pleaseInput')}
></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`${DynamicFieldName}.type`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Type</FormLabel>
<FormControl>
<RAGFlowSelect
placeholder={t('common.pleaseSelect')}
options={TypeOptions}
{...field}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</FormContainer>
</div>
)}
<div className="space-y-3">
<VariableTitle title={'Return Value'}></VariableTitle>
{legacyOutputs.length > 0 && (
<div className="flex items-start gap-2 rounded-md border border-state-error/40 bg-state-error/10 px-3 py-2 text-sm text-text-primary">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-state-error" />
<p>
This CodeExec node uses the deprecated multi-output schema:{' '}
{legacyOutputs.join(', ')}. Keep one business output here and
move field extraction to downstream nodes.
</p>
</div>
)}
<FormContainer className="space-y-5">
<FormField
control={form.control}
name="output.name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('common.pleaseInput')}
></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="output.type"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Type</FormLabel>
<FormControl>
<RAGFlowSelect
placeholder={t('common.pleaseSelect')}
options={TypeOptions}
{...field}
></RAGFlowSelect>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</FormContainer>
</div>
</FormWrapper>
<div className="p-5">
<Output
list={buildOutputList({
...(formData?.outputs ?? {}),
...CodeSystemOutputs,
})}
></Output>
<div className="space-y-4 p-5">
<Output list={buildOutputList(displayedBusinessOutputs)}>
Business
</Output>
<Output list={buildOutputList(CodeExecPanelSystemOutputs)}>
System
</Output>
</div>
</Form>
);

View File

@@ -29,9 +29,12 @@ export const TypeOptions = [
'String',
'Number',
'Boolean',
'Object',
'Array<String>',
'Array<Number>',
'Object',
'Array<Any>',
'Array<Object>',
'Any',
].map((x) => ({ label: x, value: x }));
export function DynamicVariableForm({ name = 'arguments', isOutputs }: IProps) {

View File

@@ -1,14 +1,22 @@
import { ProgrammingLanguage } from '@/constants/agent';
import { z } from 'zod';
import { isValidCodeOutputName } from './utils';
export const FormSchema = z.object({
lang: z.enum([ProgrammingLanguage.Python, ProgrammingLanguage.Javascript]),
script: z.string(),
arguments: z.array(z.object({ name: z.string(), type: z.string() })),
outputs: z.union([
z.array(z.object({ name: z.string(), type: z.string() })).optional(),
z.object({ name: z.string(), type: z.string() }),
]),
output: z.object({
name: z
.string()
.trim()
.min(1, 'Name is required')
.refine(
isValidCodeOutputName,
'Name cannot use reserved outputs or path syntax',
),
type: z.string().trim().min(1, 'Type is required'),
}),
});
export type FormSchemaType = z.infer<typeof FormSchema>;

View File

@@ -1,8 +1,8 @@
import { ProgrammingLanguage } from '@/constants/agent';
import { ICodeForm, RAGFlowNodeType } from '@/interfaces/database/agent';
import { RAGFlowNodeType } from '@/interfaces/database/agent';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { initialCodeValues } from '../../constant';
import { buildDefaultCodeOutput, deserializeCodeOutputContract } from './utils';
function convertToArray(args: Record<string, string>) {
return Object.entries(args).map(([key, value]) => ({
@@ -11,36 +11,32 @@ function convertToArray(args: Record<string, string>) {
}));
}
type OutputsFormType = { name: string; type: string };
function convertOutputsToArray({ lang, outputs = {} }: ICodeForm) {
if (lang === ProgrammingLanguage.Python) {
return Object.entries(outputs).map(([key, val]) => ({
name: key,
type: val.type,
}));
}
return Object.entries(outputs).reduce<OutputsFormType>((pre, [key, val]) => {
pre.name = key;
pre.type = val.type;
return pre;
}, {} as OutputsFormType);
}
export function useValues(node?: RAGFlowNodeType) {
const values = useMemo(() => {
const valueState = useMemo(() => {
const formData = node?.data?.form;
if (isEmpty(formData)) {
return initialCodeValues;
return {
values: {
...initialCodeValues,
arguments: convertToArray(initialCodeValues.arguments),
output: buildDefaultCodeOutput(),
},
legacyOutputs: [],
};
}
const { contract, legacyOutputs } = deserializeCodeOutputContract(formData);
return {
...formData,
arguments: convertToArray(formData.arguments),
outputs: convertOutputsToArray(formData),
values: {
...formData,
arguments: convertToArray(formData.arguments),
output: contract ?? buildDefaultCodeOutput(),
},
legacyOutputs,
};
}, [node?.data?.form]);
return values;
return valueState;
}

View File

@@ -1,10 +1,14 @@
import { CodeTemplateStrMap, ProgrammingLanguage } from '@/constants/agent';
import { ICodeForm } from '@/interfaces/database/agent';
import { isEmpty } from 'lodash';
import { useCallback, useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import useGraphStore from '../../store';
import { FormSchemaType } from './schema';
import {
buildDefaultCodeOutput,
hasLegacyMultiOutputs,
serializeCodeOutputContract,
} from './utils';
function convertToObject(list: FormSchemaType['arguments'] = []) {
return list.reduce<Record<string, string>>((pre, cur) => {
@@ -14,58 +18,52 @@ function convertToObject(list: FormSchemaType['arguments'] = []) {
}, {});
}
type ArrayOutputs = Extract<FormSchemaType['outputs'], Array<any>>;
type ObjectOutputs = Exclude<FormSchemaType['outputs'], Array<any>>;
function convertOutputsToObject({ lang, outputs }: FormSchemaType) {
if (lang === ProgrammingLanguage.Python) {
return (outputs as ArrayOutputs).reduce<ICodeForm['outputs']>(
(pre, cur) => {
pre[cur.name] = {
value: '',
type: cur.type,
};
return pre;
},
{},
);
}
const outputsObject = outputs as ObjectOutputs;
if (isEmpty(outputsObject)) {
return {};
}
return {
[outputsObject.name]: {
value: '',
type: outputsObject.type,
},
};
}
export function useWatchFormChange(
id?: string,
form?: UseFormReturn<FormSchemaType>,
) {
let values = useWatch({ control: form?.control });
const watchedValues = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
const getNode = useGraphStore((state) => state.getNode);
useEffect(() => {
// Manually triggered form updates are synchronized to the canvas
if (id) {
values = form?.getValues() || {};
let nextValues: any = {
const values = form?.getValues() || watchedValues || {};
const currentOutputs = getNode(id)?.data?.form?.outputs;
const shouldPreserveLegacyOutputs =
hasLegacyMultiOutputs(currentOutputs) &&
isEmpty(form?.formState.dirtyFields?.output);
const hasCompleteOutputContract =
!!values?.output?.name?.trim() && !!values?.output?.type?.trim();
const nextValues: any = {
...values,
arguments: convertToObject(
values?.arguments as FormSchemaType['arguments'],
),
outputs: convertOutputsToObject(values as FormSchemaType),
outputs: shouldPreserveLegacyOutputs
? currentOutputs
: hasCompleteOutputContract
? serializeCodeOutputContract({
name: values.output?.name?.trim() ?? '',
type: values.output?.type?.trim() ?? '',
})
: (currentOutputs ??
serializeCodeOutputContract(buildDefaultCodeOutput())),
};
delete nextValues.output;
updateNodeForm(id, nextValues);
}
}, [form?.formState.isDirty, id, updateNodeForm, values]);
}, [
form?.formState.dirtyFields?.output,
form?.formState.isDirty,
form,
getNode,
id,
updateNodeForm,
watchedValues,
]);
}
export function useHandleLanguageChange(
@@ -79,12 +77,14 @@ export function useHandleLanguageChange(
if (id) {
const script = CodeTemplateStrMap[lang as ProgrammingLanguage];
form?.setValue('script', script);
form?.setValue(
'outputs',
(lang === ProgrammingLanguage.Python
? []
: {}) as FormSchemaType['outputs'],
);
if (
!form?.getValues('output')?.name ||
!form?.getValues('output')?.type
) {
form?.setValue('output', buildDefaultCodeOutput(), {
shouldDirty: true,
});
}
updateNodeForm(id, script, ['script']);
}
},

View File

@@ -0,0 +1,117 @@
import { ICodeForm } from '@/interfaces/database/agent';
export type CodeOutputContract = {
name: string;
type: string;
};
type DeserializeCodeOutputResult = {
contract: CodeOutputContract | null;
legacyOutputs: string[];
};
const CodeExecReservedOutputKeys = [
'content',
'actual_type',
'raw_result',
'_ERROR',
'_ARTIFACTS',
'_ATTACHMENT_CONTENT',
'_created_time',
'_elapsed_time',
] as const;
export const CodeExecPanelSystemOutputs: ICodeForm['outputs'] = {
content: {
type: 'String',
value: '',
},
actual_type: {
type: 'String',
value: '',
},
};
const CodeExecReservedOutputKeySet = new Set<string>(
CodeExecReservedOutputKeys,
);
export function buildDefaultCodeOutput(): CodeOutputContract {
return {
name: 'result',
type: 'String',
};
}
export function isValidCodeOutputName(name: string): boolean {
const value = name.trim();
return (
!!value && !CodeExecReservedOutputKeySet.has(value) && !value.includes('.')
);
}
export function getBusinessOutputs(
outputs: ICodeForm['outputs'] = {},
): ICodeForm['outputs'] {
return Object.entries(outputs).reduce<ICodeForm['outputs']>((next, entry) => {
const [name, value] = entry;
if (!CodeExecReservedOutputKeySet.has(name)) {
next[name] = value;
}
return next;
}, {});
}
export function deserializeCodeOutputContract(
form?: Pick<ICodeForm, 'outputs'> | null,
): DeserializeCodeOutputResult {
const outputs = form?.outputs ?? {};
const businessOutputs = Object.entries(getBusinessOutputs(outputs));
if (businessOutputs.length === 0) {
return { contract: buildDefaultCodeOutput(), legacyOutputs: [] };
}
if (businessOutputs.length > 1) {
return {
contract: null,
legacyOutputs: businessOutputs.map(([name]) => name),
};
}
const [name, output] = businessOutputs[0];
return {
contract: {
name,
type: output.type,
},
legacyOutputs: [],
};
}
export function hasLegacyMultiOutputs(
outputs: ICodeForm['outputs'] = {},
): boolean {
return Object.keys(getBusinessOutputs(outputs)).length > 1;
}
export function serializeCodeOutputContract(
contract: CodeOutputContract | null,
): ICodeForm['outputs'] {
const name = contract?.name?.trim();
const type = contract?.type?.trim();
if (!name || !type || !isValidCodeOutputName(name)) {
return {};
}
return {
[name]: {
type,
value: null,
},
};
}

View File

@@ -15,6 +15,7 @@ import {
LexicalNode,
} from 'lexical';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
@@ -24,7 +25,7 @@ import { cn } from '@/lib/utils';
import { JsonSchemaDataType } from '@/pages/agent/constant';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { Variable } from 'lucide-react';
import { forwardRef, ReactNode, useCallback, useState } from 'react';
import { forwardRef, ReactNode, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EnterKeyPlugin } from './enter-key-plugin';
import { PasteHandlerPlugin } from './paste-handler-plugin';
@@ -51,24 +52,30 @@ const Nodes: Array<Klass<LexicalNode>> = [
];
type PromptContentProps = {
enablePathQueryAutoMerge: boolean;
showToolbar?: boolean;
multiLine?: boolean;
onBlur?: () => void;
onEnablePathQueryAutoMergeChange: (checked: boolean) => void;
};
type IProps = {
enablePathQueryAutoMerge?: boolean;
showToolbar?: boolean;
multiLine?: boolean;
value?: string;
onChange?: (value?: string) => void;
onBlur?: () => void;
placeholder?: ReactNode;
types?: JsonSchemaDataType[];
} & PromptContentProps &
Pick<VariablePickerMenuPluginProps, 'extraOptions' | 'baseOptions'>;
} & Pick<VariablePickerMenuPluginProps, 'extraOptions' | 'baseOptions'>;
function PromptContent({
enablePathQueryAutoMerge,
showToolbar = true,
multiLine = true,
onBlur,
onEnablePathQueryAutoMergeChange,
}: PromptContentProps) {
const [editor] = useLexicalComposerContext();
const [isBlur, setIsBlur] = useState(false);
@@ -102,7 +109,7 @@ function PromptContent({
className={cn('border rounded-sm ', { 'border-accent-primary': !isBlur })}
>
{showToolbar && (
<div className="border-b px-2 py-2 justify-end flex">
<div className="border-b px-2 py-2 justify-end flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block cursor-pointer cursor p-0.5 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-sm">
@@ -113,18 +120,60 @@ function PromptContent({
<p>{t('flow.insertVariableTip')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<label className="flex cursor-pointer items-center rounded-sm border border-border bg-bg-base/95 px-1 py-0.5 shadow-sm backdrop-blur-sm">
<span className="sr-only">{t('flow.mergePath')}</span>
<div className="origin-right scale-75">
<Switch
checked={enablePathQueryAutoMerge}
onCheckedChange={onEnablePathQueryAutoMergeChange}
aria-label={t('flow.mergePath')}
/>
</div>
</label>
</TooltipTrigger>
<TooltipContent>
<p>{t('flow.mergePath')}</p>
<p>{t('flow.mergePathTip')}</p>
</TooltipContent>
</Tooltip>
</div>
)}
<ContentEditable
className={cn(
'relative px-2 py-1 focus-visible:outline-none max-h-[50vh] overflow-auto text-sm',
{
'min-h-40': multiLine,
},
<div className="relative">
{!showToolbar && (
<div className="absolute inset-y-0 right-2 z-10 flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<label className="flex cursor-pointer items-center rounded-sm border border-border bg-bg-base/95 px-1 py-0.5 shadow-sm backdrop-blur-sm">
<span className="sr-only">{t('flow.mergePath')}</span>
<div className="origin-right scale-75">
<Switch
checked={enablePathQueryAutoMerge}
onCheckedChange={onEnablePathQueryAutoMergeChange}
aria-label={t('flow.mergePath')}
/>
</div>
</label>
</TooltipTrigger>
<TooltipContent>
<p>{t('flow.mergePath')}</p>
<p>{t('flow.mergePathTip')}</p>
</TooltipContent>
</Tooltip>
</div>
)}
onBlur={handleBlur}
onFocus={handleFocus}
/>
<ContentEditable
className={cn(
'relative px-2 py-1 pr-14 focus-visible:outline-none max-h-[50vh] overflow-auto text-sm',
{
'min-h-40': multiLine,
},
)}
onBlur={handleBlur}
onFocus={handleFocus}
/>
</div>
</section>
);
}
@@ -137,6 +186,7 @@ export const PromptEditor = forwardRef(function PromptEditor(
placeholder,
showToolbar,
multiLine = true,
enablePathQueryAutoMerge = true,
extraOptions,
baseOptions,
types,
@@ -144,6 +194,8 @@ export const PromptEditor = forwardRef(function PromptEditor(
ref: React.Ref<HTMLDivElement>,
) {
const { t } = useTranslation();
const [isPathQueryAutoMergeEnabled, setIsPathQueryAutoMergeEnabled] =
useState(enablePathQueryAutoMerge);
const initialConfig: InitialConfigType = {
namespace: 'PromptEditor',
theme,
@@ -151,6 +203,10 @@ export const PromptEditor = forwardRef(function PromptEditor(
nodes: Nodes,
};
useEffect(() => {
setIsPathQueryAutoMergeEnabled(enablePathQueryAutoMerge);
}, [enablePathQueryAutoMerge]);
const onValueChange = useCallback(
(editorState: EditorState) => {
editorState?.read(() => {
@@ -171,9 +227,11 @@ export const PromptEditor = forwardRef(function PromptEditor(
<RichTextPlugin
contentEditable={
<PromptContent
enablePathQueryAutoMerge={isPathQueryAutoMergeEnabled}
showToolbar={showToolbar}
multiLine={multiLine}
onBlur={onBlur}
onEnablePathQueryAutoMergeChange={setIsPathQueryAutoMergeEnabled}
></PromptContent>
}
placeholder={
@@ -181,7 +239,7 @@ export const PromptEditor = forwardRef(function PromptEditor(
className={cn(
'-z-10 absolute top-1 left-2 text-text-disabled pointer-events-none',
{
'truncate w-[90%]': !multiLine,
'truncate max-w-[calc(100%-4rem)]': !multiLine,
'translate-y-9': multiLine,
},
)}
@@ -200,6 +258,7 @@ export const PromptEditor = forwardRef(function PromptEditor(
<PasteHandlerPlugin />
<EnterKeyPlugin />
<VariableOnChangePlugin
enablePathQueryAutoMerge={isPathQueryAutoMergeEnabled}
onChange={onValueChange}
></VariableOnChangePlugin>
</LexicalComposer>

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
type PromptVariableOptionLike = {
label: string;
value: string;
parentLabel?: string | ReactNode;
icon?: ReactNode;
type?: string;
};
type PromptVariablePathParts = {
rootValue: string;
pathSuffix: string;
};
type PromptVariableLeadingPathMatch = {
pathSuffix: string;
remainingText: string;
};
const PromptVariableLeadingPathRegex =
/^(?<pathSuffix>(?:\.(?:\d+|[A-Za-z_][A-Za-z0-9_]*))+)/;
function splitPromptVariablePath(value: string): PromptVariablePathParts {
const [nodeId, variable = ''] = value.split('@');
if (!nodeId || !variable) {
return { rootValue: value, pathSuffix: '' };
}
const dotIndex = variable.indexOf('.');
if (dotIndex < 0) {
return { rootValue: value, pathSuffix: '' };
}
return {
rootValue: `${nodeId}@${variable.slice(0, dotIndex)}`,
pathSuffix: variable.slice(dotIndex),
};
}
export function extractLeadingPromptVariablePath(
text: string,
): PromptVariableLeadingPathMatch | undefined {
const match = PromptVariableLeadingPathRegex.exec(text);
const pathSuffix = match?.groups?.pathSuffix;
if (!pathSuffix) {
return undefined;
}
return {
pathSuffix,
remainingText: text.slice(pathSuffix.length),
};
}
export function appendPromptVariablePath(
option: PromptVariableOptionLike,
pathSuffix: string,
): PromptVariableOptionLike {
if (!pathSuffix) {
return option;
}
return {
...option,
value: `${option.value}${pathSuffix}`,
label: `${option.label}${pathSuffix}`,
};
}
export function resolvePromptVariableOption(
value: string,
options: PromptVariableOptionLike[],
): PromptVariableOptionLike | undefined {
const exactMatch = options.find((option) => option.value === value);
if (exactMatch) {
return exactMatch;
}
const { rootValue, pathSuffix } = splitPromptVariablePath(value);
if (!pathSuffix) {
return undefined;
}
const rootOption = options.find((option) => option.value === rootValue);
if (!rootOption) {
return undefined;
}
return appendPromptVariablePath(rootOption, pathSuffix);
}

View File

@@ -78,7 +78,7 @@ export class VariableNode extends DecoratorNode<ReactNode> {
export function $createVariableNode(
value: string,
label: string,
parentLabel: string | ReactNode,
parentLabel?: string | ReactNode,
icon?: ReactNode,
): VariableNode {
return new VariableNode(value, label, undefined, parentLabel, icon);

View File

@@ -1,9 +1,11 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { EditorState, LexicalEditor } from 'lexical';
import { EditorState, LexicalEditor, TextNode } from 'lexical';
import { useEffect } from 'react';
import { ProgrammaticTag } from './constant';
import { mergeLeadingVariablePathTextNode } from './variable-path-transform';
interface VariableOnChangePluginProps {
enablePathQueryAutoMerge?: boolean;
onChange: (
editorState: EditorState,
editor?: LexicalEditor,
@@ -12,14 +14,17 @@ interface VariableOnChangePluginProps {
}
export function VariableOnChangePlugin({
enablePathQueryAutoMerge = true,
onChange,
}: VariableOnChangePluginProps) {
// Access the editor through the LexicalComposerContext
const [editor] = useLexicalComposerContext();
// Wrap our listener in useEffect to handle the teardown and avoid stale references.
useEffect(() => {
// most listeners return a teardown function that can be called to clean them up.
return editor.registerUpdateListener(
const removeTransform = enablePathQueryAutoMerge
? editor.registerNodeTransform(TextNode, mergeLeadingVariablePathTextNode)
: () => {};
const removeUpdateListener = editor.registerUpdateListener(
({ editorState, tags, dirtyElements }) => {
// Check if there is a "programmatic" tag
const isProgrammaticUpdate = tags.has(ProgrammaticTag);
@@ -31,7 +36,12 @@ export function VariableOnChangePlugin({
}
},
);
}, [editor, onChange]);
return () => {
removeTransform();
removeUpdateListener();
};
}, [editor, enablePathQueryAutoMerge, onChange]);
return null;
}

View File

@@ -0,0 +1,43 @@
import { TextNode } from 'lexical';
import {
appendPromptVariablePath,
extractLeadingPromptVariablePath,
} from './utils';
import { $createVariableNode, $isVariableNode } from './variable-node';
export function mergeLeadingVariablePathTextNode(textNode: TextNode): boolean {
const previousSibling = textNode.getPreviousSibling();
if (!$isVariableNode(previousSibling)) {
return false;
}
const leadingPath = extractLeadingPromptVariablePath(
textNode.getTextContent(),
);
if (!leadingPath) {
return false;
}
const nextVariable = appendPromptVariablePath(
{
value: previousSibling.__value,
label: previousSibling.__label,
parentLabel: previousSibling.__parentLabel,
icon: previousSibling.__icon,
},
leadingPath.pathSuffix,
);
previousSibling.replace(
$createVariableNode(
nextVariable.value,
nextVariable.label,
nextVariable.parentLabel,
nextVariable.icon,
),
);
textNode.setTextContent(leadingPath.remainingText);
return true;
}

View File

@@ -36,6 +36,7 @@ import React, {
} from 'react';
import * as ReactDOM from 'react-dom';
import { resolvePromptVariableOption } from './utils';
import { $createVariableNode } from './variable-node';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -530,7 +531,7 @@ export default function VariablePickerMenuPlugin({
return agentStructuredOutput;
}
return children.find((x) => x.value === value);
return resolvePromptVariableOption(value, children);
},
[findAgentStructuredOutputLabel, options],
);