test: add unit tests for app store and configuration components, enhancing coverage for state management and UI interactions

This commit is contained in:
CodingOnStar
2026-03-30 14:42:17 +08:00
parent bfce8c5b2c
commit ce2403e0db
24 changed files with 3446 additions and 1495 deletions

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useStore } from '../store'
type AppDetailState = Parameters<ReturnType<typeof useStore.getState>['setAppDetail']>[0]
type CurrentLogItemState = Parameters<ReturnType<typeof useStore.getState>['setCurrentLogItem']>[0]
const resetStore = () => {
useStore.setState({
appDetail: undefined,
appSidebarExpand: '',
currentLogItem: undefined,
currentLogModalActiveTab: 'DETAIL',
showPromptLogModal: false,
showAgentLogModal: false,
showMessageLogModal: false,
showAppConfigureFeaturesModal: false,
needsRuntimeUpgrade: false,
})
}
describe('app store', () => {
beforeEach(() => {
resetStore()
})
it('should update each primitive flag via dedicated setters', () => {
const store = useStore.getState()
store.setAppSidebarExpand('collapse')
store.setShowPromptLogModal(true)
store.setShowAgentLogModal(true)
store.setShowAppConfigureFeaturesModal(true)
store.setNeedsRuntimeUpgrade(true)
const nextState = useStore.getState()
expect(nextState.appSidebarExpand).toBe('collapse')
expect(nextState.showPromptLogModal).toBe(true)
expect(nextState.showAgentLogModal).toBe(true)
expect(nextState.showAppConfigureFeaturesModal).toBe(true)
expect(nextState.needsRuntimeUpgrade).toBe(true)
})
it('should store app detail and current log item', () => {
const appDetail = { id: 'app-1', name: 'Demo App' } as AppDetailState
const logItem: Exclude<CurrentLogItemState, undefined> = {
id: 'log-1',
content: 'hello',
isAnswer: true,
}
const store = useStore.getState()
store.setAppDetail(appDetail)
store.setCurrentLogItem(logItem)
store.setCurrentLogModalActiveTab('TRACES')
const nextState = useStore.getState()
expect(nextState.appDetail).toEqual(appDetail)
expect(nextState.currentLogItem).toEqual(logItem)
expect(nextState.currentLogModalActiveTab).toBe('TRACES')
})
it('should preserve currentLogModalActiveTab when opening message log modal', () => {
useStore.setState({ currentLogModalActiveTab: 'AGENT' })
useStore.getState().setShowMessageLogModal(true)
const nextState = useStore.getState()
expect(nextState.showMessageLogModal).toBe(true)
expect(nextState.currentLogModalActiveTab).toBe('AGENT')
})
it('should reset currentLogModalActiveTab to DETAIL when closing message log modal', () => {
useStore.setState({
currentLogModalActiveTab: 'PROMPT',
showMessageLogModal: true,
})
useStore.getState().setShowMessageLogModal(false)
const nextState = useStore.getState()
expect(nextState.showMessageLogModal).toBe(false)
expect(nextState.currentLogModalActiveTab).toBe('DETAIL')
})
})

View File

@@ -0,0 +1,225 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import ConfigContext from '@/context/debug-configuration'
import Configuration from '../index'
type ControllerState = ReturnType<typeof import('../hooks/use-configuration-controller').useConfigurationController>
type ContextValue = ControllerState['contextValue']
const mockUseConfigurationController = vi.fn()
const mockPublish = vi.fn()
let latestDebugPanelProps: Record<string, unknown> | undefined
let latestModalProps: Record<string, unknown> | undefined
let latestFeatures: unknown
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../hooks/use-configuration-controller', () => ({
useConfigurationController: () => mockUseConfigurationController(),
}))
vi.mock('@/app/components/base/features', () => ({
FeaturesProvider: ({ children, features }: { children: ReactNode, features: unknown }) => {
latestFeatures = features
return <div data-testid="features-provider">{children}</div>
},
}))
vi.mock('@/context/mitt-context-provider', () => ({
MittProvider: ({ children }: { children: ReactNode }) => <div data-testid="mitt-provider">{children}</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/app/configuration/config', () => ({
default: function MockConfig() {
const context = useContext(ConfigContext)
return <div data-testid="config">{context.modelConfig.model_id}</div>
},
}))
vi.mock('@/app/components/app/configuration/components/configuration-header-actions', () => ({
default: ({ publisherProps }: { publisherProps: { publishDisabled: boolean, onPublish: () => void } }) => {
return (
<div data-testid="header-actions" data-disabled={String(publisherProps.publishDisabled)}>
<button type="button" onClick={() => publisherProps.onPublish()}>
publish
</button>
</div>
)
},
}))
vi.mock('@/app/components/app/configuration/components/configuration-debug-panel', () => ({
default: (props: Record<string, unknown>) => {
latestDebugPanelProps = props
return <div data-testid="debug-panel">debug-panel</div>
},
}))
vi.mock('@/app/components/app/configuration/components/configuration-modals', () => ({
default: (props: { isMobile?: boolean, isShowDebugPanel?: boolean }) => {
latestModalProps = props
return <div data-testid="configuration-modals" data-mobile={String(props.isMobile)} />
},
}))
const createContextValue = (overrides: Partial<ContextValue> = {}): ContextValue => ({
setPromptMode: vi.fn(async () => {}),
isAdvancedMode: false,
modelConfig: {
provider: 'langgenius/openai/openai',
model_id: 'gpt-4o-mini',
mode: 'chat',
configs: {
prompt_template: '',
prompt_variables: [],
},
chat_prompt_config: null,
completion_prompt_config: null,
more_like_this: null,
opening_statement: '',
suggested_questions: [],
sensitive_word_avoidance: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
suggested_questions_after_answer: null,
retriever_resource: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: {
enabled: false,
strategy: 'react',
max_iteration: 5,
tools: [],
},
},
...overrides,
} as ContextValue)
const createControllerState = (overrides: Partial<ControllerState> = {}): ControllerState => ({
currentWorkspaceId: 'workspace-id',
featuresData: {
opening: { enabled: true, opening_statement: 'hello', suggested_questions: [] },
},
contextValue: createContextValue(),
debugPanelProps: {
isAPIKeySet: true,
},
headerActionsProps: {
publisherProps: {
publishDisabled: false,
onPublish: mockPublish,
debugWithMultipleModel: false,
},
},
isLoading: false,
isLoadingCurrentWorkspace: false,
isMobile: false,
modalProps: {
isMobile: false,
},
...overrides,
} as ControllerState)
describe('Configuration', () => {
beforeEach(() => {
vi.clearAllMocks()
latestDebugPanelProps = undefined
latestModalProps = undefined
latestFeatures = undefined
mockUseConfigurationController.mockReturnValue(createControllerState())
})
it('should show loading until workspace and detail initialization finish', () => {
mockUseConfigurationController.mockReturnValue(createControllerState({
isLoading: true,
}))
render(<Configuration />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.queryByTestId('config')).not.toBeInTheDocument()
})
it('should render initialized desktop configuration and forward publish state', () => {
const baseState = createControllerState()
mockUseConfigurationController.mockReturnValue({
...baseState,
contextValue: {
...baseState.contextValue,
isAdvancedMode: true,
modelConfig: {
...baseState.contextValue.modelConfig,
model_id: 'gpt-4.1',
},
},
headerActionsProps: {
...baseState.headerActionsProps,
publisherProps: {
...baseState.headerActionsProps.publisherProps,
publishDisabled: true,
onPublish: mockPublish,
debugWithMultipleModel: true,
},
},
})
render(<Configuration />)
expect(screen.getByText('orchestrate')).toBeInTheDocument()
expect(screen.getByText('promptMode.advanced')).toBeInTheDocument()
expect(screen.getByTestId('config')).toHaveTextContent('gpt-4.1')
expect(screen.getByTestId('debug-panel')).toBeInTheDocument()
expect(screen.getByTestId('header-actions')).toHaveAttribute('data-disabled', 'true')
expect(latestFeatures).toEqual(expect.objectContaining({
opening: expect.objectContaining({ enabled: true }),
}))
fireEvent.click(screen.getByRole('button', { name: 'publish' }))
expect(mockPublish).toHaveBeenCalledTimes(1)
expect(latestDebugPanelProps).toEqual(expect.objectContaining({
isAPIKeySet: true,
}))
})
it('should switch to the mobile modal flow without rendering the desktop debug panel', () => {
const baseState = createControllerState()
mockUseConfigurationController.mockReturnValue({
...baseState,
isMobile: true,
modalProps: {
...baseState.modalProps,
isMobile: true,
isShowDebugPanel: true,
},
})
render(<Configuration />)
expect(screen.queryByTestId('debug-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('configuration-modals')).toHaveAttribute('data-mobile', 'true')
expect(latestModalProps).toEqual(expect.objectContaining({
isShowDebugPanel: true,
isMobile: true,
}))
})
})

View File

@@ -0,0 +1,22 @@
import type { ComponentProps } from 'react'
import Debug from '@/app/components/app/configuration/debug'
type DebugProps = ComponentProps<typeof Debug>
type ConfigurationDebugPanelProps = Omit<DebugProps, 'onSetting'> & {
onOpenModelProvider: () => void
}
const ConfigurationDebugPanel = ({
onOpenModelProvider,
...props
}: ConfigurationDebugPanelProps) => {
return (
<Debug
{...props}
onSetting={onOpenModelProvider}
/>
)
}
export default ConfigurationDebugPanel

View File

@@ -0,0 +1,10 @@
import type { Dispatch, SetStateAction } from 'react'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import type { ModalState } from '@/context/modal-context'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
export const getOpenModelProviderHandler = (
setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<AccountSettingTab> | null>>,
) => {
return () => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MODEL_PROVIDER })
}

View File

@@ -0,0 +1,61 @@
import type { ComponentProps } from 'react'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useTranslation } from 'react-i18next'
import AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import AgentSettingButton from '@/app/components/app/configuration/config/agent-setting-button'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
type HeaderActionsProps = {
isAgent: boolean
isFunctionCall: boolean
isMobile: boolean
showModelParameterModal: boolean
onShowDebugPanel: () => void
agentSettingButtonProps: ComponentProps<typeof AgentSettingButton>
modelParameterModalProps: ComponentProps<typeof ModelParameterModal>
publisherProps: ComponentProps<typeof AppPublisher>
}
const ConfigurationHeaderActions = ({
isAgent,
isFunctionCall,
isMobile,
showModelParameterModal,
onShowDebugPanel,
agentSettingButtonProps,
modelParameterModalProps,
publisherProps,
}: HeaderActionsProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center">
{isAgent && (
<AgentSettingButton
{...agentSettingButtonProps}
isFunctionCall={isFunctionCall}
/>
)}
{showModelParameterModal && (
<>
<ModelParameterModal {...modelParameterModalProps} />
<Divider type="vertical" className="mx-2 h-[14px]" />
</>
)}
{isMobile && (
<Button className="mr-2 !h-8 !text-[13px] font-medium" onClick={onShowDebugPanel}>
<span className="mr-1">{t('operation.debugConfig', { ns: 'appDebug' })}</span>
<CodeBracketIcon className="h-4 w-4 text-text-tertiary" />
</Button>
)}
<AppPublisher {...publisherProps} />
</div>
)
}
export default ConfigurationHeaderActions

View File

@@ -0,0 +1,142 @@
import type { ComponentProps } from 'react'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { PromptVariable } from '@/models/debug'
import { useTranslation } from 'react-i18next'
import EditHistoryModal from '@/app/components/app/configuration/config-prompt/conversation-history/edit-modal'
import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
import Drawer from '@/app/components/base/drawer'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import PluginDependency from '@/app/components/workflow/plugin-dependency'
import ConfigurationDebugPanel from './configuration-debug-panel'
type ConfigurationDebugPanelProps = ComponentProps<typeof ConfigurationDebugPanel>
type ConfigurationModalsProps = {
showUseGPT4Confirm: boolean
onConfirmUseGPT4: () => void
onCancelUseGPT4: () => void
isShowSelectDataSet: boolean
hideSelectDataSet: () => void
selectedIds: string[]
onSelectDataSet: ComponentProps<typeof SelectDataSet>['onSelect']
isShowHistoryModal: boolean
hideHistoryModal: () => void
conversationHistoriesRole: ComponentProps<typeof EditHistoryModal>['data']
setConversationHistoriesRole: (data: ComponentProps<typeof EditHistoryModal>['data']) => void
isMobile: boolean
isShowDebugPanel: boolean
hideDebugPanel: () => void
debugPanelProps: ConfigurationDebugPanelProps
showAppConfigureFeaturesModal: boolean
closeFeaturePanel: () => void
mode: string
handleFeaturesChange: OnFeaturesChange
promptVariables: PromptVariable[]
handleAddPromptVariable: (variables: PromptVariable[]) => void
}
const ConfigurationModals = ({
showUseGPT4Confirm,
onConfirmUseGPT4,
onCancelUseGPT4,
isShowSelectDataSet,
hideSelectDataSet,
selectedIds,
onSelectDataSet,
isShowHistoryModal,
hideHistoryModal,
conversationHistoriesRole,
setConversationHistoriesRole,
isMobile,
isShowDebugPanel,
hideDebugPanel,
debugPanelProps,
showAppConfigureFeaturesModal,
closeFeaturePanel,
mode,
handleFeaturesChange,
promptVariables,
handleAddPromptVariable,
}: ConfigurationModalsProps) => {
const { t } = useTranslation()
return (
<>
{showUseGPT4Confirm && (
<AlertDialog open={showUseGPT4Confirm} onOpenChange={open => !open && onCancelUseGPT4()}>
<AlertDialogContent>
<div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{t('trailUseGPT4Info.title', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full whitespace-pre-wrap break-words text-text-tertiary system-md-regular">
{t('trailUseGPT4Info.description', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirmUseGPT4}>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
{isShowSelectDataSet && (
<SelectDataSet
isShow={isShowSelectDataSet}
onClose={hideSelectDataSet}
selectedIds={selectedIds}
onSelect={onSelectDataSet}
/>
)}
{isShowHistoryModal && (
<EditHistoryModal
isShow={isShowHistoryModal}
saveLoading={false}
onClose={hideHistoryModal}
data={conversationHistoriesRole}
onSave={(data) => {
setConversationHistoriesRole(data)
hideHistoryModal()
}}
/>
)}
{isMobile && (
<Drawer showClose isOpen={isShowDebugPanel} onClose={hideDebugPanel} mask footer={null}>
<ConfigurationDebugPanel {...debugPanelProps} />
</Drawer>
)}
{showAppConfigureFeaturesModal && (
<NewFeaturePanel
show
inWorkflow={false}
showFileUpload={false}
isChatMode={mode !== 'completion'}
disabled={false}
onChange={handleFeaturesChange}
onClose={closeFeaturePanel}
promptVariables={promptVariables}
onAutoAddPromptVariable={handleAddPromptVariable}
/>
)}
<PluginDependency />
</>
)
}
export default ConfigurationModals

View File

@@ -0,0 +1,160 @@
import type { ComponentProps } from 'react'
import type { PromptEditorProps as BasePromptEditorProps } from '@/app/components/base/prompt-editor'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import ConfigContext from '@/context/debug-configuration'
import PromptEditor from '../prompt-editor'
type ContextValue = ComponentProps<typeof ConfigContext.Provider>['value']
const mockCopy = vi.fn()
const mockSetShowExternalDataToolModal = vi.fn()
const mockPromptEditor = vi.fn()
const mockSetExternalDataToolsConfig = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => values?.key ?? key,
}),
}))
vi.mock('copy-to-clipboard', () => ({
default: (...args: unknown[]) => mockCopy(...args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowExternalDataToolModal: mockSetShowExternalDataToolModal,
}),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: Array<string | undefined | false | null>) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({
Copy: ({ onClick }: { onClick?: () => void }) => <button type="button" onClick={onClick}>copy-icon</button>,
CopyCheck: () => <div>copied-icon</div>,
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
default: (props: BasePromptEditorProps) => {
mockPromptEditor(props)
return (
<div data-testid="prompt-editor">
<button type="button" onClick={() => props.externalToolBlock?.onAddExternalTool?.()}>add-external-tool</button>
</div>
)
},
}))
const createContextValue = (overrides: Partial<ContextValue> = {}): ContextValue => ({
modelConfig: {
configs: {
prompt_variables: [
{ key: 'customer_name', name: 'Customer Name' },
{ key: '', name: 'Ignored' },
],
},
},
hasSetBlockStatus: {
context: false,
},
dataSets: [
{ id: 'dataset-1', name: 'Knowledge Base', data_source_type: 'notion' },
],
showSelectDataSet: vi.fn(),
externalDataToolsConfig: [
{ label: 'Search API', variable: 'search_api', icon: 'icon.png', icon_background: '#fff' },
],
setExternalDataToolsConfig: mockSetExternalDataToolsConfig,
...overrides,
} as ContextValue)
const renderEditor = (contextOverrides: Partial<ContextValue> = {}, props: Partial<ComponentProps<typeof PromptEditor>> = {}) => {
return render(
<ConfigContext.Provider value={createContextValue(contextOverrides)}>
<PromptEditor
type="first-prompt"
value="Hello world"
onChange={vi.fn()}
{...props}
/>
</ConfigContext.Provider>,
)
}
describe('agent prompt-editor', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should copy the current prompt and toggle the copied feedback', () => {
renderEditor()
fireEvent.click(screen.getByRole('button', { name: 'copy-icon' }))
expect(mockCopy).toHaveBeenCalledWith('Hello world')
expect(screen.getByText('copied-icon')).toBeInTheDocument()
})
it('should pass context, variable, and external tool blocks into the shared prompt editor', () => {
const showSelectDataSet = vi.fn()
renderEditor({ showSelectDataSet })
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
value: 'Hello world',
contextBlock: expect.objectContaining({
show: true,
selectable: true,
datasets: [{ id: 'dataset-1', name: 'Knowledge Base', type: 'notion' }],
onAddContext: showSelectDataSet,
}),
variableBlock: {
show: true,
variables: [{ name: 'Customer Name', value: 'customer_name' }],
},
externalToolBlock: expect.objectContaining({
show: true,
externalTools: [{ name: 'Search API', variableName: 'search_api', icon: 'icon.png', icon_background: '#fff' }],
}),
}))
expect(screen.getByText('11')).toBeInTheDocument()
})
it('should reject duplicated external tool variables before save', () => {
renderEditor()
fireEvent.click(screen.getByRole('button', { name: 'add-external-tool' }))
const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0]
expect(modalPayload.onValidateBeforeSaveCallback({ variable: 'customer_name' })).toBe(false)
expect(modalPayload.onValidateBeforeSaveCallback({ variable: 'search_api' })).toBe(false)
expect(toast.error).toHaveBeenNthCalledWith(1, 'customer_name')
expect(toast.error).toHaveBeenNthCalledWith(2, 'search_api')
})
it('should append a new external tool when validation passes', () => {
renderEditor()
fireEvent.click(screen.getByRole('button', { name: 'add-external-tool' }))
const modalPayload = mockSetShowExternalDataToolModal.mock.calls[0][0]
const newTool = { label: 'CRM API', variable: 'crm_api' }
expect(modalPayload.onValidateBeforeSaveCallback(newTool)).toBe(true)
modalPayload.onSaveCallback(newTool)
expect(mockSetExternalDataToolsConfig).toHaveBeenCalledWith([
{ label: 'Search API', variable: 'search_api', icon: 'icon.png', icon_background: '#fff' },
newTool,
])
})
})

View File

