Compare commits

..

6 Commits

11 changed files with 887 additions and 66 deletions

View File

@ -43,16 +43,13 @@ request_error = httpx.RequestError
max_retries_exceeded_error = MaxRetriesExceededError
def _create_proxy_mounts(verify: bool) -> dict[str, httpx.HTTPTransport]:
"""Build per-scheme proxy transports with the same TLS policy as the SSRF client."""
def _create_proxy_mounts() -> dict[str, httpx.HTTPTransport]:
return {
"http://": httpx.HTTPTransport(
proxy=dify_config.SSRF_PROXY_HTTP_URL,
verify=verify,
),
"https://": httpx.HTTPTransport(
proxy=dify_config.SSRF_PROXY_HTTPS_URL,
verify=verify,
),
}
@ -67,7 +64,7 @@ def _build_ssrf_client(verify: bool) -> httpx.Client:
if dify_config.SSRF_PROXY_HTTP_URL and dify_config.SSRF_PROXY_HTTPS_URL:
return httpx.Client(
mounts=_create_proxy_mounts(verify=verify),
mounts=_create_proxy_mounts(),
verify=verify,
limits=_SSRF_CLIENT_LIMITS,
)

View File

@ -1,4 +1,4 @@
from unittest.mock import ANY, MagicMock, call, patch
from unittest.mock import MagicMock, patch
import httpx
import pytest
@ -6,7 +6,6 @@ import pytest
from core.helper.ssrf_proxy import (
SSRF_DEFAULT_MAX_RETRIES,
SSRFProxy,
_build_ssrf_client,
_get_user_provided_host_header,
_to_graphon_http_response,
graphon_ssrf_proxy,
@ -42,34 +41,6 @@ def test_retry_exceed_max_retries(mock_get_client):
assert str(e.value) == f"Reached maximum retries ({SSRF_DEFAULT_MAX_RETRIES - 1}) for URL http://example.com"
def test_build_ssrf_client_passes_ssl_verify_to_proxy_mount_transports():
mock_client = MagicMock()
http_transport = MagicMock()
https_transport = MagicMock()
with (
patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_ALL_URL", None),
patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy.example.com:8080"),
patch("core.helper.ssrf_proxy.dify_config.SSRF_PROXY_HTTPS_URL", "http://proxy.example.com:8443"),
patch("core.helper.ssrf_proxy.httpx.HTTPTransport", side_effect=[http_transport, https_transport]) as transport,
patch("core.helper.ssrf_proxy.httpx.Client", return_value=mock_client) as client,
):
ssrf_client = _build_ssrf_client(verify=False)
assert ssrf_client is mock_client
transport.assert_has_calls(
[
call(proxy="http://proxy.example.com:8080", verify=False),
call(proxy="http://proxy.example.com:8443", verify=False),
],
)
client.assert_called_once_with(
mounts={"http://": http_transport, "https://": https_transport},
verify=False,
limits=ANY,
)
class TestGetUserProvidedHostHeader:
"""Tests for _get_user_provided_host_header function."""

File diff suppressed because it is too large Load Diff

View File

@ -65,6 +65,10 @@
"types": "./src/form/index.tsx",
"import": "./src/form/index.tsx"
},
"./input": {
"types": "./src/input/index.tsx",
"import": "./src/input/index.tsx"
},
"./meter": {
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"

View File

@ -3,8 +3,8 @@
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import { inputVariants } from '../form-control-shared'
export type FieldRootProps
= Omit<BaseFieldNS.Root.Props, 'className'>
@ -62,37 +62,11 @@ export function FieldLabel({
)
}
const fieldControlVariants = cva(
[
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
export type FieldControlSize = NonNullable<VariantProps<typeof inputVariants>['size']>
export type FieldControlProps
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
& VariantProps<typeof fieldControlVariants>
& VariantProps<typeof inputVariants>
& {
className?: string
}
@ -106,7 +80,7 @@ export function FieldControl({
}: FieldControlProps) {
return (
<BaseField.Control
className={cn(fieldControlVariants({ size }), className)}
className={cn(inputVariants({ size }), className)}
{...props}
/>
)

View File

@ -0,0 +1,30 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const inputVariants = cva(
[
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type InputSize = NonNullable<VariantProps<typeof inputVariants>['size']>

View File

@ -0,0 +1,83 @@
import { render } from 'vitest-browser-react'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '../../field'
import { Form } from '../../form'
import { Input } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Input', () => {
it('should render a labelled Base UI input with design-system classes', async () => {
const screen = await render(
<label>
Workspace name
<Input name="workspaceName" defaultValue="Dify" />
</label>,
)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
await expect.element(input).toHaveValue('Dify')
await expect.element(input).toHaveClass('rounded-lg', 'py-[7px]', 'system-sm-regular')
})
it('should apply size variants shared with FieldControl', async () => {
const screen = await render(
<>
<label>
Small input
<Input size="small" />
</label>
<div>
Large field
<FieldRoot name="largeField">
<FieldLabel>Large field</FieldLabel>
<FieldControl size="large" />
</FieldRoot>
</div>
</>,
)
await expect.element(screen.getByRole('textbox', { name: 'Small input' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
await expect.element(screen.getByRole('textbox', { name: 'Large field' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
})
it('should use FieldRoot invalid state', async () => {
const screen = await render(
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Repository URL</FieldLabel>
<Input defaultValue="github.com/langgenius" />
</FieldRoot>,
)
const input = screen.getByRole('textbox', { name: 'Repository URL' })
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
})
it('should integrate with FieldRoot and Base UI Form validation', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="account form" onFormSubmit={onFormSubmit}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" required />
<FieldError match="valueMissing">Email is required.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const input = screen.getByRole('textbox', { name: 'Email' })
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
})
expect(onFormSubmit).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,124 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '../button'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../field'
import { Form } from '../form'
import { Input } from './index'
const meta = {
title: 'Base/Form/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A standalone text input primitive built on Base UI Input. Use it for labelled text boxes outside FieldControl, and keep FieldControl for full FieldRoot form composition.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<div className="w-80">
<label htmlFor="workspace-name" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
Workspace name
</label>
<Input
id="workspace-name"
name="workspaceName"
autoComplete="organization"
placeholder="e.g. Acme workspace…"
/>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="grid w-80 gap-3">
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-input">
Small
<Input id="small-input" size="small" name="smallInput" placeholder="e.g. tag…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-input">
Medium
<Input id="medium-input" name="mediumInput" placeholder="e.g. Production API…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-input">
Large
<Input id="large-input" size="large" name="largeInput" placeholder="e.g. Customer portal…" autoComplete="off" />
</label>
</div>
),
}
export const States: Story = {
render: () => (
<div className="grid w-80 gap-3">
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="placeholder-state">Placeholder</label>
<Input id="placeholder-state" name="placeholderState" placeholder="e.g. Search datasets…" autoComplete="off" />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="filled-state">Filled</label>
<Input id="filled-state" name="filledState" defaultValue="Customer knowledge base" autoComplete="off" />
</div>
<div className="grid gap-1">
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Invalid</FieldLabel>
<Input
id="invalid-state"
type="url"
inputMode="url"
defaultValue="github.com/langgenius"
autoComplete="off"
spellCheck={false}
/>
<FieldError match>Enter a full URL including https://.</FieldError>
</FieldRoot>
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="disabled-state">Disabled</label>
<Input id="disabled-state" disabled name="disabledEmail" type="email" inputMode="email" placeholder="name@example.com…" autoComplete="email" spellCheck={false} />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="readonly-state">Read-only</label>
<Input id="readonly-state" readOnly name="endpoint" type="url" inputMode="url" defaultValue="https://api.example.com" autoComplete="url" spellCheck={false} />
</div>
</div>
),
}
export const WithField: Story = {
render: () => (
<Form aria-label="Account form" className="grid w-80 gap-4" onFormSubmit={() => undefined}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" inputMode="email" required autoComplete="email" placeholder="name@example.com…" spellCheck={false} />
<FieldDescription>Used for account notifications.</FieldDescription>
<FieldError match="valueMissing">Email is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
</FieldRoot>
<FieldRoot name="repositoryUrl">
<FieldLabel>Repository URL</FieldLabel>
<Input type="url" inputMode="url" required autoComplete="off" placeholder="https://github.com/langgenius/dify…" spellCheck={false} />
<FieldDescription>Use the full GitHub repository URL.</FieldDescription>
<FieldError match="valueMissing">Repository URL is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save Settings</Button>
</div>
</Form>
),
}

View File

@ -0,0 +1,31 @@
'use client'
import type { Input as BaseInputNS } from '@base-ui/react/input'
import type { VariantProps } from 'class-variance-authority'
import { Input as BaseInput } from '@base-ui/react/input'
import { cn } from '../cn'
import { inputVariants } from '../form-control-shared'
export type InputSize = NonNullable<VariantProps<typeof inputVariants>['size']>
export type InputProps
= Omit<BaseInputNS.Props, 'className' | 'size'>
& VariantProps<typeof inputVariants>
& {
className?: string
}
export type InputChangeEventDetails = BaseInputNS.ChangeEventDetails
export function Input({
className,
size = 'medium',
...props
}: InputProps) {
return (
<BaseInput
className={cn(inputVariants({ size }), className)}
{...props}
/>
)
}

View File

@ -22,6 +22,11 @@ export const inputVariants = cva(
},
)
/**
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
* a dedicated composition built on the primitive input.
*/
export type InputProps = {
showLeftIcon?: boolean
showClearIcon?: boolean
@ -36,6 +41,11 @@ export type InputProps = {
const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1')
/**
* @deprecated Use `@langgenius/dify-ui/input` for primitive inputs and
* `@langgenius/dify-ui/field` for form composition. Search inputs should use
* a dedicated composition built on the primitive input.
*/
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
size,
disabled,

View File

@ -48,10 +48,21 @@ const FLOATING_UI_RESTRICTED_IMPORT_PATTERNS = [
},
]
const LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS = [
{
group: [
'**/base/input',
'**/base/input/*',
],
message: 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.',
},
]
export const WEB_RESTRICTED_IMPORT_PATTERNS = [
...NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS,
...BASE_UI_RESTRICTED_IMPORT_PATTERNS,
...FLOATING_UI_RESTRICTED_IMPORT_PATTERNS,
...LEGACY_WEB_INPUT_RESTRICTED_IMPORT_PATTERNS,
]
export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = {