Merge branch 'main' into 4-27-app-deploy

This commit is contained in:
Stephen Zhou 2026-04-30 17:23:44 +08:00
commit 17f4c89d11
No known key found for this signature in database
18 changed files with 1736 additions and 7194 deletions

View File

@ -151,6 +151,12 @@ def deserialize_response(raw_data: bytes) -> Response:
response = Response(response=body, status=status_code)
# Replace Flask's default headers (e.g. Content-Type, Content-Length) with the
# parsed ones so we faithfully reproduce the original response. Use Headers.add
# rather than dict-style assignment so that repeated headers such as Set-Cookie
# (and any other multi-valued header per RFC 9110) are preserved instead of
# being overwritten.
response.headers.clear()
for line in lines[1:]:
if not line:
continue
@ -158,6 +164,6 @@ def deserialize_response(raw_data: bytes) -> Response:
if ":" not in line_str:
continue
name, value = line_str.split(":", 1)
response.headers[name] = value.strip()
response.headers.add(name, value.strip())
return response

View File

@ -323,6 +323,50 @@ class TestDeserializeResponse:
with pytest.raises(ValueError, match="Invalid status line"):
deserialize_response(raw_data)
def test_deserialize_response_preserves_duplicate_set_cookie_headers(self):
# Regression test for https://github.com/langgenius/dify/issues/35722
# Multiple Set-Cookie headers must be preserved per RFC 9110, not collapsed
# into a single value by dict-style assignment.
raw_data = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/plain\r\n"
b"Set-Cookie: session=abc; Path=/; HttpOnly\r\n"
b"Set-Cookie: tracking=xyz; Path=/; Secure\r\n"
b"\r\n"
b"ok"
)
response = deserialize_response(raw_data)
cookies = response.headers.getlist("Set-Cookie")
assert cookies == [
"session=abc; Path=/; HttpOnly",
"tracking=xyz; Path=/; Secure",
]
# Single-valued headers should still be readable normally.
assert response.headers.get("Content-Type") == "text/plain"
def test_deserialize_response_preserves_duplicate_generic_headers(self):
# Any header name (not just Set-Cookie) may legitimately repeat; verify the
# parser preserves all values rather than overwriting earlier ones.
raw_data = b"HTTP/1.1 200 OK\r\nX-Custom: first\r\nX-Custom: second\r\n\r\n"
response = deserialize_response(raw_data)
assert response.headers.getlist("X-Custom") == ["first", "second"]
def test_deserialize_response_does_not_inject_default_content_type(self):
# Flask's Response constructor adds a default Content-Type header. When the
# raw response has no Content-Type, the parsed response should not silently
# gain one from the framework default.
raw_data = b"HTTP/1.1 204 No Content\r\nX-Trace-Id: abc\r\n\r\n"
response = deserialize_response(raw_data)
header_names = [name for name, _ in response.headers.items()]
assert "Content-Type" not in header_names
assert response.headers.get("X-Trace-Id") == "abc"
def test_roundtrip_response(self):
# Test that serialize -> deserialize produces equivalent response
original_response = Response(

View File

@ -1744,11 +1744,6 @@
"count": 4
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": {
"erasable-syntax-only/parameter-properties": {
"count": 1

View File

@ -4,4 +4,8 @@ export default defineConfig({
staged: {
'*': 'eslint --fix --pass-on-unpruned-suppressions',
},
fmt: {
singleQuote: true,
semi: false,
},
})

View File

@ -29,6 +29,7 @@ import {
} from 'lexical'
import * as React from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { VarType } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -928,5 +929,46 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
vi.useRealTimers()
})
it('does not hide the menu when focus moves into a variable child popup', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="/"
workflowVariableBlock={makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child', VarType.string)]),
]),
])}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '/', true)
expect(await screen.findByText('payload')).toBeInTheDocument()
vi.useFakeTimers()
const popupTarget = document.createElement('button')
const popup = document.createElement('div')
popup.classList.add(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME)
popup.appendChild(popupTarget)
document.body.appendChild(popup)
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: popupTarget }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('payload')).toBeInTheDocument()
popup.remove()
vi.useRealTimers()
})
})
})

View File