@@ -0,0 +1,267 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { AppModeEnum } from '@/types/app'
import { GetCodeGeneratorResModal } from '../get-code-generator-res'
const mockGenerateRule = vi.fn()
const mockStorageGet = vi.fn()
const mockStorageSet = vi.fn()
type GeneratedResult = {
error?: string
message?: string
modified?: string
}
let sessionInstruction = ''
let instructionTemplateResponse: { data: string } | undefined = { data: 'Template instruction' }
let defaultModelResponse = {
model: 'gpt-4.1-mini',
provider: {
provider: 'langgenius/openai/openai',
},
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('ahooks', async () => {
const React = await import('react')
return {
useBoolean: (initial: boolean) => {
const [value, setValue] = React.useState(initial)
return [value, { setTrue: () => setValue(true), setFalse: () => setValue(false) }]
},
useSessionStorageState: () => React.useState(sessionInstruction),
}
})
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick, disabled }: { children: React.ReactNode, onClick?: () => void, disabled?: boolean }) => (
<button type="button" onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel }: { isShow: boolean, onConfirm: () => void, onCancel: () => void }) => {
if (!isShow)
return null
return (
<div data-testid="confirm">
<button type="button" onClick={onConfirm}>confirm-overwrite</button>
<button type="button" onClick={onCancel}>cancel-overwrite</button>
</div>
)
},
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('@/app/components/base/modal', () => ({
default: ({ isShow, children }: { isShow: boolean, children: React.ReactNode }) => isShow ? <div data-testid="modal">{children}</div> : null,
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: defaultModelResponse,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: ({ modelId, provider }: { modelId: string, provider: string }) => {
return <div data-testid="model-parameter-modal" data-model={modelId} data-provider={provider} />
},
}))
vi.mock('@/service/debug', () => ({
generateRule: (...args: unknown[]) => mockGenerateRule(...args),
}))
vi.mock('@/service/use-apps', () => ({
useGenerateRuleTemplate: () => ({
data: instructionTemplateResponse,
}),
}))
vi.mock('@/utils/storage', () => ({
storage: {
get: (...args: unknown[]) => mockStorageGet(...args),
set: (...args: unknown[]) => mockStorageSet(...args),
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor/index', () => ({
languageMap: {
python3: 'python',
},
}))
vi.mock('@/app/components/app/configuration/config/automatic/idea-output', () => ({
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<input aria-label="idea-output" value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('@/app/components/app/configuration/config/automatic/instruction-editor-in-workflow', () => ({
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<textarea aria-label="instruction-editor" value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('@/app/components/app/configuration/config/automatic/res-placeholder', () => ({
default: () => <div data-testid="placeholder">placeholder</div>,
}))
vi.mock('@/app/components/app/configuration/config/automatic/result', () => ({
default: ({ current, onApply }: { current: { modified?: string }, onApply: () => void }) => (
<div data-testid="result">
<div>{current.modified}</div>
<button type="button" onClick={onApply}>apply-result</button>
</div>
),
}))
vi.mock('@/app/components/app/configuration/config/automatic/use-gen-data', async () => {
const React = await import('react')
const useMockGenData = () => {
const [versions, setVersions] = React.useState<GeneratedResult[]>([])
const [currentVersionIndex, setCurrentVersionIndex] = React.useState<number | undefined>(undefined)
const current = versions.length
? versions[currentVersionIndex ?? versions.length - 1]
: undefined
return {
addVersion: (res: GeneratedResult) => {
setVersions(prev => [...prev, res])
setCurrentVersionIndex(undefined)
},
current,
currentVersionIndex,
setCurrentVersionIndex,
versions,
}
}
return {
default: useMockGenData,
}
})
const renderModal = (overrides: Partial<React.ComponentProps<typeof GetCodeGeneratorResModal>> = {}) => {
return render(
<GetCodeGeneratorResModal
codeLanguages={CodeLanguage.python3}
flowId="flow-1"
isShow
mode={AppModeEnum.CHAT}
nodeId="node-1"
onClose={vi.fn()}
onFinished={vi.fn()}
{...overrides}
/>,
)
}
describe('GetCodeGeneratorResModal', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionInstruction = ''
defaultModelResponse = {
model: 'gpt-4.1-mini',
provider: {
provider: 'langgenius/openai/openai',
},
}
instructionTemplateResponse = { data: 'Template instruction' }
mockStorageGet.mockImplementation((key: string) => {
if (key === STORAGE_KEYS.LOCAL.GENERATOR.AUTO_GEN_MODEL)
return null
return null
})
})
it('should hydrate instruction and fall back to the default model', async () => {
renderModal()
await waitFor(() => {
expect(screen.getByLabelText('instruction-editor')).toHaveValue('Template instruction')
})
expect(screen.getByTestId('model-parameter-modal')).toHaveAttribute('data-model', 'gpt-4.1-mini')
expect(screen.getByTestId('model-parameter-modal')).toHaveAttribute('data-provider', 'langgenius/openai/openai')
})
it('should validate empty instruction before requesting code generation', () => {
instructionTemplateResponse = undefined
renderModal()
fireEvent.click(screen.getByRole('button', { name: 'codegen.generate' }))
expect(toast.error).toHaveBeenCalledWith('errorMsg.fieldRequired')
expect(mockGenerateRule).not.toHaveBeenCalled()
})
it('should generate code, show loading, and confirm overwrite before finishing', async () => {
sessionInstruction = 'Generate a parser'
const onFinished = vi.fn()
let resolveRequest!: (value: GeneratedResult) => void
mockGenerateRule.mockReturnValue(new Promise(resolve => resolveRequest = resolve))
renderModal({ onFinished })
fireEvent.click(screen.getByRole('button', { name: 'codegen.generate' }))
expect(screen.getByTestId('loading')).toBeInTheDocument()
await act(async () => {
resolveRequest({ modified: 'print("done")', message: 'Generated' })
})
await waitFor(() => {
expect(screen.getByTestId('result')).toHaveTextContent('print("done")')
})
fireEvent.click(screen.getByRole('button', { name: 'apply-result' }))
expect(screen.getByTestId('confirm')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'confirm-overwrite' }))
expect(onFinished).toHaveBeenCalledWith(expect.objectContaining({
modified: 'print("done")',
}))
})
it('should show the backend error without appending a new version', async () => {
sessionInstruction = 'Generate a parser'
mockGenerateRule.mockResolvedValue({ error: 'generation failed' })
renderModal()
fireEvent.click(screen.getByRole('button', { name: 'codegen.generate' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('generation failed')
})
expect(screen.getByTestId('placeholder')).toBeInTheDocument()
expect(screen.queryByTestId('result')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,735 @@
/* eslint-disable ts/no-explicit-any */
import type { ComponentProps } from 'react'
import type { PublishConfig } from '../types'
import type ConfigurationDebugPanel from '@/app/components/app/configuration/components/configuration-debug-panel'
import type ConfigurationHeaderActions from '@/app/components/app/configuration/components/configuration-header-actions'
import type ConfigurationModals from '@/app/components/app/configuration/components/configuration-modals'
import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Collection } from '@/app/components/tools/types'
import type { ExternalDataTool } from '@/models/common'
import type { DataSet } from '@/models/datasets'
import type {
AnnotationReplyConfig,
DatasetConfigs,
Inputs,
ModelConfig,
ModerationConfig,
MoreLikeThisConfig,
PromptConfig,
PromptVariable,
TextToSpeechConfig,
} from '@/models/debug'
import type { VisionSettings } from '@/types/app'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { produce } from 'immer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { getOpenModelProviderHandler } from '@/app/components/app/configuration/components/configuration-debug-panel.utils'
import { useStore as useAppStore } from '@/app/components/app/store'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { toast } from '@/app/components/base/ui/toast'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
useModelListAndDefaultModelAndCurrentProviderAndModel,
useTextGenerationCurrentProviderAndModelAndModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { getMultipleRetrievalConfig, getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { ANNOTATION_DEFAULT, DATASET_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PromptMode } from '@/models/debug'
import { usePathname } from '@/next/navigation'
import { useFileUploadConfig } from '@/service/use-common'
import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import { supportFunctionCall } from '@/utils/tool-call'
import { useDebugWithSingleOrMultipleModel, useFormattingChangedDispatcher } from '../debug/hooks'
import useAdvancedPromptConfig from './use-advanced-prompt-config'
import { useConfigurationInitializer } from './use-configuration-initializer'
import { useConfigurationPublish } from './use-configuration-publish'
type HeaderActionsProps = ComponentProps<typeof ConfigurationHeaderActions>
type DebugPanelProps = ComponentProps<typeof ConfigurationDebugPanel>
type ModalsProps = ComponentProps<typeof ConfigurationModals>
export const useConfigurationController = () => {
const { t } = useTranslation()
const {
isLoadingCurrentWorkspace,
currentWorkspace,
} = useAppContext()
const { setShowAccountSettingModal } = useModalContext()
const {
appDetail,
showAppConfigureFeaturesModal,
setAppSidebarExpand,
setShowAppConfigureFeaturesModal,
} = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setAppSidebarExpand: state.setAppSidebarExpand,
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})))
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const pathname = usePathname()
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [hasFetchedDetail, setHasFetchedDetail] = useState(false)
const isLoading = !hasFetchedDetail
const [formattingChanged, setFormattingChanged] = useState(false)
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)
const [conversationId, setConversationId] = useState<string | null>('')
const [isShowDebugPanel, { setTrue: showDebugPanel, setFalse: hideDebugPanel }] = useBoolean(false)
const [introduction, setIntroduction] = useState('')
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([])
const [controlClearChatMessage, setControlClearChatMessage] = useState(0)
const [prevPromptConfig, setPrevPromptConfig] = useState<PromptConfig>({
prompt_template: '',
prompt_variables: [],
})
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig>({ enabled: false })
const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<MoreLikeThisConfig>({ enabled: false })
const [speechToTextConfig, setSpeechToTextConfig] = useState<MoreLikeThisConfig>({ enabled: false })
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig>({
enabled: false,
voice: '',
language: '',
})
const [citationConfig, setCitationConfig] = useState<MoreLikeThisConfig>({ enabled: false })
const [annotationConfigState, doSetAnnotationConfig] = useState<AnnotationReplyConfig>({
id: '',
enabled: false,
score_threshold: ANNOTATION_DEFAULT.score_threshold,
embedding_model: {
embedding_model_name: '',
embedding_provider_name: '',
},
})
const [moderationConfig, setModerationConfig] = useState<ModerationConfig>({ enabled: false })
const [externalDataToolsConfig, setExternalDataToolsConfig] = useState<ExternalDataTool[]>([])
const [inputs, setInputs] = useState<Inputs>({})
const [query, setQuery] = useState('')
const [completionParamsState, doSetCompletionParams] = useState<FormValue>({})
const [, setTempStop, getTempStop] = useGetState<string[]>([])
const [modelConfig, doSetModelConfig] = useState<ModelConfig>({
provider: 'langgenius/openai/openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.unset,
configs: {
prompt_template: '',
prompt_variables: [] as PromptVariable[],
},
chat_prompt_config: clone(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
more_like_this: null,
opening_statement: '',
suggested_questions: [],
sensitive_word_avoidance: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
suggested_questions_after_answer: null,
retriever_resource: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
})
const [collectionList, setCollectionList] = useState<Collection[]>([])
const [datasetConfigsState, doSetDatasetConfigs] = useState<DatasetConfigs>({
retrieval_model: RETRIEVE_TYPE.multiWay,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: DATASET_DEFAULT.top_k,
score_threshold_enabled: false,
score_threshold: DATASET_DEFAULT.score_threshold,
datasets: {
datasets: [],
},
})
const [dataSets, setDataSets] = useState<DataSet[]>([])
const [isShowSelectDataSet, { setTrue: showSelectDataSet, setFalse: hideSelectDataSet }] = useBoolean(false)
const [rerankSettingModalOpen, setRerankSettingModalOpen] = useState(false)
const [isShowHistoryModal, { setTrue: showHistoryModal, setFalse: hideHistoryModal }] = useBoolean(false)
const [promptModeState, doSetPromptMode] = useState(PromptMode.simple)
const [canReturnToSimpleMode, setCanReturnToSimpleMode] = useState(true)
const [visionConfig, doSetVisionConfig] = useState({
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
})
const [showUseGPT4Confirm, setShowUseGPT4Confirm] = useState(false)
const formattingChangedDispatcher = useFormattingChangedDispatcher()
const datasetConfigsRef = useRef(datasetConfigsState)
const modelModeType = modelConfig.mode
const modeModeTypeRef = useRef(modelModeType)
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
const hasSetContextVar = !!contextVar
const setCompletionParams = useCallback((value: FormValue) => {
const params = { ...value }
if ((!params.stop || params.stop.length === 0) && modeModeTypeRef.current === ModelModeType.completion) {
params.stop = getTempStop()
setTempStop([])
}
doSetCompletionParams(params)
}, [getTempStop, setTempStop])
const setAnnotationConfig = useCallback((config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => {
doSetAnnotationConfig(config)
if (!notSetFormatChanged)
formattingChangedDispatcher()
}, [formattingChangedDispatcher])
const setDatasetConfigs = useCallback((newDatasetConfigs: DatasetConfigs) => {
doSetDatasetConfigs(newDatasetConfigs)
datasetConfigsRef.current = newDatasetConfigs
}, [])
const setModelConfig = useCallback((newModelConfig: ModelConfig) => {
// eslint-disable-next-line react/set-state-in-effect
doSetModelConfig(newModelConfig)
}, [])
useEffect(() => {
modeModeTypeRef.current = modelModeType
}, [modelModeType])
const syncToPublishedConfig = useCallback((nextPublishedConfig: PublishConfig) => {
const nextModelConfig = nextPublishedConfig.modelConfig
setModelConfig(nextModelConfig)
setCompletionParams(nextPublishedConfig.completionParams)
setDataSets(nextModelConfig.dataSets || [])
setIntroduction(nextModelConfig.opening_statement!)
setMoreLikeThisConfig(nextModelConfig.more_like_this || { enabled: false })
setSuggestedQuestionsAfterAnswerConfig(nextModelConfig.suggested_questions_after_answer || { enabled: false })
setSpeechToTextConfig(nextModelConfig.speech_to_text || { enabled: false })
setTextToSpeechConfig(nextModelConfig.text_to_speech || {
enabled: false,
voice: '',
language: '',
})
setCitationConfig(nextModelConfig.retriever_resource || { enabled: false })
}, [setCompletionParams, setModelConfig])
const { isAPIKeySet } = useProviderContext()
const { currentModel: currModel, textGenerationModelList } = useTextGenerationCurrentProviderAndModelAndModelList({
provider: modelConfig.provider,
model: modelConfig.model_id,
})
const isFunctionCall = supportFunctionCall(currModel?.features)
useEffect(() => {
if (hasFetchedDetail || modelModeType)
return
const nextMode = currModel?.model_properties.mode as ModelModeType | undefined
if (!nextMode)
return
setModelConfig(produce(modelConfig, (draft) => {
draft.mode = nextMode
}))
}, [currModel, hasFetchedDetail, modelConfig, modelModeType, setModelConfig, textGenerationModelList])
const handleSetVisionConfig = useCallback((config: VisionSettings, notNoticeFormattingChanged?: boolean) => {
doSetVisionConfig({
enabled: config.enabled || false,
number_limits: config.number_limits || 2,
detail: config.detail || Resolution.low,
transfer_methods: config.transfer_methods || [TransferMethod.local_file],
})
if (!notNoticeFormattingChanged)
formattingChangedDispatcher()
}, [formattingChangedDispatcher])
const {
chatPromptConfig,
setChatPromptConfig,
completionPromptConfig,
setCompletionPromptConfig,
currentAdvancedPrompt,
setCurrentAdvancedPrompt,
hasSetBlockStatus,
setConversationHistoriesRole,
migrateToDefaultPrompt,
} = useAdvancedPromptConfig({
appMode: mode,
modelName: modelConfig.model_id,
promptMode: promptModeState,
modelModeType,
prePrompt: modelConfig.configs.prompt_template,
hasSetDataSet: dataSets.length > 0,
onUserChangedPrompt: () => setCanReturnToSimpleMode(false),
completionParams: completionParamsState,
setCompletionParams,
setStop: setTempStop,
})
const setPromptMode = useCallback(async (nextPromptMode: PromptMode) => {
if (nextPromptMode === PromptMode.advanced) {
await migrateToDefaultPrompt()
setCanReturnToSimpleMode(true)
}
doSetPromptMode(nextPromptMode)
}, [migrateToDefaultPrompt])
const setModel = useCallback(async ({
modelId,
provider,
mode: nextMode,
features,
}: { modelId: string, provider: string, mode: string, features: string[] }) => {
if (promptModeState === PromptMode.advanced) {
if (nextMode === ModelModeType.completion) {
if (mode !== AppModeEnum.COMPLETION) {
if (!completionPromptConfig.prompt?.text || !completionPromptConfig.conversation_histories_role.assistant_prefix || !completionPromptConfig.conversation_histories_role.user_prefix)
await migrateToDefaultPrompt(true, ModelModeType.completion)
}
else if (!completionPromptConfig.prompt?.text) {
await migrateToDefaultPrompt(true, ModelModeType.completion)
}
}
if (nextMode === ModelModeType.chat && chatPromptConfig.prompt.length === 0)
await migrateToDefaultPrompt(true, ModelModeType.chat)
}
setModelConfig(produce(modelConfig, (draft) => {
draft.provider = provider
draft.model_id = modelId
draft.mode = nextMode as ModelModeType
}))
handleSetVisionConfig({
...visionConfig,
enabled: !!features?.includes(ModelFeatureEnum.vision),
}, true)
try {
const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams(
provider,
modelId,
completionParamsState,
promptModeState === PromptMode.advanced,
)
if (Object.keys(removedDetails).length)
toast.warning(`${t('modelProvider.parametersInvalidRemoved', { ns: 'common' })}: ${Object.entries(removedDetails).map(([key, reason]) => `${key} (${reason})`).join(', ')}`)
setCompletionParams(filtered)
}
catch {
toast.error(t('error', { ns: 'common' }))
setCompletionParams({})
}
}, [
chatPromptConfig.prompt.length,
completionParamsState,
completionPromptConfig.conversation_histories_role.assistant_prefix,
completionPromptConfig.conversation_histories_role.user_prefix,
completionPromptConfig.prompt?.text,
handleSetVisionConfig,
migrateToDefaultPrompt,
mode,
modelConfig,
promptModeState,
setCompletionParams,
setModelConfig,
t,
visionConfig,
])
const isOpenAI = modelConfig.provider === 'langgenius/openai/openai'
const isAgent = mode === AppModeEnum.AGENT_CHAT
const selectedIds = dataSets.map(item => item.id)
const {
currentModel: currentRerankModel,
currentProvider: currentRerankProvider,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const handleSelect = useCallback((selectedDataSets: DataSet[]) => {
if (isEqual(selectedDataSets.map(item => item.id), dataSets.map(item => item.id))) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
let nextDataSets = selectedDataSets
if (selectedDataSets.find(item => !item.name)) {
const hydratedSelection = produce(selectedDataSets, (draft) => {
selectedDataSets.forEach((item, index) => {
if (!item.name) {
const hydratedItem = dataSets.find(dataset => dataset.id === item.id)
if (hydratedItem)
draft[index] = hydratedItem
}
})
})
setDataSets(hydratedSelection)
nextDataSets = hydratedSelection
}
else {
setDataSets(selectedDataSets)
}
hideSelectDataSet()
const {
allExternal,
allInternal,
mixtureInternalAndExternal,
mixtureHighQualityAndEconomic,
inconsistentEmbeddingModel,
} = getSelectedDatasetsMode(nextDataSets)
if ((allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) || mixtureInternalAndExternal || allExternal)
setRerankSettingModalOpen(true)
const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = datasetConfigsState
const {
top_k,
score_threshold,
reranking_model,
reranking_mode,
weights,
reranking_enable,
} = restConfigs
const oldRetrievalConfig = {
top_k,
score_threshold,
reranking_model: (reranking_model?.reranking_provider_name && reranking_model?.reranking_model_name)
? {
provider: reranking_model.reranking_provider_name,
model: reranking_model.reranking_model_name,
}
: undefined,
reranking_mode,
weights,
reranking_enable,
}
const retrievalConfig = getMultipleRetrievalConfig(oldRetrievalConfig, nextDataSets, dataSets, {
provider: currentRerankProvider?.provider,
model: currentRerankModel?.model,
})
setDatasetConfigs({
...datasetConfigsRef.current,
...retrievalConfig,
reranking_model: {
reranking_provider_name: retrievalConfig?.reranking_model?.provider || '',
reranking_model_name: retrievalConfig?.reranking_model?.model || '',
},
retrieval_model,
score_threshold_enabled,
datasets,
})
}, [
currentRerankModel?.model,
currentRerankProvider?.provider,
dataSets,
datasetConfigsState,
formattingChangedDispatcher,
hideSelectDataSet,
setDatasetConfigs,
])
const featuresData: FeaturesData = useMemo(() => ({
moreLikeThis: modelConfig.more_like_this || { enabled: false },
opening: {
enabled: !!modelConfig.opening_statement,
opening_statement: modelConfig.opening_statement || '',
suggested_questions: modelConfig.suggested_questions || [],
},
moderation: modelConfig.sensitive_word_avoidance || { enabled: false },
speech2text: modelConfig.speech_to_text || { enabled: false },
text2speech: modelConfig.text_to_speech || { enabled: false },
file: {
image: {
detail: modelConfig.file_upload?.image?.detail || Resolution.high,
enabled: !!modelConfig.file_upload?.image?.enabled,
number_limits: modelConfig.file_upload?.image?.number_limits || 3,
transfer_methods: modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(modelConfig.file_upload?.enabled || modelConfig.file_upload?.image?.enabled),
allowed_file_types: modelConfig.file_upload?.allowed_file_types || [],
allowed_file_extensions: modelConfig.file_upload?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image], ...FILE_EXTS[SupportUploadFileTypes.video]].map(ext => `.${ext}`),
allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: modelConfig.file_upload?.number_limits || modelConfig.file_upload?.image?.number_limits || 3,
fileUploadConfig: fileUploadConfigResponse,
} as FileUpload,
suggested: modelConfig.suggested_questions_after_answer || { enabled: false },
citation: modelConfig.retriever_resource || { enabled: false },
annotationReply: modelConfig.annotation_reply || { enabled: false },
}), [fileUploadConfigResponse, modelConfig])
const handleFeaturesChange = useCallback((flag: any) => {
setShowAppConfigureFeaturesModal(true)
if (flag)
formattingChangedDispatcher()
}, [formattingChangedDispatcher, setShowAppConfigureFeaturesModal])
const handleAddPromptVariable = useCallback((variables: PromptVariable[]) => {
setModelConfig(produce(modelConfig, (draft) => {
draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...variables]
}))
}, [modelConfig, setModelConfig])
useConfigurationInitializer({
appId,
currentRerankProvider,
currentRerankModel,
syncToPublishedConfig,
setMode,
setPromptMode: doSetPromptMode,
setChatPromptConfig,
setCompletionPromptConfig,
setCanReturnToSimpleMode,
setDataSets,
setIntroduction,
setSuggestedQuestions,
setMoreLikeThisConfig,
setSuggestedQuestionsAfterAnswerConfig,
setSpeechToTextConfig,
setTextToSpeechConfig,
setCitationConfig,
setAnnotationConfig,
setModerationConfig: setModerationConfig as any,
setExternalDataToolsConfig,
handleSetVisionConfig,
setPublishedConfig,
setDatasetConfigs,
setCollectionList,
setHasFetchedDetail,
})
const { cannotPublish, onPublish } = useConfigurationPublish({
appId,
mode,
modelConfig,
completionParams: completionParamsState,
promptMode: promptModeState,
isAdvancedMode: promptModeState === PromptMode.advanced,
chatPromptConfig,
completionPromptConfig,
hasSetBlockStatus,
contextVar,
dataSets,
datasetConfigs: datasetConfigsState,
introduction,
moreLikeThisConfig,
suggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
textToSpeechConfig,
citationConfig,
externalDataToolsConfig,
isFunctionCall,
setPublishedConfig,
setCanReturnToSimpleMode,
})
const { debugWithMultipleModel, multipleModelConfigs, handleMultipleModelConfigsChange } = useDebugWithSingleOrMultipleModel(appId)
const handleDebugWithMultipleModelChange = useCallback(() => {
handleMultipleModelConfigsChange(true, [
{ id: `${Date.now()}`, model: modelConfig.model_id, provider: modelConfig.provider, parameters: completionParamsState },
{ id: `${Date.now()}-no-repeat`, model: '', provider: '', parameters: {} },
])
setAppSidebarExpand('collapse')
}, [completionParamsState, handleMultipleModelConfigsChange, modelConfig.model_id, modelConfig.provider, setAppSidebarExpand])
const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision)
const isShowDocumentConfig = !!currModel?.features?.includes(ModelFeatureEnum.document)
const isShowAudioConfig = !!currModel?.features?.includes(ModelFeatureEnum.audio)
const isAllowVideoUpload = !!currModel?.features?.includes(ModelFeatureEnum.video)
const contextValue = {
appId,
isAPIKeySet,
isTrailFinished: false,
mode,
modelModeType,
promptMode: promptModeState,
isAdvancedMode: promptModeState === PromptMode.advanced,
isAgent,
isOpenAI,
isFunctionCall,
collectionList,
setPromptMode,
canReturnToSimpleMode,
setCanReturnToSimpleMode,
chatPromptConfig,
completionPromptConfig,
currentAdvancedPrompt,
setCurrentAdvancedPrompt,
conversationHistoriesRole: completionPromptConfig.conversation_histories_role,
showHistoryModal,
setConversationHistoriesRole,
hasSetBlockStatus,
conversationId,
introduction,
setIntroduction,
suggestedQuestions,
setSuggestedQuestions,
setConversationId,
controlClearChatMessage,
setControlClearChatMessage,
prevPromptConfig,
setPrevPromptConfig,
moreLikeThisConfig,
setMoreLikeThisConfig,
suggestedQuestionsAfterAnswerConfig,
setSuggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
setSpeechToTextConfig,
textToSpeechConfig,
setTextToSpeechConfig,
citationConfig,
setCitationConfig,
annotationConfig: annotationConfigState,
setAnnotationConfig,
moderationConfig,
setModerationConfig,
externalDataToolsConfig,
setExternalDataToolsConfig,
formattingChanged,
setFormattingChanged,
inputs,
setInputs,
query,
setQuery,
completionParams: completionParamsState,
setCompletionParams,
modelConfig,
setModelConfig,
showSelectDataSet,
dataSets,
setDataSets,
datasetConfigs: datasetConfigsState,
datasetConfigsRef,
setDatasetConfigs,
hasSetContextVar,
isShowVisionConfig,
visionConfig,
setVisionConfig: handleSetVisionConfig,
isAllowVideoUpload,
isShowDocumentConfig,
isShowAudioConfig,
rerankSettingModalOpen,
setRerankSettingModalOpen,
}
const debugPanelProps: DebugPanelProps = {
isAPIKeySet,
inputs,
onOpenModelProvider: getOpenModelProviderHandler(setShowAccountSettingModal),
modelParameterParams: {
setModel: setModel as any,
onCompletionParamsChange: setCompletionParams,
},
debugWithMultipleModel,
multipleModelConfigs,
onMultipleModelConfigsChange: handleMultipleModelConfigsChange,
}
const headerActionsProps: HeaderActionsProps = {
isAgent,
isFunctionCall,
isMobile,
showModelParameterModal: !debugWithMultipleModel,
onShowDebugPanel: showDebugPanel,
agentSettingButtonProps: {
isChatModel: modelConfig.mode === ModelModeType.chat,
isFunctionCall,
agentConfig: modelConfig.agentConfig,
onAgentSettingChange: (config) => {
setModelConfig(produce(modelConfig, (draft) => {
draft.agentConfig = config
}))
},
},
modelParameterModalProps: {
isAdvancedMode: promptModeState === PromptMode.advanced,
provider: modelConfig.provider,
completionParams: completionParamsState,
modelId: modelConfig.model_id,
setModel: setModel as any,
onCompletionParamsChange: (newParams: FormValue) => {
setCompletionParams(newParams)
},
debugWithMultipleModel,
onDebugWithMultipleModelChange: handleDebugWithMultipleModelChange,
},
publisherProps: {
publishDisabled: cannotPublish,
publishedAt: (latestPublishedAt || 0) * 1000,
debugWithMultipleModel,
multipleModelConfigs,
onPublish,
publishedConfig: publishedConfig!,
resetAppConfig: () => syncToPublishedConfig(publishedConfig!),
},
}
const modalProps: ModalsProps = {
showUseGPT4Confirm,
onConfirmUseGPT4: () => {
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MODEL_PROVIDER })
setShowUseGPT4Confirm(false)
},
onCancelUseGPT4: () => setShowUseGPT4Confirm(false),
isShowSelectDataSet,
hideSelectDataSet,
selectedIds,
onSelectDataSet: handleSelect,
isShowHistoryModal,
hideHistoryModal,
conversationHistoriesRole: completionPromptConfig.conversation_histories_role,
setConversationHistoriesRole,
isMobile,
isShowDebugPanel,
hideDebugPanel,
debugPanelProps,
showAppConfigureFeaturesModal,
closeFeaturePanel: () => setShowAppConfigureFeaturesModal(false),
mode,
handleFeaturesChange,
promptVariables: modelConfig.configs.prompt_variables,
handleAddPromptVariable,
}
return {
currentWorkspaceId: currentWorkspace.id,
featuresData,
contextValue,
debugPanelProps,
headerActionsProps,
isLoading,
isLoadingCurrentWorkspace,
isMobile,
modalProps,
}
}

View File

@@ -0,0 +1,320 @@
/* eslint-disable ts/no-explicit-any */
import type { Dispatch, SetStateAction } from 'react'
import type { PublishConfig } from '../types'
import type { Collection } from '@/app/components/tools/types'
import type { ExternalDataTool } from '@/models/common'
import type { DataSet } from '@/models/datasets'
import type {
AnnotationReplyConfig,
ChatPromptConfig,
CompletionPromptConfig,
DatasetConfigs,
ModerationConfig,
MoreLikeThisConfig,
TextToSpeechConfig,
} from '@/models/debug'
import type { App, UserInputFormItem, VisionSettings } from '@/types/app'
import { clone } from 'es-toolkit/object'
import { useEffect } from 'react'
import { getMultipleRetrievalConfig } from '@/app/components/workflow/nodes/knowledge-retrieval/utils'
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { PromptMode } from '@/models/debug'
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchDatasets } from '@/service/datasets'
import { fetchCollectionList } from '@/service/tools'
import { AgentStrategy, AppModeEnum, RETRIEVE_TYPE } from '@/types/app'
import { correctModelProvider, correctToolProvider } from '@/utils'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
import { basePath } from '@/utils/var'
type BackendModelConfig = App['model_config']
type InitializerDeps = {
appId: string
currentRerankProvider?: { provider?: string }
currentRerankModel?: { model?: string }
syncToPublishedConfig: (config: PublishConfig) => void
setMode: (mode: AppModeEnum) => void
setPromptMode: Dispatch<SetStateAction<PromptMode>>
setChatPromptConfig: Dispatch<SetStateAction<ChatPromptConfig>>
setCompletionPromptConfig: Dispatch<SetStateAction<CompletionPromptConfig>>
setCanReturnToSimpleMode: (canReturn: boolean) => void
setDataSets: (datasets: DataSet[]) => void
setIntroduction: (value: string) => void
setSuggestedQuestions: (value: string[]) => void
setMoreLikeThisConfig: (value: MoreLikeThisConfig) => void
setSuggestedQuestionsAfterAnswerConfig: (value: MoreLikeThisConfig) => void
setSpeechToTextConfig: (value: MoreLikeThisConfig) => void
setTextToSpeechConfig: (value: TextToSpeechConfig) => void
setCitationConfig: (value: MoreLikeThisConfig) => void
setAnnotationConfig: (config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => void
setModerationConfig: Dispatch<SetStateAction<ModerationConfig>>
setExternalDataToolsConfig: (value: ExternalDataTool[]) => void
handleSetVisionConfig: (config: VisionSettings, notNoticeFormattingChanged?: boolean) => void
setPublishedConfig: (config: PublishConfig) => void
setDatasetConfigs: (config: DatasetConfigs) => void
setCollectionList: (collections: Collection[]) => void
setHasFetchedDetail: (value: boolean) => void
}
export const useConfigurationInitializer = ({
appId,
currentRerankProvider,
currentRerankModel,
syncToPublishedConfig,
setMode,
setPromptMode,
setChatPromptConfig,
setCompletionPromptConfig,
setCanReturnToSimpleMode,
setDataSets,
setIntroduction,
setSuggestedQuestions,
setMoreLikeThisConfig,
setSuggestedQuestionsAfterAnswerConfig,
setSpeechToTextConfig,
setTextToSpeechConfig,
setCitationConfig,
setAnnotationConfig,
setModerationConfig,
setExternalDataToolsConfig,
handleSetVisionConfig,
setPublishedConfig,
setDatasetConfigs,
setCollectionList,
setHasFetchedDetail,
}: InitializerDeps) => {
useEffect(() => {
let disposed = false
const run = async () => {
const fetchedCollectionList = await fetchCollectionList()
if (basePath) {
fetchedCollectionList.forEach((item) => {
if (typeof item.icon === 'string' && !item.icon.includes(basePath))
item.icon = `${basePath}${item.icon}`
})
}
if (disposed)
return
setCollectionList(fetchedCollectionList)
const res = await fetchAppDetailDirect({ url: '/apps', id: appId })
if (disposed)
return
setMode(res.mode as AppModeEnum)
const backendModelConfig = res.model_config as BackendModelConfig
const nextPromptMode = backendModelConfig.prompt_type === PromptMode.advanced
? PromptMode.advanced
: PromptMode.simple
setPromptMode(nextPromptMode)
if (nextPromptMode === PromptMode.advanced) {
if (backendModelConfig.chat_prompt_config && backendModelConfig.chat_prompt_config.prompt.length > 0)
setChatPromptConfig(backendModelConfig.chat_prompt_config)
else
setChatPromptConfig(clone(DEFAULT_CHAT_PROMPT_CONFIG))
setCompletionPromptConfig(backendModelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG))
setCanReturnToSimpleMode(false)
}
const { model } = backendModelConfig
let datasets: DataSet[] | null = null
if (backendModelConfig.agent_mode?.tools?.find(({ dataset }: any) => dataset?.enabled))
datasets = backendModelConfig.agent_mode.tools as any
else if (backendModelConfig.dataset_configs.datasets?.datasets?.length)
datasets = backendModelConfig.dataset_configs.datasets.datasets as any
if (datasets?.length) {
const { data: dataSetsWithDetail } = await fetchDatasets({
url: '/datasets',
params: { page: 1, ids: datasets.map(({ dataset }: any) => dataset.id) },
})
if (disposed)
return
datasets = dataSetsWithDetail
setDataSets(datasets)
}
setIntroduction(backendModelConfig.opening_statement)
setSuggestedQuestions(backendModelConfig.suggested_questions || [])
if (backendModelConfig.more_like_this)
setMoreLikeThisConfig(backendModelConfig.more_like_this)
if (backendModelConfig.suggested_questions_after_answer)
setSuggestedQuestionsAfterAnswerConfig(backendModelConfig.suggested_questions_after_answer)
if (backendModelConfig.speech_to_text)
setSpeechToTextConfig(backendModelConfig.speech_to_text)
if (backendModelConfig.text_to_speech)
setTextToSpeechConfig(backendModelConfig.text_to_speech)
if (backendModelConfig.retriever_resource)
setCitationConfig(backendModelConfig.retriever_resource)
if (backendModelConfig.annotation_reply) {
let annotationConfig = backendModelConfig.annotation_reply
if (backendModelConfig.annotation_reply.enabled) {
annotationConfig = {
...backendModelConfig.annotation_reply,
embedding_model: {
...backendModelConfig.annotation_reply.embedding_model,
embedding_provider_name: correctModelProvider(backendModelConfig.annotation_reply.embedding_model.embedding_provider_name),
},
}
}
setAnnotationConfig(annotationConfig as any, true)
}
if (backendModelConfig.sensitive_word_avoidance)
setModerationConfig(backendModelConfig.sensitive_word_avoidance)
if (backendModelConfig.external_data_tools)
setExternalDataToolsConfig(backendModelConfig.external_data_tools)
const publishedConfig: PublishConfig = {
modelConfig: {
provider: correctModelProvider(model.provider),
model_id: model.name,
mode: model.mode,
configs: {
prompt_template: backendModelConfig.pre_prompt || '',
prompt_variables: userInputsFormToPromptVariables(
([
...backendModelConfig.user_input_form,
...(
backendModelConfig.external_data_tools?.length
? backendModelConfig.external_data_tools.map((item: any) => ({
external_data_tool: {
variable: item.variable as string,
label: item.label as string,
enabled: item.enabled,
type: item.type as string,
config: item.config,
required: true,
icon: item.icon,
icon_background: item.icon_background,
},
}))
: []
),
]) as unknown as UserInputFormItem[],
backendModelConfig.dataset_query_variable,
),
},
more_like_this: backendModelConfig.more_like_this ?? { enabled: false },
opening_statement: backendModelConfig.opening_statement,
suggested_questions: backendModelConfig.suggested_questions ?? [],
sensitive_word_avoidance: backendModelConfig.sensitive_word_avoidance,
speech_to_text: backendModelConfig.speech_to_text,
text_to_speech: backendModelConfig.text_to_speech,
file_upload: backendModelConfig.file_upload ?? null,
suggested_questions_after_answer: backendModelConfig.suggested_questions_after_answer ?? { enabled: false },
retriever_resource: backendModelConfig.retriever_resource,
annotation_reply: backendModelConfig.annotation_reply ?? null,
external_data_tools: backendModelConfig.external_data_tools ?? [],
system_parameters: backendModelConfig.system_parameters,
dataSets: datasets || [],
agentConfig: res.mode === AppModeEnum.AGENT_CHAT
? {
max_iteration: DEFAULT_AGENT_SETTING.max_iteration,
...backendModelConfig.agent_mode,
enabled: true,
tools: (backendModelConfig.agent_mode?.tools ?? []).filter((tool: any) => !tool.dataset).map((tool: any) => {
const toolInCollectionList = fetchedCollectionList.find(c => tool.provider_id === c.id)
return {
...tool,
isDeleted: res.deleted_tools?.some((deletedTool: any) =>
deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
notAuthor: toolInCollectionList?.is_team_authorization === false,
...(tool.provider_type === 'builtin'
? {
provider_id: correctToolProvider(tool.provider_name, !!toolInCollectionList),
provider_name: correctToolProvider(tool.provider_name, !!toolInCollectionList),
}
: {}),
}
}),
strategy: backendModelConfig.agent_mode?.strategy ?? AgentStrategy.react,
}
: DEFAULT_AGENT_SETTING,
},
completionParams: model.completion_params,
}
if (backendModelConfig.file_upload)
handleSetVisionConfig(backendModelConfig.file_upload.image, true)
syncToPublishedConfig(publishedConfig)
setPublishedConfig(publishedConfig)
const retrievalConfig = getMultipleRetrievalConfig({
...backendModelConfig.dataset_configs,
reranking_model: backendModelConfig.dataset_configs.reranking_model && {
provider: backendModelConfig.dataset_configs.reranking_model.reranking_provider_name,
model: backendModelConfig.dataset_configs.reranking_model.reranking_model_name,
},
}, datasets ?? [], datasets ?? [], {
provider: currentRerankProvider?.provider,
model: currentRerankModel?.model,
})
const datasetConfigsToSet = {
...backendModelConfig.dataset_configs,
...retrievalConfig,
...(retrievalConfig.reranking_model
? {
reranking_model: {
reranking_model_name: retrievalConfig.reranking_model.model,
reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider),
},
}
: {}),
} as DatasetConfigs
datasetConfigsToSet.retrieval_model = datasetConfigsToSet.retrieval_model ?? RETRIEVE_TYPE.multiWay
setDatasetConfigs(datasetConfigsToSet)
setHasFetchedDetail(true)
}
run().catch(() => {
if (!disposed)
setHasFetchedDetail(true)
})
return () => {
disposed = true
}
}, [
appId,
currentRerankModel?.model,
currentRerankProvider?.provider,
handleSetVisionConfig,
setAnnotationConfig,
setCanReturnToSimpleMode,
setChatPromptConfig,
setCitationConfig,
setCollectionList,
setCompletionPromptConfig,
setDataSets,
setDatasetConfigs,
setExternalDataToolsConfig,
setHasFetchedDetail,
setIntroduction,
setMode,
setModerationConfig,
setMoreLikeThisConfig,
setPromptMode,
setPublishedConfig,
setSpeechToTextConfig,
setSuggestedQuestions,
setSuggestedQuestionsAfterAnswerConfig,
setTextToSpeechConfig,
syncToPublishedConfig,
])
}

