Merge commit '9c339239' into sandboxed-agent-rebase

Made-with: Cursor

# Conflicts:
#	api/README.md
#	api/controllers/console/app/workflow_draft_variable.py
#	api/core/agent/cot_agent_runner.py
#	api/core/agent/fc_agent_runner.py
#	api/core/app/apps/advanced_chat/app_runner.py
#	api/core/plugin/backwards_invocation/model.py
#	api/core/prompt/advanced_prompt_transform.py
#	api/core/workflow/nodes/base/node.py
#	api/core/workflow/nodes/llm/llm_utils.py
#	api/core/workflow/nodes/llm/node.py
#	api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
#	api/core/workflow/nodes/question_classifier/question_classifier_node.py
#	api/core/workflow/runtime/graph_runtime_state.py
#	api/extensions/storage/base_storage.py
#	api/factories/variable_factory.py
#	api/pyproject.toml
#	api/services/variable_truncator.py
#	api/uv.lock
#	web/app/account/oauth/authorize/page.tsx
#	web/app/components/app/configuration/config-var/config-modal/field.tsx
#	web/app/components/base/alert.tsx
#	web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx
#	web/app/components/base/chat/chat/answer/more.tsx
#	web/app/components/base/chat/chat/answer/operation.tsx
#	web/app/components/base/chat/chat/answer/workflow-process.tsx
#	web/app/components/base/chat/chat/citation/index.tsx
#	web/app/components/base/chat/chat/citation/popup.tsx
#	web/app/components/base/chat/chat/citation/progress-tooltip.tsx
#	web/app/components/base/chat/chat/citation/tooltip.tsx
#	web/app/components/base/chat/chat/question.tsx
#	web/app/components/base/chat/embedded-chatbot/inputs-form/index.tsx
#	web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx
#	web/app/components/base/markdown-blocks/form.tsx
#	web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx
#	web/app/components/base/tag-management/panel.tsx
#	web/app/components/base/tag-management/trigger.tsx
#	web/app/components/header/account-setting/index.tsx
#	web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx
#	web/app/signin/utils/post-login-redirect.ts
#	web/eslint-suppressions.json
#	web/package.json
#	web/pnpm-lock.yaml
This commit is contained in:
Novice
2026-03-23 09:00:45 +08:00
1009 changed files with 76072 additions and 18166 deletions

View File