@ -14,6 +14,7 @@ import type {
WorkflowVariableBlockType,
} from '../../types'
import type { PickerBlockMenuOption } from './menu'
import type { EventEmitterValue } from '@/context/event-emitter'
import {
flip,
offset,
@ -39,7 +40,7 @@ import {
} from 'react'
import ReactDOM from 'react-dom'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import VarReferenceVars, { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
import { $splitNodeContainingQuery } from '../../utils'
@ -119,7 +120,9 @@ const ComponentPicker = ({
(event) => {
clearBlurTimer()
const target = event?.relatedTarget as HTMLElement
if (!target?.classList?.contains('var-search-input'))
const isVariableMenuTarget = target?.classList?.contains('var-search-input')
|| target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`)
if (!isVariableMenuTarget)
blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200)
return false
},
@ -143,8 +146,8 @@ const ComponentPicker = ({
}
}, [editor, clearBlurTimer])
eventEmitter?.useSubscription((v: any) => {
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
eventEmitter?.useSubscription((v: EventEmitterValue) => {
if (typeof v !== 'string' && v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && typeof v.payload === 'string')
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
})
@ -303,7 +306,7 @@ const ComponentPicker = ({
}
</>
)
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, triggerString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
return (
<LexicalTypeaheadMenuPlugin

View File

@ -43,7 +43,7 @@ const Field: FC<Props> = ({
disabled={depth !== MAX_DEPTH + 1}
render={(
<div
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
className={cn('flex items-center justify-between rounded-md pr-2 outline-none focus:outline-none focus-visible:outline-none', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className="flex grow items-stretch">

View File

@ -29,6 +29,7 @@ import {
} from './var-reference-vars.helpers'
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup'
const resolveValueSelector = ({
itemData,
@ -210,7 +211,7 @@ const Item: FC<ItemProps> = ({
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-none focus:outline-none focus-visible:outline-none',
className,
)}
data-selected={isSelected ? 'true' : 'false'}
@ -263,7 +264,7 @@ const Item: FC<ItemProps> = ({
<PopoverContent
placement="left-start"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
popupClassName={cn(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, 'border-none bg-transparent p-0 shadow-none backdrop-blur-none')}
positionerProps={{
style: {
zIndex: zIndex || 100,

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,11 @@ const config: KnipConfig = {
'scripts/**/*.{js,ts,mjs}',
'bin/**/*.{js,ts,mjs}',
'tsslint.config.ts',
'openapi-ts.*.config.ts',
],
ignore: [
'public/**',
'contract/generated/**',
],
ignoreBinaries: [
'only-allow',

View File

@ -10,14 +10,22 @@ type OpenApiDocument = JsonObject & {
paths?: Record<string, unknown>
}
type ContractOperation = {
id: string
operationId?: string
tags?: readonly string[]
}
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
: path.resolve(currentDir, '../../dify-enterprise/server')
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml')
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
const stripConsoleApiPrefix = (routePath: string) => {
if (routePath.startsWith('/console/api/'))
if (isConsoleApiPath(routePath))
return routePath.replace('/console/api', '')
return routePath
@ -29,6 +37,20 @@ const stripSchemaNamePrefix = (schemaName: string) => {
.replace(/^pagination\./, '')
}
const contractNameSegments = (operation: ContractOperation) => {
const operationId = operation.operationId || operation.id
const tag = operation.tags?.[0]
const tagPrefixPattern = tag ? new RegExp(`^${tag}[._/-]`) : undefined
const name = tagPrefixPattern ? operationId.replace(tagPrefixPattern, '') : operationId
const segments = name.split(/[._/-]+/).filter(Boolean)
return segments.length > 0 ? segments : [operationId]
}
const contractPathSegments = (operation: ContractOperation) => {
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
}
const normalizeEnterpriseOpenApi = () => {
const openApi = yaml.load(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
@ -39,7 +61,9 @@ const normalizeEnterpriseOpenApi = () => {
const paths = document.paths ?? {}
document.paths = Object.fromEntries(
Object.entries(paths).map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
Object.entries(paths)
.filter(([routePath]) => isConsoleApiPath(routePath))
.map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
)
return document
@ -48,13 +72,20 @@ const normalizeEnterpriseOpenApi = () => {
export default defineConfig({
input: normalizeEnterpriseOpenApi(),
output: {
entryFile: false,
path: 'contract/generated/enterprise',
fileName: {
suffix: '.gen',
},
header: ctx => [
'/* eslint-disable */',
...ctx.defaultValue,
postProcess: [
{
command: 'vp',
args: ['fmt', '{{path}}'],
},
{
command: 'eslint',
args: ['--fix', '{{path}}'],
},
],
},
parser: {
@ -70,6 +101,18 @@ export default defineConfig({
'zod',
{
name: 'orpc',
contracts: {
strategy: 'single',
contractName: {
name: '{{name}}',
casing: 'camelCase',
},
nesting: contractPathSegments,
segmentName: {
name: '{{name}}',
casing: 'camelCase',
},
},
validator: 'zod',
},
],

View File

@ -2,7 +2,7 @@ import type { Plugin } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import { codeInspectorPlugin } from 'code-inspector-plugin'
import { injectClientSnippet, normalizeViteModuleId } from './utils'
import { injectClientSnippet, normalizeViteModuleId } from './utils.ts'
type CodeInspectorPluginOptions = {
injectTarget: string

View File

@ -1,6 +1,6 @@
import type { Plugin } from 'vite'
import fs from 'node:fs'
import { injectClientSnippet, normalizeViteModuleId } from './utils'
import { injectClientSnippet, normalizeViteModuleId } from './utils.ts'
type CustomI18nHmrPluginOptions = {
injectTarget: string

View File

@ -1,6 +1,6 @@
import type { Plugin } from 'vite'
import path from 'node:path'
import { normalizeViteModuleId } from './utils'
import { normalizeViteModuleId } from './utils.ts'
type NextStaticImageTestPluginOptions = {
projectRoot: string

View File

@ -15,6 +15,7 @@
"vitest/globals",
"node"
],
"allowImportingTsExtensions": true,
"allowJs": true
},
"include": [

View File

@ -4,10 +4,10 @@ import react from '@vitejs/plugin-react'
import vinext from 'vinext'
import Inspect from 'vite-plugin-inspect'
import { defineConfig } from 'vite-plus'
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
import { getRootClientInjectTarget } from './plugins/vite/inject-target'
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test'
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector.ts'
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr.ts'
import { getRootClientInjectTarget } from './plugins/vite/inject-target.ts'
import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test.ts'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
const isCI = !!process.env.CI