mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 08:26:27 +08:00
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 collections.abc import Sequence
|
||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@ -121,7 +120,7 @@ class VariableEntity(BaseModel):
|
|||||||
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
|
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
|
||||||
allowed_file_extensions: Sequence[str] | 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)
|
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")
|
@field_validator("description", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -135,17 +134,11 @@ class VariableEntity(BaseModel):
|
|||||||
|
|
||||||
@field_validator("json_schema")
|
@field_validator("json_schema")
|
||||||
@classmethod
|
@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:
|
if schema is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
json_schema = json.loads(schema)
|
Draft7Validator.check_schema(schema)
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"invalid json_schema value {schema}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
Draft7Validator.check_schema(json_schema)
|
|
||||||
except SchemaError as e:
|
except SchemaError as e:
|
||||||
raise ValueError(f"Invalid JSON schema: {e.message}")
|
raise ValueError(f"Invalid JSON schema: {e.message}")
|
||||||
return schema
|
return schema
|
||||||
|
|||||||
@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig:
|
def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig:
|
||||||
features_dict = workflow.features_dict
|
features_dict = workflow.features_dict
|
||||||
|
|
||||||
app_mode = AppMode.value_of(app_model.mode)
|
app_mode = AppMode.value_of(app_model.mode)
|
||||||
app_config = AdvancedChatAppConfig(
|
app_config = AdvancedChatAppConfig(
|
||||||
tenant_id=app_model.tenant_id,
|
tenant_id=app_model.tenant_id,
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
from collections.abc import Generator, Mapping, Sequence
|
from collections.abc import Generator, Mapping, Sequence
|
||||||
from typing import TYPE_CHECKING, Any, Union, final
|
from typing import TYPE_CHECKING, Any, Union, final
|
||||||
|
|
||||||
@ -76,12 +75,24 @@ class BaseAppGenerator:
|
|||||||
user_inputs = {**user_inputs, **files_inputs, **file_list_inputs}
|
user_inputs = {**user_inputs, **files_inputs, **file_list_inputs}
|
||||||
|
|
||||||
# Check if all files are converted to File
|
# Check if all files are converted to File
|
||||||
if any(filter(lambda v: isinstance(v, dict), user_inputs.values())):
|
invalid_dict_keys = [
|
||||||
raise ValueError("Invalid input type")
|
k
|
||||||
if any(
|
for k, v in user_inputs.items()
|
||||||
filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values()))
|
if isinstance(v, dict)
|
||||||
):
|
and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT}
|
||||||
raise ValueError("Invalid input type")
|
]
|
||||||
|
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
|
return user_inputs
|
||||||
|
|
||||||
@ -178,12 +189,8 @@ class BaseAppGenerator:
|
|||||||
elif value == 0:
|
elif value == 0:
|
||||||
value = False
|
value = False
|
||||||
case VariableEntityType.JSON_OBJECT:
|
case VariableEntityType.JSON_OBJECT:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, dict):
|
||||||
raise ValueError(f"{variable_entity.variable} in input form must be a string")
|
raise ValueError(f"{variable_entity.variable} in input form must be a dict")
|
||||||
try:
|
|
||||||
json.loads(value)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object")
|
|
||||||
case _:
|
case _:
|
||||||
raise AssertionError("this statement should be unreachable.")
|
raise AssertionError("this statement should be unreachable.")
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jsonschema import Draft7Validator, ValidationError
|
from jsonschema import Draft7Validator, ValidationError
|
||||||
@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]):
|
|||||||
if value is None and variable.required:
|
if value is None and variable.required:
|
||||||
raise ValueError(f"{key} is required in input form")
|
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
|
schema = variable.json_schema
|
||||||
if not schema:
|
if not schema:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
json_schema = json.loads(schema)
|
Draft7Validator(schema).validate(value)
|
||||||
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)
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}")
|
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 = [
|
variables = [
|
||||||
VariableEntity(
|
VariableEntity(
|
||||||
variable="profile",
|
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)
|
node = make_start_node(user_inputs, variables)
|
||||||
result = node._run()
|
result = node._run()
|
||||||
@ -87,6 +89,8 @@ def test_json_object_invalid_json_string():
|
|||||||
"required": ["age", "name"],
|
"required": ["age", "name"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
schema = json.loads(schema)
|
||||||
variables = [
|
variables = [
|
||||||
VariableEntity(
|
VariableEntity(
|
||||||
variable="profile",
|
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"'}
|
user_inputs = {"profile": '{"age": 20, "name": "Tom"'}
|
||||||
|
|
||||||
node = make_start_node(user_inputs, variables)
|
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()
|
node._run()
|
||||||
|
|
||||||
|
|
||||||
@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
schema = json.loads(schema)
|
||||||
|
|
||||||
variables = [
|
variables = [
|
||||||
VariableEntity(
|
VariableEntity(
|
||||||
variable="profile",
|
variable="profile",
|
||||||
@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema():
|
|||||||
]
|
]
|
||||||
|
|
||||||
# age is a string, which violates the schema (expects number)
|
# 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)
|
node = make_start_node(user_inputs, variables)
|
||||||
|
|
||||||
@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
schema = json.loads(schema)
|
||||||
|
|
||||||
variables = [
|
variables = [
|
||||||
VariableEntity(
|
VariableEntity(
|
||||||
variable="profile",
|
variable="profile",
|
||||||
@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field():
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Missing required field "name"
|
# Missing required field "name"
|
||||||
user_inputs = {"profile": json.dumps({"age": 20})}
|
user_inputs = {"profile": {"age": 20}}
|
||||||
|
|
||||||
node = make_start_node(user_inputs, variables)
|
node = make_start_node(user_inputs, variables)
|
||||||
|
|
||||||
|
|||||||
@ -83,7 +83,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||||||
if (!isJsonObject || !tempPayload.json_schema)
|
if (!isJsonObject || !tempPayload.json_schema)
|
||||||
return ''
|
return ''
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
|
return tempPayload.json_schema
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!inputValue)
|
if (inputValue == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (item.type === InputVarType.singleFile) {
|
if (item.type === InputVarType.singleFile) {
|
||||||
@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
|
|||||||
else
|
else
|
||||||
processedInputs[item.variable] = getProcessedFiles(inputValue)
|
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
|
return processedInputs
|
||||||
|
|||||||
@ -195,7 +195,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
|||||||
noWrapper
|
noWrapper
|
||||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||||
placeholder={
|
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 { t } = useTranslation()
|
||||||
const { type } = payload
|
const { type } = payload
|
||||||
const fileSettings = useHooksStore(s => s.configsMap?.fileSettings)
|
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) => {
|
const handleArrayItemChange = useCallback((index: number) => {
|
||||||
return (newValue: any) => {
|
return (newValue: any) => {
|
||||||
@ -211,7 +217,7 @@ const FormItem: FC<Props> = ({
|
|||||||
noWrapper
|
noWrapper
|
||||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||||
placeholder={
|
placeholder={
|
||||||
<div className="whitespace-pre">{payload.json_schema}</div>
|
<div className="whitespace-pre">{jsonSchemaPlaceholder}</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -353,7 +353,7 @@ const formatItem = (
|
|||||||
try {
|
try {
|
||||||
if (type === VarType.object && v.json_schema) {
|
if (type === VarType.object && v.json_schema) {
|
||||||
varRes.children = {
|
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
|
getVarValueFromDependent?: boolean
|
||||||
hide?: boolean
|
hide?: boolean
|
||||||
isFileItem?: boolean
|
isFileItem?: boolean
|
||||||
json_schema?: string // for jsonObject type
|
json_schema?: string | Record<string, any> // for jsonObject type
|
||||||
} & Partial<UploadFileSetting>
|
} & Partial<UploadFileSetting>
|
||||||
|
|
||||||
export type ModelConfig = {
|
export type ModelConfig = {
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export type PromptVariable = {
|
|||||||
icon?: string
|
icon?: string
|
||||||
icon_background?: string
|
icon_background?: string
|
||||||
hide?: boolean // used in frontend to hide variable
|
hide?: boolean // used in frontend to hide variable
|
||||||
json_schema?: string
|
json_schema?: string | Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompletionParams = {
|
export type CompletionParams = {
|
||||||
|
|||||||
@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W
|
|||||||
if (!graph?.nodes?.length)
|
if (!graph?.nodes?.length)
|
||||||
return params
|
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 {
|
return {
|
||||||
...params,
|
...params,
|
||||||
@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse):
|
|||||||
if (node.data)
|
if (node.data)
|
||||||
removeTempProperties(node.data as Record<string, unknown>)
|
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'
|
} from '@/types/workflow'
|
||||||
import { get, post } from './base'
|
import { get, post } from './base'
|
||||||
import { getFlowPrefix } from './utils'
|
import { getFlowPrefix } from './utils'
|
||||||
|
import { sanitizeWorkflowDraftPayload } from './workflow-payload'
|
||||||
|
|
||||||
export const fetchWorkflowDraft = (url: string) => {
|
export const fetchWorkflowDraft = (url: string) => {
|
||||||
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
|
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
|
||||||
@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: {
|
|||||||
url: string
|
url: string
|
||||||
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
|
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) => {
|
export const fetchNodesDefaultConfigs = (url: string) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user