@ -0,0 +1,364 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import type { EmbeddedChatbotContextValue } from '../../context'
import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { InstallationScope, LicenseStatus } from '@/types/feature'
import { useEmbeddedChatbotContext } from '../../context'
import Header from '../index'
vi.mock('../../context', () => ({
useEmbeddedChatbotContext: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
default: () => <div data-testid="view-form-dropdown" />,
}))
// Mock next/image to render a normal img tag for testing
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
const { unoptimized: _, ...rest } = props
return <img {...rest} />
},
}))
type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void
}
describe('EmbeddedChatbot Header', () => {
const defaultAppData: AppData = {
app_id: 'test-app-id',
can_replace_logo: true,
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
enable_site: true,
end_user_id: 'test-user-id',
site: {
title: 'Test Site',
},
}
const defaultContext: Partial<EmbeddedChatbotContextValue> = {
appData: defaultAppData,
currentConversationId: 'test-conv-id',
inputsForms: [],
allInputsHidden: false,
}
const defaultSystemFeatures: SystemFeatures = {
trial_models: [],
plugin_installation_permission: {
plugin_installation_scope: InstallationScope.ALL,
restrict_to_marketplace_only: false,
},
sso_enforced_for_signin: false,
sso_enforced_for_signin_protocol: '',
sso_enforced_for_web: false,
sso_enforced_for_web_protocol: '',
enable_marketplace: false,
enable_change_email: false,
enable_email_code_login: false,
enable_email_password_login: false,
enable_social_oauth_login: false,
enable_collaboration_mode: false,
is_allow_create_workspace: false,
is_allow_register: false,
is_email_setup: false,
license: {
status: LicenseStatus.NONE,
expired_at: '',
},
branding: {
enabled: true,
workspace_logo: '',
login_page_logo: '',
favicon: '',
application_title: '',
},
webapp_auth: {
enabled: false,
allow_sso: false,
sso_config: { protocol: '' },
allow_email_code_login: false,
allow_email_password_login: false,
},
enable_trial_app: false,
enable_explore_banner: false,
enable_creators_platform: false,
}
const setupIframe = () => {
const mockPostMessage = vi.fn()
const mockTop = { postMessage: mockPostMessage }
Object.defineProperty(window, 'self', { value: {}, configurable: true })
Object.defineProperty(window, 'top', { value: mockTop, configurable: true })
Object.defineProperty(window, 'parent', { value: mockTop, configurable: true })
return mockPostMessage
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue)
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: defaultSystemFeatures,
setSystemFeatures: vi.fn(),
}))
Object.defineProperty(window, 'self', { value: window, configurable: true })
Object.defineProperty(window, 'top', { value: window, configurable: true })
})
describe('Desktop Rendering', () => {
it('should render desktop header with branding by default', async () => {
render(<Header title="Test Chatbot" />)
expect(screen.getByTestId('webapp-brand')).toBeInTheDocument()
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
})
it('should render custom logo when provided in appData', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
appData: {
...defaultAppData,
custom_config: {
...defaultAppData.custom_config,
replace_webapp_logo: 'https://example.com/logo.png',
},
},
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://example.com/logo.png')
})
it('should render workspace logo when branding is enabled and logo exists', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
workspace_logo: 'https://example.com/workspace.png',
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />)
const img = screen.getByAltText('logo')
expect(img).toHaveAttribute('src', 'https://example.com/workspace.png')
})
it('should render Dify logo by default when no branding or custom logo is provided', () => {
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: false,
},
},
setSystemFeatures: vi.fn(),
}))
render(<Header title="Test Chatbot" />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
})
it('should NOT render branding when remove_webapp_brand is true', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
appData: {
...defaultAppData,
custom_config: {
...defaultAppData.custom_config,
remove_webapp_brand: true,
},
},
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('webapp-brand')).not.toBeInTheDocument()
})
it('should render reset button when allowResetChat is true and conversation exists', () => {
render(<Header title="Test Chatbot" allowResetChat={true} />)
expect(screen.getByTestId('reset-chat-button')).toBeInTheDocument()
})
it('should call onCreateNewChat when reset button is clicked', async () => {
const user = userEvent.setup()
const onCreateNewChat = vi.fn()
render(<Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />)
await user.click(screen.getByTestId('reset-chat-button'))
expect(onCreateNewChat).toHaveBeenCalled()
})
it('should render ViewFormDropdown when conditions are met', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
inputsForms: [{ id: '1' }],
allInputsHidden: false,
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.getByTestId('view-form-dropdown')).toBeInTheDocument()
})
it('should NOT render ViewFormDropdown when inputs are hidden', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
inputsForms: [{ id: '1' }],
allInputsHidden: true,
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
it('should NOT render ViewFormDropdown when currentConversationId is missing', () => {
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
...defaultContext,
currentConversationId: '',
inputsForms: [{ id: '1' }],
} as EmbeddedChatbotContextValue)
render(<Header title="Test Chatbot" />)
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
})
})
describe('Mobile Rendering', () => {
it('should render mobile header with title', () => {
render(<Header title="Mobile Chatbot" isMobile />)
expect(screen.getByText('Mobile Chatbot')).toBeInTheDocument()
})
it('should render customer icon in mobile header', () => {
render(<Header title="Mobile Chatbot" isMobile customerIcon={<div data-testid="custom-icon" />} />)
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
})
it('should render mobile reset button when allowed', () => {
render(<Header title="Mobile Chatbot" isMobile allowResetChat />)
expect(screen.getByTestId('mobile-reset-chat-button')).toBeInTheDocument()
})
})
describe('Iframe Communication', () => {
it('should send dify-chatbot-iframe-ready on mount', () => {
const mockPostMessage = setupIframe()
render(<Header title="Iframe" />)
expect(mockPostMessage).toHaveBeenCalledWith(
{ type: 'dify-chatbot-iframe-ready' },
'*',
)
})
it('should update expand button visibility and handle click', async () => {
const user = userEvent.setup()
const mockPostMessage = setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://parent.com',
data: {
type: 'dify-chatbot-config',
payload: { isToggledByButton: true, isDraggable: false },
},
}))
const expandBtn = await screen.findByTestId('expand-button')
expect(expandBtn).toBeInTheDocument()
await user.click(expandBtn)
expect(mockPostMessage).toHaveBeenCalledWith(
{ type: 'dify-chatbot-expand-change' },
'https://parent.com',
)
expect(expandBtn.querySelector('.i-ri-collapse-diagonal-2-line')).toBeInTheDocument()
})
it('should NOT show expand button if isDraggable is true', async () => {
setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://parent.com',
data: {
type: 'dify-chatbot-config',
payload: { isToggledByButton: true, isDraggable: true },
},
}))
await waitFor(() => {
expect(screen.queryByTestId('expand-button')).not.toBeInTheDocument()
})
})
it('should ignore messages from different origins after security lock', async () => {
setupIframe()
render(<Header title="Iframe" />)
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://secure.com',
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: true, isDraggable: false } },
}))
await screen.findByTestId('expand-button')
window.dispatchEvent(new MessageEvent('message', {
origin: 'https://malicious.com',
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: false, isDraggable: false } },
}))
expect(screen.getByTestId('expand-button')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle document.referrer for targetOrigin', () => {
const mockPostMessage = setupIframe()
Object.defineProperty(document, 'referrer', { value: 'https://referrer.com', configurable: true })
render(<Header title="Referrer" />)
expect(mockPostMessage).toHaveBeenCalledWith(
expect.anything(),
'https://referrer.com',
)
})
it('should NOT add message listener if not in iframe', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
render(<Header title="Direct" />)
expect(addSpy).not.toHaveBeenCalledWith('message', expect.any(Function))
})
})
})

View File

@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { Theme } from '../theme/theme-context'
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -89,9 +88,11 @@ const Header: FC<IHeaderProps> = ({
{/* powered by */}
<div className="shrink-0">
{!appData?.custom_config?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}
<div
className={cn(
'flex shrink-0 items-center gap-1.5 px-2',
)}
data-testid="webapp-brand"
>
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('chat.poweredBy', { ns: 'share' })}</div>
{
@ -112,11 +113,11 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleToggleExpand}>
<ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button">
{
expanded
? <RiCollapseDiagonal2Line className="h-[18px] w-[18px]" />
: <RiExpandDiagonal2Line className="h-[18px] w-[18px]" />
? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" />
: <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" />
}
</ActionButton>
</Tooltip>
@ -126,8 +127,8 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={onCreateNewChat}>
<RiResetLeftLine className="h-[18px] w-[18px]" />
<ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button">
<div className="i-ri-reset-left-line h-[18px] w-[18px]" />
</ActionButton>
</Tooltip>
)}
@ -159,11 +160,11 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleToggleExpand}>
<ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button">
{
expanded
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
: <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
}
</ActionButton>
</Tooltip>
@ -173,8 +174,8 @@ const Header: FC<IHeaderProps> = ({
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={onCreateNewChat}>
<RiResetLeftLine className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
<ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button">
<div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
</ActionButton>
</Tooltip>
)}