View File

@@ -0,0 +1,238 @@
/* eslint-disable ts/no-explicit-any */
import type { PublishConfig } from '../types'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
BlockStatus,
ChatPromptConfig,
CitationConfig,
CompletionPromptConfig,
ModelConfig,
MoreLikeThisConfig,
PromptMode,
SpeechToTextConfig,
SuggestedQuestionsAfterAnswerConfig,
TextToSpeechConfig,
} from '@/models/debug'
import type { App } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { updateAppModelConfig } from '@/service/apps'
import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
type BackendModelConfig = App['model_config']
type UseConfigurationPublishArgs = {
appId: string
mode: AppModeEnum
modelConfig: ModelConfig
completionParams: FormValue
promptMode: PromptMode
isAdvancedMode: boolean
chatPromptConfig: ChatPromptConfig
completionPromptConfig: CompletionPromptConfig
hasSetBlockStatus: BlockStatus
contextVar: string | undefined
dataSets: Array<{ id: string }>
datasetConfigs: BackendModelConfig['dataset_configs']
introduction: string
moreLikeThisConfig: MoreLikeThisConfig
suggestedQuestionsAfterAnswerConfig: SuggestedQuestionsAfterAnswerConfig
speechToTextConfig: SpeechToTextConfig
textToSpeechConfig: TextToSpeechConfig
citationConfig: CitationConfig
externalDataToolsConfig: NonNullable<BackendModelConfig['external_data_tools']>
isFunctionCall: boolean
setPublishedConfig: (config: PublishConfig) => void
setCanReturnToSimpleMode: (value: boolean) => void
}
export const useConfigurationPublish = ({
appId,
mode,
modelConfig,
completionParams,
promptMode,
isAdvancedMode,
chatPromptConfig,
completionPromptConfig,
hasSetBlockStatus,
contextVar,
dataSets,
datasetConfigs,
introduction,
moreLikeThisConfig,
suggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
textToSpeechConfig,
citationConfig,
externalDataToolsConfig,
isFunctionCall,
setPublishedConfig,
setCanReturnToSimpleMode,
}: UseConfigurationPublishArgs) => {
const { t } = useTranslation()
const promptEmpty = useMemo(() => {
if (mode !== AppModeEnum.COMPLETION)
return false
if (isAdvancedMode) {
if (modelConfig.mode === ModelModeType.chat)
return chatPromptConfig.prompt.every(({ text }: { text: string }) => !text)
return !completionPromptConfig.prompt?.text
}
return !modelConfig.configs.prompt_template
}, [chatPromptConfig.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelConfig.mode])
const cannotPublish = useMemo(() => {
if (mode !== AppModeEnum.COMPLETION) {
if (!isAdvancedMode)
return false
if (modelConfig.mode === ModelModeType.completion)
return !hasSetBlockStatus.history || !hasSetBlockStatus.query
return false
}
return promptEmpty
}, [hasSetBlockStatus.history, hasSetBlockStatus.query, isAdvancedMode, mode, modelConfig.mode, promptEmpty])
const contextVarEmpty = mode === AppModeEnum.COMPLETION && dataSets.length > 0 && !contextVar
const onPublish = async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params ? params : undefined
const modelId = modelAndParameter?.model || modelConfig.model_id
if (promptEmpty) {
toast.error(t('otherError.promptNoBeEmpty', { ns: 'appDebug' }))
return
}
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION && modelConfig.mode === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
toast.error(t('otherError.historyNoBeEmpty', { ns: 'appDebug' }))
return
}
if (!hasSetBlockStatus.query) {
toast.error(t('otherError.queryNoBeEmpty', { ns: 'appDebug' }))
return
}
}
if (contextVarEmpty) {
toast.error(t('feature.dataSet.queryVariable.contextVarNotEmpty', { ns: 'appDebug' }))
return
}
const postDatasets = dataSets.map(({ id }) => ({
enabled: true,
id,
}))
const fileUpload = (features?.file
? { ...features.file }
: modelConfig.file_upload) as (FileUpload & { fileUploadConfig?: unknown }) | null
delete fileUpload?.fileUploadConfig
const moreLikeThisPayload: BackendModelConfig['more_like_this'] = features?.moreLikeThis
? { enabled: !!features.moreLikeThis.enabled }
: moreLikeThisConfig
const moderationPayload: BackendModelConfig['sensitive_word_avoidance'] = features?.moderation
? { enabled: !!features.moderation.enabled }
: (modelConfig.sensitive_word_avoidance ?? { enabled: false })
const speechToTextPayload: BackendModelConfig['speech_to_text'] = features?.speech2text
? { enabled: !!features.speech2text.enabled }
: speechToTextConfig
const textToSpeechPayload: BackendModelConfig['text_to_speech'] = features?.text2speech
? {
enabled: !!features.text2speech.enabled,
voice: features.text2speech.voice,
language: features.text2speech.language,
autoPlay: features.text2speech.autoPlay,
}
: textToSpeechConfig
const suggestedQuestionsAfterAnswerPayload: BackendModelConfig['suggested_questions_after_answer'] = features?.suggested
? { enabled: !!features.suggested.enabled }
: suggestedQuestionsAfterAnswerConfig
const citationPayload: BackendModelConfig['retriever_resource'] = features?.citation
? { enabled: !!features.citation.enabled }
: citationConfig
const payload: BackendModelConfig = {
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
prompt_type: promptMode,
chat_prompt_config: isAdvancedMode ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: isAdvancedMode ? completionPromptConfig : clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
dataset_query_variable: contextVar || '',
more_like_this: moreLikeThisPayload,
opening_statement: features?.opening
? (features.opening.enabled ? (features.opening.opening_statement || '') : '')
: introduction,
suggested_questions: features?.opening
? (features.opening.enabled ? (features.opening.suggested_questions || []) : [])
: (modelConfig.suggested_questions ?? []),
sensitive_word_avoidance: moderationPayload,
speech_to_text: speechToTextPayload,
text_to_speech: textToSpeechPayload,
file_upload: fileUpload as BackendModelConfig['file_upload'],
suggested_questions_after_answer: suggestedQuestionsAfterAnswerPayload,
retriever_resource: citationPayload,
agent_mode: {
...modelConfig.agentConfig,
strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react,
},
external_data_tools: externalDataToolsConfig,
model: {
provider: modelAndParameter?.provider || modelConfig.provider,
name: modelId,
mode: modelConfig.mode,
completion_params: modelAndParameter?.parameters || completionParams as any,
},
dataset_configs: {
...datasetConfigs,
datasets: {
datasets: [...postDatasets],
},
},
system_parameters: modelConfig.system_parameters,
}
await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body: payload })
const nextPublishedModelConfig: ModelConfig = produce(modelConfig, (draft) => {
draft.opening_statement = introduction
draft.more_like_this = moreLikeThisConfig
draft.suggested_questions_after_answer = suggestedQuestionsAfterAnswerConfig
draft.speech_to_text = speechToTextConfig
draft.text_to_speech = textToSpeechConfig
draft.retriever_resource = citationConfig
draft.dataSets = dataSets
})
setPublishedConfig({
modelConfig: nextPublishedModelConfig,
completionParams,
})
toast.success(t('api.success', { ns: 'common' }))
setCanReturnToSimpleMode(false)
return true
}
return {
cannotPublish,
contextVarEmpty,
onPublish,
promptEmpty,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelConfig } from '@/models/debug'
export type PublishConfig = {
modelConfig: ModelConfig
completionParams: FormValue
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { parsePlacement } from '../placement'
describe('parsePlacement', () => {
it('should parse placement without explicit alignment as center', () => {
expect(parsePlacement('top')).toEqual({
side: 'top',
align: 'center',
})
})
it('should parse start aligned placement', () => {
expect(parsePlacement('bottom-start')).toEqual({
side: 'bottom',
align: 'start',
})
})
it('should parse end aligned placement', () => {
expect(parsePlacement('left-end')).toEqual({
side: 'left',
align: 'end',
})
})
})

View File

@@ -0,0 +1,61 @@
import type { SVGProps } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import UpgradeModalBase from '../index'
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode, open: boolean }) => open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => <div data-testid="dialog-content" className={className}>{children}</div>,
DialogTitle: ({ children, className }: { children: React.ReactNode, className?: string }) => <h2 className={className}>{children}</h2>,
}))
const MockIcon = (props: SVGProps<SVGSVGElement>) => <svg data-testid="upgrade-icon" {...props} />
describe('UpgradeModalBase', () => {
it('should render title, description, icon, extra info, and footer when visible', () => {
render(
<UpgradeModalBase
Icon={MockIcon}
title="Upgrade required"
description="Please upgrade to continue."
extraInfo={<div>Extra details</div>}
footer={<button>Upgrade</button>}
show
/>,
)
expect(screen.getByTestId('dialog')).toBeInTheDocument()
expect(screen.getByTestId('upgrade-icon')).toBeInTheDocument()
expect(screen.getByText('Upgrade required')).toBeInTheDocument()
expect(screen.getByText('Please upgrade to continue.')).toBeInTheDocument()
expect(screen.getByText('Extra details')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument()
})
it('should not render when show is false', () => {
render(
<UpgradeModalBase
title="Hidden"
description="No modal"
show={false}
/>,
)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
it('should omit optional sections when icon, extraInfo, and footer are absent', () => {
render(
<UpgradeModalBase
title="Basic modal"
description="No extras"
show
/>,
)
expect(screen.queryByTestId('upgrade-icon')).not.toBeInTheDocument()
expect(screen.getByText('Basic modal')).toBeInTheDocument()
expect(screen.getByText('No extras')).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { UserAvatarList } from '../index'
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { id: 'current-user' },
}),
}))
vi.mock('@/app/components/workflow/collaboration/utils/user-color', () => ({
getUserColor: (id: string) => `color-${id}`,
}))
const users = [
{ id: 'current-user', name: 'Alice', avatar_url: 'https://example.com/alice.png' },
{ id: 'user-2', name: 'Bob' },
{ id: 'user-3', name: 'Carol' },
{ id: 'user-4', name: 'Dave' },
]
describe('UserAvatarList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render nothing when user list is empty', () => {
const { container } = render(<UserAvatarList users={[]} />)
expect(container.firstChild).toBeNull()
})
it('should render visible avatars and overflow count', () => {
render(<UserAvatarList users={users} maxVisible={3} />)
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
expect(screen.getByText('+2')).toBeInTheDocument()
})
it('should hide overflow count when showCount is false', () => {
render(<UserAvatarList users={users} maxVisible={2} showCount={false} />)
expect(screen.queryByText(/\+\d/)).not.toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
})
it('should not apply generated background color for the current user', () => {
render(<UserAvatarList users={users.slice(0, 2)} />)
expect(screen.getByText('A')).not.toHaveAttribute('style')
expect(screen.getByText('B')).toHaveStyle({ backgroundColor: 'color-user-2' })
})
it('should map numeric size to the nearest avatar size token', () => {
render(<UserAvatarList users={users.slice(0, 1)} size={39} />)
expect(screen.getByText('A').parentElement).toHaveClass('size-10')
})
})

