mirror of https://github.com/langgenius/dify.git
feat: enhance start node object value check (#30732)
This commit is contained in:
parent
ae0a26f5b6
commit
0711dd4159
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
|
||||
return tempPayload.json_schema
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
|
|||
return
|
||||
}
|
||||
|
||||
if (!inputValue)
|
||||
if (inputValue == null)
|
||||
return
|
||||
|
||||
if (item.type === InputVarType.singleFile) {
|
||||
|
|
@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record<string, any>, 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
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
|||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{item.json_schema}</div>
|
||||
<div className="whitespace-pre">{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ const FormItem: FC<Props> = ({
|
|||
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<Props> = ({
|
|||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{payload.json_schema}</div>
|
||||
<div className="whitespace-pre">{jsonSchemaPlaceholder}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ export type InputVar = {
|
|||
getVarValueFromDependent?: boolean
|
||||
hide?: boolean
|
||||
isFileItem?: boolean
|
||||
json_schema?: string // for jsonObject type
|
||||
json_schema?: string | Record<string, any> // for jsonObject type
|
||||
} & Partial<UploadFileSetting>
|
||||
|
||||
export type ModelConfig = {
|
||||
|
|
|
|||
|
|
@ -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<string, any>
|
||||
}
|
||||
|
||||
export type CompletionParams = {
|
||||
|
|
|
|||
|
|
@ -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<TriggerPluginNodePayload>))
|
||||
const sanitizedNodes = graph.nodes.map((node) => {
|
||||
// First sanitize known node types (TriggerPlugin)
|
||||
const n = sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>) as Node<any>
|
||||
|
||||
// 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<string, unknown>)
|
||||
|
||||
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<any>
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<FetchWorkflowDraftResponse>
|
||||
|
|
@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: {
|
|||
url: string
|
||||
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
|
||||
}) => {
|
||||
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: params }, { silent: true })
|
||||
const sanitized = sanitizeWorkflowDraftPayload(params)
|
||||
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: sanitized }, { silent: true })
|
||||
}
|
||||
|
||||
export const fetchNodesDefaultConfigs = (url: string) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue