diff --git a/api/core/app/app_config/entities.py b/api/core/app/app_config/entities.py index 307af3747c..13c51529cc 100644 --- a/api/core/app/app_config/entities.py +++ b/api/core/app/app_config/entities.py @@ -1,4 +1,3 @@ -import json from collections.abc import Sequence from enum import StrEnum, auto from typing import Any, Literal @@ -121,7 +120,7 @@ class VariableEntity(BaseModel): allowed_file_types: Sequence[FileType] | None = Field(default_factory=list) allowed_file_extensions: Sequence[str] | None = Field(default_factory=list) allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list) - json_schema: str | None = Field(default=None) + json_schema: dict | None = Field(default=None) @field_validator("description", mode="before") @classmethod @@ -135,17 +134,11 @@ class VariableEntity(BaseModel): @field_validator("json_schema") @classmethod - def validate_json_schema(cls, schema: str | None) -> str | None: + def validate_json_schema(cls, schema: dict | None) -> dict | None: if schema is None: return None - try: - json_schema = json.loads(schema) - except json.JSONDecodeError: - raise ValueError(f"invalid json_schema value {schema}") - - try: - Draft7Validator.check_schema(json_schema) + Draft7Validator.check_schema(schema) except SchemaError as e: raise ValueError(f"Invalid JSON schema: {e.message}") return schema diff --git a/api/core/app/apps/advanced_chat/app_config_manager.py b/api/core/app/apps/advanced_chat/app_config_manager.py index e4b308a6f6..c21c494efe 100644 --- a/api/core/app/apps/advanced_chat/app_config_manager.py +++ b/api/core/app/apps/advanced_chat/app_config_manager.py @@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager): @classmethod def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig: features_dict = workflow.features_dict - app_mode = AppMode.value_of(app_model.mode) app_config = AdvancedChatAppConfig( tenant_id=app_model.tenant_id, diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index a6aace168e..e4486e892c 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -1,4 +1,3 @@ -import json from collections.abc import Generator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Union, final @@ -76,12 +75,24 @@ class BaseAppGenerator: user_inputs = {**user_inputs, **files_inputs, **file_list_inputs} # Check if all files are converted to File - if any(filter(lambda v: isinstance(v, dict), user_inputs.values())): - raise ValueError("Invalid input type") - if any( - filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values())) - ): - raise ValueError("Invalid input type") + invalid_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, dict) + and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT} + ] + if invalid_dict_keys: + raise ValueError(f"Invalid input type for {invalid_dict_keys}") + + invalid_list_dict_keys = [ + k + for k, v in user_inputs.items() + if isinstance(v, list) + and any(isinstance(item, dict) for item in v) + and entity_dictionary[k].type != VariableEntityType.FILE_LIST + ] + if invalid_list_dict_keys: + raise ValueError(f"Invalid input type for {invalid_list_dict_keys}") return user_inputs @@ -178,12 +189,8 @@ class BaseAppGenerator: elif value == 0: value = False case VariableEntityType.JSON_OBJECT: - if not isinstance(value, str): - raise ValueError(f"{variable_entity.variable} in input form must be a string") - try: - json.loads(value) - except json.JSONDecodeError: - raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object") + if not isinstance(value, dict): + raise ValueError(f"{variable_entity.variable} in input form must be a dict") case _: raise AssertionError("this statement should be unreachable.") diff --git a/api/core/workflow/nodes/start/start_node.py b/api/core/workflow/nodes/start/start_node.py index 36fc5078c5..53c1b4ee6b 100644 --- a/api/core/workflow/nodes/start/start_node.py +++ b/api/core/workflow/nodes/start/start_node.py @@ -1,4 +1,3 @@ -import json from typing import Any from jsonschema import Draft7Validator, ValidationError @@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]): if value is None and variable.required: raise ValueError(f"{key} is required in input form") + # If no value provided, skip further processing for this key + if not value: + continue + + if not isinstance(value, dict): + raise ValueError(f"JSON object for '{key}' must be an object") + + # Overwrite with normalized dict to ensure downstream consistency + node_inputs[key] = value + + # If schema exists, then validate against it schema = variable.json_schema if not schema: continue - if not value: - continue - try: - json_schema = json.loads(schema) - except json.JSONDecodeError as e: - raise ValueError(f"{schema} must be a valid JSON object") - - try: - json_value = json.loads(value) - except json.JSONDecodeError as e: - raise ValueError(f"{value} must be a valid JSON object") - - try: - Draft7Validator(json_schema).validate(json_value) + Draft7Validator(schema).validate(value) except ValidationError as e: raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}") - node_inputs[key] = json_value diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index 539e72edb5..16b432bae6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -58,6 +58,8 @@ def test_json_object_valid_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -68,7 +70,7 @@ def test_json_object_valid_schema(): ) ] - user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})} + user_inputs = {"profile": {"age": 20, "name": "Tom"}} node = make_start_node(user_inputs, variables) result = node._run() @@ -87,6 +89,8 @@ def test_json_object_invalid_json_string(): "required": ["age", "name"], } ) + + schema = json.loads(schema) variables = [ VariableEntity( variable="profile", @@ -97,12 +101,12 @@ def test_json_object_invalid_json_string(): ) ] - # Missing closing brace makes this invalid JSON + # Providing a string instead of an object should raise a type error user_inputs = {"profile": '{"age": 20, "name": "Tom"'} node = make_start_node(user_inputs, variables) - with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'): + with pytest.raises(ValueError, match="JSON object for 'profile' must be an object"): node._run() @@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema(): ] # age is a string, which violates the schema (expects number) - user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})} + user_inputs = {"profile": {"age": "twenty", "name": "Tom"}} node = make_start_node(user_inputs, variables) @@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field(): } ) + schema = json.loads(schema) + variables = [ VariableEntity( variable="profile", @@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field(): ] # Missing required field "name" - user_inputs = {"profile": json.dumps({"age": 20})} + user_inputs = {"profile": {"age": 20}} node = make_start_node(user_inputs, variables) diff --git a/web/app/components/app/configuration/config-var/config-modal/index.tsx b/web/app/components/app/configuration/config-var/config-modal/index.tsx index 782744882e..5ffa87375c 100644 --- a/web/app/components/app/configuration/config-var/config-modal/index.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/index.tsx @@ -83,7 +83,7 @@ const ConfigModal: FC = ({ if (!isJsonObject || !tempPayload.json_schema) return '' try { - return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2) + return tempPayload.json_schema } catch { return '' diff --git a/web/app/components/base/chat/chat/utils.ts b/web/app/components/base/chat/chat/utils.ts index ab150f3e61..a64c8162dc 100644 --- a/web/app/components/base/chat/chat/utils.ts +++ b/web/app/components/base/chat/chat/utils.ts @@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu return } - if (!inputValue) + if (inputValue == null) return if (item.type === InputVarType.singleFile) { @@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record, inputsForm: Inpu else processedInputs[item.variable] = getProcessedFiles(inputValue) } + else if (item.type === InputVarType.jsonObject) { + // Prefer sending an object if the user entered valid JSON; otherwise keep the raw string. + try { + const v = typeof inputValue === 'string' ? JSON.parse(inputValue) : inputValue + if (v && typeof v === 'object' && !Array.isArray(v)) + processedInputs[item.variable] = v + else + processedInputs[item.variable] = inputValue + } + catch { + // keep original string; backend will parse/validate + processedInputs[item.variable] = inputValue + } + } }) return processedInputs diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 6094147bbd..b8193fd944 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -195,7 +195,7 @@ const RunOnce: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{item.json_schema}
+
{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx index 81a8453582..3eef34bd7b 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx @@ -48,6 +48,12 @@ const FormItem: FC = ({ const { t } = useTranslation() const { type } = payload const fileSettings = useHooksStore(s => s.configsMap?.fileSettings) + const jsonSchemaPlaceholder = React.useMemo(() => { + const schema = (payload as any)?.json_schema + if (!schema) + return '' + return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2) + }, [payload]) const handleArrayItemChange = useCallback((index: number) => { return (newValue: any) => { @@ -211,7 +217,7 @@ const FormItem: FC = ({ noWrapper className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" placeholder={ -
{payload.json_schema}
+
{jsonSchemaPlaceholder}
} /> )} diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 9f77be0ce2..e5e8174456 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -353,7 +353,7 @@ const formatItem = ( try { if (type === VarType.object && v.json_schema) { varRes.children = { - schema: JSON.parse(v.json_schema), + schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema, } } } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 740f1c1113..63a66eb5cd 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -223,7 +223,7 @@ export type InputVar = { getVarValueFromDependent?: boolean hide?: boolean isFileItem?: boolean - json_schema?: string // for jsonObject type + json_schema?: string | Record // for jsonObject type } & Partial export type ModelConfig = { diff --git a/web/models/debug.ts b/web/models/debug.ts index 5290268fe9..73d0910e82 100644 --- a/web/models/debug.ts +++ b/web/models/debug.ts @@ -62,7 +62,7 @@ export type PromptVariable = { icon?: string icon_background?: string hide?: boolean // used in frontend to hide variable - json_schema?: string + json_schema?: string | Record } export type CompletionParams = { diff --git a/web/service/workflow-payload.ts b/web/service/workflow-payload.ts index b294141cb7..5e2cdebdb3 100644 --- a/web/service/workflow-payload.ts +++ b/web/service/workflow-payload.ts @@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W if (!graph?.nodes?.length) return params - const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node)) + const sanitizedNodes = graph.nodes.map((node) => { + // First sanitize known node types (TriggerPlugin) + const n = sanitizeTriggerPluginNode(node as Node) as Node + + // Normalize Start node variable json_schema: ensure dict, not string + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + return next + } + + return n + }) return { ...params, @@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse): if (node.data) removeTempProperties(node.data as Record) - return hydrateTriggerPluginNode(node) + let n = hydrateTriggerPluginNode(node) + // Normalize Start node variable json_schema to object when loading + if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) { + const next = { ...n, data: { ...n.data } } as Node + next.data.variables = (n.data as any).variables.map((v: any) => { + if (v && v.type === 'json_object' && typeof v.json_schema === 'string') { + try { + const obj = JSON.parse(v.json_schema) + return { ...v, json_schema: obj } + } + catch { + return v + } + } + return v + }) + n = next + } + return n }) } diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 7571e804a9..3a37db791b 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -9,6 +9,7 @@ import type { } from '@/types/workflow' import { get, post } from './base' import { getFlowPrefix } from './utils' +import { sanitizeWorkflowDraftPayload } from './workflow-payload' export const fetchWorkflowDraft = (url: string) => { return get(url, {}, { silent: true }) as Promise @@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: { url: string params: Pick }) => { - return post(url, { body: params }, { silent: true }) + const sanitized = sanitizeWorkflowDraftPayload(params) + return post(url, { body: sanitized }, { silent: true }) } export const fetchNodesDefaultConfigs = (url: string) => {