View File

@@ -1,425 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocToc } from '../hooks/use-doc-toc'
/**
* Unit tests for the useDocToc custom hook.
* Covers TOC extraction, viewport-based expansion, scroll tracking, and click handling.
*/
describe('useDocToc', () => {
const defaultOptions = { appDetail: { mode: 'chat' }, locale: 'en-US' }
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: false }),
})
})
// Covers initial state values based on viewport width
describe('initial state', () => {
it('should set isTocExpanded to false on narrow viewport', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(false)
expect(result.current.toc).toEqual([])
expect(result.current.activeSection).toBe('')
})
it('should set isTocExpanded to true on wide viewport', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(true)
})
})
// Covers TOC extraction from DOM article headings
describe('TOC extraction', () => {
it('should extract toc items from article h2 anchors', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#section-1'
anchor.textContent = 'Section 1'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([
{ href: '#section-1', text: 'Section 1' },
])
expect(result.current.activeSection).toBe('section-1')
document.body.removeChild(article)
vi.useRealTimers()
})
it('should return empty toc when no article exists', async () => {
vi.useFakeTimers()
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([])
expect(result.current.activeSection).toBe('')
vi.useRealTimers()
})
it('should skip h2 headings without anchors', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2NoAnchor = document.createElement('h2')
h2NoAnchor.textContent = 'No Anchor'
article.appendChild(h2NoAnchor)
const h2WithAnchor = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#valid'
anchor.textContent = 'Valid'
h2WithAnchor.appendChild(anchor)
article.appendChild(h2WithAnchor)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
expect(result.current.toc[0]).toEqual({ href: '#valid', text: 'Valid' })
document.body.removeChild(article)
vi.useRealTimers()
})
it('should re-extract toc when appDetail changes', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
document.body.appendChild(article)
const { result, rerender } = renderHook(
props => useDocToc(props),
{ initialProps: defaultOptions },
)
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([])
// Add a heading, then change appDetail to trigger re-extraction
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#new-section'
anchor.textContent = 'New Section'
h2.appendChild(anchor)
article.appendChild(h2)
rerender({ appDetail: { mode: 'workflow' }, locale: 'en-US' })
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
document.body.removeChild(article)
vi.useRealTimers()
})
it('should re-extract toc when locale changes', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#sec'
anchor.textContent = 'Sec'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result, rerender } = renderHook(
props => useDocToc(props),
{ initialProps: defaultOptions },
)
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(1)
rerender({ appDetail: defaultOptions.appDetail, locale: 'zh-Hans' })
await act(async () => {
vi.runAllTimers()
})
// Should still have the toc item after re-extraction
expect(result.current.toc).toHaveLength(1)
document.body.removeChild(article)
vi.useRealTimers()
})
})
// Covers manual toggle via setIsTocExpanded
describe('setIsTocExpanded', () => {
it('should toggle isTocExpanded state', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(false)
act(() => {
result.current.setIsTocExpanded(true)
})
expect(result.current.isTocExpanded).toBe(true)
act(() => {
result.current.setIsTocExpanded(false)
})
expect(result.current.isTocExpanded).toBe(false)
})
})
// Covers smooth-scroll click handler
describe('handleTocClick', () => {
it('should prevent default and scroll to target element', () => {
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
scrollContainer.scrollTo = vi.fn()
document.body.appendChild(scrollContainer)
const target = document.createElement('div')
target.id = 'target-section'
Object.defineProperty(target, 'offsetTop', { value: 500 })
scrollContainer.appendChild(target)
const { result } = renderHook(() => useDocToc(defaultOptions))
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
act(() => {
result.current.handleTocClick(mockEvent, { href: '#target-section', text: 'Target' })
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(scrollContainer.scrollTo).toHaveBeenCalledWith({
top: 420, // 500 - 80 (HEADER_OFFSET)
behavior: 'smooth',
})
document.body.removeChild(scrollContainer)
})
it('should do nothing when target element does not exist', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
const mockEvent = { preventDefault: vi.fn() } as unknown as React.MouseEvent<HTMLAnchorElement>
act(() => {
result.current.handleTocClick(mockEvent, { href: '#nonexistent', text: 'Missing' })
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
})
// Covers scroll-based active section tracking
describe('scroll tracking', () => {
// Helper: set up DOM with scroll container, article headings, and matching target elements
const setupScrollDOM = (sections: Array<{ id: string, text: string, top: number }>) => {
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
document.body.appendChild(scrollContainer)
const article = document.createElement('article')
sections.forEach(({ id, text, top }) => {
// Heading with anchor for TOC extraction
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = `#${id}`
anchor.textContent = text
h2.appendChild(anchor)
article.appendChild(h2)
// Target element for scroll tracking
const target = document.createElement('div')
target.id = id
target.getBoundingClientRect = vi.fn().mockReturnValue({ top })
scrollContainer.appendChild(target)
})
document.body.appendChild(article)
return {
scrollContainer,
article,
cleanup: () => {
document.body.removeChild(scrollContainer)
document.body.removeChild(article)
},
}
}
it('should register scroll listener when toc has items', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'sec-a', text: 'Section A', top: 0 },
])
const addSpy = vi.spyOn(scrollContainer, 'addEventListener')
const removeSpy = vi.spyOn(scrollContainer, 'removeEventListener')
const { unmount } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
unmount()
expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
cleanup()
vi.useRealTimers()
})
it('should update activeSection when scrolling past a section', async () => {
vi.useFakeTimers()
// innerHeight/2 = 384 in jsdom (default 768), so top <= 384 means "scrolled past"
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'intro', text: 'Intro', top: 100 },
{ id: 'details', text: 'Details', top: 600 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
// Extract TOC items
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toHaveLength(2)
expect(result.current.activeSection).toBe('intro')
// Fire scroll — 'intro' (top=100) is above midpoint, 'details' (top=600) is below
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe('intro')
cleanup()
vi.useRealTimers()
})
it('should track the last section above the viewport midpoint', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'sec-1', text: 'Section 1', top: 50 },
{ id: 'sec-2', text: 'Section 2', top: 200 },
{ id: 'sec-3', text: 'Section 3', top: 800 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
// Fire scroll — sec-1 (top=50) and sec-2 (top=200) are above midpoint (384),
// sec-3 (top=800) is below. The last one above midpoint wins.
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe('sec-2')
cleanup()
vi.useRealTimers()
})
it('should not update activeSection when no section is above midpoint', async () => {
vi.useFakeTimers()
const { scrollContainer, cleanup } = setupScrollDOM([
{ id: 'far-away', text: 'Far Away', top: 1000 },
])
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
// Initial activeSection is set by extraction
const initialSection = result.current.activeSection
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
// Should not change since the element is below midpoint
expect(result.current.activeSection).toBe(initialSection)
cleanup()
vi.useRealTimers()
})
it('should handle elements not found in DOM during scroll', async () => {
vi.useFakeTimers()
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
document.body.appendChild(scrollContainer)
// Article with heading but NO matching target element by id
const article = document.createElement('article')
const h2 = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#missing-target'
anchor.textContent = 'Missing'
h2.appendChild(anchor)
article.appendChild(h2)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
const initialSection = result.current.activeSection
// Scroll fires but getElementById returns null — no crash, no change
await act(async () => {
scrollContainer.dispatchEvent(new Event('scroll'))
})
expect(result.current.activeSection).toBe(initialSection)
document.body.removeChild(scrollContainer)
document.body.removeChild(article)
vi.useRealTimers()
})
})
})

View File

@@ -0,0 +1,175 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDocToc } from '../use-doc-toc'
describe('useDocToc', () => {
const defaultOptions = { appDetail: { mode: 'chat' }, locale: 'en-US' }
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: false }),
})
})
describe('initial state', () => {
it('should set isTocExpanded to false on narrow viewport', () => {
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(false)
expect(result.current.toc).toEqual([])
expect(result.current.activeSection).toBe('')
})
it('should set isTocExpanded to true on wide viewport', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const { result } = renderHook(() => useDocToc(defaultOptions))
expect(result.current.isTocExpanded).toBe(true)
})
})
describe('toc extraction', () => {
it('should extract toc items from article h2 anchors', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const heading = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#section-1'
anchor.textContent = 'Section 1'
heading.appendChild(anchor)
article.appendChild(heading)
document.body.appendChild(article)
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([{ href: '#section-1', text: 'Section 1' }])
expect(result.current.activeSection).toBe('section-1')
document.body.removeChild(article)
vi.useRealTimers()
})
it('should re-extract toc when dependencies change', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
document.body.appendChild(article)
const { result, rerender } = renderHook(props => useDocToc(props), {
initialProps: defaultOptions,
})
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([])
const heading = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#new-section'
anchor.textContent = 'New Section'
heading.appendChild(anchor)
article.appendChild(heading)
rerender({ appDetail: { mode: 'workflow' }, locale: 'zh-Hans' })
await act(async () => {
vi.runAllTimers()
})
expect(result.current.toc).toEqual([{ href: '#new-section', text: 'New Section' }])
document.body.removeChild(article)
vi.useRealTimers()
})
})
it('should update active section on scroll', async () => {
vi.useFakeTimers()
const article = document.createElement('article')
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
document.body.appendChild(scrollContainer)
document.body.appendChild(article)
const heading = document.createElement('h2')
const anchor = document.createElement('a')
anchor.href = '#target-section'
anchor.textContent = 'Target'
heading.appendChild(anchor)
article.appendChild(heading)
const target = document.createElement('div')
target.id = 'target-section'
target.getBoundingClientRect = vi.fn(() => ({
top: 10,
bottom: 100,
left: 0,
right: 0,
width: 0,
height: 90,
x: 0,
y: 10,
toJSON: () => ({}),
}))
scrollContainer.appendChild(target)
const addEventListenerSpy = vi.spyOn(scrollContainer, 'addEventListener')
const { result } = renderHook(() => useDocToc(defaultOptions))
await act(async () => {
vi.runAllTimers()
})
const handleScroll = addEventListenerSpy.mock.calls.find(call => call[0] === 'scroll')?.[1] as EventListener
act(() => {
handleScroll(new Event('scroll'))
})
expect(result.current.activeSection).toBe('target-section')
document.body.removeChild(scrollContainer)
document.body.removeChild(article)
vi.useRealTimers()
})
it('should smooth scroll to target on toc click', () => {
const scrollContainer = document.createElement('div')
scrollContainer.className = 'overflow-auto'
scrollContainer.scrollTo = vi.fn()
document.body.appendChild(scrollContainer)
const target = document.createElement('div')
target.id = 'target-section'
Object.defineProperty(target, 'offsetTop', { value: 500 })
scrollContainer.appendChild(target)
const { result } = renderHook(() => useDocToc(defaultOptions))
const preventDefault = vi.fn()
act(() => {
result.current.handleTocClick({ preventDefault } as unknown as React.MouseEvent<HTMLAnchorElement>, {
href: '#target-section',
text: 'Target',
})
})
expect(preventDefault).toHaveBeenCalled()
expect(scrollContainer.scrollTo).toHaveBeenCalledWith({
top: 420,
behavior: 'smooth',
})
document.body.removeChild(scrollContainer)
})
})

