mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 16:26:25 +08:00
test: add unit tests for app store and configuration components, enhancing coverage for state management and UI interactions
This commit is contained in:
84
web/app/components/app/__tests__/store.spec.ts
Normal file
84
web/app/components/app/__tests__/store.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
225
web/app/components/app/configuration/__tests__/index.spec.tsx
Normal file
225
web/app/components/app/configuration/__tests__/index.spec.tsx
Normal 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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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
7
web/app/components/app/configuration/types.ts
Normal file
7
web/app/components/app/configuration/types.ts
Normal 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
|
||||
}
|
||||
25
web/app/components/base/ui/__tests__/placement.spec.ts
Normal file
25
web/app/components/base/ui/__tests__/placement.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
175
web/app/components/develop/hooks/__tests__/use-doc-toc.spec.ts
Normal file
175
web/app/components/develop/hooks/__tests__/use-doc-toc.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user