From 6aeb92deff2b46303559cd4c84a7b8db35ee3fb4 Mon Sep 17 00:00:00 2001 From: Sbaaoui Idriss <112825897+6ba3i@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:06:12 +0800 Subject: [PATCH] fix(agent): support String and Object Invoke parameters (#21953) --- internal/agent/component/invoke.go | 12 +++ internal/agent/component/invoke_test.go | 76 +++++++++++++++++++ web/src/pages/agent/constant/index.tsx | 5 ++ .../pages/agent/form/invoke-form/schema.ts | 33 ++++++-- .../form/invoke-form/variable-dialog.tsx | 60 ++++++++++----- .../agent/form/invoke-form/variable-table.tsx | 7 ++ 6 files changed, 171 insertions(+), 22 deletions(-) diff --git a/internal/agent/component/invoke.go b/internal/agent/component/invoke.go index 590577efe9..0d484f36c5 100644 --- a/internal/agent/component/invoke.go +++ b/internal/agent/component/invoke.go @@ -230,6 +230,18 @@ func (i *InvokeComponent) Invoke(ctx context.Context, db *gorm.DB, inputs map[st } } } + if variable["type"] == "object" { + if text, ok := value.(string); ok { + var object map[string]any + if err := json.Unmarshal([]byte(text), &object); err != nil || object == nil { + return nil, fmt.Errorf("Invoke: parameter %q must be a valid JSON object", key) + } + value = object + } + if object, ok := value.(map[string]any); !ok || object == nil { + return nil, fmt.Errorf("Invoke: parameter %q must be a valid JSON object", key) + } + } fields[key] = value } if len(fields) > 0 { diff --git a/internal/agent/component/invoke_test.go b/internal/agent/component/invoke_test.go index ee0c58fefb..4d222ee692 100644 --- a/internal/agent/component/invoke_test.go +++ b/internal/agent/component/invoke_test.go @@ -18,6 +18,7 @@ package component import ( "context" + "encoding/json" "fmt" "io" "net" @@ -699,3 +700,78 @@ func TestInvoke_ProxyRejectsHostnameTarget(t *testing.T) { t.Errorf("hostname+proxy target: _ERROR = %q, want %q", got, "URL not valid") } } + +func TestInvoke_ParameterTypes(t *testing.T) { + setupAllowAnyHost(t, true) + for _, tc := range []struct { + name, kind string + value any + invalid bool + }{ + {"legacy", "", `{"nested":{"items":[1,true]}}`, false}, + {"string", "string", `{"nested":{"items":[1,true]}}`, false}, + {"object", "object", `{"nested":{"items":[1,true]}}`, false}, + {"native", "object", map[string]any{"nested": map[string]any{"items": []any{float64(1), true}}}, false}, + {"invalid", "object", `{`, true}, + {"array", "object", `[]`, true}, + {"null", "object", `null`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, ref := range []string{"", "source@result"} { + var captured any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + captured = body["input"] + w.Write([]byte(`ok`)) + })) + c, err := NewInvokeComponent(map[string]any{"url": srv.URL, "method": "POST", "variables": []any{map[string]any{"key": "input", "type": tc.kind, "value": tc.value, "ref": ref}}}) + if err != nil { + t.Fatal(err) + } + _, err = c.Invoke(t.Context(), nil, map[string]any{"source@result": tc.value}) + srv.Close() + if tc.invalid { + if err == nil || !strings.Contains(err.Error(), `parameter "input" must be a valid JSON object`) { + t.Fatalf("error = %v", err) + } + continue + } + if err != nil { + t.Fatal(err) + } + expected := tc.value + if tc.kind == "object" { + expected = map[string]any{"nested": map[string]any{"items": []any{float64(1), true}}} + } + if !reflect.DeepEqual(captured, expected) { + t.Fatalf("wire value = %#v, want %#v", captured, expected) + } + } + }) + } +} + +func TestInvoke_ExplicitBodyIgnoresParameterTypes(t *testing.T) { + setupAllowAnyHost(t, true) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if string(body) != `{"explicit":true}` { + t.Errorf("body = %s", body) + } + w.Write([]byte("ok")) + })) + defer srv.Close() + c, err := NewInvokeComponent(map[string]any{ + "url": srv.URL, "method": "PUT", "body": `{"explicit":true}`, + "variables": []any{map[string]any{"key": "ignored", "type": "object", "value": "invalid JSON"}}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := c.Invoke(t.Context(), nil, nil); err != nil { + t.Fatal(err) + } +} diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 59ca2c7d18..916642ffaf 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -515,6 +515,11 @@ export const initialCrawlerValues = { query: '', }; +export const InvokeParameterTypeOptions = [ + { label: 'String', value: 'string' }, + { label: 'Object', value: 'object' }, +]; + export const initialInvokeValues = { url: '', method: 'GET', diff --git a/web/src/pages/agent/form/invoke-form/schema.ts b/web/src/pages/agent/form/invoke-form/schema.ts index 3f3b86ccf4..7186ad3926 100644 --- a/web/src/pages/agent/form/invoke-form/schema.ts +++ b/web/src/pages/agent/form/invoke-form/schema.ts @@ -1,10 +1,33 @@ import { z } from 'zod'; -export const VariableFormSchema = z.object({ - key: z.string(), - ref: z.string(), - value: z.string(), -}); +export const VariableFormSchema = z + .object({ + key: z.string(), + ref: z.string(), + value: z.string(), + type: z.enum(['string', 'object']).optional(), + }) + .superRefine(({ type, ref, value }, context) => { + if (type !== 'object' || ref) return; + + try { + const object = JSON.parse(value); + if ( + object !== null && + typeof object === 'object' && + !Array.isArray(object) + ) + return; + } catch { + // Report malformed JSON and non-object JSON with the same field error. + } + + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['value'], + message: 'Enter a valid JSON object.', + }); + }); // {user_id} or {component@variable} const placeholderRegex = /\{([a-zA-Z_][a-zA-Z0-9_.@-]*)\}/g; diff --git a/web/src/pages/agent/form/invoke-form/variable-dialog.tsx b/web/src/pages/agent/form/invoke-form/variable-dialog.tsx index 03c4d83b03..5f1133bbee 100644 --- a/web/src/pages/agent/form/invoke-form/variable-dialog.tsx +++ b/web/src/pages/agent/form/invoke-form/variable-dialog.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button'; +import { SelectWithSearch } from '@/components/originui/select-with-search'; import { Dialog, DialogContent, @@ -15,15 +16,17 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { IModalProps } from '@/interfaces/common'; import { zodResolver } from '@hookform/resolvers/zod'; import { isEmpty } from 'lodash'; import { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; +import { InvokeParameterTypeOptions } from '../../constant'; import { QueryVariable } from '../components/query-variable'; -import { VariableFormSchemaType } from './schema'; +import { VariableFormSchema, VariableFormSchemaType } from './schema'; type ModalFormProps = { initialValue: VariableFormSchemaType; @@ -39,18 +42,17 @@ function VariableForm({ submit, }: ModalFormProps) { const { t } = useTranslation(); - const FormSchema = z.object({ - key: z - .string() - .trim() - .min(1) - .refine( - (value) => - !value || !otherThanCurrentQuery.some((x) => x.key === value), - { message: 'The key cannot be repeated!' }, - ), - ref: z.string(), - value: z.string(), + const FormSchema = VariableFormSchema.superRefine(({ key }, context) => { + if ( + !key.trim() || + otherThanCurrentQuery.some((x) => x.key === key.trim()) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['key'], + message: !key.trim() ? 'Required' : 'The key cannot be repeated!', + }); + } }); const form = useForm>({ @@ -60,19 +62,22 @@ function VariableForm({ key: '', value: '', ref: '', + type: 'string', }, }); useEffect(() => { if (!isEmpty(initialValue)) { - form.reset(initialValue); + form.reset({ type: 'string', ...initialValue }); } }, [form, initialValue]); function onSubmit(data: z.infer) { - submit(data); + submit({ ...data, key: data.key.trim() }); } + const parameterType = useWatch({ control: form.control, name: 'type' }); + return (
+ ( + + {t('flow.type')} + + + + + + )} + /> {t('flow.value')} - + {parameterType === 'object' ? ( +