View File

@@ -0,0 +1,83 @@
import type { Tag } from '@/app/components/plugins/hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MarketplaceTrigger from '../marketplace'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const tagsMap: Record<string, Tag> = {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
}
describe('MarketplaceTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render all-tags label when no tag is selected', () => {
render(
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('allTags')).toBeInTheDocument()
})
it('should render selected tag labels and overflow count', () => {
render(
<MarketplaceTrigger
selectedTagsLength={3}
open={false}
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('should clear selected tags when close icon is clicked', () => {
const onTagsChange = vi.fn()
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={2}
open={false}
tags={['agent', 'rag']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
)
const icons = container.querySelectorAll('svg')
fireEvent.click(icons[1] as Element)
expect(onTagsChange).toHaveBeenCalledWith([])
})
it('should apply open-state styling without selected tags', () => {
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={0}
open
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(container.firstChild).toHaveClass('bg-state-base-hover')
})
})

View File

@@ -0,0 +1,67 @@
import type { Tag } from '@/app/components/plugins/hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToolSelectorTrigger from '../tool-selector'
const tagsMap: Record<string, Tag> = {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
}
describe('ToolSelectorTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render selected tags and overflow count', () => {
render(
<ToolSelectorTrigger
selectedTagsLength={3}
open={false}
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('should clear selected tags and stop propagation when close icon is clicked', () => {
const onTagsChange = vi.fn()
const parentClick = vi.fn()
const { container } = render(
<div onClick={parentClick}>
<ToolSelectorTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
</div>,
)
const icons = container.querySelectorAll('svg')
fireEvent.click(icons[1] as Element)
expect(onTagsChange).toHaveBeenCalledWith([])
expect(parentClick).not.toHaveBeenCalled()
})
it('should apply open-state styling when no tag is selected', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={0}
open
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(container.firstChild).toHaveClass('bg-state-base-hover')
})
})

View File

@@ -0,0 +1,185 @@
import type { App } from '@/types/app'
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { AppModeEnum, Resolution, TransferMethod } from '@/types/app'
import { useAppInputsFormSchema } from '../use-app-inputs-form-schema'
let mockAppDetailData: App | null = null
let mockAppDetailLoading = false
let mockWorkflowData: Record<string, unknown> | null = null
let mockWorkflowLoading = false
let mockFileUploadConfig: Record<string, unknown> | undefined
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({
data: mockAppDetailData,
isFetching: mockAppDetailLoading,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: mockWorkflowData,
isFetching: mockWorkflowLoading,
}),
}))
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: mockFileUploadConfig,
}),
}))
const createAppDetail = (overrides: Partial<App> = {}): App => ({
id: 'app-1',
mode: AppModeEnum.CHAT,
name: 'Test App',
icon: '',
icon_background: '',
icon_type: 'emoji',
model_config: {
user_input_form: [],
},
...overrides,
} as App)
describe('useAppInputsFormSchema', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetailLoading = false
mockWorkflowLoading = false
mockFileUploadConfig = { image_file_size_limit: 10 }
mockWorkflowData = null
mockAppDetailData = null
})
it('should build basic app schema and attach fileUploadConfig for file inputs', () => {
mockAppDetailData = createAppDetail({
mode: AppModeEnum.CHAT,
model_config: {
user_input_form: [
{ paragraph: { label: 'Summary', variable: 'summary' } },
{ file: { label: 'Attachment', variable: 'attachment' } },
{ external_data_tool: { variable: 'ignored' } },
],
} as unknown as App['model_config'],
})
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: createAppDetail({ mode: AppModeEnum.CHAT }),
}))
expect(result.current.inputFormSchema).toEqual([
expect.objectContaining({
label: 'Summary',
variable: 'summary',
type: 'paragraph',
}),
expect.objectContaining({
label: 'Attachment',
variable: 'attachment',
type: 'file',
fileUploadConfig: mockFileUploadConfig,
}),
])
})
it('should build workflow schema and attach fileUploadConfig for file variables', () => {
mockAppDetailData = createAppDetail({ mode: AppModeEnum.WORKFLOW })
mockWorkflowData = {
graph: {
nodes: [
{
data: {
type: 'start',
variables: [
{ type: InputVarType.textInput, label: 'Question', variable: 'question' },
{ type: InputVarType.singleFile, label: 'Image', variable: 'image' },
],
},
},
],
},
features: {},
}
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: createAppDetail({ mode: AppModeEnum.WORKFLOW }),
}))
expect(result.current.inputFormSchema).toEqual([
expect.objectContaining({ type: InputVarType.textInput, variable: 'question' }),
expect.objectContaining({
type: InputVarType.singleFile,
variable: 'image',
fileUploadConfig: mockFileUploadConfig,
}),
])
})
it('should append image upload schema when completion app enables file upload', () => {
mockAppDetailData = createAppDetail({
mode: AppModeEnum.COMPLETION,
model_config: {
user_input_form: [{ 'text-input': { label: 'Prompt', variable: 'prompt' } }],
file_upload: {
enabled: true,
allowed_file_types: [SupportUploadFileTypes.image],
number_limits: 5,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 4,
transfer_methods: [TransferMethod.remote_url],
},
},
} as unknown as App['model_config'],
})
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: createAppDetail({ mode: AppModeEnum.COMPLETION }),
}))
expect(result.current.inputFormSchema).toEqual([
expect.objectContaining({ type: 'text-input', variable: 'prompt' }),
expect.objectContaining({
label: 'Image Upload',
variable: '#image#',
type: InputVarType.singleFile,
fileUploadConfig: mockFileUploadConfig,
image: expect.objectContaining({
enabled: true,
detail: Resolution.low,
number_limits: 4,
transfer_methods: [TransferMethod.remote_url],
}),
enabled: true,
number_limits: 5,
}),
])
})
it('should return empty schema when workflow draft is unavailable for workflow apps', () => {
mockAppDetailData = createAppDetail({ mode: AppModeEnum.WORKFLOW })
mockWorkflowData = null
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: createAppDetail({ mode: AppModeEnum.WORKFLOW }),
}))
expect(result.current.inputFormSchema).toEqual([])
})
it('should surface loading state from app detail and workflow queries', () => {
mockAppDetailLoading = true
mockWorkflowLoading = true
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: createAppDetail({ mode: AppModeEnum.WORKFLOW }),
}))
expect(result.current.isLoading).toBe(true)
expect(result.current.fileUploadConfig).toEqual(mockFileUploadConfig)
})
})

View File

