mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
Merge branch 'main' into 4-27-app-deploy
This commit is contained in:
commit
17f4c89d11
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,4 +4,8 @@ export default defineConfig({
|
||||
staged: {
|
||||
'*': 'eslint --fix --pass-on-unpruned-suppressions',
|
||||
},
|
||||
fmt: {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
@ -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',
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"vitest/globals",
|
||||
"node"
|
||||
],
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user