mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-15 01:18:26 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1262,6 +1262,9 @@ export default {
|
||||
categoryName: '分類名稱',
|
||||
nextStep: '下一步',
|
||||
insertVariableTip: `輸入 / 插入變數`,
|
||||
mergePath: '合併路徑',
|
||||
mergePathTip:
|
||||
'開啟後,緊跟在變數後面的點號後綴會合併為路徑查詢,例如 {node@result.name}。',
|
||||
promptMessage: '提示詞是必填項',
|
||||
promptTip:
|
||||
'系統提示為大型模型提供任務描述、規定回覆方式,以及設定其他各種要求。系統提示通常與 key(變數)合用,透過變數設定大型模型的輸入資料。你可以透過斜線或 (x) 按鈕顯示可用的 key。',
|
||||
|
||||
@@ -1818,6 +1818,9 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于
|
||||
categoryName: '分类名称',
|
||||
nextStep: '下一步',
|
||||
insertVariableTip: `输入 / 插入变量`,
|
||||
mergePath: '合并路径',
|
||||
mergePathTip:
|
||||
'开启后,紧跟在变量后面的点号后缀会合并为路径查询,例如 {node@result.name}。',
|
||||
setting: '设置',
|
||||
settings: {
|
||||
agentSetting: 'Agent设置',
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
40
web/src/pages/agent/form-sheet/single-debug-sheet/utils.ts
Normal file
40
web/src/pages/agent/form-sheet/single-debug-sheet/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
},
|
||||
|
||||
117
web/src/pages/agent/form/code-form/utils.ts
Normal file
117
web/src/pages/agent/form/code-form/utils.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
93
web/src/pages/agent/form/components/prompt-editor/utils.ts
Normal file
93
web/src/pages/agent/form/components/prompt-editor/utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user