@@ -0,0 +1,244 @@
/* eslint-disable ts/no-explicit-any */
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '@/app/components/plugins/types'
import DetailHeader from '../index'
const mockUseDetailHeaderState = vi.fn()
const mockUsePluginOperations = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => values?.time ? `${key}:${values.time}` : key,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: {
timezone: 'Asia/Shanghai',
},
}),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
theme: 'light',
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
useLocale: () => 'en-US',
}))
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({
data: [{ name: 'plugin-1/search', type: 'builtin' }],
}),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: Array<string | undefined | false | null>) => args.filter(Boolean).join(' '),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`,
}))
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
default: () => ({
referenceSetting: {
auto_upgrade: {
upgrade_time_of_day: 36000,
},
},
}),
}))
vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/utils', () => ({
convertUTCDaySecondsToLocalSeconds: (value: number) => value,
timeOfDayToDayjs: () => ({
format: () => '10:00 AM',
}),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () => ({
useDetailHeaderState: () => mockUseDetailHeaderState(),
usePluginOperations: (...args: unknown[]) => mockUsePluginOperations(...args),
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
}))
vi.mock('@/app/components/base/badge', () => ({
default: ({ text, hasRedCornerMark, className }: { text: React.ReactNode, hasRedCornerMark?: boolean, className?: string }) => (
<div data-testid="badge" data-red={String(!!hasRedCornerMark)} data-class={className}>{text}</div>
),
}))
vi.mock('@/app/components/base/button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
}))
vi.mock('@/app/components/base/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AuthCategory: { tool: 'tool' },
PluginAuth: ({ pluginPayload }: { pluginPayload: { provider: string, providerType: string } }) => (
<div data-testid="plugin-auth">{`${pluginPayload.provider}|${pluginPayload.providerType}`}</div>
),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="card-icon">{src}</div>,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName, packageName }: { orgName: string, packageName: string }) => <div data-testid="org-info">{`${orgName}/${packageName}`}</div>,
}))
vi.mock('@/app/components/plugins/card/base/title', () => ({
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
}))
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
default: () => <div data-testid="verified-badge" />,
}))
vi.mock('@/app/components/plugins/base/deprecation-notice', () => ({
default: () => <div data-testid="deprecation-notice" />,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/operation-dropdown', () => ({
default: ({ detailUrl, onCheckVersion }: { detailUrl: string, onCheckVersion: () => void }) => (
<div data-testid="operation-dropdown">
<div>{detailUrl}</div>
<button type="button" onClick={onCheckVersion}>check-version</button>
</div>
),
}))
vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({
default: ({ trigger, disabled }: { trigger: React.ReactNode, disabled?: boolean }) => <div data-testid="version-picker" data-disabled={String(!!disabled)}>{trigger}</div>,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components', () => ({
HeaderModals: () => <div data-testid="header-modals" />,
PluginSourceBadge: ({ source }: { source: string }) => <div data-testid="plugin-source-badge">{source}</div>,
}))
vi.mock('../../../base/icons/src/vender/system', () => ({
AutoUpdateLine: () => <div data-testid="auto-update-line" />,
}))
const createDetail = (overrides: Record<string, unknown> = {}) => ({
id: 'detail-1',
source: PluginSource.marketplace,
tenant_id: 'tenant-1',
version: '1.0.0',
latest_version: '1.1.0',
latest_unique_identifier: 'plugin-1@1.1.0',
plugin_id: 'plugin-1',
status: 'active',
deprecated_reason: '',
alternative_plugin_id: 'plugin-2',
meta: {
repo: 'langgenius/dify-plugin',
},
declaration: {
author: 'langgenius',
category: 'tool',
name: 'search',
label: { en_US: 'Search Plugin' },
description: { en_US: 'Search description' },
icon: 'icon.png',
icon_dark: 'icon-dark.png',
verified: true,
tool: {
identity: {
name: 'search',
},
},
},
...overrides,
})
describe('DetailHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseDetailHeaderState.mockReturnValue({
modalStates: {
showPluginInfo: vi.fn(),
showDeleteConfirm: vi.fn(),
},
versionPicker: {
isShow: false,
setIsShow: vi.fn(),
setTargetVersion: vi.fn(),
},
hasNewVersion: true,
isAutoUpgradeEnabled: true,
isFromGitHub: false,
isFromMarketplace: true,
})
mockUsePluginOperations.mockReturnValue({
handleUpdate: vi.fn(),
handleUpdatedFromMarketplace: vi.fn(),
handleDelete: vi.fn(),
})
})
it('should build marketplace links, show update badge, auto-upgrade tooltip, and plugin auth', () => {
render(<DetailHeader detail={createDetail() as any} />)
expect(screen.getByTestId('operation-dropdown')).toHaveTextContent('https://marketplace.example.com/plugins/langgenius/search')
expect(screen.getAllByTestId('badge')[0]).toHaveAttribute('data-red', 'true')
expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument()
expect(screen.getByText('autoUpdate.nextUpdateTime:10:00 AM')).toBeInTheDocument()
expect(screen.getByTestId('plugin-auth')).toHaveTextContent('plugin-1/search|builtin')
expect(screen.getByTestId('description')).toHaveTextContent('Search description')
})
it('should switch to the github detail url and keep update actions visible for github sources', () => {
mockUseDetailHeaderState.mockReturnValue({
modalStates: {
showPluginInfo: vi.fn(),
showDeleteConfirm: vi.fn(),
},
versionPicker: {
isShow: false,
setIsShow: vi.fn(),
setTargetVersion: vi.fn(),
},
hasNewVersion: false,
isAutoUpgradeEnabled: false,
isFromGitHub: true,
isFromMarketplace: false,
})
render(<DetailHeader detail={createDetail({ source: PluginSource.github }) as any} />)
expect(screen.getByTestId('operation-dropdown')).toHaveTextContent('https://github.com/langgenius/dify-plugin')
expect(screen.getByText('detailPanel.operation.update')).toBeInTheDocument()
})
it('should collapse into readme mode without operation controls or auth panels', () => {
render(<DetailHeader detail={createDetail() as any} isReadmeView />)
expect(screen.queryByTestId('operation-dropdown')).not.toBeInTheDocument()
expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument()
expect(screen.queryByTestId('description')).not.toBeInTheDocument()
expect(screen.queryByTestId('plugin-auth')).not.toBeInTheDocument()
expect(screen.getByTestId('version-picker')).toHaveAttribute('data-disabled', 'true')
})
})

View File

@@ -0,0 +1,210 @@
/* eslint-disable ts/no-explicit-any */
import type { SchemaItem } from '../modal-steps'
import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { ApiKeyStep } from '../../hooks/use-common-modal-state'
import {
AutoParametersForm,
ConfigurationStepContent,
ManualPropertiesSection,
MultiSteps,
SubscriptionForm,
VerifyStepContent,
} from '../modal-steps'
const baseFormCalls: Array<Record<string, unknown>> = []
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/form/components/base', async () => {
const React = await import('react')
return {
BaseForm: React.forwardRef((props: any, ref) => {
baseFormCalls.push(props)
React.useImperativeHandle(ref, () => ({}))
return (
<div data-testid="base-form">
{props.formSchemas.map((schema: { name: string, type: string }) => (
<div
key={schema.name}
data-testid={`schema-${schema.name}`}
data-type={schema.type}
/>
))}
</div>
)
}),
}
})
vi.mock('@/app/components/plugins/plugin-detail-panel/subscription-list/log-viewer', () => ({
default: ({ logs }: { logs: Array<{ id: string, message: string }> }) => (
<div data-testid="log-viewer">{logs.map(log => <span key={log.id}>{log.message}</span>)}</div>
),
}))
describe('modal steps', () => {
beforeEach(() => {
vi.clearAllMocks()
baseFormCalls.length = 0
})
it('should highlight the active status step', () => {
const { rerender } = render(<MultiSteps currentStep={ApiKeyStep.Verify} />)
expect(screen.getByText('modal.steps.verify')).toHaveClass('text-state-accent-solid')
expect(screen.getByText('modal.steps.configuration')).toHaveClass('text-text-tertiary')
rerender(<MultiSteps currentStep={ApiKeyStep.Configuration} />)
expect(screen.getByText('modal.steps.configuration')).toHaveClass('text-state-accent-solid')
})
it('should render verify form only when credentials schema exists', () => {
const onChange = vi.fn()
const { rerender } = render(
<VerifyStepContent
apiKeyCredentialsSchema={[]}
apiKeyCredentialsFormRef={{ current: null }}
onChange={onChange}
/>,
)
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
rerender(
<VerifyStepContent
apiKeyCredentialsSchema={[{ name: 'api_key', type: FormTypeEnum.secretInput }]}
apiKeyCredentialsFormRef={{ current: null }}
onChange={onChange}
/>,
)
expect(screen.getByTestId('schema-api_key')).toBeInTheDocument()
})
it('should build subscription form with callback url defaults', () => {
render(
<SubscriptionForm
subscriptionFormRef={{ current: null }}
endpoint="https://example.com/callback"
/>,
)
expect(baseFormCalls[0]?.formSchemas).toEqual([
expect.objectContaining({
name: 'subscription_name',
type: FormTypeEnum.textInput,
required: true,
}),
expect.objectContaining({
name: 'callback_url',
default: 'https://example.com/callback',
disabled: true,
showCopy: true,
}),
])
})
it('should normalize auto parameter schema types', () => {
const schemas = [
{ name: 'count', type: 'integer' },
{ name: 'enabled', type: 'boolean' },
{ name: 'remote', type: FormTypeEnum.dynamicSelect },
{ name: 'fallback', type: 'mystery' },
] as unknown as SchemaItem[]
render(
<AutoParametersForm
schemas={schemas}
formRef={{ current: null }}
pluginId="plugin-id"
provider="provider-id"
credentialId="credential-id"
/>,
)
const formSchemas = baseFormCalls[0]?.formSchemas as Array<Record<string, unknown>>
expect(formSchemas).toEqual([
expect.objectContaining({ name: 'count', type: FormTypeEnum.textNumber }),
expect.objectContaining({
name: 'enabled',
type: FormTypeEnum.boolean,
fieldClassName: 'flex items-center justify-between',
}),
expect.objectContaining({
name: 'remote',
type: FormTypeEnum.dynamicSelect,
dynamicSelectParams: {
plugin_id: 'plugin-id',
provider: 'provider-id',
action: 'provider',
parameter: 'remote',
credential_id: 'credential-id',
},
}),
expect.objectContaining({ name: 'fallback', type: FormTypeEnum.textInput }),
])
})
it('should render manual properties section with logs', () => {
render(
<ManualPropertiesSection
schemas={[{ name: 'webhook_url', type: FormTypeEnum.textInput, description: 'Webhook URL' }]}
formRef={{ current: null }}
onChange={vi.fn()}
logs={[{ id: 'log-1', message: 'Listening' }] as unknown as TriggerLogEntity[]}
pluginName="Webhook Plugin"
/>,
)
expect(screen.getByTestId('schema-webhook_url')).toBeInTheDocument()
expect(screen.getByText('modal.manual.logs.loading')).toBeInTheDocument()
expect(screen.getByTestId('log-viewer')).toHaveTextContent('Listening')
})
it('should switch between auto and manual configuration content', () => {
const commonProps = {
subscriptionBuilder: { id: 'builder-1', endpoint: 'https://example.com/callback' } as unknown as TriggerSubscriptionBuilder,
subscriptionFormRef: { current: null },
autoCommonParametersSchema: [{ name: 'repo', type: FormTypeEnum.textInput }],
autoCommonParametersFormRef: { current: null },
manualPropertiesSchema: [{ name: 'webhook_url', type: FormTypeEnum.textInput }],
manualPropertiesFormRef: { current: null },
onManualPropertiesChange: vi.fn(),
logs: [{ id: 'log-1', message: 'Waiting' }] as unknown as TriggerLogEntity[],
pluginId: 'plugin-id',
pluginName: 'Plugin',
provider: 'provider-id',
}
const { rerender } = render(
<ConfigurationStepContent
{...commonProps}
createType={SupportedCreationMethods.APIKEY}
/>,
)
expect(screen.getByTestId('schema-repo')).toBeInTheDocument()
expect(screen.queryByTestId('schema-webhook_url')).not.toBeInTheDocument()
rerender(
<ConfigurationStepContent
{...commonProps}
createType={SupportedCreationMethods.MANUAL}
/>,
)
expect(screen.getByTestId('schema-webhook_url')).toBeInTheDocument()
expect(screen.queryByTestId('schema-repo')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getSandboxMigrationDismissed, setSandboxMigrationDismissed } from '../sandbox-migration-storage'
const STORAGE_KEY = 'workflow:sandbox-migration-dismissed-app-ids'
describe('sandbox migration storage', () => {
beforeEach(() => {
window.localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should return false when app id is missing or storage is empty', () => {
expect(getSandboxMigrationDismissed()).toBe(false)
expect(getSandboxMigrationDismissed('app-1')).toBe(false)
})
it('should return false for invalid stored json', () => {
window.localStorage.setItem(STORAGE_KEY, '{invalid-json')
expect(getSandboxMigrationDismissed('app-1')).toBe(false)
})
it('should persist dismissed ids without duplicates', () => {
setSandboxMigrationDismissed('app-1')
setSandboxMigrationDismissed('app-1')
setSandboxMigrationDismissed('app-2')
expect(getSandboxMigrationDismissed('app-1')).toBe(true)
expect(getSandboxMigrationDismissed('app-2')).toBe(true)
expect(window.localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify(['app-1', 'app-2']))
})
it('should fall back to writing only current id when existing value cannot be parsed', () => {
window.localStorage.setItem(STORAGE_KEY, '{broken')
setSandboxMigrationDismissed('app-3')
expect(window.localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify(['app-3']))
})
})